defmodule Tarakan.FindingMemoryTest do
  use Tarakan.DataCase, async: true
  use Oban.Testing, repo: Tarakan.Repo

  alias Tarakan.Accounts.Scope
  alias Tarakan.Credits
  alias Tarakan.Credits.MintWorker
  alias Tarakan.FindingMemory
  alias Tarakan.Scans.CanonicalFinding

  describe "fingerprint/1" do
    test "collapses path variants" do
      base = %{file_path: "lib/auth.ex", line_start: 10, line_end: 12, title: "SQL injection"}
      dotted = %{base | file_path: "./lib/auth.ex"}
      slashy = %{base | file_path: "lib//auth.ex"}
      cased = %{base | file_path: "Lib/Auth.ex"}

      assert FindingMemory.fingerprint(base) == FindingMemory.fingerprint(dotted)
      assert FindingMemory.fingerprint(base) == FindingMemory.fingerprint(slashy)
      assert FindingMemory.fingerprint(base) == FindingMemory.fingerprint(cased)
    end

    test "collapses confidence title prefixes and ignores line_end drift" do
      base = %{
        file_path: "lib/auth.ex",
        line_start: 10,
        line_end: 10,
        title: "SQL injection in login"
      }

      prefixed = %{base | title: "Verified: Likely: SQL injection in login"}
      ranged = %{base | line_end: 40}
      punct = %{base | title: "SQL injection in login."}

      assert FindingMemory.fingerprint(base) == FindingMemory.fingerprint(prefixed)
      assert FindingMemory.fingerprint(base) == FindingMemory.fingerprint(ranged)
      assert FindingMemory.fingerprint(base) == FindingMemory.fingerprint(punct)
    end

    test "still separates different lines or titles" do
      a = %{file_path: "lib/a.ex", line_start: 1, line_end: 1, title: "Issue A"}
      b = %{a | line_start: 2}
      c = %{a | title: "Issue B"}

      refute FindingMemory.fingerprint(a) == FindingMemory.fingerprint(b)
      refute FindingMemory.fingerprint(a) == FindingMemory.fingerprint(c)
    end
  end

  describe "assimilate near-duplicates" do
    setup do
      submitter = github_account_fixture()
      repository = listed_github_repository_fixture(submitter)
      %{repository: repository, submitter: submitter}
    end

    test "merges agent path and title variants into one canonical", %{
      repository: repository,
      submitter: submitter
    } do
      commit_sha = random_commit_sha()

      first =
        scan_fixture(repository, submitter, %{
          "commit_sha" => commit_sha,
          "findings_json" =>
            finding_doc("lib/example/module_1.ex", 10, 15, "Unsanitized input reaches query")
        })

      second =
        scan_fixture(repository, account_fixture(), %{
          "commit_sha" => commit_sha,
          "findings_json" =>
            finding_doc(
              "./lib/example/module_1.ex",
              10,
              99,
              "Verified: Unsanitized input reaches query."
            )
        })

      [f1] = first.findings
      [f2] = second.findings

      assert f1.file_path == "lib/example/module_1.ex"
      assert f2.file_path == "lib/example/module_1.ex"
      assert f1.canonical_finding_id == f2.canonical_finding_id
      assert f2.disposition == "matches_existing"

      canonical = Repo.get!(CanonicalFinding, f1.canonical_finding_id)
      assert canonical.detections_count == 2
    end

    test "upgrades a legacy fingerprint when a v2 match arrives", %{
      repository: repository,
      submitter: submitter
    } do
      commit_sha = random_commit_sha()

      first =
        scan_fixture(repository, submitter, %{
          "commit_sha" => commit_sha,
          "findings_json" => finding_doc("lib/legacy.ex", 5, 20, "Buffer overflow")
        })

      [occurrence] = first.findings
      legacy_fp = FindingMemory.legacy_fingerprint(occurrence)
      modern_fp = FindingMemory.fingerprint(occurrence)

      # Simulate a pre-v2 row still keyed by the old hash.
      if legacy_fp != modern_fp do
        canonical = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)

        canonical
        |> Ecto.Changeset.change(fingerprint: legacy_fp)
        |> Repo.update!()

        second =
          scan_fixture(repository, account_fixture(), %{
            "commit_sha" => commit_sha,
            "findings_json" => finding_doc("lib/legacy.ex", 5, 20, "Buffer overflow")
          })

        [f2] = second.findings
        assert f2.canonical_finding_id == occurrence.canonical_finding_id

        upgraded = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)
        assert upgraded.fingerprint == modern_fp
        assert upgraded.detections_count == 2
      else
        # When v1 and v2 coincide (same lines collapsing), assimilation still works.
        second =
          scan_fixture(repository, account_fixture(), %{
            "commit_sha" => commit_sha,
            "findings_json" => finding_doc("lib/legacy.ex", 5, 20, "Buffer overflow")
          })

        [f2] = second.findings
        assert f2.canonical_finding_id == occurrence.canonical_finding_id
      end
    end
  end

  describe "assimilate disclosure metadata" do
    setup do
      submitter = github_account_fixture()
      repository = listed_github_repository_fixture(submitter)
      %{repository: repository, submitter: submitter}
    end

    test "copies disclosure fields onto a new canonical finding", %{
      repository: repository,
      submitter: submitter
    } do
      scan =
        scan_fixture(repository, submitter, %{
          "commit_sha" => random_commit_sha(),
          "findings_json" =>
            finding_doc("lib/auth.ex", 10, 12, "SQL injection", %{
              "reproduction" => "1. POST /login with ' OR 1=1 --",
              "affected_versions" => ">= 1.0, < 1.4.2",
              "cwe" => "CWE-89",
              "cve" => "CVE-2026-12345"
            })
        })

      [occurrence] = scan.findings
      canonical = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)

      assert canonical.reproduction_steps == "1. POST /login with ' OR 1=1 --"
      assert canonical.affected_versions == ">= 1.0, < 1.4.2"
      assert canonical.cwe_id == "CWE-89"
      assert canonical.cve_id == "CVE-2026-12345"
      assert canonical.vendor_notified_at == nil
    end

    test "leaves disclosure fields nil when the scan omits them", %{
      repository: repository,
      submitter: submitter
    } do
      scan =
        scan_fixture(repository, submitter, %{
          "commit_sha" => random_commit_sha(),
          "findings_json" => finding_doc("lib/auth.ex", 10, 12, "SQL injection")
        })

      [occurrence] = scan.findings
      canonical = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)

      assert canonical.reproduction_steps == nil
      assert canonical.affected_versions == nil
      assert canonical.cwe_id == nil
      assert canonical.cve_id == nil
    end

    test "keeps first non-null values and backfills only nil fields", %{
      repository: repository,
      submitter: submitter
    } do
      commit_sha = random_commit_sha()

      first =
        scan_fixture(repository, submitter, %{
          "commit_sha" => commit_sha,
          "findings_json" =>
            finding_doc("lib/auth.ex", 10, 12, "SQL injection", %{"cwe" => "CWE-89"})
        })

      [occurrence] = first.findings

      second =
        scan_fixture(repository, account_fixture(), %{
          "commit_sha" => commit_sha,
          "findings_json" =>
            finding_doc("lib/auth.ex", 10, 12, "SQL injection", %{
              "cwe" => "CWE-79",
              "cve" => "CVE-2026-12345",
              "reproduction" => "Later reproduction notes."
            })
        })

      [f2] = second.findings
      assert f2.canonical_finding_id == occurrence.canonical_finding_id

      canonical = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)
      assert canonical.cwe_id == "CWE-89"
      assert canonical.cve_id == "CVE-2026-12345"
      assert canonical.reproduction_steps == "Later reproduction notes."
    end
  end

  describe "regression detection" do
    setup do
      submitter = github_account_fixture()
      repository = github_repository_fixture(submitter)

      scan =
        scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})

      [occurrence] = scan.findings
      canonical = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)

      %{submitter: submitter, repository: repository, scan: scan, canonical: canonical}
    end

    defp settle_as_fixed(repository, canonical, commit_sha) do
      attrs = %{
        "commit_sha" => commit_sha,
        "verdict" => "fixed",
        "provenance" => "human",
        "notes" => "Confirmed the patch removes the interpolation at the pinned commit."
      }

      Enum.reduce(1..2, canonical, fn _index, _acc ->
        {:ok, _check, updated} =
          FindingMemory.record_check(
            Scope.for_account(reviewer_account_fixture()),
            repository,
            canonical.public_id,
            attrs
          )

        updated
      end)
    end

    test "stamps the fix and records nothing while the finding stays fixed", %{
      repository: repository,
      scan: scan,
      canonical: canonical
    } do
      fixed = settle_as_fixed(repository, canonical, scan.commit_sha)

      assert fixed.status == "fixed"
      assert fixed.fixed_at
      assert fixed.fixed_commit_sha == scan.commit_sha
      assert fixed.regressions_count == 0
      assert FindingMemory.list_repository_regressions(repository) == []
    end

    test "records a regression when the finding reappears at a later commit", %{
      submitter: submitter,
      repository: repository,
      scan: scan,
      canonical: canonical
    } do
      fixed = settle_as_fixed(repository, canonical, scan.commit_sha)

      later =
        scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})

      assert [occurrence] = later.findings
      assert occurrence.canonical_finding_id == canonical.id
      assert occurrence.disposition == "regression"

      assert [regression] = FindingMemory.list_repository_regressions(repository)
      assert regression.canonical_finding_id == canonical.id
      assert regression.detected_commit_sha == later.commit_sha
      assert regression.fixed_commit_sha == scan.commit_sha
      assert regression.detected_by_scan_id == later.id

      reloaded = Repo.get!(CanonicalFinding, canonical.id)
      assert reloaded.regressions_count == 1
      assert reloaded.last_regressed_at
      # The original time-to-fix survives the reintroduction.
      assert reloaded.fixed_at == fixed.fixed_at
    end

    test "rescanning the fixed commit is not a regression", %{
      submitter: submitter,
      repository: repository,
      scan: scan,
      canonical: canonical
    } do
      settle_as_fixed(repository, canonical, scan.commit_sha)

      _rescan =
        scan_fixture(repository, submitter, %{
          "commit_sha" => scan.commit_sha,
          "findings_json" => findings_json_fixture(1)
        })

      assert FindingMemory.list_repository_regressions(repository) == []
      assert Repo.get!(CanonicalFinding, canonical.id).regressions_count == 0
    end

    test "repeated scans of the same regressed commit count once", %{
      submitter: submitter,
      repository: repository,
      scan: scan,
      canonical: canonical
    } do
      settle_as_fixed(repository, canonical, scan.commit_sha)

      regressed_sha = random_commit_sha()

      for _attempt <- 1..2 do
        scan_fixture(repository, submitter, %{
          "commit_sha" => regressed_sha,
          "findings_json" => findings_json_fixture(1)
        })
      end

      assert [_single] = FindingMemory.list_repository_regressions(repository)
      assert Repo.get!(CanonicalFinding, canonical.id).regressions_count == 1
    end
  end

  describe "credit minting on status settlement" do
    setup do
      submitter = github_account_fixture()
      repository = github_repository_fixture(submitter)

      scan =
        scan_fixture(repository, submitter, %{
          "findings_json" => findings_json_fixture(1)
        })

      [occurrence] = scan.findings
      canonical = Repo.get!(CanonicalFinding, occurrence.canonical_finding_id)
      %{submitter: submitter, repository: repository, scan: scan, canonical: canonical}
    end

    test "settling a canonical as verified mints finder and checker credits", %{
      submitter: submitter,
      repository: repository,
      scan: scan,
      canonical: canonical
    } do
      attrs = %{
        "commit_sha" => scan.commit_sha,
        "verdict" => "confirmed",
        "provenance" => "human",
        "notes" => "Independently reproduced this exact canonical finding at the pinned commit."
      }

      first_checker = reviewer_account_fixture()
      second_checker = reviewer_account_fixture()

      assert {:ok, _check, still_open} =
               FindingMemory.record_check(
                 Scope.for_account(first_checker),
                 repository,
                 canonical.public_id,
                 attrs
               )

      assert still_open.status == "open"
      refute_enqueued(worker: MintWorker)

      assert {:ok, _check, verified} =
               FindingMemory.record_check(
                 Scope.for_account(second_checker),
                 repository,
                 canonical.public_id,
                 attrs
               )

      assert verified.status == "verified"

      mint_args = %{
        "canonical_finding_id" => canonical.id,
        "event" => "status_settled",
        "previous_status" => "open",
        "new_status" => "verified"
      }

      assert_enqueued(worker: MintWorker, args: mint_args)

      # Oban runs in manual mode: nothing is credited until the job performs.
      assert Credits.balance(submitter) == 0

      assert :ok = perform_job(MintWorker, mint_args)

      assert Credits.balance(submitter) == 50
      assert Credits.balance(first_checker) == 10
      assert Credits.balance(second_checker) == 10

      # Re-performing the settled job never double-mints.
      assert :ok = perform_job(MintWorker, mint_args)
      assert Credits.balance(submitter) == 50
      assert Credits.balance(first_checker) == 10
    end
  end

  defp finding_doc(file, line_start, line_end, title, extras \\ %{}) do
    Jason.encode!(%{
      "tarakan_scan_format" => 1,
      "findings" => [
        Map.merge(
          %{
            "file" => file,
            "line_start" => line_start,
            "line_end" => line_end,
            "severity" => "high",
            "title" => title,
            "description" => "Evidence body for assimilation tests."
          },
          extras
        )
      ]
    })
  end
end
