lib/goal.ex

defmodule Goal do
  @moduledoc ~S"""
  Goal is a parameter validation library based on [Ecto](https://github.com/elixir-ecto/ecto).
  It can be used with JSON APIs, HTML controllers and LiveViews.

  Goal builds a changeset from a validation schema and controller or LiveView parameters, and
  returns the validated parameters or an `Ecto.Changeset`, depending on the function you use.

  If your frontend and backend use different parameter cases, you can recase parameter keys with
  the `:recase_keys` option. `PascalCase`, `camelCase`, `kebab-case` and `snake_case` are
  supported.

  You can configure your own regexes for password, email, and URL format validations. This is
  helpful in case of backward compatibility, where Goal's defaults might not match your production
  system's behavior.

  ## Installation

  Add `goal` to the list of dependencies in `mix.exs`:

  ```elixir
  def deps do
    [{:goal, "~> 1.1"}]
  end
  ```

  ## Examples

  Goal can be used with LiveViews and JSON and HTML controllers.

  ### Example with JSON and HTTP controllers

  With JSON and HTML-based APIs, Goal takes the `params` from a controller action, validates those
  against a validation schema using `validate/3`, and returns an atom-based map or an error
  changeset.

  ```elixir
  defmodule AppWeb.SomeController do
    use AppWeb, :controller
    use Goal

    def create(conn, params) do
      with {:ok, attrs} <- validate(:create, params)) do
        ...
      else
        {:error, changeset} -> {:error, changeset}
      end
    end

    defparams :create do
      required :uuid, :string, format: :uuid
      required :name, :string, min: 3, max: 3
      optional :age, :integer, min: 0, max: 120
      optional :gender, :enum, values: ["female", "male", "non-binary"]

      optional :data, :map do
        required :color, :string
        optional :money, :decimal
        optional :height, :float
      end
    end
  end
  ```

  ### Example with LiveViews

  With LiveViews, Goal builds a changeset in `mount/3` that is assigned in the socket, and then it
  takes the `params` from `handle_event/3`, validates those against a validation schema, and
  returns an atom-based map or an error changeset.

  ```elixir
  defmodule AppWeb.SomeLiveView do
    use AppWeb, :live_view
    use Goal

    def mount(params, _session, socket) do
      changeset = changeset(:new, %{})
      socket = assign(socket, :changeset, changeset)

      {:ok, socket}
    end

    def handle_event("validate", %{"some" => params}, socket) do
      changeset = changeset(:new, params)
      socket = assign(socket, :changeset, changeset)

      {:noreply, socket}
    end

    def handle_event("save", %{"some" => params}, socket) do
      with {:ok, attrs} <- validate(:new, params)) do
        ...
      else
        {:error, changeset} -> {:noreply, assign(socket, :changeset, changeset)}
      end
    end

    defparams :new do
      required :uuid, :string, format: :uuid
      required :name, :string, min: 3, max: 3
      optional :age, :integer, min: 0, max: 120
      optional :gender, :enum, values: ["female", "male", "non-binary"]

      optional :data, :map do
        required :color, :string
        optional :money, :decimal
        optional :height, :float
      end
    end
  end
  ```

  ### Example with GraphQL resolvers

  With GraphQL, you may want to validate input fields without marking them as `non-null` to enhance
  backward compatibility. You can use Goal inside GraphQL resolvers to validate the input fields:

  ```elixir
  defmodule AppWeb.MyResolver do
    use Goal

    defparams(:create_user) do
      required(:id, :uuid)
      required(:input, :map) do
        required(:first_name, :string)
        required(:last_name, :string)
      end
    end

    def create_user(args, info) do
      with {:ok, attrs} <- validate(:create_user) do
        ...
      end
    end
  end
  ```

  ### Example with isolated schemas

  Validation schemas can be defined in a separate namespace, for example `AppWeb.MySchema`:

  ```elixir
  defmodule AppWeb.MySchema do
    use Goal

    defparams :show do
      required :id, :string, format: :uuid
      optional :query, :string
    end
  end

  defmodule AppWeb.SomeController do
    use AppWeb, :controller

    alias AppWeb.MySchema

    def show(conn, params) do
      with {:ok, attrs} <- MySchema.validate(:show, params) do
        ...
      else
        {:error, changeset} -> {:error, changeset}
      end
    end
  end
  ```

  ## Features

  ### Presence checks

  Sometimes all you need is to check if a parameter is present:

  ```elixir
  use Goal

  defparams :show do
    required :id
    optional :query
  end
  ```

  ### Deeply nested maps

  Goal efficiently builds error changesets for nested maps, and has support for lists of nested
  maps. There is no limitation on depth.

  ```elixir
  use Goal

  defparams :schema do
    optional :nested_map, :map do
      required :id, :integer
      optional :inner_map, :map do
        required :id, :integer
        optional :map, :map do
          required :id, :integer
          optional :list, {:array, :integer}
        end
      end
    end
  end

  iex(1)> validate(:show, params)
  {:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}}
  ```

  ### Powerful array validations

  If you need expressive validations for arrays types, look no further!

  Arrays can be made optional/required or the number of items can be set via `min`, `max` and `is`.
  Additionally, `rules` allows specifying any validations that are available for the inner type.
  Of course, both can be combined:

  ```elixir
  use Goal

  defparams do
    required :my_list, {:array, :string}, max: 2, rules: [trim: true, min: 1]
  end

  iex(1)> Goal.validate_params(schema(), %{"my_list" => ["hello ", " world "]})
  {:ok, %{my_list: ["hello", "world"]}}
  ```

  ### Readable error messages

  Use `Goal.traverse_errors/2` to build readable errors. Phoenix by default uses
  `Ecto.Changeset.traverse_errors/2`, which works for embedded Ecto schemas but not for the plain
  nested maps used by Goal. Goal's `traverse_errors/2` is compatible with (embedded)
  `Ecto.Schema`, so you don't have to make any changes to your existing logic.

  ```elixir
  def translate_errors(changeset) do
    Goal.traverse_errors(changeset, &translate_error/1)
  end
  ```

  ### Recasing inbound keys

  By default, Goal will look for the keys defined in `defparams`. But sometimes frontend applications
  send parameters in a different format. For example, in `camelCase` but your backend uses
  `snake_case`. For this scenario, Goal has the `:recase_keys` option:

  ```elixir
  config :goal,
    recase_keys: [from: :camel_case]

  iex(1)> MySchema.validate(:show, %{"firstName" => "Jane"})
  {:ok, %{first_name: "Jane"}}
  ```

  ### Recasing outbound keys

  Use `recase_keys/2` to recase outbound keys. For example, in your views:

  ```elixir
  config :goal,
    recase_keys: [to: :camel_case]

  defmodule AppWeb.UserJSON do
    import Goal

    def show(%{user: user}) do
      recase_keys(%{data: %{first_name: user.first_name}})
    end

    def error(%{changeset: changeset}) do
      recase_keys(%{errors: Goal.Changeset.traverse_errors(changeset, &translate_error/1)})
    end
  end

  iex(1)> UserJSON.show(%{user: %{first_name: "Jane"}})
  %{data: %{firstName: "Jane"}}
  iex(2)> UserJSON.error(%Ecto.Changeset{errors: [first_name: {"can't be blank", [validation: :required]}]})
  %{errors: %{firstName: ["can't be blank"]}}
  ```

  ### Bring your own regex

  Goal has sensible defaults for string format validation. If you'd like to use your own regex,
  e.g. for validating email addresses or passwords, then you can add your own regex in the
  configuration:

  ```elixir
  config :goal,
    uuid_regex: ~r/^[[:alpha:]]+$/,
    email_regex: ~r/^[[:alpha:]]+$/,
    password_regex: ~r/^[[:alpha:]]+$/,
    url_regex: ~r/^[[:alpha:]]+$/
  ```

  ### Available validations

  The field types and available validations are:

  | Field type             | Validations                 | Description                                                                                          |
  | ---------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------- |
  | `:uuid`                | `:equals`                   | string value                                                                                         |
  | `:string`              | `:equals`                   | string value                                                                                         |
  |                        | `:is`                       | exact string length                                                                                  |
  |                        | `:min`                      | minimum string length                                                                                |
  |                        | `:max`                      | maximum string length                                                                                |
  |                        | `:trim`                     | oolean to remove leading and trailing spaces                                                         |
  |                        | `:squish`                   | boolean to trim and collapse spaces                                                                  |
  |                        | `:format`                   | `:uuid`, `:email`, `:password`, `:url`                                                               |
  |                        | `:subset`                   | list of required strings                                                                             |
  |                        | `:included`                 | list of allowed strings                                                                              |
  |                        | `:excluded`                 | list of disallowed strings                                                                           |
  | `:integer`             | `:equals`                   | integer value                                                                                        |
  |                        | `:is`                       | integer value                                                                                        |
  |                        | `:min`                      | minimum integer value                                                                                |
  |                        | `:max`                      | maximum integer value                                                                                |
  |                        | `:greater_than`             | minimum integer value                                                                                |
  |                        | `:less_than`                | maximum integer value                                                                                |
  |                        | `:greater_than_or_equal_to` | minimum integer value                                                                                |
  |                        | `:less_than_or_equal_to`    | maximum integer value                                                                                |
  |                        | `:equal_to`                 | integer value                                                                                        |
  |                        | `:not_equal_to`             | integer value                                                                                        |
  |                        | `:subset`                   | list of required integers                                                                            |
  |                        | `:included`                 | list of allowed integers                                                                             |
  |                        | `:excluded`                 | list of disallowed integers                                                                          |
  | `:float`               |                             | all of the integer validations                                                                       |
  | `:decimal`             |                             | all of the integer validations                                                                       |
  | `:boolean`             | `:equals`                   | boolean value                                                                                        |
  | `:date`                | `:equals`                   | date value                                                                                           |
  | `:time`                | `:equals`                   | time value                                                                                           |
  | `:enum`                | `:values`                   | list of allowed values                                                                               |
  | `:map`                 | `:properties`               | use `:properties` to define the fields                                                               |
  | `{:array, :map}`       | `:properties`               | use `:properties` to define the fields                                                               |
  | `{:array, inner_type}` | `:rules`                    | `inner_type` can be any basic type. `rules` supported all validations available for `inner_type`     |
  |                        | `:min`                      | minimum array length                                                                                 |
  |                        | `:max`                      | maximum array length                                                                                 |
  |                        | `:is`                       | exact array length                                                                                   |
  | More basic types       |                             | See [Ecto.Schema](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types) for the full list |

  All field types, excluding `:map` and `{:array, :map}`, can use `:equals`, `:subset`,
  `:included`, `:excluded` validations.

  ## Benchmarks

  Run `mix deps.get` and then `mix run scripts/bench.exs` to run the benchmark on your computer.

  ```zsh
  Operating System: macOS
  CPU Information: Apple M2 Pro
  Number of Available Cores: 10
  Available memory: 16 GB
  Elixir 1.16.2
  Erlang 26.2.1
  JIT enabled: true

  Benchmark suite executing with the following configuration:
  warmup: 5 s
  time: 10 s
  memory time: 5 s
  reduction time: 0 ns
  parallel: 1
  inputs: none specified
  Estimated total run time: 1 min 40 s

  Name                                       ips        average  deviation         median         99th %
  presence params (4 fields)            702.67 K        1.42 μs  ±1370.44%        1.29 μs        1.63 μs
  simple params (4 fields)              339.92 K        2.94 μs   ±367.42%        2.67 μs        4.96 μs
  flat params (12 fields)               115.59 K        8.65 μs    ±79.41%        8.04 μs       21.08 μs
  nested params (12 fields)             110.47 K        9.05 μs    ±88.77%        8.38 μs       39.88 μs
  deeply nested params (12 fields)      107.88 K        9.27 μs    ±85.37%        8.33 μs       40.58 μs

  Comparison:
  presence params (4 fields)            702.67 K
  simple params (4 fields)              339.92 K - 2.07x slower +1.52 μs
  flat params (12 fields)               115.59 K - 6.08x slower +7.23 μs
  nested params (12 fields)             110.47 K - 6.36x slower +7.63 μs
  deeply nested params (12 fields)      107.88 K - 6.51x slower +7.85 μs

  Memory usage statistics:

  Name                                Memory usage
  presence params (4 fields)               4.76 KB
  simple params (4 fields)                 7.95 KB - 1.67x memory usage +3.19 KB
  flat params (12 fields)                 25.36 KB - 5.33x memory usage +20.60 KB
  nested params (12 fields)               27.49 KB - 5.78x memory usage +22.73 KB
  deeply nested params (12 fields)        27.38 KB - 5.75x memory usage +22.62 KB

  **All measurements for memory usage were the same**
  ```

  ## Credits

  This library is based on [Ecto](https://github.com/elixir-ecto/ecto) and I had to copy and adapt
  `Ecto.Changeset.traverse_errors/2`. Thanks for making such an awesome library! 🙇
  """

  import Ecto.Changeset

  alias Ecto.Changeset

  @typedoc false
  @type name :: atom() | binary()

  @typedoc false
  @type schema :: map()

  @typedoc false
  @type params :: map()

  @typedoc false
  @type cases :: :camel_case | :snake_case | :pascal_case | :kebab_case

  @typedoc false
  @type opts :: [recase_keys: [from: cases(), to: cases()]]

  @typedoc false
  @type error :: {String.t(), Keyword.t()}

  @typedoc false
  @type changeset :: Changeset.t()

  @typedoc false
  @type block :: {:__block__, any, any}

  @typedoc false
  @type do_block :: [do: block()]

  @doc false
  @spec __using__(block()) :: any()
  defmacro __using__(_) do
    quote do
      import Goal,
        only: [
          defparams: 1,
          defparams: 2,
          optional: 1,
          optional: 2,
          optional: 3,
          required: 1,
          required: 2,
          required: 3,
          build_changeset: 2,
          recase_keys: 3
        ]

      @doc """
      Builds a changeset from the schema and params.
      """
      @spec changeset(atom() | binary()) :: Ecto.Changeset.t()
      @spec changeset(atom() | binary(), map()) :: Ecto.Changeset.t()
      @spec changeset(atom() | binary(), map(),
              recase_keys: [from: :camel_case | :snake_case | :pascal_case | :kebab_case]
            ) :: Ecto.Changeset.t()
      def changeset(name, params \\ %{}, opts \\ []) do
        schema = schema(name)
        params = recase_keys(schema, params, opts)

        build_changeset(schema, params)
      end

      @doc """
      Returns the validated parameters or an error changeset.
      """
      @spec validate(Ecto.Changeset.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
      def validate(%Changeset{valid?: true} = changeset),
        do: {:ok, Changeset.apply_changes(changeset)}

      def validate(%Changeset{valid?: false} = changeset), do: {:error, changeset}

      @doc """
      Returns the validated parameters or an error changeset.
      Expects a schema to be defined with `defparams`.
      """
      @spec validate(atom() | binary()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
      @spec validate(atom() | binary(), map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
      @spec validate(atom() | binary(), map(),
              recase_keys: [from: :camel_case | :snake_case | :pascal_case | :kebab_case]
            ) ::
              {:ok, map()}
              | {:error, Ecto.Changeset.t()}
      def validate(name, params \\ %{}, opts \\ []) do
        schema = schema(name)
        params = recase_keys(schema, params, opts)

        schema
        |> build_changeset(params)
        |> Map.put(:action, :validate)
        |> validate()
      end
    end
  end

  @doc """
  A macro for defining validation schemas encapsulated in a `schema` function with arity 0.

  ```elixir
  defmodule MySchema do
    use Goal

    defparams do
      required :id, :string, format: :uuid
    end
  end

  iex(1)> schema()
  %{id: [type: :integer, required: true]}]
  ```
  """
  @spec defparams(do_block()) :: any
  defmacro defparams(do: block) do
    fields =
      case block do
        {:__block__, _, contents} -> contents
        {_, _, _} -> [block]
      end

    quote do
      def schema do
        Enum.reduce(unquote(fields), %{}, fn item, acc ->
          Map.merge(acc, item)
        end)
      end
    end
  end

  @doc """
  A macro for defining validation schemas encapsulated in a `schema` function with arity 1.
  The argument can be an atom or a binary.

  ```elixir
  defmodule MySchema do
    use Goal

    defparams :index do
      required :id, :string, format: :uuid
    end
  end

  iex(1)> MySchema.schema(:index)
  %{id: [type: :integer, required: true]}]
  iex(2)> MySchema.changeset(:index, %{id: 12})
  %Ecto.Changeset{valid?: true, changes: %{id: 12}}
  iex(3)> MySchema.validate(:index, %{id: 12})
  {:ok, %{id: 12}}
  ```
  """
  @spec defparams(name(), do_block()) :: any
  defmacro defparams(name, do: block) do
    fields =
      case block do
        {:__block__, _, contents} -> contents
        {_, _, _} -> [block]
      end

    quote do
      def schema(unquote(name)) do
        Enum.reduce(unquote(fields), %{}, fn item, acc ->
          Map.merge(acc, item)
        end)
      end
    end
  end

  @doc """
  Defines an optional field in the schema.
  """
  @spec optional(atom()) :: Macro.t()
  @spec optional(atom(), atom()) :: Macro.t()
  @spec optional(atom(), atom(), opts()) :: Macro.t()
  defmacro optional(name, type \\ :any, opts \\ [])

  defmacro optional(name, type, opts) when type in [:map, {:array, :map}] do
    children = get_field_children(opts)
    field_opts = get_field_options(opts)

    quote do
      properties = Enum.reduce(unquote(children), %{}, &Map.merge(&2, &1))

      if properties == %{} do
        %{unquote(name) => [{:type, unquote(type)} | unquote(field_opts)]}
      else
        %{
          unquote(name) => [
            {:type, unquote(type)} | [{:properties, properties} | unquote(field_opts)]
          ]
        }
      end
    end
  end

  defmacro optional(name, type, opts) do
    quote do
      %{unquote(name) => [{:type, unquote(type)} | unquote(opts)]}
    end
  end

  @doc """
  Defines a required field in the schema.
  """
  @spec required(atom()) :: Macro.t()
  @spec required(atom(), atom()) :: Macro.t()
  @spec required(atom(), atom(), opts()) :: Macro.t()
  defmacro required(name, type \\ :any, opts \\ [])

  defmacro required(name, type, opts) when type in [:map, {:array, :map}] do
    children = get_field_children(opts)
    field_opts = get_field_options(opts)

    quote do
      properties = Enum.reduce(unquote(children), %{}, &Map.merge(&2, &1))

      if properties == %{} do
        %{unquote(name) => [{:type, unquote(type)} | [{:required, true} | unquote(field_opts)]]}
      else
        %{
          unquote(name) => [
            {:type, unquote(type)}
            | [{:required, true} | [{:properties, properties} | unquote(field_opts)]]
          ]
        }
      end
    end
  end

  defmacro required(name, type, opts) do
    quote do
      %{unquote(name) => [{:type, unquote(type)} | [{:required, true} | unquote(opts)]]}
    end
  end

  defp get_field_children(opts) do
    case block_or_tuple = Keyword.get(opts, :do) do
      {:__block__, _, contents} -> contents
      {_, _, _} -> [block_or_tuple]
      _ -> []
    end
  end

  defp get_field_options(opts) do
    if Keyword.has_key?(opts, :do) do
      []
    else
      opts
    end
  end

  @doc ~S"""
  Validates parameters against a validation schema.

  ## Examples

      iex> validate_params(%{email: [format: :email]}, %{"email" => "jane@example.com"})
      {:ok, %{email: "jane@example.com"}}

      iex> validate_params(%{email: [format: :email]}, %{"email" => "invalid"})
      {:error, %Ecto.Changeset{valid?: false, errors: [email: {"has invalid format", ...}]}}

  """
  @spec validate_params(schema(), params()) :: {:ok, params()} | {:error, changeset()}
  @spec validate_params(schema(), params(), opts()) :: {:ok, params()} | {:error, changeset()}
  def validate_params(schema, params, opts \\ []) do
    params = recase_keys(schema, params, opts)

    schema
    |> build_changeset(params)
    |> Map.put(:action, :validate)
    |> case do
      %Changeset{valid?: true} = changes -> {:ok, Changeset.apply_changes(changes)}
      %Changeset{valid?: false} = changeset -> {:error, changeset}
    end
  end

  @doc ~S"""
  Builds an `Ecto.Changeset` using the parameters and a validation schema.

  ## Examples

      iex> build_changeset(%{email: [format: :email]}, %{"email" => "jane@example.com"})
      %Ecto.Changeset{valid?: true, changes: %{email: "jane@example.com"}}

      iex> build_changeset(%{email: [format: :email]}, %{"email" => "invalid"})
      %Ecto.Changeset{valid?: false, errors: [email: {"has invalid format", ...}]}

  """
  @spec build_changeset(schema(), params()) :: Changeset.t()
  def build_changeset(schema, params) do
    types = get_types(schema)
    defaults = build_defaults(schema)

    {defaults, types}
    |> Changeset.cast(params, Map.keys(types), force_changes: true)
    |> validate_required_fields(schema)
    |> validate_basic_fields(schema)
    |> validate_nested_fields(types, schema)
  end

  @doc """
  Recases parameter keys.

  Use only when you have full control of the data. For example, to render JSON responses.

  ## Examples

      iex> recase_keys(%{"first_name" => "Jane"}, recase_keys: [to: :camel_case])
      %{firstName: "Jane"}

  Supported are `:camel_case`, `:pascal_case`, `:kebab_case` and `:snake_case`.
  """
  @spec recase_keys(params()) :: params()
  @spec recase_keys(params(), opts()) :: params()
  def recase_keys(params, opts \\ []) do
    settings = Keyword.get(opts, :recase_keys) || Application.get_env(:goal, :recase_keys)

    if settings do
      to_case = Keyword.get(settings, :to)
      is_atom_map = is_atom_map?(params)

      recase_outbound_keys(params, to_case, is_atom_map)
    else
      params
    end
  end

  @doc """
  Recases parameter keys that are present in the schema.

  Use this instead of `recase_keys/2` for incoming parameters. For example, for user requests.

  ## Examples

      iex> recase_keys(%{first_name: [type: :string]}, %{"firstName" => "Jane"}, recase_keys: [from: :camel_case])
      %{first_name: "Jane"}

  Supported are `:camel_case`, `:pascal_case`, `:kebab_case` and `:snake_case`.
  """
  @spec recase_keys(schema(), params(), opts()) :: params()
  def recase_keys(schema, params, opts) do
    settings = Keyword.get(opts, :recase_keys) || Application.get_env(:goal, :recase_keys)

    if settings do
      from_case = Keyword.get(settings, :from)
      is_atom_map = is_atom_map?(params)

      recase_inbound_keys(schema, params, from_case, is_atom_map)
    else
      params
    end
  end

  @doc ~S"""
  Traverses changeset errors and applies the given function to error messages.

  ## Examples

      iex> traverse_errors(changeset, fn {msg, opts} ->
      ...>   Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
      ...>     opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
      ...>   end)
      ...> end)
      %{title: ["should be at least 3 characters"]}

  """
  @spec traverse_errors(
          changeset(),
          (error() -> binary()) | (changeset(), atom(), error() -> binary())
        ) :: %{atom() => [term()]}
  defdelegate traverse_errors(changeset, msg_func), to: Goal.Changeset

  defp get_types(schema) do
    Enum.reduce(schema, %{}, fn {field, rules}, acc ->
      case Keyword.get(rules, :type, :any) do
        {:array, :enum} ->
          values =
            rules
            |> Keyword.get(:rules, [])
            |> Keyword.get(:values, [])
            |> Enum.map(&atomize/1)

          Map.put(
            acc,
            field,
            {:array, {:parameterized, {Ecto.Enum, Ecto.Enum.init(values: values)}}}
          )

        :enum ->
          values =
            rules
            |> Keyword.get(:values, [])
            |> Enum.map(&atomize/1)

          Map.put(acc, field, {:parameterized, {Ecto.Enum, Ecto.Enum.init(values: values)}})

        :uuid ->
          Map.put(acc, field, Ecto.UUID)

        type ->
          Map.put(acc, field, type)
      end
    end)
  end

  defp build_defaults(schema) do
    Enum.reduce(schema, %{}, fn {field, rules}, acc ->
      case Keyword.get(rules, :default) do
        nil -> acc
        default -> Map.put_new(acc, field, default)
      end
    end)
  end

  defp validate_required_fields(%Changeset{} = changeset, schema) do
    required_fields =
      Enum.reduce(schema, [], fn {field, rules}, acc ->
        if Keyword.get(rules, :required, false),
          do: [field | acc],
          else: acc
      end)

    validate_required(changeset, required_fields)
  end

  defp validate_basic_fields(%Changeset{changes: changes} = changeset, schema) do
    Enum.reduce(changes, changeset, fn {field, _value}, changeset_acc ->
      schema
      |> Map.get(field, [])
      |> validate_fields(field, changeset_acc)
    end)
  end

  defp validate_fields([], _field, changeset), do: changeset

  defp validate_fields(rules, field, changeset) do
    Enum.reduce(rules, changeset, fn
      {:equals, value}, acc ->
        validate_inclusion(acc, field, [value])

      {:excluded, values}, acc ->
        validate_exclusion(acc, field, values)

      {:included, values}, acc ->
        validate_inclusion(acc, field, values)

      {:subset, values}, acc ->
        validate_subset(acc, field, values)

      {:is, integer}, acc ->
        change = get_in(acc, [Access.key(:changes), Access.key(field)])

        if is_binary(change) || is_list(change),
          do: validate_length(acc, field, is: integer),
          else: validate_number(acc, field, equal_to: integer)

      {:min, integer}, acc ->
        change = get_in(acc, [Access.key(:changes), Access.key(field)])

        if is_binary(change) || is_list(change),
          do: validate_length(acc, field, min: integer),
          else: validate_number(acc, field, greater_than_or_equal_to: integer)

      {:max, integer}, acc ->
        change = get_in(acc, [Access.key(:changes), Access.key(field)])

        if is_binary(change) || is_list(change),
          do: validate_length(acc, field, max: integer),
          else: validate_number(acc, field, less_than_or_equal_to: integer)

      {:trim, true}, acc ->
        update_change(acc, field, &String.trim/1)

      {:squish, true}, acc ->
        update_change(acc, field, &Goal.String.squish/1)

      {:format, :uuid}, acc ->
        validate_format(acc, field, Goal.Regex.uuid())

      {:format, :email}, acc ->
        validate_format(acc, field, Goal.Regex.email())

      {:format, :password}, acc ->
        validate_format(acc, field, Goal.Regex.password())

      {:format, :url}, acc ->
        validate_format(acc, field, Goal.Regex.url())

      {:format, %Regex{} = regex}, acc ->
        validate_format(acc, field, regex)

      {:format, key}, acc when is_atom(key) ->
        validate_format(acc, field, Goal.Regex.custom(key))

      {:less_than, integer}, acc ->
        validate_number(acc, field, less_than: integer)

      {:greater_than, integer}, acc ->
        validate_number(acc, field, greater_than: integer)

      {:less_than_or_equal_to, integer}, acc ->
        validate_number(acc, field, less_than_or_equal_to: integer)

      {:greater_than_or_equal_to, integer}, acc ->
        validate_number(acc, field, greater_than_or_equal_to: integer)

      {:equal_to, integer}, acc ->
        validate_number(acc, field, equal_to: integer)

      {:not_equal_to, integer}, acc ->
        validate_number(acc, field, not_equal_to: integer)

      {_name, _setting}, acc ->
        acc
    end)
  end

  defp validate_nested_fields(%Changeset{changes: changes} = changeset, types, schema) do
    Enum.reduce(types, changeset, fn
      {field, :map}, acc ->
        validate_map_field(changes, field, schema, acc)

      {field, {:array, :map}}, acc ->
        validate_array_map_field(changes, field, schema, acc)

      {field, {:array, type}}, acc
      when type in [:string, :integer, :decimal, :float, :boolean, :date, :time] ->
        validate_array_basic_field(changes, field, schema, type, acc)

      {field, {:array, {:parameterized, _}}}, acc ->
        validate_array_enum_field(field, schema, acc)

      {_field, _type}, acc ->
        acc
    end)
  end

  defp validate_map_field(changes, field, schema, changeset) do
    params = Map.get(changes, field)
    rules = Map.get(schema, field)
    schema = Keyword.get(rules, :properties)

    if schema && params do
      schema
      |> build_changeset(params)
      |> case do
        %Changeset{valid?: true, changes: inner_changes} ->
          put_in(changeset, [Access.key(:changes), Access.key(field)], inner_changes)

        %Changeset{valid?: false} = inner_changeset ->
          changeset
          |> put_in([Access.key(:changes), Access.key(field)], inner_changeset)
          |> Map.put(:valid?, false)
      end
    else
      changeset
    end
  end

  defp validate_array_map_field(changes, field, schema, changeset) do
    params = Map.get(changes, field)
    rules = Map.get(schema, field)
    schema = Keyword.get(rules, :properties)

    if schema && is_list(params) do
      {valid?, changesets} = reduce_and_validate_array_map_fields(schema, params)

      changeset
      |> put_in([Access.key(:changes), Access.key(field)], Enum.reverse(changesets))
      |> Map.update!(:valid?, &Kernel.&&(&1, valid?))
    else
      item_rules = Keyword.get(rules, :rules)

      if item_rules do
        validate_fields(item_rules, field, changeset)
      else
        changeset
      end
    end
  end

  defp reduce_and_validate_array_map_fields(schema, params) do
    Enum.reduce(params, {true, []}, fn params, {boolean, list} ->
      schema
      |> build_changeset(params)
      |> case do
        %Changeset{valid?: true, changes: inner_changes} ->
          {boolean, [inner_changes | list]}

        %Changeset{valid?: false} = inner_changeset ->
          {false, [inner_changeset | list]}
      end
    end)
  end

  defp validate_array_basic_field(changes, field, schema, type, changeset) do
    params = Map.get(changes, field)
    rules = Map.get(schema, field)
    item_rules = Keyword.get(rules, :rules)

    if item_rules && is_list(params) do
      case reduce_and_validate_array_basic_fields(item_rules, type, field, params) do
        {:valid, changes} ->
          changeset
          |> put_in([Access.key(:changes), Access.key(field)], Enum.reverse(changes))
          |> Map.update!(:valid?, &Kernel.&&(&1, true))

        {:invalid, errors} ->
          changeset
          |> Map.update!(:errors, &(errors ++ &1))
          |> Map.put(:valid?, false)
      end
    else
      changeset
    end
  end

  defp reduce_and_validate_array_basic_fields(schema, type, field, params) do
    schema = %{:inner_schema => [{:type, type} | schema]}

    result =
      Enum.reduce(params, {:valid, []}, fn params, {status, acc} ->
        changeset = build_changeset(schema, %{:inner_schema => params})

        case {status, changeset} do
          {:valid, %Changeset{valid?: true, changes: %{:inner_schema => inner_changes}}} ->
            {:valid, [inner_changes | acc]}

          {:valid, %Changeset{valid?: false, errors: errors}} ->
            # The implementation is "all or nothing", so even if there where successful
            # changes before, we reset them now since the whole changeset isn't valid.
            {:invalid, MapSet.new(errors)}

          {:invalid, %Changeset{valid?: false, errors: errors}} ->
            {:invalid, MapSet.new(errors) |> MapSet.union(acc)}

          {:invalid, %Changeset{valid?: true}} ->
            {:invalid, acc}
        end
      end)

    case result do
      {:invalid, errors} ->
        errors = for {:inner_schema, {msg, opts}} <- errors, do: {field, {"item " <> msg, opts}}
        {:invalid, errors}

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

  defp validate_array_enum_field(field, schema, changeset) do
    item_rules =
      schema
      |> Map.get(field)
      |> Keyword.get(:rules, [])
      |> Keyword.delete(:values)

    validate_fields(item_rules, field, changeset)
  end

  defp atomize(atom) when is_atom(atom), do: atom
  defp atomize(string) when is_binary(string), do: String.to_atom(string)

  defp is_atom_map?(map) when is_map(map) do
    Enum.reduce_while(map, false, fn {key, _value}, _acc -> {:halt, is_atom(key)} end)
  end

  defp recase_inbound_keys(_schema, value, _from_case, _is_atom_map) when is_struct(value),
    do: value

  defp recase_inbound_keys(schema, params, from_case, is_atom_map) when is_map(params) do
    Enum.reduce(schema, %{}, fn {field, rules}, acc ->
      recased_field =
        field
        |> Atom.to_string()
        |> recase_key(from_case)

      recased_field = if is_atom_map, do: String.to_atom(recased_field), else: recased_field
      fallback_field = if is_atom_map, do: field, else: Atom.to_string(field)

      cond do
        Map.has_key?(params, recased_field) ->
          Map.put(acc, field, get_value(rules, params, recased_field, from_case, is_atom_map))

        Map.has_key?(params, fallback_field) ->
          Map.put(acc, field, get_value(rules, params, fallback_field, from_case, is_atom_map))

        true ->
          acc
      end
    end)
  end

  defp recase_inbound_keys(schema, value, from_case, is_atom_map) when is_list(value) do
    Enum.map(value, &recase_inbound_keys(schema, &1, from_case, is_atom_map))
  end

  defp recase_inbound_keys(_schema, value, _from_case, _is_atom_map), do: value

  defp get_value(rules, params, field, from_case, is_atom_map) do
    value = Map.get(params, field)
    schema = Keyword.get(rules, :properties)

    if (is_map(value) || is_list(value)) && schema do
      rules
      |> Keyword.get(:properties)
      |> recase_inbound_keys(value, from_case, is_atom_map)
    else
      value
    end
  end

  defp recase_outbound_keys(struct, _to_case, _is_atom_map) when is_struct(struct), do: struct

  defp recase_outbound_keys(params, to_case, is_atom_map) when is_map(params) do
    Enum.reduce(params, %{}, fn {key, value}, acc ->
      value = recase_outbound_keys(value, to_case, is_atom_map)

      key = if is_atom(key), do: Atom.to_string(key), else: key
      key = recase_key(key, to_case)
      key = if is_atom_map, do: String.to_atom(key), else: key

      Map.put(acc, key, value)
    end)
  end

  defp recase_outbound_keys(value, to_case, is_atom_map) when is_list(value) do
    Enum.map(value, &recase_outbound_keys(&1, to_case, is_atom_map))
  end

  defp recase_outbound_keys(value, _to_case, _is_atom_map), do: value

  defp recase_key(string, :camel_case), do: Recase.to_camel(string)
  defp recase_key(string, :snake_case), do: Recase.to_snake(string)
  defp recase_key(string, :kebab_case), do: Recase.to_kebab(string)
  defp recase_key(string, :pascal_case), do: Recase.to_pascal(string)
end