defmodule Absinthe.Federation.Schema.EntitiesField do
  @moduledoc false

  alias Absinthe.Adapter.LanguageConventions
  alias Absinthe.Blueprint
  alias Absinthe.Blueprint.Schema.FieldDefinition
  alias Absinthe.Blueprint.Schema.InputValueDefinition
  alias Absinthe.Blueprint.TypeReference.List, as: ListType
  alias Absinthe.Blueprint.TypeReference.Name
  alias Absinthe.Blueprint.TypeReference.NonNull
  alias Absinthe.Schema.Notation

  # TODO: Fix __reference__ typespec upstream in absinthe
  @type input_value_definition :: %InputValueDefinition{
          name: String.t(),
          description: nil | String.t(),
          type: Blueprint.TypeReference.t(),
          default_value: nil | Blueprint.Input.t(),
          default_value_blueprint: Blueprint.Draft.t(),
          directives: [Blueprint.Directive.t()],
          source_location: nil | Blueprint.SourceLocation.t(),
          # The struct module of the parent
          placement: :argument_definition | :input_field_definition,
          # Added by phases
          flags: Blueprint.flags_t(),
          errors: [Absinthe.Phase.Error.t()],
          __reference__: nil | map()
        }

  @type field_definition :: %FieldDefinition{
          name: String.t(),
          identifier: atom,
          description: nil | String.t(),
          deprecation: nil | Blueprint.Schema.Deprecation.t(),
          arguments: [input_value_definition()],
          type: Blueprint.TypeReference.t(),
          directives: [Blueprint.Directive.t()],
          source_location: nil | Blueprint.SourceLocation.t(),
          # Added by DSL
          description: nil | String.t(),
          middleware: [any],
          # Added by phases
          flags: Blueprint.flags_t(),
          errors: [Absinthe.Phase.Error.t()],
          triggers: [],
          module: nil | module(),
          function_ref: nil | function(),
          default_value: nil | any(),
          config: nil,
          complexity: nil,
          __reference__: nil | map(),
          __private__: []
        }

  @spec build() :: field_definition()
  def build() do
    %FieldDefinition{
      __reference__: Notation.build_reference(__ENV__),
      description: """
      Returns a non-nullable list of _Entity types
      and have a single argument with an argument name of representations
      and type [_Any!]! (non-nullable list of non-nullable _Any scalars).
      The _entities field on the query root must allow a list of _Any scalars
      which are "representations" of entities from external services.
      These representations should be validated with the following rules:

      - Any representation without a __typename: String field is invalid.
      - Representations must contain at least the fields defined in the fieldset of a @key directive on the base type.
      """,
      identifier: :_entities,
      module: __MODULE__,
      name: "_entities",
      type: %NonNull{
        of_type: %ListType{
          of_type: %Name{
            name: "_Entity"
          }
        }
      },
      middleware: [__MODULE__],
      arguments: build_arguments()
    }
  end

  def call(%{state: :unresolved} = resolution, _args) do
    resolution_acc = resolution_accumulator(resolution)

    # Run pre-resolution plugins, such as async/batch and dataloader.
    resolution_acc = run_callbacks(resolution_acc.schema.plugins(), :before_resolution, resolution_acc)

    representations_to_resolve =
      Enum.map(resolution.arguments.representations, &resolve_reference_field(&1, resolution_acc))

    # Resolve representations first time
    resolution_acc = Enum.reduce(representations_to_resolve, resolution_acc, &resolve_field_representation/2)

    # Run post-resolution plugins
    resolution_acc = run_callbacks(resolution_acc.schema.plugins(), :after_resolution, resolution_acc)

    # If any representation fields are suspended (i.e async or dataloaded),
    # run the plugins and resolve_representation pipeline again.
    resolution_acc =
      if Enum.any?(resolution_acc.path, &(&1.state == :suspended)) do
        representations_to_resolve = resolution_acc.path

        resolution_acc =
          run_callbacks(resolution_acc.schema.plugins(), :before_resolution, resolution_acc)
          |> Map.put(:path, [])

        resolution_acc = Enum.reduce(representations_to_resolve, resolution_acc, &resolve_field_representation/2)

        run_callbacks(resolution_acc.schema.plugins(), :after_resolution, resolution_acc)
      else
        resolution_acc
      end

    representations = resolution_acc.path

    # Collect values and errors
    resolution_acc =
      Enum.reduce(representations, resolution_acc, fn representation, acc ->
        representation_errors = representation.errors
        representation_value = representation.value

        acc
        |> Map.update!(:value, fn value -> [representation_value | value] end)
        |> Map.update!(:errors, fn errors -> representation_errors ++ errors end)
      end)
      |> then(&reverse_values_and_errors/1)
      |> then(&set_final_state/1)

    %{resolution | state: resolution_acc.state, value: resolution_acc.value, errors: resolution_acc.errors}
  end

  defp reverse_values_and_errors(res) do
    res
    |> Map.update!(:value, &Enum.reverse/1)
    |> Map.update!(:errors, &Enum.reverse/1)
  end

  defp set_final_state(res) do
    if Enum.all?(res.path, &(&1.state == :resolved)) do
      Map.put(res, :state, :resolved)
    else
      paths = Enum.map(res.path, &%{state: &1.state, value: &1.value, errors: &1.errors})
      raise "Expected all fields to be resolved, but got: #{inspect(paths)}"
    end
  end

  # These are the fields to be threaded through every single representation resolution.
  defp resolution_accumulator(resolution) do
    %{resolution | path: [], errors: [], value: []}
  end

  # Resolve a single representation and accumulate it to field resolution
  # under path key. If a field is already resolved, do not run any middlewares.
  defp resolve_field_representation(representation, acc) do
    if representation.state == :resolved do
      new_path = acc.path ++ [representation]

      acc
      |> Map.put(:path, new_path)
    else
      local_res =
        representation
        |> Map.put(:context, acc.context)
        |> Map.put(:acc, acc.acc)

      result = reduce_resolution(local_res)
      new_path = acc.path ++ [result]

      acc
      |> Map.put(:context, result.context)
      |> Map.put(:acc, result.acc)
      |> Map.put(:path, new_path)
    end
  end

  defp resolve_reference_field(representation, resolution_acc) do
    typename = Map.get(representation, "__typename")

    %Absinthe.Resolution{
      schema: schema,
      source: source,
      context: context,
      adapter: adapter,
      definition: definition,
      parent_type: parent_type
    } = resolution_acc

    args = convert_keys_to_atom(representation, context)

    field =
      schema
      |> Absinthe.Schema.lookup_type(typename)
      |> resolve_representation(representation)

    %Absinthe.Resolution{
      arguments: args,
      schema: schema,
      definition: definition,
      parent_type: parent_type,
      source: source,
      state: :unresolved,
      value: nil,
      errors: [],
      middleware: field.middleware,
      context: context,
      adapter: adapter
    }
  end

  defp resolve_representation(%struct_type{fields: fields}, _representation)
       when struct_type in [Absinthe.Type.Object, Absinthe.Type.Interface] do
    resolve_reference(fields[:_resolve_reference])
  end

  defp resolve_representation(_schema_type, representation),
    do:
      {:error,
       "The _entities resolver tried to load an entity for type '#{Map.get(representation, "__typename")}', but no object type of that name was found in the schema"}

  defp resolve_reference(field) do
    # When there is a field _resolve_reference, set it up so the resolution pipeline can be run
    # on it.
    if field do
      field
    else
      # When there is no field name _resolve_reference defined on the key object, create
      # a stub middleware that returns arguments as the field resolution.
      middleware = {{Absinthe.Resolution, :call}, fn args, _res -> {:ok, args} end}
      %Absinthe.Resolution{middleware: [middleware], state: :unresolved, value: nil}
    end
  end

  defp convert_keys_to_atom(map, context) when is_map(map) do
    Map.new(map, fn {k, v} ->
      k = convert_key(k, context)
      v = convert_keys_to_atom(v, context)
      {k, v}
    end)
  end

  defp convert_keys_to_atom(v, _context), do: v

  defp convert_key(k, context) do
    adapter = Map.get(context, :adapter, LanguageConventions)

    if adapter_has_to_internal_name_modifier?(adapter) do
      adapter.to_internal_name(k, :field)
    else
      k
    end
    |> String.to_atom()
  end

  defp run_callbacks(plugins, callback, acc) do
    Enum.reduce(plugins, acc, &apply(&1, callback, [&2]))
  end

  defp reduce_resolution(%Absinthe.Resolution{middleware: []} = res), do: res

  defp reduce_resolution(%Absinthe.Resolution{middleware: [middleware | remaining_middleware]} = res) do
    case call_middleware(middleware, %{res | middleware: remaining_middleware}) do
      %{state: :suspended} = res ->
        res

      res ->
        reduce_resolution(res)
    end
  end

  defp call_middleware({{mod, fun}, opts}, res) do
    apply(mod, fun, [res, opts])
  end

  defp call_middleware({mod, opts}, res) do
    apply(mod, :call, [res, opts])
  end

  defp call_middleware(mod, res) when is_atom(mod) do
    apply(mod, :call, [res, []])
  end

  defp call_middleware(fun, res) when is_function(fun, 2) do
    fun.(res, [])
  end

  defp adapter_has_to_internal_name_modifier?(adapter) do
    Keyword.get(adapter.__info__(:functions), :to_internal_name) == 2
  end

  defp build_arguments(), do: [build_argument()]

  defp build_argument(),
    do: %InputValueDefinition{
      __reference__: Notation.build_reference(__ENV__),
      identifier: :representations,
      module: __MODULE__,
      name: "representations",
      placement: :argument_definition,
      type: %NonNull{
        of_type: %ListType{
          of_type: %NonNull{
            of_type: %Name{
              name: "_Any"
            }
          }
        }
      }
    }
end
