defmodule WhoNeedHelp.Catalog.Category do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "categories" do
    field :slug, :string
    field :names, :map, default: %{}
    field :description, :string
    field :descriptions, :map, default: %{}
    field :mode, Ecto.Enum, values: [:help, :activity], default: :help
    field :active, :boolean, default: true
    field :sort_order, :integer, default: 0
    field :structured_fields, :map, default: %{}
    belongs_to :parent, __MODULE__
    has_many :children, __MODULE__, foreign_key: :parent_id

    timestamps(type: :utc_datetime)
  end

  def changeset(category, attrs) do
    category
    |> cast(attrs, [
      :slug,
      :names,
      :description,
      :descriptions,
      :mode,
      :active,
      :sort_order,
      :structured_fields,
      :parent_id
    ])
    |> validate_required([:slug, :names])
    |> validate_format(:slug, ~r/^[a-z0-9-]+$/)
    |> validate_length(:slug, max: 80)
    |> validate_number(:sort_order,
      greater_than_or_equal_to: -2_147_483_648,
      less_than_or_equal_to: 2_147_483_647
    )
    |> validate_names()
    |> validate_descriptions()
    |> validate_structured_fields()
    |> unique_constraint(:slug)
  end

  def name(%__MODULE__{names: names}, locale) do
    names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help"
  end

  def description(%__MODULE__{descriptions: descriptions, description: legacy}, locale) do
    descriptions[to_string(locale)] ||
      descriptions["en"] ||
      descriptions |> Map.values() |> List.first() ||
      legacy
  end

  defp validate_names(changeset) do
    validate_change(changeset, :names, fn :names, names ->
      if is_map(names) and map_size(names) > 0 and
           Enum.all?(names, fn {locale, name} ->
             is_binary(locale) and is_binary(name) and String.trim(name) != ""
           end) do
        []
      else
        [names: "must contain at least one non-empty localized name"]
      end
    end)
  end

  defp validate_descriptions(changeset) do
    validate_change(changeset, :descriptions, fn :descriptions, descriptions ->
      if is_map(descriptions) and Enum.all?(descriptions, &valid_label?/1) do
        []
      else
        [descriptions: "must contain non-empty localized descriptions"]
      end
    end)
  end

  defp validate_structured_fields(changeset) do
    validate_change(changeset, :structured_fields, fn :structured_fields, schema ->
      case schema do
        %{"fields" => fields} when is_list(fields) ->
          keys = Enum.map(fields, &field_key/1)

          cond do
            not Enum.all?(fields, &valid_field?/1) ->
              [structured_fields: "contains an invalid field definition"]

            length(keys) != length(Enum.uniq(keys)) ->
              [structured_fields: "field keys must be unique"]

            true ->
              []
          end

        schema when schema == %{} ->
          []

        _ ->
          [structured_fields: "must be an object with a fields array"]
      end
    end)
  end

  defp valid_field?(%{"key" => key, "type" => type, "label" => labels} = field)
       when is_binary(key) and type in ["text", "select", "boolean"] and is_map(labels) do
    valid_key? = Regex.match?(~r/^[a-z][a-z0-9_]*$/, key)
    valid_labels? = map_size(labels) > 0 and Enum.all?(labels, &valid_label?/1)
    valid_required? = field["required"] in [nil, true, false]

    valid_type_options? =
      case type do
        "select" -> valid_options?(field["options"])
        "text" -> field["max_length"] in [nil] or positive_integer?(field["max_length"])
        "boolean" -> true
      end

    valid_key? and valid_labels? and valid_required? and valid_type_options?
  end

  defp valid_field?(_field), do: false

  defp valid_options?(options) when is_list(options) and options != [] do
    Enum.all?(options, fn
      %{"value" => value, "label" => labels} ->
        is_binary(value) and value != "" and is_map(labels) and map_size(labels) > 0 and
          Enum.all?(labels, &valid_label?/1)

      value ->
        is_binary(value) and value != ""
    end)
  end

  defp valid_options?(_options), do: false

  defp valid_label?({locale, label}),
    do: is_binary(locale) and is_binary(label) and String.trim(label) != ""

  defp field_key(%{"key" => key}), do: key
  defp field_key(_field), do: nil
  defp positive_integer?(value), do: is_integer(value) and value > 0
end
