defmodule Berrypod.Backup do
  @moduledoc """
  Database backup and restore functionality.

  Provides database statistics, backup creation via VACUUM INTO,
  backup history management, and restore operations for SQLCipher-encrypted databases.

  Backups are stored in the configured backup directory (default: priv/backups/).
  Before any restore, an automatic backup of the current database is created.
  Old backups are automatically pruned to keep the most recent N backups.
  """

  alias Berrypod.Repo
  require Logger

  # Tables to show row counts for in the stats display
  @key_tables ~w(products product_variants orders images pages newsletter_subscribers)

  # Critical tables that must exist for a valid Berrypod database
  @required_tables ~w(users settings products orders pages images schema_migrations)

  # Maximum number of backups to keep (configurable via :berrypod, :backup, :max_backups)
  @default_max_backups 5

  @doc """
  Returns the directory where backups are stored.
  Defaults to priv/backups/ but can be configured via :berrypod, :backup, :directory.
  """
  def backup_dir do
    config = Application.get_env(:berrypod, :backup, [])

    dir =
      Keyword.get_lazy(config, :directory, fn ->
        Path.join(:code.priv_dir(:berrypod), "backups")
      end)

    # Ensure directory exists
    File.mkdir_p!(dir)
    dir
  end

  @doc """
  Returns the maximum number of backups to keep.
  """
  def max_backups do
    config = Application.get_env(:berrypod, :backup, [])
    Keyword.get(config, :max_backups, @default_max_backups)
  end

  @doc """
  Lists all available backups, sorted by date (newest first).

  Returns a list of maps with:
  - filename: the backup filename
  - path: full path to the backup file
  - size: file size in bytes
  - created_at: DateTime when the backup was created (parsed from filename)
  - type: :manual or :pre_restore
  """
  def list_backups do
    dir = backup_dir()

    dir
    |> File.ls!()
    |> Enum.filter(&String.ends_with?(&1, ".db"))
    |> Enum.map(fn filename ->
      path = Path.join(dir, filename)
      stat = File.stat!(path)

      %{
        filename: filename,
        path: path,
        size: stat.size,
        created_at: parse_backup_timestamp(filename),
        type: parse_backup_type(filename)
      }
    end)
    |> Enum.sort_by(& &1.created_at, {:desc, DateTime})
  end

  defp parse_backup_timestamp(filename) do
    # Expected format: berrypod-backup-YYYYMMDD-HHMMSS.db or pre-restore-YYYYMMDD-HHMMSS.db
    case Regex.run(~r/(\d{8})-(\d{6})\.db$/, filename) do
      [_, date, time] ->
        <<y::binary-4, m::binary-2, d::binary-2>> = date
        <<hh::binary-2, mm::binary-2, ss::binary-2>> = time

        case NaiveDateTime.new(
               String.to_integer(y),
               String.to_integer(m),
               String.to_integer(d),
               String.to_integer(hh),
               String.to_integer(mm),
               String.to_integer(ss)
             ) do
          {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
          _ -> DateTime.utc_now()
        end

      _ ->
        DateTime.utc_now()
    end
  end

  defp parse_backup_type(filename) do
    if String.starts_with?(filename, "pre-restore-") do
      :pre_restore
    else
      :manual
    end
  end

  @doc """
  Returns comprehensive database statistics.

  Includes:
  - Total database size
  - Encryption status (SQLCipher version or nil)
  - Per-table row counts and sizes
  - Key entity counts
  """
  def get_stats do
    %{
      total_size: get_total_size(),
      encryption_status: get_encryption_status(),
      tables: get_table_stats(),
      key_counts: get_key_counts(),
      schema_version: get_current_schema_version()
    }
  end

  @doc """
  Returns the total database file size in bytes.
  """
  def get_total_size do
    case Repo.query(
           "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
         ) do
      {:ok, %{rows: [[size]]}} -> size
      _ -> 0
    end
  end

  @doc """
  Returns the SQLCipher version if encryption is enabled, nil otherwise.
  """
  def get_encryption_status do
    case Repo.query("PRAGMA cipher_version") do
      {:ok, %{rows: [[version]]}} when is_binary(version) and version != "" -> version
      _ -> nil
    end
  end

  @doc """
  Returns a list of tables with their row counts and sizes.
  """
  def get_table_stats do
    # Get all user tables (exclude sqlite internals and FTS shadow tables)
    case Repo.query("""
           SELECT name FROM sqlite_master
           WHERE type='table'
           AND name NOT LIKE 'sqlite_%'
           AND name NOT LIKE '%_content'
           AND name NOT LIKE '%_data'
           AND name NOT LIKE '%_idx'
           AND name NOT LIKE '%_docsize'
           AND name NOT LIKE '%_config'
           ORDER BY name
         """) do
      {:ok, %{rows: tables}} ->
        process_table_stats(tables)

      {:error, _} ->
        []
    end
  end

  defp process_table_stats(tables) do
    table_names = Enum.map(tables, fn [name] -> name end)

    # Get sizes via dbstat if available
    sizes = get_table_sizes()

    # Get row counts
    Enum.map(table_names, fn name ->
      count = get_row_count(name)
      size = Map.get(sizes, name, 0)

      %{
        name: name,
        rows: count,
        size: size
      }
    end)
    |> Enum.sort_by(& &1.size, :desc)
  end

  defp get_table_sizes do
    # Try dbstat first (most accurate, but requires ENABLE_DBSTAT_VTAB compile flag)
    case Repo.query("""
           SELECT name, SUM(pgsize) as size
           FROM dbstat
           GROUP BY name
         """) do
      {:ok, %{rows: rows}} ->
        Map.new(rows, fn [name, size] -> {name, size || 0} end)

      _ ->
        # Fallback: estimate sizes by summing column data lengths
        # This gives a reasonable approximation for display purposes
        estimate_table_sizes()
    end
  end

  defp estimate_table_sizes do
    # Get all user tables
    case Repo.query("""
           SELECT name FROM sqlite_master
           WHERE type='table'
           AND name NOT LIKE 'sqlite_%'
           AND name NOT LIKE '%_content'
           AND name NOT LIKE '%_data'
           AND name NOT LIKE '%_idx'
           AND name NOT LIKE '%_docsize'
           AND name NOT LIKE '%_config'
         """) do
      {:ok, %{rows: tables}} ->
        tables
        |> Enum.map(fn [name] -> {name, estimate_table_size(name)} end)
        |> Enum.into(%{})

      _ ->
        %{}
    end
  end

  defp estimate_table_size(table_name) do
    # Get column names for this table
    case Repo.query("PRAGMA table_info(\"#{table_name}\")") do
      {:ok, %{rows: columns}} when columns != [] ->
        column_names = Enum.map(columns, fn [_cid, name | _] -> name end)

        # Build a query that sums the length of all columns
        # Using COALESCE and length() for text, or 8 bytes for numeric types
        length_exprs =
          column_names
          |> Enum.map(fn col ->
            "COALESCE(LENGTH(CAST(\"#{col}\" AS BLOB)), 0)"
          end)
          |> Enum.join(" + ")

        query = "SELECT SUM(#{length_exprs}) FROM \"#{table_name}\""

        case Repo.query(query) do
          {:ok, %{rows: [[size]]}} when is_integer(size) -> size
          {:ok, %{rows: [[size]]}} when is_float(size) -> round(size)
          _ -> 0
        end

      _ ->
        0
    end
  end

  defp get_row_count(table_name) do
    # Safe since table_name comes from sqlite_master
    case Repo.query("SELECT COUNT(*) FROM \"#{table_name}\"") do
      {:ok, %{rows: [[count]]}} -> count
      _ -> 0
    end
  end

  @doc """
  Returns counts for key entities (products, orders, etc).
  """
  def get_key_counts do
    @key_tables
    |> Enum.map(fn table ->
      {table, get_row_count(table)}
    end)
    |> Enum.into(%{})
  end

  @doc """
  Creates a backup of the database using VACUUM INTO.

  Returns `{:ok, backup_path}` on success.
  The backup is encrypted with the same key as the source database.
  """
  def create_backup(opts \\ []) do
    prefix = Keyword.get(opts, :prefix, "berrypod-backup")
    save_to_history = Keyword.get(opts, :save_to_history, true)

    timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S")
    filename = "#{prefix}-#{timestamp}.db"

    backup_path =
      if save_to_history do
        Path.join(backup_dir(), filename)
      else
        Path.join(System.tmp_dir!(), filename)
      end

    case Repo.query("VACUUM INTO ?", [backup_path]) do
      {:ok, _} ->
        if save_to_history, do: prune_old_backups()
        {:ok, backup_path}

      {:error, error} ->
        {:error, error}
    end
  end

  @doc """
  Creates a pre-restore backup of the current database.
  This is automatically called before any restore operation.
  """
  def create_pre_restore_backup do
    create_backup(prefix: "pre-restore", save_to_history: true)
  end

  @doc """
  Deletes old backups, keeping only the most recent N backups.
  """
  def prune_old_backups do
    max = max_backups()
    backups = list_backups()

    if length(backups) > max do
      backups
      |> Enum.drop(max)
      |> Enum.each(fn backup ->
        Logger.info("[Backup] Pruning old backup: #{backup.filename}")
        File.rm(backup.path)
      end)
    end

    :ok
  end

  @doc """
  Deletes a specific backup by filename.
  """
  def delete_backup(filename) do
    path = Path.join(backup_dir(), filename)

    if File.exists?(path) and String.ends_with?(filename, ".db") do
      File.rm(path)
    else
      {:error, :not_found}
    end
  end

  @doc """
  Validates that a backup file can be opened and is a valid Berrypod database.

  Performs comprehensive checks:
  1. File can be opened with current encryption key
  2. Contains required tables (users, settings, products, etc.)
  3. Has schema_migrations table with valid versions
  4. Integrity check passes

  Returns:
  - `{:ok, validation_result}` with detailed stats and validation info
  - `{:error, reason}` with specific error details
  """
  def validate_backup(path) do
    config = Application.get_env(:berrypod, Berrypod.Repo)
    key = Keyword.get(config, :key)

    case Exqlite.Sqlite3.open(path, mode: :readonly) do
      {:ok, conn} ->
        result = validate_backup_connection(conn, key)
        Exqlite.Sqlite3.close(conn)
        result

      {:error, reason} ->
        {:error, {:open_failed, reason}}
    end
  end

  defp validate_backup_connection(conn, key) do
    # Set the encryption key if we have one
    if key do
      case Exqlite.Sqlite3.execute(conn, "PRAGMA key = #{key}") do
        :ok -> :ok
        {:error, reason} -> throw({:error, {:key_failed, reason}})
      end
    end

    # Try to read from the database (will fail if wrong key)
    case Exqlite.Sqlite3.execute(conn, "SELECT COUNT(*) FROM sqlite_master") do
      :ok -> :ok
      {:error, "file is not a database"} -> throw({:error, :invalid_key})
      {:error, reason} -> throw({:error, {:read_failed, reason}})
    end

    # Run integrity check
    case run_integrity_check(conn) do
      :ok -> :ok
      {:error, reason} -> throw({:error, {:integrity_failed, reason}})
    end

    # Check required tables exist
    case check_required_tables(conn) do
      :ok -> :ok
      {:error, missing} -> throw({:error, {:missing_tables, missing}})
    end

    # Check schema migrations
    case check_schema_migrations(conn) do
      {:ok, migration_info} -> migration_info
      {:error, reason} -> throw({:error, {:migrations_failed, reason}})
    end

    # Get comprehensive stats
    stats = get_backup_stats(conn)
    {:ok, stats}
  catch
    {:error, reason} -> {:error, reason}
  end

  defp run_integrity_check(conn) do
    {:ok, stmt} = Exqlite.Sqlite3.prepare(conn, "PRAGMA integrity_check(1)")

    case Exqlite.Sqlite3.step(conn, stmt) do
      {:row, ["ok"]} ->
        Exqlite.Sqlite3.release(conn, stmt)
        :ok

      {:row, [error]} ->
        Exqlite.Sqlite3.release(conn, stmt)
        {:error, error}

      {:error, reason} ->
        Exqlite.Sqlite3.release(conn, stmt)
        {:error, reason}
    end
  end

  defp check_required_tables(conn) do
    {:ok, stmt} =
      Exqlite.Sqlite3.prepare(conn, "SELECT name FROM sqlite_master WHERE type='table'")

    tables = collect_rows(conn, stmt, [])
    table_names = Enum.map(tables, fn [name] -> name end)

    missing = @required_tables -- table_names

    if Enum.empty?(missing) do
      :ok
    else
      {:error, missing}
    end
  end

  defp check_schema_migrations(conn) do
    {:ok, stmt} =
      Exqlite.Sqlite3.prepare(
        conn,
        "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
      )

    case Exqlite.Sqlite3.step(conn, stmt) do
      {:row, [latest_version]} ->
        Exqlite.Sqlite3.release(conn, stmt)
        {:ok, %{latest_migration: latest_version}}

      :done ->
        Exqlite.Sqlite3.release(conn, stmt)
        {:error, "no migrations found"}

      {:error, reason} ->
        Exqlite.Sqlite3.release(conn, stmt)
        {:error, reason}
    end
  end

  defp collect_rows(conn, stmt, acc) do
    case Exqlite.Sqlite3.step(conn, stmt) do
      {:row, row} -> collect_rows(conn, stmt, [row | acc])
      :done -> Exqlite.Sqlite3.release(conn, stmt) && Enum.reverse(acc)
    end
  end

  defp get_backup_stats(conn) do
    # Get table count (excluding sqlite internals and FTS shadow tables, same as get_table_stats)
    {:ok, stmt} =
      Exqlite.Sqlite3.prepare(conn, """
        SELECT COUNT(*) FROM sqlite_master
        WHERE type='table'
        AND name NOT LIKE 'sqlite_%'
        AND name NOT LIKE '%_content'
        AND name NOT LIKE '%_data'
        AND name NOT LIKE '%_idx'
        AND name NOT LIKE '%_docsize'
        AND name NOT LIKE '%_config'
      """)

    {:row, [table_count]} = Exqlite.Sqlite3.step(conn, stmt)
    Exqlite.Sqlite3.release(conn, stmt)

    # Get page count for size estimate
    {:ok, stmt} =
      Exqlite.Sqlite3.prepare(
        conn,
        "SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()"
      )

    {:row, [size]} = Exqlite.Sqlite3.step(conn, stmt)
    Exqlite.Sqlite3.release(conn, stmt)

    # Get latest migration version
    {:ok, stmt} =
      Exqlite.Sqlite3.prepare(
        conn,
        "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
      )

    latest_migration =
      case Exqlite.Sqlite3.step(conn, stmt) do
        {:row, [version]} -> version
        _ -> nil
      end

    Exqlite.Sqlite3.release(conn, stmt)

    # Get key entity counts
    key_counts = get_backup_key_counts(conn)

    %{
      table_count: table_count,
      size: size,
      latest_migration: latest_migration,
      key_counts: key_counts
    }
  end

  defp get_backup_key_counts(conn) do
    @key_tables
    |> Enum.map(fn table ->
      count =
        case Exqlite.Sqlite3.prepare(conn, "SELECT COUNT(*) FROM \"#{table}\"") do
          {:ok, stmt} ->
            result =
              case Exqlite.Sqlite3.step(conn, stmt) do
                {:row, [count]} -> count
                _ -> 0
              end

            Exqlite.Sqlite3.release(conn, stmt)
            result

          _ ->
            0
        end

      {table, count}
    end)
    |> Enum.into(%{})
  end

  @doc """
  Restores a database from a backup file.

  This performs a full file-based restore by:

  1. Validating the backup (including schema version match)
  2. Stopping Oban completely
  3. Checkpointing WAL and draining all database connections
  4. Stopping the Repo
  5. Replacing the database file
  6. Restarting the Repo
  7. Restarting Oban
  8. Clearing and warming caches

  Returns `:ok` on success, `{:error, reason}` on failure.
  """
  def restore_backup(backup_path) do
    config = Application.get_env(:berrypod, Berrypod.Repo)
    db_path = Keyword.fetch!(config, :database)

    Logger.info("[Backup] Starting database restore from #{backup_path}")

    with :ok <- validate_backup_before_restore(backup_path),
         {:ok, pre_restore_path} <- create_pre_restore_backup(),
         :ok <- broadcast_maintenance_mode(:entering),
         :ok <- stop_oban(),
         :ok <- drain_and_stop_repo(),
         :ok <- swap_database_file(backup_path, db_path),
         :ok <- start_repo(),
         :ok <- start_oban(),
         :ok <- clear_ets_caches(),
         :ok <- warm_caches() do
      broadcast_maintenance_mode(:exited)
      Logger.info("[Backup] Database restore completed successfully")
      Logger.info("[Backup] Pre-restore backup saved: #{pre_restore_path}")
      :ok
    else
      {:error, reason} ->
        Logger.error("[Backup] Restore failed: #{inspect(reason)}")
        # Try to recover
        start_repo()
        start_oban()
        broadcast_maintenance_mode(:exited)
        {:error, reason}
    end
  end

  defp stop_oban do
    # Terminate Oban child from the application supervisor
    # This stops all Oban processes including plugins and reporters
    try do
      Supervisor.terminate_child(Berrypod.Supervisor, Oban)
    catch
      _, _ -> :ok
    end

    # Give processes time to fully terminate
    Process.sleep(500)
    :ok
  end

  defp start_oban do
    # Restart Oban child in the application supervisor
    try do
      Supervisor.restart_child(Berrypod.Supervisor, Oban)
    catch
      _, _ -> :ok
    end

    # Wait for Oban to be ready
    Process.sleep(500)
    :ok
  end

  defp drain_and_stop_repo do
    # First checkpoint WAL while we still have connections
    try do
      Repo.query!("PRAGMA wal_checkpoint(TRUNCATE)")
    rescue
      _ -> :ok
    end

    # Disconnect all connections - this forces them to close gracefully
    Ecto.Adapters.SQL.disconnect_all(Repo, 0)

    # Give connections time to release their file handles
    Process.sleep(500)

    # Use GenServer.stop which properly shuts down the pool
    repo_pid = Process.whereis(Repo)

    if repo_pid do
      ref = Process.monitor(repo_pid)

      # GenServer.stop sends a :stop call which is handled gracefully
      try do
        GenServer.stop(repo_pid, :normal, 10_000)
      catch
        :exit, _ -> :ok
      end

      # Wait for the process to actually terminate
      receive do
        {:DOWN, ^ref, :process, ^repo_pid, _reason} -> :ok
      after
        5000 -> :ok
      end
    end

    # Wait for file handles to be fully released by the OS
    Process.sleep(500)
    :ok
  end

  defp start_repo do
    # The supervisor will restart the Repo automatically since we stopped it
    # Wait for it to come back up
    wait_for_repo(100)
  end

  defp wait_for_repo(0), do: {:error, :repo_start_timeout}

  defp wait_for_repo(attempts) do
    case Process.whereis(Repo) do
      nil ->
        Process.sleep(100)
        wait_for_repo(attempts - 1)

      _pid ->
        # Give it a moment to fully initialize the connection pool
        Process.sleep(200)

        # Verify we can actually query
        try do
          case Repo.query("SELECT 1") do
            {:ok, _} ->
              :ok

            {:error, _} ->
              Process.sleep(100)
              wait_for_repo(attempts - 1)
          end
        catch
          _, _ ->
            Process.sleep(100)
            wait_for_repo(attempts - 1)
        end
    end
  end

  defp swap_database_file(backup_path, db_path) do
    # Remove WAL and SHM files if they exist (they're part of the old database state)
    File.rm("#{db_path}-wal")
    File.rm("#{db_path}-shm")

    # Replace the database file
    case File.cp(backup_path, db_path) do
      :ok ->
        File.rm(backup_path)
        :ok

      {:error, reason} ->
        {:error, {:file_copy_failed, reason}}
    end
  end

  defp validate_backup_before_restore(path) do
    case validate_backup(path) do
      {:ok, backup_stats} ->
        # Check schema versions match
        current_version = get_current_schema_version()

        if backup_stats.latest_migration == current_version do
          :ok
        else
          {:error, {:schema_mismatch, backup_stats.latest_migration, current_version}}
        end

      {:error, reason} ->
        {:error, {:validation_failed, reason}}
    end
  end

  defp get_current_schema_version do
    case Repo.query("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1") do
      {:ok, %{rows: [[version]]}} -> version
      _ -> nil
    end
  end

  defp broadcast_maintenance_mode(status) do
    # Broadcast to all connected LiveViews that maintenance is happening
    Phoenix.PubSub.broadcast(Berrypod.PubSub, "maintenance", {:maintenance, status})
    :ok
  end

  defp clear_ets_caches do
    # Clear known ETS caches to ensure they get rebuilt from the new database
    caches = [
      Berrypod.Theme.CSSCache,
      Berrypod.Pages.PageCache,
      Berrypod.Redirects.Cache
    ]

    for cache <- caches do
      try do
        :ets.delete_all_objects(cache)
      rescue
        ArgumentError -> :ok
      end
    end

    :ok
  end

  defp warm_caches do
    # Warm up caches after restore
    try do
      Berrypod.Pages.PageCache.warm()
    rescue
      _ -> :ok
    end

    try do
      Berrypod.Redirects.warm_cache()
    rescue
      _ -> :ok
    end

    try do
      Berrypod.Theme.CSSCache.warm()
    rescue
      _ -> :ok
    end

    :ok
  end

  @doc """
  Formats a byte size into a human-readable string.
  """
  def format_size(bytes) when is_integer(bytes) do
    cond do
      bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
      bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
      bytes >= 1024 -> "#{Float.round(bytes / 1024, 1)} KB"
      true -> "#{bytes} B"
    end
  end

  def format_size(_), do: "0 B"
end
