defmodule Tarakan.FindingSignalsTest do
  use Tarakan.DataCase, async: true

  alias Tarakan.Accounts.Scope
  alias Tarakan.FindingSignals
  alias Tarakan.Repo
  alias Tarakan.Scans.CanonicalFinding

  defp canonical(repository, attrs) do
    defaults = %{
      repository_id: repository.id,
      fingerprint: "fp-#{System.unique_integer([:positive])}",
      file_path: "lib/a.ex",
      severity: "high",
      title: "Title #{System.unique_integer([:positive])}",
      description: "Description.",
      status: "open",
      first_seen_commit_sha: String.duplicate("a", 40),
      last_seen_commit_sha: String.duplicate("a", 40)
    }

    %CanonicalFinding{}
    |> CanonicalFinding.changeset(Map.merge(defaults, attrs))
    |> Repo.insert!()
  end

  defp with_vector(finding, vector, model \\ "test-embed-v1", key \\ nil) do
    finding
    |> CanonicalFinding.embedding_changeset(%{
      embedding: vector,
      embedding_model: model,
      code_pattern_key: key
    })
    |> Repo.update!()
  end

  describe "cosine_similarity/2" do
    test "identical vectors are 1.0 and opposites are -1.0" do
      assert_in_delta FindingSignals.cosine_similarity([1.0, 0.0], [1.0, 0.0]), 1.0, 1.0e-9
      assert_in_delta FindingSignals.cosine_similarity([1.0, 0.0], [-1.0, 0.0]), -1.0, 1.0e-9
      assert_in_delta FindingSignals.cosine_similarity([1.0, 0.0], [0.0, 1.0]), 0.0, 1.0e-9
    end

    test "magnitude does not matter, only direction" do
      assert_in_delta FindingSignals.cosine_similarity([1.0, 1.0], [9.0, 9.0]), 1.0, 1.0e-9
    end

    test "mismatched lengths and zero vectors degrade to 0.0 instead of raising" do
      assert FindingSignals.cosine_similarity([1.0, 0.0], [1.0]) == 0.0
      assert FindingSignals.cosine_similarity([0.0, 0.0], [1.0, 0.0]) == 0.0
    end
  end

  describe "embedding validation" do
    test "rejects an all-zero vector, which would match everything equally" do
      changeset =
        CanonicalFinding.embedding_changeset(%CanonicalFinding{}, %{embedding: [0.0, 0.0]})

      refute changeset.valid?
      assert %{embedding: ["must not be all zeros"]} = errors_on(changeset)
    end

    test "rejects an empty vector" do
      changeset = CanonicalFinding.embedding_changeset(%CanonicalFinding{}, %{embedding: []})
      refute changeset.valid?
    end
  end

  describe "assign_cluster/1" do
    setup do
      owner = github_account_fixture()
      %{repository: listed_github_repository_fixture(owner), account: reviewer_account_fixture()}
    end

    test "seeds a new cluster when nothing is close enough", %{repository: repository} do
      finding = repository |> canonical(%{}) |> with_vector([1.0, 0.0, 0.0])

      assert {:ok, clustered} = FindingSignals.assign_cluster(finding)
      assert clustered.code_pattern_key =~ ~r/^code:[0-9a-f]+$/
    end

    test "joins the nearest cluster above the threshold", %{repository: repository} do
      seed = repository |> canonical(%{}) |> with_vector([1.0, 0.0, 0.0])
      {:ok, seed} = FindingSignals.assign_cluster(seed)

      near = repository |> canonical(%{}) |> with_vector([0.98, 0.05, 0.0])
      assert {:ok, joined} = FindingSignals.assign_cluster(near)

      assert joined.code_pattern_key == seed.code_pattern_key
    end

    test "an unrelated vector starts its own cluster", %{repository: repository} do
      seed = repository |> canonical(%{}) |> with_vector([1.0, 0.0, 0.0])
      {:ok, seed} = FindingSignals.assign_cluster(seed)

      far = repository |> canonical(%{}) |> with_vector([0.0, 1.0, 0.0])
      assert {:ok, other} = FindingSignals.assign_cluster(far)

      refute other.code_pattern_key == seed.code_pattern_key
    end

    test "never clusters across embedding models", %{repository: repository} do
      seed = repository |> canonical(%{}) |> with_vector([1.0, 0.0, 0.0], "model-a")
      {:ok, seed} = FindingSignals.assign_cluster(seed)

      # Same direction, different model: the vector spaces are not comparable.
      other = repository |> canonical(%{}) |> with_vector([1.0, 0.0, 0.0], "model-b")
      assert {:ok, other} = FindingSignals.assign_cluster(other)

      refute other.code_pattern_key == seed.code_pattern_key
    end

    test "a finding without a vector is left alone", %{repository: repository} do
      finding = canonical(repository, %{})
      assert {:ok, unchanged} = FindingSignals.assign_cluster(finding)
      assert is_nil(unchanged.code_pattern_key)
    end
  end

  describe "calibrate_severity/4" do
    setup do
      owner = github_account_fixture()
      %{repository: listed_github_repository_fixture(owner), account: reviewer_account_fixture()}
    end

    test "stores the calibrated value beside the submitter's claim", %{
      repository: repository,
      account: account
    } do
      finding = canonical(repository, %{severity: "critical"})
      scope = Scope.for_account(account)

      assert {:ok, calibrated} =
               FindingSignals.calibrate_severity(scope, finding, "medium", "rubric-v1")

      # The claim is evidence about the submitter and must survive.
      assert calibrated.severity == "critical"
      assert calibrated.calibrated_severity == "medium"
      assert calibrated.severity_rubric == "rubric-v1"
      assert calibrated.calibrated_at
    end

    test "rejects a severity outside the scale", %{repository: repository, account: account} do
      finding = canonical(repository, %{})
      scope = Scope.for_account(account)

      assert {:error, changeset} =
               FindingSignals.calibrate_severity(scope, finding, "catastrophic", "rubric-v1")

      assert %{calibrated_severity: _} = errors_on(changeset)
    end
  end
end
