#!/usr/bin/env python3
"""Phase 2 early win — local slot cash/jackpot trap (970C Triple 7's paytable replay).

Unlike Phase 1 (progwinval trap), 8-liner slots carry NO progressive block on 0x89.
The win is computed on the bridge from the decoded 3x3 grid + cached 0x80 wager via
``decode_x9_result.slot_eval_result_frame`` (pure paytable replay, sheet 13).

Policy (see docs/EARLY-WIN-FIREWORKS-TODO.md, SLOT_8LINER_EARLY_WIN.md):
- **TID filter** — big cash wins stay **local TID only**. Other Pis may still hear the
  multi-drop / ETH-relayed ``0x89``, but only the winning terminal's Pi fires fireworks.
  Bank-wide celebration is Phase 1 progressive only (``progwinval`` / skill grants).
- **FireworksWinThreshold** — configurable; fire when the AGGREGATE win (sum of all winning
  paylines) is at or over the threshold (>=). Dollar-based, NOT tied to any win tier (a natural
  3x TRIPLE7 is too rare to gate on). Default $4.00 (bench); production ~$50.00.
"""

from __future__ import annotations

import os
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional

# Flat layout: shared decode modules live beside this file (dev/ or prod/).
_RUNTIME_ROOT = Path(__file__).resolve().parent
if str(_RUNTIME_ROOT) not in sys.path:
    sys.path.insert(0, str(_RUNTIME_ROOT))

from decode_x9_result import (
    game_id_hex_from_frame,
    slot_eval_result_frame,
    slot_wager_byte8_play_credits,
    tid_hex_from_frame,
)

# Slot x0/x9 opcodes (match pog422_fixture_core; kept local to avoid a core import).
SLOT_WAGER_MSG = 0x80
SLOT_RESULT_MSG = 0x89

# 970C validated; 970D SpinBall symbol map still open (fire on 3x BONUS is future work).
DEFAULT_SLOT_EVAL_GAMES = frozenset({"970C"})

DEFAULT_THRESHOLD_USD = 4.00
DEFAULT_CREDIT_CENTS = 25  # $0.25 / credit (25c steps)
DEFAULT_DEDUPE_MS = 5000.0


def resolve_slot_threshold_cents() -> int:
    """FireworksWinThreshold in cents. USD env wins; else explicit cents; else default."""
    usd = os.environ.get("FIXTURE_FIREWORKS_WIN_THRESHOLD_USD", "").strip()
    if usd:
        try:
            return max(0, round(float(usd) * 100.0))
        except ValueError:
            pass
    cents = os.environ.get("FIXTURE_EARLY_WIN_SLOT_THRESHOLD_CENTS", "").strip()
    if cents:
        try:
            return max(0, int(cents))
        except ValueError:
            pass
    return round(DEFAULT_THRESHOLD_USD * 100.0)


def resolve_slot_credit_cents() -> int:
    raw = os.environ.get("ETHERGOLD_SLOT_CREDIT_CENTS", "").strip()
    if raw:
        try:
            return max(1, int(raw))
        except ValueError:
            pass
    return DEFAULT_CREDIT_CENTS


def slot_dedupe_key(raw: bytes) -> tuple[str, int, int, int] | None:
    if len(raw) < 8 or raw[0] != 0x05 or raw[-1] != 0x0D:
        return None
    tid = tid_hex_from_frame(raw) or "????"
    msg = raw[1]
    play_seq = raw[7]
    counter = raw[17] if len(raw) > 17 else -1
    return (tid.upper(), msg, play_seq, counter)


@dataclass
class SlotHit:
    tid_hex: str
    game_id_hex: str | None
    total_credits: int
    win_cents: int
    is_jackpot: bool
    top_tier: str | None
    bet_per_line: int | None
    source: str
    dedupe_key: tuple[str, int, int, int]


class Phase2SlotTrap:
    """Detect qualifying 8-liner slot wins on the bus; fire local DyzineFrame fireworks."""

    def __init__(
        self,
        *,
        threshold_cents: int = 400,
        credit_cents: int = DEFAULT_CREDIT_CENTS,
        dedupe_ms: float = DEFAULT_DEDUPE_MS,
        dry_run: bool = False,
        eval_games: frozenset[str] = DEFAULT_SLOT_EVAL_GAMES,
        on_event: Callable[..., None] | None = None,
    ):
        self.threshold_cents = max(0, int(threshold_cents))
        self.credit_cents = max(1, int(credit_cents))
        self.dedupe_ms = dedupe_ms
        self.dry_run = dry_run
        self.eval_games = frozenset(g.upper() for g in eval_games)
        self._on_event = on_event
        self._lock = threading.Lock()
        self._seen: dict[tuple[str, int, int, int], float] = {}
        self._wager_credits: dict[str, int] = {}

    def _emit(self, ev: str, **fields: Any) -> None:
        if self._on_event is not None:
            self._on_event(ev, **fields)

    def _prune(self, now: float) -> None:
        cutoff = now - (self.dedupe_ms / 1000.0)
        self._seen = {k: ts for k, ts in self._seen.items() if ts >= cutoff}

    def inspect(
        self,
        frame: bytes,
        *,
        source: str,
        local_wire_tid: str | None = None,
    ) -> SlotHit | None:
        if len(frame) < 8 or frame[0] != 0x05 or frame[-1] != 0x0D:
            return None
        msg = frame[1]
        if msg == SLOT_WAGER_MSG:
            self._remember_wager(frame)
            return None
        if msg != SLOT_RESULT_MSG:
            return None
        return self._eval_result(frame, source=source, local_wire_tid=local_wire_tid)

    def _remember_wager(self, frame: bytes) -> None:
        tid = tid_hex_from_frame(frame)
        credits = slot_wager_byte8_play_credits(frame)
        if tid and credits:
            self._wager_credits[tid.upper()] = credits
            self._emit(
                "early_win_slot_wager_store",
                tid=tid.upper(),
                credits=credits,
            )

    def _eval_result(
        self,
        frame: bytes,
        *,
        source: str,
        local_wire_tid: str | None,
    ) -> SlotHit | None:
        tid = (tid_hex_from_frame(frame) or "").upper()
        game = game_id_hex_from_frame(frame)
        if game and game.upper() not in self.eval_games:
            self._emit("early_win_slot_skip", reason="game_unsupported", game=game, tid=tid, source=source)
            return None
        wager_credits = self._wager_credits.get(tid)
        res = slot_eval_result_frame(frame, wager_credits=wager_credits)
        if res is None:
            self._emit("early_win_slot_skip", reason="decode_failed", tid=tid, source=source)
            return None
        # Aggregate win across ALL winning paylines (slot_eval_result_frame already sums
        # every active line — e.g. a corner cherry pays 3 lines with 8 lines bet).
        win_cents = res.total_credits * self.credit_cents
        # Trigger is the aggregate dollar value at or over FireworksWinThreshold — not tied
        # to any win tier.
        qualifies = win_cents >= self.threshold_cents
        self._emit(
            "early_win_slot_eval",
            tid=tid,
            game=res.game_id_hex,
            total_credits=res.total_credits,
            win_cents=win_cents,
            win_dollars=round(win_cents / 100.0, 2),
            is_jackpot=res.is_jackpot,
            top_tier=res.top_tier,
            bet_per_line=res.bet_per_line,
            wager_credits=wager_credits,
            threshold_cents=self.threshold_cents,
            qualifies=qualifies,
            source=source,
        )
        if not qualifies:
            return None
        local = (local_wire_tid or "").upper()
        if not local:
            self._emit("early_win_slot_skip", reason="local_tid_unknown", tid=tid, source=source)
            return None
        if tid != local:
            self._emit(
                "early_win_slot_skip",
                reason="not_local_terminal",
                tid=tid,
                local=local,
                source=source,
            )
            return None
        key = slot_dedupe_key(frame)
        if key is None:
            return None
        now = time.monotonic()
        with self._lock:
            self._prune(now)
            if key in self._seen:
                self._emit("early_win_slot_dedupe", tid=tid, win_cents=win_cents, source=source)
                return None
            self._seen[key] = now
        hit = SlotHit(
            tid_hex=tid,
            game_id_hex=res.game_id_hex,
            total_credits=res.total_credits,
            win_cents=win_cents,
            is_jackpot=res.is_jackpot,
            top_tier=res.top_tier,
            bet_per_line=res.bet_per_line,
            source=source,
            dedupe_key=key,
        )
        self._emit(
            "early_win_slot_hit",
            tid=tid,
            game=res.game_id_hex,
            total_credits=res.total_credits,
            win_cents=win_cents,
            win_dollars=round(win_cents / 100.0, 2),
            is_jackpot=res.is_jackpot,
            top_tier=res.top_tier,
            source=source,
        )
        if self.dry_run:
            self._emit("early_win_slot_fireworks_dry_run", tid=tid, win_cents=win_cents)
            return hit
        dedupe_s = ":".join(str(x) for x in key)
        ipc_payload = self._trigger_local_fireworks(hit, dedupe_key=dedupe_s, local_wire_tid=local)
        if ipc_payload is not None:
            self._emit(
                "early_win_slot_fireworks_ipc",
                tid=tid,
                win_cents=win_cents,
                duration_sec=ipc_payload.get("duration_sec"),
                is_winner=ipc_payload.get("is_winner"),
                seq=ipc_payload.get("seq"),
            )
        else:
            self._emit(
                "early_win_slot_fireworks_err",
                tid=tid,
                win_cents=win_cents,
                error="local IPC disabled",
            )
        return hit

    def _trigger_local_fireworks(
        self,
        hit: SlotHit,
        *,
        dedupe_key: str,
        local_wire_tid: str | None,
    ) -> dict[str, Any] | None:
        from fireworks_ipc import local_fireworks_ipc_enabled, write_fireworks_request

        if not local_fireworks_ipc_enabled():
            return None
        return write_fireworks_request(
            winner_tid=hit.tid_hex,
            progwin_cents=hit.win_cents,
            game_id_hex=hit.game_id_hex,
            dedupe_key=dedupe_key,
            local_wire_tid=local_wire_tid,
            # Local slot cash win — DyzineFrame plays a single loop with no jackpot
            # amount overlay (progressive celebration is reserved for Phase 1).
            is_progressive=False,
        )


def build_phase2_slot_trap_from_env(
    on_event: Callable[..., None] | None = None,
    *,
    default: str = "0",
) -> Phase2SlotTrap | None:
    if os.environ.get("FIXTURE_EARLY_WIN_PHASE2_SLOTS", default).strip().lower() not in (
        "1",
        "true",
        "yes",
    ):
        return None
    dedupe_ms = float(os.environ.get("FIXTURE_EARLY_WIN_DEDUPE_MS", str(DEFAULT_DEDUPE_MS)))
    dry_run = os.environ.get("FIXTURE_EARLY_WIN_DRY_RUN", "0") in ("1", "true", "yes")
    return Phase2SlotTrap(
        threshold_cents=resolve_slot_threshold_cents(),
        credit_cents=resolve_slot_credit_cents(),
        dedupe_ms=dedupe_ms,
        dry_run=dry_run,
        on_event=on_event,
    )


def _selftest() -> list[str]:
    errors: list[str] = []
    from decode_x9_result import parse_hex_line

    if resolve_slot_threshold_cents() != 400 and not os.environ.get(
        "FIXTURE_FIREWORKS_WIN_THRESHOLD_USD"
    ):
        errors.append("default threshold should be 400 cents ($4.00)")

    # 970C 0x89 wire layout: header 0-7, grid @ 21-29 (row-major), 36 B, SOH 0x05, EOP 0x0D.
    # play_seq @ 7 varies per case so dedupe keys differ.
    def _build_frame(grid: list[int], *, play_seq: int) -> bytes:
        frame = bytearray(36)
        frame[0] = 0x05
        frame[1] = SLOT_RESULT_MSG
        frame[3] = 0x0C  # tid low; game id 970C read from bytes 5-6
        frame[4] = 0x97
        frame[5] = 0x0C
        frame[6] = 0x97
        frame[7] = play_seq & 0xFF
        frame[17] = 0x40
        frame[21:30] = bytes(grid)
        frame[-1] = 0x0D
        return bytes(frame)

    # Payline 1 = middle row (idx 3,4,5). Symbols: CHERRY=0x00, LEMON=0x04, PLUM=0x05,
    # ORANGE=0x06, WATERMELON=0x07, APPLE=0x08, BELL=0x09, RED7=0x0A, TRIPLE7=0x0D.
    jackpot = _build_frame([0x04, 0x05, 0x06, 0x0D, 0x0D, 0x0D, 0x08, 0x07, 0x0A], play_seq=1)
    bell = _build_frame([0x04, 0x05, 0x06, 0x09, 0x09, 0x09, 0x08, 0x07, 0x0A], play_seq=2)
    cherry = _build_frame([0x04, 0x05, 0x06, 0x00, 0x00, 0x00, 0x08, 0x07, 0x0A], play_seq=3)
    tid = tid_hex_from_frame(jackpot)

    # Jackpot fires — but by DOLLAR VALUE (10000 cr x 25c = $2500), not a tier flag.
    events: list[str] = []
    trap = Phase2SlotTrap(dry_run=True, on_event=lambda ev, **kw: events.append(ev))
    hit = trap.inspect(jackpot, source="test", local_wire_tid=tid)
    if hit is None or "early_win_slot_hit" not in events:
        errors.append(f"jackpot value should fire; got {events}")
    if hit is not None and not hit.is_jackpot:
        errors.append("jackpot grid should still set is_jackpot metadata")
    # dedupe: same frame again should not re-hit
    events.clear()
    trap.inspect(jackpot, source="test", local_wire_tid=tid)
    if "early_win_slot_dedupe" not in events:
        errors.append(f"expected dedupe on repeat, got {events}")

    # 3 Bell = 100 cr x 25c = $25.00: fires at $4 threshold, NOT at $50 threshold.
    ev4: list[str] = []
    trap4 = Phase2SlotTrap(threshold_cents=400, dry_run=True, on_event=lambda ev, **kw: ev4.append(ev))
    if trap4.inspect(bell, source="test", local_wire_tid=tid) is None:
        errors.append("$25 win should fire at $4.00 threshold")
    ev50: list[str] = []
    trap50 = Phase2SlotTrap(threshold_cents=5000, dry_run=True, on_event=lambda ev, **kw: ev50.append(ev))
    if trap50.inspect(bell, source="test", local_wire_tid=tid) is not None:
        errors.append("$25 win should NOT fire at $50.00 threshold")
    if "early_win_slot_hit" in ev50:
        errors.append("no hit expected below threshold")

    # >= semantics: a win exactly equal to the threshold FIRES; one cent above does not.
    eveq: list[str] = []
    trapeq = Phase2SlotTrap(threshold_cents=2500, dry_run=True, on_event=lambda ev, **kw: eveq.append(ev))
    if trapeq.inspect(bell, source="test", local_wire_tid=tid) is None:
        errors.append("$25 win should fire when threshold == $25.00 (>=)")
    evov: list[str] = []
    trapov = Phase2SlotTrap(threshold_cents=2501, dry_run=True, on_event=lambda ev, **kw: evov.append(ev))
    if trapov.inspect(bell, source="test", local_wire_tid=tid) is not None:
        errors.append("$25 win should NOT fire when threshold is $25.01")

    # 3 Cherry = 8 cr x 25c = $2.00: below $4 threshold, no fire.
    evc: list[str] = []
    trapc = Phase2SlotTrap(threshold_cents=400, dry_run=True, on_event=lambda ev, **kw: evc.append(ev))
    if trapc.inspect(cherry, source="test", local_wire_tid=tid) is not None:
        errors.append("$2 win should NOT fire at $4.00 threshold")

    # Aggregate across lines: a lone corner cherry (idx 0) pays cherry-any-any on paylines
    # 2, 4, 6 (all start at idx 0) = 3 x 2 = 6 cr with bet_per_line=1, not a single line.
    corner = _build_frame([0x00, 0x05, 0x06, 0x04, 0x09, 0x0A, 0x08, 0x07, 0x0B], play_seq=4)
    from decode_x9_result import slot_eval_result_frame as _eval
    cres = _eval(corner)
    if cres is None or cres.total_base != 6:
        errors.append(f"corner cherry should aggregate 3 lines (base 6); got {cres and cres.total_base}")

    # TID filter: a qualifying win for a different local terminal is skipped.
    evt: list[str] = []
    trapt = Phase2SlotTrap(dry_run=True, on_event=lambda ev, **kw: evt.append(ev))
    trapt.inspect(jackpot, source="test", local_wire_tid="DEAD")
    if "early_win_slot_skip" not in evt:
        errors.append(f"expected skip for non-local terminal, got {evt}")
    return errors


if __name__ == "__main__":
    fails = _selftest()
    if fails:
        for msg in fails:
            print(msg, file=sys.stderr)
        sys.exit(1)
    print("early_win_phase2_slots selftest ok")
