defmodule MixUnused.Analyzer do
  @moduledoc """
  Reads compiled BEAM files via Erlang's built-in `:xref` cross-reference
  analyser and computes the set of public functions that no caller in the
  project ever invokes.

  This is more reliable than a tracer-based approach because it works against
  the already-compiled artifacts and doesn't require re-running the compiler
  with a tracer attached.
  """

  @type unused_entry :: %{
          module: module(),
          name: atom(),
          arity: arity(),
          file: String.t() | nil,
          line: pos_integer() | nil
        }

  @doc """
  Returns a sorted list of unused public functions discovered in the given
  compile path (defaults to `Mix.Project.compile_path/0`).

  ## Options

    * `:exclude` — list of `module` atoms or `{module, atom, arity}` triples
      that should never appear in the output.
    * `:compile_path` — the directory containing the project's `*.beam`
      files (defaults to `Mix.Project.compile_path/0`).
    * `:app` — only consider modules belonging to this OTP application
      (defaults to the current Mix project's `:app` setting).
  """
  @spec analyze(keyword()) :: [unused_entry()]
  def analyze(opts \\ []) do
    state = Keyword.get_lazy(opts, :precomputed_state, fn -> compute_state(opts) end)
    filter_state(state, opts)
  end

  @doc """
  Computes the heavy parts of analysis (project modules, defs, calls) once.
  Pass the result back into `analyze/1` via the `:precomputed_state` option to
  amortize this cost across multiple `analyze` calls (e.g. in tests that
  re-run analysis with different `:exclude` filters).
  """
  @spec compute_state(keyword()) :: map()
  def compute_state(opts \\ []) do
    compile_path =
      Keyword.get_lazy(opts, :compile_path, fn -> Mix.Project.compile_path() end)

    app = Keyword.get_lazy(opts, :app, fn -> Keyword.get(Mix.Project.config(), :app) end)

    project_modules = collect_project_modules(compile_path, app)
    project_module_set = MapSet.new(project_modules)

    %{
      defs: collect_definitions(project_modules),
      calls: collect_calls(project_modules),
      project_module_set: project_module_set
    }
  end

  defp filter_state(%{defs: defs, calls: calls, project_module_set: project_module_set}, opts) do
    excluded = Keyword.get(opts, :exclude, [])
    excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m)
    excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a})

    defs
    |> Enum.reject(fn {{m, f, a}, _meta} ->
      MapSet.member?(excluded_modules, m) or
        MapSet.member?(excluded_mfas, {m, f, a}) or
        MapSet.member?(calls, {m, f, a}) or
        skip_def?(m, f, a, project_module_set)
    end)
    |> Enum.map(fn {{m, f, a}, %{file: file, line: line}} ->
      %{module: m, name: f, arity: a, file: file, line: line}
    end)
    |> Enum.sort_by(fn e -> {e.module, e.name, e.arity} end)
  end

  # ── Project module discovery ──────────────────────────────────────────────

  defp collect_project_modules(compile_path, app) do
    case File.ls(compile_path) do
      {:ok, files} ->
        for file <- files,
            String.ends_with?(file, ".beam"),
            module = beam_to_module(file),
            in_app?(module, app),
            do: module

      _ ->
        []
    end
  end

  defp beam_to_module(file) do
    file
    |> String.replace_suffix(".beam", "")
    |> String.to_atom()
  end

  # When app is nil we can't filter by application — return all modules.
  defp in_app?(_module, nil), do: true

  defp in_app?(module, app) do
    case Application.get_application(module) do
      ^app -> true
      _ -> false
    end
  end

  # ── Definition collection ─────────────────────────────────────────────────

  defp collect_definitions(modules) do
    Enum.flat_map(modules, &module_definitions/1)
  end

  defp module_definitions(module) do
    Code.ensure_loaded(module)

    file = source_file(module)

    attrs = module.__info__(:attributes)
    impls = attrs |> Keyword.get_values(:impl) |> List.flatten()
    callbacks = attrs |> Keyword.get_values(:callback) |> List.flatten()
    behaviours = attrs |> Keyword.get_values(:behaviour) |> List.flatten()

    module
    |> module_exports()
    |> Enum.reject(fn {f, a} ->
      callback?(f, a, impls, callbacks, behaviours)
    end)
    |> Enum.map(fn {f, a} ->
      line = function_line(module, f, a)
      {{module, f, a}, %{file: file, line: line}}
    end)
  rescue
    _ -> []
  catch
    _, _ -> []
  end

  defp module_exports(module) do
    fns = module.__info__(:functions)
    macros = module.__info__(:macros)
    fns ++ macros
  rescue
    _ -> []
  end

  defp source_file(module) do
    case module.module_info(:compile)[:source] do
      nil -> nil
      source -> source |> List.to_string() |> normalize_path()
    end
  rescue
    _ -> nil
  end

  defp normalize_path(path) do
    cwd = File.cwd!()
    if String.starts_with?(path, cwd <> "/"), do: Path.relative_to_cwd(path), else: path
  end

  defp function_line(module, name, arity) do
    case module.module_info(:compile)[:source] do
      nil ->
        nil

      _source ->
        # Try to extract from BEAM debug_info if available.
        debug_info_line(module, name, arity)
    end
  rescue
    _ -> nil
  end

  defp debug_info_line(module, name, arity) do
    with {:ok, {_, [{:debug_info, {:debug_info_v1, backend, data}}]}} <-
           :beam_lib.chunks(beam_path(module), [:debug_info]),
         {:ok, %{definitions: defs}} <- backend.debug_info(:elixir_v1, module, data, []) do
      Enum.find_value(defs, fn
        {{^name, ^arity}, _kind, meta, _clauses} -> Keyword.get(meta, :line)
        _ -> nil
      end)
    else
      _ -> nil
    end
  rescue
    _ -> nil
  catch
    _, _ -> nil
  end

  defp beam_path(module) do
    case :code.which(module) do
      path when is_list(path) -> path
      _ -> []
    end
  end

  defp callback?(name, arity, impls, callbacks, behaviours) do
    {name, arity} in callbacks or
      Enum.any?(impls, fn
        true -> false
        false -> false
        m when is_atom(m) -> behaviour_callback?(m, name, arity)
        _ -> false
      end) or
      Enum.any?(behaviours, fn b -> behaviour_callback?(b, name, arity) end)
  end

  defp behaviour_callback?(behaviour, name, arity) do
    callbacks = behaviour.behaviour_info(:callbacks)
    {name, arity} in callbacks
  rescue
    _ -> false
  catch
    _, _ -> false
  end

  # ── Call collection ───────────────────────────────────────────────────────

  defp collect_calls(modules) do
    modules
    |> Enum.flat_map(&module_calls/1)
    |> MapSet.new()
  end

  defp module_calls(module) do
    case :beam_lib.chunks(beam_path(module), [:debug_info]) do
      {:ok, {_, [{:debug_info, {:debug_info_v1, _backend, _data}}]}} ->
        # Walk debug info looking for remote calls.
        # Use Erlang's xref-style approach: scan abstract code via beam_lib.
        case :beam_lib.chunks(beam_path(module), [:abstract_code]) do
          {:ok, {_, [{:abstract_code, {:raw_abstract_v1, forms}}]}} ->
            extract_calls_from_forms(forms)

          _ ->
            []
        end

      _ ->
        []
    end
  rescue
    _ -> []
  catch
    _, _ -> []
  end

  defp extract_calls_from_forms(forms) do
    Enum.flat_map(forms, &extract_calls_from_form/1)
  end

  defp extract_calls_from_form({:function, _line, _name, _arity, clauses}) do
    Enum.flat_map(clauses, &walk_form/1)
  end

  defp extract_calls_from_form(_), do: []

  defp walk_form({:call, _line, {:remote, _, {:atom, _, m}, {:atom, _, f}}, args}) do
    [{m, f, length(args)} | Enum.flat_map(args, &walk_form/1)]
  end

  defp walk_form(tuple) when is_tuple(tuple) do
    tuple |> Tuple.to_list() |> Enum.flat_map(&walk_form/1)
  end

  defp walk_form(list) when is_list(list) do
    Enum.flat_map(list, &walk_form/1)
  end

  defp walk_form(_), do: []

  # ── Skip rules ────────────────────────────────────────────────────────────

  @entry_points MapSet.new([
                  :__info__,
                  :__struct__,
                  :__changeset__,
                  :__schema__,
                  :module_info,
                  :child_spec,
                  :start_link,
                  :start,
                  :init,
                  :handle_call,
                  :handle_cast,
                  :handle_info,
                  :handle_continue,
                  :terminate,
                  :code_change,
                  :format_status,
                  :handle_event,
                  :handle_params,
                  :handle_async,
                  :mount,
                  :render,
                  :update,
                  :__live__,
                  :__components__,
                  :main,
                  :run
                ])

  defp skip_def?(_module, name, _arity, _project_modules) do
    name_str = Atom.to_string(name)

    MapSet.member?(@entry_points, name) or
      String.starts_with?(name_str, "__") or
      String.starts_with?(name_str, "MACRO-")
  end

  # ── Formatting ────────────────────────────────────────────────────────────

  @doc "Format a list of unused entries as a human-readable string."
  @spec format([unused_entry()]) :: String.t()
  def format([]), do: "No unused public functions found.\n"

  def format(entries) do
    grouped = Enum.group_by(entries, & &1.module)

    body =
      grouped
      |> Enum.sort_by(fn {module, _} -> Atom.to_string(module) end)
      |> Enum.map_join("\n", &format_module_block/1)

    "Unused public functions:\n\n" <> body <> "\n"
  end

  defp format_module_block({module, items}) do
    lines = Enum.map_join(items, "\n", &format_entry_line/1)
    "#{inspect(module)}\n#{lines}"
  end

  defp format_entry_line(%{name: n, arity: a, file: f, line: l}) do
    location =
      cond do
        f && l -> "#{f}:#{l}"
        f -> f
        true -> "?"
      end

    "  def #{n}/#{a}  (#{location})"
  end
end
