defmodule Tarakan.ModelAnalytics do
  @moduledoc """
  What each model finds, and what it misses.

  Contributors run their own agents against commit-pinned targets, so the same
  code is reviewed repeatedly by different models. That makes two things
  measurable that a single-vendor scanner cannot measure at all:

    * **Precision** - of the findings a model reported, how many the record
      later confirmed versus disputed. A model that reports constantly and is
      usually wrong is expensive, not thorough.

    * **Blind spots** - findings that were confirmed by the record and that a
      model had the chance to catch (it scanned the same repository) but did
      not report. Missing a bug nobody else found is unremarkable; missing one
      that three other models reported is a blind spot.

  Every number here is derived from the existing public record. Models are
  identified by the self-reported `scans.model` string, which is provenance,
  not attestation - a model that never identifies itself is simply absent.
  """

  import Ecto.Query, warn: false

  alias Tarakan.AnalyticsCache
  alias Tarakan.Repo
  alias Tarakan.Scans.{CanonicalFinding, Finding, Scan}

  # Below this a model's rates are noise, and publishing them would be unfair
  # to whoever happened to run it twice.
  @min_findings 5
  @settled_statuses ~w(verified fixed disputed)

  @doc """
  Per-model detection and accuracy, most findings first.

  `precision` is confirmed / (confirmed + disputed) over settled findings only;
  it is nil until a model has settled findings to judge.
  """
  def model_scoreboard(opts \\ []) do
    limit = opts |> Keyword.get(:limit, 25) |> min(100) |> max(1)
    min_findings = Keyword.get(opts, :min_findings, @min_findings)

    Repo.all(
      from scan in Scan,
        join: occurrence in Finding,
        on: occurrence.scan_id == scan.id,
        join: canonical in CanonicalFinding,
        on: canonical.id == occurrence.canonical_finding_id,
        where: not is_nil(scan.model) and scan.model != "",
        where: scan.visibility == "public",
        group_by: scan.model,
        having: count(occurrence.id) >= ^min_findings,
        order_by: [desc: count(occurrence.id)],
        limit: ^limit,
        select: %{
          model: scan.model,
          scans: count(scan.id, :distinct),
          repositories: count(scan.repository_id, :distinct),
          findings: count(canonical.id, :distinct),
          confirmed:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? IN ('verified', 'fixed'))",
              canonical.id,
              canonical.status
            ),
          disputed:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? = 'disputed')",
              canonical.id,
              canonical.status
            )
        }
    )
    |> Enum.map(&put_precision/1)
  end

  @doc """
  One contributor's own models, ranked by findings reported.

  A contributor paying for several agents has no way to tell which of them
  actually finds real bugs in the code they care about. This is that answer,
  computed from their own submissions rather than the corpus average: it makes
  running a scan useful to the person running it, whatever the finding turns
  out to be.

  No minimum applies - it is the contributor's own data, and hiding their first
  four findings would defeat the point.
  """
  def account_model_scoreboard(account_id, opts \\ []) when is_integer(account_id) do
    limit = opts |> Keyword.get(:limit, 25) |> min(100) |> max(1)

    Repo.all(
      from scan in Scan,
        join: occurrence in Finding,
        on: occurrence.scan_id == scan.id,
        join: canonical in CanonicalFinding,
        on: canonical.id == occurrence.canonical_finding_id,
        where: scan.submitted_by_id == ^account_id,
        where: not is_nil(scan.model) and scan.model != "",
        group_by: scan.model,
        order_by: [desc: count(canonical.id, :distinct)],
        limit: ^limit,
        select: %{
          model: scan.model,
          scans: count(scan.id, :distinct),
          repositories: count(scan.repository_id, :distinct),
          findings: count(canonical.id, :distinct),
          confirmed:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? IN ('verified', 'fixed'))",
              canonical.id,
              canonical.status
            ),
          disputed:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? = 'disputed')",
              canonical.id,
              canonical.status
            )
        }
    )
    |> Enum.map(&put_precision/1)
  end

  @doc """
  Confirmed findings a model missed on repositories it actually scanned.

  A miss requires opportunity: the model must have scanned the repository the
  finding belongs to. Without that constraint every model "misses" every
  finding in the corpus, which measures nothing.
  """
  def blind_spots(model, opts \\ []) when is_binary(model) do
    limit = opts |> Keyword.get(:limit, 25) |> min(100) |> max(1)

    # Repositories this model has publicly scanned.
    scanned =
      from scan in Scan,
        where: scan.model == ^model and scan.visibility == "public",
        select: scan.repository_id

    # Canonical findings this model itself reported.
    reported =
      from occurrence in Finding,
        join: scan in Scan,
        on: scan.id == occurrence.scan_id,
        where: scan.model == ^model,
        select: occurrence.canonical_finding_id

    Repo.all(
      from canonical in CanonicalFinding,
        where: canonical.status in ["verified", "fixed"],
        where: canonical.repository_id in subquery(scanned),
        where: canonical.id not in subquery(reported),
        order_by: [desc: canonical.detections_count, desc: canonical.id],
        limit: ^limit,
        preload: [:repository],
        select: canonical
    )
  end

  @doc """
  Finding classes where models systematically disagree.

  For each `pattern_key`, the number of distinct models that reported it and
  the settled outcome. A class many models report and the record rejects is a
  shared artefact; one a single model reports and the record confirms is that
  model's edge.
  """
  def disagreement_patterns(opts \\ []) do
    limit = opts |> Keyword.get(:limit, 25) |> min(100) |> max(1)

    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: not is_nil(canonical.pattern_key),
        where: not is_nil(scan.model) and scan.model != "",
        where: canonical.status in ^@settled_statuses,
        group_by: canonical.pattern_key,
        having: count(scan.model, :distinct) > 1,
        order_by: [desc: count(scan.model, :distinct), desc: count(canonical.id, :distinct)],
        limit: ^limit,
        select: %{
          pattern_key: canonical.pattern_key,
          title: min(canonical.title),
          models: count(scan.model, :distinct),
          findings: count(canonical.id, :distinct),
          confirmed:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? IN ('verified', 'fixed'))",
              canonical.id,
              canonical.status
            ),
          disputed:
            fragment(
              "COUNT(DISTINCT ?) FILTER (WHERE ? = 'disputed')",
              canonical.id,
              canonical.status
            )
        }
    )
  end

  # The scoreboard and the disagreement table are grouped aggregates over every
  # public finding, identical for every visitor, on a page that needs no
  # authentication. Cache them so the page cannot be used to replay expensive
  # queries on demand.
  @public_ttl_ms 300_000

  @doc "Cached `model_scoreboard/1` for the public page."
  def cached_model_scoreboard(opts \\ []) do
    limit = Keyword.get(opts, :limit, 25)
    min_findings = Keyword.get(opts, :min_findings, @min_findings)

    cached({:model_scoreboard, limit, min_findings}, fn -> model_scoreboard(opts) end)
  end

  @doc "Cached `disagreement_patterns/1` for the public page."
  def cached_disagreement_patterns(opts \\ []) do
    cached({:model_disagreements, Keyword.get(opts, :limit, 25)}, fn ->
      disagreement_patterns(opts)
    end)
  end

  @doc "Cached `blind_spots/2` for the public page."
  def cached_blind_spots(model, opts \\ []) when is_binary(model) do
    cached({:model_blind_spots, model, Keyword.get(opts, :limit, 25)}, fn ->
      blind_spots(model, opts)
    end)
  end

  defp cached(key, compute) do
    AnalyticsCache.fetch(key, compute,
      ttl_ms: min(AnalyticsCache.configured_ttl(), @public_ttl_ms),
      on_unavailable: []
    )
  end

  @doc "Models that have publicly reported at least one finding."
  def known_models do
    Repo.all(
      from scan in Scan,
        join: occurrence in Finding,
        on: occurrence.scan_id == scan.id,
        where: not is_nil(scan.model) and scan.model != "",
        where: scan.visibility == "public",
        distinct: true,
        order_by: [asc: scan.model],
        select: scan.model
    )
  end

  defp put_precision(row) do
    settled = row.confirmed + row.disputed

    precision =
      if settled > 0 do
        Float.round(row.confirmed * 100 / settled, 1)
      end

    Map.put(row, :precision, precision)
  end
end
