defmodule Mix.Tasks.Compile.Unused do
  @moduledoc """
  Mix compiler that finds public functions in this project which no observed
  caller ever invokes.

  ## Usage

  Run directly:

      mix compile.unused

  Or wire into the compiler chain in `mix.exs`:

      compilers: Mix.compilers() ++ [:unused]

  ## Configuration

  In `mix.exs`:

      def project do
        [
          ...,
          unused: [
            severity: :warning,        # :warning (default) | :error | :info
            exclude: [
              MyApp.SomeModule,        # whole modules
              {MyApp.Foo, :ignored, 0} # specific MFA triples
            ],
            ignore_test_files: true    # skip files under test/ (default true)
          ]
        ]
      end

  ## How it works

    * The project is compiled (via `mix compile`).
    * `MixUnused.Analyzer` then enumerates every public function in every
      project-owned BEAM file via `Module.__info__/1` and walks the
      abstract code in each BEAM (via `:beam_lib`) to collect the set of
      remote `Module.fun/arity` calls.
    * Functions in the def-set that aren't in the call-set are reported.
    * Behaviour callbacks (`@impl true` or via `behaviour_info(:callbacks)`),
      well-known entry points (`start_link`, `child_spec`, `mount`,
      `render`, GenServer callbacks, etc.) and reflection helpers
      (`__info__/1`, `module_info/0`, ...) are excluded automatically.
  """

  use Mix.Task.Compiler

  alias Mix.Task.Compiler
  alias MixUnused.Analyzer

  @recursive false

  @impl Compiler
  def run(args) do
    {opts, _, _} =
      OptionParser.parse(args,
        switches: [severity: :string, all: :boolean]
      )

    config = read_config()

    severity =
      opts
      |> Keyword.get(:severity)
      |> parse_severity(Keyword.get(config, :severity, :warning))

    # Make sure the project is compiled before analyzing.
    _ = Mix.Task.run("compile", [])

    # Tests may stash a precomputed Analyzer state in the process dict to
    # avoid scanning all BEAM files on every Unused.run/1 call.
    extra_analyze_opts = Process.get(:mix_unused_extra_analyze_opts, [])

    config
    |> filter_options()
    |> Keyword.merge(extra_analyze_opts)
    |> Analyzer.analyze()
    |> filter_test_files(Keyword.get(config, :ignore_test_files, true))
    |> emit(severity)
  end

  @impl Compiler
  def manifests, do: []

  @impl Compiler
  def clean, do: :ok

  defp read_config, do: Keyword.get(Mix.Project.config(), :unused, [])

  defp filter_options(config) do
    [exclude: Keyword.get(config, :exclude, [])]
  end

  defp parse_severity(nil, default), do: default

  defp parse_severity(string, _default) do
    case String.downcase(string) do
      "warning" -> :warning
      "error" -> :error
      "info" -> :info
      _ -> :warning
    end
  end

  defp filter_test_files(entries, false), do: entries

  defp filter_test_files(entries, true) do
    Enum.reject(entries, fn %{file: file} ->
      file != nil and (String.starts_with?(file, "test/") or String.contains?(file, "/test/"))
    end)
  end

  defp emit([], _severity) do
    Mix.shell().info("[unused] No unused public functions found.")
    {:ok, []}
  end

  defp emit(entries, severity) do
    formatted = Analyzer.format(entries)
    Mix.shell().info("\n" <> formatted)

    diagnostics = Enum.map(entries, &to_diagnostic(&1, severity))

    case severity do
      :error -> {:error, diagnostics}
      _ -> {:ok, diagnostics}
    end
  end

  defp to_diagnostic(%{module: m, name: n, arity: a, file: file, line: line}, severity) do
    %Mix.Task.Compiler.Diagnostic{
      compiler_name: "unused",
      file: file,
      position: line || 0,
      message: "unused public function #{inspect(m)}.#{n}/#{a}",
      severity: severity,
      details: nil
    }
  end
end
