#!/usr/bin/env python3
"""Background link watch — carrier on eth0/eth1; optional ARP to peer (pos3/4 bench).

Writes link_* events into the same events.jsonl as the bridge (role=link_watch).
Daisy-chain (pos1/2): use --no-arp — topology order is not defined.
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path

from pog422_fixture_core import EventLog, FixtureIdentity

LOCAL_IP = {3: "169.254.3.3", 4: "169.254.4.4"}
PEER_IP = {3: "169.254.4.4", 4: "169.254.3.3"}
CARRIER_PORTS = ("eth0", "eth1")
SCRIPT_DIR = Path(__file__).resolve().parent
RECOVER_SCRIPT = SCRIPT_DIR / "br0_link_recover.sh"


def read_carrier(port: str) -> int | None:
    path = Path(f"/sys/class/net/{port}/carrier")
    if not path.is_file():
        return None
    try:
        return int(path.read_text().strip())
    except (OSError, ValueError):
        return None


def read_operstate(port: str) -> str | None:
    path = Path(f"/sys/class/net/{port}/operstate")
    if not path.is_file():
        return None
    try:
        return path.read_text().strip()
    except OSError:
        return None


def neigh_state(interface: str, peer_ip: str) -> str | None:
    try:
        out = subprocess.run(
            ["ip", "neigh", "show", "dev", interface, peer_ip],
            capture_output=True,
            text=True,
            timeout=2.0,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    line = out.stdout.strip()
    if not line:
        return None
    m = re.search(r"\b(REACHABLE|STALE|DELAY|PROBE|FAILED|INCOMPLETE|PERMANENT|NOARP)\b", line)
    return m.group(1) if m else "unknown"


def arp_probe(interface: str, peer_ip: str, timeout_s: float) -> tuple[bool, float | None]:
    """Return (ok, rtt_ms). Uses arping when installed, else ping -I (kernel ARP)."""
    t0 = time.perf_counter()
    wait = max(1, int(timeout_s + 0.5))
    attempts: list[list[str]] = []
    if shutil.which("arping"):
        attempts.append(
            ["arping", "-I", interface, "-c", "1", "-w", str(max(1, int(timeout_s)))]
        )
    attempts.append(["ping", "-I", interface, "-c", "1", "-W", str(wait)])
    for cmd in attempts:
        try:
            proc = subprocess.run(
                [*cmd, peer_ip],
                capture_output=True,
                text=True,
                timeout=timeout_s + 1.0,
                check=False,
            )
        except (OSError, subprocess.TimeoutExpired):
            continue
        if proc.returncode == 0:
            rtt_ms = round((time.perf_counter() - t0) * 1000.0, 2)
            m = re.search(r"time[=<]([\d.]+)\s*ms", proc.stdout, re.I)
            if m:
                rtt_ms = float(m.group(1))
            return True, rtt_ms
    return False, None


def run_recover(
    mode: str,
    pos: int,
    interface: str,
    log_dir: Path,
    log: EventLog,
) -> bool:
    if not RECOVER_SCRIPT.is_file():
        log.emit("link_recover_skip", mode=mode, reason="script_missing")
        return False
    log.emit("link_recover_start", mode=mode, script=str(RECOVER_SCRIPT))
    env = {
        **dict(os.environ),
        "FIXTURE_POS": str(pos),
        "INTERFACE": interface,
        "DEVICE": interface,
        "LOG_DIR": str(log_dir),
    }
    try:
        proc = subprocess.run(
            [
                "sudo",
                "-n",
                "env",
                f"FIXTURE_POS={pos}",
                f"INTERFACE={interface}",
                f"DEVICE={interface}",
                f"LOG_DIR={log_dir}",
                f"JUN11_ETH={env.get('JUN11_ETH', '')}",
                "bash",
                str(RECOVER_SCRIPT),
                mode,
            ],
            capture_output=True,
            text=True,
            timeout=30.0,
            check=False,
            env=env,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        log.emit("link_recover_fail", mode=mode, error=str(exc))
        return False
    tail = (proc.stdout or proc.stderr or "").strip().splitlines()
    summary = tail[-1] if tail else f"exit={proc.returncode}"
    if proc.returncode == 0:
        log.emit("link_recover_ok", mode=mode, detail=summary)
        return True
    log.emit("link_recover_fail", mode=mode, exit_code=proc.returncode, detail=summary)
    return False


def main() -> int:
    parser = argparse.ArgumentParser(description="Fixture link watch (carrier + optional ARP)")
    parser.add_argument(
        "--fixture-pos",
        type=int,
        required=True,
        metavar="N",
        help="Fixture id 1–99 (1–5 seats/office; 51+=witness)",
    )
    parser.add_argument("--pi-label", default="")
    parser.add_argument("--interface", default="eth1", help="ARP probe interface (when ARP enabled)")
    parser.add_argument("--no-arp", action="store_true", help="Carrier/operstate only — no peer ARP")
    parser.add_argument("--output-dir", default="data")
    parser.add_argument("--interval", type=float, default=1.0)
    parser.add_argument("--arp-timeout", type=float, default=0.5)
    parser.add_argument("--ok-heartbeat", type=float, default=60.0, help="emit link_arp_ok at most this often when stable")
    parser.add_argument(
        "--recover-after",
        type=int,
        default=int(os.environ.get("LINK_RECOVER_AFTER", "0")),
        help="run br0_link_recover.sh soft after this many consecutive ARP fails (0=off)",
    )
    parser.add_argument(
        "--recover-cooldown",
        type=float,
        default=float(os.environ.get("LINK_RECOVER_COOLDOWN", "15")),
        help="minimum seconds between soft recover attempts",
    )
    parser.add_argument(
        "--recover-hard-after",
        type=int,
        default=int(os.environ.get("LINK_RECOVER_HARD_AFTER", "0")),
        help="after this many failed soft recoveries, request hard br0 reinit (supervised bridge only)",
    )
    parser.add_argument("--log-dir", default="", help="logs/ for recover script (default: ../logs from output-dir)")
    parser.add_argument("--echo-events", action="store_true")
    parser.add_argument(
        "--prod",
        action="store_true",
        help="Errors-only logging to errors.jsonl",
    )
    args = parser.parse_args()
    if not 1 <= int(args.fixture_pos) <= 99:
        parser.error("FIXTURE_POS must be 1..99")

    pos = args.fixture_pos
    arp_enabled = not args.no_arp and pos in PEER_IP
    peer_ip = PEER_IP.get(pos, "")
    local_ip = LOCAL_IP.get(pos, "")
    label = args.pi_label or f"topbox{pos}"
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    log_dir = Path(args.log_dir) if args.log_dir else output_dir.parent / "logs"
    log_dir.mkdir(parents=True, exist_ok=True)
    hard_flag = log_dir / ".link_hard_recover"

    identity = FixtureIdentity(fixture_pos=pos, pi_label=label, role="link_watch")
    log = EventLog(output_dir, identity, echo=args.echo_events, prod_mode=args.prod)

    stop = False

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

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

    if not args.prod:
        log.emit(
            "link_watch_start",
            interface=args.interface if arp_enabled else "eth0,eth1",
            local_ip=local_ip or None,
            peer_ip=peer_ip or None,
            arp_enabled=arp_enabled,
            interval_s=args.interval,
            recover_after=args.recover_after if arp_enabled else 0,
            recover_cooldown_s=args.recover_cooldown,
        )

    last_carrier: dict[str, int | None] = {}
    last_oper: dict[str, str | None] = {}
    arp_ok: bool | None = None
    last_ok_emit = 0.0
    consecutive_fails = 0
    last_recover_at = 0.0
    soft_recover_fails = 0

    while not stop:
        now = time.time()
        for port in CARRIER_PORTS:
            carrier = read_carrier(port)
            oper = read_operstate(port)
            if carrier != last_carrier.get(port):
                log.emit(
                    "link_carrier",
                    port=port,
                    carrier=carrier,
                    operstate=oper,
                )
                last_carrier[port] = carrier
            if not args.prod and oper != last_oper.get(port):
                log.emit(
                    "link_operstate",
                    port=port,
                    operstate=oper,
                    carrier=carrier,
                )
                last_oper[port] = oper

        if not arp_enabled:
            time.sleep(max(0.2, args.interval))
            continue

        ok, rtt_ms = arp_probe(args.interface, peer_ip, args.arp_timeout)
        nstate = neigh_state(args.interface, peer_ip)

        if not ok:
            log.emit(
                "link_arp_fail",
                peer_ip=peer_ip,
                neigh=nstate,
            )
            arp_ok = False
            consecutive_fails += 1
            if (
                args.recover_after > 0
                and consecutive_fails >= args.recover_after
                and now - last_recover_at >= args.recover_cooldown
            ):
                if run_recover("soft", pos, args.interface, log_dir, log):
                    consecutive_fails = 0
                    soft_recover_fails = 0
                else:
                    soft_recover_fails += 1
                    if (
                        args.recover_hard_after > 0
                        and soft_recover_fails >= args.recover_hard_after
                    ):
                        log.emit(
                            "link_recover_escalate",
                            mode="hard",
                            soft_failures=soft_recover_fails,
                        )
                        try:
                            hard_flag.write_text("1\n", encoding="utf-8")
                        except OSError:
                            pass
                        subprocess.run(
                            [
                                "pkill",
                                "-f",
                                f"pog422_fixture_bridge.py.*--fixture-pos {pos}",
                            ],
                            check=False,
                        )
                        stop = True
                last_recover_at = now
                consecutive_fails = 0
        else:
            consecutive_fails = 0
            soft_recover_fails = 0
            if arp_ok is False or arp_ok is None:
                log.emit(
                    "link_arp_ok",
                    peer_ip=peer_ip,
                    rtt_ms=rtt_ms,
                    neigh=nstate,
                    recovery=arp_ok is False,
                )
                last_ok_emit = now
            elif now - last_ok_emit >= args.ok_heartbeat:
                log.emit(
                    "link_arp_ok",
                    peer_ip=peer_ip,
                    rtt_ms=rtt_ms,
                    neigh=nstate,
                    heartbeat=True,
                )
                last_ok_emit = now
            arp_ok = True

        time.sleep(max(0.2, args.interval))

    log.emit("link_watch_stop")
    log.close()
    return 0


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