defmodule Oban.Pro.Utils do
  @moduledoc false

  alias Ecto.Changeset

  @spec encode64(term(), [:compressed | :deterministic]) :: String.t()
  def encode64(term, opts \\ [:compressed]) do
    term
    |> :erlang.term_to_binary(opts)
    |> Base.encode64(padding: false)
  end

  @spec decode64(binary(), [:safe | :used]) :: term()
  def decode64(bin, opts \\ [:safe]) do
    bin
    |> Base.decode64!(padding: false)
    |> :erlang.binary_to_term(opts)
  end

  @spec hash64(iodata()) :: String.t()
  def hash64(iodata) when is_binary(iodata) or is_list(iodata) do
    :blake2s
    |> :crypto.hash(iodata)
    |> Base.encode64(padding: false)
  end

  @spec persistent_cache(tuple(), fun()) :: any()
  def persistent_cache(key, fun) when is_function(fun, 0) do
    case :persistent_term.get(key, nil) do
      nil -> tap(fun.(), &:persistent_term.put(key, &1))
      val -> val
    end
  end

  @spec validate_opts(list(), fun()) :: :ok | {:error, term()}
  def validate_opts(opts, validator) do
    Enum.reduce_while(opts, :ok, fn opt, acc ->
      case validator.(opt) do
        {:error, _reason} = error -> {:halt, error}
        _ -> {:cont, acc}
      end
    end)
  end

  @spec cast_period(pos_integer() | {atom(), pos_integer()}) :: pos_integer()
  def cast_period({value, unit}) do
    unit = to_string(unit)

    cond do
      unit in ~w(second seconds) -> value
      unit in ~w(minute minutes) -> value * 60
      unit in ~w(hour hours) -> value * 60 * 60
      unit in ~w(day days) -> value * 24 * 60 * 60
      true -> unit
    end
  end

  def cast_period(period), do: period

  @spec enforce_keys(Changeset.t(), map(), module()) :: Changeset.t()
  def enforce_keys(changeset, params, schema) do
    fields =
      [:fields, :virtual_fields]
      |> Enum.flat_map(&schema.__schema__/1)
      |> Enum.map(&to_string/1)

    Enum.reduce(params, changeset, fn {key, _val}, acc ->
      if to_string(key) in fields do
        acc
      else
        Changeset.add_error(acc, :base, "unknown field #{key} provided")
      end
    end)
  end

  @spec take_keys(Changeset.t(), atom(), list()) :: [any()]
  def take_keys(changeset, field, keys) do
    changeset
    |> Changeset.get_field(field)
    |> take_keys(keys)
  end

  @spec take_keys(map(), [atom()]) :: [any()]
  def take_keys(map, _keys) when map_size(map) == 0, do: []

  def take_keys(map, []) when is_map(map), do: take_keys(map, Map.keys(map))

  def take_keys(map, keys) when is_map(map) and is_list(keys) do
    map = Map.new(map, fn {key, val} -> {to_string(key), val} end)

    keys
    |> Enum.map(&to_string/1)
    |> Enum.sort()
    |> Enum.map(fn key ->
      val = map |> Map.get(key) |> to_hash_val()

      [key, val]
    end)
  end

  if Application.compile_env(:oban_pro, [__MODULE__, :safe_hash], false) do
    defp to_hash_val(val) when is_binary(val), do: val
    defp to_hash_val(val) when is_number(val), do: to_string(val)
    defp to_hash_val(val) when is_atom(val), do: to_string(val)
    defp to_hash_val(val), do: inspect(val)
  else
    defp to_hash_val(val), do: val |> :erlang.phash2() |> Integer.to_string()
  end

  def normalize_by(by) do
    by
    |> List.wrap()
    |> Enum.map(fn
      {key, val} -> [key, val |> List.wrap() |> Enum.sort()]
      field -> field
    end)
  end

  @spec to_exception(Changeset.t()) :: Exception.t()
  def to_exception(changeset) do
    changeset
    |> to_translated_errors()
    |> Enum.reverse()
    |> Enum.map(fn {field, message} -> ArgumentError.exception("#{field} #{message}") end)
    |> List.first()
  end

  @spec to_translated_errors(Changeset.t()) :: Keyword.t()
  def to_translated_errors(changeset) do
    changeset
    |> Changeset.traverse_errors(&translate_errors/1)
    |> Enum.map(&extract_errors/1)
    |> List.flatten()
  end

  ## Conversions

  @spec maybe_to_atom(atom() | String.t()) :: atom()
  def maybe_to_atom(key) when is_binary(key), do: String.to_existing_atom(key)
  def maybe_to_atom(key), do: key

  # Helpers

  defp translate_errors({msg, opt}) do
    Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
      opt
      |> Keyword.get(String.to_existing_atom(key), key)
      |> to_string()
    end)
  end

  defp extract_errors({_key, val}) when is_map(val) do
    val
    |> Map.to_list()
    |> Enum.map(&extract_errors/1)
  end

  defp extract_errors({key, [message | _]}), do: {key, message}
end
