defmodule Tarakan.InfestationSwarmTest do
  @moduledoc """
  The swarm button fans work out onto other contributors' agents, so most of
  what matters here is what it *refuses* to do a second time.
  """

  use Tarakan.DataCase, async: true

  alias Tarakan.Accounts.Scope
  alias Tarakan.FindingMemory
  alias Tarakan.Infestations
  alias Tarakan.Infestations.Swarm
  alias Tarakan.Repo
  alias Tarakan.Scans.{CanonicalFinding, FindingCheck}
  alias Tarakan.Work.ReviewTask

  setup do
    submitter = github_account_fixture()
    other = github_account_fixture()
    repo_a = listed_github_repository_fixture(submitter)

    {:ok, repo_b} = Tarakan.Repositories.register_github_repository("acme/widget", other)
    repo_b = listed_repository_fixture(repo_b)

    title = "Swarm sample #{System.unique_integer([:positive])}"

    scan_a =
      scan_fixture(repo_a, submitter, %{"findings_json" => findings_json(title, "lib/a.ex")})

    scan_b = scan_fixture(repo_b, other, %{"findings_json" => findings_json(title, "src/b.rs")})

    %{
      moderator: Scope.for_account(moderator_account_fixture()),
      pattern_key: FindingMemory.pattern_key(title),
      repo_a: repo_a,
      repo_b: repo_b,
      scan_a: scan_a,
      scan_b: scan_b
    }
  end

  describe "swarm_candidates/2" do
    test "is empty while the automatic check jobs are still in flight", %{
      pattern_key: pattern_key
    } do
      # Submitting a report already opens one agent verify job per scan. The
      # swarm exists for what that path missed, not to duplicate it.
      assert verify_jobs() == 2
      assert Infestations.swarm_candidates(pattern_key) == []
    end

    test "picks up instances whose automatic job was cancelled", %{pattern_key: pattern_key} do
      cancel_auto_jobs()

      candidates = Infestations.swarm_candidates(pattern_key)

      assert length(candidates) == 2
      assert Enum.all?(candidates, &is_integer(&1.scan_id))
      assert Enum.all?(candidates, &(byte_size(&1.commit_sha) == 40))
    end

    test "drops an instance already checked at that commit", %{
      pattern_key: pattern_key,
      repo_a: repo_a,
      scan_a: scan_a
    } do
      cancel_auto_jobs()
      canonical = canonical_for(repo_a)

      {:ok, _check, _canonical} =
        FindingMemory.record_check(
          Scope.for_account(reviewer_account_fixture()),
          repo_a,
          canonical.public_id,
          %{
            "commit_sha" => scan_a.commit_sha,
            "verdict" => "confirmed",
            "provenance" => "human",
            "notes" => "Traced the same interpolation at the pinned commit by hand."
          }
        )

      candidates = Infestations.swarm_candidates(pattern_key)

      assert length(candidates) == 1
      refute Enum.any?(candidates, &(&1.canonical_finding_id == canonical.id))
    end

    test "orders never-checked instances ahead of checked ones", %{
      pattern_key: pattern_key,
      repo_a: repo_a,
      scan_a: scan_a
    } do
      cancel_auto_jobs()
      canonical = canonical_for(repo_a)

      # A check at some *other* commit leaves the instance eligible, but it is
      # no longer the most neglected one.
      check_at(canonical, String.duplicate("a", 40))

      assert [first, second] = Infestations.swarm_candidates(pattern_key)
      assert first.last_checked_at == nil
      assert second.canonical_finding_id == canonical.id
      assert second.commit_sha == scan_a.commit_sha
    end

    test "respects the limit", %{pattern_key: pattern_key} do
      cancel_auto_jobs()

      assert length(Infestations.swarm_candidates(pattern_key, 1)) == 1
      assert Infestations.swarm_candidates(pattern_key, 0) == []
    end
  end

  describe "swarm_check_jobs/2" do
    test "opens one job per eligible instance and records the run", %{
      moderator: moderator,
      pattern_key: pattern_key
    } do
      cancel_auto_jobs()

      assert {:ok, %{opened: 2, failed: 0}} =
               Infestations.swarm_check_jobs(moderator, pattern_key)

      assert verify_jobs("open") == 2

      assert [%Swarm{jobs_opened: 2, candidates_seen: 2, pattern_key: ^pattern_key}] =
               Repo.all(Swarm)
    end

    test "a second run is refused by the cooldown", %{
      moderator: moderator,
      pattern_key: pattern_key
    } do
      cancel_auto_jobs()

      assert {:ok, %{opened: 2}} = Infestations.swarm_check_jobs(moderator, pattern_key)

      assert {:error, {:cooldown, seconds}} =
               Infestations.swarm_check_jobs(moderator, pattern_key)

      assert seconds > 0
      assert verify_jobs("open") == 2
    end

    test "records nothing when it opened nothing, so the cooldown cannot be primed", %{
      moderator: moderator,
      pattern_key: pattern_key
    } do
      assert {:ok, %{opened: 0}} = Infestations.swarm_check_jobs(moderator, pattern_key)
      assert Repo.all(Swarm) == []
      assert Infestations.swarm_cooldown_seconds(pattern_key) == 0
    end

    test "stays a no-op once the jobs it opened have been accepted", %{
      moderator: moderator,
      pattern_key: pattern_key
    } do
      cancel_auto_jobs()

      assert {:ok, %{opened: 2}} = Infestations.swarm_check_jobs(moderator, pattern_key)

      # The old dedup only looked at queue slots, so closing the jobs made every
      # instance eligible again - the same set could be re-opened every day.
      settle_open_jobs()
      expire_cooldown()

      assert Infestations.swarm_candidates(pattern_key) == []
      assert {:ok, %{opened: 0}} = Infestations.swarm_check_jobs(moderator, pattern_key)
      assert verify_jobs() == 4
    end

    test "a rejected job leaves the commit eligible again", %{
      moderator: moderator,
      pattern_key: pattern_key
    } do
      cancel_auto_jobs()

      assert {:ok, %{opened: 2}} = Infestations.swarm_check_jobs(moderator, pattern_key)

      Repo.update_all(from(t in ReviewTask, where: t.status == "open"), set: [status: "rejected"])
      expire_cooldown()

      assert length(Infestations.swarm_candidates(pattern_key)) == 2
    end

    test "a non-moderator cannot swarm", %{pattern_key: pattern_key} do
      scope = Scope.for_account(account_fixture())

      assert {:error, :unauthorized} = Infestations.swarm_check_jobs(scope, pattern_key)
      assert Repo.all(Swarm) == []
    end
  end

  defp findings_json(title, path) do
    Jason.encode!(%{
      "tarakan_scan_format" => 1,
      "findings" => [
        %{
          "file" => path,
          "line_start" => 1,
          "line_end" => 2,
          "severity" => "high",
          "title" => title,
          "description" => "Shared issue class for swarm tests."
        }
      ]
    })
  end

  defp canonical_for(repository) do
    Repo.one!(from c in CanonicalFinding, where: c.repository_id == ^repository.id)
  end

  defp check_at(canonical, commit_sha) do
    now = DateTime.utc_now()

    Repo.insert!(%FindingCheck{
      canonical_finding_id: canonical.id,
      account_id: reviewer_account_fixture().id,
      commit_sha: commit_sha,
      verdict: "confirmed",
      provenance: "human",
      notes: "Checked at an older commit than the one the swarm would pin.",
      inserted_at: now,
      updated_at: now
    })
  end

  defp cancel_auto_jobs do
    Repo.update_all(from(t in ReviewTask, where: t.kind == "verify_findings"),
      set: [status: "cancelled"]
    )
  end

  defp settle_open_jobs do
    Repo.update_all(from(t in ReviewTask, where: t.status == "open"), set: [status: "accepted"])
  end

  defp expire_cooldown do
    stale = DateTime.add(DateTime.utc_now(), -2, :day)
    Repo.update_all(Swarm, set: [inserted_at: stale])
  end

  defp verify_jobs do
    Repo.aggregate(from(t in ReviewTask, where: t.kind == "verify_findings"), :count)
  end

  defp verify_jobs(status) do
    Repo.aggregate(
      from(t in ReviewTask, where: t.kind == "verify_findings" and t.status == ^status),
      :count
    )
  end
end
