defmodule Doctor.ModuleInformation do
  @moduledoc """
  This module defines a struct which houses all the
  documentation data for an entire module.
  """

  alias __MODULE__
  alias Doctor.{Docs, Specs}

  @type t :: %ModuleInformation{
          module: module(),
          behaviours: [module()],
          file_full_path: String.t(),
          file_relative_path: String.t(),
          file_ast: list(),
          docs_version: atom(),
          module_doc: map(),
          metadata: map(),
          docs: [%Docs{}],
          specs: list(),
          user_defined_functions: [{atom(), integer(), atom() | boolean()}],
          struct_type_spec: atom() | boolean(),
          properties: Keyword.t()
        }

  defstruct ~w(
    module
    file_full_path
    file_relative_path
    file_ast
    docs_version
    module_doc
    metadata
    docs
    specs
    user_defined_functions
    behaviours
    struct_type_spec
    properties
  )a

  @doc """
  Breaks down the docs format entry returned from Code.fetch_docs(MODULE)
  """
  def build({docs_version, _annotation, _language, _format, module_doc, metadata, docs}, module) do
    {:ok, module_specs} = Code.Typespec.fetch_specs(module)

    %ModuleInformation{
      module: module,
      behaviours: get_module_behaviours(module),
      file_full_path: get_full_file_path(module),
      file_relative_path: get_relative_file_path(module),
      file_ast: nil,
      docs_version: docs_version,
      module_doc: module_doc,
      metadata: metadata,
      docs: Enum.map(docs, &Docs.build/1),
      specs: Enum.map(module_specs, &Specs.build/1),
      user_defined_functions: nil,
      struct_type_spec: contains_struct_type_spec?(module),
      properties: [
        is_exception: is_exception?(module),
        is_protocol_implementation: is_protocol_implementation?(module)
      ]
    }
  end

  @doc """
  Given the provided module, read the file from which the module was generated and
  convert the file to an AST.
  """
  def load_file_ast(%ModuleInformation{} = module_info) do
    ast =
      module_info.file_full_path
      |> File.read!()
      |> Code.string_to_quoted!()

    %{module_info | file_ast: ast}
  end

  @doc """
  Checks the provided module for a __struct__ function which is injected into the module
  whenever you use `defstruct`
  """
  def contains_struct_type_spec?(module) do
    cond do
      is_exception?(module) ->
        :not_struct

      is_struct?(module) ->
        {:ok, specs} = Code.Typespec.fetch_types(module)

        Enum.any?(specs, fn
          {:type, {:t, _, _}} -> true
          {:opaque, {:t, _, _}} -> true
          _ -> false
        end)

      true ->
        :not_struct
    end
  end

  defp is_struct?(module) do
    function_exported?(module, :__struct__, 0) or function_exported?(module, :__struct__, 1)
  end

  defp is_exception?(module) when is_atom(module) do
    function_exported?(module, :__struct__, 0) and :__exception__ in Map.keys(module.__struct__())
  end

  defp is_protocol_implementation?(module) when is_atom(module) do
    function_exported?(module, :__impl__, 1)
  end

  @doc """
  Given a ModuleInformation struct with the AST loaded, fetch all of the author defined functions
  """
  def load_user_defined_functions(%ModuleInformation{} = module_info) do
    {_ast, %{modules: modules}} =
      Macro.traverse(
        module_info.file_ast,
        %{modules: %{}, stack: []},
        &parse_ast_node_for_defmodules/2,
        &pop_module_stack/2
      )

    {_ast, %{functions: functions}} =
      modules
      |> Map.get(module_info.module)
      |> Macro.traverse(
        %{functions: [], last_impl: :none, nesting_level: 0},
        &parse_ast_node_for_def/2,
        &unnest/2
      )

    %{module_info | user_defined_functions: Enum.uniq(functions)}
    |> load_using_docs_and_specs(modules)
  end

  defp load_using_docs_and_specs(%ModuleInformation{} = module_info, modules) do
    {_ast, using} =
      modules
      |> Map.get(module_info.module)
      |> Macro.prewalk(%{using: :none}, &parse_ast_for_using/2)

    acc = %{
      last_doc: :none,
      last_spec: :none,
      using_docs: [],
      using_specs: []
    }

    {_ast, extra} =
      using[:using]
      |> Macro.prewalk(acc, &parse_ast_using_node/2)

    %{
      module_info
      | specs: module_info.specs ++ extra.using_specs,
        docs: module_info.docs ++ extra.using_docs
    }
  end

  defp get_module_behaviours(module) do
    {_module, bin, _beam_file_path} = :code.get_object_code(module)

    case :beam_lib.chunks(bin, [:attributes]) do
      {:ok, {^module, attributes}} ->
        attributes
        |> Keyword.get(:attributes, [])
        |> Keyword.get(:behaviour, [])

      _ ->
        []
    end
  end

  defp get_full_file_path(module) do
    module.module_info()
    |> Keyword.get(:compile)
    |> Keyword.get(:source)
    |> to_string()
  end

  defp get_relative_file_path(module) do
    module
    |> get_full_file_path()
    |> Path.relative_to(File.cwd!())
  end

  defp parse_ast_node_for_def({definition, _defmodule_line, _body} = ast, %{nesting_level: level} = acc)
       when definition in [:defimpl, :defmodule, :defprotocol] do
    {ast, Map.put(acc, :nesting_level, level + 1)}
  end

  defp parse_ast_node_for_def(ast, %{nesting_level: level} = acc) when level > 1 do
    {ast, acc}
  end

  defp parse_ast_node_for_def({:@, _line_number, [{:doc, _, [false]}]} = ast, acc) do
    updated_acc = Map.put(acc, :last_impl, false)

    {ast, updated_acc}
  end

  defp parse_ast_node_for_def({:@, _line_number, [{:impl, _, impl_def}]} = ast, acc) do
    normalized_impl = normalize_impl(impl_def)
    updated_acc = Map.put(acc, :last_impl, normalized_impl)

    {ast, updated_acc}
  end

  defp parse_ast_node_for_def(
         {:def, _def_line, [{:when, _line_when, [{function_name, _function_line, args}, _guard]}, _do_block]} = ast,
         %{last_impl: impl} = acc
       ) do
    function_arity = get_function_arity(args)

    updated_acc = update_acc_for_def(acc, function_name, function_arity, impl)

    {ast, updated_acc}
  end

  defp parse_ast_node_for_def(
         {:def, _def_line, [{function_name, _function_line, args}, _do_block]} = ast,
         %{last_impl: impl} = acc
       ) do
    function_arity = get_function_arity(args)

    updated_acc = update_acc_for_def(acc, function_name, function_arity, impl)

    {ast, updated_acc}
  end

  defp parse_ast_node_for_def(
         {:def, _def_line, [{function_name, _function_line, args}]} = ast,
         %{last_impl: impl} = acc
       ) do
    function_arity = get_function_arity(args)

    updated_acc = update_acc_for_def(acc, function_name, function_arity, impl)

    {ast, updated_acc}
  end

  defp parse_ast_node_for_def(ast, acc) do
    {ast, acc}
  end

  defp unnest({definition, _defmodule_line, _body} = ast, %{nesting_level: level} = acc)
       when definition in [:defmodule, :defprotocol] do
    {ast, Map.put(acc, :nesting_level, level - 1)}
  end

  defp unnest(ast, acc) do
    {ast, acc}
  end

  defp update_acc_for_def(acc, function_name, function_arity, last_impl) do
    impl =
      case last_impl do
        :none ->
          acc[:functions]
          |> Enum.filter(fn {name, arity, _impl} -> name == function_name and arity == function_arity end)
          |> Enum.at(0, {function_name, function_arity, :none})
          |> Kernel.elem(2)

        last_impl ->
          last_impl
      end

    acc
    |> Map.put(:last_impl, :none)
    |> Map.update(:functions, [], fn functions ->
      [{function_name, function_arity, impl} | functions]
    end)
  end

  defp normalize_impl([value]) when is_boolean(value) do
    value
  end

  defp normalize_impl([{:__aliases__, _, module}]) do
    Module.concat(module)
  end

  defp normalize_impl(value) do
    value
  end

  defp parse_ast_node_for_defmodules(
         {definition, _defmodule_line, [{:__aliases__, _line_num, module}, _do_block]} = ast,
         %{modules: modules, stack: stack} = acc
       )
       when definition in [:defmodule, :defprotocol] do
    parent = List.first(stack)
    full_module_name = Module.concat(List.wrap(parent) ++ module)

    updated_acc =
      acc
      |> Map.put(:modules, Map.put(modules, full_module_name, ast))
      |> Map.put(:stack, [full_module_name | stack])

    {ast, updated_acc}
  end

  defp parse_ast_node_for_defmodules(ast, acc) do
    {ast, acc}
  end

  defp pop_module_stack(
         {definition, _defmodule_line, _body} = ast,
         %{stack: [_current | rest]} = acc
       )
       when definition in [:defmodule, :defprotocol] do
    {ast, Map.put(acc, :stack, rest)}
  end

  defp pop_module_stack(ast, acc) do
    {ast, acc}
  end

  defp get_function_arity(nil), do: 0
  defp get_function_arity(args), do: length(args)

  defp parse_ast_for_using({:defmacro, _macro_line, [{:__using__, _line, _args}, do_block]} = ast, _acc),
    do: {ast, %{using: do_block}}

  defp parse_ast_for_using(ast, acc), do: {ast, acc}

  defp parse_ast_using_node(
         {:@, _doc_line, [{:doc, _line, [doc]}]} = ast,
         acc
       ),
       do: {ast, Map.put(acc, :last_doc, doc)}

  defp parse_ast_using_node(
         {:@, _spec_line, [{:spec, _line, _spec_info}]} = ast,
         acc
       ),
       do: {ast, Map.put(acc, :last_spec, true)}

  defp parse_ast_using_node(
         {:def, _def_line, [{:when, _line_when, [{function_name, _function_line, args}, _guard]}, _do_block]} = ast,
         acc
       ) do
    {ast, update_acc_for_using(function_name, args, acc)}
  end

  defp parse_ast_using_node(
         {:def, _def_line, [{function_name, _function_line, args}, _do_block]} = ast,
         acc
       ) do
    {ast, update_acc_for_using(function_name, args, acc)}
  end

  defp parse_ast_using_node(
         {:def, _def_line, [{function_name, _function_line, args}]} = ast,
         acc
       ) do
    {ast, update_acc_for_using(function_name, args, acc)}
  end

  defp parse_ast_using_node(ast, acc), do: {ast, acc}

  defp update_acc_for_using(function_name, args, acc) do
    function_arity = get_function_arity(args)

    function_spec =
      if acc.last_spec != :none do
        [%Doctor.Specs{arity: function_arity, name: function_name}]
      else
        []
      end

    function_doc =
      if acc.last_doc != :none do
        [
          %Doctor.Docs{
            arity: function_arity,
            doc: %{"en" => acc.last_doc},
            kind: :function,
            name: function_name
          }
        ]
      else
        []
      end

    %{
      last_doc: :none,
      last_spec: :none,
      using_docs: acc.using_docs ++ function_doc,
      using_specs: acc.using_specs ++ function_spec
    }
  end
end
