defmodule Ecto.ERD.Graph do
  @moduledoc false
  alias Ecto.ERD.{Edge, Node, Field}
  defstruct [:edges, :nodes]
  require Logger
  @type t :: %__MODULE__{}

  def new(modules, relation_types) do
    data =
      modules
      |> Enum.flat_map(fn module -> components(module, relation_types) end)
      |> Enum.group_by(fn
        %Edge{} -> :edges
        %Node{} -> :nodes
      end)

    %__MODULE__{
      # multiple nodes could be generated by multiple schemas which use the same table in many to many relation
      nodes: Enum.uniq(Map.get(data, :nodes, [])),
      edges: merge_edges_with_same_direction(Map.get(data, :edges, []))
    }
  end

  def sort(%__MODULE__{nodes: nodes, edges: edges}) do
    %__MODULE__{
      nodes: Enum.sort_by(nodes, &{&1.cluster, &1.schema_module, &1.source}),
      edges: Enum.sort_by(edges, &{&1.from, &1.to})
    }
  end

  def map_nodes(%__MODULE__{nodes: nodes, edges: edges}, map_node_callback)
      when is_function(map_node_callback, 1) do
    {nodes, removed_nodes} =
      Enum.flat_map_reduce(nodes, [], fn node, removed_nodes ->
        case map_node_callback.(node) do
          nil -> {[], [node | removed_nodes]}
          node -> {[node], removed_nodes}
        end
      end)

    edges =
      Enum.reject(edges, fn edge ->
        Enum.any?(removed_nodes, fn node -> Edge.connected_with_node?(edge, node) end)
      end)

    %__MODULE__{
      nodes: List.wrap(nodes),
      edges: edges
    }
  end

  # it, actually, doesn't remove ALL information about schemas, but only in duplicated entities -
  # when nodes have the same source or when edges are equal by source and port.
  def make_schemaless(%__MODULE__{nodes: nodes, edges: edges}) do
    nodes =
      nodes
      |> Enum.group_by(fn %Node{source: source} -> source end)
      |> Map.delete(nil)
      |> Enum.map(fn {_source, nodes} -> Enum.reduce(nodes, &Node.merge_to_schemaless/2) end)

    edges =
      edges
      |> Enum.uniq_by(fn %Edge{from: {source1, _module1, port1}, to: {source2, _module2, port2}} ->
        {{source1, port1}, {source2, port2}}
      end)

    %__MODULE__{
      nodes: nodes,
      edges: edges
    }
  end

  defp merge_edges_with_same_direction(edges) do
    edges
    |> Enum.group_by(fn %Edge{from: from, to: to} -> {from, to} end)
    |> Enum.map(fn {_direction, edges} -> Enum.reduce(edges, &Edge.merge/2) end)
  end

  defp components(schema_module, relation_types) do
    if ecto_schema?(schema_module) do
      relation_components =
        Enum.flat_map(relation_types, &components_from_relations(schema_module, &1))

      primary_keys = schema_module.__schema__(:primary_key)

      node =
        Node.new(
          schema_module,
          schema_module.__schema__(:source),
          Enum.map(
            schema_module.__schema__(:fields),
            fn field ->
              Field.new(%{
                name: field,
                type: schema_module.__schema__(:type, field),
                primary?: field in primary_keys
              })
            end
          )
        )

      [node | relation_components]
    else
      Logger.warning("Skipping schema #{schema_module}: not found")
      []
    end
  end

  defp components_from_relations(module, :associations) do
    :associations
    |> module.__schema__()
    |> Enum.flat_map(fn assoc_field ->
      from_relation_struct(module.__schema__(:association, assoc_field))
    end)
  end

  defp components_from_relations(module, :embeds) do
    :embeds
    |> module.__schema__()
    |> Enum.flat_map(fn embed_field ->
      from_relation_struct(module.__schema__(:embed, embed_field))
    end)
  end

  defp from_relation_struct(%Ecto.Embedded{
         owner: owner,
         related: related,
         field: field,
         cardinality: cardinality
       }) do
    if ecto_schema?(related) do
      [
        Edge.new(%{
          from: {owner.__schema__(:source), owner, {:field, field}},
          to: {related.__schema__(:source), related, {:header, :schema_module}},
          assoc_types: [has: cardinality]
        })
      ]
    else
      Logger.warning(
        "Skipping association `embeds_#{cardinality} #{inspect(field)}` in schema #{inspect(owner)}: #{inspect(related)} is not an Ecto schema"
      )

      []
    end
  end

  defp from_relation_struct(%Ecto.Association.BelongsTo{
         owner: owner,
         owner_key: owner_key,
         related: related,
         related_key: related_key,
         field: field
       }) do
    if ecto_schema?(related) do
      related_source = related.__schema__(:source)
      owner_source = owner.__schema__(:source)

      [
        Edge.new(%{
          from: {related_source, related, {:field, related_key}},
          to: {owner_source, owner, {:field, owner_key}},
          assoc_types: [:belongs_to]
        })
      ]
    else
      Logger.warning(
        "Skipping association `belongs_to #{inspect(field)}` in schema #{inspect(owner)}: #{inspect(related)} is not an Ecto schema"
      )

      []
    end
  end

  defp from_relation_struct(%Ecto.Association.Has{
         owner: owner,
         owner_key: owner_key,
         related: related,
         related_key: related_key,
         cardinality: cardinality,
         field: field
       }) do
    if ecto_schema?(related) do
      related_source = related.__schema__(:source)
      owner_source = owner.__schema__(:source)

      [
        Edge.new(%{
          from: {owner_source, owner, {:field, owner_key}},
          to: {related_source, related, {:field, related_key}},
          assoc_types: [has: cardinality]
        })
      ]
    else
      Logger.warning(
        "Skipping association `has_#{cardinality} #{inspect(field)}` in schema #{inspect(owner)}: #{inspect(related)} is not an Ecto schema"
      )

      []
    end
  end

  defp from_relation_struct(%Ecto.Association.ManyToMany{
         join_through: join_through,
         owner: owner,
         field: field,
         related: related,
         join_keys: [{join_source_owner_fk, owner_pk}, {join_source_related_fk, related_pk}]
       }) do
    if ecto_schema?(related) do
      if is_atom(join_through) and not ecto_schema?(join_through) do
        Logger.warning(
          "Skipping association `many_to_many #{inspect(field)}` in schema #{inspect(owner)}: #{inspect(join_through)} is not an Ecto schema"
        )

        []
      else
        {join_module, join_source} =
          case join_through do
            value when is_atom(value) ->
              {value, value.__schema__(:source)}

            value when is_binary(value) ->
              {nil, value}
          end

        nodes =
          case join_module do
            nil ->
              fields = [
                Field.new(%{
                  name: join_source_owner_fk,
                  type: owner.__schema__(:type, owner_pk),
                  primary?: false
                }),
                Field.new(%{
                  name: join_source_related_fk,
                  type: related.__schema__(:type, related_pk),
                  primary?: false
                })
              ]

              [Node.new(join_source, Enum.sort(fields))]

            _join_module ->
              []
          end

        nodes ++
          [
            Edge.new(%{
              from: {owner.__schema__(:source), owner, {:field, owner_pk}},
              to: {join_source, join_module, {:field, join_source_owner_fk}},
              assoc_types: [has: :many]
            }),
            Edge.new(%{
              from: {related.__schema__(:source), related, {:field, related_pk}},
              to: {join_source, join_module, {:field, join_source_related_fk}},
              assoc_types: [has: :many]
            })
          ]
      end
    else
      Logger.warning(
        "Skipping association `many_to_many #{inspect(field)}` in schema #{inspect(owner)}: module #{inspect(related)} is not an Ecto schema"
      )

      []
    end
  end

  defp from_relation_struct(%Ecto.Association.HasThrough{}) do
    []
  end

  defp ecto_schema?(module) do
    function_exported?(module, :__schema__, 1)
  end
end
