defmodule JUnitFormatter do
  @moduledoc """
  An `ExUnit.Formatter` implementation that generates a XML in the format understood by JUnit.

  To accomplish this, there are some mappings that are not straight one to one.
  Therefore, here goes the mapping:

  - JUnit - `ExUnit`
  - Testsuites - :testsuite
  - Testsuite - `ExUnit.Case`
  - failures = failures
  - skipped = skipped
  - errors = invalid
  - time = (sum of all times in seconds rounded down)
  - Testcase - `ExUnit.Test`
  - name = :case
  - test = :test
  - content (only if not successful)
  - skipped = {:state, {:skipped, _}}
  - failed = {:state, {:failed, {_, reason, stacktrace}}}
  - reason = reason.message
  - content = `Exception.format_stacktrace/1`
  - error = {:invalid, module}

  The report is written to a file in the `_build` directory.
  """
  require Record

  use GenServer

  defmodule Stats do
    @moduledoc """
    A struct to keep track of test values and tests themselves.

    It is used to build the testsuite JUnit node.
    """
    defstruct errors: 0,
              failures: 0,
              skipped: 0,
              tests: 0,
              time: 0,
              test_cases: []

    @type t :: %__MODULE__{
            errors: non_neg_integer,
            failures: non_neg_integer,
            skipped: non_neg_integer,
            tests: non_neg_integer,
            time: non_neg_integer,
            test_cases: [ExUnit.Test.t()]
          }
  end

  defstruct cases: %{}, properties: %{}

  @impl true
  def init(opts) do
    if automatic_create_dir?() do
      :ok = File.mkdir_p(report_dir())
    end

    {:ok,
     %__MODULE__{
       properties: %{
         seed: opts[:seed],
         date: DateTime.to_iso8601(DateTime.utc_now())
       }
     }}
  end

  @impl true
  def handle_cast({:suite_finished, %{async: _, load: _, run: _}}, config) do
    handle_suite_finished(config)

    {:noreply, config}
  end

  def handle_cast({:suite_finished, _run_us, _load_us}, config) do
    handle_suite_finished(config)

    {:noreply, config}
  end

  def handle_cast({:test_finished, %ExUnit.Test{state: nil} = test}, config) do
    config = adjust_case_stats(test, nil, config)

    {:noreply, config}
  end

  def handle_cast({:test_finished, %ExUnit.Test{state: {tag, _}} = test}, config)
      when tag == :skipped do
    config = adjust_case_stats(test, :skipped, config)

    {:noreply, config}
  end

  def handle_cast({:test_finished, %ExUnit.Test{state: {:excluded, _}} = test}, config) do
    config = adjust_case_stats(test, :skipped, config)

    {:noreply, config}
  end

  def handle_cast({:test_finished, %ExUnit.Test{state: {:failed, _failed}} = test}, config) do
    config = adjust_case_stats(test, :failures, config)

    {:noreply, config}
  end

  def handle_cast({:test_finished, %ExUnit.Test{state: {:invalid, _module}} = test}, config) do
    config = adjust_case_stats(test, :errors, config)

    {:noreply, config}
  end

  def handle_cast(_event, config), do: {:noreply, config}

  @doc "Formats time from nanos to seconds"
  @spec format_time(integer) :: binary
  def format_time(time), do: ~c"~.4f" |> :io_lib.format([time / 1_000_000]) |> List.to_string()

  @doc """
  Helper function to get the full path of the generated report file.
  It can be passed 2 configurations
  - report_dir: full path of a directory (defaults to `Mix.Project.app_path()`)
  - report_file: name of the generated file (defaults to "test-junit-report.xml")
  """
  @spec get_report_file_path() :: String.t()
  def get_report_file_path do
    report_file = Application.get_env(:junit_formatter, :report_file, "test-junit-report.xml")

    prepend = Application.get_env(:junit_formatter, :prepend_project_name?, false)
    prefix = if prepend, do: "#{Mix.Project.config()[:app]}-", else: ""

    Path.join([report_dir(), prefix <> report_file])
  end

  defp report_dir do
    subdir = Application.get_env(:junit_formatter, :use_project_subdirectory?, false)

    report_dir = Application.get_env(:junit_formatter, :report_dir, Mix.Project.app_path())
    prefix = if subdir, do: "#{Mix.Project.config()[:app]}", else: ""

    Path.join([report_dir, prefix])
  end

  defp automatic_create_dir? do
    automatic_create_dir? = Application.get_env(:junit_formatter, :automatic_create_dir?, false)
    use_project_subdir? = Application.get_env(:junit_formatter, :use_project_subdirectory?, false)

    automatic_create_dir? || use_project_subdir?
  end

  # PRIVATE ------------

  defp handle_suite_finished(config) do
    # do the real magic
    suites = Enum.map(config.cases, &generate_testsuite_xml(&1, config.properties))
    # wrap result in a root node (not adding any attribute to root)
    result = :xmerl.export_simple([{:testsuites, [], suites}], :xmerl_xml)

    # save the report in an XML file
    file_name = get_report_file_path()

    :ok = File.write!(file_name, result, [:write])

    if Application.get_env(:junit_formatter, :print_report_file, false) do
      IO.puts(:stderr, "Wrote JUnit report to: #{file_name}")
    end
  end

  defp adjust_case_stats(%ExUnit.Test{case: name, time: time} = test, type, state) do
    test_without_logs = %ExUnit.Test{test | logs: ""}

    cases =
      Map.update(
        state.cases,
        name,
        struct(Stats, [{type, 1}, test_cases: [test_without_logs], time: time, tests: 1]),
        fn stats ->
          stats =
            struct(
              stats,
              test_cases: [test_without_logs | stats.test_cases],
              time: stats.time + time,
              tests: stats.tests + 1
            )

          if type, do: Map.update!(stats, type, &(&1 + 1)), else: stats
        end
      )

    %{state | cases: cases}
  end

  defp generate_testsuite_xml({name, %Stats{} = stats}, properties) do
    properties =
      for {name, value} <- properties do
        {:property, [name: name, value: value], []}
      end

    cases =
      for {test, idx} <- Enum.with_index(stats.test_cases, 1) do
        generate_testcases(test, idx)
      end

    {
      :testsuite,
      [
        errors: stats.errors,
        failures: stats.failures,
        skipped: stats.skipped,
        name: name,
        tests: stats.tests,
        time: format_time(stats.time)
      ],
      [{:properties, [], properties} | cases]
    }
  end

  defp generate_testcases(test, idx) do
    attrs = [
      classname: Atom.to_string(test.case),
      name: Atom.to_string(test.name),
      time: format_time(test.time)
    ]

    attrs = maybe_add_filename(attrs, test.tags.file, test.tags.line)

    {
      :testcase,
      attrs,
      generate_test_body(test, idx)
    }
  end

  defp generate_test_body(%ExUnit.Test{state: nil}, _idx), do: []

  defp generate_test_body(%ExUnit.Test{state: {atom, message}}, _idx)
       when atom in ~w[skip skipped excluded]a do
    [{:skipped, [message: message], []}]
  end

  defp generate_test_body(%ExUnit.Test{state: {:failed, failures}} = test, idx) do
    body =
      test
      |> ExUnit.Formatter.format_test_failure(failures, idx, :infinity, fn _, msg -> msg end)
      |> :erlang.binary_to_list()

    [{:failure, [message: message(failures)], [body]}]
  end

  defp generate_test_body(%ExUnit.Test{state: {:invalid, %name{} = module}}, _idx),
    do: [{:error, [message: "Invalid module #{name}"], [~c"#{inspect(module)}"]}]

  defp message([msg | _]), do: message(msg)
  defp message({_, %ExUnit.AssertionError{message: reason}, _}), do: reason
  defp message({:error, reason, _}), do: "error: #{Exception.message(reason)}"
  defp message({type, reason, _}) when is_atom(type), do: "#{type}: #{inspect(reason)}"
  defp message({type, reason, _}), do: "#{inspect(type)}: #{inspect(reason)}"

  defp maybe_add_filename(attrs, path, line) do
    if Application.get_env(:junit_formatter, :include_filename?) do
      path = relative_path(path)

      file =
        if Application.get_env(:junit_formatter, :include_file_line?) do
          "#{path}:#{line}"
        else
          path
        end

      Keyword.put(attrs, :file, file)
    else
      attrs
    end
  end

  defp relative_path(path) do
    root = Application.get_env(:junit_formatter, :project_dir, nil) || File.cwd!()
    Path.relative_to(path, root)
  end
end
