defmodule Berrypod.Settings do
  @moduledoc """
  The Settings context for managing site-wide configuration.
  """

  import Ecto.Query, warn: false
  alias Berrypod.Repo
  alias Berrypod.Settings.{Setting, SettingsCache, ThemeSettings}
  alias Berrypod.Vault

  @doc """
  Gets a setting by key with an optional default value.

  ## Examples

      iex> get_setting("site_name", "My Shop")
      "My Shop"

  """
  def get_setting(key, default \\ nil) do
    case SettingsCache.get(key) do
      {:ok, value} ->
        value

      :miss ->
        case fetch_setting(key) do
          {:ok, setting} -> decode_value(setting)
          :not_found -> default
        end
    end
  end

  @doc """
  Sets a setting value by key.

  ## Examples

      iex> put_setting("site_name", "My Awesome Shop")
      {:ok, %Setting{}}

  """
  def put_setting(key, value, value_type \\ "string") do
    encoded_value = encode_value(value, value_type)

    result =
      %Setting{key: key}
      |> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type})
      |> Repo.insert(
        on_conflict: {:replace, [:value, :value_type, :updated_at]},
        conflict_target: :key
      )

    case result do
      {:ok, _} ->
        SettingsCache.invalidate()
        SettingsCache.warm()

      _ ->
        :ok
    end

    result
  end

  @doc """
  Gets the theme settings as a ThemeSettings struct.

  ## Examples

      iex> get_theme_settings()
      %ThemeSettings{mood: "neutral", typography: "clean", ...}

  """
  def get_theme_settings do
    case get_setting("theme_settings") do
      nil ->
        %ThemeSettings{}

      settings_map when is_map(settings_map) ->
        settings_map
        |> atomize_keys()
        |> migrate_logo_mode()
        |> then(&struct(ThemeSettings, &1))
    end
  end

  # Migrate old logo_mode field to new show_site_name/show_logo booleans
  defp migrate_logo_mode(settings) do
    case Map.get(settings, :logo_mode) do
      "text-only" ->
        settings
        |> Map.put(:show_site_name, true)
        |> Map.put(:show_logo, false)
        |> Map.delete(:logo_mode)

      "logo-text" ->
        settings
        |> Map.put(:show_site_name, true)
        |> Map.put(:show_logo, true)
        |> Map.delete(:logo_mode)

      "logo-only" ->
        settings
        |> Map.put(:show_site_name, false)
        |> Map.put(:show_logo, true)
        |> Map.delete(:logo_mode)

      _ ->
        settings
    end
  end

  @doc "Returns the shop name from Settings, falling back to a default."
  def site_name do
    get_setting("site_name") || "Store Name"
  end

  @doc "Returns the shop description from Settings, falling back to a default."
  def site_description do
    get_setting("site_description") || "Discover unique products and original designs."
  end

  @doc """
  Updates the theme settings.

  ## Examples

      iex> update_theme_settings(%{mood: "dark", typography: "modern"})
      {:ok, %ThemeSettings{}}

  """
  def update_theme_settings(attrs, opts \\ []) when is_map(attrs) do
    current = get_theme_settings()

    changeset = ThemeSettings.changeset(current, attrs)

    if changeset.valid? do
      settings = Ecto.Changeset.apply_changes(changeset)
      json = Jason.encode!(settings)
      put_setting("theme_settings", json, "json")

      unless opts[:skip_customised_flag] do
        put_setting("theme_customised", true, "boolean")
      end

      # Invalidate and rewarm CSS cache
      alias Berrypod.Theme.{CSSCache, CSSGenerator}
      CSSCache.invalidate()
      css = CSSGenerator.generate(settings)
      CSSCache.put(css)

      {:ok, settings}
    else
      {:error, changeset}
    end
  end

  @doc """
  Applies a preset to theme settings.

  ## Examples

      iex> apply_preset(:gallery)
      {:ok, %ThemeSettings{}}

  """
  def apply_preset(preset_name, opts \\ []) when is_atom(preset_name) do
    preset = Berrypod.Theme.Presets.get(preset_name)

    if preset do
      update_theme_settings(preset, opts)
    else
      {:error, :preset_not_found}
    end
  end

  @doc """
  Returns whether the shop is live (visible to the public).

  Defaults to `false` for fresh installs.
  """
  def site_live? do
    get_setting("site_live", false) == true
  end

  @doc """
  Sets whether the shop is live (visible to the public).
  """
  def set_site_live(live?) when is_boolean(live?) do
    put_setting("site_live", live?, "boolean")
  end

  @doc """
  Returns whether abandoned cart recovery emails are enabled.

  Defaults to false — shop owners must explicitly opt in, since the
  feature has legal implications (privacy policy wording, PECR/GDPR compliance).
  """
  def abandoned_cart_recovery_enabled? do
    get_setting("abandoned_cart_recovery", false) == true
  end

  @doc """
  Enables or disables abandoned cart recovery emails.
  """
  def set_abandoned_cart_recovery(enabled?) when is_boolean(enabled?) do
    put_setting("abandoned_cart_recovery", enabled?, "boolean")
  end

  @doc """
  Deletes a setting by key.
  """
  def delete_setting(key) do
    result =
      case fetch_setting(key) do
        {:ok, setting} -> Repo.delete(setting)
        :not_found -> :ok
      end

    SettingsCache.invalidate()
    SettingsCache.warm()

    result
  end

  @doc """
  Stores an encrypted secret in the database.

  The plaintext is encrypted via Vault before storage.
  """
  def put_secret(key, plaintext) when is_binary(plaintext) do
    result =
      case Vault.encrypt(plaintext) do
        {:ok, encrypted} ->
          %Setting{key: key}
          |> Setting.changeset(%{
            key: key,
            value: "[encrypted]",
            value_type: "encrypted",
            encrypted_value: encrypted
          })
          |> Repo.insert(
            on_conflict: {:replace, [:value, :encrypted_value, :value_type, :updated_at]},
            conflict_target: :key
          )

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

    SettingsCache.invalidate()
    SettingsCache.warm()

    result
  end

  @doc """
  Retrieves and decrypts an encrypted secret from the database.

  Returns the plaintext value or the default if not found.
  """
  def get_secret(key, default \\ nil) do
    case fetch_setting(key) do
      {:ok, %Setting{value_type: "encrypted", encrypted_value: encrypted}}
      when not is_nil(encrypted) ->
        case Vault.decrypt(encrypted) do
          {:ok, plaintext} -> plaintext
          {:error, _} -> default
        end

      _ ->
        default
    end
  end

  @doc """
  Checks whether an encrypted secret exists in the database.
  """
  def has_secret?(key) do
    case fetch_setting(key) do
      {:ok, %Setting{value_type: "encrypted", encrypted_value: encrypted}}
      when not is_nil(encrypted) ->
        true

      _ ->
        false
    end
  end

  @doc """
  Returns a masked hint for an encrypted secret (e.g. "sk_test_•••abc").

  Useful for admin UIs to confirm which key is active without exposing it.
  """
  def secret_hint(key) do
    case get_secret(key) do
      nil ->
        nil

      plaintext when byte_size(plaintext) > 8 ->
        prefix = binary_part(plaintext, 0, min(8, byte_size(plaintext)))
        suffix = binary_part(plaintext, byte_size(plaintext), -3)
        "#{prefix}•••#{suffix}"

      _short ->
        "•••"
    end
  end

  # ── URL prefixes ─────────────────────────────────────────────

  @valid_prefix_types ~w(products collections orders)a
  @default_prefixes %{products: "products", collections: "collections", orders: "orders"}

  @doc """
  Gets the current URL prefixes map.

  Returns a map like `%{"products" => "p", "collections" => "c"}`.
  Missing keys use defaults.
  """
  def get_url_prefixes do
    get_setting("url_prefixes") || %{}
  end

  @doc """
  Gets the current prefix for a specific type.

  ## Examples

      iex> get_url_prefix(:products)
      "p"  # if customised, or "products" by default
  """
  def get_url_prefix(type) when type in @valid_prefix_types do
    prefixes = get_url_prefixes()
    Map.get(prefixes, Atom.to_string(type)) || @default_prefixes[type]
  end

  @doc """
  Updates a single URL prefix.

  Creates redirects from old prefix URLs to new ones if products/collections exist.
  Invalidates the R module cache.

  ## Examples

      iex> update_url_prefix(:products, "p")
      {:ok, %{"products" => "p"}}
  """
  def update_url_prefix(type, new_prefix)
      when type in @valid_prefix_types and is_binary(new_prefix) do
    new_prefix = String.downcase(String.trim(new_prefix))

    # Validate prefix format
    cond do
      new_prefix == "" ->
        {:error, :empty_prefix}

      not Regex.match?(~r/^[a-z0-9-]+$/, new_prefix) ->
        {:error, :invalid_format}

      reserved_prefix?(new_prefix) ->
        {:error, :reserved_prefix}

      true ->
        old_prefix = get_url_prefix(type)
        prefixes = get_url_prefixes()

        # If same as current, no-op
        if old_prefix == new_prefix do
          {:ok, prefixes}
        else
          # Update the prefixes map
          # If new prefix matches default, remove the key (use default)
          new_prefixes =
            if new_prefix == @default_prefixes[type] do
              Map.delete(prefixes, Atom.to_string(type))
            else
              Map.put(prefixes, Atom.to_string(type), new_prefix)
            end

          # Save to database
          {:ok, _} = put_setting("url_prefixes", new_prefixes, "json")

          # Create redirects for existing items
          create_prefix_redirects(type, old_prefix, new_prefix)

          # Invalidate R module cache
          BerrypodWeb.R.invalidate_all_sync()

          {:ok, new_prefixes}
        end
    end
  end

  @doc """
  Resets a URL prefix to its default value.
  """
  def reset_url_prefix(type) when type in @valid_prefix_types do
    update_url_prefix(type, @default_prefixes[type])
  end

  # Check if prefix conflicts with reserved routes
  defp reserved_prefix?(prefix) do
    reserved = ~w(admin api auth checkout coming-soon health live phoenix static _)
    prefix in reserved
  end

  # Create redirects from old prefix to new prefix for existing items
  defp create_prefix_redirects(:products, old_prefix, new_prefix) do
    Berrypod.Products.list_product_slugs()
    |> Enum.each(fn slug ->
      Berrypod.Redirects.create_auto(%{
        from_path: "/#{old_prefix}/#{slug}",
        to_path: "/#{new_prefix}/#{slug}",
        source: "auto_prefix_change"
      })
    end)
  end

  defp create_prefix_redirects(:collections, old_prefix, new_prefix) do
    Berrypod.Products.list_category_slugs()
    |> Enum.each(fn slug ->
      Berrypod.Redirects.create_auto(%{
        from_path: "/#{old_prefix}/#{slug}",
        to_path: "/#{new_prefix}/#{slug}",
        source: "auto_prefix_change"
      })
    end)

    # Also redirect "all" and "sale" virtual collections
    for slug <- ["all", "sale"] do
      Berrypod.Redirects.create_auto(%{
        from_path: "/#{old_prefix}/#{slug}",
        to_path: "/#{new_prefix}/#{slug}",
        source: "auto_prefix_change"
      })
    end
  end

  defp create_prefix_redirects(:orders, _old_prefix, _new_prefix) do
    # Orders use order numbers, which we can't enumerate easily
    # The DynamicRoutes plug handles the redirect at runtime
    :ok
  end

  # Private helpers

  defp fetch_setting(key) do
    case Repo.get_by(Setting, key: key) do
      nil -> :not_found
      setting -> {:ok, setting}
    end
  end

  defp decode_value(%Setting{value_type: "encrypted", encrypted_value: encrypted})
       when not is_nil(encrypted) do
    case Vault.decrypt(encrypted) do
      {:ok, plaintext} -> plaintext
      {:error, _} -> nil
    end
  end

  defp decode_value(%Setting{value: value, value_type: "json"}), do: Jason.decode!(value)
  defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value)

  defp decode_value(%Setting{value: value, value_type: "boolean"}),
    do: value == "true"

  defp decode_value(%Setting{value: value, value_type: "string"}), do: value

  defp encode_value(value, "json") when is_binary(value), do: value
  defp encode_value(value, "json"), do: Jason.encode!(value)
  defp encode_value(value, "integer") when is_integer(value), do: Integer.to_string(value)
  defp encode_value(value, "boolean") when is_boolean(value), do: Atom.to_string(value)
  defp encode_value(value, "string") when is_binary(value), do: value

  defp atomize_keys(map) when is_map(map) do
    Map.new(map, fn
      {key, value} when is_binary(key) -> {String.to_atom(key), value}
      {key, value} -> {key, value}
    end)
  end
end
