"""The featured selection, exactly as agreed: v2 and v2.1 (the classes below still hold under
v3, a v2 record reading as a tier-3 bottle), then v3 (Stream AW2.2, the panel's design of 19 Sep
2026: `order()` behind every featured sort, one class per rule)."""

from app.services.featured import SavingRecord, headline, pick_featured


def rec(pid, usd, pct, awarded=False, image=False, shops=3):
    return SavingRecord(pid, usd, pct, awarded, image, shops)


class TestChampions:
    def test_best_dollar_and_best_percent_always_show(self):
        """The page's promise is 'the best savings' -- no photo requirement,
        no award, nothing, may hide either champion."""
        records = [rec(1, 500, 0.05), rec(2, 5, 0.90)] + [
            rec(i, 50, 0.30, awarded=True, image=True) for i in range(3, 15)
        ]
        picks = pick_featured(records, total=8)
        assert 1 in picks and 2 in picks


class TestAwardFloor:
    """v3 (assumption 5): the award's bonus counts only on a saving that stands at least
    `AWARD_FLOOR` of the pool's best; v2's floor of two winners is gone, so no winner is forced
    in on a poor saving (the 19 Sep home page was six medals of eight)."""

    def test_two_winners_on_poor_savings_are_not_forced_in(self):
        records = [rec(i, 100 - i, 0.30) for i in range(1, 11)] + [
            rec(20, 8, 0.05, awarded=True), rec(21, 7, 0.04, awarded=True),
        ]
        picks = pick_featured(records, total=8)
        assert 20 not in picks and 21 not in picks and len(picks) == 8

    def test_a_winner_at_the_floor_earns_its_bonus_and_one_under_it_does_not(self):
        """Ten savings at $50 to $95 and two winners: $78 stands in the better half (0.32 of
        the best's 0.5) and its bonus, 0.10 since version 2 (about two rungs of this ladder),
        lifts it past the $80 and $85 cards that out-save it, not past $90; $52 stands under
        half the best and scores as an ordinary card. At v2's 0.25 the same winner led the
        rail; the 19 Sep copy's home page was six medals of eight at that weight."""
        records = [rec(i, 50 + 5 * i, 0.30) for i in range(0, 10)] + [
            rec(20, 78, 0.30, awarded=True), rec(21, 52, 0.30, awarded=True),
        ]
        picks = pick_featured(records, total=8)
        assert picks[:3] == [9, 8, 20], picks  # the $95 champion, $90, then the winner
        assert picks.index(20) < picks.index(7) < picks.index(6)
        assert 21 not in picks
        # Without its medal the same card sits where its saving puts it: after $85 and $80.
        plain = pick_featured([r for r in records if r.variant_id != 20] + [rec(20, 78, 0.30)], total=8)
        assert plain.index(7) < plain.index(6) < plain.index(20)

    def test_winners_without_savings_never_count(self):
        records = [rec(i, 100 - i, 0.30) for i in range(1, 11)] + [
            rec(20, 0, 0.0, awarded=True), rec(21, 0, 0.0, awarded=True),
        ]
        picks = pick_featured(records, total=8)
        assert 20 not in picks and 21 not in picks

    def test_more_than_two_winners_can_earn_their_way_in(self):
        """The floor is a floor: the award bonus lets strong winners exceed it."""
        records = [rec(i, 60, 0.25, awarded=True) for i in range(1, 6)] + [
            rec(i, 55, 0.22) for i in range(10, 20)
        ]
        picks = pick_featured(records, total=8)
        assert sum(1 for p in picks if p < 6) == 5


class TestImagePreference:
    def test_photo_wins_a_close_call(self):
        records = [rec(1, 50, 0.20, image=True), rec(2, 51, 0.20)] + [
            rec(i, 100, 0.5) for i in range(3, 9)
        ]
        picks = pick_featured(records, total=8)
        assert picks.index(1) < picks.index(2)

    def test_photo_never_buries_a_clearly_better_saving(self):
        records = [rec(1, 200, 0.50), rec(2, 40, 0.10, image=True)] + [
            rec(i, 30, 0.1, image=True) for i in range(3, 10)
        ]
        picks = pick_featured(records, total=4)
        assert picks[0] == 1


class TestShape:
    def test_no_repeats_and_respects_total(self):
        records = [rec(i, i * 3, i * 0.02, awarded=(i % 3 == 0), image=(i % 2 == 0))
                   for i in range(1, 30)]
        picks = pick_featured(records, total=8)
        assert len(picks) == 8 == len(set(picks))

    def test_empty_and_tiny_inputs(self):
        assert pick_featured([], total=8) == []
        assert pick_featured([rec(1, 5, 0.1)], total=8) == [1]

    def test_deterministic_on_ties(self):
        records = [rec(i, 10, 0.10) for i in range(1, 6)]
        assert pick_featured(records, total=3) == pick_featured(records, total=3) == [1, 2, 3]


class TestCorroboration:
    def test_an_extreme_two_shop_spread_is_not_featured(self):
        """$21 vs $77 at exactly two shops: as likely someone's pricing error
        as a bargain. ProductVariant pages still show it; the home page waits for a
        third shop to corroborate."""
        records = [rec(1, 56, 0.73, shops=2)] + [rec(i, 30, 0.3) for i in range(2, 10)]
        assert 1 not in pick_featured(records, total=8)

    def test_the_same_spread_with_three_shops_is_featured(self):
        records = [rec(1, 56, 0.73, shops=3)] + [rec(i, 30, 0.3) for i in range(2, 10)]
        assert 1 in pick_featured(records, total=8)

    def test_a_modest_two_shop_spread_is_fine(self):
        records = [rec(1, 90, 0.45, shops=2)] + [rec(i, 30, 0.3) for i in range(2, 10)]
        assert 1 in pick_featured(records, total=8)


class TestHeadline:
    """v2.1 (rian, 11 Sep): an award winner with a photo takes the big card whenever
    it can without looking bought. The pages below are staging's own, from the
    simulation over 438 airport sets that set the guard."""

    def page(self, *cards):
        return [rec(i, usd, pct, awarded=aw, image=img) for i, (usd, pct, aw, img) in enumerate(cards, start=1)]

    def test_a_winner_with_a_photo_leads_when_it_sits_with_the_pack(self):
        # $12 at 33% under one outlier ($75): the rest of the page saves about as much.
        page = self.page((75, 0.21, False, False), (21, 0.33, False, False), (14, 0.25, False, False),
                         (12.33, 0.33, True, True), (10, 0.34, False, False), (8, 0.24, False, False))
        assert headline(page).variant_id == 4

    def test_one_outlier_may_tower_over_it(self):
        # A $2,004 bottle alone does not veto the headline.
        page = self.page((2004, 0.26, False, False), (33, 0.75, False, False), (43.47, 0.37, True, True),
                         (32, 0.54, False, False), (29, 0.34, False, False))
        assert headline(page).variant_id == 3

    def test_two_cards_dwarfing_it_keep_the_top_pick(self):
        """The case that set the guard: a $10 headline above $65 and $57 cards
        reads as a paid placement."""
        page = self.page((65, 0.42, False, False), (57, 0.46, False, False), (10, 0.27, True, True),
                         (8, 0.34, False, False), (4, 0.09, False, False))
        assert headline(page).variant_id == 1

    def test_an_award_alone_or_a_photo_alone_is_not_enough(self):
        page = self.page((40, 0.30, False, False), (38, 0.29, True, False), (37, 0.28, False, True))
        assert headline(page).variant_id == 1

    def test_between_two_candidates_the_better_saving_leads(self):
        page = self.page((50, 0.30, False, False), (30, 0.20, True, True), (35, 0.40, True, True))
        assert headline(page).variant_id == 3

    def test_pick_featured_puts_the_headline_first_and_keeps_the_rest_in_order(self):
        records = [rec(1, 65, 0.42), rec(2, 30, 0.35), rec(3, 28, 0.33), rec(4, 25, 0.40, awarded=True, image=True),
                   rec(5, 20, 0.30), rec(6, 18, 0.28)]
        picks = pick_featured(records, total=6)
        assert picks[0] == 4
        assert sorted(picks) == [1, 2, 3, 4, 5, 6]
        without = [p for p in picks if p != 4]
        assert without == [p for p in pick_featured([r for r in records if r.variant_id != 4], total=5)]


# --- v3 (Stream AW2.1): the record's new fields, the gates and the evidence -----------------

import math

from app.services import featured, imagery


def rec3(pid, usd, pct, **kw):
    return SavingRecord(pid, usd, pct, kw.pop("awarded", False), **kw)


class TestRecordShape:
    def test_a_v2_positional_record_still_builds_and_reads_as_a_public_bottle(self):
        """Every v2 test builds `SavingRecord(pid, usd, pct, awarded, image, shops)`; the flag was
        a public variant photo before AW3, so it is tier 3 and a photo, never tier 0."""
        r = rec(409, 66.19, 0.43, awarded=True, image=True, shops=10)
        assert (featured.tier(r), featured.photo(r)) == (3, True)
        assert featured.tier(rec(1, 1, 0.1)) == 0 and not featured.photo(rec(1, 1, 0.1))

    def test_the_tier_wins_over_the_flag_and_a_brand_mark_is_not_a_photo(self):
        assert featured.tier(rec3(1, 5, 0.1, has_image=True, picture_tier=6)) == 6
        assert not featured.photo(rec3(1, 5, 0.1, picture_tier=4))  # a supplied brand mark
        assert featured.photo(rec3(1, 5, 0.1, picture_tier=2))  # a public line picture

    def test_photo_tiers_are_imagery_s_bottle_levels_never_restated(self):
        assert featured.PHOTO_TIERS == {
            t for (kind, level), t in imagery.TIERS.items() if level in imagery.STRUCTURED_DATA_LEVELS
        } == {2, 3, 5, 6}


class TestGates:
    """Each D3 gate names itself; a hand-built record is eligible by default (fresh, sized,
    in stock, compared), so a test that means to fail one gate says which."""

    def test_a_default_record_with_a_real_saving_is_eligible(self):
        assert featured.gates(rec3(1, 0.51, 0.1)) == () and featured.eligible(rec3(1, 0.51, 0.1))

    def test_each_gate_by_name(self):
        assert featured.gates(rec3(1, 0.50, 0.1)) == ("no_saving",)
        assert featured.gates(rec3(1, 5, 0.1, compared=False)) == ("uncompared",)
        assert featured.gates(rec3(1, 5, 0.1, in_stock=False)) == ("out_of_stock",)
        assert featured.gates(rec3(1, 5, 0.1, sized=False)) == ("unsized",)
        assert featured.gates(rec3(1, 5, 0.1, age_days=featured.STALE_DAYS + 1)) == ("stale",)
        assert featured.gates(rec3(1, 5, 0.1, age_days=featured.STALE_DAYS)) == ()
        assert featured.gates(rec3(1, 5, 0.1, oldest_age_days=featured.EXPIRED_AFTER_DAYS + 1)) == ("expired",)
        assert featured.gates(rec3(1, 56, 0.73, shop_count=2)) == ("uncorroborated",)
        assert featured.gates(rec3(1, 56, 0.73, shop_count=3)) == ()

    def test_an_unknown_age_is_stale_and_an_unknown_oldest_is_expired(self):
        assert featured.gates(rec3(1, 5, 0.1, age_days=None)) == ("stale",)
        assert featured.gates(rec3(1, 5, 0.1, oldest_age_days=None)) == ("expired",)

    def test_several_gates_come_in_order(self):
        r = rec3(1, 0.0, 0.0, compared=False, in_stock=False, age_days=None)
        assert featured.gates(r) == ("uncompared", "no_saving", "out_of_stock", "stale")


class TestStandings:
    def test_a_rank_not_a_size_so_one_outlier_flattens_nobody(self):
        """$2,003 against a p90 of $39 (the 19 Sep copy): normalised by the max, a $66 saving
        scored 0.03; by standing it is where it belongs, above almost everything."""
        pool = [rec3(1, 2003.76, 0.26), rec3(2, 66.19, 0.43), rec3(3, 18.07, 0.48), rec3(4, 5.0, 0.05)]
        s = featured.standings(pool)
        assert s[1] == (1 / 3, 1.0) and s[2] == (2 / 3, 2 / 3) and s[3] == (1.0, 1 / 3) and s[4] == (0.0, 0.0)

    def test_ties_share_a_standing_and_a_lone_record_stands_at_the_top(self):
        s = featured.standings([rec3(1, 10, 0.1), rec3(2, 10, 0.1), rec3(3, 20, 0.2)])
        assert s[1] == s[2] == (0.0, 0.0) and s[3] == (1.0, 1.0)
        assert featured.standings([rec3(9, 1, 0.01)]) == {9: (1.0, 1.0)}
        assert featured.standings([]) == {}

    def test_which_axis_leads_within_the_margin_is_both(self):
        assert featured.lead(0.9, 0.5) == "pct" and featured.lead(0.5, 0.9) == "usd"
        assert featured.lead(0.6, 0.5) == "both" and featured.lead(0.5, 0.64) == "both"


class TestInputHygiene:
    def test_a_duplicate_id_keeps_the_first_and_bad_figures_read_as_zero(self):
        records = [rec3(1, 5, 0.1, name="first"), rec3(1, 9, 0.9, name="second"),
                   rec3(2, math.nan, 0.1), rec3(3, -4, 0.1), rec3(4, 5, 0.1, picture_tier=9)]
        cleaned, stats = featured.clean(records)
        assert [r.variant_id for r in cleaned] == [1, 2, 3, 4] and cleaned[0].name == "first"
        assert cleaned[1].saving_usd == 0.0 and cleaned[2].saving_usd == 0.0 and cleaned[3].picture_tier == 0
        assert stats == {"duplicate": 1, "bad_saving": 2, "bad_tier": 1, "order_unknown": 0}


class TestEvidence:
    """The home page of the 19 Sep copy as v2.1 drew it: Lagavulin 16 `$66.19 / 43% / 10 shops
    / tier 3 / awarded` first, the $2,003 Hibiki second, six medals in eight, the percent
    champion (Campari, 75%) at slot 8. What the numbers say about it: awards over the window,
    one champion in the first four."""

    def page(self):
        pool = [
            rec3(409, 66.19, 0.43, awarded=True, picture_tier=3, brand_id=1, category="Whisky", shop_count=10, name="Lagavulin 16"),
            rec3(981, 2003.76, 0.26, picture_tier=0, brand_id=2, category="Whisky", shop_count=3, name="Hibiki 30"),
            rec3(775, 18.07, 0.48, awarded=True, picture_tier=3, brand_id=3, category="Wine", shop_count=4),
            rec3(1904, 58.56, 0.41, awarded=True, picture_tier=3, brand_id=4, category="Whisky", shop_count=5),
            rec3(1219, 17.66, 0.42, awarded=True, picture_tier=3, brand_id=5, category="Rum", shop_count=4),
            rec3(800, 45.59, 0.38, awarded=True, picture_tier=3, brand_id=6, category="Cognac & Brandy", shop_count=3),
            rec3(52, 12.56, 0.38, awarded=True, picture_tier=3, brand_id=7, category="Gin", shop_count=6),
            rec3(23, 33.00, 0.75, picture_tier=3, brand_id=8, category="Liqueurs & Aperitifs", shop_count=4),
        ] + [rec3(100 + i, 5.0 + i, 0.10 + i / 100, brand_id=100 + i, category="Vodka", shop_count=3) for i in range(20)]
        order = [409, 981, 775, 1904, 1219, 800, 52, 23]
        return pool, order

    def test_the_columns_read_off_the_first_eight_against_the_pool(self):
        pool, order = self.page()
        e = featured.evidence(pool, order, 8)
        assert (e.records, e.pool, e.shown) == (28, 28, 8)
        assert (e.pictured, e.bottles, e.tiers) == (7, 7, (1, 0, 0, 7, 0, 0, 0))
        assert (e.pool_pictured, round(e.pool_picture_rate, 2), e.pool_bottles) == (7, 0.25, 7)
        assert (e.awarded, e.pool_awarded, e.pool_better_half_winner) == (6, 6, True)
        assert (e.brands, e.pool_brands, e.categories, e.max_per_category, e.exclusives) == (8, 28, 6, 3, 0)
        assert e.champions == (981, 23) and e.champions_in_first_mix == 1
        assert e.median_usd == (33.00 + 45.59) / 2 and e.pool_p75_usd == 22.0  # nearest rank, 21st of 28
        assert e.gates == {g: 0 for g in featured.GATES} and e.hygiene["order_unknown"] == 0
        assert e.slots[0].variant_id == 409 and e.slots[0].lead == "both" and e.slots[1].lead == "usd"

    def test_the_targets_are_functions_of_the_pool(self):
        pool, order = self.page()
        by_name = {t.name: t for t in featured.evidence(pool, order, 8).targets}
        assert by_name["pictured"].met and by_name["pictured"].target == ">= 6"
        assert not by_name["awarded"].met and by_name["awarded"].target == "1..4"
        assert not by_name["champions_in_first_mix"].met and by_name["champions_in_first_mix"].target == "= 2"
        assert by_name["brands"].met and by_name["max_per_category"].met and by_name["exclusives"].met
        assert by_name["median_usd"].met and by_name["median_pct"].met and by_name["gates"].met

    def test_a_pool_with_one_photo_is_not_asked_for_six_and_a_pool_without_a_winner_wants_none(self):
        pool = [rec3(i, 10 + i, 0.2, brand_id=i, picture_tier=(3 if i == 1 else 0)) for i in range(1, 12)]
        by_name = {t.name: t for t in featured.evidence(pool, [r.variant_id for r in pool], 8).targets}
        assert by_name["pictured"].target == ">= 1" and by_name["pictured"].met
        assert by_name["awarded"].target == "0" and by_name["awarded"].met

    def test_a_winner_in_the_worse_half_does_not_open_the_award_window(self):
        pool = [rec3(i, 10 + i, 0.2, brand_id=i, awarded=(i == 1)) for i in range(1, 12)]
        e = featured.evidence(pool, [r.variant_id for r in pool], 8)
        assert not e.pool_better_half_winner and {t.name: t for t in e.targets}["awarded"].target == "0"

    def test_a_cap_is_off_when_the_pool_cannot_honour_it(self):
        """A category page has one category: eight of it in eight is not a miss."""
        pool = [rec3(i, 10 + i, 0.2, brand_id=i, category="Perfume") for i in range(1, 12)]
        by_name = {t.name: t for t in featured.evidence(pool, [r.variant_id for r in pool], 8).targets}
        assert by_name["max_per_category"].target == "off" and by_name["max_per_category"].met
        assert featured.cap_off(2, 3, 8) and not featured.cap_off(3, 3, 8)

    def test_the_first_n_are_read_off_the_order_even_when_they_fail_a_gate(self):
        """A page ranked by something other than the saving (the SQL sort before v3) shows its
        gate failures as numbers; an id no record answers to is a hygiene count, never a crash."""
        pool = [rec3(1, 0.0, 0.0, awarded=True, picture_tier=3), rec3(2, 20, 0.3), rec3(3, 30, 0.8, shop_count=2)]
        e = featured.evidence(pool, [1, 2, 999], 8)
        assert e.shown == 2 and e.gates["no_saving"] == 1 and e.hygiene["order_unknown"] == 1
        assert e.held_back == 1 and e.ineligible["uncorroborated"] == 1 and e.pool == 1
        assert not {t.name: t for t in e.targets}["gates"].met

    def test_empty_input_is_a_page_of_zeros(self):
        e = featured.evidence([], [], 8)
        assert (e.pool, e.shown, e.median_usd, e.pool_p75_usd, e.champions) == (0, 0, None, None, ())
        assert e.targets_met < len(e.targets)

    def test_the_same_input_gives_the_same_evidence_whatever_the_record_order(self):
        pool, order = self.page()
        a = featured.evidence(pool, order, 8)
        b = featured.evidence(list(reversed(pool)), order, 8)
        assert a == b


# --- v3 (Stream AW2.2): the order, one class per rule --------------------------------------------

import random

from app.services.featured import order, ordering


def pool_of(n=12, **kw):
    """`n` fresh, compared, corroborated savings, distinct brands and categories, $10 apart."""
    return [rec3(i, 10.0 * i, 0.10 + i / 100, brand_id=i, category=f"c{i}", shop_count=3, **kw) for i in range(1, n + 1)]


class TestPinsLead:
    def test_a_pin_leads_every_list_it_is_a_comparison_in(self):
        """The worst saving in the pool, pinned, is slot 1 and the headline; it is not labelled
        (nothing in the order says so) and the champions still fill the window after it."""
        records = pool_of(12)
        records[0] = rec3(1, 10.0, 0.11, brand_id=1, category="c1", shop_count=3, pinned=True)
        o = ordering(records, 8)
        assert o.ids[0] == 1 and o.headline == 1 and o.pins == (1,)
        assert set(o.champions) <= set(o.ids[1:4])

    def test_a_line_pin_lifts_one_variant_its_best_by_score(self):
        records = pool_of(12)
        records[1] = rec3(2, 20.0, 0.12, brand_id=2, category="c2", shop_count=3, product_line_id=7, line_pinned=True)
        records[2] = rec3(3, 30.0, 0.13, brand_id=3, category="c3", shop_count=3, product_line_id=7, line_pinned=True, picture_tier=6)
        o = ordering(records, 8)
        assert o.pins == (3,) and o.ids[0] == 3 and o.ids.index(2) > 1

    def test_own_pins_order_by_score_and_a_line_pin_adds_no_second_of_its_own_pinned_variant(self):
        records = pool_of(12)
        records[0] = rec3(1, 10.0, 0.11, brand_id=1, category="c1", shop_count=3, pinned=True, product_line_id=5, line_pinned=True)
        records[5] = rec3(6, 60.0, 0.16, brand_id=6, category="c6", shop_count=3, pinned=True)
        o = ordering(records, 8)
        assert o.pins == (6, 1) and o.ids[:2] == (6, 1)

    def test_a_pin_orders_never_admits(self):
        """D6: a pinned record outside the pool (stale, no saving) is not invented into the
        first eight; it heads the band it belongs to. A pinned one-shop variant heads the tail."""
        records = pool_of(12) + [
            rec3(90, 500.0, 0.5, pinned=True, age_days=99, name="stale pin"),
            rec3(91, 0.0, 0.0, compared=False, pinned=True, name="one-shop pin"),
            rec3(92, 0.0, 0.0, compared=False, picture_tier=6, name="a tail bottle"),
        ]
        o = ordering(records, 8)
        assert o.pins == () and 90 not in o.ids[:12]
        assert o.ids[12] == 90 and o.ids[13] == 91 and o.ids[14] == 92
        assert len(o.ids) == 15 == len(set(o.ids))


class TestPictureLadder:
    def test_a_tier_6_picture_lifts_a_better_half_saving_past_a_top_decile_one_with_none(self):
        """D2: standing 0.55 plus 0.36 (0.91) beats 0.90 with no picture; the champions keep
        the best savings regardless, so the pictured card takes the first free slot."""
        records = pool_of(20)
        records[11] = rec3(12, 120.0, 0.22, brand_id=12, category="c12", shop_count=3, picture_tier=6)
        o = ordering(records, 8)
        first = list(o.ids[:8])
        # Standing 11/19 = 0.58 plus 0.36 beats the 17/19 = 0.89 of the $180 card with none.
        assert first.index(12) < first.index(18) and first[0] == 20 == o.champions[0]

    def test_the_step_is_per_tier_so_a_supplied_bottle_outranks_a_public_one_outranks_a_mark(self):
        records = [rec3(i, 50.0, 0.30, brand_id=i, category=f"c{i}", shop_count=3, picture_tier=t)
                   for i, t in ((1, 0), (2, 1), (3, 3), (4, 6), (5, 2))]
        records.append(rec3(9, 100.0, 0.50, brand_id=9, category="c9", shop_count=3))  # the champion, tier 0
        assert order(records, 8) == [9, 4, 3, 5, 2, 1]

    def test_a_v2_image_flag_reads_as_tier_3(self):
        with_flag = rec(1, 50, 0.3, image=True)
        with_tier = rec3(2, 50, 0.3, picture_tier=3)
        without = rec3(3, 50, 0.3)
        assert order([without, with_flag, with_tier], 8) == [1, 2, 3]


class TestChampionsInTheFirstFour:
    def test_both_champions_sit_in_the_first_four_dollar_first(self):
        """The 19 Sep home page had the percent champion (Campari, 75%) at slot 8."""
        records = pool_of(12) + [rec3(50, 5.0, 0.75, brand_id=50, category="c50", shop_count=3, name="Campari"),
                                 rec3(51, 2003.76, 0.26, brand_id=51, category="c51", shop_count=3, name="Hibiki 30")]
        o = ordering(records, 8)
        assert o.champions == (51, 50)
        assert 51 in o.ids[:4] and 50 in o.ids[:4]

    def test_an_exclusive_is_never_a_champion_and_a_four_card_rail_carries_both(self):
        records = pool_of(6) + [rec3(50, 999.0, 0.9, brand_id=50, category="c50", shop_count=3, is_exclusive=True)]
        o = ordering(records, 4)
        assert 50 not in o.champions and set(o.champions) <= set(o.ids[:4])

    def test_the_champions_follow_the_pins_when_pins_fill_the_window(self):
        records = pool_of(12)
        for i in range(4):
            records[i] = rec3(i + 1, 10.0 * (i + 1), 0.10 + (i + 1) / 100, brand_id=i + 1, category=f"c{i+1}", shop_count=3, pinned=True)
        o = ordering(records, 8)
        assert set(o.ids[:4]) == {1, 2, 3, 4}
        assert o.champions and set(o.ids[4:4 + len(o.champions)]) == set(o.champions)

    def test_the_headline_move_never_pushes_a_champion_out_of_the_window(self):
        """The review of AW2.4: two tier-6 pictures outscore the champions into slots 1 and 2,
        the champions take 3 and 4, and the only award with a bottle sits at slot 5; taking it to
        slot 1 pushed the percent champion to slot 5, so the order failed the evidence's own
        "both champions in the first four" by its own last move. The last non-champion in the
        window (slot 2) gives way instead; the champions keep their order, dollar first."""
        records = [rec3(1, 500.0, 0.50, brand_id=1, category="c1", shop_count=3, picture_tier=6),
                   rec3(2, 480.0, 0.48, brand_id=2, category="c2", shop_count=3, picture_tier=6),
                   rec3(50, 1000.0, 0.05, brand_id=50, category="c50", shop_count=3, name="dollar champion"),
                   rec3(51, 1.0, 0.90, brand_id=51, category="c51", shop_count=3, name="percent champion"),
                   rec3(60, 300.0, 0.30, brand_id=60, category="c60", shop_count=3, awarded=True, picture_tier=3)]
        records += [rec3(i, 100.0 + i, 0.20 + i / 100, brand_id=i, category=f"c{i}", shop_count=3) for i in range(3, 10)]
        o = ordering(records, 8)
        assert o.champions == (50, 51) and o.headline == 60
        assert set(o.champions) <= set(o.ids[:featured.FIRST_MIX]), o.ids[:8]
        assert o.ids[:5] == (60, 1, 50, 51, 2)
        assert sorted(o.ids) == sorted(r.variant_id for r in records)


class TestDiversity:
    def test_one_per_brand_three_per_category_one_exclusive_in_the_first_eight(self):
        """Perfume's eight held three Estee Lauder and two sizes of one Bvlgari line; Whisky
        holds 38 exclusives and the next shelf is the exclusives."""
        records = [rec3(i, 100.0 - i, 0.40 - i / 100, brand_id=(1 if i <= 5 else i), category=("Perfume" if i <= 8 else f"c{i}"),
                        shop_count=3, is_exclusive=(i in (2, 3))) for i in range(1, 25)]
        o = ordering(records, 8)
        first = [r for r in records if r.variant_id in o.ids[:8]]
        assert o.caps_off == ()
        brands = [r.brand_id for r in first]
        assert len(set(brands)) == 8, brands
        assert sum(1 for r in first if r.category == "Perfume") <= 3
        assert sum(1 for r in first if r.is_exclusive) <= 1

    def test_a_cap_is_off_when_the_pool_cannot_honour_it(self):
        """A brand page has one brand and a category page one category: eight of it is right."""
        records = [rec3(i, 100.0 - i, 0.40 - i / 100, brand_id=1, category="Perfume", shop_count=3) for i in range(1, 13)]
        o = ordering(records, 8)
        assert set(o.caps_off) == {"brand", "category"} and len(o.ids[:8]) == 8

    def test_the_caps_release_rather_than_leave_a_rail_short(self):
        """Nine brands but two of them hold every good saving beyond slot 2: the rail still
        fills to eight from the pool, never from a lower band."""
        records = [rec3(i, 100.0 - i, 0.40 - i / 100, brand_id=(i % 2) + 1, category=f"c{i}", shop_count=3) for i in range(1, 9)]
        records += [rec3(i, 10.0, 0.05, brand_id=i, category=f"c{i}", shop_count=3) for i in range(20, 27)]
        o = ordering(records, 8)
        assert len(o.ids[:8]) == 8 and all(vid in {r.variant_id for r in records} for vid in o.ids[:8])
        # Once the cap releases nothing keeps the pool's $10 cards out, and nothing ineligible comes in.
        assert not [vid for vid in o.ids[:8] if vid >= 27]


class TestDecay:
    def test_the_bonus_fades_to_nothing_by_decay_end_and_after_that_only_the_saving_counts(self):
        """A tier-6 winner with a low standing beats nothing after slot 24; a plain card with a
        better saving comes first there."""
        records = [rec3(i, 200.0 - i, 0.50 - i / 100, brand_id=i, category=f"c{i}", shop_count=3) for i in range(1, 40)]
        # Standing 0.50 with a public bottle (0.18): kept out of the first eight by better
        # savings, pulled forward while the bonus still counts. Standing 0.29 with a supplied
        # bottle (0.36) and a medal: the award needs half the best, the picture has faded to
        # nothing by slot 24, so it sits exactly where its saving alone puts it.
        records.append(rec3(98, 175.0, 0.35, brand_id=98, category="c98", shop_count=3, picture_tier=3))
        records.append(rec3(99, 165.0, 0.30, brand_id=99, category="c99", shop_count=3, picture_tier=6, awarded=True))
        ids = order(records, 8)
        plain = order([rec3(r.variant_id, r.saving_usd, r.saving_pct, awarded=r.awarded, brand_id=r.brand_id, category=r.category,
                            shop_count=3) for r in records], 8)  # the same records, no picture
        assert 98 not in ids[:8] and ids.index(98) < plain.index(98) < featured.DECAY_END
        assert ids.index(99) > featured.DECAY_END
        after = ids[featured.DECAY_END:]
        assert after == [vid for vid in plain if vid in set(after)], "after slot 24 the saving alone decides"


class TestTail:
    def test_ineligible_comparisons_by_dollars_then_the_uncompared_by_tier_then_name(self):
        records = pool_of(3) + [
            rec3(10, 90.0, 0.5, age_days=None, name="stale big"), rec3(11, 40.0, 0.4, in_stock=False, name="sold out"),
            rec3(20, 0.0, 0.0, compared=False, picture_tier=1, name="b mark"),
            rec3(21, 0.0, 0.0, compared=False, picture_tier=6, name="z bottle"),
            rec3(22, 0.0, 0.0, compared=False, name="a nothing"),
        ]
        ids = order(records, 8)
        assert ids[3:] == [10, 11, 21, 20, 22]

    def test_every_id_exactly_once_whatever_the_bands(self):
        records = pool_of(30) + [rec3(100 + i, 0.0, 0.0, compared=False) for i in range(10)] + [rec3(200, 5.0, 0.1, sized=False)]
        ids = order(records, 8)
        assert len(ids) == len(records) == len(set(ids))


class TestDeterminism:
    def test_two_shuffles_one_order(self):
        rng = random.Random(19)
        records = [rec3(i, rng.uniform(0, 300), rng.uniform(0, 0.8), awarded=rng.random() < 0.3, picture_tier=rng.choice([0, 0, 3, 6]),
                        brand_id=rng.randint(1, 12), category=rng.choice(["Whisky", "Gin", "Perfume", None]), shop_count=rng.randint(2, 9),
                        is_exclusive=rng.random() < 0.1, compared=rng.random() > 0.1, name=f"n{i}") for i in range(1, 80)]
        a = order(records, 8)
        shuffled = list(records)
        rng.shuffle(shuffled)
        b = order(shuffled, 8)
        rng.shuffle(shuffled)
        c = order(shuffled, 8)
        assert a == b == c and len(a) == 79 == len(set(a))

    def test_hygiene_is_counted_and_never_a_crash(self):
        records = pool_of(4) + [rec3(1, 99.0, 0.9), rec3(9, math.nan, 0.1), rec3(8, 5.0, 0.1, picture_tier=9)]
        o = ordering(records, 8)
        assert o.stats == {"duplicate": 1, "bad_saving": 1, "bad_tier": 1, "order_unknown": 0}
        assert len(o.ids) == 6 == len(set(o.ids))


class TestPickFeaturedIsOrder:
    def test_pick_featured_is_the_order_s_first_total_with_the_mix_over_min_total_first(self):
        records = pool_of(20, picture_tier=3)
        assert pick_featured(records, total=8) == order(records, first=8)[:8]
        assert pick_featured(records, total=4) == order(records, first=4)[:4]
        assert pick_featured(records, total=12) == order(records, first=featured.FIRST)[:12]
        assert pick_featured([], total=8) == []

    def test_a_rail_runs_short_rather_than_padded_from_below_the_pool(self):
        """Three eligible savings, a stale one and a one-shop pin: the rail shows three."""
        records = pool_of(3) + [rec3(10, 90.0, 0.5, age_days=None), rec3(11, 0.0, 0.0, compared=False, pinned=True)]
        assert len(pick_featured(records, total=8)) == 3 and 10 not in pick_featured(records, 8)


class TestRemoved:
    def test_v2_s_image_bonus_and_winner_floor_are_gone(self):
        import pytest

        with pytest.raises(AttributeError):
            featured.IMAGE_BONUS
        with pytest.raises(AttributeError):
            featured.MIN_WINNERS

    def test_every_doc_constant_exists_and_the_weights_share_one_scale(self):
        for name in featured.DOC_CONSTANTS:
            assert hasattr(featured, name), name
        assert featured.PCT_WEIGHT + featured.USD_WEIGHT == 1.0 and featured.VERSION == 2
