defmodule Credo.Test.Case do
  @moduledoc """
  Conveniences for testing Credo custom checks and plugins.

  This module can be used in your test cases, like this:

      use Credo.Test.Case

  Using this module will:

  * import all the functions from this module
  * make the test case `:async` by default (use `use Credo.Test.Case, async: false` to opt out)

  ## Testing custom checks

  Suppose we have a custom check in our project that checks whether or not
  the "FooBar rules" are applied (one of those *very* project-specific things).

      defmodule MyProject.MyCustomChecks.FooBar do
        use Credo.Check, category: :warning, base_priority: :high

        def run(%SourceFile{} = source_file, params) do
          # ... implement all the "FooBar rules" ...
        end
      end

  When we want to test this check, we can use `Credo.Test.Case` for convenience:

      defmodule MyProject.MyCustomChecks.FooBarTest do
        use Credo.Test.Case

        alias MyProject.MyCustomChecks.FooBar

        test "it should NOT report expected code" do
          \"\"\"
          defmodule CredoSampleModule do
            # ... some good Elixir code ...
          end
          \"\"\"
          |> to_source_file()
          |> run_check(FooBar)
          |> refute_issues()
        end

        test "it should report code that violates the FooBar rule" do
          \"\"\"
          defmodule CredoSampleModule do
            # ... some Elixir code that violates the FooBar rule ...
          end
          \"\"\"
          |> to_source_file()
          |> run_check(FooBar)
          |> assert_issues()
        end
      end

  This is as simple and mundane as it looks (which is a good thing):
  We have two tests: one for the good case, one for the bad case.
  In each, we create a source file representation from a heredoc, run our custom check and assert/refute the issues
  we expect.

  ## Asserting found issues

  Once we get to know domain a little better, we can add more tests, typically testing for other bad cases in which
  our check should produce issues.

  Note that there are two assertion functions for this: `assert_issue/2` and `assert_issues/2`, where the first one
  ensures that there is a single issue and the second asserts that there are at least two issues.

  Both functions take an optional `callback` as their second parameter, which is called with the `issue` or the
  list of `issues` found, which makes it convenient  to check for the issues properties ...

      \"\"\"
      # ... any Elixir code ...
      \"\"\"
      |> to_source_file()
      |> run_check(FooBar)
      |> assert_issue(fn issue -> assert issue.trigger == "foo" end)

  ... or properties of the list of issues:

      \"\"\"
      # ... any Elixir code ...
      \"\"\"
      |> to_source_file()
      |> run_check(FooBar)
      |> assert_issue(fn issues -> assert Enum.count(issues) == 3 end)

  ## Testing checks that analyse multiple source files

  For checks that analyse multiple source files, like Credo's consistency checks, we can use `to_source_files/1` to
  create

      [
        \"\"\"
        # source file 1
        \"\"\",
        \"\"\"
        # source file 2
        \"\"\"
      ]
      |> to_source_files()
      |> run_check(FooBar)
      |> refute_issues()

  If our check needs named source files, we can always use `to_source_file/2` to create individually named source
  files and combine them into a list:

      source_file1 =
        \"\"\"
        # source file 1
        \"\"\"
        |> to_source_file("foo.ex")

      source_file2 =
        \"\"\"
        # source file 2
        \"\"\"
        |> to_source_file("bar.ex")

      [source_file1, source_file2]
      |> run_check(FooBar)
      |> assert_issue(fn issue -> assert issue.filename == "foo.ex" end)
  """
  defmacro __using__(opts) do
    async = opts[:async] != false

    quote do
      use ExUnit.Case, async: unquote(async)

      import Credo.Test.Case
    end
  end

  alias Credo.Test.Assertions
  alias Credo.Test.CheckRunner
  alias Credo.Test.SourceFiles

  @doc """
  Refutes the presence of any issues.
  """
  def refute_issues(issues) do
    Assertions.refute_issues(issues)
  end

  @doc """
  Asserts the presence of a single issue.

  This is useful for saying "in this snippet, there is exactly one issue":

      source_file
      |> run_check(FooBar)
      |> assert_issue()

  Giving a pattern, you can say "in this snippet, there should be one issue looking like this":

      source_file
      |> run_check(FooBar)
      |> assert_issue(%{line_no: 3})

  The given patterns can contain all fields of `Credo.Issue`
  (`:message` can either be a string or a regex):

      source_file
      |> run_check(FooBar)
      |> assert_issue(%{
          message: ~r/should reference/,
          trigger: "foo",
          line_no: 3
        })

  If `callback` is given, calls it with the found issue:

      source_file
      |> run_check(FooBar)
      |> assert_issue(fn issue ->
        assert issue.line_no == 3
        assert issue.trigger == "foo"
      end)

  """
  def assert_issue(issues, callback \\ nil) do
    Assertions.assert_issue(issues, callback)
  end

  @doc """
  Asserts the presence of more than one issue.

  This is useful for saying "in this snippet, there is more than one issue":

      source_file
      |> run_check(FooBar)
      |> assert_issues()

  If `callback` is given, calls it with the found issues:

      source_file
      |> run_check(FooBar)
      |> assert_issues(fn issues ->
        assert Enum.count(issues) == 3
      end)

  If a number is given, checks that the count of issues matches:

      source_file
      |> run_check(FooBar)
      |> assert_issues(3)

  """
  def assert_issues(issues, callback \\ nil) do
    Assertions.assert_issues(issues, callback)
  end

  @doc """
  Asserts the presence of one or more issues based on the given patterns.

  This is useful for saying "in this snippet, there should be issues looking like this":

      source_file
      |> run_check(FooBar)
      |> assert_issues_match([
        %{line_no: 3},
        %{line_no: 6}
      ])

  The given patterns can contain all fields of `Credo.Issue`:

      source_file
      |> run_check(FooBar)
      |> assert_issues_match([
        %{
          message: "Module attribute @config_7 causes trouble",
          trigger: "@config_7",
          line_no: 8,
          column: 13
        },
        %{
          message: "Module attribute @config_9 causes trouble",
          trigger: "@config_9",
          line_no: 10,
          column: 13
        }
      ])

    One can combine this with `assert_issues/2` to make sure there are only the issues one expects:

      source_file
      |> run_check(FooBar)
      |> assert_issues(2)
      |> assert_issues_match([
        %{line_no: 3},
        %{line_no: 6}
      ])

  """
  def assert_issues_match(issues, patterns) do
    Assertions.assert_issues_match(issues, patterns)
  end

  @doc """
  Asserts the presence of one or more issues based on the given patterns.

  This is useful for saying "in this snippet, there should be at least one issue looking like this":

      source_file
      |> run_check(FooBar)
      |> assert_issue_matches(%{line_no: 3})

  The given patterns can contain all fields of `Credo.Issue`:

      source_file
      |> run_check(FooBar)
      |> assert_issue_matches(%{
          message: "Module attribute @config_7 causes trouble",
          trigger: "@config_7",
          line_no: 8,
          column: 13
        })
  """
  def assert_issue_matches(issues, pattern) do
    Assertions.assert_issue_matches(issues, pattern)
  end

  @doc false
  # TODO: remove this
  def assert_trigger(issue, trigger) do
    Assertions.assert_trigger(issue, trigger)
  end

  #

  @doc """
  Runs the given `check` on the given `source_file` using the given `params`.

      "x = 5"
      |> to_source_file()
      |> run_check(MyProject.MyCheck, foo_parameter: "bar")
  """
  def run_check(source_files, check, params \\ []) do
    Process.put(:credo_test_source_files, source_files)
    issues = CheckRunner.run_check(source_files, check, params)

    check_on_malformed_issues(source_files, issues)

    issues
  end

  defp check_on_malformed_issues(source_files, issues) do
    source_files = List.wrap(source_files)
    no_trigger = Credo.Issue.no_trigger()

    Enum.each(issues, fn issue ->
      case issue.trigger do
        ^no_trigger ->
          :ok

        trigger when is_nil(trigger) ->
          warn_or_flunk(":trigger is nil")

        trigger when is_binary(trigger) ->
          warn_on_missing_trigger(source_files, issue)

        trigger when is_atom(trigger) ->
          warn_on_missing_trigger(source_files, issue)

        trigger ->
          warn_or_flunk(":trigger is not a binary: #{inspect(trigger, pretty: true)}")
      end
    end)
  end

  defp warn_on_missing_trigger(source_files, issue) do
    trigger = to_string(issue.trigger)
    line = get_source_line(source_files, issue)

    if issue.column do
      actual_trigger = String.slice(line, issue.column - 1, String.length(trigger))

      if actual_trigger != trigger do
        warn_or_flunk("""
        found trigger is not the given trigger:
          actual:   #{inspect(actual_trigger, pretty: true)}
          expected: #{inspect(trigger, pretty: true)}

        #{get_issue_inline(issue)}
        """)
      end
    end
  end

  @doc false
  def test_source_files? do
    test_source_files() != []
  end

  @doc false
  def test_source_files do
    Process.get(:credo_test_source_files, [])
  end

  @doc false
  def get_issue_inline(issue, reset_color \\ :red) do
    source_files = test_source_files()
    source_line = get_source_line(source_files, issue)

    marker =
      if issue.column && issue.trigger != Credo.Issue.no_trigger() do
        String.duplicate(" ", issue.column - 1) <>
          String.duplicate("^", String.length(to_string(issue.trigger)))
      else
        ""
      end

    """
    #{IO.ANSI.cyan()}#{String.pad_leading("#{issue.line_no} |", 6)}#{IO.ANSI.format([reset_color])} #{source_line}
    #{IO.ANSI.cyan()}#{String.pad_leading("", 6)} #{marker}#{IO.ANSI.format([reset_color])}
    """
  end

  defp get_source_line(source_files, %Credo.Issue{} = issue) when is_list(source_files) do
    source_files
    |> find_source_file(issue)
    |> Credo.SourceFile.line_at(issue.line_no)
  end

  defp get_source_line(%Credo.SourceFile{} = source_file, %Credo.Issue{} = issue) do
    Credo.SourceFile.line_at(source_file, issue.line_no)
  end

  defp find_source_file(source_files, %Credo.Issue{filename: filename})
       when is_list(source_files) do
    Enum.find(source_files, &(&1.filename == filename)) || raise "Could not find source file"
  end

  defp warn_or_flunk(message) do
    if System.get_env("ASSERT_TRIGGERS") do
      ExUnit.Assertions.flunk(message)
    else
      IO.warn(message)
    end
  end

  #

  @doc """
  Converts the given `source` string to a `%SourceFile{}`.

      "x = 5"
      |> to_source_file()
  """
  def to_source_file(source) when is_binary(source) do
    SourceFiles.to_source_file(source)
  end

  @doc """
  Converts the given `source` string to a `%SourceFile{}` with the given `filename`.

      "x = 5"
      |> to_source_file("simple.ex")
  """
  def to_source_file(source, filename) when is_binary(source) and is_binary(filename) do
    SourceFiles.to_source_file(source, filename)
  end

  @doc """
  Converts the given `list` of source code strings to a list of `%SourceFile{}` structs.

      ["x = 5", "y = 6"]
      |> to_source_files()
  """
  def to_source_files(list) when is_list(list) do
    Enum.map(list, &to_source_file/1)
  end
end
