#!/usr/bin/env python3
"""Parse P3 GAMESTATS CSV (DyzineFrame P3_update_credit_balance wire format).

Timing: GAMESTATS blocks the T340+ ~600 ms (BBU reads + CSV format). Only pull in
idle windows — never during on-screen animation.

Floor list policy: parse once per refresh; enabled/progressive floor games rarely change.
"""
from __future__ import annotations

import csv
import re
from dataclasses import dataclass
from typing import List, Optional, Sequence, Tuple

GAMESTATS_CMD = bytes([0x47, 0x41, 0x4D, 0x45, 0x53, 0x54, 0x41, 0x54, 0x53, 0x0A, 0x0D])
READ_UNTIL = bytes([0x0A, 0x0D, 0x45, 0x4E, 0x44, 0x0A, 0x0D, 0x0A, 0x0D])

GAME_SLOTS = 16
BLACK_GOLD_21_GAME_ID = 15
MAX_FLOOR_PROGRESSIVE_GAMES = 12
MAX_FLOOR_PROGRESSIVE_GAMES_WITH_BG21 = 11

COL_NAME = 0
COL_ENABLED = 1
COL_PROGRESSIVE = 8


@dataclass(frozen=True)
class GameStatsRow:
    game_id: int
    slot_index: int
    name: str
    enabled: bool
    progressive: bool
    raw: Tuple[str, ...]


_TID_HEX = re.compile(r"^[0-9A-Fa-f]{4}$")


@dataclass(frozen=True)
class GameStatsParse:
    rows: Tuple[GameStatsRow, ...]
    bank_number: Optional[int]
    term_number: Optional[int]
    machine_number: Optional[int]
    tid_hex: Optional[str]
    modm_cntr: Optional[int]
    machine_role: str
    credit_value_cents: Optional[int]
    credit_balance: Optional[int]
    credit_row_index: Optional[int]
    dyzineframe_credit: Optional[int]
    progressive_floor_games: Tuple[int, ...]
    enabled_games: Tuple[int, ...]
    black_gold_21_enabled: bool
    raw_line_count: int


def normalize_gamestats_bytes(raw: bytes) -> str:
    """Strip LF (0x0A) before ASCII decode; split rows on CR (DyzineFrame convention)."""
    return raw.replace(b"\x0A", b"").decode("ascii", errors="replace")


def credit_from_parse(parsed: GameStatsParse) -> Optional[int]:
    """Balance in hundredths of a dollar (matches legacy data[4][2] / getcredit)."""
    if parsed.dyzineframe_credit is not None:
        return parsed.dyzineframe_credit
    return parsed.credit_balance


def _gamestats_lines(text: str) -> List[str]:
    repaired = re.sub(r"(\d)(END)(?=\"|\s|$)", r"\1\n\2", text, count=1)
    lines: List[str] = []
    for line in repaired.splitlines():
        line = line.strip("\r")
        if line:
            lines.append(line)
    return lines


def _row_has_menu_gamestats(row: Sequence[str]) -> bool:
    return "MENU GAMESTATS" in ",".join(row).upper()


def _header_row_indices(rows: Sequence[Sequence[str]]) -> Tuple[int, int, int]:
    menu_offset = 1 if rows and _row_has_menu_gamestats(rows[0]) else 0
    config_row = menu_offset + 2
    credit_row = menu_offset + 3
    return menu_offset, config_row, credit_row


def _tid_hex_at(rows: Sequence[Sequence[str]], row_idx: int) -> Optional[str]:
    """Last 4-hex cell on the GAMESTATS id row (live T340+: BD07). Not machine number."""
    if len(rows) <= row_idx:
        return None
    found: List[str] = []
    for cell in rows[row_idx]:
        token = str(cell).strip().strip('"').upper()
        if _TID_HEX.match(token):
            found.append(token)
    return found[-1] if found else None


def _machine_role_at(rows: Sequence[Sequence[str]], row_idx: int) -> str:
    if len(rows) <= row_idx or len(rows[row_idx]) <= 4:
        return ""
    role = rows[row_idx][4].strip().upper()
    return role if role in ("M", "S") else ""


def _int_at(rows: Sequence[Sequence[str]], row_idx: int, col_idx: int) -> Optional[int]:
    if len(rows) <= row_idx or len(rows[row_idx]) <= col_idx:
        return None
    try:
        return int(rows[row_idx][col_idx].strip())
    except ValueError:
        return None


def _is_enabled_cell(value: str) -> Optional[bool]:
    text = value.strip().upper()
    if text == "ON" or text.endswith("ON"):
        return True
    if text == "OFF" or text.endswith("OFF"):
        return False
    return None


def _looks_like_game_row(row: Sequence[str]) -> bool:
    if len(row) < COL_PROGRESSIVE + 1:
        return False
    if _is_enabled_cell(row[COL_ENABLED]) is None:
        return False
    name = row[COL_NAME].strip().strip('"').upper()
    if name.startswith("DOUBLE-UPS"):
        return False
    return bool(name)


def parse_gamestats_csv(text: str) -> GameStatsParse:
    rows = list(csv.reader(_gamestats_lines(text)))
    menu_offset, config_row_index, credit_row_index = _header_row_indices(rows)
    bank_number = _int_at(rows, config_row_index, 0)
    machine_number = _int_at(rows, config_row_index, 1)
    term_number = machine_number  # config col 1 is machine number, not POG TID
    tid_hex = _tid_hex_at(rows, menu_offset + 1)
    modm_cntr = _int_at(rows, config_row_index, 3)
    machine_role = _machine_role_at(rows, config_row_index)
    credit_value_cents = _int_at(rows, config_row_index, 2)
    credit_balance = _int_at(rows, credit_row_index, 2)
    if credit_balance is None:
        for i, row in enumerate(rows):
            if len(row) > 2 and ":" in row[0] and "," in row[1]:
                credit_balance = _int_at(rows, i, 2)
                if credit_balance is not None:
                    credit_row_index = i
                    break

    dyzineframe_credit = _int_at(rows, 4, 2)

    raw_game_rows = [row for row in rows if _looks_like_game_row(row)]
    if len(raw_game_rows) != GAME_SLOTS:
        raise ValueError(
            f"incomplete GAMESTATS: expected {GAME_SLOTS} game rows, "
            f"got {len(raw_game_rows)}"
        )

    game_rows: List[GameStatsRow] = []
    for slot_index, row in enumerate(raw_game_rows):
        enabled_flag = _is_enabled_cell(row[COL_ENABLED])
        enabled = bool(enabled_flag)
        progressive = row[COL_PROGRESSIVE].strip().upper() == "Y"
        game_id = slot_index + 1
        game_rows.append(
            GameStatsRow(
                game_id=game_id,
                slot_index=slot_index,
                name=row[COL_NAME].strip().strip('"'),
                enabled=enabled,
                progressive=progressive,
                raw=tuple(row),
            )
        )

    enabled_games = tuple(g.game_id for g in game_rows if g.enabled)
    black_gold_21_enabled = any(
        g.game_id == BLACK_GOLD_21_GAME_ID and g.enabled for g in game_rows
    )
    max_progressive = (
        MAX_FLOOR_PROGRESSIVE_GAMES_WITH_BG21
        if black_gold_21_enabled
        else MAX_FLOOR_PROGRESSIVE_GAMES
    )
    progressive_floor_games = tuple(
        g.game_id
        for g in game_rows
        if g.enabled and g.progressive and g.game_id != BLACK_GOLD_21_GAME_ID
    )[:max_progressive]

    return GameStatsParse(
        rows=tuple(game_rows),
        bank_number=bank_number,
        term_number=term_number,
        machine_number=machine_number,
        tid_hex=tid_hex,
        modm_cntr=modm_cntr,
        machine_role=machine_role,
        credit_value_cents=credit_value_cents,
        credit_balance=credit_balance,
        credit_row_index=credit_row_index,
        dyzineframe_credit=dyzineframe_credit,
        progressive_floor_games=progressive_floor_games,
        enabled_games=enabled_games,
        black_gold_21_enabled=black_gold_21_enabled,
        raw_line_count=len(rows),
    )


GAME_SLOTS = GAME_SLOTS
parse_gamestats_csv = parse_gamestats_csv
parse_gamestats_csv = parse_gamestats_csv
GAME_SLOTS = GAME_SLOTS
