defmodule Dx do
  @moduledoc """
  This module used to be the main entry for using the Dx API. It is now deprecated.
  Please use `Dx.Defd` instead.

  - `get/3` evaluates the given predicate(s) using only the (pre)loaded data available, and returns the result(s)
  - `load/3` is like `get`, but loads any additional data as needed
  - `put/3` is like `load`, but puts the results into the `:inferred` field
    (or virtual schema field) of the subject(s) as a map, and returns the subject(s)

  These functions return a tuple, either `{:ok, result}`, `{:error, error}`, or `{:not_loaded, data_reqs}` (only `get`).

  The corresponding `get!/3`, `load!/3` and `put!/3` functions return `result`
  directly, or otherwise raise an exception.

  Arguments:
  - `subjects` can either be an individual subject (with the given predicates defined on it), or a list of subjects.
    Passing an individual subject will return the predicates for the subject, passing a list will return a list of them.
  - `predicates` can either be a single predicate, or a list of predicates.
    Passing a single predicate will return the resulting value, passing a list will return a `Map`
    of the predicates and their resulting values.
  - `options` (optional) See below.

  Options:
  - `args` (list or map) can be used to pass in data from the caller's context that can be used in
    rules (see *Arguments* below). A classic example is the `current_user`, e.g.
    ```elixir
    put!(project, :can_edit?, args: [user: current_user])
    ```
  - `extra_rules` (module or list of modules) can be used to add context-specific rules that are
    not defined directly on the subject. This can be used to structure rules into their own modules
    and use them only where needed.
  - `debug` (boolean) makes Dx print additional information to the console as rules are evaluated.
    Should only be used while debugging.
  - `return_cache` (boolean) makes non-bang functions return `{:ok, result, cache}` instead of
    `{:ok, result}` on success. This `cache` can be passed to other Dx functions (see `cache` option)
  - `cache` (`Dataloader` struct) can be used to pass in an existing cache, so data already loaded
    doesn't need to be loaded again. Can be initialized using `Dx.Loaders.Dataloader.init/1`.
  - `loader` allows choosing a loader module. Defaults to `Dx.Loaders.Dataloader`.
  - `loader_options` are passed to `loader.init/1` function. See `Dx.Loaders.Dataloader` for options
    supported by the default loader.
  """

  alias Dx.{Engine, Result, Util}
  alias Dx.Evaluation, as: Eval

  @doc """
  Evaluates one or multiple predicates for one or multiple records and returns the results.

  Does not load any additional data.
  """
  def get(records, predicates, opts \\ []) do
    eval = Eval.from_options(opts)

    do_get(records, predicates, eval)
    |> Result.to_simple_if(not eval.return_cache?)
  end

  defp do_get(records, predicates, eval) when is_list(records) do
    Result.map(records, &do_get(&1, predicates, eval))
  end

  defp do_get(record, predicates, eval) when is_list(predicates) do
    Result.map(predicates, &Engine.resolve_predicate(&1, record, eval))
    |> Result.transform(&Util.Map.zip(predicates, &1))
  end

  defp do_get(record, predicate, eval) when is_atom(predicate) do
    Engine.resolve_predicate(predicate, record, eval)
  end

  defp do_get(record, result, eval) do
    Engine.map_result(result, %{eval | root_subject: record})
  end

  @doc """
  Like `get/3` but returns the result value, or raises an error.
  """
  def get!(records, predicates, opts \\ []) do
    get(records, predicates, opts)
    |> Result.unwrap!()
  end

  @doc """
  Like `get/3`, but loads additional data if needed.
  """
  def load(records, predicates, opts \\ []) do
    eval = Eval.from_options(opts)

    do_load(records, predicates, eval)
    |> Result.to_simple_if(not eval.return_cache?)
  end

  defp do_load(records, predicates, eval) do
    Eval.load_all_data_reqs(eval, fn eval ->
      do_get(records, predicates, eval)
    end)
  end

  @doc """
  Like `get!/3`, but loads additional data if needed.
  """
  def load!(records, predicates, opts \\ []) do
    load(records, predicates, opts)
    |> Result.unwrap!()
  end

  @doc """
  Loads the given predicate(s) for the given record(s) and merges the
  results into the `inferred` map field of the record(s), returning them.

  ## Options

  Same as for `get/3`.
  """
  def put(records, predicates, opts \\ []) do
    eval = Eval.from_options(opts)

    do_load(records, List.wrap(predicates), eval)
    |> Result.transform(&do_put(records, &1))
    |> Result.to_simple_if(not eval.return_cache?)
  end

  defp do_put(records, results) when is_list(records) do
    Util.Enum.zip(records, results, &do_put/2)
  end

  defp do_put(record, results) do
    Map.update!(record, :inferred, &Util.Map.maybe_merge(&1, results))
  end

  def put!(records, predicates, opts \\ []) do
    put(records, predicates, opts)
    |> Result.unwrap!()
  end

  defp get_type(%Ecto.Query{from: %{source: {_, type}}}), do: type
  defp get_type(%type{}), do: type
  defp get_type([%type{} | _]), do: type

  defp get_type(type) when is_atom(type) do
    Code.ensure_compiled(type)

    if Util.Module.has_function?(type, :dx_rules_for, 2) do
      type
    else
      raise ArgumentError, "Could not derive type from " <> inspect(type, pretty: true)
    end
  end

  @doc "Removes all elements not matching the given condition from the given list."
  def filter(records, condition, opts \\ []) when is_list(records) do
    eval = Eval.from_options(opts)

    do_filter(records, condition, eval)
  end

  defp do_filter(records, true, _eval) do
    records
  end

  defp do_filter(records, condition, eval) do
    if eval.debug?, do: IO.puts("Dx Filter: #{inspect(condition, pretty: true)}")

    Eval.load_all_data_reqs(eval, fn eval ->
      Result.filter_map(
        records,
        &Engine.evaluate_condition(condition, &1, %{eval | root_subject: &1})
      )
    end)
    |> Result.unwrap!()
  end

  @doc "Removes all elements matching the given condition from the given list."
  def reject(records, condition, opts \\ []) when is_list(records) do
    filter(records, {:not, condition}, opts)
  end

  @doc """
  Returns all records matching the given condition.

  ## Caveat

  In general, as much work as possible is done in the database.
  If possible, the condition is completely translated to an `Ecto.Query`
  so the database only returns matching records.
  All condition parts that can not be translated to an `Ecto.Query`, will be
  evaluated by `loading all remaining records`, and associations as needed,
  and evaluating the rules on them.
  """
  def query_all(queryable, condition, opts \\ []) do
    {queryable, condition, repo, eval} = build_query(queryable, condition, opts)

    queryable
    |> repo.all()
    |> do_filter(condition, eval)
    |> apply_select(eval)
  end

  @doc """
  Returns the first record matching the given condition.

  ## Options

  Same as for `query_all/3`.
  """
  def query_one(queryable, condition, opts \\ []) do
    {queryable, condition, repo, eval} = build_query(queryable, condition, opts)

    queryable
    |> repo.one!()
    |> do_filter(condition, eval)
    |> apply_select(eval)
  end

  defp build_query(queryable, condition, opts) do
    type = get_type(queryable)
    query_mod = type.dx_query_module()
    repo = type.dx_repo()

    {queryable, opts} = query_mod.apply_options(queryable, opts)

    eval = Eval.from_options(opts)
    {queryable, condition} = query_mod.apply_condition(queryable, condition, eval)

    if eval.debug? do
      sql = query_mod.to_sql(repo, queryable)
      IO.puts("Dx SQL: #{sql}")
    end

    {queryable, condition, repo, eval}
  end

  defp apply_select(records, %{select: nil}), do: records

  defp apply_select(records, %{select: mapping} = eval) do
    Eval.load_all_data_reqs(eval, fn eval ->
      Result.map(records, &Engine.map_result(mapping, %{eval | root_subject: &1}))
    end)
    |> Result.unwrap!()
  end
end
