defmodule Tarakan.FindingMemory do
  @moduledoc """
  Assimilates immutable report findings into canonical repository issues.

  Only deterministic fingerprints auto-link (no embeddings / LLM merge).
  Matching is exact on normalized path + line_start + title. Agent-provided
  dispositions and canonical IDs are retained as hints but never override
  server matching.

  Path/title normalization collapses common agent variants (`./path`,
  confidence prefixes, punctuation). A legacy fingerprint is still consulted
  so pre-existing canonical rows keep accumulating detections after upgrades.
  """

  import Ecto.Query, warn: false

  alias Tarakan.Abuse
  alias Tarakan.Accounts
  alias Tarakan.Accounts.{Account, Scope}
  alias Tarakan.ContentSafety
  alias Tarakan.Policy
  alias Tarakan.PromptSafety
  alias Tarakan.Repo
  alias Tarakan.RepositoryPath
  alias Tarakan.Repositories.{Repository, RepositoryMembership}

  alias Tarakan.Scans.{
    CanonicalFinding,
    Finding,
    FindingCheck,
    FindingRegression,
    Scan,
    ScanFormat
  }

  @counting_provenances ~w(human hybrid)
  @verification_threshold 2
  @quorum_states ["active"]

  @doc "Links every finding in a newly inserted scan to canonical memory."
  def assimilate_scan(%Scan{} = scan) do
    scan = Repo.preload(scan, :findings, force: true)
    disclosures = disclosures_by_position(scan)

    canonical_ids =
      Enum.map(scan.findings, fn occurrence ->
        fingerprint = fingerprint(occurrence)

        canonical =
          upsert_canonical(
            scan,
            occurrence,
            fingerprint,
            Map.get(disclosures, occurrence.position)
          )

        prior_detections =
          Repo.aggregate(
            from(item in Finding,
              where: item.canonical_finding_id == ^canonical.id,
              select: item.scan_id,
              distinct: true
            ),
            :count
          )

        regressed? = regression?(canonical, scan)

        disposition =
          cond do
            prior_detections == 0 -> "new"
            regressed? -> "regression"
            canonical.status == "fixed" -> "regression"
            occurrence.disposition == "regression" -> "regression"
            true -> "matches_existing"
          end

        occurrence
        |> Ecto.Changeset.change(
          canonical_finding_id: canonical.id,
          fingerprint: fingerprint,
          disposition: disposition
        )
        |> Repo.update!()

        if regressed?, do: record_regression(canonical, scan)

        canonical.id
      end)
      |> Enum.uniq()

    Enum.each(canonical_ids, &refresh_canonical/1)
    Repo.preload(scan, [findings: :canonical_finding], force: true)
  end

  # A finding is regressed when it carries a fix stamp and is reported again at
  # a commit other than the one it was fixed at. Rescanning the fixed commit
  # itself is a disagreement about the fix, not a reintroduction.
  defp regression?(%CanonicalFinding{fixed_at: nil}, _scan), do: false

  defp regression?(%CanonicalFinding{} = canonical, %Scan{} = scan) do
    scan.commit_sha != canonical.fixed_commit_sha
  end

  defp record_regression(%CanonicalFinding{} = canonical, %Scan{} = scan) do
    # Assimilation runs inside the submission transaction, where a raised
    # constraint violation would poison the whole insert. `on_conflict: :nothing`
    # makes a repeat detection a no-op instead: the returned struct has no id.
    {:ok, regression} =
      %FindingRegression{}
      |> FindingRegression.changeset(%{
        canonical_finding_id: canonical.id,
        repository_id: canonical.repository_id,
        detected_by_scan_id: scan.id,
        fixed_commit_sha: canonical.fixed_commit_sha,
        fixed_at: canonical.fixed_at,
        detected_commit_sha: scan.commit_sha
      })
      |> Repo.insert(
        on_conflict: :nothing,
        conflict_target: [:canonical_finding_id, :detected_commit_sha]
      )

    if regression.id do
      # Counters live on the canonical so list views never join.
      from(item in CanonicalFinding, where: item.id == ^canonical.id)
      |> Repo.update_all(
        inc: [regressions_count: 1],
        set: [last_regressed_at: regression.inserted_at]
      )

      {:ok, regression}
    else
      :already_recorded
    end
  end

  @doc """
  The account that first reported a canonical finding, or nil.

  There is exactly one first finder per finding, forever - later detections
  add corroboration but never displace it. This is the same query the credit
  mint uses to decide who gets paid, shared so the record and the payout can
  never name different people.
  """
  def first_finder(canonical_finding_id) when is_integer(canonical_finding_id) do
    Repo.one(
      from occurrence in Finding,
        join: scan in Scan,
        on: scan.id == occurrence.scan_id,
        join: account in Account,
        on: account.id == scan.submitted_by_id,
        where: occurrence.canonical_finding_id == ^canonical_finding_id,
        order_by: [asc: scan.inserted_at, asc: occurrence.id],
        limit: 1,
        select: %{
          handle: account.handle,
          account_id: account.id,
          reported_at: scan.inserted_at,
          commit_sha: scan.commit_sha
        }
    )
  end

  def first_finder(_canonical_finding_id), do: nil

  @doc "Regressions on one repository, newest first."
  def list_repository_regressions(%Repository{id: repository_id}, opts \\ []) do
    limit = opts |> Keyword.get(:limit, 20) |> min(100) |> max(1)

    Repo.all(
      from regression in FindingRegression,
        where: regression.repository_id == ^repository_id,
        order_by: [desc: regression.inserted_at, desc: regression.id],
        limit: ^limit,
        preload: [:canonical_finding]
    )
  end

  @doc "Regressions across every listed repository, newest first."
  def list_recent_regressions(opts \\ []) do
    limit = opts |> Keyword.get(:limit, 20) |> min(100) |> max(1)

    Repo.all(
      from regression in FindingRegression,
        join: repository in Repository,
        on: repository.id == regression.repository_id,
        where: repository.listing_status == "listed",
        order_by: [desc: regression.inserted_at, desc: regression.id],
        limit: ^limit,
        preload: [:canonical_finding, :repository]
    )
  end

  @doc """
  Stable fingerprint for a normalized finding occurrence.

  Uses repository-relative path (canonicalized), **line_start only** (line_end
  is display/range noise across agents), and normalized title.
  """
  def fingerprint(%{file_path: path, line_start: line_start, title: title}) do
    hash_parts([
      normalize_path(path),
      number(line_start),
      normalize_title(title)
    ])
  end

  @doc false
  # Frozen v1 algorithm (path + start + end + title) for upgrade lookup only.
  def legacy_fingerprint(%{
        file_path: path,
        line_start: line_start,
        line_end: line_end,
        title: title
      }) do
    hash_parts([
      legacy_normalize(path),
      number(line_start),
      number(line_end),
      legacy_normalize(title)
    ])
  end

  @doc """
  Cross-repository infestation key: normalized title only.

  Same class of issue in different repos/paths share a pattern_key even when
  the full fingerprint differs.
  """
  def pattern_key(%{title: title}), do: pattern_key(title)

  def pattern_key(title) when is_binary(title) do
    title
    |> normalize_title()
    |> then(&:crypto.hash(:sha256, &1))
    |> Base.encode16(case: :lower)
  end

  def pattern_key(_), do: pattern_key("")

  @doc "Lists prompt-safe canonical memory backed by publicly disclosed occurrences."
  def list_repository_memory(%Repository{id: repository_id}, opts \\ []) do
    limit = opts |> Keyword.get(:limit, 200) |> min(500) |> max(1)

    rows =
      Repo.all(
        from canonical in CanonicalFinding,
          join: occurrence in Finding,
          on: occurrence.canonical_finding_id == canonical.id,
          join: scan in Scan,
          on: scan.id == occurrence.scan_id,
          where:
            canonical.repository_id == ^repository_id and
              scan.visibility == "public",
          distinct: canonical.id,
          order_by: [asc: canonical.id, desc: scan.inserted_at, desc: occurrence.id],
          select: {canonical, occurrence, scan.commit_sha},
          limit: ^limit
      )

    rows
    |> Enum.map(fn {canonical, occurrence, commit_sha} ->
      last_sha = canonical.last_seen_commit_sha || commit_sha

      %{
        id: canonical.id,
        public_id: canonical.public_id,
        occurrence_public_id: occurrence.public_id,
        status: canonical.status,
        file_path: occurrence.file_path,
        line_start: occurrence.line_start,
        line_end: occurrence.line_end,
        severity: occurrence.severity,
        # Titles and descriptions are contributor-written and are read by other
        # contributors' agents, so they are neutralized here rather than at
        # each call site.
        title: PromptSafety.sanitize_line(occurrence.title),
        description: PromptSafety.sanitize(occurrence.description, max_bytes: 2_000),
        first_seen_commit_sha: canonical.first_seen_commit_sha,
        last_seen_commit_sha: last_sha,
        detections_count: canonical.detections_count,
        distinct_submitters_count: canonical.distinct_submitters_count,
        distinct_models_count: canonical.distinct_models_count,
        confirmations_count: canonical.confirmations_count,
        disputes_count: canonical.disputes_count,
        trust: trust_summary(canonical, last_sha)
      }
    end)
    |> Enum.sort_by(&evidence_sort_key/1)
  end

  @doc "Finds one canonical issue by its non-enumerable public ID."
  def get(repository, public_id) do
    with {:ok, public_id} <- Ecto.UUID.cast(public_id),
         %CanonicalFinding{} = finding <-
           Repo.get_by(CanonicalFinding, repository_id: repository.id, public_id: public_id) do
      {:ok, finding}
    else
      _missing -> {:error, :not_found}
    end
  end

  @doc "Records an independent per-finding check at a specific commit."
  def record_check(
        %Scope{account: %Account{} = account} = scope,
        %Repository{} = repository,
        public_id,
        attrs
      ) do
    attrs = stringify_keys(attrs)

    with {:ok, canonical} <- get(repository, public_id),
         commit_sha when is_binary(commit_sha) <- attrs["commit_sha"],
         %Finding{} = occurrence <- occurrence_for_check(canonical.id, commit_sha) do
      Repo.transaction(fn ->
        locked_repository =
          Repo.one!(
            from item in Repository,
              where: item.id == ^repository.id,
              lock: "FOR UPDATE"
          )

        fresh_account =
          Repo.one!(
            from item in Account,
              where: item.id == ^account.id,
              lock: "FOR UPDATE"
          )

        canonical =
          Repo.one!(
            from item in CanonicalFinding,
              where: item.id == ^canonical.id,
              lock: "FOR UPDATE"
          )

        fresh_scope =
          case Accounts.refresh_scope_for_account(fresh_account, scope) do
            {:ok, refreshed} -> refreshed
            {:error, reason} -> Repo.rollback(reason)
          end

        scan = %{Repo.get!(Scan, occurrence.scan_id) | repository: locked_repository}

        with :ok <- Policy.authorize(fresh_scope, :verify_review, scan),
             :ok <- ensure_independent(canonical.id, commit_sha, fresh_account.id) do
          :ok
        else
          {:error, reason} -> Repo.rollback(reason)
        end

        check_attrs =
          attrs
          |> Map.put("client_ip_hash", Abuse.hash_client_ip(attrs["client_ip"]))
          |> Map.delete("client_ip")

        # A reproduction transcript is the likeliest place in the system for a
        # credential to surface: a PoC that dumps the environment, a stack trace
        # carrying a connection string, a token echoed in a response body. It is
        # machine-generated and rarely read before submission, so it gets the
        # same scan as prose the submitter actually wrote.
        with :ok <- ContentSafety.scan_text(check_attrs["notes"]),
             :ok <- ContentSafety.scan_text(check_attrs["evidence"]),
             :ok <- ContentSafety.scan_text(check_attrs["repro_artifact"]),
             :ok <- ContentSafety.scan_text(check_attrs["repro_transcript"]) do
          :ok
        else
          {:error, :secrets_detected} -> Repo.rollback(:secrets_detected)
        end

        check =
          %FindingCheck{
            canonical_finding_id: canonical.id,
            scan_finding_id: occurrence.id,
            account_id: fresh_account.id
          }
          |> FindingCheck.changeset(check_attrs)

        case Repo.insert(check) do
          {:ok, inserted} ->
            transition = refresh_canonical_checks(canonical.id)
            Tarakan.Scans.recalculate_repository_metrics(repository.id)
            {inserted, transition}

          {:error, changeset} ->
            Repo.rollback(changeset)
        end
      end)
      |> case do
        {:ok, {check, transition}} ->
          refresh_scans_for_check(canonical.id, check.commit_sha)
          updated = Repo.get!(CanonicalFinding, canonical.id)

          if is_binary(updated.pattern_key) and updated.pattern_key != "" do
            _ =
              Tarakan.Infestations.schedule_refresh_after_commit([updated.pattern_key],
                reason: :status
              )
          end

          maybe_enqueue_status_mint(canonical.id, transition)

          {:ok, check, updated}

        {:error, reason} ->
          {:error, reason}
      end
    else
      nil -> {:error, :commit_not_found}
      error -> error
    end
  end

  def record_check(_scope, _repository, _public_id, _attrs), do: {:error, :unauthorized}

  @doc """
  Records (or clears) the vendor-notification timestamp on a canonical finding.

  Authorized exactly like recording a check: platform reviewers, moderators,
  or verified repository reviewers/stewards.
  """
  def record_vendor_notification(
        %Scope{account: %Account{}} = scope,
        %Repository{} = repository,
        public_id,
        attrs
      ) do
    with {:ok, canonical} <- get(repository, public_id),
         :ok <- Policy.authorize(scope, :verify_review, repository) do
      canonical
      |> CanonicalFinding.disclosure_changeset(attrs)
      |> Repo.update()
    end
  end

  def record_vendor_notification(_scope, _repository, _public_id, _attrs),
    do: {:error, :unauthorized}

  @doc "Whether a scope may record vendor notification for a repository's findings."
  def can_record_vendor_notification?(
        %Scope{account: %Account{}} = scope,
        %Repository{} = repository
      ) do
    Policy.allowed?(scope, :verify_review, repository)
  end

  def can_record_vendor_notification?(_scope, _repository), do: false

  @doc "Whether a scope may independently check this canonical finding."
  def can_check?(
        %Scope{account: %Account{id: account_id}} = scope,
        repository,
        public_id,
        commit_sha
      ) do
    with {:ok, canonical} <- get(repository, public_id),
         %Finding{} = occurrence <- occurrence_for_check(canonical.id, commit_sha),
         scan <- Repo.get!(Scan, occurrence.scan_id) |> Repo.preload(:repository),
         :ok <- Policy.authorize(scope, :verify_review, scan),
         :ok <- ensure_independent(canonical.id, commit_sha, account_id),
         :ok <- ensure_not_checked(canonical.id, commit_sha, account_id) do
      true
    else
      _reason -> false
    end
  end

  def can_check?(_scope, _repository, _public_id, _commit_sha), do: false

  @doc "Lists the named checks behind a canonical tally at one commit."
  def list_checks(canonical_id, commit_sha)
      when is_integer(canonical_id) and is_binary(commit_sha) do
    Repo.all(
      from check in FindingCheck,
        join: canonical in assoc(check, :canonical_finding),
        join: account in assoc(check, :account),
        left_join: membership in RepositoryMembership,
        on:
          membership.repository_id == canonical.repository_id and
            membership.account_id == account.id and membership.status == "verified" and
            membership.role in ["reviewer", "steward"],
        where: check.canonical_finding_id == ^canonical_id and check.commit_sha == ^commit_sha,
        order_by: [desc: check.inserted_at, desc: check.id],
        select: {check, account, membership}
    )
    |> Enum.map(fn {check, account, membership} ->
      counts? =
        check_counts_toward_quorum?(
          check,
          account,
          membership
        )

      %{
        id: check.id,
        verdict: check.verdict,
        provenance: check.provenance,
        notes: check.notes,
        evidence: check.evidence,
        inserted_at: check.inserted_at,
        account: account,
        counts_toward_quorum: counts?
      }
    end)
  end

  def list_checks(_canonical_id, _commit_sha), do: []

  @doc "Returns the public IDs the current account may check, using batched conflict queries."
  def checkable_public_ids(
        %Scope{account: %Account{id: account_id}} = scope,
        %Repository{} = repository,
        findings
      )
      when is_list(findings) do
    subject = %Scan{repository: repository, repository_id: repository.id}

    if findings == [] or not Policy.allowed?(scope, :verify_review, subject) do
      MapSet.new()
    else
      canonical_ids = Enum.map(findings, & &1.id)

      authored_pairs =
        Repo.all(
          from occurrence in Finding,
            join: scan in Scan,
            on: scan.id == occurrence.scan_id,
            where:
              occurrence.canonical_finding_id in ^canonical_ids and
                scan.submitted_by_id == ^account_id,
            select: {occurrence.canonical_finding_id, scan.commit_sha},
            distinct: true
        )
        |> MapSet.new()

      checked_pairs =
        Repo.all(
          from check in FindingCheck,
            where:
              check.canonical_finding_id in ^canonical_ids and check.account_id == ^account_id,
            select: {check.canonical_finding_id, check.commit_sha}
        )
        |> MapSet.new()

      findings
      |> Enum.reject(fn finding ->
        pair = {finding.id, finding.last_seen_commit_sha}
        MapSet.member?(authored_pairs, pair) or MapSet.member?(checked_pairs, pair)
      end)
      |> Enum.map(& &1.public_id)
      |> MapSet.new()
    end
  end

  def checkable_public_ids(_scope, _repository, _findings), do: MapSet.new()

  @doc "Whether every finding occurrence in a report has canonical check quorum at its commit."
  def scan_verified?(%Scan{} = scan) do
    canonical_ids =
      Repo.all(
        from occurrence in Finding,
          where: occurrence.scan_id == ^scan.id and not is_nil(occurrence.canonical_finding_id),
          select: occurrence.canonical_finding_id,
          distinct: true
      )

    if canonical_ids == [] do
      not is_nil(scan.verified_at)
    else
      tallies = qualified_check_tallies(scan.repository_id, canonical_ids, scan.commit_sha)

      Enum.all?(canonical_ids, fn canonical_id ->
        tally = Map.get(tallies, canonical_id, %{confirmed: 0, disputed: 0, fixed: 0})

        tally.confirmed - tally.disputed >= @verification_threshold or
          tally.fixed - tally.disputed >= @verification_threshold
      end)
    end
  end

  @doc "Recomputes canonical check quorum after account or membership authority changes."
  def refresh_repository_checks(repository_id) when is_integer(repository_id) do
    canonical_ids =
      Repo.all(
        from canonical in CanonicalFinding,
          where: canonical.repository_id == ^repository_id,
          select: canonical.id
      )

    Enum.each(canonical_ids, fn canonical_id ->
      maybe_enqueue_status_mint(canonical_id, refresh_canonical_checks(canonical_id))
    end)

    Repo.all(
      from scan in Scan,
        where: scan.repository_id == ^repository_id,
        select: scan.id
    )
    |> Enum.each(&refresh_scan_verification/1)

    :ok
  end

  @doc "Copies a legacy report-level verdict onto each canonical finding occurrence."
  def assimilate_report_check(%Scan{} = scan, confirmation, account) do
    occurrences =
      Repo.all(
        from occurrence in Finding,
          where: occurrence.scan_id == ^scan.id and not is_nil(occurrence.canonical_finding_id)
      )

    canonical_ids =
      Enum.map(occurrences, fn occurrence ->
        attrs = %{
          "commit_sha" => scan.commit_sha,
          "verdict" => confirmation.verdict,
          "provenance" => confirmation.provenance,
          "notes" => confirmation.notes,
          "evidence" => confirmation.evidence
        }

        %FindingCheck{
          canonical_finding_id: occurrence.canonical_finding_id,
          scan_finding_id: occurrence.id,
          account_id: account.id
        }
        |> FindingCheck.changeset(attrs)
        |> Repo.insert(
          on_conflict: :nothing,
          conflict_target: [:canonical_finding_id, :account_id, :commit_sha]
        )

        occurrence.canonical_finding_id
      end)
      |> Enum.uniq()

    Enum.each(canonical_ids, fn canonical_id ->
      maybe_enqueue_status_mint(canonical_id, refresh_canonical_checks(canonical_id))
    end)

    :ok
  end

  defp upsert_canonical(scan, occurrence, fingerprint, disclosure) do
    pattern = pattern_key(occurrence)

    case get_canonical(scan.repository_id, fingerprint) ||
           upgrade_legacy_canonical(scan.repository_id, occurrence, fingerprint, pattern) do
      %CanonicalFinding{} = existing ->
        backfill_disclosure(existing, disclosure)

      nil ->
        attrs = %{
          repository_id: scan.repository_id,
          fingerprint: fingerprint,
          pattern_key: pattern,
          file_path: occurrence.file_path,
          line_start: occurrence.line_start,
          line_end: occurrence.line_end,
          severity: occurrence.severity,
          title: occurrence.title,
          description: occurrence.description,
          reproduction_steps: disclosure[:reproduction_steps],
          affected_versions: disclosure[:affected_versions],
          cwe_id: disclosure[:cwe_id],
          cve_id: disclosure[:cve_id],
          first_seen_commit_sha: scan.commit_sha,
          last_seen_commit_sha: scan.commit_sha
        }

        %CanonicalFinding{}
        |> CanonicalFinding.changeset(attrs)
        |> Repo.insert(
          on_conflict: {:replace, [:pattern_key]},
          conflict_target: [:repository_id, :fingerprint]
        )

        get_canonical(scan.repository_id, fingerprint) ||
          Repo.get_by!(CanonicalFinding,
            repository_id: scan.repository_id,
            fingerprint: fingerprint
          )
    end
  end

  # Disclosure metadata travels in the scan document, not on occurrence rows;
  # re-parse the raw document so canonical creation can consult the values.
  defp disclosures_by_position(%Scan{raw_document: raw_document})
       when is_binary(raw_document) do
    case ScanFormat.parse(raw_document) do
      {:ok, findings} ->
        Map.new(findings, fn finding ->
          {finding.position,
           %{
             reproduction_steps: finding.reproduction_steps,
             affected_versions: finding.affected_versions,
             cwe_id: finding.cwe_id,
             cve_id: finding.cve_id
           }}
        end)

      {:error, _message} ->
        %{}
    end
  end

  defp disclosures_by_position(%Scan{}), do: %{}

  # First non-null wins: only fill fields the canonical row still lacks.
  defp backfill_disclosure(%CanonicalFinding{} = existing, disclosure)
       when is_map(disclosure) do
    updates =
      [:reproduction_steps, :affected_versions, :cwe_id, :cve_id]
      |> Enum.filter(fn field ->
        is_nil(Map.get(existing, field)) and not is_nil(Map.get(disclosure, field))
      end)
      |> Map.new(fn field -> {field, Map.get(disclosure, field)} end)

    if updates == %{} do
      existing
    else
      existing
      |> Ecto.Changeset.change(updates)
      |> Repo.update!()
    end
  end

  defp backfill_disclosure(%CanonicalFinding{} = existing, _disclosure), do: existing

  defp get_canonical(repository_id, fingerprint) do
    Repo.get_by(CanonicalFinding, repository_id: repository_id, fingerprint: fingerprint)
  end

  # If an older report created a v1 fingerprint, re-key it to v2 so new reports merge.
  defp upgrade_legacy_canonical(repository_id, occurrence, fingerprint, pattern) do
    legacy = legacy_fingerprint(occurrence)

    if legacy == fingerprint do
      nil
    else
      case get_canonical(repository_id, legacy) do
        %CanonicalFinding{} = existing ->
          existing
          |> Ecto.Changeset.change(fingerprint: fingerprint, pattern_key: pattern)
          |> Repo.update!()

        nil ->
          nil
      end
    end
  end

  defp refresh_canonical(canonical_id) do
    stats =
      Repo.one!(
        from occurrence in Finding,
          join: scan in Scan,
          on: scan.id == occurrence.scan_id,
          where: occurrence.canonical_finding_id == ^canonical_id,
          select: %{
            detections: count(scan.id, :distinct),
            submitters: count(scan.submitted_by_id, :distinct),
            models: count(scan.model, :distinct),
            first_seen: min(scan.inserted_at),
            last_seen: max(scan.inserted_at)
          }
      )

    first_commit = commit_at(canonical_id, stats.first_seen, :asc)
    last_commit = commit_at(canonical_id, stats.last_seen, :desc)

    canonical = Repo.get!(CanonicalFinding, canonical_id)

    canonical
    |> Ecto.Changeset.change(
      detections_count: stats.detections,
      distinct_submitters_count: stats.submitters,
      distinct_models_count: stats.models,
      first_seen_commit_sha: first_commit,
      last_seen_commit_sha: last_commit
    )
    |> Repo.update!()

    maybe_enqueue_status_mint(canonical_id, refresh_canonical_checks(canonical_id))
  end

  defp commit_at(canonical_id, _timestamp, :asc) do
    Repo.one!(
      from occurrence in Finding,
        join: scan in Scan,
        on: scan.id == occurrence.scan_id,
        where: occurrence.canonical_finding_id == ^canonical_id,
        order_by: [asc: scan.inserted_at, asc: occurrence.id],
        limit: 1,
        select: scan.commit_sha
    )
  end

  defp commit_at(canonical_id, _timestamp, :desc) do
    Repo.one!(
      from occurrence in Finding,
        join: scan in Scan,
        on: scan.id == occurrence.scan_id,
        where: occurrence.canonical_finding_id == ^canonical_id,
        order_by: [desc: scan.inserted_at, desc: occurrence.id],
        limit: 1,
        select: scan.commit_sha
    )
  end

  # Returns {previous_status, updated_canonical} so callers can enqueue credit
  # mints when the settled status changes.
  defp refresh_canonical_checks(canonical_id) do
    canonical = Repo.get!(CanonicalFinding, canonical_id)
    previous_status = canonical.status

    # Load candidate checks then filter collusion in Elixir (IP hash rules).
    candidates =
      Repo.all(
        from check in FindingCheck,
          join: account in Account,
          on: account.id == check.account_id,
          left_join: membership in RepositoryMembership,
          on:
            membership.repository_id == ^canonical.repository_id and
              membership.account_id == account.id and membership.status == "verified" and
              membership.role in ["reviewer", "steward"],
          where:
            check.canonical_finding_id == ^canonical.id and
              check.commit_sha == ^canonical.last_seen_commit_sha and
              check.provenance in ^@counting_provenances and
              account.state in ^@quorum_states and
              (account.trust_tier == "reviewer" or
                 account.platform_role in ["moderator", "admin"] or not is_nil(membership.id)),
          select: check
      )
      |> Enum.reject(fn check ->
        Abuse.colluding_ip_check?(canonical.id, check.account_id, check.client_ip_hash)
      end)

    tallies = %{
      confirmed: Enum.count(candidates, &(&1.verdict == "confirmed")),
      disputed: Enum.count(candidates, &(&1.verdict == "disputed")),
      fixed: Enum.count(candidates, &(&1.verdict == "fixed"))
    }

    {status, verified_at} =
      cond do
        tallies.fixed - tallies.disputed >= @verification_threshold ->
          {"fixed", canonical.verified_at || DateTime.utc_now()}

        tallies.confirmed - tallies.disputed >= @verification_threshold ->
          {"verified", canonical.verified_at || DateTime.utc_now()}

        tallies.disputed > tallies.confirmed ->
          {"disputed", nil}

        true ->
          {"open", nil}
      end

    # The fix stamp records the first settlement only: a finding that regresses
    # and is fixed again keeps its original time-to-fix, and the regression is
    # counted separately.
    fix_changes =
      if status == "fixed" and is_nil(canonical.fixed_at) do
        [fixed_at: DateTime.utc_now(), fixed_commit_sha: canonical.last_seen_commit_sha]
      else
        []
      end

    # Adversarial and reproduction evidence is counted over the finding's whole
    # life, not just the current commit: an attack that failed two commits ago
    # still failed, and a reproduction that worked still worked.
    adversarial = adversarial_tallies(canonical)
    repro = reproduction_tallies(canonical)

    repro_changes =
      if repro.count > 0 and is_nil(canonical.reproduced_at) do
        [reproduced_at: DateTime.utc_now()]
      else
        []
      end

    updated =
      canonical
      |> Ecto.Changeset.change(
        [
          status: status,
          confirmations_count: tallies.confirmed,
          disputes_count: tallies.disputed,
          verified_at: verified_at,
          refutations_count: adversarial.attempts,
          survived_refutations_count: adversarial.survived,
          reproductions_count: repro.count
        ] ++ fix_changes ++ repro_changes
      )
      |> Repo.update!()

    {previous_status, updated}
  end

  # Checks that qualify for counting, across every commit. Same account-quality
  # and collusion gate as the quorum tally, minus the commit filter.
  defp qualified_lifetime_checks(canonical, extra_where) do
    from(check in FindingCheck,
      join: account in Account,
      on: account.id == check.account_id,
      where:
        check.canonical_finding_id == ^canonical.id and
          check.provenance in ^@counting_provenances and
          account.state in ^@quorum_states,
      select: check
    )
    |> where(^extra_where)
    |> Repo.all()
    |> Enum.reject(fn check ->
      Abuse.colluding_ip_check?(canonical.id, check.account_id, check.client_ip_hash)
    end)
  end

  defp adversarial_tallies(canonical) do
    checks = qualified_lifetime_checks(canonical, dynamic([check], check.adversarial))

    %{
      attempts: length(checks),
      # An adversarial check that lands on "confirmed" tried to break the finding
      # and could not. That is what survival means here.
      survived: Enum.count(checks, &(&1.verdict == "confirmed"))
    }
  end

  defp reproduction_tallies(canonical) do
    checks = qualified_lifetime_checks(canonical, dynamic([check], check.repro_status == "pass"))
    %{count: length(checks)}
  end

  # Enqueues the credit mint worker for a settled status transition. Safe to
  # call inside a transaction: the Oban insert joins it and only becomes
  # visible on commit.
  defp maybe_enqueue_status_mint(canonical_id, {previous_status, %CanonicalFinding{} = updated}) do
    if updated.status != previous_status do
      _ = Tarakan.Credits.MintWorker.enqueue(canonical_id, previous_status, updated.status)
      # Carrying a fix elsewhere is tracked separately from fixing it here; a
      # settled target closes its propagation without the propagation ever
      # being what decided the status.
      _ = Tarakan.Fixes.refresh_propagation_statuses(canonical_id)

      # A fix on a cross-repo pattern becomes a template other repositories can
      # start from. Capture is automatic; carrying it anywhere is not.
      if updated.status == "fixed" do
        _ = Tarakan.Fixes.CaptureWorker.enqueue(canonical_id)
      end
    end

    :ok
  end

  defp qualified_check_tallies(repository_id, canonical_ids, commit_sha) do
    Repo.all(
      from check in FindingCheck,
        join: account in Account,
        on: account.id == check.account_id,
        left_join: membership in RepositoryMembership,
        on:
          membership.repository_id == ^repository_id and
            membership.account_id == account.id and membership.status == "verified" and
            membership.role in ["reviewer", "steward"],
        where:
          check.canonical_finding_id in ^canonical_ids and check.commit_sha == ^commit_sha and
            check.provenance in ^@counting_provenances and
            account.state in ^@quorum_states and
            (account.trust_tier == "reviewer" or
               account.platform_role in ["moderator", "admin"] or not is_nil(membership.id)),
        select: check
    )
    |> Enum.reject(fn check ->
      Abuse.colluding_ip_check?(
        check.canonical_finding_id,
        check.account_id,
        check.client_ip_hash
      )
    end)
    |> Enum.group_by(& &1.canonical_finding_id)
    |> Map.new(fn {canonical_id, checks} ->
      {canonical_id,
       %{
         confirmed: Enum.count(checks, &(&1.verdict == "confirmed")),
         disputed: Enum.count(checks, &(&1.verdict == "disputed")),
         fixed: Enum.count(checks, &(&1.verdict == "fixed"))
       }}
    end)
  end

  defp check_counts_toward_quorum?(check, account, membership) do
    check.provenance in @counting_provenances and
      account.state in @quorum_states and
      (account.trust_tier == "reviewer" or
         account.platform_role in ["moderator", "admin"] or not is_nil(membership)) and
      not Abuse.colluding_ip_check?(
        check.canonical_finding_id,
        check.account_id,
        check.client_ip_hash
      )
  end

  defp refresh_scans_for_check(canonical_id, commit_sha) do
    Repo.all(
      from occurrence in Finding,
        join: scan in Scan,
        on: scan.id == occurrence.scan_id,
        where:
          occurrence.canonical_finding_id == ^canonical_id and scan.commit_sha == ^commit_sha,
        select: scan.id,
        distinct: true
    )
    |> Enum.each(&refresh_scan_verification/1)
  end

  defp refresh_scan_verification(scan_id) do
    scan = Repo.get!(Scan, scan_id)
    verified? = scan_verified?(scan)

    changeset =
      cond do
        verified? ->
          Ecto.Changeset.change(scan, verified_at: scan.verified_at || DateTime.utc_now())

        scan.review_status == "accepted" ->
          Scan.quorum_lost_changeset(scan)

        true ->
          Ecto.Changeset.change(scan, verified_at: nil)
      end

    Repo.update!(changeset)
  end

  @doc """
  Trust chips for UI: reported always; agent_reproduced / human_checked from checks.

  Agent checks never alone equal quorum; human_checked means a qualified
  human/hybrid confirmation exists at this commit.
  """
  def trust_summary(%CanonicalFinding{} = canonical, commit_sha)
      when is_binary(commit_sha) and commit_sha != "" do
    checks = list_checks(canonical.id, commit_sha)

    agent_reproduced =
      Enum.any?(checks, fn c -> c.verdict == "confirmed" and c.provenance == "agent" end)

    human_checked =
      Enum.any?(checks, fn c ->
        c.verdict in ["confirmed", "fixed"] and c.counts_toward_quorum
      end)

    agent_disputed =
      Enum.any?(checks, fn c -> c.verdict == "disputed" and c.provenance == "agent" end)

    %{
      reported: true,
      agent_reproduced: agent_reproduced,
      agent_disputed: agent_disputed,
      human_checked: human_checked,
      verified: canonical.status == "verified",
      disputed: canonical.status == "disputed",
      fixed: canonical.status == "fixed"
    }
  end

  def trust_summary(_canonical, _commit_sha) do
    %{
      reported: true,
      agent_reproduced: false,
      agent_disputed: false,
      human_checked: false,
      verified: false,
      disputed: false,
      fixed: false
    }
  end

  defp occurrence_for_check(canonical_id, commit_sha) do
    Repo.one(
      from occurrence in Finding,
        join: scan in Scan,
        on: scan.id == occurrence.scan_id,
        where:
          occurrence.canonical_finding_id == ^canonical_id and scan.commit_sha == ^commit_sha and
            scan.visibility == "public",
        order_by: [desc: scan.inserted_at, desc: occurrence.id],
        limit: 1,
        select: occurrence
    )
  end

  defp ensure_independent(canonical_id, commit_sha, account_id) do
    authored? =
      Repo.exists?(
        from occurrence in Finding,
          join: scan in Scan,
          on: scan.id == occurrence.scan_id,
          where:
            occurrence.canonical_finding_id == ^canonical_id and
              scan.commit_sha == ^commit_sha and scan.submitted_by_id == ^account_id
      )

    if authored?, do: {:error, :conflict_of_interest}, else: :ok
  end

  defp ensure_not_checked(canonical_id, commit_sha, account_id) do
    checked? =
      Repo.exists?(
        from check in FindingCheck,
          where:
            check.canonical_finding_id == ^canonical_id and
              check.commit_sha == ^commit_sha and check.account_id == ^account_id
      )

    if checked?, do: {:error, :already_checked}, else: :ok
  end

  defp hash_parts(parts) do
    parts
    |> Enum.join(<<31>>)
    |> then(&:crypto.hash(:sha256, &1))
    |> Base.encode16(case: :lower)
  end

  defp normalize_path(path), do: RepositoryPath.fingerprint_form(path)

  # Aggressive normalize so near-duplicate agent dumps assimilate (spam collapse).
  defp normalize_title(value) do
    value
    |> to_string()
    |> String.downcase()
    |> strip_confidence_prefixes()
    |> String.replace(~r/[^\p{L}\p{N}\s\/\.\-_]/u, " ")
    |> String.replace(~r/\s+/u, " ")
    |> String.trim()
    |> String.trim_trailing(".")
  end

  # Frozen copy of the pre-v2 title/path normalizer for legacy fingerprints.
  defp legacy_normalize(value) do
    value
    |> to_string()
    |> String.downcase()
    |> String.replace(
      ~r/^(verified|hypothesis(?:\/low)?|unverified|likely|possible|confirmed)\s*:\s*/iu,
      ""
    )
    |> String.replace(~r/[^\p{L}\p{N}\s\/\.\-_]/u, " ")
    |> String.replace(~r/\s+/u, " ")
    |> String.trim()
  end

  defp strip_confidence_prefixes(title) do
    # Repeatedly strip agent boilerplate so "Verified: Likely: X" collapses.
    prefixes =
      ~r/^(?:verified|hypothesis(?:\/low)?|unverified|likely|possible|confirmed|warning|error|critical|high|medium|low|info|cwe[-\s]?\d+)\s*:\s*/iu

    next = String.replace(title, prefixes, "")
    if next == title, do: title, else: strip_confidence_prefixes(next)
  end

  defp number(nil), do: ""
  defp number(value), do: to_string(value)

  defp stringify_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)

  # Lower sorts first: verified, then checked, then open, then fixed.
  defp evidence_sort_key(finding) do
    trust = finding.trust || %{}

    tier =
      cond do
        finding.status == "verified" or trust[:verified] == true ->
          0

        trust[:human_checked] == true ->
          1

        finding.status == "open" and trust[:agent_reproduced] == true and
            trust[:agent_disputed] != true ->
          2

        finding.status == "open" ->
          3

        finding.status == "disputed" or trust[:disputed] == true ->
          4

        finding.status == "fixed" or trust[:fixed] == true ->
          5

        true ->
          6
      end

    severity_rank =
      case finding.severity do
        "critical" -> 0
        "high" -> 1
        "medium" -> 2
        "low" -> 3
        _ -> 4
      end

    {tier, severity_rank, -(finding.confirmations_count || 0), -(finding.detections_count || 0),
     finding.file_path || ""}
  end
end
