#!/usr/bin/env python3
"""EtherGold → DyzineFrame current-game index (atomic JSON beside progressives.csv).

Maps POG422 ``game_id_hex`` to DyzineFrame ``curr_index`` (1..16).
Bridge filters local TID; listen publishes UART gameplay on the tap (optional
``ETHERGOLD_CURRENT_GAME_TID``). Publish when the mapped index changes — unless
a Heltec boot arm is active (``pogcc-boot-arm.json`` after POG-CC ``/hello``):
then the **next** mapped gameplay force-publishes even if the index is unchanged.

Modes via ``ETHERGOLD_CURRENT_GAME_IPC``:

  off / 0 / false / no   — no continuous publish; **boot-arm force still works**
  readonly / compare      — log would_publish only; no file write (except force→write)
  1 / on / write / write  — atomic write when game_index changes

SDS enter-game remains primary when live; this only gap-fills after Heltec flash
+ post-arm play.
"""

from __future__ import annotations

import json
import os
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

CURRENT_GAME_NAME = "current-game.json"
DF_DISPLAYED_GAME_NAME = "df-displayed-game.json"
POGCC_BOOT_ARM_NAME = "pogcc-boot-arm.json"
DEFAULT_ETHERGOLD_ROOT = "/home/topbox/EtherGold"

# Wire game_id_hex (upper) → DyzineFrame curr_index / VID_NAMES / progressives slot.
# Source: sessions/2026-06-19_1835-UTC_pog422-only-wire-len-census/EVAL.md
# (operator trunk walkthrough). Still missing capture for Wild Jokers / 8-Ball /
# Lightning Keno (inferred 9402 / 9405 / 930B — not stamped until played).
WIRE_GAME_TO_INDEX: dict[str, int] = {
    "9400": 1,   # Jacks or Better Poker
    "9401": 2,   # Joker Poker
    "9403": 4,   # Deuces Wild Poker
    "9404": 5,   # Shamrock 7's Poker
    "9206": 7,   # Super Gold Bingo
    "9107": 8,   # Touch 6 Lotto
    "9508": 9,   # SuperPick Lotto (floor; legacy lab 9507)
    "9507": 9,   # SuperPick legacy lab id
    "9309": 10,  # Toucheasy Keno
    "930A": 11,  # SuperBall Keno
    "970C": 13,  # Triple 7's
    "970D": 14,  # SpinBall Bonus
    "960E": 15,  # Black Gold 21
    "960F": 16,  # SpinJack 21
}


def resolve_ethergold_root() -> str:
    return os.environ.get("ETHERGOLD_ROOT", DEFAULT_ETHERGOLD_ROOT).strip() or DEFAULT_ETHERGOLD_ROOT


def resolve_current_game_path() -> str:
    explicit = os.environ.get("ETHERGOLD_CURRENT_GAME_PATH", "").strip()
    if explicit:
        return explicit
    return os.path.join(resolve_ethergold_root(), CURRENT_GAME_NAME)


def resolve_df_displayed_game_path() -> str:
    explicit = os.environ.get("ETHERGOLD_DF_DISPLAYED_GAME_PATH", "").strip()
    if explicit:
        return explicit
    return os.path.join(resolve_ethergold_root(), DF_DISPLAYED_GAME_NAME)


def write_df_displayed_game(
    game_index: int,
    path: str | None = None,
) -> dict[str, Any]:
    """HDMI game DF is showing (0 = POG logo / menu). Listen compares this on x0."""
    idx = int(game_index)
    if idx < 0 or idx > 16:
        raise ValueError("df displayed game_index must be 0..16")
    target = path or resolve_df_displayed_game_path()
    parent = os.path.dirname(target) or "."
    os.makedirs(parent, exist_ok=True)
    payload: dict[str, Any] = {
        "game_index": idx,
        "updated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "source": "dyzineframe",
    }
    fd, tmp_path = tempfile.mkstemp(
        dir=parent,
        prefix=".df-displayed-game.",
        suffix=".tmp",
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(payload, handle, separators=(",", ":"))
            handle.write("\n")
        os.replace(tmp_path, target)
        try:
            os.chmod(target, 0o644)
        except OSError:
            pass
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    return payload


def read_df_displayed_index_this_boot(
    path: str | None = None,
    boot_epoch: float | None = None,
) -> int | None:
    """DF HDMI game this OS boot. None = missing, pre-boot, or junk. 0 = logo."""
    target = path or resolve_df_displayed_game_path()
    boot = os_boot_epoch() if boot_epoch is None else boot_epoch
    try:
        mtime = os.path.getmtime(target)
    except OSError:
        return None
    if boot is not None and mtime < (boot - 2.0):
        return None
    try:
        with open(target, encoding="utf-8") as handle:
            payload = json.load(handle)
    except (OSError, ValueError, json.JSONDecodeError):
        return None
    if not isinstance(payload, dict):
        return None
    try:
        idx = int(payload.get("game_index"))
    except (TypeError, ValueError):
        return None
    if idx < 0 or idx > 16:
        return None
    return idx


PREBOOT_PROGRESSIVE_RUNTIME_NAMES = (
    CURRENT_GAME_NAME,
    "progressives.csv",
    "progressives-sync.json",
)


def os_boot_epoch() -> float | None:
    """Wall-clock time of this OS boot, or None when it cannot be determined."""
    try:
        return time.time() - time.clock_gettime(time.CLOCK_BOOTTIME)
    except (AttributeError, OSError):
        pass
    try:
        with open("/proc/uptime", encoding="ascii") as handle:
            uptime = float(handle.read().split()[0])
        return time.time() - uptime
    except (OSError, ValueError, IndexError):
        return None


def clear_preboot_progressive_runtime(root: str | None = None) -> list[str]:
    """Unlink jackpot CSV / current-game.json written before this OS boot.

    Never show last-boot SuperBall (or any) meter after power-up. Floor-games.json
    stays (enabled-game list, not dollar amounts). Windows is a no-op.
    """
    boot = os_boot_epoch()
    if boot is None:
        return []
    base = root or resolve_ethergold_root()
    cutoff = boot - 2.0
    removed: list[str] = []
    for name in PREBOOT_PROGRESSIVE_RUNTIME_NAMES:
        path = os.path.join(base, name)
        try:
            mtime = os.path.getmtime(path)
        except OSError:
            continue
        if mtime >= cutoff:
            continue
        try:
            os.unlink(path)
        except OSError as exc:
            print(f"WARN: could not unlink stale {path}: {exc}", flush=True)
            continue
        removed.append(path)
        print(f"cleared pre-boot progressive runtime: {path}", flush=True)
    return removed


def resolve_pogcc_boot_arm_path() -> str:
    explicit = os.environ.get("ETHERGOLD_POGCC_BOOT_ARM_PATH", "").strip()
    if explicit:
        return explicit
    return os.path.join(resolve_ethergold_root(), POGCC_BOOT_ARM_NAME)


POGCC_TELEMETRY_REL = os.path.join("status", "pogcc-telemetry.json")


def resolve_pogcc_telemetry_path() -> str:
    explicit = os.environ.get("ETHERGOLD_POGCC_TELEMETRY_PATH", "").strip()
    if explicit:
        return explicit
    status_dir = os.environ.get("ETHERGOLD_STATUS_DIR", "").strip()
    if status_dir:
        return os.path.join(status_dir, "pogcc-telemetry.json")
    return os.path.join(resolve_ethergold_root(), POGCC_TELEMETRY_REL)


def write_pogcc_boot_arm(
    *,
    firmware_version: str,
    heltec_local_ms: int | None = None,
    heltec_utc_ms: int | None = None,
    path: str | None = None,
) -> str:
    """Atomic arm file: force-publish current game on next local play after Heltec USB-up."""
    target = path or resolve_pogcc_boot_arm_path()
    parent = os.path.dirname(target) or "."
    os.makedirs(parent, exist_ok=True)
    payload: dict[str, Any] = {
        "armed": True,
        "reason": "pogcc_usb_up",
        "firmware_version": (firmware_version or "").strip() or "unknown",
        "armed_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    if heltec_local_ms is not None:
        payload["heltec_local_ms"] = int(heltec_local_ms)
    if heltec_utc_ms is not None and int(heltec_utc_ms) > 0:
        payload["heltec_utc_ms"] = int(heltec_utc_ms)
    fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".pogcc-boot-arm.", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(payload, handle, separators=(",", ":"))
            handle.write("\n")
        os.replace(tmp_path, target)
        try:
            os.chmod(target, 0o644)
        except OSError:
            pass
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    return target


def write_pogcc_telemetry(payload: dict[str, Any], path: str | None = None) -> str:
    """Atomic write of Heltec /telemetry for machine-status merge."""
    target = path or resolve_pogcc_telemetry_path()
    parent = os.path.dirname(target) or "."
    os.makedirs(parent, exist_ok=True)
    try:
        os.chmod(parent, 0o775)
    except OSError:
        pass
    body = dict(payload)
    body.setdefault("schema", "pogcc-telemetry/1")
    body.setdefault(
        "updated_utc",
        datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    )
    fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".pogcc-telemetry.", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(body, handle, indent=2)
            handle.write("\n")
        os.replace(tmp_path, target)
        try:
            os.chmod(target, 0o664)
        except OSError:
            pass
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    try:
        from machine_status_ipc import publish_machine_status

        publish_machine_status(force=True)
    except Exception:
        pass
    return target


def current_game_ipc_mode() -> str:
    """Return ``off``, ``readonly``, or ``write``."""
    raw = os.environ.get("ETHERGOLD_CURRENT_GAME_IPC", "0").strip().lower()
    if raw in ("", "0", "false", "no", "off", "disabled"):
        return "off"
    if raw in ("readonly", "compare", "dry", "dry-run", "dry_run"):
        return "readonly"
    if raw in ("1", "on", "true", "yes", "write", "publish"):
        return "write"
    return "off"


def map_wire_game_to_index(game_id_hex: str | None) -> int | None:
    if not game_id_hex:
        return None
    return WIRE_GAME_TO_INDEX.get(game_id_hex.strip().upper())


def read_current_game(path: str | None = None) -> dict[str, Any] | None:
    target = path or resolve_current_game_path()
    if not os.path.isfile(target):
        return None
    try:
        with open(target, encoding="utf-8") as handle:
            payload = json.load(handle)
        if isinstance(payload, dict):
            return payload
    except (OSError, ValueError, json.JSONDecodeError):
        pass
    return None


def read_pogcc_boot_arm(path: str | None = None) -> dict[str, Any] | None:
    target = path or resolve_pogcc_boot_arm_path()
    if not os.path.isfile(target):
        return None
    try:
        with open(target, encoding="utf-8") as handle:
            payload = json.load(handle)
        if isinstance(payload, dict) and payload.get("armed"):
            return payload
    except (OSError, ValueError, json.JSONDecodeError):
        pass
    return None


def clear_pogcc_boot_arm(path: str | None = None) -> None:
    target = path or resolve_pogcc_boot_arm_path()
    try:
        os.unlink(target)
    except OSError:
        pass


def _payload_index(payload: dict[str, Any] | None) -> int | None:
    if not payload:
        return None
    raw = payload.get("game_index")
    try:
        idx = int(raw)
    except (TypeError, ValueError):
        return None
    if idx < 1 or idx > 16:
        return None
    return idx


def _chown_like_progressives(path: str) -> None:
    try:
        os.chmod(path, 0o644)
        real_user = os.environ.get("ETHERGOLD_REAL_USER", "topbox")
        import pwd

        pw = pwd.getpwnam(real_user)
        os.chown(path, pw.pw_uid, pw.pw_gid)
    except (OSError, KeyError, ImportError):
        try:
            os.chmod(path, 0o644)
        except OSError:
            pass


def write_current_game(
    *,
    game_index: int,
    game_id_hex: str,
    tid_hex: str | None = None,
    source: str = "local_tid_gameplay",
    path: str | None = None,
) -> dict[str, Any]:
    """Atomically publish current-game.json (caller already decided it changed)."""
    target = path or resolve_current_game_path()
    parent = os.path.dirname(target) or "."
    os.makedirs(parent, exist_ok=True)
    payload: dict[str, Any] = {
        "game_index": int(game_index),
        "game_id_hex": game_id_hex.strip().upper(),
        "updated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "source": source,
    }
    try:
        from machine_status_ipc import game_name_for_index

        gname = game_name_for_index(int(game_index))
        if gname:
            payload["game_name"] = gname
    except Exception:
        pass
    if tid_hex:
        payload["tid_hex"] = tid_hex.strip().upper()
    fd, tmp_path = tempfile.mkstemp(
        dir=parent,
        prefix=".current-game.",
        suffix=".tmp",
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(payload, handle, separators=(",", ":"))
            handle.write("\n")
        os.replace(tmp_path, target)
        _chown_like_progressives(target)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    return payload


@dataclass
class CurrentGamePublishResult:
    action: str  # off | skip_unmapped | skip_unchanged | would_publish | published | error | armed
    game_index: int | None = None
    game_id_hex: str | None = None
    tid_hex: str | None = None
    mode: str = "off"
    payload: dict[str, Any] | None = None
    detail: str | None = None
    force: bool = False


def maybe_arm_listen_df_mismatch(
    publisher: CurrentGamePublisher,
    game_id_hex: str | None,
) -> bool:
    """On x0, force-publish if HDMI is not this copper game. Returns True if armed."""
    mapped = map_wire_game_to_index(game_id_hex)
    if mapped is None:
        return False
    shown = read_df_displayed_index_this_boot()
    if shown == mapped:
        return False
    publisher.arm_force_next("df_mismatch")
    return True


class CurrentGamePublisher:
    """Compare-then-write helper; keeps last published index in memory."""

    def __init__(self, path: str | None = None, arm_path: str | None = None):
        self._path = path or resolve_current_game_path()
        self._arm_path = arm_path or resolve_pogcc_boot_arm_path()
        existing = read_current_game(self._path)
        self._last_index = _payload_index(existing)
        self._last_hex: str | None = None
        if existing and isinstance(existing.get("game_id_hex"), str):
            self._last_hex = existing["game_id_hex"].strip().upper()
        self._force_next = False
        self._force_reason: str | None = None
        self._pull_arm_file()

    def _pull_arm_file(self) -> bool:
        """Load Heltec boot arm if present. Returns True if newly armed."""
        arm = read_pogcc_boot_arm(self._arm_path)
        if not arm:
            return False
        if not self._force_next:
            self._force_next = True
            self._force_reason = str(arm.get("reason") or "pogcc_boot_arm")
            return True
        return False

    def arm_force_next(self, reason: str = "manual") -> None:
        """Arm force-publish on next mapped local gameplay (tests / explicit)."""
        self._force_next = True
        self._force_reason = reason

    def observe(
        self,
        *,
        game_id_hex: str | None,
        tid_hex: str | None = None,
        source: str = "local_tid_gameplay",
    ) -> CurrentGamePublishResult:
        self._pull_arm_file()
        mode = current_game_ipc_mode()
        force = self._force_next

        # Continuous publish off — only force-after-arm still writes.
        if mode == "off" and not force:
            return CurrentGamePublishResult(action="off", mode=mode)

        gid = (game_id_hex or "").strip().upper()
        index = map_wire_game_to_index(gid)
        if index is None:
            return CurrentGamePublishResult(
                action="skip_unmapped",
                game_id_hex=gid or None,
                tid_hex=(tid_hex or "").strip().upper() or None,
                mode=mode,
                detail="no WIRE_GAME_TO_INDEX entry",
                force=force,
            )

        if not force:
            if self._last_index == index and (self._last_hex is None or self._last_hex == gid):
                return CurrentGamePublishResult(
                    action="skip_unchanged",
                    game_index=index,
                    game_id_hex=gid,
                    tid_hex=(tid_hex or "").strip().upper() or None,
                    mode=mode,
                )

            # Also skip if file already matches (another process / restart seed).
            on_disk = _payload_index(read_current_game(self._path))
            if on_disk == index:
                self._last_index = index
                self._last_hex = gid
                return CurrentGamePublishResult(
                    action="skip_unchanged",
                    game_index=index,
                    game_id_hex=gid,
                    tid_hex=(tid_hex or "").strip().upper() or None,
                    mode=mode,
                    detail="file already matches",
                )

        pub_source = source
        if force:
            reason = self._force_reason or "pogcc_boot_arm"
            pub_source = f"{source}_force_{reason}"

        draft = {
            "game_index": index,
            "game_id_hex": gid,
            "tid_hex": (tid_hex or "").strip().upper() or None,
            "source": pub_source,
        }

        # Force-after-arm always writes so DF inotify re-fires even if index same.
        # Readonly continuous mode: force still writes (gap-fill must reach DF).
        if mode == "readonly" and not force:
            self._last_index = index
            self._last_hex = gid
            return CurrentGamePublishResult(
                action="would_publish",
                game_index=index,
                game_id_hex=gid,
                tid_hex=draft["tid_hex"],
                mode=mode,
                payload=draft,
                force=force,
            )

        try:
            payload = write_current_game(
                game_index=index,
                game_id_hex=gid,
                tid_hex=tid_hex,
                source=pub_source,
                path=self._path,
            )
        except OSError as exc:
            return CurrentGamePublishResult(
                action="error",
                game_index=index,
                game_id_hex=gid,
                tid_hex=draft["tid_hex"],
                mode=mode,
                detail=str(exc),
                force=force,
            )

        self._last_index = index
        self._last_hex = gid
        if force:
            self._force_next = False
            self._force_reason = None
            clear_pogcc_boot_arm(self._arm_path)

        return CurrentGamePublishResult(
            action="published",
            game_index=index,
            game_id_hex=gid,
            tid_hex=draft["tid_hex"],
            mode=mode,
            payload=payload,
            force=force,
        )


def _selftest() -> None:
    assert map_wire_game_to_index("970C") == 13
    assert map_wire_game_to_index("960f") == 16
    assert map_wire_game_to_index("9400") == 1
    assert map_wire_game_to_index("930A") == 11
    assert map_wire_game_to_index("960E") == 15
    assert map_wire_game_to_index("DEAD") is None
    # Gaps from Jun 19 census (never played that day)
    assert map_wire_game_to_index("9402") is None
    assert map_wire_game_to_index("9405") is None
    assert map_wire_game_to_index("930B") is None
    print("current_game_ipc selftest ok")


if __name__ == "__main__":
    _selftest()
