defmodule BullX.I18n.Resolver do
  @moduledoc """
  Fallback-chain construction and catalog lookup.

  Reads the persistent-term dictionary populated by
  `BullX.I18n.Catalog` and, for a requested locale, produces an
  ordered deduplicated list of locales to try. The first locale in
  the chain that has a message for the requested key wins.

  The chain order, per RFC 0007 §4.4:

  1. The requested locale.
  2. `__meta__.fallback` from the requested locale's TOML, if any.
  3. The application default locale.
  4. `:"en-US"` as the final backstop.

  Every step is filtered against the set of loaded locales. BullX
  does not derive same-language parent tags via BCP 47 truncation:
  loaded locales are always full tags like `zh-Hans-CN` or `en-US`.
  If a deployment wants a less-specific Chinese locale to satisfy
  `zh-Hans-CN` keys, it must add that locale's TOML and declare it
  as `__meta__.fallback`.
  """

  @messages_prefix {:bullx_i18n, :messages}
  @meta_prefix {:bullx_i18n, :meta}
  @loaded_key {:bullx_i18n, :loaded}
  @chain_cache_key {:bullx_i18n, :chains}
  @default_fallback :"en-US"

  @type catalog_key :: atom()
  @type message :: String.t()

  @spec put_catalog(atom(), %{optional(String.t()) => String.t()}, map()) :: :ok
  def put_catalog(locale, messages, meta) do
    :persistent_term.put({@messages_prefix, locale}, messages)
    :persistent_term.put({@meta_prefix, locale}, meta)
    clear_chain_cache()
    :ok
  end

  @spec drop_catalog(atom()) :: :ok
  def drop_catalog(locale) do
    :persistent_term.erase({@messages_prefix, locale})
    :persistent_term.erase({@meta_prefix, locale})
    clear_chain_cache()
    :ok
  end

  @spec put_loaded([atom()]) :: :ok
  def put_loaded(locales) when is_list(locales) do
    :persistent_term.put(@loaded_key, Map.new(locales, &{&1, true}))
    clear_chain_cache()
    :ok
  end

  @doc false
  @spec clear_chain_cache() :: :ok
  def clear_chain_cache do
    :persistent_term.erase(@chain_cache_key)
    :ok
  end

  @spec loaded() :: %{atom() => true}
  def loaded do
    :persistent_term.get(@loaded_key, %{})
  end

  @spec loaded_list() :: [atom()]
  def loaded_list do
    loaded() |> Map.keys() |> Enum.sort()
  end

  @spec messages(atom()) :: %{String.t() => String.t()} | nil
  def messages(locale) do
    :persistent_term.get({@messages_prefix, locale}, nil)
  end

  @spec meta(atom()) :: map()
  def meta(locale) do
    :persistent_term.get({@meta_prefix, locale}, %{})
  end

  @doc """
  Resolve `key` in `locale`. Returns `{resolved_locale, message}` or
  `nil` if no fallback entry matched.
  """
  @spec lookup(String.t(), atom()) :: {atom(), String.t()} | nil
  def lookup(key, locale) do
    Enum.find_value(fallback_chain(locale), fn candidate ->
      case messages(candidate) do
        nil -> nil
        map -> map |> Map.get(key) |> wrap(candidate)
      end
    end)
  end

  defp wrap(nil, _locale), do: nil
  defp wrap(message, locale), do: {locale, message}

  @doc """
  Build and memoise the fallback chain for `locale`.
  """
  @spec fallback_chain(atom()) :: [atom()]
  def fallback_chain(locale) do
    chains = :persistent_term.get(@chain_cache_key, %{})

    case Map.get(chains, locale) do
      nil ->
        chain = build_chain(locale)
        :persistent_term.put(@chain_cache_key, Map.put(chains, locale, chain))
        chain

      chain ->
        chain
    end
  end

  @doc """
  Resolve a `%Localize.LanguageTag{}` to the catalog locale atom,
  preferring the user-supplied BCP 47 tag, then the CLDR locale ID,
  then the English backstop. Used by `BullX.I18n.t/3` when no
  `:locale` option is provided.
  """
  @spec language_tag_to_locale(Localize.LanguageTag.t()) :: atom()
  def language_tag_to_locale(%Localize.LanguageTag{} = tag) do
    loaded = loaded()

    exact_loaded_locale(tag.requested_locale_id, loaded) ||
      exact_loaded_locale(tag.cldr_locale_id, loaded) ||
      @default_fallback
  end

  defp build_chain(locale) do
    loaded = loaded()

    [
      locale,
      meta_fallback_atom(locale, loaded),
      default_locale_atom(loaded),
      @default_fallback
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
    |> Enum.filter(&Map.has_key?(loaded, &1))
  end

  defp meta_fallback_atom(locale, loaded) do
    case meta(locale) do
      %{fallback: fallback} when is_binary(fallback) ->
        exact_loaded_locale(fallback, loaded)

      _ ->
        nil
    end
  end

  defp default_locale_atom(loaded) do
    %Localize.LanguageTag{} = tag = Localize.default_locale()

    exact_loaded_locale(tag.requested_locale_id, loaded) ||
      exact_loaded_locale(tag.cldr_locale_id, loaded)
  end

  defp exact_loaded_locale(locale, loaded) when is_atom(locale) do
    if Map.has_key?(loaded, locale), do: locale
  end

  defp exact_loaded_locale(locale, loaded) when is_binary(locale) do
    loaded
    |> Map.keys()
    |> Enum.find(fn loaded_locale ->
      Atom.to_string(loaded_locale) == locale
    end)
  end

  defp exact_loaded_locale(_locale, _loaded), do: nil
end
