defmodule Zephyr do
  @moduledoc """
  Documentation for `Zephyr`.
  """
  alias Ecto.Changeset.Relation
  alias Zephyr.Helpers
  alias Zephyr.QueryBuilder
  alias Zephyr.RelationResolver
  alias Zephyr.RelationTuple

  @type subject :: {namespace :: String.t(), key :: any(), predicate :: String.t()}
  @type object :: {namespace :: String.t(), key :: any(), predicate :: String.t()}

  @doc """
  List subjects who has relation to the object
  """
  @spec read(
          object :: Ecto.Schema.t(),
          relation :: String.t(),
          repo_opts :: Keyword.t()
        ) :: [subject()]
  def read(object, relation, repo_opts \\ []) do
    object_source = object.__struct__.__schema__(:source)
    {repo, repo_opts} = fetch_repo_opts(repo_opts)

    object_source
    |> Helpers.get_definition()
    |> RelationResolver.resolve_relation(String.to_atom(relation))
    |> QueryBuilder.build_query(object)
    |> repo.all(repo_opts)
    |> Enum.map(&{&1.subject_namespace, &1.subject_key, &1.subject_predicate})
  end

  @doc """
  Checks if the given subject has access to the given object based on the given relation
  """
  @spec check(
          object :: Ecto.Schema.t(),
          relation :: String.t(),
          subject :: Ecto.Schema.t(),
          repo_opts :: Keyword.t()
        ) ::
          boolean()
  def check(object, relation, subject, repo_opts \\ []) do
    object_source = object.__struct__.__schema__(:source)
    subject_source = subject.__struct__.__schema__(:source)
    {repo, repo_opts} = fetch_repo_opts(repo_opts)

    object_source
    |> Helpers.get_definition()
    |> RelationResolver.resolve_relation(String.to_atom(relation))
    |> QueryBuilder.build_query(object)
    |> repo.all(repo_opts)
    |> Enum.map(&{&1.subject_namespace, &1.subject_key})
    |> Enum.member?({subject_source, subject.id})
  end

  @doc """
  Inserts a new relation between the given subject and object
  """
  @spec write(
          subject :: subject(),
          object :: object(),
          repo_opts :: Keyword.t()
        ) ::
          {:ok, Relation.t()} | {:error, Ecto.Changeset.t()}
  def write(subject, object, repo_opts \\ []) do
    {repo, repo_opts} = fetch_repo_opts(repo_opts)

    subject
    |> change_relation_tuple(object)
    |> repo.insert(repo_opts)
  end

  @doc """
  Similar to write/2 but raises an error if the relation could not be inserted
  """
  @spec write!(
          subject :: subject(),
          object :: object(),
          repo_opts :: Keyword.t()
        ) ::
          Relation.t()
  def write!(subject, object, repo_opts \\ []) do
    {repo, repo_opts} = fetch_repo_opts(repo_opts)

    subject
    |> change_relation_tuple(object)
    |> repo.insert!(repo_opts)
  end

  defp change_relation_tuple(
         {subject_namespace, subject_key, subject_predicate},
         {object_namespace, object_key, object_predicate}
       ) do
    %RelationTuple{}
    |> RelationTuple.changeset(%{
      subject_namespace: subject_namespace,
      subject_key: subject_key,
      subject_predicate: subject_predicate,
      object_namespace: object_namespace,
      object_key: object_key,
      object_predicate: object_predicate
    })
  end

  defp fetch_repo_opts(opts) do
    {opts[:repo] || repo!(), Keyword.take(opts, [:prefix])}
  end

  defp repo! do
    Application.get_env(:zephyr, :repo) || raise "Repo not configured"
  end
end
