defmodule Tarakan.Scans.CanonicalFinding do
  @moduledoc """
  A repository-level security issue assembled from immutable finding occurrences.

  Exact deterministic matches are linked automatically. Raw report findings remain
  unchanged so provenance is never rewritten during assimilation.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias Tarakan.Repositories.Repository
  alias Tarakan.Scans.{Finding, FindingCheck}

  @statuses ~w(open verified disputed fixed)
  @severities ~w(critical high medium low info)

  schema "canonical_findings" do
    field :public_id, Ecto.UUID, autogenerate: true
    field :fingerprint, :string
    # Cross-repo infestation key: normalized title only (path/lines stripped).
    field :pattern_key, :string
    field :file_path, :string
    field :line_start, :integer
    field :line_end, :integer
    field :severity, :string
    field :title, :string
    field :description, :string
    field :status, :string, default: "open"
    field :first_seen_commit_sha, :string
    field :last_seen_commit_sha, :string
    field :detections_count, :integer, default: 0
    field :distinct_submitters_count, :integer, default: 0
    field :distinct_models_count, :integer, default: 0
    field :confirmations_count, :integer, default: 0
    field :disputes_count, :integer, default: 0
    field :verified_at, :utc_datetime_usec
    # Fix lifecycle: stamped when the status first settles on "fixed", and
    # counted again whenever the finding is seen after that (a regression).
    field :fixed_at, :utc_datetime_usec
    field :fixed_commit_sha, :string
    field :regressions_count, :integer, default: 0
    field :last_regressed_at, :utc_datetime_usec
    field :reproduction_steps, :string
    field :affected_versions, :string
    field :cwe_id, :string
    field :cve_id, :string
    field :vendor_notified_at, :utc_datetime_usec

    # Second opinion on severity, scored against a rubric. Never overwrites the
    # submitter's claim in `severity`, so the two stay comparable and the rubric
    # can be re-run over old findings.
    field :calibrated_severity, :string
    field :calibrated_at, :utc_datetime_usec
    field :severity_rubric, :string

    # Adversarial verification: attempts made, and attempts that failed to break
    # the finding.
    field :refutations_count, :integer, default: 0
    field :survived_refutations_count, :integer, default: 0

    # Checks that carried a working reproduction. A finding somebody re-ran and
    # watched fail is a different class of evidence from one somebody agreed with.
    field :reproductions_count, :integer, default: 0
    field :reproduced_at, :utc_datetime_usec

    # Client-submitted embedding of the finding's code context, plus the cluster
    # derived from it. Independent of `pattern_key`, which hashes the title.
    field :embedding, {:array, :float}
    field :embedding_model, :string
    field :code_pattern_key, :string

    belongs_to :repository, Repository
    has_many :occurrences, Finding
    has_many :checks, FindingCheck

    timestamps(type: :utc_datetime_usec)
  end

  def statuses, do: @statuses
  def severities, do: @severities

  @doc false
  def changeset(finding, attrs) do
    finding
    |> cast(attrs, [
      :repository_id,
      :fingerprint,
      :pattern_key,
      :file_path,
      :line_start,
      :line_end,
      :severity,
      :title,
      :description,
      :status,
      :first_seen_commit_sha,
      :last_seen_commit_sha,
      :detections_count,
      :distinct_submitters_count,
      :distinct_models_count,
      :confirmations_count,
      :disputes_count,
      :verified_at,
      :reproduction_steps,
      :affected_versions,
      :cwe_id,
      :cve_id,
      :vendor_notified_at
    ])
    |> validate_required([
      :repository_id,
      :fingerprint,
      :file_path,
      :severity,
      :title,
      :description,
      :status,
      :first_seen_commit_sha,
      :last_seen_commit_sha
    ])
    |> validate_inclusion(:status, @statuses)
    |> unique_constraint([:repository_id, :fingerprint])
    |> check_constraint(:status, name: :canonical_findings_status_must_be_valid)
  end

  @doc """
  Records a calibrated severity without touching the submitter's claim.
  """
  def calibration_changeset(finding, attrs) do
    finding
    |> cast(attrs, [:calibrated_severity, :calibrated_at, :severity_rubric])
    |> validate_required([:calibrated_severity, :severity_rubric])
    |> validate_inclusion(:calibrated_severity, @severities)
    |> validate_length(:severity_rubric, max: 40)
    |> check_constraint(:calibrated_severity,
      name: :canonical_findings_calibrated_severity_valid
    )
  end

  @doc """
  Stores the client-submitted embedding and the cluster it resolved to.
  """
  def embedding_changeset(finding, attrs) do
    finding
    |> cast(attrs, [:embedding, :embedding_model, :code_pattern_key])
    |> validate_length(:embedding_model, max: 120)
    |> validate_embedding()
  end

  @doc """
  Applies one adversarial check outcome. `survived?` is true when the attack was
  attempted and the finding held.
  """
  def refutation_changeset(finding, survived?) do
    change(finding, %{
      refutations_count: (finding.refutations_count || 0) + 1,
      survived_refutations_count:
        (finding.survived_refutations_count || 0) + if(survived?, do: 1, else: 0)
    })
    |> check_constraint(:survived_refutations_count,
      name: :canonical_findings_survived_within_attempts
    )
  end

  # Vectors are compared with cosine similarity, so a zero vector has no
  # direction and would match everything equally. Reject it rather than let it
  # poison a cluster.
  defp validate_embedding(changeset) do
    case get_change(changeset, :embedding) do
      nil ->
        changeset

      [] ->
        add_error(changeset, :embedding, "must not be empty")

      vector when is_list(vector) ->
        cond do
          not Enum.all?(vector, &is_number/1) ->
            add_error(changeset, :embedding, "must contain only numbers")

          Enum.all?(vector, &(&1 == 0)) ->
            add_error(changeset, :embedding, "must not be all zeros")

          length(vector) > 4096 ->
            add_error(changeset, :embedding, "must have at most 4096 dimensions")

          true ->
            changeset
        end

      _other ->
        add_error(changeset, :embedding, "must be a list of numbers")
    end
  end

  @doc """
  Edits disclosure metadata (reproduction, versions, CWE/CVE, vendor contact)
  without touching provenance counters.
  """
  def disclosure_changeset(finding, attrs) do
    finding
    |> cast(attrs, [
      :reproduction_steps,
      :affected_versions,
      :cwe_id,
      :cve_id,
      :vendor_notified_at
    ])
    |> validate_length(:reproduction_steps, max: 10_000)
    |> validate_length(:affected_versions, max: 500)
    |> validate_format(:cwe_id, ~r/^CWE-\d+$/)
    |> validate_format(:cve_id, ~r/^CVE-\d{4}-\d{4,7}$/)
  end
end
