#!/usr/bin/env python3
"""Decode POG422 game-result and progressive-jackpot frames (pure functions).

Used by analyze_game_results.py, the bridge, and listen-only early-win fireworks.
No bus I/O — safe to import from tests and offline capture tools.

Progressive block layout (1995 SLAVE.C stripjpotdata — **BE32 cents** at bytes 9–16):
  byte 8   active_wins (optional; Keno sets bit when hits present)
  bytes 9–12  progressive meter display (progdsp) — BE32 cents
  bytes 13–16 progwinval — BE32 cents; non-zero => progressive jackpot win on slave

See PROGRESSIVE_DECODE.md (bench-validated 2026-07-02 Keno $5000.88).

Legacy mtosJackpotDecode opcodes + T340 x9 map. See GAME_RESULT_X9.md.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Iterable, Optional

SOH = 0x05
EOP = 0x0D

# Master→Slave frames that carry stripjpotdata() progressive block (1995 SLAVE.C).
JPOT_STRIP_OPCODES = frozenset(
    {
        0x18,  # pulltab
        0x29,  # touch6 lotto
        0x39,  # bingo
        0x49,  # keno
        0x58,  # poker cards (1995)
        0x59,  # poker cards (T340 x9)
        0x5A,  # poker jpot grant
        0x69,  # superpick
        0x78,  # blackjack cards (1995)
        0x79,  # blackjack cards (T340 x9)
        0x7A,  # blackjack jpot grant
    }
)

SKILL_JPOT_CLAIM_OPCODES = frozenset({0x52, 0x72})  # slave → master
SKILL_JPOT_GRANT_OPCODES = frozenset({0x5A, 0x7A})  # master → slave

GAMEPLAY_X9_OPCODES = frozenset({0x29, 0x39, 0x49, 0x59, 0x69, 0x79, 0x89})

REQUEST_FOR_RESPONSE = {
    0x20: 0x29,
    0x30: 0x39,
    0x40: 0x49,
    0x50: 0x59,
    0x60: 0x69,
    0x70: 0x79,
    0x80: 0x89,
}


def parse_hex_line(line: str) -> bytes:
    line = line.strip()
    if not line or line.startswith("#"):
        return b""
    return bytes(int(part, 16) for part in line.split(","))


def tid_hex_from_frame(raw: bytes, offset: int = 3) -> Optional[str]:
    if offset + 1 >= len(raw):
        return None
    return f"{raw[offset]:02X}{raw[offset + 1]:02X}"


def game_id_hex_from_frame(raw: bytes) -> Optional[str]:
    if len(raw) >= 7:
        return f"{raw[6]:02X}{raw[5]:02X}"
    return None


def u32_be(raw: bytes, offset: int) -> Optional[int]:
    if offset + 4 > len(raw):
        return None
    chunk = raw[offset : offset + 4]
    return int.from_bytes(chunk, "big")


def u32_le(raw: bytes, offset: int) -> Optional[int]:
    if offset + 4 > len(raw):
        return None
    chunk = raw[offset : offset + 4]
    return int.from_bytes(chunk, "little")


@dataclass(frozen=True)
class ProgressiveBlock:
    active_wins: Optional[int]
    progdsp_be: Optional[int]
    progdsp_le: Optional[int]
    progwin_be: Optional[int]
    progwin_le: Optional[int]

    @property
    def progwin_primary(self) -> int:
        """1995 SLAVE.C: BE32 cents at bytes 13–16."""
        return self.progwin_be or 0

    @property
    def progwin_dollars(self) -> float:
        return self.progwin_primary / 100.0

    @property
    def progdsp_dollars(self) -> float:
        return (self.progdsp_be or 0) / 100.0

    @property
    def is_jackpot_hit(self) -> bool:
        return bool(self.progwin_be)


@dataclass
class GameResultDecode:
    msg_type: int
    wire_len: int
    tid_hex: Optional[str]
    game_id_hex: Optional[str]
    play_sequence: Optional[int]
    session_counter: Optional[int]
    progressive: Optional[ProgressiveBlock]
    is_progressive_hit: bool
    category: str
    payload_hex: str
    notes: list[str] = field(default_factory=list)


def decode_progressive_block(raw: bytes) -> Optional[ProgressiveBlock]:
    if len(raw) < 17:
        return None
    return ProgressiveBlock(
        active_wins=raw[8],
        progdsp_be=u32_be(raw, 9),
        progdsp_le=u32_le(raw, 9),
        progwin_be=u32_be(raw, 13),
        progwin_le=u32_le(raw, 13),
    )


def decode_frame(raw: bytes) -> GameResultDecode:
    if len(raw) < 2 or raw[0] != SOH or raw[-1] != EOP:
        return GameResultDecode(
            msg_type=-1,
            wire_len=len(raw),
            tid_hex=None,
            game_id_hex=None,
            play_sequence=None,
            session_counter=None,
            progressive=None,
            is_progressive_hit=False,
            category="invalid",
            payload_hex=raw.hex(),
            notes=["not a SOH..EOP frame"],
        )

    msg = raw[1]
    tid = tid_hex_from_frame(raw)
    game = game_id_hex_from_frame(raw)
    play_seq = raw[7] if len(raw) >= 8 and msg != 0x0D else None
    session_ctr = raw[8] if len(raw) >= 9 and msg == 0x0D else None

    category = "other"
    notes: list[str] = []
    progressive: Optional[ProgressiveBlock] = None
    is_hit = False

    if msg in JPOT_STRIP_OPCODES:
        progressive = decode_progressive_block(raw)
        is_hit = bool(progressive and progressive.is_jackpot_hit)
        if msg in SKILL_JPOT_GRANT_OPCODES:
            category = "skill_jpot_grant"
        elif msg in GAMEPLAY_X9_OPCODES or msg in {0x58, 0x78}:
            category = "gameplay_x9"
        else:
            category = "jpot_strip"
    elif msg in SKILL_JPOT_CLAIM_OPCODES:
        category = "skill_jpot_claim"
        notes.append("slave jackpot claim — expect follow-on 0x5A or 0x7A")
    elif msg in REQUEST_FOR_RESPONSE:
        category = "gameplay_x0"
    elif msg in {v + 1 for v in REQUEST_FOR_RESPONSE.values()}:
        category = "gameplay_x1"
    elif msg == 0x0D:
        category = "status_close"

    if progressive and is_hit:
        if progressive.progwin_be and progressive.progwin_le and progressive.progwin_be != progressive.progwin_le:
            notes.append(
                f"progwin BE={progressive.progwin_be} LE={progressive.progwin_le} — validate endianness"
            )

    return GameResultDecode(
        msg_type=msg,
        wire_len=len(raw),
        tid_hex=tid,
        game_id_hex=game,
        play_sequence=play_seq,
        session_counter=session_ctr,
        progressive=progressive,
        is_progressive_hit=is_hit,
        category=category,
        payload_hex=",".join(f"{b:02X}" for b in raw),
        notes=notes,
    )


def decode_hex_line(line: str) -> GameResultDecode:
    return decode_frame(parse_hex_line(line))


def progressive_hits(raw_lines: Iterable[str]) -> list[GameResultDecode]:
    out: list[GameResultDecode] = []
    for line in raw_lines:
        raw = parse_hex_line(line)
        if not raw:
            continue
        dec = decode_frame(raw)
        if dec.is_progressive_hit:
            out.append(dec)
    return out


# --- 8-liner slots (970C Triple 7's, 970D SpinBall) — grid @ 0x89 bytes 21–29 ---
# Bench-validated 2026-07-04 pos2/pos3 oracle capture (5 spins, games 1–4 pos2 = pos3;
# spin 5 tail matched pos3 Master only — pos2 relay bytes 25–29 differed).

SLOT_89_WIRE_LEN = 36
SLOT_89_GRID_OFFSET = 21
SLOT_89_GRID_LEN = 9
SLOT_89_COUNTER_OFFSET = 17  # status_0d counter hint (play_sequence byte @ 7 is separate)

SLOT_WAGER_WIRE_LEN = 32
SLOT_WAGER_BYTE8_OFFSET = 8
# Operator oracle 2026-07-04: byte 8 = total credits with all 8 lines active.
SLOT_WAGER_PROG_MIN_CREDITS = 0x08  # 1 cr × 8 lines — progressive tier minimum
SLOT_WAGER_MAX_CREDITS = 0x10  # 2 cr × 8 lines — MAX bet ($4.00 @ $0.25/credit)

# Row-major 3×3: indices 0–2 top row, 3–5 middle, 6–8 bottom (left→right).
SLOT_8LINER_PAYLINE_INDICES = (
    (3, 4, 5),  # 1 middle row
    (0, 1, 2),  # 2 upper row
    (6, 7, 8),  # 3 lower row
    (0, 4, 8),  # 4 UL→LR diagonal
    (6, 4, 2),  # 5 LL→UR diagonal
    (0, 3, 6),  # 6 left column
    (1, 4, 7),  # 7 center column
    (2, 5, 8),  # 8 right column
)

# Triple 7's (970C) — capture-validated subset; SpinBall (970D) TBD.
TRIPLE7S_SYMBOL_BY_WIRE: dict[int, str] = {
    0x00: "CHERRY",
    0x01: "SBAR",
    0x02: "DBAR",
    0x03: "BAR3",
    0x04: "LEMON",
    0x05: "PLUM",
    0x06: "ORANGE",
    0x07: "WATERMELON",
    0x08: "APPLE",
    0x09: "BELL",
    0x0A: "RED7",
    0x0B: "SILVER7",
    0x0C: "BLUE7",
    0x0D: "TRIPLE7",  # jackpot tier — 7-family wire sequence (0x0A–0x0D)
}

# Operator oracle aliases from bench notes → canonical wire names above.
SLOT_SYMBOL_ORACLE_ALIASES: dict[str, str] = {
    "TBAR": "BAR3",
    "SBAR": "SBAR",
    "DBAR": "DBAR",
    "WMELON": "WATERMELON",
    "BLUE7": "BLUE7",
    "TRIPLE7": "TRIPLE7",
    "RED7": "RED7",
    "SILVER7": "SILVER7",
    "ORANGE": "ORANGE",
    "PLUM": "PLUM",
    "LEMON": "LEMON",
    "CHERRY": "CHERRY",
    "BELL": "BELL",
    "APPLE": "APPLE",
}


def slot_wager_byte8_play_credits(raw: bytes) -> int | None:
    """Total play credits from 0x80 byte 8 (all 8 lines in play)."""
    if len(raw) != SLOT_WAGER_WIRE_LEN or raw[0] != SOH or raw[1] != 0x80 or raw[-1] != EOP:
        return None
    return raw[SLOT_WAGER_BYTE8_OFFSET] & 0xFF


def slot_wager_progressive_eligible(raw: bytes) -> bool:
    """True when wager meets progressive minimum (8 credits = 1 cr × 8 lines)."""
    credits = slot_wager_byte8_play_credits(raw)
    return credits is not None and credits >= SLOT_WAGER_PROG_MIN_CREDITS


def slot_wager_is_max_bet(raw: bytes) -> bool:
    """True when byte 8 is MAX bet (16 credits = 2 cr × 8 lines)."""
    return slot_wager_byte8_play_credits(raw) == SLOT_WAGER_MAX_CREDITS


@dataclass(frozen=True)
class Slot89GridDecode:
    wire_len: int
    game_id_hex: Optional[str]
    tid_hex: Optional[str]
    play_sequence: Optional[int]
    session_counter: Optional[int]
    grid_wire: tuple[int, ...]
    grid_names: tuple[str, ...]
    unknown_wire: tuple[int, ...]
    payload_hex: str


def slot_symbol_name(wire: int, *, game_id_hex: str = "970C") -> str:
    """Map one symbol wire byte to a display name (970C map today)."""
    if game_id_hex == "970C":
        return TRIPLE7S_SYMBOL_BY_WIRE.get(wire & 0xFF, f"UNK_{wire:02X}")
    return f"UNK_{wire:02X}"


def decode_slot_89_grid(raw: bytes, *, game_id_hex: Optional[str] = None) -> Optional[Slot89GridDecode]:
    """Decode 3×3 symbol grid from a 36 B slot result (0x89)."""
    if len(raw) != SLOT_89_WIRE_LEN or raw[0] != SOH or raw[1] != 0x89 or raw[-1] != EOP:
        return None
    gid = game_id_hex or game_id_hex_from_frame(raw)
    end = SLOT_89_GRID_OFFSET + SLOT_89_GRID_LEN
    if end > len(raw):
        return None
    grid_wire = tuple(raw[SLOT_89_GRID_OFFSET:end])
    names: list[str] = []
    unknown: list[int] = []
    for b in grid_wire:
        name = slot_symbol_name(b, game_id_hex=gid or "970C")
        names.append(name)
        if name.startswith("UNK_"):
            unknown.append(b)
    counter = raw[SLOT_89_COUNTER_OFFSET] if len(raw) > SLOT_89_COUNTER_OFFSET else None
    return Slot89GridDecode(
        wire_len=len(raw),
        game_id_hex=gid,
        tid_hex=tid_hex_from_frame(raw),
        play_sequence=raw[7] if len(raw) > 7 else None,
        session_counter=counter,
        grid_wire=grid_wire,
        grid_names=tuple(names),
        unknown_wire=tuple(unknown),
        payload_hex=",".join(f"{b:02X}" for b in raw),
    )


def slot_grid_from_oracle_rows(rows: Iterable[Iterable[str]]) -> tuple[str, ...]:
    """Flatten three row strings (3 symbols each) into row-major grid names."""
    flat: list[str] = []
    for row in rows:
        for cell in row:
            key = cell.strip().upper().replace(" ", "").replace("'", "")
            flat.append(SLOT_SYMBOL_ORACLE_ALIASES.get(key, key))
    if len(flat) != SLOT_89_GRID_LEN:
        raise ValueError(f"expected {SLOT_89_GRID_LEN} cells, got {len(flat)}")
    return tuple(flat)


# --- Phase 2: 970C Triple 7's payline evaluator (SGVI paytable sheet 13) ---
# Awards are credits PER credit bet on the line (multiply by bet_per_line).
# Bench cross-check (SLOT_8LINER_EARLY_WIN.md): 3 Bell @ MAX (2 cr/line) = 100 × 2 = 200 cr.

TRIPLE7S_LINE_PAYS: dict[str, int] = {
    "TRIPLE7": 10000,  # jackpot tier (progressive when bet meets 8-credit min)
    "BLUE7": 1000,
    "RED7": 1000,
    "SILVER7": 1000,
    "BELL": 100,
    "BAR3": 75,  # triple bar
    "DBAR": 50,  # double bar
    "SBAR": 40,  # single bar
    "APPLE": 30,
    "WATERMELON": 25,
    "ORANGE": 20,
    "PLUM": 15,
    "LEMON": 12,
    "CHERRY": 8,
}

TRIPLE7S_SEVEN_FAMILY = frozenset({"RED7", "SILVER7", "BLUE7", "TRIPLE7"})
TRIPLE7S_BAR_FAMILY = frozenset({"BAR3", "DBAR", "SBAR"})
TRIPLE7S_ANY7_PAY = 250  # three mixed 7's (not all identical)
TRIPLE7S_ANYBAR_PAY = 10  # three mixed bars (not all identical)
TRIPLE7S_CHERRY_CC_PAY = 5  # cherry, cherry, any (left to right)
TRIPLE7S_CHERRY_C_PAY = 2  # cherry, any, any (left to right)

TRIPLE7S_JACKPOT_SYMBOL = "TRIPLE7"


@dataclass(frozen=True)
class SlotLineWin:
    line_no: int  # 1-8 (UI numbering)
    indices: tuple[int, int, int]
    symbols: tuple[str, str, str]
    tier: str  # e.g. "3xBELL", "ANY7", "ANYBAR", "CHERRYx2", "CHERRYx1", "3xTRIPLE7"
    base_award: int  # credits per credit bet on the line
    credits_won: int  # base_award × bet_per_line
    is_jackpot: bool


@dataclass(frozen=True)
class SlotEvalResult:
    game_id_hex: Optional[str]
    grid_names: tuple[str, ...]
    active_lines: tuple[int, ...]
    bet_per_line: int
    lines: tuple[SlotLineWin, ...]
    total_base: int  # sum of base awards (credits per line-bet)
    total_credits: int  # sum of credits_won (base × bet_per_line)
    is_jackpot: bool
    top_tier: Optional[str]


def slot_bet_per_line_credits(total_credits: Optional[int]) -> Optional[int]:
    """Per-line bet in credits from total wager (all 8 lines active).

    Byte-8 oracle: 8 cr => 1 cr/line (prog min), 16 cr => 2 cr/line (MAX). Partial
    line masks are not yet wire-mapped, so only exact multiples of 8 are trusted.
    """
    if not total_credits or total_credits <= 0:
        return None
    if total_credits % 8 != 0:
        return None
    return total_credits // 8


def slot_active_paylines(total_credits: Optional[int]) -> tuple[int, ...]:
    """Active UI line numbers (1-8). All 8 when wager meets the 8-line minimum.

    Partial line masks (< 8 credits) are not yet decoded from the wire; return empty
    so callers do not credit unmapped lines.
    """
    if total_credits is not None and total_credits >= SLOT_WAGER_PROG_MIN_CREDITS:
        return tuple(range(1, len(SLOT_8LINER_PAYLINE_INDICES) + 1))
    return ()


def slot_eval_line(symbols: tuple[str, str, str]) -> tuple[str, int, bool]:
    """Evaluate one payline → (tier, base_award, is_jackpot).

    Highest-value rule wins. Ordered identical → 7-family → bar-family → cherry
    partials yields the max because a 3-of-a-kind always pays >= its family rule.
    """
    s0, s1, s2 = symbols
    if s0 == s1 == s2:
        award = TRIPLE7S_LINE_PAYS.get(s0)
        if award is not None:
            return (f"3x{s0}", award, s0 == TRIPLE7S_JACKPOT_SYMBOL)
    trio = {s0, s1, s2}
    if trio <= TRIPLE7S_SEVEN_FAMILY:
        return ("ANY7", TRIPLE7S_ANY7_PAY, False)
    if trio <= TRIPLE7S_BAR_FAMILY:
        return ("ANYBAR", TRIPLE7S_ANYBAR_PAY, False)
    if s0 == "CHERRY":
        if s1 == "CHERRY":
            return ("CHERRYx2", TRIPLE7S_CHERRY_CC_PAY, False)
        return ("CHERRYx1", TRIPLE7S_CHERRY_C_PAY, False)
    return ("", 0, False)


def _normalize_grid_names(
    grid: Iterable[object], *, game_id_hex: str = "970C"
) -> tuple[str, ...]:
    names: list[str] = []
    for cell in grid:
        if isinstance(cell, int):
            names.append(slot_symbol_name(cell, game_id_hex=game_id_hex))
        else:
            key = str(cell).strip().upper().replace(" ", "").replace("'", "")
            names.append(SLOT_SYMBOL_ORACLE_ALIASES.get(key, key))
    if len(names) != SLOT_89_GRID_LEN:
        raise ValueError(f"expected {SLOT_89_GRID_LEN} cells, got {len(names)}")
    return tuple(names)


def slot_eval_paylines_from_grid(
    grid: Iterable[object],
    *,
    active_lines: Optional[Iterable[int]] = None,
    bet_per_line: int = 1,
    game_id_hex: str = "970C",
) -> SlotEvalResult:
    """Evaluate 970C paylines from a 3×3 grid (wire ints or symbol names).

    Pure function: grid + active lines + bet → per-line wins and totals. Trailer
    win-flag bytes 30–32 are NOT used (decode still open); this is the paytable path.
    """
    names = _normalize_grid_names(grid, game_id_hex=game_id_hex)
    if active_lines is None:
        active = tuple(range(1, len(SLOT_8LINER_PAYLINE_INDICES) + 1))
    else:
        active = tuple(sorted({int(n) for n in active_lines}))
    bet = max(1, int(bet_per_line))

    wins: list[SlotLineWin] = []
    total_base = 0
    total_credits = 0
    is_jackpot = False
    top_tier: Optional[str] = None
    top_award = -1

    for line_no in active:
        idx = SLOT_8LINER_PAYLINE_INDICES[line_no - 1]
        trio = (names[idx[0]], names[idx[1]], names[idx[2]])
        tier, base_award, jackpot = slot_eval_line(trio)
        if base_award <= 0:
            continue
        credits = base_award * bet
        wins.append(
            SlotLineWin(
                line_no=line_no,
                indices=idx,
                symbols=trio,
                tier=tier,
                base_award=base_award,
                credits_won=credits,
                is_jackpot=jackpot,
            )
        )
        total_base += base_award
        total_credits += credits
        is_jackpot = is_jackpot or jackpot
        if base_award > top_award:
            top_award = base_award
            top_tier = tier

    return SlotEvalResult(
        game_id_hex=game_id_hex,
        grid_names=names,
        active_lines=active,
        bet_per_line=bet,
        lines=tuple(wins),
        total_base=total_base,
        total_credits=total_credits,
        is_jackpot=is_jackpot,
        top_tier=top_tier,
    )


def slot_eval_result_frame(
    result_raw: bytes,
    *,
    wager_credits: Optional[int] = None,
) -> Optional[SlotEvalResult]:
    """Bridge entry: decode a 0x89 result and evaluate paylines.

    wager_credits (from the cached 0x80 byte 8) sets active lines + bet_per_line.
    When omitted, all 8 lines at 1 cr/line are assumed (base-award report).
    """
    grid = decode_slot_89_grid(result_raw)
    if grid is None:
        return None
    bet = slot_bet_per_line_credits(wager_credits) or 1
    active = slot_active_paylines(wager_credits) or None
    return slot_eval_paylines_from_grid(
        grid.grid_wire,
        active_lines=active,
        bet_per_line=bet,
        game_id_hex=grid.game_id_hex or "970C",
    )


def _slot_selftest() -> list[str]:
    errors: list[str] = []
    # pos3 Master lines — archive 2026-07-04 triple7s-symbol-map (spin 5 = pos3 native).
    frames = [
        "05,89,01,38,D7,0C,97,07,00,00,07,A6,56,00,00,00,00,9B,00,F5,F6,06,05,03,0B,03,01,02,07,01,03,04,0A,15,0A,0D",
        "05,89,01,38,D7,0C,97,00,00,00,07,A6,5A,00,00,00,00,9C,00,F5,F7,05,01,04,06,04,03,00,00,0C,00,00,04,F5,DD,0D",
        "05,89,01,38,D7,0C,97,01,00,00,07,A6,5E,00,00,00,00,9D,00,F5,F8,09,00,05,0A,00,05,00,00,00,03,05,05,14,EF,0D",
        "05,89,01,38,D7,0C,97,02,00,00,07,A6,62,00,00,00,00,9E,00,F5,F9,05,0C,06,01,01,04,02,09,03,03,0C,07,FB,B1,0D",
        "05,89,01,38,D7,0C,97,03,00,00,07,A6,66,00,00,00,00,9F,00,F5,FA,01,07,08,03,06,0C,01,00,05,02,04,08,25,1F,0D",
    ]
    oracles = [
        slot_grid_from_oracle_rows(
            [
                ("Orange", "Plum", "TBar"),
                ("Silver7", "TBar", "SBar"),
                ("DBar", "WMelon", "SBar"),
            ]
        ),
        slot_grid_from_oracle_rows(
            [
                ("Plum", "SBar", "Lemon"),
                ("Orange", "Lemon", "TBar"),
                ("Cherry", "Cherry", "Blue7"),
            ]
        ),
        slot_grid_from_oracle_rows(
            [
                ("Bell", "Cherry", "Plum"),
                ("Red7", "Cherry", "Plum"),
                ("Cherry", "Cherry", "Cherry"),
            ]
        ),
        slot_grid_from_oracle_rows(
            [
                ("Plum", "Blue7", "Orange"),
                ("SBar", "SBar", "Lemon"),
                ("DBar", "Bell", "TBar"),
            ]
        ),
        slot_grid_from_oracle_rows(
            [
                ("SBar", "WMelon", "Apple"),
                ("TBar", "Orange", "Blue7"),
                ("SBar", "Cherry", "Plum"),
            ]
        ),
    ]
    for idx, (line, expected) in enumerate(zip(frames, oracles), start=1):
        dec = decode_slot_89_grid(parse_hex_line(line))
        if dec is None:
            errors.append(f"spin {idx}: decode failed")
            continue
        if dec.grid_names != expected:
            errors.append(f"spin {idx}: grid {dec.grid_names!r} != {expected!r}")
    wager80 = parse_hex_line(
        "05,80,01,38,D7,0C,97,05,10,00,00,00,00,00,00,00,00,7A,A6,0C,00,00,00,00,00,00,00,00,00,69,CA,0D"
    )
    if slot_wager_byte8_play_credits(wager80) != 16:
        errors.append("wager80 byte8 != 16")
    if not slot_wager_progressive_eligible(wager80):
        errors.append("wager80 not progressive eligible")
    if not slot_wager_is_max_bet(wager80):
        errors.append("wager80 not max bet")

    # --- Phase 2 payline evaluator (base awards hand-verified from oracle spins) ---
    if slot_bet_per_line_credits(16) != 2:
        errors.append("bet_per_line(16) != 2")
    if slot_bet_per_line_credits(8) != 1:
        errors.append("bet_per_line(8) != 1")
    if slot_bet_per_line_credits(12) is not None:
        errors.append("bet_per_line(12) should be None (partial mask unmapped)")
    if slot_active_paylines(16) != (1, 2, 3, 4, 5, 6, 7, 8):
        errors.append("active_paylines(16) != all 8")
    if slot_active_paylines(4) != ():
        errors.append("active_paylines(4) should be empty (partial unmapped)")

    # spin 1 = two mixed-bar lines (ANYBAR ×2 = 20); spin 2 = cherry partials (5 + 2 = 7);
    # spin 3 = two 3-cherry + one cherry-cherry (8 + 8 + 5 = 21); spin 4 = no wins (0);
    # spin 5 = left column SBAR,BAR3,SBAR mixed-bar (ANYBAR = 10).
    eval_expect = [20, 7, 21, 0, 10]
    for idx, (grid, want) in enumerate(zip(oracles, eval_expect), start=1):
        res = slot_eval_paylines_from_grid(grid, bet_per_line=1)
        if res.total_base != want:
            errors.append(f"spin {idx}: total_base {res.total_base} != {want}")

    # bet multiply: spin 3 base 21 × 2 cr/line = 42 credits.
    res3 = slot_eval_paylines_from_grid(oracles[2], bet_per_line=2)
    if res3.total_credits != 42:
        errors.append(f"spin 3 total_credits {res3.total_credits} != 42")
    if res3.is_jackpot:
        errors.append("spin 3 should not be jackpot")
    if res3.top_tier != "3xCHERRY":
        errors.append(f"spin 3 top_tier {res3.top_tier} != 3xCHERRY")

    # end-to-end from wire 0x89 + wager credits (frames[2] == spin 3 grid).
    res_frame = slot_eval_result_frame(parse_hex_line(frames[2]), wager_credits=16)
    if res_frame is None or res_frame.total_credits != 42:
        errors.append("slot_eval_result_frame(spin3, 16cr) total_credits != 42")

    # synthetic jackpot: three TRIPLE7 (0x0D) on line 1 (indices 3,4,5).
    jpot_grid = [0x00, 0x01, 0x02, 0x0D, 0x0D, 0x0D, 0x06, 0x07, 0x08]
    jres = slot_eval_paylines_from_grid(jpot_grid, bet_per_line=1)
    if not jres.is_jackpot:
        errors.append("jackpot grid not flagged is_jackpot")
    if jres.top_tier != "3xTRIPLE7":
        errors.append(f"jackpot top_tier {jres.top_tier} != 3xTRIPLE7")
    jline = next((ln for ln in jres.lines if ln.line_no == 1), None)
    if jline is None or jline.base_award != 10000:
        errors.append("jackpot line 1 base_award != 10000")

    # ANY7 (mixed 7 family) and ANYBAR family rules.
    any7 = slot_eval_line(("RED7", "BLUE7", "SILVER7"))
    if any7 != ("ANY7", 250, False):
        errors.append(f"ANY7 rule {any7!r}")
    anybar = slot_eval_line(("BAR3", "DBAR", "SBAR"))
    if anybar != ("ANYBAR", 10, False):
        errors.append(f"ANYBAR rule {anybar!r}")
    return errors


# --- Phase 2: TouchEasy (sheet 10) / SuperBall (sheet 11) Keno cash replay ---

KENO_WAGER_MSG = 0x40
KENO_RESULT_MSG = 0x49
TOUCHEASY_KENO_GAME_ID = "9309"
SUPERBALL_KENO_GAME_ID = "930A"
KENO_PHASE2_GAME_IDS = frozenset({TOUCHEASY_KENO_GAME_ID, SUPERBALL_KENO_GAME_ID})
KENO_WAGER_BET_OFFSET = 8
KENO_WAGER_SPOT_MASK_OFFSET = 29
KENO_WAGER_SPOT_MASK_LEN = 10
# Natural T340 / lab synth: 20 unique balls 1..80 at offset 21 (stable; raw on wire).
KENO_RESULT_BALLS_OFFSET = 21
KENO_RESULT_BALL_COUNT = 20
KENO_RESULT_PROGWIN_OFFSET = 13
# SuperBall feature: if the 20th (last) draw ball is one of the player's picks,
# base cash win (sheet-11 mult) is multiplied by 4.
SUPERBALL_LAST_BALL_MULT = 4

# Linear multipliers: pay_cents = bet_cents * mult[hits][picks-2].
# Source: POG552U_GLD4_SGVI_CONFIG_PAYTABLES/SGVI_Paytables_in_Use.xlsx
# TouchEasy sheet 10 ("N or Jpot" → N on cash path; progwin≠0 is Phase 1).
TOUCHEASY_KENO_PAY_MULT: tuple[tuple[int, ...], ...] = (
    # picks→  2    3    4    5    6    7     8     9    10
    (0, 0, 0, 0, 0, 0, 0, 0, 1),  # 0 hits
    (1, 0, 0, 0, 0, 0, 0, 0, 0),  # 1
    (9, 3, 2, 1, 0, 0, 0, 0, 0),  # 2
    (0, 36, 6, 3, 2, 1, 1, 0, 0),  # 3
    (0, 0, 76, 22, 11, 6, 3, 2, 1),  # 4
    (0, 0, 0, 200, 90, 30, 13, 12, 5),  # 5
    (0, 0, 0, 0, 480, 200, 64, 36, 24),  # 6
    (0, 0, 0, 0, 0, 800, 380, 128, 80),  # 7
    (0, 0, 0, 0, 0, 0, 1200, 480, 400),  # 8
    (0, 0, 0, 0, 0, 0, 0, 2000, 1100),  # 9
    (0, 0, 0, 0, 0, 0, 0, 0, 5000),  # 10
)

# SuperBall Keno cash (SGVI sheet 11). Validated bank help @ 1 credit / $0.25
# (pos5 T340_0002 2026-08-04). Buy-A-Jackpot is a *separate* option table and is
# not used here — progressive awards ride Phase 1 via progwinval only.
SUPERBALL_KENO_PAY_MULT: tuple[tuple[int, ...], ...] = (
    # picks→  2    3    4    5    6    7     8     9    10
    (0, 0, 0, 0, 0, 0, 0, 0, 0),  # 0 hits
    (1, 0, 0, 0, 0, 0, 0, 0, 0),  # 1
    (6, 2, 1, 1, 0, 0, 0, 0, 0),  # 2
    (0, 27, 6, 2, 2, 1, 1, 0, 0),  # 3
    (0, 0, 50, 10, 7, 4, 2, 2, 1),  # 4
    (0, 0, 0, 100, 21, 14, 6, 4, 4),  # 5
    (0, 0, 0, 0, 150, 60, 21, 16, 8),  # 6
    (0, 0, 0, 0, 0, 200, 100, 90, 25),  # 7
    (0, 0, 0, 0, 0, 0, 500, 200, 125),  # 8
    (0, 0, 0, 0, 0, 0, 0, 1000, 500),  # 9
    (0, 0, 0, 0, 0, 0, 0, 0, 1000),  # 10
)


@dataclass(frozen=True)
class KenoEvalResult:
    game_id_hex: str
    picks: int
    hits: int
    bet_credits: int
    bet_cents: int
    pay_mult: int
    win_cents: int
    pick_spots: tuple[int, ...]
    draw_balls: tuple[int, ...]
    balls_source: str  # always "offset_21" (stable layout)
    progwin_cents: int
    last_ball: int | None = None
    last_ball_hit: bool = False
    superball_x4: bool = False
    base_pay_mult: int = 0
    base_win_cents: int = 0


def keno_wager_bet_credits(raw: bytes) -> Optional[int]:
    if len(raw) <= KENO_WAGER_BET_OFFSET or raw[0] != SOH or raw[1] != KENO_WAGER_MSG:
        return None
    return int(raw[KENO_WAGER_BET_OFFSET])


def keno_spot_picks_from_wager(raw: bytes) -> Optional[tuple[int, ...]]:
    """Return 1-origin spot numbers (1..80) from 0x40 mask @29 (10 bytes, bit0=spot1)."""
    end = KENO_WAGER_SPOT_MASK_OFFSET + KENO_WAGER_SPOT_MASK_LEN
    if len(raw) < end or raw[0] != SOH or raw[1] != KENO_WAGER_MSG:
        return None
    mask = raw[KENO_WAGER_SPOT_MASK_OFFSET:end]
    picks: list[int] = []
    for bi, byt in enumerate(mask):
        for bit in range(8):
            if byt & (1 << bit):
                picks.append(bi * 8 + bit + 1)
    return tuple(picks)


def keno_display_balls_from_result(raw: bytes) -> Optional[tuple[tuple[int, ...], str]]:
    """Decode 20 draw balls from 0x49 at fixed offset 21 (raw display 1–80)."""
    end = KENO_RESULT_BALLS_OFFSET + KENO_RESULT_BALL_COUNT
    if len(raw) < end or raw[0] != SOH or raw[1] != KENO_RESULT_MSG:
        return None
    balls = tuple(int(b) for b in raw[KENO_RESULT_BALLS_OFFSET:end])
    if (
        len(balls) == KENO_RESULT_BALL_COUNT
        and all(1 <= b <= 80 for b in balls)
        and len(set(balls)) == KENO_RESULT_BALL_COUNT
    ):
        return balls, "offset_21"
    return None


def toucheasy_keno_pay_mult(hits: int, picks: int) -> int:
    if picks < 2 or picks > 10 or hits < 0 or hits > 10 or hits > picks:
        return 0
    return TOUCHEASY_KENO_PAY_MULT[hits][picks - 2]


def superball_keno_pay_mult(hits: int, picks: int) -> int:
    if picks < 2 or picks > 10 or hits < 0 or hits > 10 or hits > picks:
        return 0
    return SUPERBALL_KENO_PAY_MULT[hits][picks - 2]


def keno_pay_mult_for_game(game_id_hex: str, hits: int, picks: int) -> int:
    gid = (game_id_hex or TOUCHEASY_KENO_GAME_ID).upper()
    if gid == SUPERBALL_KENO_GAME_ID:
        return superball_keno_pay_mult(hits, picks)
    return toucheasy_keno_pay_mult(hits, picks)


def keno_count_hits(picks: Iterable[int], balls: Iterable[int]) -> int:
    pick_set = {int(p) for p in picks}
    return sum(1 for b in balls if int(b) in pick_set)


def keno_eval_toucheasy(
    *,
    pick_spots: Iterable[int],
    draw_balls: Iterable[int],
    bet_credits: int,
    credit_cents: int = 25,
    balls_source: str = "offset_21",
    progwin_cents: int = 0,
    game_id_hex: str = TOUCHEASY_KENO_GAME_ID,
    apply_superball_last_x4: bool | None = None,
) -> KenoEvalResult:
    """Cash keno eval — sheet 10 (TouchEasy 9309) or sheet 11 (SuperBall 930A)."""
    picks_t = tuple(sorted({int(p) for p in pick_spots if 1 <= int(p) <= 80}))
    balls_t = tuple(int(b) for b in draw_balls)
    hits = keno_count_hits(picks_t, balls_t)
    picks_n = len(picks_t)
    gid = (game_id_hex or TOUCHEASY_KENO_GAME_ID).upper()
    mult = keno_pay_mult_for_game(gid, hits, picks_n)
    bet_cents = max(0, int(bet_credits)) * max(1, int(credit_cents))
    base_win = bet_cents * mult
    if apply_superball_last_x4 is None:
        apply_superball_last_x4 = gid == SUPERBALL_KENO_GAME_ID
    last_ball = int(balls_t[-1]) if balls_t else None
    last_hit = bool(
        last_ball is not None and last_ball in set(picks_t)
    )
    x4 = bool(apply_superball_last_x4 and last_hit and base_win > 0)
    win_cents = base_win * SUPERBALL_LAST_BALL_MULT if x4 else base_win
    pay_mult = mult * SUPERBALL_LAST_BALL_MULT if x4 else mult
    return KenoEvalResult(
        game_id_hex=gid,
        picks=picks_n,
        hits=hits,
        bet_credits=int(bet_credits),
        bet_cents=bet_cents,
        pay_mult=pay_mult,
        win_cents=win_cents,
        pick_spots=picks_t,
        draw_balls=balls_t,
        balls_source=balls_source,
        progwin_cents=max(0, int(progwin_cents)),
        last_ball=last_ball,
        last_ball_hit=last_hit,
        superball_x4=x4,
        base_pay_mult=mult,
        base_win_cents=base_win,
    )


def keno_eval_result_frame(
    result_raw: bytes,
    *,
    pick_spots: Iterable[int] | None,
    bet_credits: int | None,
    credit_cents: int = 25,
) -> Optional[KenoEvalResult]:
    """Bridge entry: cash eval for TouchEasy (9309) or SuperBall (930A) 0x49 + 0x40."""
    if len(result_raw) < 8 or result_raw[0] != SOH or result_raw[1] != KENO_RESULT_MSG:
        return None
    game = game_id_hex_from_frame(result_raw)
    game_u = (game or TOUCHEASY_KENO_GAME_ID).upper()
    if game_u not in KENO_PHASE2_GAME_IDS:
        return None
    if pick_spots is None or bet_credits is None:
        return None
    decoded = keno_display_balls_from_result(result_raw)
    if decoded is None:
        return None
    balls, src = decoded
    progwin = u32_be(result_raw, KENO_RESULT_PROGWIN_OFFSET) or 0
    return keno_eval_toucheasy(
        pick_spots=pick_spots,
        draw_balls=balls,
        bet_credits=bet_credits,
        credit_cents=credit_cents,
        balls_source=src,
        progwin_cents=progwin,
        game_id_hex=game_u,
        apply_superball_last_x4=(game_u == SUPERBALL_KENO_GAME_ID),
    )


def _keno_selftest() -> list[str]:
    errors: list[str] = []
    # Paytable spot-checks (sheet 10).
    if toucheasy_keno_pay_mult(5, 10) != 5:
        errors.append("5/10 mult != 5")
    if toucheasy_keno_pay_mult(8, 8) != 1200:
        errors.append("8/8 mult != 1200")
    if toucheasy_keno_pay_mult(10, 10) != 5000:
        errors.append("10/10 mult != 5000")
    if toucheasy_keno_pay_mult(2, 10) != 0:
        errors.append("2/10 mult != 0")

    # 0x40: 10 spots 1-10, bet 8 credits ($2 @ 25c).
    wager = parse_hex_line(
        "05,40,01,1D,AF,09,93,00,08,00,00,00,00,00,00,00,00,3D,90,"
        "00,00,00,00,00,00,00,00,00,00,FF,03,00,00,00,00,00,00,00,00,61,40,0D"
    )
    picks = keno_spot_picks_from_wager(wager)
    if picks != tuple(range(1, 11)):
        errors.append(f"wager picks {picks!r} != 1..10")
    if keno_wager_bet_credits(wager) != 8:
        errors.append("wager bet credits != 8")

    # Natural 0x49 (2026-07-01 keno-finish): balls @21; hits spots 1 and 10 → 2 hits → $0.
    native_result = parse_hex_line(
        "05,49,01,1D,AF,09,93,00,00,00,07,A1,76,00,00,00,00,FF,00,F0,5A,"
        "48,38,39,4D,33,24,23,01,28,17,1B,14,27,37,31,1F,47,15,0A,45,6E,FF,0D"
    )
    nat = keno_eval_result_frame(
        native_result, pick_spots=picks, bet_credits=8, credit_cents=25
    )
    if nat is None:
        errors.append("native keno_eval_result_frame failed")
    else:
        if nat.balls_source != "offset_21":
            errors.append(f"expected offset_21 got {nat.balls_source}")
        if nat.hits != 2:
            errors.append(f"native hits {nat.hits} != 2")
        if nat.win_cents != 0:
            errors.append(f"native win_cents {nat.win_cents} != 0")
        if nat.progwin_cents != 0:
            errors.append("native progwin should be 0")

    # Lab / synth path: raw balls 1..20 at offset 21 → 10/10 hits → $10000 @ $2.
    lab = bytearray(44)
    lab[0], lab[1], lab[5], lab[6], lab[-1] = SOH, KENO_RESULT_MSG, 0x09, 0x93, EOP
    for i, display in enumerate(range(1, 21)):
        lab[KENO_RESULT_BALLS_OFFSET + i] = display
    lab_res = keno_eval_result_frame(
        bytes(lab), pick_spots=range(1, 11), bet_credits=8, credit_cents=25
    )
    if lab_res is None or lab_res.balls_source != "offset_21":
        errors.append("lab offset_21 decode failed")
    elif lab_res.hits != 10 or lab_res.win_cents != 8 * 25 * 5000:
        errors.append(
            f"lab 10/10 win {lab_res.hits}/{lab_res.win_cents} != 10/{8*25*5000}"
        )

    # 5 hits @10 picks × $2 = $10.
    five = keno_eval_toucheasy(
        pick_spots=range(1, 11),
        draw_balls=list(range(1, 6)) + list(range(20, 35)),
        bet_credits=8,
        credit_cents=25,
    )
    if five.hits != 5 or five.win_cents != 1000:
        errors.append(f"5/10 eval {five.hits}/{five.win_cents} != 5/1000")

    # SuperBall sheet-11 spot checks (pos5 T340_0002 @ $0.25 → mult = dollars/0.25).
    if superball_keno_pay_mult(2, 2) != 6:
        errors.append("sb 2/2 mult != 6")
    if superball_keno_pay_mult(3, 3) != 27:
        errors.append("sb 3/3 mult != 27")
    if superball_keno_pay_mult(5, 5) != 100:
        errors.append("sb 5/5 mult != 100")
    if superball_keno_pay_mult(10, 10) != 1000:
        errors.append("sb 10/10 mult != 1000")
    if superball_keno_pay_mult(4, 5) != 10:
        errors.append("sb 4/5 mult != 10")
    # SuperBall differs from TouchEasy at 4 hits / 5 picks (10 vs 22).
    if toucheasy_keno_pay_mult(4, 5) == superball_keno_pay_mult(4, 5):
        errors.append("sheet10 vs sheet11 4/5 should differ")

    # SuperBall (930A): sheet-11 cash; last draw ball in picks → ×4.
    # 5 picks, 2 hits incl last → base mult 1 ×4 @ $5 = $20.
    sb_picks = (76, 77, 78, 79, 80)
    sb_balls = list(range(1, 19)) + [76, 78]
    sb = keno_eval_toucheasy(
        pick_spots=sb_picks,
        draw_balls=sb_balls,
        bet_credits=20,
        credit_cents=25,
        game_id_hex=SUPERBALL_KENO_GAME_ID,
    )
    if not sb.last_ball_hit or not sb.superball_x4:
        errors.append(f"superball last-ball x4 flags {sb!r}")
    if sb.hits != 2 or sb.base_pay_mult != 1 or sb.pay_mult != 4:
        errors.append(f"superball mult path {sb.hits}/{sb.base_pay_mult}/{sb.pay_mult}")
    if sb.win_cents != 20 * 25 * 4:
        errors.append(f"superball win_cents {sb.win_cents} != 2000")
    # Full card 5/5 @ 1 credit $0.25 → $25 (no last-ball).
    sb_full = keno_eval_toucheasy(
        pick_spots=sb_picks,
        draw_balls=list(sb_picks) + list(range(1, 16)),
        bet_credits=1,
        credit_cents=25,
        game_id_hex=SUPERBALL_KENO_GAME_ID,
    )
    if sb_full.hits != 5 or sb_full.superball_x4 or sb_full.win_cents != 2500:
        errors.append(f"superball 5/5 1cr {sb_full!r}")
    # No last-ball hit → no ×4 even with hits (2 hits / 5 picks base mult 1).
    sb2 = keno_eval_toucheasy(
        pick_spots=sb_picks,
        draw_balls=[76, 77] + list(range(1, 18)) + [1],
        bet_credits=20,
        credit_cents=25,
        game_id_hex=SUPERBALL_KENO_GAME_ID,
    )
    if sb2.hits != 2 or sb2.superball_x4 or sb2.win_cents != 20 * 25 * 1:
        errors.append(f"superball no-last-x4 {sb2!r}")
    # Frame path accepts 930A.
    sb_frame = bytearray(44)
    sb_frame[0], sb_frame[1] = SOH, KENO_RESULT_MSG
    sb_frame[5], sb_frame[6], sb_frame[-1] = 0x0A, 0x93, EOP
    sb_frame[21:41] = bytes(sb_balls)
    fr = keno_eval_result_frame(
        bytes(sb_frame), pick_spots=sb_picks, bet_credits=20, credit_cents=25
    )
    if fr is None or fr.game_id_hex != SUPERBALL_KENO_GAME_ID or not fr.superball_x4:
        errors.append(f"superball frame eval {fr!r}")
    return errors


if __name__ == "__main__":
    import sys

    fails = _slot_selftest() + _keno_selftest()
    if fails:
        for msg in fails:
            print(msg, file=sys.stderr)
        sys.exit(1)
    print(
        f"slot+keno selftest ok "
        f"({len(TRIPLE7S_SYMBOL_BY_WIRE)} symbols; TouchEasy s10 + SuperBall s11)"
    )
