defmodule Norm do
  @external_resource "README.md"

  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  alias Norm.Conformer
  alias Norm.Generatable
  alias Norm.Generator
  alias Norm.MismatchError
  alias Norm.GeneratorError
  alias Norm.Core.{
    Alt,
    AnyOf,
    Collection,
    Schema,
    Selection,
    Spec,
    Delegate
  }

  @doc false
  defmacro __using__(_) do
    quote do
      import Norm
      use Norm.Contract
    end
  end

  @doc ~S"""
  Verifies that the payload conforms to the specification. A "success tuple"
  is returned that contains either the conformed value or the error explanation.

  ## Examples:

      iex> conform(42, spec(is_integer()))
      {:ok, 42}
      iex> conform(42, spec(fn x -> x == 42 end))
      {:ok, 42}
      iex> conform(42, spec(&(&1 >= 0)))
      {:ok, 42}
      iex> conform(42, spec(&(&1 >= 100)))
      {:error, [%{spec: "&(&1 >= 100)", input: 42, path: []}]}
      iex> conform("foo", spec(is_integer()))
      {:error, [%{spec: "is_integer()", input: "foo", path: []}]}
  """
  def conform(input, spec) do
    Conformer.conform(spec, input)
  end

  @doc ~s"""
  Returns the conformed value or raises a mismatch error.

  ## Examples

      iex> conform!(42, spec(is_integer()))
      42
      iex> conform!(42, spec(is_binary()))
      ** (Norm.MismatchError) Could not conform input:
      val: 42 fails: is_binary()
  """
  def conform!(input, spec) do
    case Conformer.conform(spec, input) do
      {:ok, input} -> input
      {:error, errors} -> raise MismatchError, errors
    end
  end

  @doc ~S"""
  Checks if the value conforms to the spec and returns a boolean.

  ## Examples

      iex> valid?(42,  spec(is_integer()))
      true
      iex> valid?("foo",  spec(is_integer()))
      false
  """
  def valid?(input, spec) do
    case Conformer.conform(spec, input) do
      {:ok, _} -> true
      {:error, _} -> false
    end
  end

  @doc ~S"""
  Creates a generator from a spec, schema, or selection.

  ## Examples

      iex> gen(spec(is_integer())) |> Enum.take(3) |> Enum.all?(&is_integer/1)
      true
      iex> gen(spec(is_binary())) |> Enum.take(3) |> Enum.all?(&is_binary/1)
      true
      iex> gen(spec(&(&1 > 0)))
      ** (Norm.GeneratorError) Unable to create a generator for: &(&1 > 0)
  """
  def gen(spec) do
    if Code.ensure_loaded?(StreamData) do
      case Generatable.gen(spec) do
        {:ok, generator} -> generator
        {:error, error} -> raise GeneratorError, error
      end
    else
      raise Norm.GeneratorLibraryError
    end
  end

  @doc """
  Overwrites the default generator with a custom generator. The generator
  can be any valid StreamData generator. This means you can either use Norms
  built in `gen/1` function or you can drop into StreamData directly.

  ## Examples

      iex> Enum.take(gen(with_gen(spec(is_integer()), StreamData.constant("hello world"))), 3)
      ["hello world", "hello world", "hello world"]
  """
  if Code.ensure_loaded?(StreamData) do
    def with_gen(spec, %StreamData{} = generator) do
      Generator.new(spec, generator)
    end
  else
    def with_gen(spec, _) do
      Generator.new(spec, :null)
    end
  end

  @doc ~S"""
  Creates a new spec. Specs can be created from any existing predicates or
  anonymous functions. Specs must return a boolean value.

  Predicates can be arbitrarily composed using the `and` and `or` keywords.

  ## Examples:

      iex> conform!(21, spec(is_integer()))
      21
      iex> conform!(21, spec(is_integer() and &(&1 >= 21)))
      21
      iex> conform("21", spec(is_integer() and &(&1 >= 21)))
      {:error, [%{spec: "is_integer()", input: "21", path: []}]}
      iex> conform!(:foo, spec(is_atom() or is_binary()))
      :foo
      iex> conform!("foo", spec(is_atom() or is_binary()))
      "foo"
      iex> conform(21, spec(is_atom() or is_binary()))
      {:error, [%{spec: "is_atom()", input: 21, path: []}, %{spec: "is_binary()", input: 21, path: []}]}
  """
  defmacro spec(predicate) do
    Spec.build(predicate)
  end

  @doc ~S"""
  Allows encapsulation of a spec in another function. This enables late-binding of
  specs which enables definition of recursive specs.

  ## Examples:
      iex> conform!(%{"value" => 1, "left" => %{"value" => 2, "right" => %{"value" => 4}}}, Norm.Core.DelegateTest.TreeTest.spec())
      %{"value" => 1, "left" => %{"value" => 2, "right" => %{"value" => 4}}}
      iex> conform(%{"value" => 1, "left" => %{"value" => 2, "right" => %{"value" => 4, "right" => %{"value" => "12"}}}}, Norm.Core.DelegateTest.TreeTest.spec())
      {:error, [%{input: "12", path: ["left", "right", "right", "value"], spec: "is_integer()"}]}
  """
  def delegate(predicate) do
    Delegate.build(predicate)
  end

  @doc ~S"""
  Creates a re-usable schema. Schema's are open which means that all keys are
  optional and any non-specified keys are passed through without being conformed.
  If you need to mark keys as required instead of optional you can use `selection`.

  ## Examples

      iex> valid?(%{}, schema(%{name: spec(is_binary())}))
      true
      iex> valid?(%{name: "Chris"}, schema(%{name: spec(is_binary())}))
      true
      iex> valid?(%{name: "Chris", age: 31}, schema(%{name: spec(is_binary())}))
      true
      iex> valid?(%{age: 31}, schema(%{name: spec(is_binary())}))
      true
      iex> valid?(%{name: 123}, schema(%{name: spec(is_binary())}))
      false
      iex> conform!(%{}, schema(%{name: spec(is_binary())}))
      %{}
      iex> conform!(%{age: 31, name: "chris"}, schema(%{name: spec(is_binary())}))
      %{age: 31, name: "chris"}
      iex> conform!(%{age: 31}, schema(%{name: spec(is_binary())}))
      %{age: 31}
      iex> conform!(%{user: %{name: "chris"}}, schema(%{user: schema(%{name: spec(is_binary())})}))
      %{user: %{name: "chris"}}
  """
  def schema(input) when is_map(input) do
    Schema.build(input)
  end

  @doc ~S"""
  Selections can be used to mark keys on a schema as required. Any unspecified keys
  in the selection are still considered optional. Selections, like schemas,
  are open and allow unspecied keys to be passed through. If no selectors are
  provided then `selection` defaults to `:all` and recursively marks all keys in
  all nested schema's. If the schema includes internal selections these selections
  will not be overwritten.

  ## Examples

      iex> valid?(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
      true
      iex> valid?(%{}, selection(schema(%{name: spec(is_binary())}), [:name]))
      false
      iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})}), [user: [:name]]))
      true
      iex> conform!(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
      %{name: "chris"}
      iex> conform!(%{name: "chris", age: 31}, selection(schema(%{name: spec(is_binary())}), [:name]))
      %{name: "chris", age: 31}

  ## Require all keys
      iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})})))
      true
  """
  def selection(%Schema{} = schema, path \\ :all) do
    Selection.new(schema, path)
  end

  @doc ~S"""
  Chooses between alternative predicates or patterns. The patterns must be tagged with an atom.
  When conforming data to this specification the data is returned as a tuple with the tag.

  ## Examples

      iex> conform!("foo", alt(s: spec(is_binary()), a: spec(is_atom())))
      {:s, "foo"}
      iex> conform!(:foo, alt(s: spec(is_binary()), a: spec(is_atom())))
      {:a, :foo}
      iex> conform!(123, alt(num: spec(is_integer()), str: spec(is_binary())))
      {:num, 123}
      iex> conform!("foo", alt(num: spec(is_integer()), str: spec(is_binary())))
      {:str, "foo"}
      iex> conform(true, alt(num: spec(is_integer()), str: spec(is_binary())))
      {:error, [%{spec: "is_integer()", input: true, path: [:num]}, %{spec: "is_binary()", input: true, path: [:str]}]}
  """
  def alt(specs) when is_list(specs) do
    %Alt{specs: specs}
  end

  @doc """
  Chooses between a list of options. Unlike `alt/1` the options don't need to
  be tagged. Specs are always tested in order and will short circuit if the
  data passes a validation.

  ## Examples
      iex> conform!("chris", one_of([spec(is_binary()), :alice]))
      "chris"
      iex> conform!(:alice, one_of([spec(is_binary()), :alice]))
      :alice
  """
  def one_of(specs) when is_list(specs) do
    AnyOf.new(specs)
  end

  @doc ~S"""
  Specifies a generic collection. Collections can be any enumerable type.

  `coll_of` takes multiple arguments:

  * `:kind` - predicate function the kind of collection being conformed
  * `:distinct` - boolean value for specifying if the collection should have distinct elements
  * `:min_count` - Minimum element count
  * `:max_count` - Maximum element count
  * `:into` - The output collection the input will be conformed into. If not specified then the input type will be used.

  ## Examples

      iex> conform!([:a, :b, :c], coll_of(spec(is_atom())))
      [:a, :b, :c]
      iex> conform!([:a, :b, :c], coll_of(spec(is_atom), into: MapSet.new()))
      MapSet.new([:a, :b, :c])
      iex> conform!(MapSet.new([:a, :b, :c]), coll_of(spec(is_atom)))
      MapSet.new([:a, :b, :c])
      iex> conform!(%{a: 1, b: 2, c: 3}, coll_of({spec(is_atom), spec(is_integer)}))
      %{a: 1, b: 2, c: 3}
      iex> conform!([1, 2], coll_of(spec(is_integer), min_count: 1))
      [1, 2]
  """
  @default_opts [
    kind: nil,
    distinct: false,
    min_count: 0,
    max_count: :infinity,
    into: nil,
  ]

  def coll_of(spec, opts \\ []) do
    opts = Keyword.merge(@default_opts, opts)
    if opts[:min_count] > opts[:max_count] do
      raise ArgumentError, "min_count cannot be larger than max_count"
    end

    Collection.new(spec, opts)
  end

  @doc ~S"""
  Specifies a map with a type of key and a type of value.

  ## Examples

      iex> conform!(%{a: 1, b: 2, c: 3}, map_of(spec(is_atom()), spec(is_integer())))
      %{a: 1, b: 2, c: 3}
  """
  def map_of(kpred, vpred, opts \\ []) do
    opts = Keyword.merge(opts, [into: %{}, kind: &is_map/1])
    coll_of({kpred, vpred}, opts)
  end

  # @doc ~S"""
  # Concatenates a sequence of predicates or patterns together. These predicates
  # must be tagged with an atom. The conformed data is returned as a
  # keyword list.

  # iex> conform!([31, "Chris"], cat(age: integer?(), name: string?()))
  # [age: 31, name: "Chris"]
  # iex> conform([true, "Chris"], cat(age: integer?(), name: string?()))
  # {:error, ["in: [0] at: :age val: true spec: integer?()"]}
  # iex> conform([31, :chris], cat(age: integer?(), name: string?()))
  # {:error, ["in: [1] at: :name val: :chris spec: string?()"]}
  # iex> conform([31], cat(age: integer?(), name: string?()))
  # {:error, ["in: [1] at: :name val: nil spec: Insufficient input"]}
  # """
  # def cat(opts) do
  #   fn path, input ->
  #     results =
  #       opts
  #       |> Enum.with_index
  #       |> Enum.map(fn {{tag, spec}, i} ->
  #         val = Enum.at(input, i)
  #         if val do
  #           {tag, spec.(path ++ [{:index, i}], val)}
  #         else
  #           {tag, {:error, [error(path ++ [{:index, i}], nil, "Insufficient input")]}}
  #         end
  #       end)

  #     errors =
  #       results
  #       |> Enum.filter(fn {_, {result, _}} -> result == :error end)
  #       |> Enum.map(fn {tag, {_, errors}} -> {tag, errors} end)
  #       |> Enum.flat_map(fn {tag, errors} -> Enum.map(errors, &(%{&1 | at: tag})) end)

  #     if Enum.any?(errors) do
  #       {:error, errors}
  #     else
  #       {:ok, Enum.map(results, fn {tag, {_, data}} -> {tag, data} end)}
  #     end
  #   end
  # end
end
