# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.BodyJsonMap do
  @moduledoc """
  Module for defining a JSON object as a map with a preprocessor function.

  This module is designed for request body validation (see `Antikythera.Plug.ParamsValidator` and `Antikythera.BodyJsonCommon`).
  You can define a type-safe map similar to `Croma.SubtypeOfMap` plus a preprocessor function.

  ## Usage

  To define a JSON object as a map with a preprocessor function, `use` this module in a module.

  ```elixir
  defmodule DateMap do
    use Antikythera.BodyJsonMap, value_module: {Date, &Date.to_iso8601/1}
  end
  ```

  You can use it for request body validation in a controller module, as shown below.

  ```elixir
  defmodule MyBody do
    use Antikythera.BodyJsonCommon, fields: [dates: DateMap]
  end

  plug Antikythera.Plug.ParamsValidator, :validate, body: MyBody
  ```

  When a request with the following JSON body is sent to the controller, it is validated by `MyBody`.
  Every value in the `dates` field is converted to an `Date` struct by the `Date.to_iso8601/1` preprocessor.

  ```json
  {
    "dates": {
      "date1": "1970-01-01",
      "date2": "1970-01-02",
      "date3": "1970-01-03"
    }
  }
  ```

  ## Exported functions

  The map module generated by this module has the following functions:

  - `@spec from_params(term) :: Croma.Result.t(t)`
  - `@spec from_params!(term) :: t`
  - `@spec valid?(term) :: boolean`
  - `@spec new(term) :: Croma.Result.t(t)`
  - `@spec new!(term) :: t`
  - `@spec min_size() :: non_neg_integer` (if `min_size` is specified)
  - `@spec max_size() :: non_neg_integer` (if `max_size` is specified)

  ## Options

  Options are almost the same as `Croma.SubtypeOfMap`.
  The following options are available:

  - `value_module`: The module that defines the type of each value in the map. It must either have a `valid?/1` function or be a struct with a preprocessor function.
  - `min_size`: The minimum size of the map. If not specified, there is no minimum size.
  - `max_size`: The maximum size of the map. If not specified, there is no maximum size.
  """
  alias Croma.Result, as: R
  alias AntikytheraCore.BaseParamStruct
  alias AntikytheraCore.BodyJsonCommon

  @doc false
  defun preprocess_params(
          map_mod :: v[module],
          value_mod :: v[module],
          preprocessor :: BodyJsonCommon.PreprocessorGenerator.t(),
          params :: v[map]
        ) :: R.t(map, BaseParamStruct.validate_error_t()) do
    Enum.map(params, fn {k, v} ->
      preprocess_value(v, value_mod, preprocessor) |> R.map(&{k, &1})
    end)
    |> R.sequence()
    |> case do
      {:ok, kv_list} -> {:ok, Map.new(kv_list)}
      {:error, {reason, mods}} -> {:error, {reason, [map_mod | mods]}}
    end
  end

  defunp preprocess_value(
           value :: BaseParamStruct.json_value_t(),
           mod :: v[module],
           preprocessor :: BodyJsonCommon.PreprocessorGenerator.t()
         ) :: R.t(term, BaseParamStruct.validate_error_t()) do
    try do
      case preprocessor.(value) do
        {:ok, v} ->
          {:ok, v}

        {:error, {reason, mods}}
        when reason in [:invalid_value, :value_missing] and is_list(mods) ->
          {:error, {reason, [mod | mods]}}

        {:error, _} ->
          {:error, {:invalid_value, [mod]}}

        v ->
          {:ok, v}
      end
    rescue
      _error -> {:error, {:invalid_value, [mod]}}
    end
  end

  @doc false
  defun new_impl(map_mod :: v[module], value_mod :: v[module], value :: v[map]) ::
          R.t(map, BaseParamStruct.validate_error_t()) do
    Enum.map(value, fn
      {k, v} when is_binary(k) -> validate_field(v, value_mod) |> R.map(&{k, &1})
      _ -> {:error, {:invalid_value, []}}
    end)
    |> R.sequence()
    |> case do
      {:ok, kv_list} ->
        {:ok, Map.new(kv_list)}

      {:error, {reason, mods}}
      when reason in [:invalid_value, :value_missing] and is_list(mods) ->
        {:error, {reason, [map_mod | mods]}}
    end
  end

  defunp validate_field(value :: term, mod :: v[module]) ::
           R.t(term, BaseParamStruct.validate_error_t()) do
    if valid_field?(value, mod), do: {:ok, value}, else: {:error, {:invalid_value, [mod]}}
  end

  @doc false
  defdelegate valid_field?(value, mod), to: BaseParamStruct

  defmacro __using__(opts) do
    quote bind_quoted: [
            value_module: opts[:value_module],
            min: opts[:min_size],
            max: opts[:max_size]
          ] do
      {mod, preprocessor} =
        AntikytheraCore.BodyJsonCommon.extract_preprocessor_or_default(value_module)

      @mod mod
      @preprocessor preprocessor

      @type t :: %{String.t() => @mod.t()}

      @min min
      @max max
      cond do
        is_nil(@min) and is_nil(@max) ->
          defguardp is_valid_size(_size) when true

        is_nil(@min) ->
          defguardp is_valid_size(size) when size <= @max

        is_nil(@max) ->
          defguardp is_valid_size(size) when @min <= size

        true ->
          defguardp is_valid_size(size) when @min <= size and size <= @max
      end

      defun valid?(value :: term) :: boolean do
        m when is_map(m) and is_valid_size(map_size(m)) ->
          Enum.all?(m, fn {k, v} ->
            is_binary(k) and Antikythera.BodyJsonMap.valid_field?(v, @mod)
          end)

        _ ->
          false
      end

      defun new(value :: term) :: R.t(t()) do
        m when is_map(m) and is_valid_size(map_size(m)) ->
          Antikythera.BodyJsonMap.new_impl(__MODULE__, @mod, m)

        _ ->
          {:error, {:invalid_value, [__MODULE__]}}
      end

      defun new!(value :: term) :: t() do
        new(value) |> R.get!()
      end

      defun from_params(params :: term) :: R.t(t()) do
        m when is_map(m) ->
          Antikythera.BodyJsonMap.preprocess_params(__MODULE__, @mod, @preprocessor, m)
          |> R.bind(&new/1)

        _ ->
          {:error, {:invalid_value, [__MODULE__]}}
      end

      defun from_params!(params :: term) :: t() do
        from_params(params) |> R.get!()
      end

      unless is_nil(@min) do
        defun min_size() :: non_neg_integer, do: @min
      end

      unless is_nil(@max) do
        defun max_size() :: non_neg_integer, do: @max
      end
    end
  end
end
