#!/usr/bin/env python3
"""Cabinet identity for production EtherGold.

GAMESTATS Terminal ID + Master/Slave, plus operator BANK_POSITION.
This is not GA fixture FIXTURE_POS (seats 1–5 / witnesses 51–99).
"""
from __future__ import annotations

import os
import re
from configparser import ConfigParser
from pathlib import Path
from typing import Any, Optional

VALID_ROLES = frozenset({"M", "S"})
DEFAULT_DF_CONFIG = "/home/topbox/DyzineFrame/config.ini"
# Heltec / cabinet assets are 5xxxx. GAMESTATS Terminal ID is a small bank term.
HELTEC_ASSET_TERM_MIN = 50000
HELTEC_ASSET_TERM_MAX = 59999
# DF machine_identity.py writes these names; HTTP must read either case.
_IDENTITY_KEYS = (
    ("MACHINE_ROLE", "machine_role"),
    ("BANK_NUMBER", "bank_number"),
    ("TERM_NUMBER", "term_number"),
    ("BANK_POSITION", "bank_position"),
    ("POG_TID", "pog_tid"),
    ("MACHINE_NUMBER", "machine_number"),
)
_TID_HEX = re.compile(r"^[0-9A-Fa-f]{4}$")


def df_config_path() -> Path:
    raw = (os.environ.get("DYZINEFRAME_CONFIG") or DEFAULT_DF_CONFIG).strip()
    return Path(raw)


def hostname_for_asset(asset_id: str) -> str:
    """Production hostname: topbox_<asset>. Linux user stays topbox."""
    token = re.sub(r"[^A-Za-z0-9-]+", "-", (asset_id or "").strip()).strip("-").lower()
    if not token:
        return "topbox"
    return f"topbox_{token}"[:63]


def normalize_tid_hex(value: str | None) -> str:
    token = (value or "").strip().upper().replace(" ", "")
    return token if _TID_HEX.match(token) else ""


def term_to_tid_hex(term_number: int) -> str:
    n = max(0, int(term_number))
    return f"{n:04X}"[-4:]


def looks_like_heltec_asset(term_number: int) -> bool:
    """True when TERM_NUMBER looks like a Heltec asset, not a T340+ GAMESTATS term."""
    return HELTEC_ASSET_TERM_MIN <= int(term_number) <= HELTEC_ASSET_TERM_MAX


def _read_ini(path: Path) -> ConfigParser:
    parser = ConfigParser(strict=False)
    parser.optionxform = str.lower  # type: ignore[assignment]
    if path.is_file():
        parser.read(path, encoding="utf-8")
    if not parser.has_section("TOPBOX"):
        parser.add_section("TOPBOX")
    return parser


def _read_ini_writeable(path: Path) -> ConfigParser:
    """Preserve key case so DF ExtendedConfigParser still sees TERM_NUMBER."""
    parser = ConfigParser(strict=False)
    parser.optionxform = str  # type: ignore[assignment]
    if path.is_file():
        parser.read(path, encoding="utf-8")
    if not parser.has_section("TOPBOX"):
        parser.add_section("TOPBOX")
    for upper, lower in _IDENTITY_KEYS:
        lower_val = None
        if parser.has_option("TOPBOX", lower):
            lower_val = parser.get("TOPBOX", lower)
            parser.remove_option("TOPBOX", lower)
        if not parser.has_option("TOPBOX", upper) and lower_val is not None:
            parser.set("TOPBOX", upper, lower_val)
    return parser


def _atomic_write_ini(path: Path, parser: ConfigParser) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    with tmp.open("w", encoding="utf-8") as handle:
        parser.write(handle)
    tmp.replace(path)


def load_cabinet_identity(config_path: Path | str | None = None) -> dict[str, Any]:
    path = Path(config_path) if config_path else df_config_path()
    parser = _read_ini(path)
    role = (parser.get("TOPBOX", "MACHINE_ROLE", fallback="") or "").strip().upper()
    if role not in VALID_ROLES:
        role = ""
    try:
        bank = parser.getint("TOPBOX", "BANK_NUMBER", fallback=0)
    except ValueError:
        bank = 0
    try:
        term = parser.getint("TOPBOX", "TERM_NUMBER", fallback=0)
    except ValueError:
        term = 0
    try:
        pos = parser.getint("TOPBOX", "BANK_POSITION", fallback=0)
    except ValueError:
        pos = 0
    host = (parser.get("TOPBOX", "TOPBOX_HOST", fallback="") or "").strip()
    tid_hex = normalize_tid_hex(parser.get("TOPBOX", "POG_TID", fallback=""))
    try:
        machine_number = parser.getint("TOPBOX", "MACHINE_NUMBER", fallback=0)
    except ValueError:
        machine_number = 0
    if machine_number <= 0:
        machine_number = max(0, term)
    return {
        "machine_role": role,
        "bank_number": max(0, bank),
        "term_number": max(0, term),
        "machine_number": max(0, machine_number),
        "bank_position": max(0, pos),
        "topbox_host": host,
        "config_path": str(path),
        "tid_hex": tid_hex,
        "pog_tid": tid_hex,
        "mode_label": "Master" if role == "M" else ("Slave" if role == "S" else ""),
    }


def persist_gamestats_identity(
    config_path: Path | str | None = None,
    *,
    machine_role: Optional[str] = None,
    bank_number: Optional[int] = None,
    term_number: Optional[int] = None,
    machine_number: Optional[int] = None,
    pog_tid: Optional[str] = None,
    tid_hex: Optional[str] = None,
) -> bool:
    path = Path(config_path) if config_path else df_config_path()
    parser = _read_ini_writeable(path)
    current = load_cabinet_identity(path)
    changed = False
    role = (machine_role or "").strip().upper()
    if role in VALID_ROLES and role != current["machine_role"]:
        parser.set("TOPBOX", "MACHINE_ROLE", role)
        changed = True
    if bank_number is not None and int(bank_number) != current["bank_number"]:
        parser.set("TOPBOX", "BANK_NUMBER", str(int(bank_number)))
        changed = True
    if term_number is not None and int(term_number) != current["term_number"]:
        parser.set("TOPBOX", "TERM_NUMBER", str(int(term_number)))
        changed = True
    number = machine_number if machine_number is not None else None
    if number is not None and int(number) != int(current.get("machine_number") or 0):
        parser.set("TOPBOX", "MACHINE_NUMBER", str(int(number)))
        changed = True
    tid = normalize_tid_hex(pog_tid if pog_tid is not None else tid_hex)
    if tid and tid != (current.get("tid_hex") or ""):
        parser.set("TOPBOX", "POG_TID", tid)
        changed = True
    mixed = False
    if path.is_file():
        raw = path.read_text(encoding="utf-8")
        mixed = any(lower in raw for _upper, lower in _IDENTITY_KEYS)
    if not changed and not mixed:
        return False
    _atomic_write_ini(path, parser)
    return True


def persist_bank_position(
    position: int,
    config_path: Path | str | None = None,
) -> bool:
    path = Path(config_path) if config_path else df_config_path()
    parser = _read_ini_writeable(path)
    current = load_cabinet_identity(path)
    pos = max(0, int(position))
    if pos == current["bank_position"]:
        return False
    parser.set("TOPBOX", "BANK_POSITION", str(pos))
    _atomic_write_ini(path, parser)
    return True


def apply_listen_tid_filter() -> str:
    """If GAMESTATS POG TID is known, filter listen current-game to that 4-hex TID.

    Listen taps the whole daisy; without this it cannot tell which T340+ it sits on.
    Existing ETHERGOLD_CURRENT_GAME_TID wins if already set.
    """
    existing = os.environ.get("ETHERGOLD_CURRENT_GAME_TID", "").strip()
    if existing:
        return existing.upper()
    ident = load_cabinet_identity()
    hex_tid = normalize_tid_hex(str(ident.get("tid_hex") or ident.get("pog_tid") or ""))
    if not hex_tid:
        return ""
    os.environ["ETHERGOLD_CURRENT_GAME_TID"] = hex_tid
    return hex_tid


apply_listen_tid_filter = apply_listen_tid_filter
