defmodule Tarakan.Suppressions do
  @moduledoc """
  The known-non-bug corpus fed back to scanning clients.

  Every agent that scans a repository spends tokens rediscovering the same
  false positives, and those tokens come out of a contributor's own AI
  subscription. Findings the record has already judged `disputed` are exactly
  the set worth not paying for twice, so they are published back to clients as
  suppressions before a scan runs.

  Two grains:

    * **repository** - this precise finding (fingerprint) was disputed here.
      Strong signal: do not report it again at this path and line.

    * **pattern** - this class of finding (`pattern_key`) has been disputed on
      several distinct repositories and confirmed on none. That is a detector
      artefact rather than a property of any one codebase, so it is advisory
      everywhere.

  Suppression is advice, never a gag. A client may still report a suppressed
  finding with fresh evidence; the record simply starts it from a sceptical
  prior, and a later confirmation removes the pattern from the corpus
  automatically because the query requires zero confirmations.
  """

  import Ecto.Query, warn: false

  alias Tarakan.AnalyticsCache
  alias Tarakan.PromptSafety
  alias Tarakan.Repo
  alias Tarakan.Repositories.Repository
  alias Tarakan.Scans.CanonicalFinding

  # A pattern needs this many disputing repositories before it is called a
  # detector artefact rather than one project's disagreement.
  @pattern_min_repositories 2
  @default_limit 200

  @doc """
  Disputed findings on one repository, newest first.

  Each entry carries the fingerprint the client matches against plus enough
  context for an agent to recognise the finding it is about to re-report.
  """
  def repository_suppressions(%Repository{id: repository_id}, opts \\ []) do
    limit = opts |> Keyword.get(:limit, @default_limit) |> min(500) |> max(1)

    Repo.all(
      from finding in CanonicalFinding,
        where: finding.repository_id == ^repository_id and finding.status == "disputed",
        order_by: [desc: finding.updated_at, desc: finding.id],
        limit: ^limit,
        select: %{
          public_id: finding.public_id,
          fingerprint: finding.fingerprint,
          pattern_key: finding.pattern_key,
          file_path: finding.file_path,
          line_start: finding.line_start,
          title: finding.title,
          disputes_count: finding.disputes_count,
          scope: "repository"
        }
    )
  end

  @doc """
  Finding classes disputed across several repositories and confirmed on none.

  These are detector artefacts: the same title keeps being raised and keeps
  being rejected regardless of codebase.
  """
  def pattern_suppressions(opts \\ []) do
    limit = opts |> Keyword.get(:limit, @default_limit) |> min(500) |> max(1)
    min_repositories = Keyword.get(opts, :min_repositories, @pattern_min_repositories)

    Repo.all(
      from finding in CanonicalFinding,
        where: not is_nil(finding.pattern_key),
        group_by: finding.pattern_key,
        having:
          fragment(
            "COUNT(DISTINCT ?) FILTER (WHERE ? = 'disputed')",
            finding.repository_id,
            finding.status
          ) >=
            ^min_repositories and
            fragment("COUNT(*) FILTER (WHERE ? IN ('verified', 'fixed'))", finding.status) == 0,
        order_by: [
          desc:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? = 'disputed')",
              finding.repository_id,
              finding.status
            )
        ],
        limit: ^limit,
        select: %{
          pattern_key: finding.pattern_key,
          title: min(finding.title),
          disputed_repositories:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? = 'disputed')",
              finding.repository_id,
              finding.status
            ),
          scope: "pattern"
        }
    )
  end

  @doc """
  The full suppression set a client should load before scanning `repository`.

  Repository-grain entries come first because they are the stronger signal.
  """
  def for_repository(%Repository{} = repository, opts \\ []) do
    %{
      repository: repository |> repository_suppressions(opts) |> Enum.map(&sanitize_entry/1),
      patterns: cached_pattern_suppressions(opts)
    }
  end

  # The pattern corpus is a grouped aggregate over every canonical finding and
  # it is identical for every caller, but it ships on every `/memory` response
  # a scanning client requests. Cache it: without this, each scan a client
  # starts costs a full-table group-by.
  @pattern_cache_ttl_ms 300_000

  defp cached_pattern_suppressions(opts) do
    limit = Keyword.get(opts, :limit, @default_limit)
    min_repositories = Keyword.get(opts, :min_repositories, @pattern_min_repositories)

    # A missing corpus makes agents redo work; it never makes them wrong. Under
    # cache pressure that is the right thing to drop.
    AnalyticsCache.fetch(
      {:pattern_suppressions, limit, min_repositories},
      fn -> opts |> pattern_suppressions() |> Enum.map(&sanitize_entry/1) end,
      ttl_ms: min(AnalyticsCache.configured_ttl(), @pattern_cache_ttl_ms),
      on_unavailable: []
    )
  end

  # Suppression entries are read by other contributors' agents, and their
  # titles and paths are contributor-written. Neutralize before they ship.
  defp sanitize_entry(entry) do
    entry
    |> maybe_sanitize(:title, 300)
    |> maybe_sanitize(:file_path, 500)
  end

  defp maybe_sanitize(entry, key, max_bytes) do
    case Map.get(entry, key) do
      value when is_binary(value) ->
        Map.put(entry, key, PromptSafety.sanitize_line(value, max_bytes: max_bytes))

      _absent ->
        entry
    end
  end

  @doc """
  Whether a fingerprint is suppressed for a repository.

  Used by callers that check one finding at a time rather than loading the set.
  """
  def suppressed?(%Repository{id: repository_id}, fingerprint) when is_binary(fingerprint) do
    Repo.exists?(
      from finding in CanonicalFinding,
        where:
          finding.repository_id == ^repository_id and
            finding.fingerprint == ^fingerprint and
            finding.status == "disputed"
    )
  end

  def suppressed?(_repository, _fingerprint), do: false
end
