"""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.

Pure logic, no database: the queries feed it and the tests pin it.
"""

from dataclasses import dataclass

AWARD_BONUS = 0.25
IMAGE_BONUS = 0.10
USD_WEIGHT = 0.6
PCT_WEIGHT = 0.4
MIN_WINNERS = 2


# 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


@dataclass(frozen=True, slots=True)
class SavingRecord:
    variant_id: int
    saving_usd: float
    saving_pct: float
    awarded: bool
    has_image: bool = False
    shop_count: int = 2


def _score(record: SavingRecord, max_usd: float, max_pct: float) -> float:
    base = USD_WEIGHT * (record.saving_usd / max_usd if max_usd else 0.0)
    base += PCT_WEIGHT * (record.saving_pct / max_pct if max_pct else 0.0)
    if record.awarded:
        base += AWARD_BONUS
    if record.has_image:
        base += IMAGE_BONUS
    return base


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 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 r.has_image 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)))


def pick_featured(records: list[SavingRecord], total: int = 8) -> list[int]:
    """The product ids to feature: the headline (v2.1) first, then best score first."""
    eligible = [
        r
        for r in records
        if r.saving_usd > 0
        and (
            r.saving_pct < 1 - 1 / MAX_UNCORROBORATED_RATIO
            or r.shop_count >= CORROBORATION_SHOPS
        )
    ]
    if not eligible:
        return []

    max_usd = max(r.saving_usd for r in eligible)
    max_pct = max(r.saving_pct for r in eligible)

    def sort_key(record: SavingRecord):
        return (
            -_score(record, max_usd, max_pct),
            not record.awarded,
            not record.has_image,
            record.variant_id,
        )

    ranked = sorted(eligible, key=sort_key)

    # The two champions are non-negotiable: the page promises the best savings.
    champion_ids = {
        min(eligible, key=lambda r: (-r.saving_usd, r.variant_id)).variant_id,
        min(eligible, key=lambda r: (-r.saving_pct, r.variant_id)).variant_id,
    }

    chosen: list[SavingRecord] = [r for r in ranked if r.variant_id in champion_ids]
    for record in ranked:
        if len(chosen) >= total:
            break
        if record.variant_id in champion_ids:
            continue
        chosen.append(record)

    # Winner floor: at least two award winners whenever two exist with real
    # savings. Swap out the weakest non-champion picks for the best winners.
    # The floor is min(2, how many exist): two when two exist, and a lone
    # winner with a real saving still gets favored in.
    winners_available = [r for r in ranked if r.awarded]
    if winners_available:
        while sum(1 for r in chosen if r.awarded) < min(MIN_WINNERS, len(winners_available)):
            incoming = next(
                (r for r in winners_available if r not in chosen), None
            )
            outgoing = next(
                (r for r in reversed(chosen) if not r.awarded and r.variant_id not in champion_ids),
                None,
            )
            if incoming is None or outgoing is None:
                break
            chosen[chosen.index(outgoing)] = incoming

    chosen.sort(key=sort_key)
    page = chosen[:total]
    lead = headline(page)
    return [lead.variant_id] + [r.variant_id for r in page if r is not lead]
