defmodule Spec.MetaUtils do
  @moduledoc """
  Meta-programming utils to generate and inspect spec test files.
  """

  @vectors_dir Path.join(["test", "spec", "vectors", "tests"])
  @runners_dir Path.join(["test", "spec", "runners"])
  @generated_dir Path.join(["test", "generated"])
  @vector_sub_dirs ["config", "fork", "runner", "handler", "suite", "case"]
  @vector_dir_keys Enum.map(@vector_sub_dirs, &String.to_atom/1)

  def vectors_dir(), do: @vectors_dir
  def runners_dir(), do: @runners_dir
  def generated_dir(), do: @generated_dir

  @doc """
  All SpecTestCases for a filter. The filter might have either of the following keys:
  [:config, :fork, :runner, :handler, :suite, :case]

  If, the filter is, for example, [config: "mainnet", fork: "capella"], then only spec tests
  for the mainnet config and capella fork will be returned. If the filter is empty, then
  all test cases will be returned.
  """
  def cases_for(filter \\ []) do
    @vector_dir_keys
    |> Enum.map(fn key -> filter[key] || "*" end)
    |> then(&[@vectors_dir | &1])
    |> Path.join()
    |> Path.wildcard()
    |> Stream.map(&Path.relative_to(&1, SpecTestUtils.vectors_dir()))
    |> Stream.map(&Path.split/1)
    |> Enum.map(&SpecTestCase.new/1)
  end

  @doc """
  Module (represented as an atom) for the ExUnit test that will be run for a single
  combination of config, fork and runner.
  """
  def test_module(config, fork, runner) do
    c = Macro.camelize(config)
    f = Macro.camelize(fork)
    r = Macro.camelize(runner)
    "Elixir.#{c}.#{f}.#{r}Test" |> String.to_atom()
  end

  @doc """
  Module that implements the runner behavior for a single runner, regardless of the config
  or fork.
  """
  def runner_module(runner_name) do
    "Elixir.#{Macro.camelize(runner_name)}TestRunner" |> String.to_atom()
  end

  @doc """
  Returns a hierarchical map of which spec tests are enabled or which ones are skipped.
  For any folder, a map will be built with the keys ":full_run", ":full_skip" or "partial",
  depending on the results of its sub_folders/tests. Full runs and full skips will not show
  details, only the number of tests that are run or skipped. Partial directories are more
  interesting, as they might have unimplemented cases by oversight.
  """
  def check_enabled() do
    {_status, "tests", res} = check_enabled(@vectors_dir, ["root" | @vector_sub_dirs])
    res
  end

  defp check_enabled(current_dir, ["runner" | _rest]) do
    # Get the test cases for this runner.
    [config, fork, runner | _rest] = current_dir |> Path.relative_to(@vectors_dir) |> Path.split()
    runner_name = current_dir |> Path.basename()
    runner_m = runner_module(runner)

    cases = cases_for(config: config, fork: fork, runner: runner)

    # TODO: we can also check if the thing is generated by checking the exs file.
    case Code.ensure_loaded(runner_m) do
      {:module, _} ->
        cases
        |> Enum.group_by(&runner_m.skip?/1)
        |> Enum.map(fn
          {true, skipped} -> {:full_skip, names(skipped)}
          {false, enabled} -> {:full_run, length(enabled)}
        end)
        |> Map.new()
        |> merge_grouped(runner_name)

      _ ->
        {:full_skip, runner_name, length(cases)}
    end
  end

  defp check_enabled(current_dir, [_dir_category | rest]) do
    File.ls!(current_dir)
    |> Enum.map(fn child -> Path.join(current_dir, child) |> check_enabled(rest) end)
    |> Enum.group_by(fn {status, _, _} -> status end, fn {_, name, data} -> {name, data} end)
    |> merge_grouped(Path.basename(current_dir))
  end

  defp merge_grouped(%{full_run: all} = m, name) when map_size(m) == 1 do
    {:full_run, name, lengths(all)}
  end

  defp merge_grouped(%{full_skip: all} = m, name) when map_size(m) == 1 do
    {:full_skip, name, lengths(all)}
  end

  defp merge_grouped(m, name), do: {:partial, name, m}

  defp lengths([h | _t] = l) when is_binary(h), do: length(l)
  defp lengths(i) when is_integer(i), do: i

  defp lengths(merged_list) do
    Enum.reduce(merged_list, 0, fn {_name, l}, s -> s + l end)
  end

  defp names(cases), do: Enum.map(cases, &Path.join([&1.handler, &1.suite, &1.case]))
end
