#!/usr/bin/env python3
"""Production supervisor — keep jun11-good bridge alive across USB/ETH hot-plug.

Manual disable: touch DISABLE_FILE (see disable_prod.sh).
Manual uninstall: bash uninstall_prod.sh
"""

from __future__ import annotations

import argparse
import glob
import os
import signal
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"))
DEFAULT_STATE = Path(os.environ.get("ETHERGOLD_STATE_DIR", "/var/lib/ethergold/state"))
# Legacy fallback if migrate not yet run
if not DEFAULT_DISABLE.parent.is_dir() and Path("/var/lib/fixture-prod").is_dir():
    DEFAULT_DISABLE = Path("/var/lib/fixture-prod/disabled")
    DEFAULT_STATE = Path("/var/lib/fixture-prod/state")

_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"))
GOOD = BRIDGE_ROOT

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()
ETH_PORT_CHANGE_DEBOUNCE_S = float(os.environ.get("FIXTURE_ETH_PORT_CHANGE_DEBOUNCE_S", "3.0"))
ETH_PORTS = ("eth0", "eth1")
BRIDGE_PATTERN = "pog422_bridge.py"
WATCH_PATTERN = "fixture_link_watch.py"


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 []
    pids = []
    for line in out.stdout.splitlines():
        line = line.strip()
        if line.isdigit():
            pids.append(int(line))
    return pids


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


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, "supervisor_usb_recover skip missing ensure_tty_xrusb.sh")
        return False
    log_line(log_path, "supervisor_usb_recover start")
    proc = subprocess.run(
        ["bash", str(script)],
        capture_output=True,
        text=True,
        check=False,
    )
    ok = proc.returncode == 0 and tty_present()
    log_line(
        log_path,
        f"supervisor_usb_recover {'ok' if ok else 'fail'} exit={proc.returncode}",
    )
    return ok


def read_eth_carriers() -> dict[str, int | None]:
    carriers: dict[str, int | None] = {}
    for port in ETH_PORTS:
        carrier_path = Path(f"/sys/class/net/{port}/carrier")
        if not carrier_path.is_file():
            carriers[port] = None
            continue
        try:
            carriers[port] = int(carrier_path.read_text().strip())
        except (OSError, ValueError):
            carriers[port] = None
    return carriers


def any_carrier_up(carriers: dict[str, int | None]) -> bool:
    return any(v == 1 for v in carriers.values())


def carrier_key(carriers: dict[str, int | None]) -> tuple[tuple[str, int | None], ...]:
    return tuple(sorted(carriers.items()))


def edge_mode_enabled() -> bool:
    return os.environ.get("EDGE_MODE", "0").strip() == "1"


def status_led_enabled() -> bool:
    return os.environ.get("FIXTURE_STATUS_LED", "1").strip() != "0"


def set_status_led(mode: str) -> None:
    if not status_led_enabled():
        return
    script = SCRIPT_DIR / "ethergold_status_led.sh"
    if not script.is_file():
        script = SCRIPT_DIR / "fixture_status_led.sh"
    if not script.is_file():
        return
    subprocess.run(
        ["bash", str(script), mode],
        capture_output=True,
        check=False,
    )


def start_bridge(pos: int, log_path: Path) -> None:
    env = os.environ.copy()
    env["FIXTURE_POS"] = str(pos)
    env["FIXTURE_PROD"] = "1"
    env["CLEAR_CAPTURE"] = "0"
    env["ETHERGOLD_PROFILE"] = "prod"
    env["ETHERGOLD_ENTRY"] = "1"
    # Unified entry: EDGE_MODE from edge.env selects copper vs edge inside the script.
    script = BRIDGE_ROOT / "run_ethergold.sh"
    if not script.is_file():
        # Legacy trees before unified entry
        if edge_mode_enabled():
            script = BRIDGE_ROOT / "run_bridge_edge_prod.sh"
            mode = "edge"
        else:
            script = BRIDGE_ROOT / "run_bridge_prod.sh"
            mode = "copper"
        cmd = (
            ["sudo", "-n", "bash", str(script)]
            if mode == "edge"
            else ["bash", str(script)]
        )
    else:
        mode = "edge" if edge_mode_enabled() else "copper"
        # EDGE prod needs root for tunnel_up; copper prod does not.
        if mode == "edge":
            cmd = ["sudo", "-n", "bash", str(script)]
        else:
            cmd = ["bash", str(script)]
    log_line(log_path, f"supervisor_restart pos={pos} mode={mode} script={script}")
    subprocess.Popen(
        cmd,
        cwd=str(BRIDGE_ROOT),
        env=env,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )


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


def restart_bridge(pos: int, log_path: Path, reason: str, carriers: dict[str, int | None]) -> float:
    log_line(log_path, f"{reason} carriers={carriers}")
    stop_workers(pos)
    start_bridge(pos, log_path)
    return time.time()


def main() -> int:
    parser = argparse.ArgumentParser(description="Fixture production supervisor")
    parser.add_argument(
        "--fixture-pos",
        type=int,
        default=1,
        help="Internal timing/bookkeeping id only. Prod is position-free "
        "(identity = hostname); leave unset for default 1.",
    )
    parser.add_argument(
        "--disable-file",
        type=Path,
        default=DEFAULT_DISABLE,
        help="When this file exists, supervisor idles (manual shutdown)",
    )
    parser.add_argument(
        "--log",
        type=Path,
        default=None,
        help="Supervisor log (default: ~/EtherGold/logs/supervisor.log)",
    )
    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"supervisor_start pos={pos} disable={args.disable_file}")

    if not args.disable_file.is_file():
        set_status_led("heartbeat")

    stop = False
    last_restart = 0.0
    last_carriers = read_eth_carriers()
    port_change_pending_at: float | None = None
    logged_no_eth_tty_start = False

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

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

    while not stop:
        if args.disable_file.is_file():
            time.sleep(max(1.0, args.poll_s))
            continue

        if not tty_present():
            stop_workers(pos)
            recover_usb(log_path)
            port_change_pending_at = None
            time.sleep(args.restart_backoff_s)
            continue

        carriers = read_eth_carriers()
        had_carrier = any_carrier_up(last_carriers)
        has_carrier = any_carrier_up(carriers)
        carriers_changed = carrier_key(carriers) != carrier_key(last_carriers)

        if not had_carrier and has_carrier:
            if bridge_running(pos):
                last_restart = restart_bridge(
                    pos,
                    log_path,
                    "supervisor_eth_replug",
                    carriers,
                )
            else:
                log_line(log_path, f"supervisor_eth_replug_start carriers={carriers}")
                start_bridge(pos, log_path)
                last_restart = time.time()
            port_change_pending_at = None
        elif had_carrier and has_carrier and carriers_changed:
            if port_change_pending_at is None:
                port_change_pending_at = time.time()
                log_line(
                    log_path,
                    f"supervisor_eth_port_change_pending carriers={carriers}",
                )
            elif time.time() - port_change_pending_at >= ETH_PORT_CHANGE_DEBOUNCE_S:
                last_restart = restart_bridge(
                    pos,
                    log_path,
                    "supervisor_eth_port_change",
                    carriers,
                )
                port_change_pending_at = None
        else:
            port_change_pending_at = None

        last_carriers = carriers

        if not has_carrier:
            if bridge_running(pos):
                time.sleep(args.poll_s)
                continue
            # Field Topbox: USB serial (ttyXRUSB) may carry 0x0A progressives even when
            # eth0/eth1 show NO-CARRIER (no game-network cable). Start bridge on tty alone.
            now = time.time()
            if now - last_restart >= args.restart_backoff_s:
                if not logged_no_eth_tty_start:
                    log_line(
                        log_path,
                        f"supervisor_bridge_tty_only carriers={carriers}",
                    )
                    logged_no_eth_tty_start = True
                start_bridge(pos, log_path)
                last_restart = now
            time.sleep(args.poll_s)
        elif not bridge_running(pos):
            now = time.time()
            if now - last_restart >= args.restart_backoff_s:
                start_bridge(pos, log_path)
                last_restart = now

        time.sleep(args.poll_s)

    log_line(log_path, f"supervisor_stop pos={pos}")
    set_status_led("mmc0")
    stop_workers(pos)
    return 0


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