defmodule Tarakan.FindingSignals do
  @moduledoc """
  Second-opinion signals on a canonical finding: calibrated severity, and the
  client-submitted embedding used to cluster findings by code shape.

  None of this overwrites what the submitter claimed. `severity` stays as
  reported and `calibrated_severity` sits beside it, so the two can be compared
  and a rubric change can be re-run over old findings. Likewise `code_pattern_key`
  is separate from `pattern_key`, which hashes the normalized title: the title
  grouping keeps working while clusters fill in, and where they disagree that
  disagreement is visible rather than lost.

  Embeddings are produced by the worker with its own model, so vectors are only
  comparable within a model. Clustering therefore always partitions by
  `embedding_model` before comparing anything.
  """

  import Ecto.Query, warn: false

  alias Tarakan.Accounts.Scope
  alias Tarakan.Policy
  alias Tarakan.Repo
  alias Tarakan.Scans.CanonicalFinding
  alias Tarakan.Scans.CodePatternRule

  # Cosine similarity above which two findings are the same code pattern.
  # Deliberately high: a false merge corrupts an infestation for every repo in
  # it, while a false split just leaves two smaller patterns that still work.
  @cluster_threshold 0.86

  # Comparing a new finding against every stored vector is O(n) without pgvector.
  # Bounded so a large registry cannot turn one submission into a table scan.
  @max_cluster_candidates 2_000

  def cluster_threshold, do: @cluster_threshold

  @doc """
  Records a calibrated severity for `finding`.

  `rubric` is a version tag so a later rubric can be told apart from an earlier
  one and re-run selectively.
  """
  def calibrate_severity(%Scope{} = scope, %CanonicalFinding{} = finding, severity, rubric)
      when is_binary(severity) and is_binary(rubric) do
    with :ok <- authorize_finding(scope, finding) do
      finding
      |> CanonicalFinding.calibration_changeset(%{
        calibrated_severity: severity,
        calibrated_at: DateTime.utc_now(),
        severity_rubric: rubric
      })
      |> Repo.update()
    end
  end

  def calibrate_severity(_scope, _finding, _severity, _rubric), do: {:error, :unauthorized}

  @doc """
  Stores a client-submitted embedding and assigns the finding to a code cluster.

  Returns `{:ok, finding}` with `code_pattern_key` set: either the key of the
  nearest existing cluster above the threshold, or a fresh key seeded from this
  finding when nothing is close enough.
  """
  def record_embedding(%Scope{} = scope, %CanonicalFinding{} = finding, vector, model)
      when is_list(vector) and is_binary(model) do
    with :ok <- authorize_finding(scope, finding),
         {:ok, stored} <-
           finding
           |> CanonicalFinding.embedding_changeset(%{embedding: vector, embedding_model: model})
           |> Repo.update() do
      assign_cluster(stored)
    end
  end

  def record_embedding(_scope, _finding, _vector, _model), do: {:error, :unauthorized}

  @doc """
  Resolves and stores the code cluster for a finding that already has a vector.
  """
  def assign_cluster(%CanonicalFinding{embedding: nil} = finding), do: {:ok, finding}

  def assign_cluster(%CanonicalFinding{} = finding) do
    key =
      case nearest_cluster(finding) do
        {:ok, existing_key, _score} -> existing_key
        :none -> seed_key(finding)
      end

    finding
    |> CanonicalFinding.embedding_changeset(%{code_pattern_key: key})
    |> Repo.update()
  end

  @doc """
  Nearest already-clustered finding above the similarity threshold.

  Only compares against vectors from the same `embedding_model`: cosine distance
  between two different models' vector spaces is meaningless.
  """
  def nearest_cluster(%CanonicalFinding{embedding: nil}), do: :none

  def nearest_cluster(%CanonicalFinding{} = finding) do
    candidates =
      Repo.all(
        from item in CanonicalFinding,
          where:
            item.id != ^finding.id and
              not is_nil(item.embedding) and
              not is_nil(item.code_pattern_key) and
              item.embedding_model == ^finding.embedding_model,
          order_by: [desc: item.updated_at],
          limit: ^@max_cluster_candidates,
          select: %{
            code_pattern_key: item.code_pattern_key,
            embedding: item.embedding
          }
      )

    candidates
    |> Enum.map(fn item ->
      {item.code_pattern_key, cosine_similarity(finding.embedding, item.embedding)}
    end)
    |> Enum.filter(fn {_key, score} -> score >= @cluster_threshold end)
    |> case do
      [] -> :none
      scored -> scored |> Enum.max_by(&elem(&1, 1)) |> then(fn {k, s} -> {:ok, k, s} end)
    end
  end

  @doc """
  Cosine similarity of two equal-length vectors, in -1.0..1.0.

  Returns 0.0 for mismatched lengths or a zero vector rather than raising: a
  malformed neighbour should not be able to abort a submission.
  """
  def cosine_similarity(a, b) when is_list(a) and is_list(b) and length(a) == length(b) do
    {dot, norm_a, norm_b} =
      Enum.zip(a, b)
      |> Enum.reduce({0.0, 0.0, 0.0}, fn {x, y}, {dot, na, nb} ->
        {dot + x * y, na + x * x, nb + y * y}
      end)

    if norm_a == 0.0 or norm_b == 0.0 do
      0.0
    else
      dot / (:math.sqrt(norm_a) * :math.sqrt(norm_b))
    end
  end

  def cosine_similarity(_a, _b), do: 0.0

  @doc "Findings in one code cluster, newest first."
  def list_cluster(code_pattern_key, opts \\ []) when is_binary(code_pattern_key) do
    limit = opts |> Keyword.get(:limit, 50) |> max(1) |> min(200)

    Repo.all(
      from item in CanonicalFinding,
        where: item.code_pattern_key == ^code_pattern_key,
        order_by: [desc: item.updated_at],
        limit: ^limit,
        preload: [:repository]
    )
  end

  @doc """
  Stores a worker-authored detector for a code cluster.

  The worker validates: it has the rule engine and the code, so it runs the rule
  against known members of the cluster and reports which ones it hit. A rule that
  matched nothing is stored unvalidated and is never served.
  """
  def put_rule(%Scope{account: %Tarakan.Accounts.Account{} = account}, code_pattern_key, attrs)
      when is_binary(code_pattern_key) and is_map(attrs) do
    existing =
      Repo.get_by(CodePatternRule,
        code_pattern_key: code_pattern_key,
        author_account_id: account.id
      ) || %CodePatternRule{}

    existing
    |> CodePatternRule.changeset(
      attrs
      |> Map.put(:code_pattern_key, code_pattern_key)
      |> Map.put(:author_account_id, account.id)
    )
    |> Repo.insert_or_update()
  end

  def put_rule(_scope, _key, _attrs), do: {:error, :unauthorized}

  @doc """
  The servable rule for a cluster: validated, and the one that matched the most
  instances. Returns nil when nobody has produced a working detector yet, which
  is an honest "not yet" rather than a placeholder that matches nothing.
  """
  def best_rule(code_pattern_key) when is_binary(code_pattern_key) do
    Repo.one(
      from rule in CodePatternRule,
        where:
          rule.code_pattern_key == ^code_pattern_key and
            not is_nil(rule.validated_at) and rule.matched_count > 0,
        order_by: [desc: rule.matched_count, desc: rule.validated_at],
        limit: 1
    )
  end

  def best_rule(_key), do: nil

  @doc """
  The dominant code cluster among a title-keyed infestation's findings.

  Infestations are grouped by normalized title; clusters are grouped by code
  shape. They usually agree, but when they do not, the cluster shared by the
  most findings in the infestation is the one a detector should be built from.
  """
  def dominant_cluster(pattern_key) when is_binary(pattern_key) do
    Repo.all(
      from item in CanonicalFinding,
        where: item.pattern_key == ^pattern_key and not is_nil(item.code_pattern_key),
        group_by: item.code_pattern_key,
        order_by: [desc: count(item.id)],
        limit: 1,
        select: item.code_pattern_key
    )
    |> List.first()
  end

  def dominant_cluster(_key), do: nil

  # Reviewer qualification is evaluated against the repository, matching how
  # `FindingMemory` authorizes a check when there is no scan in hand.
  defp authorize_finding(scope, %CanonicalFinding{} = finding) do
    case Repo.get(Tarakan.Repositories.Repository, finding.repository_id) do
      nil -> {:error, :not_found}
      repository -> Policy.authorize(scope, :verify_review, repository)
    end
  end

  # A cluster is named after the finding that started it, so the key is stable
  # and traceable back to its seed.
  defp seed_key(%CanonicalFinding{public_id: public_id}) do
    "code:" <> (public_id |> to_string() |> String.replace("-", "") |> String.slice(0, 24))
  end
end
