#!/usr/bin/env python3
"""Production supervisor for POG422 listen-only collectors (no Ethernet bridge).

Keeps pog422_listen.py alive across USB hot-plug. If leftover hat_listen.sh
blocks respawn, or progressives.csv stays missing while listen is up, bounce
listen (not the Pi, not uartd persist). Manual disable:
  touch /var/lib/ethergold/disabled  (legacy: /var/lib/fixture-prod/disabled)
"""

from __future__ import annotations

import argparse
import glob
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DISABLE = Path(os.environ.get("ETHERGOLD_DISABLE_FILE", "/var/lib/ethergold/disabled"))
if not DEFAULT_DISABLE.parent.is_dir() and Path("/var/lib/fixture-prod").is_dir():
    DEFAULT_DISABLE = Path("/var/lib/fixture-prod/disabled")

_eg_root = Path(os.environ.get("ETHERGOLD_ROOT", "/home/topbox/EtherGold"))
# Flat layout: runtime files live beside this supervisor (dev/ or prod/).
BRIDGE_ROOT = Path(os.environ.get("ETHERGOLD_BRIDGE", SCRIPT_DIR))
DAEMON_ROOT = Path(os.environ.get("ETHERGOLD_DAEMON", SCRIPT_DIR))
LOGS_ROOT = Path(os.environ.get("ETHERGOLD_LOGS", _eg_root / "logs"))

sys.path.insert(0, str(SCRIPT_DIR))
from prod_log_manager import env_max_age_s, env_max_bytes, manage_prod_log

SUPERVISOR_LOG_MAX_BYTES = env_max_bytes("FIXTURE_PROD_SUPERVISOR_LOG_MAX_BYTES")
SUPERVISOR_LOG_MAX_AGE_S = env_max_age_s()
LISTEN_PATTERN = "pog422_listen.py"
LAUNCH_WRAPPER_PATTERNS = ("hat_listen.sh", "run_listen_prod.sh")
LAUNCH_STUCK_S = float(os.environ.get("ETHERGOLD_LAUNCH_STUCK_S", "20"))
CSV_MISSING_GRACE_S = float(os.environ.get("ETHERGOLD_CSV_MISSING_GRACE_S", "45"))
CSV_RESTART_BACKOFF_S = float(os.environ.get("ETHERGOLD_CSV_RESTART_BACKOFF_S", "30"))
CSV_RESTART_MAX = int(os.environ.get("ETHERGOLD_CSV_RESTART_MAX", "5"))
CSV_GIVE_UP_S = float(os.environ.get("ETHERGOLD_CSV_GIVE_UP_S", "300"))


def sd_notify(message: str) -> None:
    """systemd Type=notify datagram. No-op when not under systemd. No python-systemd."""
    path = os.environ.get("NOTIFY_SOCKET", "")
    if not path:
        return
    try:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
        try:
            addr: str | bytes
            if path.startswith("@"):
                addr = "\0" + path[1:]
            else:
                addr = path
            sock.sendto(message.encode("utf-8"), addr)
        finally:
            sock.close()
    except OSError:
        pass


def utc_now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def log_line(path: Path, msg: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    manage_prod_log(
        path,
        max_bytes=SUPERVISOR_LOG_MAX_BYTES,
        max_age_s=SUPERVISOR_LOG_MAX_AGE_S,
    )
    with path.open("a", encoding="utf-8") as f:
        f.write(f"{utc_now()} {msg}\n")


def pgrep(pattern: str) -> list[int]:
    try:
        out = subprocess.run(
            ["pgrep", "-f", pattern],
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError:
        return []
    return [int(line.strip()) for line in out.stdout.splitlines() if line.strip().isdigit()]


def csv_is_missing(path: Path | str) -> bool:
    """True when progressives.csv is absent or empty (HDMI has no live meters)."""
    try:
        return Path(path).stat().st_size <= 0
    except OSError:
        return True


def launch_wrappers_are_stuck(
    *,
    listen_python_running: bool,
    wrappers_present: bool,
    seconds_since_spawn: float,
    stuck_after_s: float = LAUNCH_STUCK_S,
) -> bool:
    """Leftover hat_listen.sh without pog422_listen.py must not block respawn."""
    if listen_python_running or not wrappers_present:
        return False
    return float(seconds_since_spawn) >= float(stuck_after_s)


def resolve_progressives_csv() -> Path:
    explicit = os.environ.get("ETHERGOLD_PROGRESSIVES_CSV", "").strip()
    if explicit:
        return Path(explicit)
    return _eg_root / "progressives.csv"


def stop_launch_wrappers() -> list[int]:
    killed: list[int] = []
    for pattern in LAUNCH_WRAPPER_PATTERNS:
        for pid in pgrep(pattern):
            try:
                os.kill(pid, signal.SIGTERM)
                killed.append(pid)
            except OSError:
                pass
    return killed


def stop_listen(pos: int) -> None:
    for pid in pgrep(f"{LISTEN_PATTERN}.*--fixture-pos {pos}"):
        try:
            os.kill(pid, signal.SIGTERM)
        except OSError:
            pass
    time.sleep(1.0)
    for pid in pgrep(f"{LISTEN_PATTERN}.*--fixture-pos {pos}"):
        try:
            os.kill(pid, signal.SIGKILL)
        except OSError:
            pass
    stop_launch_wrappers()


def tty_present() -> bool:
    return bool(glob.glob("/dev/ttyXRUSB*"))


def recover_usb(log_path: Path) -> bool:
    script = BRIDGE_ROOT / "ensure_tty_xrusb.sh"
    if not script.is_file():
        log_line(log_path, "listen_supervisor_usb_recover skip missing ensure_tty_xrusb.sh")
        return False
    log_line(log_path, "listen_supervisor_usb_recover start")
    sd_notify("WATCHDOG=1")
    proc = subprocess.run(
        ["bash", str(script)],
        capture_output=True,
        text=True,
        check=False,
    )
    sd_notify("WATCHDOG=1")
    ok = proc.returncode == 0 and tty_present()
    log_line(
        log_path,
        f"listen_supervisor_usb_recover {'ok' if ok else 'fail'} exit={proc.returncode}",
    )
    return ok


def listen_running(pos: int) -> bool:
    return bool(pgrep(f"{LISTEN_PATTERN}.*--fixture-pos {pos}"))


def listen_launching() -> bool:
    return bool(pgrep("run_listen_prod.sh")) or bool(pgrep("hat_listen.sh"))


def start_listen(pos: int, log_path: Path) -> None:
    env = os.environ.copy()
    env["FIXTURE_POS"] = str(pos)
    env["FIXTURE_PROD"] = "1"
    env["CLEAR_CAPTURE"] = "0"
    env["HOME"] = env.get("HOME") or "/home/topbox"
    env.setdefault("ETHERGOLD_CURRENT_GAME_IPC", "write")
    script = BRIDGE_ROOT / "run_listen_prod.sh"
    child_log = log_path.parent / "listen-supervisor-child.log"
    log_line(log_path, f"listen_supervisor_restart pos={pos} script={script} ipc={env.get('ETHERGOLD_CURRENT_GAME_IPC')}")
    child_log.parent.mkdir(parents=True, exist_ok=True)
    with child_log.open("ab") as fh:
        fh.write(f"\n--- {utc_now()} spawn pos={pos} ---\n".encode())
        fh.flush()
        subprocess.Popen(
            ["bash", str(script)],
            cwd=str(BRIDGE_ROOT),
            env=env,
            stdout=fh,
            stderr=fh,
            start_new_session=True,
        )


def main() -> int:
    parser = argparse.ArgumentParser(description="Fixture listen-only production supervisor")
    parser.add_argument("--fixture-pos", type=int, choices=(1, 2, 3, 4), required=True)
    parser.add_argument("--disable-file", type=Path, default=DEFAULT_DISABLE)
    parser.add_argument("--log", type=Path, default=None)
    parser.add_argument("--poll-s", type=float, default=5.0)
    parser.add_argument("--restart-backoff-s", type=float, default=10.0)
    args = parser.parse_args()

    pos = args.fixture_pos
    log_path = args.log or (LOGS_ROOT / "supervisor.log")
    log_line(log_path, f"listen_supervisor_start pos={pos} disable={args.disable_file}")
    sd_notify("READY=1")

    stop = False
    last_restart = time.time()
    csv_restarts = 0
    csv_give_up_until = 0.0

    def _stop(*_a: object) -> None:
        nonlocal stop
        stop = True

    signal.signal(signal.SIGTERM, _stop)
    signal.signal(signal.SIGINT, _stop)

    while not stop:
        sd_notify("WATCHDOG=1")
        if args.disable_file.is_file():
            time.sleep(max(1.0, args.poll_s))
            continue

        if not tty_present():
            stop_listen(pos)
            recover_usb(log_path)
            time.sleep(args.restart_backoff_s)
            continue

        now = time.time()
        running = listen_running(pos)
        wrappers = listen_launching()
        since_spawn = now - last_restart
        stuck = launch_wrappers_are_stuck(
            listen_python_running=running,
            wrappers_present=wrappers,
            seconds_since_spawn=since_spawn,
        )

        if not running:
            if wrappers and not stuck:
                pass
            else:
                if stuck:
                    killed = stop_launch_wrappers()
                    log_line(
                        log_path,
                        f"listen_supervisor_launch_stuck pos={pos} "
                        f"killed_wrappers={killed} since_spawn_s={since_spawn:.1f}",
                    )
                    time.sleep(1.0)
                if now - last_restart >= args.restart_backoff_s:
                    start_listen(pos, log_path)
                    last_restart = now
        elif now >= csv_give_up_until and since_spawn >= CSV_MISSING_GRACE_S:
            if csv_is_missing(resolve_progressives_csv()):
                if csv_restarts >= CSV_RESTART_MAX:
                    csv_restarts = 0
                    log_line(
                        log_path,
                        f"listen_supervisor_csv_missing keep_bouncing pos={pos} "
                        f"path={resolve_progressives_csv()}",
                    )
                elif since_spawn >= CSV_RESTART_BACKOFF_S:
                    log_line(
                        log_path,
                        f"listen_supervisor_csv_missing bounce pos={pos} "
                        f"path={resolve_progressives_csv()} n={csv_restarts + 1}",
                    )
                    stop_listen(pos)
                    start_listen(pos, log_path)
                    last_restart = now
                    csv_restarts += 1
            else:
                csv_restarts = 0

        time.sleep(args.poll_s)

    persist = os.environ.get("ETHERGOLD_UARTD_PERSIST", "1") == "1"
    log_line(log_path, f"listen_supervisor_stop pos={pos} persist={int(persist)}")
    # Persist is uartd-only. Leaving pog422_listen up holds BCM9 after
    # `systemctl stop` (KillMode=process + start_new_session).
    stop_listen(pos)
    if persist:
        log_line(log_path, "listen_supervisor_stop left pog422_uartd up")
    return 0


if __name__ == "__main__":
    sys.exit(main())
