"""Which savings the home page leads with.

The selection rules are the client conversation turned into code, so they are
written here exactly as agreed and nowhere else.

v2 (agreed 2026-08-25, supersedes the slot-based v1): three factors, in order
of weight -- (1) genuinely good savings, (2) award winners, (3) product variants with
photographs.

- A single saving score blends the dollar gap (60%) and the percentage gap
  (40%), each normalised against the day's best, so a $400 gap and an 80% gap
  can both surface.
- Award winners get a fixed bonus, and the final set must contain AT LEAST TWO
  award winners whenever two exist with real savings -- more when they earn it.
- A product with a photo gets a smaller bonus: enough to win a close call,
  never enough to bury a clearly better saving.
- The single best dollar saving and the single best percentage saving are
  always shown, photo or not -- the page's headline claim is "the best
  savings", and hiding the best saving for lacking a picture would break it.

v2.1, the headline (rian, 2026-09-11): the first card is drawn large, and an award
winner with a photo takes it whenever it can without looking bought: when no more than
one other featured card saves more than twice its dollars (one outlier, a $2,000 bottle,
may tower over it; two may not). Among several such, the best saving by rank. Otherwise
the headline is the top pick, as before. Tuned on staging's savings for 438 airport sets
(11 Sep): award winners with photos headline 11% of them, up from 5%, and 18% of those
where one is featured; looser guards put a $10 headline above $65 and $57 cards.

v3 (the panel of 19 Sep 2026, Stream AW2; the brief's Design section is the authority until
`docs/FEATURED.md` lands): one standard for every list that says "featured", applied by
`order()` behind every featured sort (the home page, the category and family rails, brand
pages, the airport block, the full list). The factors, in rian's order: an admin's pin (it
orders, never admits, never labels), the picture and its provenance tier, an award counted only
on a real saving, and the saving's standing in percent and in dollars, a rank rather than a
size so one $2,003 bottle flattens nobody. The first eight slots carry the mix (the two
champions in the first four, one bottle per brand, three per category, one exclusive); the
bonuses fade to nothing by slot 24; after that only the saving counts. The v2.1 guard and
headline are unchanged. Every constant is named in `DOC_CONSTANTS` and mirrored in the doc.

Pure logic, no database: the queries feed it and the tests pin it. Deterministic: no clock,
no randomness, so two workers answer alike.
"""

import math
from dataclasses import dataclass, replace
from statistics import median

from app.services import imagery

#: The rules version: equals the version line of `docs/FEATURED.md`; a rule change bumps both.
#: 2 (AW2.4): the award's bonus 0.25 to 0.10, tuned on the 19 Sep copy by the evidence's window.
VERSION = 2
#: The award's bonus, counted only when the saving stands at least AWARD_FLOOR of the pool's
#: best (v3 replaces v2's floor of two winners: no winner is forced in on a poor saving). At
#: v2's 0.25 the home page stayed a medal page (six winners of eight, 27 in a pool of 1,089,
#: their tier-3 pictures adding 0.18 on top); 0.10 is the largest step of the sweep 0.25, 0.20,
#: 0.15, 0.12, 0.10, 0.08 that puts home inside the window of one to AWARDS_TARGET_MAX (four of
#: eight, the first eight's median saving $39 to $52) and moves no other page.
AWARD_BONUS = 0.10
AWARD_FLOOR = 0.5
#: The two axes of the saving, each a standing in the pool, so both share one scale. v2's
#: 0.6/0.4 compensated for max-normalising dollars, which the $2,003 outlier flattened.
PCT_WEIGHT = 0.5
USD_WEIGHT = 0.5
#: The picture ladder's step per tier: tier 6 adds 0.36, tier 3 0.18. A tier-6 picture lifts a
#: better-half saving past a top-decile one with none; the champions keep the best regardless.
PICTURE_STEP = 0.06

# --- the rest of D2 -----------------------------------------------------------------------------

#: Under half a dollar a saving is rounding, not a reason to feature.
REAL_SAVING_USD = 0.50
#: The picture ladder is imagery's (0 none, 1..3 public brand/line/variant, 4..6 supplied). A
#: v2 record's `has_image` flag was a public variant photo before AW3, so it reads as tier 3.
LEGACY_PHOTO_TIER = 3
#: The tiers that show a bottle rather than a brand mark: what the headline's "photo" means.
#: Derived from imagery, never restated.
PHOTO_TIERS = frozenset(t for (kind, level), t in imagery.TIERS.items() if level in imagery.STRUCTURED_DATA_LEVELS)
#: Days since the NEWEST observation on the comparison after which it is stale for a rail
#: (13 of 1,472 rows on the 19 Sep copy). The oldest leg would have bitten 370, because the
#: collectors stagger.
STALE_DAYS = 14
#: Days since the OLDEST observation after which the comparison has expired for a rail: a host
#: that refused us cannot keep a frozen saving on every rail. It stays on its page, dated.
EXPIRED_AFTER_DAYS = 60
#: The home grid and the family sample; the champions' window; the browse page size.
FIRST = 8
FIRST_MIX = 4
DECAY_END = 24
#: Diversity caps over the first FIRST slots (three Estée Lauder in Perfume's eight; the same
#: bottle on the exclusives shelf one screen down).
MAX_PER_BRAND_FIRST = 1
MAX_PER_CATEGORY_FIRST = 3
MAX_EXCLUSIVE_FIRST = 1
#: The evidence's percent-led / dollar-led split on standings: within fifteen points the two
#: axes say the same thing.
LEAD_MARGIN = 0.15
#: The evidence's picture target: this many of the first eight, or every bottle the pool holds.
PICTURED_TARGET = 6
#: The evidence's award window: one to this many winners when the pool holds one in its better half.
AWARDS_TARGET_MAX = 4


# A spread this extreme, seen at only two shops, is as likely a pricing error
# on one side as a bargain -- one $77-vs-$21 case proved unexplainable. It can
# still be found on its product page; the HOME PAGE does not headline it until
# a third shop corroborates the spread.
MAX_UNCORROBORATED_RATIO = 3.0
CORROBORATION_SHOPS = 3

# The headline guard (v2.1): an award winner with a photo leads unless more than
# HEADLINE_OUTLIERS other cards save more than HEADLINE_DWARF times its dollars.
HEADLINE_DWARF = 2.0
HEADLINE_OUTLIERS = 1

#: Every constant the doc `docs/FEATURED.md` mirrors as `` `NAME = value` `` (a two-way test).
DOC_CONSTANTS = (
    "VERSION", "REAL_SAVING_USD", "PCT_WEIGHT", "USD_WEIGHT", "PICTURE_STEP", "LEGACY_PHOTO_TIER",
    "AWARD_BONUS", "AWARD_FLOOR", "STALE_DAYS", "EXPIRED_AFTER_DAYS",
    "MAX_UNCORROBORATED_RATIO", "CORROBORATION_SHOPS", "HEADLINE_DWARF", "HEADLINE_OUTLIERS",
    "MAX_PER_BRAND_FIRST", "MAX_PER_CATEGORY_FIRST", "MAX_EXCLUSIVE_FIRST",
    "FIRST", "FIRST_MIX", "DECAY_END", "LEAD_MARGIN", "PICTURED_TARGET", "AWARDS_TARGET_MAX",
)


@dataclass(frozen=True, slots=True)
class SavingRecord:
    """One comparison as the selection sees it. The v2 fields lead, in v2's order, so a
    positional constructor and every older test still build one; v3's follow with defaults."""

    variant_id: int
    saving_usd: float
    saving_pct: float
    awarded: bool
    has_image: bool = False
    #: How many of our airports carry it (D10: an online catalogue never corroborates a spread).
    shop_count: int = 2
    #: imagery's 0..6 from `Picture.tier`; 0 when the flag above is the only signal.
    picture_tier: int = 0
    product_line_id: int | None = None
    brand_id: int | None = None
    #: None never collides with another None in a cap or a count.
    category: str | None = None
    is_exclusive: bool = False
    #: The variant's own pin, and its line's (AND NOT hidden; a line pin lifts one variant).
    pinned: bool = False
    line_pinned: bool = False
    #: Days since the newest observation (None = unknown = stale) and since the oldest (None =
    #: expired). 0 is fresh, the default, so a hand-built record is eligible.
    age_days: int | None = 0
    oldest_age_days: int | None = 0
    #: Some shop's latest observation is in stock, or no shop says otherwise.
    in_stock: bool = True
    #: `quantity_value` is set: no size is as likely two sizes folded.
    sized: bool = True
    #: Two or more airports. False is the tail: identified, never compared.
    compared: bool = True
    name: str = ""


def tier(record: SavingRecord) -> int:
    """The picture ladder rung: the one place v2's flag meets v3's tier."""
    return record.picture_tier or (LEGACY_PHOTO_TIER if record.has_image else 0)


def photo(record: SavingRecord) -> bool:
    """A bottle, not a brand mark: what an award winner needs to headline."""
    return tier(record) in PHOTO_TIERS


# --- the gates (D3): why a comparison stays off a rail ---------------------------------------

GATES = ("uncompared", "no_saving", "out_of_stock", "unsized", "stale", "expired", "uncorroborated")


def gates(record: SavingRecord) -> tuple[str, ...]:
    """Every gate this record fails, in `GATES` order; empty means eligible for a rail. A
    record failing only `uncorroborated` is a two-shop spread held back (v2's guard)."""
    failed = []
    if not record.compared:
        failed.append("uncompared")
    if not (record.saving_usd > REAL_SAVING_USD):
        failed.append("no_saving")
    if not record.in_stock:
        failed.append("out_of_stock")
    if not record.sized:
        failed.append("unsized")
    if record.age_days is None or record.age_days > STALE_DAYS:
        failed.append("stale")
    if record.oldest_age_days is None or record.oldest_age_days > EXPIRED_AFTER_DAYS:
        failed.append("expired")
    if not (record.saving_pct < 1 - 1 / MAX_UNCORROBORATED_RATIO or record.shop_count >= CORROBORATION_SHOPS):
        failed.append("uncorroborated")
    return tuple(failed)


def eligible(record: SavingRecord) -> bool:
    return not gates(record)


def standings(pool: list[SavingRecord]) -> dict[int, tuple[float, float]]:
    """Where each saving stands in the pool, `{variant_id: (pct_standing, usd_standing)}`: the
    share of the pool's other values below it, 1.0 alone (v2.1's `_rank_saving`, one axis at a
    time). A rank, so one $2,003 bottle flattens nobody."""
    n = len(pool)
    if n == 0:
        return {}
    if n == 1:
        return {pool[0].variant_id: (1.0, 1.0)}
    out: dict[int, list[float]] = {r.variant_id: [0.0, 0.0] for r in pool}
    for axis, key in enumerate((lambda r: r.saving_pct, lambda r: r.saving_usd)):
        ordered = sorted(pool, key=key)
        below = 0
        i = 0
        while i < n:
            j = i
            while j < n and key(ordered[j]) == key(ordered[i]):
                j += 1
            for r in ordered[i:j]:
                out[r.variant_id][axis] = below / (n - 1)
            below += j - i
            i = j
    return {vid: (pct, usd) for vid, (pct, usd) in out.items()}


def lead(pct_standing: float, usd_standing: float) -> str:
    """`pct`, `usd` or `both`: which axis a saving leads on, by more than `LEAD_MARGIN`."""
    if pct_standing - usd_standing > LEAD_MARGIN:
        return "pct"
    if usd_standing - pct_standing > LEAD_MARGIN:
        return "usd"
    return "both"


def _rank_saving(record: SavingRecord, page: list[SavingRecord]) -> float:
    """Where a saving stands among the page's, dollars and percent weighted as the
    score weighs them, by rank rather than size: a single $2,000 gap does not
    flatten every other card's dollar standing to nothing."""
    others = len(page) - 1
    if others <= 0:
        return 1.0
    usd = sum(1 for r in page if r.saving_usd > record.saving_usd)
    pct = sum(1 for r in page if r.saving_pct > record.saving_pct)
    return USD_WEIGHT * (others - usd) / others + PCT_WEIGHT * (others - pct) / others


def headline(page: list[SavingRecord]) -> SavingRecord:
    """The card the page draws large (v2.1): the best-ranked award winner with a
    photo (a bottle, not a brand mark) that no more than one other card dwarfs in dollars;
    else the top pick."""
    def dwarfed_by(record: SavingRecord) -> int:
        return sum(1 for r in page if r is not record and r.saving_usd > HEADLINE_DWARF * record.saving_usd)

    candidates = [r for r in page if r.awarded and photo(r) and dwarfed_by(r) <= HEADLINE_OUTLIERS]
    if not candidates:
        return page[0]
    return min(candidates, key=lambda r: (-_rank_saving(r, page), page.index(r)))


# --- the order (D3): every id exactly once ---------------------------------------------------

@dataclass(frozen=True, slots=True)
class Ordering:
    """What `order()` decided and why: the ids, the hygiene counts (a bad adapter is a number
    in the evidence, never a 500), and the bands' facts the CLI and the tests read."""

    ids: tuple[int, ...]
    stats: dict[str, int]
    pool: int
    pins: tuple[int, ...]
    champions: tuple[int, ...]
    headline: int | None
    caps_off: tuple[str, ...]


def _tie(record: SavingRecord) -> tuple:
    """Silence favours a picture: an award, then the higher tier, then the wider comparison,
    then the name, then the id."""
    return (-int(record.awarded), -tier(record), -record.shop_count, record.name, record.variant_id)


def ordering(records: list[SavingRecord], first: int = FIRST) -> Ordering:
    """The v3 order over every record (D3), the facts beside it. Pure and deterministic.

    Bands, in order: the pins (own pins in the pool, plus one variant per pinned line, its best
    by score; a pin orders, never admits); the two champions, forced into the first `FIRST_MIX`
    slots; the rest of the first `first` by score under the diversity caps (a cap is off when the
    pool cannot honour it, and releases rather than leave a rail short); slots to `DECAY_END` by
    score with the bonuses fading; the rest of the pool by saving; the ineligible comparisons by
    dollars; the tail (never compared) by tier then name; then the v2.1 headline to slot 1.
    """
    cleaned, stats = clean(records)
    first = max(1, int(first))
    pool = [r for r in cleaned if eligible(r)]
    stand = standings(pool)
    saving = {vid: PCT_WEIGHT * pct + USD_WEIGHT * usd for vid, (pct, usd) in stand.items()}
    best = max(saving.values(), default=0.0)

    def bonus(r: SavingRecord) -> float:
        b = PICTURE_STEP * tier(r)
        if r.awarded and saving[r.variant_id] >= AWARD_FLOOR * best:
            b += AWARD_BONUS
        return b

    def fade(slot: int) -> float:
        if slot <= first:
            return 1.0
        if DECAY_END <= first:
            return 0.0
        return max(0.0, (DECAY_END - slot) / (DECAY_END - first))

    def score(r: SavingRecord, slot: int) -> float:
        return saving[r.variant_id] + bonus(r) * fade(slot)

    def by_score(slot: int):
        return lambda r: (-score(r, slot), _tie(r))

    # 1. The pins: the variant's own, and one per pinned line (its best by score).
    pins: list[SavingRecord] = [r for r in pool if r.pinned]
    lifted: dict[int, SavingRecord] = {}
    for r in pool:
        if r.line_pinned and r.product_line_id is not None:
            held = lifted.get(r.product_line_id)
            if held is None or by_score(1)(r) < by_score(1)(held):
                lifted[r.product_line_id] = r
    pinned_ids = {r.variant_id for r in pins}
    for r in lifted.values():
        if r.variant_id not in pinned_ids:
            pins.append(r)
            pinned_ids.add(r.variant_id)
    pins.sort(key=by_score(1))
    placed: list[SavingRecord] = list(pins)
    remaining: dict[int, SavingRecord] = {r.variant_id: r for r in pool if r.variant_id not in pinned_ids}

    # 2. The champions, over the pool minus the pins (non-exclusive, dollar first).
    champions = champions_of(list(remaining.values()))
    window = min(FIRST_MIX, first)

    # 3. The first `first`: a champion as soon as the window needs it, else the best score that
    #    respects the caps; the caps count everything placed and release when nothing respects them.
    brand_counts: dict[int, int] = {}
    category_counts: dict[str, int] = {}
    exclusives = 0
    brand_cap_on = not cap_off(_distinct(r.brand_id for r in pool), MAX_PER_BRAND_FIRST, first)
    category_cap_on = not cap_off(_distinct(r.category for r in pool), MAX_PER_CATEGORY_FIRST, first)
    exclusive_cap_on = sum(1 for r in pool if not r.is_exclusive) >= first - MAX_EXCLUSIVE_FIRST
    caps_off = tuple(name for name, on in (("brand", brand_cap_on), ("category", category_cap_on),
                                            ("exclusive", exclusive_cap_on)) if not on)

    def count(r: SavingRecord) -> None:
        nonlocal exclusives
        if r.brand_id is not None:
            brand_counts[r.brand_id] = brand_counts.get(r.brand_id, 0) + 1
        if r.category is not None:
            category_counts[r.category] = category_counts.get(r.category, 0) + 1
        if r.is_exclusive:
            exclusives += 1

    def respects(r: SavingRecord) -> bool:
        if brand_cap_on and r.brand_id is not None and brand_counts.get(r.brand_id, 0) >= MAX_PER_BRAND_FIRST:
            return False
        if category_cap_on and r.category is not None and category_counts.get(r.category, 0) >= MAX_PER_CATEGORY_FIRST:
            return False
        if exclusive_cap_on and r.is_exclusive and exclusives >= MAX_EXCLUSIVE_FIRST:
            return False
        return True

    def place(r: SavingRecord) -> None:
        placed.append(r)
        del remaining[r.variant_id]
        count(r)

    for r in pins:
        count(r)
    slot = len(placed) + 1
    while slot <= first and remaining:
        champions_left = [vid for vid in champions if vid in remaining]
        if champions_left and (slot > window or window - slot + 1 <= len(champions_left)):
            place(remaining[champions_left[0]])
        else:
            candidates = [r for r in remaining.values() if respects(r)] or list(remaining.values())
            place(min(candidates, key=by_score(slot)))
        slot += 1
    for vid in champions:  # the pins filled the first slots: the champions come next, not never
        if vid in remaining:
            place(remaining[vid])
            slot += 1

    # 4. To DECAY_END by score, the bonuses fading; 5. the rest of the pool by the saving alone.
    while slot <= DECAY_END and remaining:
        place(min(remaining.values(), key=by_score(slot)))
        slot += 1
    for r in sorted(remaining.values(), key=lambda r: (-saving[r.variant_id], _tie(r))):
        place(r)

    # 6. The ineligible comparisons by dollars; 7. the tail by tier then name; a pin heads each.
    ineligible = [r for r in cleaned if r.compared and not eligible(r)]
    ineligible.sort(key=lambda r: (not (r.pinned or r.line_pinned), -r.saving_usd, _tie(r)))
    tail = [r for r in cleaned if not r.compared]
    tail.sort(key=lambda r: (not (r.pinned or r.line_pinned), -tier(r), r.name, r.variant_id))

    # 8. The headline (v2.1) to slot 1: the first pin, else the page's own.
    lead_id: int | None = None
    if pins:
        lead_id = pins[0].variant_id
    elif placed:
        page = placed[:first]
        lead = headline(page)
        lead_id = lead.variant_id
        placed = _lead_first(placed, lead, window, set(champions))

    ids = tuple(r.variant_id for r in (*placed, *ineligible, *tail))
    return Ordering(ids=ids, stats=stats, pool=len(pool), pins=tuple(r.variant_id for r in pins),
                    champions=champions, headline=lead_id, caps_off=caps_off)


def _lead_first(placed: list[SavingRecord], lead: SavingRecord, window: int, champion_ids: set[int]) -> list[SavingRecord]:
    """The headline to slot 1 without pushing a champion out of the first `window` (D3 band 2).

    A headline taken from below the window shifts every card above it down one, and the card at
    slot `window` falls out: when that card is a champion, the last non-champion inside the
    window takes the fall instead. The review of AW2.4 caught it on a made-up page (an award
    with a bottle at slot 5, the champions at 3 and 4), where the percent champion landed at
    slot 5 and the evidence's own "both champions in the first four" failed by the rule's
    own move; the copy's four pages happened to headline from the window.
    """
    out = [lead] + [r for r in placed if r is not lead]
    if lead in placed[:window] or len(out) <= window:
        return out
    if out[window].variant_id not in champion_ids:
        return out
    for i in range(window - 1, 0, -1):
        if out[i].variant_id not in champion_ids:
            out.insert(window, out.pop(i))
            return out
    return out


def order(records: list[SavingRecord], first: int = FIRST) -> list[int]:
    """Every id exactly once, in the v3 order (D3); `ordering()` carries the facts beside it."""
    return list(ordering(records, first).ids)


def pick_featured(records: list[SavingRecord], total: int = 8) -> list[int]:
    """The ids a rail of `total` cards shows (the home page, the airport block): the v3 order's
    first `total`, the mix carried by the first `min(total, FIRST)` slots, and never a record
    from below the pool: a rail runs short for lack of eligible records, it is not padded with
    stale, sold-out or held-back comparisons (D3); a pin outside the pool is not admitted (D6)."""
    o = ordering(records, first=min(total, FIRST))
    return list(o.ids[:min(total, o.pool)])


# --- the evidence (D7): what a rail's first N look like against its pool --------------------

#: The hygiene counts `evidence()` reports over its input: a duplicate variant id (the first
#: kept), a saving that is NaN or negative (read as 0), a picture tier outside 0..6 (read as
#: 0), and an id in the order that no record answers to (skipped). A bad adapter is a number
#: in the evidence, never a 500. `order()` (AW2.2) counts the first three the same way.
HYGIENE = ("duplicate", "bad_saving", "bad_tier", "order_unknown")


def clean(records: list[SavingRecord]) -> tuple[list[SavingRecord], dict[str, int]]:
    """The records `order()` and `evidence()` work over, and what was fixed on the way in."""
    stats = {name: 0 for name in HYGIENE}
    seen: set[int] = set()
    out: list[SavingRecord] = []
    for r in records:
        if r.variant_id in seen:
            stats["duplicate"] += 1
            continue
        seen.add(r.variant_id)
        changes = {}
        usd = float(r.saving_usd)
        if math.isnan(usd) or usd < 0:
            stats["bad_saving"] += 1
            changes["saving_usd"] = 0.0
        pct = float(r.saving_pct)
        if math.isnan(pct) or pct < 0:
            stats["bad_saving"] += 1
            changes["saving_pct"] = 0.0
        if not 0 <= int(r.picture_tier) <= max(imagery.TIERS.values()):
            stats["bad_tier"] += 1
            changes["picture_tier"] = 0
        if changes:
            r = replace(r, **changes)
        out.append(r)
    return out, stats


@dataclass(frozen=True, slots=True)
class Slot:
    """One placed card, for `--verbose` and the tests."""

    position: int
    variant_id: int
    name: str
    saving_usd: float
    saving_pct: float
    tier: int
    awarded: bool
    pinned: bool
    is_exclusive: bool
    brand_id: int | None
    category: str | None
    lead: str | None
    gates: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class Target:
    """One D7 target as a function of the pool: what the first N show, what was asked, met or not."""

    name: str
    value: float | int | bool
    target: str
    met: bool


@dataclass(frozen=True, slots=True)
class Evidence:
    """The columns per page (D7). Every figure but the two timings is pure over the records
    and the order; the command fills `query_ms` and `order_ms` from its clock."""

    first: int
    shown: int
    records: int
    pool: int
    pool_pictured: int
    pool_picture_rate: float
    pool_bottles: int
    pool_awarded: int
    pool_better_half_winner: bool
    pool_brands: int
    pool_p75_usd: float | None
    pool_p75_pct: float | None
    pictured: int
    bottles: int
    tiers: tuple[int, ...]
    awarded: int
    pinned: int
    pct_led: int
    usd_led: int
    both_led: int
    median_usd: float | None
    median_pct: float | None
    brands: int
    categories: int
    max_per_category: int
    exclusives: int
    champions: tuple[int, ...]
    champions_in_first_mix: int
    held_back: int
    gates: dict[str, int]
    #: Over every record, not the first N: how many fail each gate (a record can fail several).
    ineligible: dict[str, int]
    hygiene: dict[str, int]
    targets: tuple[Target, ...]
    slots: tuple[Slot, ...] = ()
    query_ms: float | None = None
    order_ms: float | None = None

    @property
    def targets_met(self) -> int:
        return sum(1 for t in self.targets if t.met)


def _p75(values: list[float]) -> float | None:
    """The 75th percentile by nearest rank, the value a "better than most of the pool" card
    has to reach; None over nothing."""
    if not values:
        return None
    ordered = sorted(values)
    return ordered[min(len(ordered) - 1, max(0, math.ceil(0.75 * len(ordered)) - 1))]


def _distinct(values) -> int:
    """Distinct values where None never collides: each None counts as its own."""
    seen = set()
    nones = 0
    for v in values:
        if v is None:
            nones += 1
        else:
            seen.add(v)
    return len(seen) + nones


def cap_off(distinct_in_pool: int, cap: int, first: int) -> bool:
    """A diversity cap is off when the pool cannot honour it: fewer distinct values than the
    slots need (a brand page has one brand, a category page one category), D3."""
    return distinct_in_pool * cap < first


def _num(value: float | int | None) -> str:
    if value is None:
        return "-"
    return f"{value:.2f}" if isinstance(value, float) else str(value)


def champions_of(pool: list[SavingRecord]) -> tuple[int, ...]:
    """The best dollar saving and the best percent saving over the pool, non-exclusive, dollar
    first, deduplicated: the two cards a rail's promise of "the best savings" cannot drop."""
    candidates = [r for r in pool if not r.is_exclusive] or list(pool)
    if not candidates:
        return ()
    usd = min(candidates, key=lambda r: (-r.saving_usd, r.variant_id)).variant_id
    pct = min(candidates, key=lambda r: (-r.saving_pct, r.variant_id)).variant_id
    return (usd,) if usd == pct else (usd, pct)


def evidence(records: list[SavingRecord], order: list[int], first: int = FIRST) -> Evidence:
    """Measure the first `first` ids of `order` against the pool the records form (D7). Pure:
    the same records and order give the same figures whoever runs it, before and after v3.

    The pool is every record that passes the gates; standings, p75 and the champions are the
    pool's. The first N are read off `order` whether or not they are in the pool, so a page
    ranked by something other than the saving shows its gate failures as numbers.
    """
    cleaned, hygiene = clean(records)
    by_id = {r.variant_id: r for r in cleaned}
    pool = [r for r in cleaned if eligible(r)]
    stand = standings(pool)
    placed: list[SavingRecord] = []
    for vid in order[:first]:
        r = by_id.get(vid)
        if r is None:
            hygiene["order_unknown"] += 1
            continue
        placed.append(r)

    pool_bottles = sum(1 for r in pool if photo(r))
    pool_pictured = sum(1 for r in pool if tier(r) >= 1)
    half = {vid for vid, (p, u) in stand.items() if (p + u) / 2 >= 0.5}
    better_half_winner = any(r.awarded and r.variant_id in half for r in pool)
    pool_brands = _distinct(r.brand_id for r in pool)
    p75_usd = _p75([r.saving_usd for r in pool])
    p75_pct = _p75([r.saving_pct for r in pool])
    champions = champions_of(pool)

    tiers = [0] * (max(imagery.TIERS.values()) + 1)
    leads = {"pct": 0, "usd": 0, "both": 0}
    gate_counts = {name: 0 for name in GATES}
    ineligible = {name: 0 for name in GATES}
    for r in cleaned:
        for g in gates(r):
            ineligible[g] += 1
    slots = []
    for i, r in enumerate(placed, start=1):
        tiers[tier(r)] += 1
        failed = gates(r)
        for g in failed:
            gate_counts[g] += 1
        which = lead(*stand[r.variant_id]) if r.variant_id in stand else None
        if which:
            leads[which] += 1
        slots.append(Slot(
            position=i, variant_id=r.variant_id, name=r.name, saving_usd=r.saving_usd, saving_pct=r.saving_pct,
            tier=tier(r), awarded=r.awarded, pinned=r.pinned or r.line_pinned, is_exclusive=r.is_exclusive,
            brand_id=r.brand_id, category=r.category, lead=which, gates=failed,
        ))
    per_category: dict[str | None, int] = {}
    for r in placed:
        if r.category is not None:
            per_category[r.category] = per_category.get(r.category, 0) + 1
    median_usd = median([r.saving_usd for r in placed]) if placed else None
    median_pct = median([r.saving_pct for r in placed]) if placed else None
    pictured = sum(1 for r in placed if tier(r) >= 1)
    awarded = sum(1 for r in placed if r.awarded)
    brands = _distinct(r.brand_id for r in placed)
    max_per_category = max(per_category.values(), default=0)
    exclusives = sum(1 for r in placed if r.is_exclusive)
    in_mix = sum(1 for vid in champions if vid in order[:FIRST_MIX])
    held_back = sum(1 for r in cleaned if gates(r) == ("uncorroborated",))
    gate_total = sum(gate_counts.values())

    want_pictured = min(PICTURED_TARGET, first, pool_bottles)
    want_brands = min(first, pool_brands)
    pool_categories = _distinct(r.category for r in pool)
    pool_non_exclusive = sum(1 for r in pool if not r.is_exclusive)
    category_cap_off = cap_off(pool_categories, MAX_PER_CATEGORY_FIRST, first)
    exclusive_cap_off = pool_non_exclusive < first - MAX_EXCLUSIVE_FIRST
    targets = (
        Target("pictured", pictured, f">= {want_pictured}", pictured >= want_pictured),
        Target("awarded", awarded, f"1..{AWARDS_TARGET_MAX}" if better_half_winner else "0",
               (1 <= awarded <= AWARDS_TARGET_MAX) if better_half_winner else awarded == 0),
        Target("champions_in_first_mix", in_mix, f"= {len(champions)}", in_mix == len(champions)),
        Target("brands", brands, f"= {want_brands}", brands == want_brands),
        Target("max_per_category", max_per_category, "off" if category_cap_off else f"<= {MAX_PER_CATEGORY_FIRST}",
               category_cap_off or max_per_category <= MAX_PER_CATEGORY_FIRST),
        Target("exclusives", exclusives, "off" if exclusive_cap_off else f"<= {MAX_EXCLUSIVE_FIRST}",
               exclusive_cap_off or exclusives <= MAX_EXCLUSIVE_FIRST),
        Target("median_usd", round(median_usd, 2) if median_usd is not None else 0.0, f">= {_num(p75_usd)}",
               median_usd is not None and p75_usd is not None and median_usd >= p75_usd),
        Target("median_pct", round(median_pct, 4) if median_pct is not None else 0.0, f">= {_num(p75_pct)}",
               median_pct is not None and p75_pct is not None and median_pct >= p75_pct),
        Target("gates", gate_total, "= 0", gate_total == 0),
    )
    return Evidence(
        first=first, shown=len(placed), records=len(cleaned), pool=len(pool),
        pool_pictured=pool_pictured, pool_picture_rate=(pool_pictured / len(pool)) if pool else 0.0,
        pool_bottles=pool_bottles, pool_awarded=sum(1 for r in pool if r.awarded),
        pool_better_half_winner=better_half_winner, pool_brands=pool_brands,
        pool_p75_usd=p75_usd, pool_p75_pct=p75_pct,
        pictured=pictured, bottles=sum(1 for r in placed if photo(r)), tiers=tuple(tiers),
        awarded=awarded, pinned=sum(1 for r in placed if r.pinned or r.line_pinned),
        pct_led=leads["pct"], usd_led=leads["usd"], both_led=leads["both"],
        median_usd=median_usd, median_pct=median_pct, brands=brands, categories=len(per_category),
        max_per_category=max_per_category, exclusives=exclusives, champions=champions,
        champions_in_first_mix=in_mix, held_back=held_back, gates=gate_counts, ineligible=ineligible, hygiene=hygiene,
        targets=targets, slots=tuple(slots),
    )
