"""Which medal a product shows when it holds several.

The rules are the strategy Adam was given on 2026-09-05
(`notes/awards-strategy-for-adam-2026-09-05.md`, /discuss decision card 5) and they live
here and nowhere else. A first version chose "best tier, then most recent" over every
medal a product ever won, so a bottle that won Double Gold once and Bronze since kept
showing the Double Gold for years; nothing suppressed a stale result, nothing knew the
visitor's airports, and a re-import renumbered every award so nothing could be pinned.

- **Retained** (what the page shows, and what the structured data emits): the latest
  result per competition. Older medals from the same competition step back.
- **Featured** (the card corner, the top of the page): the highest level among the
  retained, then the more recent year, then the competition nearest the visitor's chosen
  airports (JFK: a New York medal), then the higher judges' score, then the competition
  name, so the same bottle shows the same medal to a visitor with no airports chosen.
- **A pin** is a human decision stored by the natural key `(competition_slug, year,
  medal)`, never `awards.id` (ids may churn on `--rebuild`; keys do not). A pin that names
  a medal the product still holds wins outright and is shown even if the recency rule
  would have hidden it; a pin that matches nothing falls back to the picker and is
  reported as stale. The read path only falls back (a GET never writes); the importer
  queues the `reverifications` row when a rebuild is what made the pin stale.
- **Aging** (rian's proposal, Adam's switch, off until he says): on LISTINGS a medal older
  than a year no longer takes the card corner. The product page and the structured data
  keep it.

Pure logic: the catalogue queries feed it rows (ORM `Award`, `AwardOut` or any object with
`competition`, `competition_slug`, `year`, `medal` and optionally `score`) and the tests
pin every rule. Call sites: `catalog_queries._top_awards` (cards, `listing=True`, the
request's `at` codes) and `catalog_queries.get_product` (the page: `retained` becomes
`ProductDetail.awards`, featured first, which `seo.py` reads for the `award` property).
The pin arrives from `overrides` (`entity_type='product'`, `entity_key=str(variant_id)`,
`field='award_pin'`) through `pin_from_override` once that table exists.
"""

from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date
from typing import Any, Protocol

# Lower is better; anything unrecognised sorts after Bronze rather than raising, because
# the retailers' own award strings will arrive here one day.
MEDAL_RANK = {"double gold": 0, "gold": 1, "silver": 2, "bronze": 3}
_UNRANKED = 9

# Each competition's home, as the airports a visitor could pick. City level, as the
# strategy states it; a country-level refinement is a change to this table, not the code.
_NEW_YORK = frozenset({"JFK", "EWR", "LGA"})
_BERLIN = frozenset({"BER"})
_MELBOURNE = frozenset({"MEL"})
_ASIA = frozenset({
    "HKG", "SIN", "ICN", "GMP", "NRT", "HND", "KIX", "TPE", "BKK", "KUL", "PVG", "PEK",
    "MNL", "CGK", "MFM", "HAN", "SGN",
})
COMPETITION_HOME_AIRPORTS: dict[str, frozenset[str]] = {
    "nyisc": _NEW_YORK, "nyiwc": _NEW_YORK,
    "bisc": _BERLIN, "biwc": _BERLIN,
    "misc": _MELBOURNE, "miwc": _MELBOURNE,
    "aisc": _ASIA, "aiwc": _ASIA,
}

# Adam's switch (running list: do-adam-awards-strategy). A medal from before last year's
# judging fades off listings when this is on; the page and the structured data keep it.
AGING_ENABLED = False
FADE_AFTER_YEARS = 1


class AwardLike(Protocol):
    competition: str
    competition_slug: str | None
    year: int | None
    medal: str | None


@dataclass(frozen=True, slots=True)
class AwardKey:
    """The natural key of one medal on one product, minus the product id."""

    competition_slug: str | None
    year: int | None
    medal: str | None

    def matches(self, award: AwardLike) -> bool:
        return (
            (award.competition_slug or "").lower() == (self.competition_slug or "").lower()
            and award.year == self.year
            and (award.medal or "").lower() == (self.medal or "").lower()
        )


@dataclass(frozen=True, slots=True)
class Selection:
    #: The medal the card corner and the top of the page carry; None only when no award
    #: qualifies (no awards, or every retained one faded off a listing).
    featured: Any
    #: What the page shows and the structured data emits: featured first, then ranked.
    retained: list
    #: A pin named a medal the product holds, and it is `featured`.
    pin_applied: bool = False
    #: A pin was given and matched nothing; the picker chose instead. Reads only report
    #: it; the importer queues the reverification, because a GET never writes.
    pin_stale: bool = False


def medal_rank(medal: str | None) -> int:
    return MEDAL_RANK.get((medal or "").strip().lower(), _UNRANKED)


def competition_key(award: AwardLike) -> str:
    """One competition, one key: the slug when the row has one, else the name."""
    return (award.competition_slug or award.competition or "").strip().lower()


def has_affinity(award: AwardLike, visitor_airports: Sequence[str]) -> bool:
    home = COMPETITION_HOME_AIRPORTS.get((award.competition_slug or "").lower())
    if not home or not visitor_airports:
        return False
    return any((code or "").upper() in home for code in visitor_airports)


def rank_key(award: AwardLike, visitor_airports: Sequence[str] = ()) -> tuple:
    """Sort key, lowest first: level, recency, affinity, judges' score, competition name.

    The final components make the order total for one product: the database allows one
    row per product, competition and year, so nothing can still tie after them.
    """
    score = getattr(award, "score", None)
    return (
        medal_rank(award.medal),
        -(award.year or 0),
        0 if has_affinity(award, visitor_airports) else 1,
        -(score or 0),
        (award.competition or "").lower(),
        (award.competition_slug or "").lower(),
        (award.medal or "").lower(),
    )


def is_faded(award: AwardLike, today: date | None = None) -> bool:
    """Older than a year, counted in judging years: in 2026, a 2024 medal has faded, a
    2025 one has not. A medal with no year cannot age."""
    if award.year is None:
        return False
    return award.year < (today or date.today()).year - FADE_AFTER_YEARS


def retained_awards(awards: Sequence[AwardLike]) -> list:
    """The latest result per competition, in no particular order.

    Two rows of one competition in one year cannot both belong to a product (the importer
    treats that as ambiguity and stores neither), so the newest year is a single row; the
    rank key settles the impossible remainder deterministically anyway.
    """
    latest: dict[str, AwardLike] = {}
    for award in awards:
        key = competition_key(award)
        current = latest.get(key)
        if current is None or (award.year or 0, -medal_rank(award.medal)) > (
            current.year or 0, -medal_rank(current.medal)
        ):
            latest[key] = award
    return list(latest.values())


def pick(
    awards: Sequence[AwardLike],
    *,
    visitor_airports: Sequence[str] = (),
    pin: AwardKey | None = None,
    listing: bool = False,
    aging: bool | None = None,
    today: date | None = None,
) -> Selection:
    """Apply the strategy to one product's awards.

    `listing=True` is a card corner, the only place aging applies; `aging` overrides the
    module switch for tests. Deterministic for a given input: no randomness, no clock
    unless aging is on (and then only `today.year`).
    """
    if not awards:
        return Selection(featured=None, retained=[], pin_stale=pin is not None)

    ordered = sorted(retained_awards(awards), key=lambda a: rank_key(a, visitor_airports))

    pinned = None
    if pin is not None:
        by_rank = sorted(awards, key=lambda a: rank_key(a, visitor_airports))
        pinned = next((a for a in by_rank if pin.matches(a)), None)
        if pinned is not None and pinned not in ordered:
            # A human chose a medal the recency rule would hide: show it too. The newer
            # result from the same competition stays; both are facts.
            ordered.append(pinned)

    if pinned is not None:
        featured = pinned
    else:
        fade = AGING_ENABLED if aging is None else aging
        candidates = ordered
        if listing and fade:
            candidates = [a for a in ordered if not is_faded(a, today)]
        featured = candidates[0] if candidates else None

    shown = ordered
    if featured is not None:
        shown = [featured] + [a for a in ordered if a is not featured]

    return Selection(
        featured=featured,
        retained=shown,
        pin_applied=pinned is not None,
        pin_stale=pin is not None and pinned is None,
    )


def pin_from_override(value: Any) -> AwardKey | None:
    """Decode an `overrides.value` for `field='award_pin'` into a key, or None.

    The stored shape is `{"competition_slug": "nyisc", "year": 2025, "medal": "Gold"}`;
    a malformed value is treated as no pin rather than an error, because a bad row must
    never take a product page down.
    """
    if not isinstance(value, dict):
        return None
    slug = value.get("competition_slug")
    year = value.get("year")
    medal = value.get("medal")
    if not isinstance(slug, str) or not slug.strip():
        return None
    if year is not None and not isinstance(year, int):
        try:
            year = int(year)
        except (TypeError, ValueError):
            return None
    if medal is not None and not isinstance(medal, str):
        return None
    return AwardKey(competition_slug=slug.strip().lower(), year=year, medal=medal)
