defmodule Ash.Domain.Info do
  @moduledoc "Introspection tools for Ash.Domain"

  alias Ash.Error.Invalid.{NoSuchResource, ResourceNotAllowed}

  alias Spark.Dsl.Extension

  @doc """
  Gets the resources of a domain module.
  """
  @spec resources(Spark.Dsl.t() | Ash.Domain.t()) :: list(Ash.Resource.t())
  def resources(domain) do
    domain
    |> Extension.get_entities([:resources])
    |> Enum.map(& &1.resource)
  end

  @doc """
  Gets the resource references of a domain module. DO NOT USE AT COMPILE TIME.

  If you need the resource list at compile time, use `depend_on_resources/1`
  """
  @spec resource_references(Spark.Dsl.t() | Ash.Domain.t()) ::
          list(Ash.Domain.Dsl.ResourceReference.t())
  def resource_references(domain) do
    Extension.get_entities(domain, [:resources])
  end

  @doc """
  Determine what domain to use when interacting with a related resource.

  We choose the first domain found in the following order:

  * `relationship.domain`, i.e an explicitly configured domain for a relationship
  * `resource.domain`, i.e. the domain the resource declares
  * `subject.domain`, i.e. the domain of the query, changeset or action input (if it has one)
  * `default`, the default domain provided as the third argument
  """
  @spec related_domain(
          Ash.Resource.t() | Ash.Query.t() | Ash.Changeset.t() | Ash.ActionInput.t(),
          atom
          | Ash.Resource.Relationships.relationship()
          | [atom | Ash.Resource.Relationships.relationship()],
          Ash.Domain.t() | nil
        ) :: Ash.Domain.t()
  def related_domain(subject, relationship, default \\ nil)

  def related_domain(subject, [relationship], default) do
    related_domain(subject, relationship, default)
  end

  def related_domain(subject, [], default) do
    resource =
      if is_atom(subject) do
        subject
      else
        subject.resource
      end

    Ash.Resource.Info.domain(resource) || default
  end

  def related_domain(subject, [relationship | relationships], default) do
    resource =
      if is_atom(subject) do
        subject
      else
        subject.resource
      end

    relationship =
      if is_atom(relationship) do
        Ash.Resource.Info.relationship(resource, relationship)
      else
        relationship
      end

    subject_domain =
      case subject do
        %{domain: domain} -> domain
        _ -> nil
      end

    related_domain(
      relationship.destination,
      relationships,
      Ash.Resource.Info.domain(resource) || relationship.domain ||
        subject_domain || default
    )
  end

  def related_domain(subject, relationship, default) do
    resource =
      if is_atom(subject) do
        subject
      else
        subject.resource
      end

    relationship =
      if is_atom(relationship) do
        Ash.Resource.Info.relationship(resource, relationship)
      else
        relationship
      end

    subject_domain =
      case subject do
        %{domain: domain} -> domain
        _ -> nil
      end

    Ash.Resource.Info.domain(relationship.destination) || relationship.domain || subject_domain ||
      default
  end

  def find_manage_relationships_with_identity_not_configured(otp_app) do
    otp_app
    |> Application.get_env(:ash_domains, [])
    |> Enum.flat_map(&Ash.Domain.Info.resources/1)
    |> Enum.flat_map(fn resource ->
      resource
      |> Ash.Resource.Info.actions()
      |> Enum.flat_map(fn action ->
        action
        |> Map.get(:changes, [])
        |> Enum.flat_map(fn
          %{change: {Ash.Resource.Change.ManageRelationship, opts}} ->
            related = Ash.Resource.Info.related(resource, opts[:relationship])
            identities = Ash.Resource.Info.identities(related)
            argument = Enum.find(action.arguments, &(&1.name == opts[:argument]))

            if argument && map_type?(argument.type) && !Enum.empty?(identities) do
              [{resource, action.name, opts[:argument]}]
            else
              []
            end

          _ ->
            []
        end)
      end)
    end)
    |> Enum.group_by(
      fn {resource, action, _} ->
        {resource, action}
      end,
      &elem(&1, 2)
    )
    |> Enum.map_join("\n\n", fn {{resource, action}, args} ->
      "#{inspect(resource)}.#{action}:\n" <>
        Enum.map_join(args, "\n", fn arg ->
          "* #{arg}"
        end)
    end)
  end

  defp map_type?({:array, type}) do
    map_type?(type)
  end

  defp map_type?(:map), do: true
  defp map_type?(Ash.Type.Map), do: true

  defp map_type?(type) do
    if Ash.Type.embedded_type?(type) do
      if is_atom(type) && :erlang.function_exported(type, :admin_map_type?, 0) do
        type.admin_map_type?()
      else
        false
      end
    else
      false
    end
  end

  @doc """
  Gets the resources of a domain module. Can be used at compile time.

  Liberal use of this can greatly increase compile times, or even cause compiler deadlocks.
  Use with care.
  """
  @spec depend_on_resources(Macro.t()) :: Macro.t()
  @deprecated "Use `Ash.Domain.Info.resources/1` instead. This macro is no longer necessary"
  defmacro depend_on_resources(domain) do
    quote do
      Code.ensure_compiled!(unquote(domain))

      for resource <- Ash.Domain.Info.resources(unquote(domain)) do
        Code.ensure_compiled!(resource)
        # same note as above
        Kernel.LexicalTracker.remote_dispatch(__ENV__.lexical_tracker, resource, :compile)
        resource
      end
    end
  end

  @doc """
  The description of the domain
  """
  @spec description(Spark.Dsl.t() | Ash.Domain.t()) :: String.t() | nil
  def description(domain) do
    Extension.get_opt(domain, [:domain], :description, nil)
  end

  @doc "The allow MFA for a domain"
  @spec allow(Ash.Domain.t() | Spark.Dsl.t()) :: mfa() | nil
  def allow(domain) do
    Extension.get_opt(domain, [:resources], :allow, nil, true)
  end

  @doc "The execution timeout for a domain"
  @spec timeout(Ash.Domain.t()) :: nil | :infinity | integer()
  def timeout(domain) do
    Extension.get_opt(domain, [:execution], :timeout, :infinity, true)
  end

  @doc "The short name for a domain"
  @spec short_name(Ash.Domain.t()) :: atom
  def short_name(domain) do
    Extension.get_opt(domain, [:execution], :short_name, nil) || domain.default_short_name()
  end

  @doc "The trace name for a domain"
  @spec trace_name(Ash.Domain.t()) :: String.t()
  def trace_name(domain) do
    Extension.get_opt(domain, [:execution], :trace_name, nil) || to_string(short_name(domain))
  end

  @doc "The span_name for a domain and resource combination"
  @spec span_name(Ash.Domain.t(), Ash.Resource.t(), action :: atom | binary()) :: String.t()
  def span_name(domain, resource, action) do
    "#{trace_name(domain)}:#{Ash.Resource.Info.trace_name(resource)}.#{action}"
  end

  @doc "Names a telemetry event for a given domain/resource combo"
  @spec telemetry_event_name(Ash.Domain.t(), atom | list(atom)) :: list(atom)
  def telemetry_event_name(domain, name) do
    List.flatten([:ash, short_name(domain), name])
  end

  @doc "Whether or not the actor is always required for a domain"
  @spec require_actor?(Ash.Domain.t()) :: boolean
  def require_actor?(domain) do
    Extension.get_opt(domain, [:authorization], :require_actor?, false, true)
  end

  @doc "When authorization should happen for a given domain"
  @spec authorize(Ash.Domain.t()) :: :always | :by_default | :when_requested
  def authorize(domain) do
    Extension.get_opt(domain, [:authorization], :authorize, :by_default, true)
  end

  @doc "Whether or not the domain allows unregistered resources to be used with it"
  @spec allow_unregistered?(Ash.Domain.t() | Spark.Dsl.t()) :: atom | nil
  def allow_unregistered?(domain) do
    Extension.get_opt(domain, [:resources], :allow_unregistered?, nil)
  end

  @doc """
  Returns `{:ok, resource}` if the resource can be used by the domain, or `{:error, error}`.
  """
  def resource(domain, resource) do
    cond do
      allow_unregistered?(domain) ->
        if Spark.Dsl.is?(resource, Ash.Resource) do
          resource
        else
          nil
        end

      Ash.Resource.Info.embedded?(resource) ->
        resource

      true ->
        domain
        |> resources()
        |> Enum.find(&(&1 == resource))
    end
    |> case do
      nil ->
        if allowed?(allow(domain), resource) do
          {:ok, resource}
        else
          if Ash.Resource.Info.resource?(resource) do
            domain =
              case domain do
                domain when is_atom(domain) -> domain
                dsl -> Extension.get_persisted(dsl, :module)
              end

            {:error, ResourceNotAllowed.exception(domain: domain, resource: resource)}
          else
            {:error, NoSuchResource.exception(resource: resource)}
          end
        end

      resource ->
        {:ok, resource}
    end
  end

  @doc """
  Returns a list of extensions in use by the domain.

  ## Options
  - `:include_resource_extensions?` - If `true`, includes extensions defined on
    resources in the domain. Defaults to `false`.
  """
  @spec extensions(domain :: Ash.Domain.t(), opts :: [include_resource_extensions?: boolean()]) ::
          list(module())
  def extensions(domain, opts \\ []) do
    if opts[:include_resource_extensions?] do
      domain
      |> resources()
      |> Enum.flat_map(&Ash.Resource.Info.extensions/1)
      |> Kernel.++(Spark.extensions(domain))
      |> Enum.uniq()
    else
      Spark.extensions(domain)
    end
  end

  @spec allowed?(mfa | nil, module()) :: boolean
  defp allowed?({m, f, a}, resource) do
    apply(m, f, List.wrap(a) ++ [resource])
  end

  defp allowed?(_, _), do: false
end
