defmodule BDS.AI.SecretKey do
  @moduledoc """
  Resolves the 32-byte machine-local master key that encrypts AI provider
  secrets at rest (the `SecureKeyStore` entity in `specs/ai.allium`).

  Resolution order:

  1. `config :bds, :ai_secret_key` — explicit override. Used by the test
     suite for determinism; must be at least 32 bytes (the first 32 are
     used). An invalid value is an error, never a silent fallback.
  2. A previously resolved key cached in `:persistent_term`.
  3. The OS keyring: on macOS the login Keychain via the `security` CLI; on
     other platforms — or when the Keychain is unavailable — a random key
     file under the private app dir, written with `0600` permissions.

  A fresh random key is generated and stored on first use. There is no
  deterministic fallback: when no key can be obtained or persisted, `fetch/0`
  returns `{:error, reason}` and secret encryption/decryption fails loudly
  instead of degrading to obfuscation.

  Config (`config :bds, BDS.AI.SecretKey`):

    * `:strategy` — `:auto` (default; Keychain on macOS, key file elsewhere),
      `:keychain`, or `:file`
    * `:key_file_path` — overrides the key file location
    * `:command_runner` — 3-arity replacement for `System.cmd/3` (tests)
  """

  require Logger

  @key_bytes 32
  @cache_key {__MODULE__, :key}
  @keychain_service "bDS2"
  @keychain_account "ai-secret-key"
  @keychain_not_found_status 44
  @key_file_name "ai_secret.key"

  @spec fetch() :: {:ok, binary()} | {:error, term()}
  def fetch do
    case configured_key() do
      {:ok, key} -> {:ok, key}
      {:error, _detail} = error -> error
      :unset -> cached_or_resolve()
    end
  end

  @doc "Clears the cached key so the next fetch re-resolves it (test helper)."
  @spec reset_cache() :: :ok
  def reset_cache do
    :persistent_term.erase(@cache_key)
    :ok
  end

  defp configured_key do
    case Application.get_env(:bds, :ai_secret_key) do
      nil ->
        :unset

      key when is_binary(key) and byte_size(key) >= @key_bytes ->
        {:ok, binary_part(key, 0, @key_bytes)}

      other ->
        {:error, {:invalid_configured_key, "expected a binary of at least #{@key_bytes} bytes, got: #{inspect(other)}"}}
    end
  end

  defp cached_or_resolve do
    case :persistent_term.get(@cache_key, nil) do
      key when is_binary(key) ->
        {:ok, key}

      nil ->
        with {:ok, key} <- resolve() do
          :persistent_term.put(@cache_key, key)
          {:ok, key}
        end
    end
  end

  defp resolve do
    case strategy() do
      :keychain -> resolve_keychain()
      :file -> resolve_file()
    end
  end

  defp strategy do
    case config(:strategy, :auto) do
      :auto ->
        if match?({:unix, :darwin}, :os.type()), do: :keychain, else: :file

      explicit when explicit in [:keychain, :file] ->
        explicit
    end
  end

  # ─── macOS Keychain ─────────────────────────────────────────

  defp resolve_keychain do
    case keychain_find() do
      {:ok, key} ->
        {:ok, key}

      :not_found ->
        keychain_create()

      {:error, reason} ->
        fallback_to_key_file("AI secret key: macOS Keychain unavailable", inspect(reason))
    end
  end

  defp keychain_find do
    case run_security([
           "find-generic-password",
           "-s",
           @keychain_service,
           "-a",
           @keychain_account,
           "-w"
         ]) do
      {output, 0} ->
        case output |> String.trim() |> Base.decode64() do
          {:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
          _other -> {:error, :corrupt_keychain_item}
        end

      {_output, @keychain_not_found_status} ->
        :not_found

      {output, status} ->
        {:error, {:security_failed, status, String.trim(output)}}
    end
  end

  # The generated key passes through `security`'s argv, which is briefly
  # visible to other local processes of the same user. Accepted trade-off for
  # a single-user desktop app; `security` offers no non-interactive way to
  # take the password on stdin in one shot.
  defp keychain_create do
    key = :crypto.strong_rand_bytes(@key_bytes)

    case run_security([
           "add-generic-password",
           "-U",
           "-s",
           @keychain_service,
           "-a",
           @keychain_account,
           "-w",
           Base.encode64(key)
         ]) do
      {_output, 0} ->
        {:ok, key}

      {output, status} ->
        fallback_to_key_file(
          "AI secret key: could not store the key in the Keychain",
          "status #{status}: #{String.trim(output)}"
        )
    end
  end

  defp run_security(args) do
    runner = config(:command_runner, &default_runner/3)
    runner.("security", args, stderr_to_stdout: true)
  end

  defp default_runner(command, args, opts) do
    System.cmd(command, args, opts)
  rescue
    error in ErlangError -> {"#{command} unavailable: #{inspect(error.original)}", 127}
  end

  # ─── Key file ───────────────────────────────────────────────

  defp resolve_file do
    path = key_file_path()

    case File.read(path) do
      {:ok, contents} -> decode_key_file(contents, path)
      {:error, :enoent} -> create_key_file(path)
      {:error, reason} -> {:error, {:key_file_unreadable, path, reason}}
    end
  end

  defp decode_key_file(contents, path) do
    case contents |> String.trim() |> Base.decode64() do
      {:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
      _other -> {:error, {:key_file_corrupt, path}}
    end
  end

  defp create_key_file(path) do
    key = :crypto.strong_rand_bytes(@key_bytes)
    temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))

    with :ok <- File.mkdir_p(Path.dirname(path)),
         :ok <- File.write(temp_path, Base.encode64(key) <> "\n"),
         :ok <- File.chmod(temp_path, 0o600),
         :ok <- File.rename(temp_path, path) do
      {:ok, key}
    else
      {:error, reason} ->
        _ = File.rm(temp_path)
        {:error, {:key_file_write_failed, path, reason}}
    end
  end

  defp key_file_path do
    config(:key_file_path, nil) || Path.join(private_app_dir(), @key_file_name)
  end

  # Same private app dir as BDS.Projects.private_app_dir/0 — on macOS
  # ~/Library/Application Support/BDS2. Duplicated to keep this module free of
  # project/DB dependencies.
  defp private_app_dir do
    case :filename.basedir(:user_config, "BDS2") do
      path when is_list(path) -> List.to_string(path)
      path -> path
    end
    |> Path.expand()
  end

  defp config(key, default) do
    Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
  end

  defp fallback_to_key_file(prefix, detail) do
    Logger.warning(prefix <> " (" <> detail <> "); falling back to the key file")
    resolve_file()
  end
end
