defmodule Couch.Test.Suite do
  @moduledoc """
    Common code to configure ExUnit runner.
    It replaces the usual invocation of `ExUnit.start()` in
    `test_helper.exs` related to integration tests with:
    ```
    Couch.Test.Suite.start()
    ```
  """
  @doc """
  This helper function can be used to create `suite.elixir`
  as
  ```
  tests =
    Couch.Test.Suite.list()
    |> Enum.sort()
    |> Couch.Test.Suite.group_by()

  IO.puts(Couch.Test.Suite.pretty_print(tests))

  ```
  """
  def list() do
    test_paths = Keyword.get(Mix.Project.config(), :test_paths, [])
    Enum.reduce(test_paths, [], fn directory, acc ->
      list(directory) ++ acc
    end)
  end

  @doc """
  This helper function can be used to create `suite.elixir`
  as
  ```
  tests =
    Couch.Test.Suite.list(["test/elixir/test"])
    |> Enum.sort()
    |> Couch.Test.Suite.group_by()

  IO.puts(Couch.Test.Suite.pretty_print(tests))
  ```
  """
  def list(directory) do
    ensure_exunit_started()
    Enum.reduce(test_files(directory), [], fn file_path, acc ->
      tests_in_file(file_path) ++ acc
    end)
  end

  @doc """
  This helper function is used in a snippet to create `suite.elixir`
  see list/1
  """
  def group_by(tests) do
    tests |> Enum.group_by(&module_name/1, &test_name/1)
  end

  @doc """
  This helper function is used in a snippet to create `suite.elixir`
  see list/1
  """
  def pretty_print(tests) do
    tests = Enum.join(Enum.sort(Enum.map(tests, fn {module_name, test_names} ->
      test_names = test_names
        |> Enum.map_join(fn x -> ~s("#{x}") end, ",\n    ")
      ~s(  "#{module_name}": [\n    #{test_names}\n  ])
    end)), ",\n")
    "%{\n#{tests}\n}"
  end

  def start(exclude \\ []) do
    # If build number detected assume we running on Jenkins
    # and skip certain tests that fail on jenkins.
    default_exclude =
      case System.get_env("BUILD_NUMBER") !== nil do
        true -> [:pending, :skip_on_jenkins]
        false -> [:pending]
      end

    current_exclude = Keyword.get(ExUnit.configuration(), :exclude, [])
    {ignores, current_exclude} = from_file(current_exclude)

    current_include = Keyword.get(ExUnit.configuration(), :include, [])
    {suite, current_include} = from_file(current_include)

    only_test_ids =
      case suite -- ignores do
        [] ->
          nil

        test_ids ->
          to_tests(test_ids)
      end

    ExUnit.configure(
      exclude: Enum.uniq(default_exclude ++ current_exclude ++ exclude),
      include: current_include,
      formatters: [JUnitFormatter, ExUnit.CLIFormatter],
      only_test_ids: only_test_ids
    )

    ExUnit.start()
  end

  # Helpers for start/0

  defp split_files(opts) do
    {files, opts} =
      Enum.split_with(opts, fn x ->
        String.ends_with?(Atom.to_string(x), ".elixir")
      end)

    {Enum.map(files, &Atom.to_string/1), opts}
  end

  defp read_from_file(file_name) do
    {map, _} = Code.eval_file(file_name)

    map
    |> Enum.reduce([], fn {module, tests}, acc ->
      Enum.map(tests, &{module, &1}) ++ acc
    end)
  end

  defp from_file(opts) do
    case split_files(opts) do
      {[], opts} ->
        {[], opts}

      {[file_name], opts} ->
        {read_from_file(file_name), opts}

      {_, _} ->
        throw("Only one file is supported in --exclude or --include")
    end
  end

  defp to_tests(ids) do
    MapSet.new(
      Enum.map(ids, fn {module_name, test_name} ->
        {String.to_atom("Elixir.#{module_name}"), String.to_atom("test #{test_name}")}
      end)
    )
  end

  # Helpers for list/0

  defp ensure_exunit_started() do
    if not Process.get(EXUNIT_STARTED, false) do
      started? =
        Application.started_applications()
        |> Enum.map(&Kernel.elem(&1, 0))
        |> Enum.member?(:ex_unit)

      if not started? do
        ExUnit.start(autorun: false)
        Process.put(EXUNIT_STARTED, true)
      end
    end
  end

  defp test_files(directory) do
    files = Path.wildcard(Path.join(directory, "*_test.exs"))
    Enum.filter(files, &File.regular?/1)
  end

  defp test_helpers(directory) do
    files = Path.wildcard(Path.join(directory, "*_helpers.exs"))
    Enum.filter(files, &File.regular?/1)
  end

  def tests_in_file(file_path) do
    ensure_exunit_started()
    Code.compiler_options(ignore_module_conflict: true)

    Enum.each(
      test_helpers(Path.dirname(file_path)), &require_file/1
    )

    tests =
      Enum.reduce(require_file(file_path), [], fn {module_name, _}, acc ->
        if :erlang.function_exported(module_name, :__ex_unit__, 0) do
          module_name.__ex_unit__().tests ++ acc
        else
          acc
        end
      end)

    Code.unrequire_files([file_path])
    tests
  end

  def require_file(file_path) do
    drop_stderr(fn ->
      Code.require_file(file_path)
    end)
  end

  defp drop_stderr(fun) do
    {:ok, pid} = StringIO.open("")
    original_pid = Process.whereis(:standard_error)

    try do
      Process.unregister(:standard_error)
      Process.register(pid, :standard_error)
      fun.()
    after
      Process.unregister(:standard_error)
      Process.register(original_pid, :standard_error)
      StringIO.close(pid)
    end
  end

  defp test_name(test) do
    String.replace_leading(Atom.to_string(test.name), "test ", "")
  end

  defp module_name(test) do
    test.module
    |> Atom.to_string()
    |> String.replace_leading("Elixir.", "")
  end
end
