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

use Croma

defmodule Antikythera.Plug.ParamsValidator do
  @moduledoc """
  Plug to validate request parameters.

  ## Usage

  First, generate parameter validation models using `Antikythera.ParamStringStruct` or `Antikythera.BodyJsonStruct`.

  ```elixir
  defmodule MyQueryParams do
    use Antikythera.ParamStringStruct,
      fields: [
        item_id: Croma.PosInteger
      ]
  end
  ```

  Then, you can install this plug to validate parameters, as shown below.

  ```elixir
  plug Antikythera.Plug.ParamsValidator, :validate, query_params: MyQueryParams
  ```

  In the controller function, you can get the validated parameters from their corresponding name in `conn.assigns.validated`.
  For example, to access the validated query parameters, use the following code.

  ```elixir
  defun index(conn :: v[Conn.t()]) :: Conn.t() do
    %MyQueryParams{item_id: item_id} = conn.assigns.validated.query_params

    # Use item_id
  end
  ```

  ### Validation error handling

  When a validation error occurs, a response with status code 400 like the following is returned without calling the controller function.

  ```text
  ParameterValidationError: Invalid value at item_id of query_params
  ```

  The error response can be customized by implementing `YourGear.Controller.Error.parameter_validation_error/3`.
  See `Antikythera.GearApplication.ErrorHandler` for more information on custom error handlers.
  The second argument of `parameter_validation_error/3` is an atom representing the parameter type that failed validation.
  The third argument is a tuple representing the reason for the validation error (see `t:AntikytheraCore.BaseParamStruct.validate_error_t/0`).
  For example, when the plug above is set and the query parameter `item_id` is not a positive integer, the second argument is `:query_params`
  and the third argument is `{:invalid_value, [MyQueryParams, {Croma.PosInteger, :item_id}]}`.

  ## Supported parameter types

  - `:path_matches`: Path parameters
  - `:query_params`: Query parameters
  - `:body`: Request body (JSON)
      - Note: The module must be generated by `Antikythera.BodyJsonStruct`.
  - `:headers`: Request headers
      - Note: The keys must be lowercase. (e.g., `:"x-item-id"` instead of `:"X-Item-Id"`)
  - `:cookies`: Cookies

  Multiple parameter types can be validated at once.

  ```elixir
  plug Antikythera.Plug.ParamsValidator, :validate, path_matches: MyPathParams, body: MyBody
  ```
  """

  alias Antikythera.Conn

  @type parameter_type_t :: :path_matches | :query_params | :body | :headers | :cookies
  @type validate_option_t :: {parameter_type_t, module}

  defun validate(conn :: v[Conn.t()], opts :: v[[validate_option_t]]) :: v[Conn.t()] do
    :ok = validate_plug_options(opts)

    conn
    |> prepare_validated_params_assignment()
    |> validate_path_matches(opts[:path_matches])
    |> validate_query_params(opts[:query_params])
    |> validate_body(opts[:body])
    |> validate_headers(opts[:headers])
    |> validate_cookies(opts[:cookies])
  end

  defunp validate_plug_options(opts :: v[[validate_option_t]]) :: :ok do
    Enum.reduce_while(opts, {:ok, []}, fn {param_type, mod}, {:ok, validated_param_types} ->
      if :code.get_mode() == :interactive do
        Code.ensure_compiled!(mod)
      end

      cond do
        param_type in validated_param_types ->
          {:halt, {:error, "Parameter #{param_type} is specified more than once"}}

        function_exported?(mod, :from_params, 1) ->
          {:cont, {:ok, [param_type | validated_param_types]}}

        param_type == :body ->
          {:halt,
           {:error,
            "Body parameter validation module must be generated by Antikythera.BodyJsonStruct."}}

        true ->
          {:halt,
           {:error,
            "Parameter validation module must be generated by Antikythera.ParamStringStruct."}}
      end
    end)
    |> case do
      {:ok, []} ->
        raise ArgumentError, "At least one parameter to be validated must be specified."

      {:ok, _} ->
        :ok

      {:error, msg} ->
        raise ArgumentError, msg
    end
  end

  defunp prepare_validated_params_assignment(conn :: v[Conn.t()]) :: v[Conn.t()] do
    if Map.has_key?(conn.assigns, :validated) do
      IO.warn(
        "[antikythera] Parameter validation may be executed more than once. The validation result will be overwritten."
      )
    end

    Conn.assign(conn, :validated, %{})
  end

  for param_type <- [:path_matches, :query_params, :body, :headers, :cookies] do
    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    defunp unquote(:"validate_#{param_type}")(conn :: Conn.t(), validator :: nil | module) ::
             Conn.t() do
      conn, nil ->
        conn

      conn, validator when is_atom(validator) ->
        params = conn.request.unquote(param_type)

        validate_params(conn, unquote(param_type), validator, params)
    end
  end

  defunp validate_params(
           conn :: v[Conn.t()],
           param_type :: parameter_type_t,
           validator :: v[module],
           params :: AntikytheraCore.BaseParamStruct.params_t()
         ) :: v[Conn.t()] do
    case validator.from_params(params) do
      {:ok, validated_params} ->
        Conn.assign(
          conn,
          :validated,
          Map.put(conn.assigns.validated, param_type, validated_params)
        )

      {:error, reason} ->
        AntikytheraCore.Handler.GearError.parameter_validation_error(conn, param_type, reason)
    end
  end
end
