lib/exshome/settings.ex

defmodule Exshome.Settings do
  @moduledoc """
  Settings module.
  """
  import Ecto.Changeset
  alias Exshome.Settings.Schema

  @callback fields() :: term()

  @spec get_settings(arg :: module()) :: Ecto.Schema.t()
  def get_settings(module) when is_atom(module) do
    module
    |> get_module_name()
    |> Schema.get_or_create(default_values(module))
    |> from_map(module)
    |> set_default_values_for_errors()
  end

  @spec save_settings(Ecto.Schema.t()) :: Ecto.Schema.t() | {:error, Ecto.Changeset.t()}
  def save_settings(%module{} = data) do
    case valid_changes?(data) do
      {:ok, data} ->
        result =
          module
          |> get_module_name()
          |> Schema.update!(data)
          |> from_map(module)

        Exshome.Dependency.broadcast_value(module, result)
        result

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

  @spec get_module_name(module()) :: String.t()
  def get_module_name(module) do
    if module in available_modules() do
      module.name()
    else
      raise "#{inspect(module)} is not valid settings!"
    end
  end

  @spec set_default_values_for_errors(Ecto.Schema.t()) :: Ecto.Schema.t()
  defp set_default_values_for_errors(%module{} = data) do
    case valid_changes?(data) do
      {:ok, result} ->
        result

      {:error, %Ecto.Changeset{} = changeset} ->
        module_default_values = default_values(module)

        default_values =
          for field <- Keyword.keys(changeset.errors), into: %{} do
            {field, module_default_values[field]}
          end

        module
        |> get_module_name()
        |> Schema.update!(default_values)
        |> from_map(module)
    end
  end

  defp from_map(data, module) do
    module
    |> struct!()
    |> cast(data, module.fields() |> Keyword.keys())
    |> apply_changes()
  end

  @spec valid_changes?(struct()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
  def valid_changes?(data) do
    data
    |> changeset()
    |> Ecto.Changeset.apply_action(:update)
  end

  @spec changeset(struct()) :: Ecto.Changeset.t()
  def changeset(%module{} = data), do: changeset(module, Map.from_struct(data))

  @spec changeset(module(), map()) :: Ecto.Changeset.t()
  def changeset(module, data) do
    fields = module.fields()
    available_keys = Keyword.keys(fields)
    required_fields = for {field, data} <- fields, data[:required], do: field

    module
    |> struct(%{})
    |> Ecto.Changeset.cast(data, available_keys)
    |> Ecto.Changeset.validate_required(required_fields)
    |> check_allowed_values(allowed_values(module))
  end

  @spec available_modules() :: MapSet.t(atom())
  def available_modules do
    Exshome.Tag.tag_mapping() |> Map.fetch!(__MODULE__)
  end

  @spec default_values(module()) :: map()
  def default_values(module) when is_atom(module) do
    module.fields()
    |> Enum.map(fn {field, data} -> {field, data[:default]} end)
    |> Enum.into(%{})
  end

  @spec allowed_values(module()) :: map()
  def allowed_values(module) when is_atom(module) do
    for {field, data} <- module.fields(), data[:allowed_values], into: %{} do
      {field, data[:allowed_values].()}
    end
  end

  defp check_allowed_values(changeset, allowed_values) do
    for {field, values} <- allowed_values, reduce: changeset do
      ch -> validate_inclusion(ch, field, values)
    end
  end

  defmacro __using__(config) do
    config |> Macro.expand(__ENV__) |> validate_config!()
    name = Keyword.fetch!(config, :name)
    fields = Keyword.fetch!(config, :fields)
    database_fields = Enum.map(fields, fn {name, data} -> {name, data[:type]} end)

    quote do
      alias Exshome.DataType
      alias Exshome.Settings
      use Exshome.Schema
      use Exshome.Named, "settings:#{unquote(name)}"
      use Exshome.Dependency
      import Ecto.Changeset

      import Exshome.Tag, only: [add_tag: 1]
      @behaviour Settings

      @primary_key false
      embedded_schema do
        @derive {Jason.Encoder, only: Keyword.keys(unquote(fields))}
        for {field_name, db_type} <- unquote(database_fields) do
          field(field_name, db_type)
        end
      end

      @type t() :: %__MODULE__{unquote_splicing(database_fields)}

      add_tag(Settings)

      @impl Exshome.Dependency
      def get_value, do: Settings.get_settings(__MODULE__)

      @impl Settings
      def fields, do: unquote(fields)
    end
  end

  defp validate_config!(config) do
    validation_schema = [
      name: [
        type: :string,
        required: true
      ],
      fields: [
        type: :keyword_list,
        keys: [
          *: [
            keys: [
              allowed_values: [
                type: :any
              ],
              default: [
                type: :any,
                required: true
              ],
              required: [
                type: :boolean,
                required: true
              ],
              type: [
                type: :atom,
                required: true
              ]
            ]
          ]
        ]
      ]
    ]

    config
    |> expand()
    |> NimbleOptions.validate!(validation_schema)
  end

  defp expand({key, value}), do: {expand(key), expand(value)}
  defp expand(values) when is_list(values), do: Enum.map(values, &expand/1)
  defp expand(value), do: Macro.expand(value, __ENV__)
end