#!/usr/bin/env python3
"""EtherGold owns Heltec USB CDC + P3 GAMESTATS for POG-CC COM.

Replaces DyzineFrame thread_usb_serial when config pogcc_com_owner=ethergold.
Heltec /sds /hello /telemetry /flag /credits /heartbeat plus Identity
downlink /asset /theme /volume from config.ini (Apply works with DF down).
"""

from __future__ import annotations

import configparser
import json
import os
import queue
import sys
import tempfile
import threading
import time
import urllib.request
from pathlib import Path
from typing import Any, Optional

try:
    import fcntl
except ImportError:  # Windows unit tests
    fcntl = None  # type: ignore[assignment]

import serial

_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
    sys.path.insert(0, _HERE)

from current_game_ipc import (  # noqa: E402
    read_current_game,
    write_current_game,
    write_pogcc_boot_arm,
    write_pogcc_telemetry,
)
from p3_gamestats import (  # noqa: E402
    GAMESTATS_CMD,
    READ_UNTIL,
    credit_from_parse,
    normalize_gamestats_bytes,
    parse_gamestats_csv,
)
from pogcc_com_ports import resolve_heltec_cdc_port, resolve_p3_port  # noqa: E402

STATUS_PATH = os.environ.get(
    "ETHERGOLD_POGCC_COM_STATUS", "/run/ethergold/pogcc-com.status"
)
DF_BASE = os.environ.get("DYZINEFRAME_HTTP", "http://127.0.0.1:31235").rstrip("/")
P3_MIN_INTERVAL_SEC = 2.0

SERVICE_BUILD = "4.0.0.37"
DF_CONFIG_DEFAULT = "/home/topbox/DyzineFrame/config.ini"
POGCC_COM_LOG_MAX_BYTES = 1 * 1024 * 1024
POGCC_COM_LOG_BACKUPS = 2


def default_pogcc_com_log_path() -> str:
    """Capture tree so sync_to_dropbox.sh (SYNC_SOURCE=dev) uploads it."""
    return os.environ.get(
        "ETHERGOLD_POGCC_COM_LOG",
        os.path.expanduser("~/EtherGold/dev/logs/pogcc-com.log"),
    )


def is_pogcc_com_log_name(name: str) -> bool:
    return name == "pogcc-com.log" or name.startswith("pogcc-com.log.")


_log_fp = None
_log_path: Optional[str] = None


def _open_pogcc_com_log(path: str):
    global _log_fp, _log_path
    parent = os.path.dirname(path) or "."
    os.makedirs(parent, exist_ok=True)
    _log_fp = open(path, "a", encoding="utf-8", buffering=1)
    _log_path = path


def _rotate_pogcc_com_log(path: str) -> None:
    global _log_fp
    try:
        if os.path.getsize(path) < POGCC_COM_LOG_MAX_BYTES:
            return
    except OSError:
        return
    if _log_fp is not None:
        try:
            _log_fp.close()
        except OSError:
            pass
        _log_fp = None
    for idx in range(POGCC_COM_LOG_BACKUPS, 0, -1):
        src = path if idx == 1 else f"{path}.{idx - 1}"
        dst = f"{path}.{idx}"
        try:
            if os.path.isfile(src):
                if os.path.isfile(dst):
                    os.remove(dst)
                os.replace(src, dst)
        except OSError:
            pass


def log(msg: str) -> None:
    """Journal + ~/EtherGold/dev/logs/pogcc-com.log (Dropbox capture). Flush every line."""
    text = msg if msg.startswith("pogcc-com:") else f"pogcc-com: {msg}"
    print(text, flush=True)
    path = default_pogcc_com_log_path()
    try:
        _rotate_pogcc_com_log(path)
        global _log_fp, _log_path
        if _log_fp is None or _log_path != path:
            if _log_fp is not None:
                try:
                    _log_fp.close()
                except OSError:
                    pass
                _log_fp = None
            _open_pogcc_com_log(path)
        stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
        assert _log_fp is not None
        _log_fp.write(f"{stamp} {text}\n")
        _log_fp.flush()
    except OSError:
        pass

# Never usbreset 303a:0002 (DTR drop looks like port closed). JTAG only:
# sudo -n usbreset 303a:1001.


class HeltecCdc:
    """Non-blocking USB ACM. Do not use pyserial on this gadget.

    pyserial write_timeout still compiled to pselect(..., NULL) here and
    never returned; Heltec then bailed on heartbeat after theme applied.
    """

    def __init__(self, fd: int, port: str) -> None:
        self.fd = fd
        self.port = port
        self.is_open = True
        self._rx = bytearray()

    @classmethod
    def open_port(cls, port: str) -> "HeltecCdc":
        fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
        if fcntl is not None:
            flags = fcntl.fcntl(fd, fcntl.F_GETFL)
            fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
        return cls(fd, port)

    def close(self) -> None:
        self.is_open = False
        fd, self.fd = self.fd, -1
        if fd >= 0:
            try:
                os.close(fd)
            except OSError:
                pass

    def fileno(self) -> int:
        return self.fd

    def write(self, data: bytes) -> int:
        if self.fd < 0:
            raise OSError("CDC closed")
        sent = 0
        deadline = time.monotonic() + 0.5
        while sent < len(data):
            if time.monotonic() > deadline:
                raise TimeoutError("CDC write timeout")
            try:
                sent += os.write(self.fd, data[sent:])
            except BlockingIOError:
                time.sleep(0.01)
        return sent

    def readline(self) -> bytes:
        if self.fd < 0:
            return b""
        try:
            chunk = os.read(self.fd, 1024)
            if chunk:
                self._rx.extend(chunk)
        except BlockingIOError:
            pass
        nl = self._rx.find(b"\n")
        if nl < 0:
            return b""
        line = bytes(self._rx[: nl + 1])
        del self._rx[: nl + 1]
        return line


def identity_downlink_from_ini(path: str | Path | None = None) -> tuple[int, int, int]:
    """Read Identity asset/theme/volume from DyzineFrame config.ini.

    Apply stamps these keys; pogcc-com USB-pushes them so Heltec attract
    follows /config even when DyzineFrame is not running.
    """
    raw = str(path or os.environ.get("DYZINEFRAME_CONFIG") or DF_CONFIG_DEFAULT)
    parser = configparser.ConfigParser(interpolation=None)
    try:
        parser.read(raw, encoding="utf-8")
    except OSError:
        return 0, 1, 5
    section = "TOPBOX" if parser.has_section("TOPBOX") else parser.default_section

    def _getint(key: str, default: int) -> int:
        if not parser.has_option(section, key):
            return default
        try:
            return parser.getint(section, key)
        except (ValueError, TypeError):
            return default

    asset = _getint("asset_number", 0)
    theme = _getint("pog_cc_theme", 1)
    volume = _getint("pog_cc_volume", 5)
    if theme < 1 or theme > 5:
        theme = 1
    if volume < 0:
        volume = 0
    elif volume > 62:
        volume = 62
    return asset, theme, volume


def format_identity_downlink(asset: int, theme: int, volume: int) -> str:
    return "/asset/%s\n/theme/%s\n/volume/%s\n" % (int(asset), int(theme), int(volume))


class PogccComService:
    def __init__(self) -> None:
        self.cdc: Optional[HeltecCdc] = None
        self.cdc_port: Optional[str] = None
        self.last_rx_mono = time.monotonic()
        self.credit_pennies = 0
        self.p3_last_mono = 0.0
        self.p3_up = False
        self.usb_up = False
        self.last_cmd = ""
        self._last_identity: tuple[int, int, int] | None = None
        self._identity_due = False
        self._loop_mono = time.monotonic()
        self._stop = False
        self._rxq: queue.Queue[bytes] = queue.Queue(maxsize=64)
        self.df_err = 0
        self.df_door = 0
        self.df_help = 0
        self.df_menu = 0
        self._tele_log_t = 0.0
        self.status_path = STATUS_PATH

    def write_status(self) -> None:
        path = self.status_path
        parent = os.path.dirname(path) or "."
        try:
            os.makedirs(parent, exist_ok=True)
        except PermissionError:
            # Fallback when /run/ethergold is missing (pre-RuntimeDirectory unit).
            path = os.path.expanduser("~/EtherGold/status/pogcc-com.status")
            parent = os.path.dirname(path)
            os.makedirs(parent, exist_ok=True)
            self.status_path = path
        payload = {
            "schema": "pogcc-com/1",
            "build": SERVICE_BUILD,
            "ts": time.time(),
            "usb_up": bool(self.usb_up),
            "cdc_port": self.cdc_port or "",
            "p3_up": bool(self.p3_up),
            "p3_port": resolve_p3_port(),
            "credit_pennies": int(self.credit_pennies),
            "last_cmd": self.last_cmd,
        }
        fd, tmp = tempfile.mkstemp(dir=parent, prefix=".pogcc-com.", 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)
        except Exception:
            try:
                os.unlink(tmp)
            except OSError:
                pass

    def open_cdc(self) -> bool:
        port = resolve_heltec_cdc_port()
        if not port:
            self.usb_up = False
            self.cdc_port = None
            return False
        if self.cdc and self.cdc_port == port and self.cdc.is_open:
            self.usb_up = True
            return True
        self.close_cdc()
        try:
            self.cdc = HeltecCdc.open_port(port)
            self.cdc_port = port
            self.usb_up = True
            self.last_rx_mono = time.monotonic()
            log(f"pogcc-com: opened Heltec CDC {port}")
            return True
        except Exception as exc:
            log(f"pogcc-com: open {port} failed: {exc}")
            self.cdc = None
            self.cdc_port = None
            self.usb_up = False
            return False

    def close_cdc(self) -> None:
        if self.cdc is not None:
            try:
                self.cdc.close()
            except Exception:
                pass
        self.cdc = None
        self.usb_up = False

    def cdc_write(self, line: str) -> None:
        if not self.cdc or not self.cdc.is_open:
            return
        data = line if line.endswith("\n") else line + "\n"
        try:
            self.cdc.write(data.encode("ascii", errors="replace"))
        except Exception as exc:
            log(f"pogcc-com: CDC write failed: {exc}")

    def rx_loop(self) -> None:
        """ACM reads off the main thread. cdc_acm os.read can ignore O_NONBLOCK."""
        buf = bytearray()
        while not self._stop:
            cdc = self.cdc
            if cdc is None or cdc.fd < 0:
                time.sleep(0.02)
                continue
            try:
                chunk = os.read(cdc.fd, 1024)
            except BlockingIOError:
                time.sleep(0.02)
                continue
            except OSError:
                time.sleep(0.05)
                continue
            if not chunk:
                time.sleep(0.02)
                continue
            buf.extend(chunk)
            while True:
                nl = buf.find(b"\n")
                if nl < 0:
                    break
                line = bytes(buf[:nl])
                del buf[: nl + 1]
                try:
                    self._rxq.put_nowait(line)
                except queue.Full:
                    log("pogcc-com: rx queue full — dropped Heltec line")

    def push_identity_downlink(self) -> None:
        """USB-downlink /asset /theme /volume from Identity config.ini."""
        if not self.cdc or not self.cdc.is_open:
            return
        asset, theme, volume = identity_downlink_from_ini()
        want = (asset, theme, volume)
        if want == self._last_identity:
            return
        blob = format_identity_downlink(asset, theme, volume)
        try:
            self.cdc.write(blob.encode("ascii"))
        except Exception as exc:
            log(f"pogcc-com: identity push failed: {exc}")
            return
        self._last_identity = want
        log(
            f"pogcc-com: pushed identity asset={asset} theme={theme} volume={volume}"
        )

    def fetch_p3_credit(self) -> Optional[int]:
        now = time.monotonic()
        if (now - self.p3_last_mono) < P3_MIN_INTERVAL_SEC:
            return self.credit_pennies
        self.p3_last_mono = now
        port = resolve_p3_port()
        log(f"pogcc-com: P3 open begin port={port}")
        t0 = time.monotonic()
        raw = b""
        try:
            with serial.Serial(port=port, baudrate=115200, timeout=2.0) as ser:
                ser.reset_input_buffer()
                ser.write(GAMESTATS_CMD)
                raw = ser.read_until(expected=READ_UNTIL)
        except Exception as exc:
            ms = int((time.monotonic() - t0) * 1000)
            log(f"pogcc-com: P3 open/read {port} failed ms={ms} {exc}")
            self.p3_up = False
            return None
        ms = int((time.monotonic() - t0) * 1000)
        if not raw or not raw.endswith(READ_UNTIL):
            log(
                f"pogcc-com: P3 incomplete GAMESTATS ms={ms} n={0 if not raw else len(raw)}"
            )
            self.p3_up = False
            return None
        try:
            text = normalize_gamestats_bytes(raw)
            parsed = parse_gamestats_csv(text)
            credit = credit_from_parse(parsed)
        except Exception as exc:
            log(f"pogcc-com: P3 parse failed: {exc}")
            self.p3_up = False
            return None
        self.p3_up = True
        try:
            from cabinet_identity import persist_gamestats_identity

            persist_gamestats_identity(
                machine_role=getattr(parsed, "machine_role", None),
                bank_number=getattr(parsed, "bank_number", None),
                machine_number=getattr(parsed, "machine_number", None) or getattr(parsed, "term_number", None),
                term_number=getattr(parsed, "machine_number", None) or getattr(parsed, "term_number", None),
                pog_tid=getattr(parsed, "tid_hex", None),
            )
        except Exception as exc:
            log(f"pogcc-com: identity persist skipped: {exc}")
        if credit is None:
            log(f"pogcc-com: P3 parse no credit ms={ms}")
            return None
        self.credit_pennies = int(credit)
        log(f"pogcc-com: P3 ok ms={ms} credits={self.credit_pennies}")
        return self.credit_pennies

    def notify_df_flag(
        self, flag: str, state: int, utc_ms: Optional[int], local_ms: Optional[int]
    ) -> None:
        url = f"{DF_BASE}/flag/{flag}/{int(state)}"
        # Bottle route is GET in DF
        try:
            with urllib.request.urlopen(url, timeout=1.5) as resp:
                resp.read(64)
        except Exception as exc:
            log(f"pogcc-com: DF flag notify failed {url}: {exc}")

    def refresh_df_hb_state(self) -> None:
        url = f"{DF_BASE}/pogcc/hb-state"
        try:
            with urllib.request.urlopen(url, timeout=0.8) as resp:
                body = json.loads(resp.read().decode("utf-8"))
            if isinstance(body, dict):
                self.df_err = 1 if body.get("error") else 0
                self.df_door = 1 if body.get("door") else 0
                self.df_help = 1 if body.get("help") else 0
                menu = body.get("game_menu")
                if isinstance(menu, int) and 0 <= menu <= 16:
                    self.df_menu = menu
        except Exception:
            # Keep last known; Heltec still gets UTC + credits
            pass

    def handle_line(self, ascii_data: str) -> None:
        split = [p.strip() for p in ascii_data.split("/")]
        if len(split) <= 1:
            return
        del split[0]
        cmd = split[0]
        self.last_cmd = cmd
        if cmd != "telemetry":
            shown = ascii_data if len(ascii_data) <= 96 else ascii_data[:96] + "..."
            log(f"pogcc-com: rx {shown}")

        def _opt_u64(idx: int) -> Optional[int]:
            if len(split) <= idx:
                return None
            tok = split[idx]
            return int(tok) if tok.isdigit() else None

        if cmd == "sds":
            try:
                idx = int(split[1])
            except (IndexError, ValueError):
                return
            if 1 <= idx <= 16:
                write_current_game(
                    game_index=idx,
                    game_id_hex=f"SDS{idx:02X}",
                    source="pogcc_com_sds",
                )
                self.df_menu = idx
            return

        if cmd == "hello":
            fw = split[1] if len(split) > 1 else "unknown"
            local_ms = int(split[2]) if len(split) > 2 and split[2].isdigit() else None
            utc_ms = int(split[3]) if len(split) > 3 and split[3].isdigit() else None
            try:
                path = write_pogcc_boot_arm(
                    firmware_version=fw,
                    heltec_local_ms=local_ms,
                    heltec_utc_ms=utc_ms,
                )
                log(f"pogcc-com: hello fw={fw} arm={path}")
            except OSError as exc:
                log(f"pogcc-com: hello arm write failed: {exc}")
            # Do not USB-write here. Heltec force-heartbeats on CDC-up and
            # waits 4s for HB,. Identity bytes first can fill TinyUSB RX and
            # block this pump (pyserial write_timeout default is None).
            return

        if cmd == "telemetry":
            def _tok(i: int, default: str = "0") -> str:
                return split[i] if len(split) > i else default

            def _si(i: int, default: int = 0) -> int:
                try:
                    return int(_tok(i))
                except ValueError:
                    return default

            def _rail(base: int) -> dict[str, Any]:
                present = _si(base) != 0
                mv, ma, mw = _si(base + 1), _si(base + 2), _si(base + 3)
                return {
                    "present": present,
                    "volts": round(mv / 1000.0, 3) if present else None,
                    "amps": round(ma / 1000.0, 3) if present else None,
                    "watts": round(mw / 1000.0, 3) if present else None,
                    "millivolts": mv if present else None,
                    "milliamps": ma if present else None,
                    "milliwatts": mw if present else None,
                }

            u_ok = _si(5) != 0
            l_ok = _si(7) != 0
            u_f100 = _si(6)
            l_f100 = _si(8)
            payload = {
                "firmware_version": _tok(1, "unknown"),
                "heltec_local_ms": _si(2),
                "credits_pennies": _si(3),
                "credits_dollars": round(_si(3) / 100.0, 2),
                "game_menu": _si(4),
                "temps_f": {
                    "upper": round(u_f100 / 100.0, 2) if u_ok else None,
                    "lower": round(l_f100 / 100.0, 2) if l_ok else None,
                    "upper_present": u_ok,
                    "lower_present": l_ok,
                },
                "power": {
                    "rail_5": _rail(9),
                    "rail_12a": _rail(13),
                    "rail_12b": _rail(17),
                    "rail_24": _rail(21),
                },
            }
            try:
                path = write_pogcc_telemetry(payload)
                now = time.monotonic()
                if now - self._tele_log_t >= 60.0:
                    self._tele_log_t = now
                    log(
                        f"pogcc-com: telemetry ok credits={payload['credits_pennies']} path={path}"
                    )
            except OSError as exc:
                log(f"pogcc-com: telemetry write failed: {exc}")
            return

        if cmd == "flag":
            try:
                flag = split[1]
                state = int(split[2])
            except (IndexError, ValueError):
                return
            self.notify_df_flag(flag, state, _opt_u64(3), _opt_u64(4))
            if flag == "error":
                self.df_err = 1 if state else 0
            elif flag == "door":
                self.df_door = 1 if state else 0
            elif flag in ("help", "helpreq"):
                self.df_help = 1 if state else 0
            return

        if cmd == "credits":
            t0 = time.monotonic()
            credit = self.fetch_p3_credit()
            if credit is None:
                credit = -99999999
                self.credit_pennies = credit
            self.cdc_write(f"CR,{credit}")
            log(
                f"pogcc-com: cmd=credits done ms={int((time.monotonic() - t0) * 1000)} cr={credit}"
            )
            return

        if cmd == "heartbeat":
            # /heartbeat/<bal>/<err>/<door>/<help>/<menu>
            t0 = time.monotonic()
            try:
                _bal = int(split[1])
                _err = int(split[2])
                _door = int(split[3])
                _help = int(split[4])
                _menu = int(split[5])
            except (IndexError, ValueError):
                self.cdc_write("Error")
                log("pogcc-com: cmd=heartbeat parse Error")
                return
            self.refresh_df_hb_state()
            df_ms = int((time.monotonic() - t0) * 1000)
            cg = read_current_game()
            menu = self.df_menu
            if cg and isinstance(cg.get("game_index"), int):
                menu = int(cg["game_index"])
            elif 0 <= _menu <= 16:
                menu = _menu
            utc_ms = int(time.time() * 1000)
            line = (
                f"HB,{self.credit_pennies},"
                f"{self.df_err},{self.df_door},{self.df_help},"
                f"{menu},{utc_ms}"
            )
            self.cdc_write(line)
            self._identity_due = True
            log(
                f"pogcc-com: cmd=heartbeat done df_ms={df_ms} "
                f"total_ms={int((time.monotonic() - t0) * 1000)} {line}"
            )
            return

        if cmd in ("ip", "asset"):
            # Old Heltec Setup still uplinks these. Pi Identity owns them.
            log(f"pogcc-com: ignoring config cmd {cmd} (Pi Identity /config)")
            return

        log(f"pogcc-com: unknown cmd {cmd}")

    def pump_once(self) -> None:
        if not self.open_cdc():
            return
        try:
            raw = self._rxq.get_nowait()
        except queue.Empty:
            return
        self.last_rx_mono = time.monotonic()
        raw = raw.replace(b"\x0A", b"").replace(b"\x0D", b"")
        try:
            ascii_data = raw.decode("ascii").strip()
        except Exception:
            return
        if not ascii_data:
            return
        self.handle_line(ascii_data)

    def run(self) -> None:
        log(f"pogcc-com: start build={SERVICE_BUILD}")
        self.write_status()
        threading.Thread(
            target=self.rx_loop,
            name="pogcc-com-rx",
            daemon=True,
        ).start()
        last_status = 0.0
        while True:
            self.pump_once()
            if self._identity_due:
                self.push_identity_downlink()
                self._identity_due = False
            now = time.monotonic()
            if (now - last_status) >= 2.0:
                self.write_status()
                last_status = now
            time.sleep(0.025)


def main() -> int:
    PogccComService().run()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
