defmodule Xema do
  @moduledoc """
  A schema validator inspired by [JSON Schema](http://json-schema.org).

  All available keywords to construct a schema are described on page
  [Usage](usage.html).

  This module can be used to construct a schema module. Should a module
  contain multiple schemas the option `multi: true` is required.

  `use Xema` imports `Xema.Builder` and extends the module with the functions
  + `__MODULE__.valid?/2`
  + `__MODULE__.validate/2`
  + `__MODULE__.validate!/2`
  + `__MODULE__.cast/2`
  + `__MODULE__.cast!/2`
  + `__MODULE__.xema/1`

  The macro `xema/2` supports the construction of a schema. After that
  the schema is available via the functions above.

  In a multi schema module a schema can be tagged with the option
  `default: :schema_name` and then called by
  + `__MODULE__.valid?/1`
  + `__MODULE__.validate/1`
  + `__MODULE__.validate!/1`
  + `__MODULE__.cast/1`
  + `__MODULE__.cast!/1`
  + `__MODULE__.xema/0`

  The functions with arity 1 are also available for single schema modules.

  The macro `xema_struct/1` creates a schema with the coresponding struct.

  ## Examples

  Single schema module:

      iex> defmodule SingleSchema do
      ...>   use Xema
      ...>
      ...>   # The name :num is optional.
      ...>   xema :num, do: number(minimum: 1)
      ...> end
      iex>
      iex> SingleSchema.valid?(:num, 6)
      true
      iex> SingleSchema.valid?(5)
      true
      iex> SingleSchema.validate(0)
      {:error, %Xema.ValidationError{
         reason: %{minimum: 1, value: 0}
      }}
      iex> SingleSchema.cast("5")
      {:ok, 5}
      iex> SingleSchema.cast("-5")
      {:error, %Xema.ValidationError{
         reason: %{minimum: 1, value: -5}
      }}

  Multi schema module:

      iex> defmodule Schema do
      ...>   use Xema, multi: true, default: :user
      ...>
      ...>   @pos integer(minimum: 0)
      ...>   @neg integer(maximum: 0)
      ...>
      ...>   xema :user do
      ...>     map(
      ...>       properties: %{
      ...>         name: string(min_length: 1),
      ...>         age: @pos
      ...>       }
      ...>     )
      ...>   end
      ...>
      ...>   xema :nums do
      ...>     map(
      ...>       properties: %{
      ...>         pos: list(items: @pos),
      ...>         neg: list(items: @neg)
      ...>       }
      ...>     )
      ...>   end
      ...> end
      iex>
      iex> Schema.valid?(:user, %{name: "John", age: 21})
      true
      iex> Schema.valid?(%{name: "John", age: 21})
      true
      iex> Schema.valid?(%{name: "", age: 21})
      false
      iex> Schema.validate(%{name: "John", age: 21})
      :ok
      iex> Schema.validate(%{name: "", age: 21})
      {:error, %Xema.ValidationError{
        reason: %{
          properties: %{name: %{min_length: 1, value: ""}}}
        }
      }
      iex> Schema.valid?(:nums, %{pos: [1, 2, 3]})
      true
      iex> Schema.valid?(:nums, %{neg: [1, 2, 3]})
      false

  Struct schema module:

      iex> defmodule StructA do
      ...>   use Xema
      ...>
      ...>   xema_struct do
      ...>     field :foo, :integer, minimum: 0
      ...>   end
      ...> end
      ...>
      ...> defmodule StructB do
      ...>   use Xema
      ...>
      ...>   xema_struct do
      ...>     field :a, :string, min_length: 3
      ...>     field :b, StructA
      ...>     required [:a]
      ...>   end
      ...> end
      ...>
      ...> data = StructB.cast!(a: "abc", b: %{foo: 5})
      ...> data.a
      "abc"
      iex> Map.from_struct(data.b)
      %{foo: 5}

  For more examples to construct schemas see "[Examples](examples.html)".
  """

  use Xema.Behaviour

  import Xema.Castable.Helper, only: [cast_key: 2]
  import Xema.Utils, only: [to_existing_atom: 1, to_sorted_list: 1]

  alias Xema.{
    Castable,
    CastError,
    JsonSchema,
    Ref,
    Schema,
    SchemaError,
    SchemaValidator,
    ValidationError
  }

  @keywords Schema.keywords()
  @types Schema.types()

  @doc false
  defmacro __using__(opts) do
    multi = Keyword.get(opts, :multi, false)
    default = Keyword.get(opts, :default)

    quote do
      import Xema.Builder
      @xemas []
      @__xema_default__ unquote(default)
      @multi unquote(multi)
    end
  end

  @doc """
  This function creates the schema from the given `data`.

  Possible options:
  + `:loader` - a loader for remote schemas. This option will overwrite the
                loader from the config.
                See [Configure a loader](loader.html) to how to define a loader.

  + `inline` - inlined all references in the schema. Default `:true`.

  ## Examples

  Simple schema:

      iex> schema = Xema.new :string
      iex> Xema.valid? schema, "hello"
      true
      iex> Xema.valid? schema, 42
      false

  Schema:

      iex> schema = Xema.new {:string, min_length: 3, max_length: 12}
      iex> Xema.valid? schema, "hello"
      true
      iex> Xema.valid? schema, "hi"
      false

  Nested schemas:

      iex> schema = Xema.new {:list, items: {:number, minimum: 2}}
      iex> Xema.validate(schema, [2, 3, 4])
      :ok
      iex> Xema.valid?(schema, [2, 3, 4])
      true
      iex> Xema.validate(schema, [2, 3, 1])
      {:error, %Xema.ValidationError{
        reason: %{
          items: %{2 => %{value: 1, minimum: 2}}}
        }
      }

  More examples can be found on page
  [Usage](https://hexdocs.pm/xema/usage.html#content).
  """
  @spec new(Schema.t() | Schema.type() | tuple | atom | keyword, keyword) :: __MODULE__.t()
  def new(data, opts)

  # The implementation of `init`.
  #
  # This function prepares the given keyword list for the function schema.
  @impl true
  @doc false
  @spec init(atom | keyword | {atom | [atom], keyword}, keyword) :: Schema.t()
  def init(type, opts) when is_atom(type), do: init({type, []}, opts)

  def init(val, opts) when is_list(val) do
    case Keyword.keyword?(val) do
      true ->
        # init without a given type
        init({:any, val}, opts)

      false ->
        # init with multiple types
        init({val, []}, opts)
    end
  end

  def init({:ref, pointer}, opts), do: init({:any, ref: pointer}, opts)

  def init(data, opts) do
    # If opts contains key :draft the schema is created from_json_schema and
    # already checked.
    if !Keyword.has_key?(opts, :draft), do: SchemaValidator.validate!(data)
    schema(data)
  end

  @doc """
  Creates a `Xema` from a JSON Schema.  The argument `json_schema` is expected
  as a decoded JSON Schema.

  All keys that are not standard JSON Schema keywords have to be known atoms. If
  the schema has additional keys that are unknown atoms the option
  `atom: :force` is needed. In this case the atoms will be created. This is not
  needed for keys expected by JSON Schema (e.g. in properties)

  Options:
  * `:draft` specifies the draft to check the given JSON Schema. Possible values
    are `"draft4"`, `"draft6"`, and `"draft7"`, default is `"draft7"`. If
    `:draft` not set and the schema contains `$schema` then the value for
    `$schema` is used for this option.
  * `:atom` creates atoms for unknown atoms when set to `:force`. This is just
    needed for additional JSON Schema keywords.

  ## Examples

      iex> Xema.from_json_schema(%{"type" => "integer", "minimum" => 5})
      %Xema{schema: %Xema.Schema{minimum: 5, type: :integer}}

      iex> schema = %{
      ...>   "type" => "object",
      ...>   "properties" => %{"foo" => %{"type" => "integer"}}
      ...> }
      iex> Xema.from_json_schema(schema)
      %Xema{schema:
        %Xema.Schema{
          properties: %{"foo" => %Xema.Schema{type: :integer}},
          type: :map,
          keys: :strings
        }
      }

      iex> Xema.from_json_schema(%{"type" => "integer", "foo" => "bar"}, atom: :force)
      %Xema{schema: %Xema.Schema{data: %{foo: "bar"}, type: :integer}}

      iex> Xema.from_json_schema(%{"exclusiveMaximum" => 5}, draft: "draft7")
      %Xema{schema: %Xema.Schema{exclusive_maximum: 5}}

      iex> Xema.from_json_schema(%{"exclusiveMaximum" => 5}, draft: "draft4")
      ** (Xema.SchemaError) Can't build schema:
      Dependencies for "exclusiveMaximum" failed. Missing required key "maximum".
  """
  @spec from_json_schema(atom | map, keyword) :: __MODULE__.t()
  def from_json_schema(json_schema, opts \\ []) do
    json_schema |> JsonSchema.to_xema(opts) |> new(opts)
  end

  # This function creates a schema from the given data.
  defp schema(type, opts \\ [])

  # Extracts the schema form a `%Xema{}` struct.
  @spec schema(Xema.t(), keyword) :: Schema.t()
  defp schema(%Xema{schema: schema}, _), do: schema

  # Creates a schema from a list. Expected a list of types or a keyword list
  # for an any schema.
  # This function will be just called for nested schemas.
  @spec schema([Schema.type()] | keyword, keyword) :: Schema.t()
  defp schema(list, opts) when is_list(list) do
    case Keyword.keyword?(list) do
      true ->
        schema({:any, list}, opts)

      false ->
        schema({list, []}, opts)
    end
  end

  # Creates a schema from an atom type.
  @spec schema(atom, keyword) :: Schema.t()
  defp schema(value, opts) when value in @types do
    schema({value, []}, opts)
  end

  # Creates a schema from a `Xema` module.
  defp schema(value, _opts) when is_atom(value) do
    ensure_behaviour!(value).xema().schema
  end

  # Creates a bool schema. Keywords and opts will be ignored.
  @spec schema({Schema.type() | [Schema.type()], keyword}, keyword) :: Schema.t()
  defp schema({bool, _}, _) when is_boolean(bool), do: Schema.new(type: bool)

  # Creates a schema for a reference.
  defp schema({:ref, keywords}, _), do: schema({:any, [{:ref, keywords}]})

  defp schema({type, keywords}, _) do
    keywords
    |> Keyword.put(:type, type)
    |> update()
    |> Schema.new()
  end

  # This function creates the schema tree.
  @spec update(keyword) :: keyword
  defp update(keywords) do
    keywords
    |> Keyword.update(:additional_items, nil, &bool_or_schema/1)
    |> Keyword.update(:additional_properties, nil, &bool_or_schema/1)
    |> Keyword.update(:all_of, nil, &schemas/1)
    |> Keyword.update(:any_of, nil, &schemas/1)
    |> Keyword.update(:contains, nil, &schema/1)
    |> Keyword.update(:dependencies, nil, &dependencies/1)
    |> Keyword.update(:else, nil, &schema/1)
    |> Keyword.update(:if, nil, &schema/1)
    |> Keyword.update(:items, nil, &items/1)
    |> Keyword.update(:not, nil, &schema/1)
    |> Keyword.update(:one_of, nil, &schemas/1)
    |> Keyword.update(:pattern_properties, nil, &schemas/1)
    |> Keyword.update(:properties, nil, &schemas/1)
    |> Keyword.update(:property_names, nil, &schema/1)
    |> Keyword.update(:definitions, nil, &schemas/1)
    |> Keyword.update(:required, nil, &MapSet.new/1)
    |> Keyword.update(:then, nil, &schema/1)
    |> update_allow()
    |> update_data()
  end

  @spec schemas(list) :: list
  defp schemas(list) when is_list(list), do: Enum.map(list, fn schema -> schema(schema) end)

  @spec schemas(map) :: map
  defp schemas(map) when is_map(map), do: map_values(map, &schema/1)

  @spec dependencies(map) :: map
  defp dependencies(map) do
    Enum.into(map, %{}, fn
      {key, dep} when is_list(dep) ->
        case Keyword.keyword?(dep) do
          true -> {key, schema(dep)}
          false -> {key, dep}
        end

      {key, dep} when is_boolean(dep) ->
        {key, schema(dep)}

      {key, dep} when is_atom(dep) ->
        {key, [dep]}

      {key, dep} when is_binary(dep) ->
        {key, [dep]}

      {key, dep} ->
        {key, schema(dep)}
    end)
  end

  @spec bool_or_schema(boolean | atom) :: boolean | Schema.t()
  defp bool_or_schema(bool) when is_boolean(bool), do: bool

  defp bool_or_schema(schema), do: schema(schema)

  @spec items(any) :: list
  defp items(%Xema{schema: schema}), do: schema

  defp items(schema) when is_atom(schema) or is_tuple(schema),
    do: schema(schema)

  defp items(value) when is_list(value) do
    case Keyword.keyword?(value) do
      true ->
        case schemas?(value) do
          true -> schemas(value)
          false -> schema(value)
        end

      false ->
        schemas(value)
    end
  end

  @spec schemas?(keyword) :: boolean
  defp schemas?(value),
    do:
      value
      |> Keyword.keys()
      |> Enum.all?(fn type -> type in [:ref | @types] end)

  defp update_allow(keywords) do
    case Keyword.pop(keywords, :allow, :undefined) do
      {:undefined, keywords} ->
        keywords

      {value, keywords} when is_list(value) ->
        Keyword.update!(keywords, :type, fn
          types when is_list(types) -> Enum.concat(value, types)
          type -> [type | value]
        end)

      {value, keywords} ->
        Keyword.update!(keywords, :type, fn
          types when is_list(types) -> [value | types]
          type -> [type, value]
        end)
    end
  end

  defp update_data(keywords) do
    {data, keywords} = do_update_data(keywords)

    data =
      case Enum.empty?(data) do
        true -> nil
        false -> data
      end

    Keyword.put(keywords, :data, data)
  end

  @spec do_update_data(keyword) :: {map, keyword}
  defp do_update_data(keywords),
    do:
      keywords
      |> diff_keywords()
      |> Enum.reduce({%{}, keywords}, fn key, {data, keywords} ->
        {value, keywords} = Keyword.pop(keywords, key)
        {Map.put(data, key, maybe_schema(value)), keywords}
      end)

  defp maybe_schema(list) when is_list(list) do
    case Keyword.keyword?(list) do
      true ->
        case has_keyword?(list) do
          true -> schema(list)
          false -> list
        end

      false ->
        Enum.map(list, &maybe_schema/1)
    end
  end

  defp maybe_schema(atom) when is_atom(atom) do
    case atom in Schema.types() do
      true -> schema(atom)
      false -> atom
    end
  end

  defp maybe_schema({:ref, str} = ref) when is_binary(str), do: schema(ref)

  defp maybe_schema({atom, list} = tuple)
       when is_atom(atom) and is_list(list) do
    case atom in Schema.types() do
      true -> schema(tuple)
      false -> tuple
    end
  end

  defp maybe_schema(%_{} = struct), do: struct

  defp maybe_schema(map) when is_map(map), do: map_values(map, &maybe_schema/1)

  defp maybe_schema(value), do: value

  defp diff_keywords(list),
    do:
      list
      |> Keyword.keys()
      |> MapSet.new()
      |> MapSet.difference(MapSet.new(@keywords))
      |> MapSet.to_list()

  defp has_keyword?(list),
    do:
      list
      |> Keyword.keys()
      |> MapSet.new()
      |> MapSet.disjoint?(MapSet.new(@keywords))
      |> Kernel.not()

  # Returns a map where each value is the result of invoking `fun` on each
  # value of the given `map`.
  @spec map_values(map, (any -> any)) :: map
  defp map_values(map, fun) when is_map(map) and is_function(fun),
    do: Enum.into(map, %{}, fn {key, val} -> {key, fun.(val)} end)

  @doc """
  Returns the source for a given `xema`. The output can differ from the input
  if the schema contains references. To get the original source the schema
  must be created with `inline: false`.

  ## Examples

      iex> {:integer, minimum: 1} |> Xema.new() |> Xema.source()
      {:integer, minimum: 1}
  """
  @spec source(Xema.t() | Schema.t()) :: atom | keyword | {atom, keyword}
  def source(%Xema{} = xema), do: source(xema.schema)

  def source(%Schema{} = schema) do
    type = schema.type
    data = Map.get(schema, :data) || %{}

    keywords =
      schema
      |> Schema.to_map()
      |> Map.delete(:type)
      |> Map.delete(:data)
      |> Map.merge(data)
      |> Enum.map(fn {key, val} -> {key, nested_source(val)} end)
      |> map_ref()

    case {type, keywords} do
      {type, []} -> type
      {:any, keywords} -> keywords
      tuple -> tuple
    end
  end

  defp map_ref(keywords) do
    case Keyword.has_key?(keywords, :ref) do
      true ->
        if length(keywords) == 1 do
          keywords[:ref]
        else
          {_, pointer} = keywords[:ref]
          Keyword.put(keywords, :ref, pointer)
        end

      false ->
        keywords
    end
  end

  defp nested_source(%Schema{} = val), do: source(val)

  defp nested_source(%Ref{} = val), do: {:ref, val.pointer}

  defp nested_source(%MapSet{} = val), do: Map.keys(val.map)

  defp nested_source(%_{} = struct), do: struct

  defp nested_source(val) when is_map(val) do
    map_values(val, &nested_source/1)
  end

  defp nested_source(val) when is_list(val), do: Enum.map(val, &nested_source/1)

  defp nested_source(val), do: val

  @doc """
  Converts the given data using the specified schema. Returns the converted data
  or an exception.
  """
  @spec cast!(Xema.t(), term) :: term
  def cast!(xema, value, opts \\ []) do
    case cast(xema, value, opts) do
      {:ok, cast} ->
        cast

      {:error, exception} ->
        raise exception
    end
  end

  @doc """
  Converts the given data using the specified schema. Returns `{:ok, result}` or
  `{:error, reason}`. The `result` is converted and validated with the schema.

  ## Examples:

      iex> schema = Xema.new({:integer, minimum: 1})
      iex> Xema.cast(schema, "5")
      {:ok, 5}
      iex> Xema.cast(schema, "five")
      {:error, %Xema.CastError{
        key: nil,
        path: [],
        to: :integer,
        value: "five"
      }}
      iex> Xema.cast(schema, "0")
      {:error, %Xema.ValidationError{
        reason: %{minimum: 1, value: 0}
      }}

  ## Multiple types

  If for a value multiple types are defined the function used the result of the
  first successful conversion.

  ## Examples

      iex> schema = Xema.new([:integer, :string, nil])
      iex> Xema.cast(schema, 5)
      {:ok, 5}
      iex> Xema.cast(schema, 5.5)
      {:ok, "5.5"}
      iex> Xema.cast(schema, "5")
      {:ok, 5}
      iex> Xema.cast(schema, "five")
      {:ok, "five"}
      iex> Xema.cast(schema, nil)
      {:ok, nil}
      iex> Xema.cast(schema, [5])
      {:error,
        %Xema.CastError{path: [], to: [:integer, :string, nil], value: [5]}
      }

  ## Cast with `any_of`, `all_of`, and `one_of`

  Schemas in a combiner will be cast independently one by one in reverse order.

  ## Examples

      iex> schema = Xema.new(any_of: [
      ...>   [properties: %{a: :integer}],
      ...>   [properties: %{a: :string}]
      ...> ])
      iex> Xema.cast(schema, %{a: 5})
      {:ok, %{a: 5}}
      iex> Xema.cast(schema, %{a: 5.5})
      {:ok, %{a: "5.5"}}
      iex> Xema.cast(schema, %{a: "5"})
      {:ok, %{a: 5}}
      iex> Xema.cast(schema, %{a: "five"})
      {:ok, %{a: "five"}}
      iex> Xema.cast(schema, %{a: [5]})
      {:error,
        %Xema.CastError{
          error: nil,
          key: nil,
          message: nil,
          path: [],
          to: [
            %{path: [:a], to: :integer, value: [5]},
            %{path: [:a], to: :string, value: [5]}
          ],
          value: %{a: [5]}
      }}

  ## Options

  With the option `additional_properties: :delete` additional properties will be
  deleted on cast. Additional properties will be deleted in schemas with
  `additional_properties: false`.

  ## Examples

      iex> schema = Xema.new(
      ...>   properties: %{
      ...>     a: [
      ...>       properties: %{
      ...>         foo: :integer
      ...>       },
      ...>       additional_properties: false
      ...>     ],
      ...>     b: [
      ...>       properties: %{
      ...>         foo: :integer
      ...>       }
      ...>     ]
      ...>   }
      ...> )
      iex>
      iex> Xema.cast(schema, %{
      ...>   a: %{foo: "6", bar: "7"},
      ...>   b: %{foo: "6", bar: "7"},
      ...> }, additional_properties: :delete)
      {:ok, %{
        a: %{foo: 6},
        b: %{foo: 6, bar: "7"}
      }}
  """
  @spec cast(Xema.t(), term) :: {:ok, term} | {:error, term}
  def cast(xema, value, opts \\ [])

  def cast(%Xema{schema: schema}, value, opts) do
    cast(schema, value, opts)
  end

  def cast(%Schema{} = schema, value, opts) do
    with {:ok, result} <- do_cast(schema, value, opts, []),
         :ok <- validate(schema, result) do
      {:ok, result}
    else
      {:error, %ValidationError{}} = validation_error ->
        validation_error

      {:error, reason} ->
        {:error,
         CastError.exception(
           error: Map.get(reason, :error),
           key: Map.get(reason, :key),
           path: Map.get(reason, :path),
           required: Map.get(reason, :required),
           to: Map.get(reason, :to),
           value: Map.get(reason, :value)
         )}
    end
  end

  @spec do_cast(Schema.t(), term, keyword, list) :: {:ok, term} | {:error, term}
  defp do_cast(%Schema{} = schema, data, opts, path)
       when is_list(data) or is_tuple(data) or is_map(data) do
    with {:ok, values} <- cast_values(schema, data, opts, path),
         {:ok, cast} <- castable_cast(schema, values) do
      cast_combiner(schema, cast, opts, path)
    else
      {:error, reason} ->
        {:error, Map.put_new(reason, :path, Enum.reverse(path))}
    end
  end

  defp do_cast(%Schema{} = schema, value, opts, path) do
    case castable_cast(schema, value) do
      {:ok, cast} -> cast_combiner(schema, cast, opts, path)
      {:error, reason} -> {:error, Map.put(reason, :path, Enum.reverse(path))}
    end
  end

  defp do_cast(nil, value, _opts, _path), do: {:ok, value}

  @spec castable_cast(Schema.t(), term) :: {:ok, term} | {:error, term}
  defp castable_cast(%Schema{} = schema, value) do
    case do_castable_cast(schema, value) do
      {:ok, _} = ok ->
        ok

      {:error, _} = error ->
        error

      _ ->
        case schema do
          %{type: :struct, module: module} -> {:error, %{to: module, value: value}}
          %{type: type} -> {:error, %{to: type, value: value}}
        end
    end
  end

  defp do_castable_cast(%Schema{caster: caster}, value)
       when is_function(caster),
       do: caster.(value)

  defp do_castable_cast(%Schema{caster: {caster, fun}}, value)
       when is_atom(caster) and is_atom(fun),
       do: apply(caster, fun, [value])

  defp do_castable_cast(%Schema{caster: {caster, fun, args}}, value)
       when is_atom(caster) and is_atom(fun),
       do: apply(caster, fun, [value | args])

  defp do_castable_cast(%Schema{caster: caster}, value)
       when caster != nil and is_atom(caster),
       do: caster.cast(value)

  defp do_castable_cast(schema, value) do
    Castable.cast(value, schema)
  end

  @spec cast_values(Schema.t(), term, keyword, list) :: term
  defp cast_values(schema, tuple, opts, path) when is_tuple(tuple) do
    with {:ok, values} <- cast_values(schema, Tuple.to_list(tuple), opts, path) do
      {:ok, List.to_tuple(values)}
    end
  end

  defp cast_values(schema, %module{} = struct, opts, path) do
    with {:ok, values} <- cast_values(schema, Map.from_struct(struct), opts, path) do
      {:ok, struct!(module, values)}
    end
  end

  defp cast_values(%Schema{} = schema, data, opts, path) when is_list(data) do
    case Keyword.keyword?(data) do
      true -> cast_values_keyword(schema, data, opts, path)
      false -> cast_values_list(schema, data, opts, path)
    end
  end

  defp cast_values(%Schema{type: :list, items: items} = schema, data, opts, path)
       when is_map(data) do
    case items do
      nil ->
        {:ok, data}

      %Schema{} = schema ->
        result =
          Enum.reduce_while(data, [], fn {index, item}, acc ->
            case do_cast(schema, item, opts, [index | path]) do
              {:ok, cast} -> {:cont, [{index, cast} | acc]}
              {:error, _} = error -> {:halt, error}
            end
          end)

        case result do
          {:error, _} = error -> error
          values -> {:ok, Map.new(values)}
        end

      [_ | _] ->
        case to_sorted_list(data) do
          :error ->
            {:error, %{to: :list, path: path, value: data}}

          {:ok, list} ->
            cast_values_map(schema, list, opts, path)
        end
    end
  end

  defp cast_values(
         %Schema{
           type: type,
           keys: keys,
           properties: properties,
           pattern_properties: pattern_properties,
           additional_properties: additional_properties
         } = schema,
         data,
         opts,
         path
       )
       when is_map(data) do
    key_type = if type in [:keyword, :struct], do: :atoms, else: keys

    with :ok <- check_required(schema, data, path) do
      data
      |> Enum.reduce_while([], fn {key, value}, acc ->
        schema =
          get_properties_schema(
            properties,
            pattern_properties,
            additional_properties,
            key_to(key_type, key)
          )

        case do_cast(schema, value, opts, [key | path]) do
          {:ok, cast} -> {:cont, [{key, cast} | acc]}
          {:error, _} = error -> {:halt, error}
        end
      end)
      |> case do
        {:error, _} = error ->
          error

        values ->
          {:ok,
           values
           |> delete_additional_properties(schema, opts)
           |> Enum.into(%{})
           |> add_defaults(schema, opts)}
      end
    end
  end

  @spec cast_values_map(Schema.t(), list(), keyword(), list()) :: {:error, term} | {:ok, map}
  defp cast_values_map(%Schema{type: :list, items: items} = schema, list, opts, path)
       when is_list(items) do
    additional_items = Map.get(schema, :additional_items)

    result =
      list
      |> Enum.with_index()
      |> Enum.reduce_while([], fn {{key, item}, index}, acc ->
        schema = Enum.at(items, index, additional_items)

        case do_cast(schema, item, opts, [key | path]) do
          {:ok, cast} -> {:cont, [{index, cast} | acc]}
          error -> {:halt, error}
        end
      end)

    case result do
      {:error, _} = error -> error
      values -> {:ok, Map.new(values)}
    end
  end

  @spec cast_values_keyword(Schema.t(), term, keyword, list) :: term
  defp cast_values_keyword(
         %Schema{
           keys: keys,
           properties: properties,
           pattern_properties: pattern_properties,
           additional_properties: additional_properties
         } = schema,
         data,
         opts,
         path
       )
       when is_list(data) do
    with :ok <- check_required(schema, data, path) do
      data
      |> Enum.reduce_while([], fn {key, value}, acc ->
        schema =
          get_properties_schema(
            properties,
            pattern_properties,
            additional_properties,
            key_to(keys, key)
          )

        case do_cast(schema, value, opts, [key | path]) do
          {:ok, cast} -> {:cont, [{key, cast} | acc]}
          {:error, _} = error -> {:halt, error}
        end
      end)
      |> case do
        {:error, _} = error ->
          error

        values ->
          {:ok,
           values
           |> delete_additional_properties(schema, opts)
           |> add_defaults(schema, opts)
           |> Enum.reverse()}
      end
    end
  end

  @spec cast_values_list(Schema.t(), term, keyword, list) :: term
  defp cast_values_list(%Schema{items: items} = schema, data, opts, path) when is_list(data) do
    case items do
      nil ->
        {:ok, data}

      %Schema{} = schema ->
        data
        |> Enum.with_index()
        |> Enum.reduce_while([], fn {item, index}, acc ->
          case do_cast(schema, item, opts, [index | path]) do
            {:ok, cast} -> {:cont, [cast | acc]}
            {:error, _} = error -> {:halt, error}
          end
        end)
        |> case do
          {:error, _} = error -> error
          values -> {:ok, Enum.reverse(values)}
        end

      items ->
        additional_items = Map.get(schema, :additional_items)

        data
        |> Enum.with_index()
        |> Enum.reduce_while([], fn {item, index}, acc ->
          schema = Enum.at(items, index, additional_items)

          case do_cast(schema, item, opts, [index | path]) do
            {:ok, cast} -> {:cont, [cast | acc]}
            {:error, _} = error -> {:halt, error}
          end
        end)
        |> case do
          {:error, _} = error -> error
          values -> {:ok, Enum.reverse(values)}
        end
    end
  end

  @spec check_required(Schema.t(), term, list) :: :ok | {:error, [String.t()] | [:atom]}
  defp check_required(%Schema{required: nil}, _data, _path), do: :ok

  defp check_required(
         %Schema{type: type, module: module, keys: keys, required: required},
         data,
         path
       ) do
    with {:error, keys} <- do_check_required(required, data, keys || :atoms) do
      to = if module == nil, do: type, else: module
      {:error, %{to: to, value: data, required: keys, path: path}}
    end
  end

  defp do_check_required(required, data, keys_type) do
    keys =
      data
      |> keys()
      |> cast_keys(keys_type)
      |> MapSet.new()

    required
    |> MapSet.difference(keys)
    |> MapSet.to_list()
    |> case do
      [] -> :ok
      keys -> {:error, keys}
    end
  end

  @spec cast_keys([String.t()] | [atom], :strings | :atoms) :: [String.t()] | [atom]
  defp cast_keys(keys, keys_type) do
    Enum.map(keys, fn key ->
      case cast_key(key, keys_type) do
        :error -> key
        {:ok, cast} -> cast
      end
    end)
  end

  # additional_properties false will be ignored
  defp get_properties_schema(properties, pattern_properties, false, key),
    do: get_properties_schema(properties, pattern_properties, nil, key)

  defp get_properties_schema(nil, nil, additional_properties, _key), do: additional_properties

  defp get_properties_schema(properties, nil, additional_properties, key),
    do: Map.get(properties, key, additional_properties)

  defp get_properties_schema(nil, pattern_properties, additional_properties, key) do
    Enum.find_value(pattern_properties, additional_properties, fn {regex, schema} ->
      with true <- Regex.match?(regex, to_string(key)), do: schema
    end)
  end

  defp get_properties_schema(properties, pattern_properties, additional_properties, key) do
    get_properties_schema(properties, nil, additional_properties, key) ||
      get_properties_schema(nil, pattern_properties, additional_properties, key)
  end

  defp delete_additional_properties(data, %Schema{additional_properties: false} = schema, opts) do
    case Keyword.get(opts, :additional_properties) do
      :delete ->
        keys = Map.keys(Map.get(schema, :properties) || %{})
        patterns = Map.keys(Map.get(schema, :pattern_properties) || %{})
        Enum.filter(data, fn {key, _} -> key?(key, keys, patterns) end)

      _ ->
        data
    end
  end

  defp delete_additional_properties(data, _schema, _opts), do: data

  defp add_defaults(data, schema, _opts) do
    schema
    |> get_defaults()
    |> merge_defaults(data)
  end

  defp get_defaults(%Schema{properties: nil}), do: %{}

  defp get_defaults(%Schema{properties: properties}) do
    Enum.reduce(properties, %{}, fn
      {_key, %Schema{default: nil}}, acc ->
        acc

      {key, %Schema{default: default}}, acc ->
        Map.put(acc, key, get_default(default))
    end)
  end

  defp get_default(fun) when is_function(fun), do: fun.()

  defp get_default({mod, fun})
       when is_atom(mod) and is_atom(fun),
       do: apply(mod, fun, [])

  defp get_default({mod, fun, arg})
       when is_atom(mod) and is_atom(fun) and is_list(arg),
       do: apply(mod, fun, arg)

  defp get_default(value), do: value

  defp merge_defaults(defaults, data) when defaults == %{}, do: data

  defp merge_defaults(defaults, data) when is_map(data) do
    Enum.reduce(defaults, data, fn {key, value}, acc ->
      case {Map.get(acc, key), Map.get(acc, to_string(key))} do
        {nil, nil} -> Map.put(acc, key, value)
        _ -> acc
      end
    end)
  end

  defp merge_defaults(defaults, data) when is_list(data) do
    Enum.reduce(defaults, data, fn {key, value}, acc ->
      case Keyword.get(data, key) do
        nil -> Keyword.put(acc, key, value)
        _ -> acc
      end
    end)
  end

  defp key?(key, keys, []), do: key in keys

  defp key?(key, [], patterns),
    do: Enum.find_value(patterns, false, fn regex -> Regex.match?(regex, to_string(key)) end)

  defp key?(key, keys, patterns), do: key?(key, keys, []) && key?(key, [], patterns)

  defp cast_combiner(schema, data, opts, path) do
    schema
    |> get_combiner()
    |> do_cast_combiner(data, opts, path)
  end

  defp do_cast_combiner(nil, data, _opts, _path), do: {:ok, data}

  defp do_cast_combiner({type, schemas}, data, opts, path) when type in [:any, :one] do
    schemas
    |> Enum.reverse()
    |> Enum.reduce({nil, []}, fn schema, {result, errors} ->
      case cast(schema, data, opts) do
        {:ok, cast} ->
          {cast, errors}

        {:error, %ValidationError{} = validation_error} ->
          error = %{to: schema.type, module: schema.module, value: data, reason: validation_error}
          {result, [error | errors]}

        {:error, %CastError{path: path, to: to, value: value}} ->
          error = %{path: path, to: to, value: value}
          {result, [error | errors]}
      end
    end)
    |> case do
      {data, errors} when length(errors) < length(schemas) ->
        {:ok, data}

      {_, errors} ->
        {:error,
         %{
           to: errors,
           value: data,
           path: Enum.reverse(path)
         }}
    end
  end

  defp do_cast_combiner({:all, schemas}, data, opts, path) do
    schemas
    |> Enum.reverse()
    |> Enum.reduce({data, []}, fn schema, {data, errors} ->
      case do_cast(schema, data, opts, []) do
        {:ok, cast} -> {cast, errors}
        {:error, error} -> {data, [error | errors]}
      end
    end)
    |> case do
      {data, errors} when length(errors) < length(schemas) ->
        {:ok, data}

      {_, errors} ->
        {:error,
         %{
           to: errors,
           value: data,
           path: Enum.reverse(path)
         }}
    end
  end

  defp get_combiner(%Schema{} = schema) do
    cond do
      schema.any_of != nil -> {:any, schema.any_of}
      schema.all_of != nil -> {:all, schema.all_of}
      schema.one_of != nil -> {:one, schema.one_of}
      true -> nil
    end
  end

  defp key_to(:atoms, key) when is_binary(key), do: to_existing_atom(key)

  defp key_to(:strings, key) when is_atom(key), do: to_string(key)

  defp key_to(_, key) when is_binary(key) or is_atom(key), do: key

  defp keys(data) when is_map(data), do: Map.keys(data)

  defp keys(data) when is_list(data), do: Keyword.keys(data)

  @doc false
  def ensure_behaviour!(name) when is_atom(name) do
    case behaviour?(name) do
      true -> name
      false -> raise SchemaError, "Module #{inspect(name)} is not a Xema behaviour"
    end
  end

  @doc false
  def behaviour?(name) when is_atom(name) do
    module =
      case Code.ensure_compiled(name) do
        {:module, module} -> module
        {:error, _} -> raise SchemaError, "Module #{inspect(name)} not compiled"
      end

    function_exported?(module, :xema, 0)
  end
end
