#!/usr/bin/env python3
"""
stress_run.py — Orchestrator for the streaming-chain stress matrix (stress.py).

Where run_e2e.sh drives scenarios.py for *correctness*, this drives
STRESS_CASES for *capacity*. Per case it:

  1. generates the FileReader input + MARTe app cfg + 1..M StreamHub cfgs
     (reusing gen_data / gen_cfg unchanged — a stress case is a scenario superset),
  2. launches the server stack:
       * "hub"       shape: 1 MARTe UDPStreamer feed + 1 StreamHub, then N
         parallel chain-clients (stress mode) on that one hub,
       * "ds_fanout" shape: 1 MARTe feed + M independent StreamHubs all
         subscribing to the same UDPStreamer (unicast fan-out), 1 client each,
  3. drives the clients in stress mode (liveness + sustained zoom-latency),
  4. snapshots CPU / peak-RSS of marte and every hub *while alive* (proc_perf),
  5. evaluates the per-case gate (survival + liveness hard gates; RSS + zoom-p95
     soft gates against the case's ceilings),

and writes stress_results.json (one record per case, carrying the axis/level for
later scaling-curve plots). Server binaries + LD_LIBRARY_PATH come from the
caller (run_stress.sh sources env.sh); this module only orchestrates.
"""
import argparse
import copy
import json
import os
import signal
import socket
import subprocess
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gen_cfg          # noqa: E402
import gen_data         # noqa: E402
import proc_perf        # noqa: E402
import stress as ST     # noqa: E402

# WS port probing bases. StreamHub does not set SO_REUSEADDR, so a leftover
# listener (e.g. an orphaned hub from an aborted run) keeps a fixed port wedged;
# we therefore probe for genuinely-free ports at launch rather than trusting a
# static assignment. ds_fanout hubs probe from one base, single hubs from another.
FANOUT_WS_BASE = 9000
SINGLE_WS_BASE = 9100


def _popen(cmd, log_path):
    """Launch cmd in its own session, stdout+stderr → log_path."""
    f = open(log_path, "w")
    return subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT,
                            start_new_session=True), f


def _free_ports(n, base):
    """Find n free TCP ports on 127.0.0.1 at/above base.

    Probed ports are closed and handed straight to the hub launch; the race
    window is negligible for this sequential localhost harness, and probing is
    what makes the suite robust to leftover wedged listeners."""
    found = []
    cand = base
    while len(found) < n and cand < base + 1000:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            s.bind(("127.0.0.1", cand))
            found.append(cand)
        except OSError:
            pass
        finally:
            s.close()
        cand += 1
    if len(found) < n:
        raise RuntimeError(f"could not find {n} free ports from {base}")
    return found


def _hub_ports(case):
    st = case["stress"]
    if case["shape"] == "ds_fanout":
        return _free_ports(st["hubs"], FANOUT_WS_BASE)
    return _free_ports(1, SINGLE_WS_BASE)


def _teardown(procs_files):
    for p, _f in procs_files:
        if p.poll() is None:
            try:
                os.killpg(os.getpgid(p.pid), signal.SIGTERM)
            except (ProcessLookupError, PermissionError):
                pass
    deadline = time.time() + 5
    for p, _f in procs_files:
        try:
            p.wait(timeout=max(0.1, deadline - time.time()))
        except subprocess.TimeoutExpired:
            try:
                os.killpg(os.getpgid(p.pid), signal.SIGKILL)
            except (ProcessLookupError, PermissionError):
                pass
    for _p, f in procs_files:
        try:
            f.close()
        except OSError:
            pass


def run_case(case, bins, work, out):
    sid = case["id"]
    st = case["stress"]
    input_bin = os.path.join(work, f"sinput_{sid}.bin")
    marte_cfg = os.path.join(work, f"sm_{sid}.cfg")

    gen_data.write_input(case, input_bin)
    gen_cfg.write_marte_cfg(case, marte_cfg, input_bin)

    ports = _hub_ports(case)
    hub_pf = []        # (proc, file)
    for i, p in enumerate(ports):
        hc = copy.deepcopy(case)
        hc["ws_port"] = p
        hcfg = os.path.join(work, f"sh_{sid}_{i}.cfg")
        gen_cfg.write_hub_cfg(hc, hcfg)
        proc, f = _popen([bins["hub"], "-cfg", hcfg],
                         os.path.join(out, f"shub_{sid}_{i}.log"))
        hub_pf.append((proc, f))
    time.sleep(1.0)

    marte_proc, marte_f = _popen(
        [bins["marte"], "-l", "RealTimeLoader", "-f", marte_cfg, "-s", "Running"],
        os.path.join(out, f"smarte_{sid}.log"))
    time.sleep(1.5)

    dur, reqrate = st["dur"], st["reqrate"]
    # clean stale client outputs so a missing file means "client failed".
    for i in range(max(st["clients"], st["hubs"])):
        sp = os.path.join(work, f"stress_{sid}_c{i}.json")
        if os.path.exists(sp):
            os.remove(sp)

    client_pf = []
    if case["shape"] == "ds_fanout":
        targets = [(i, ports[i]) for i in range(len(ports))]
    else:
        targets = [(i, ports[0]) for i in range(st["clients"])]
    for cid, wsport in targets:
        cmd = [bins["client"], "-mode", "stress",
               "-hub", f"127.0.0.1:{wsport}", "-scenario", sid,
               "-clientid", str(cid), "-dur", str(dur),
               "-reqrate", str(reqrate), "-out", work,
               "-timeout", f"{int(dur) + 90}s"]
        proc, f = _popen(cmd, os.path.join(out, f"sclient_{sid}_c{cid}.log"))
        client_pf.append((proc, f))

    deadline = time.time() + dur + 60
    for proc, _f in client_pf:
        try:
            proc.wait(timeout=max(1.0, deadline - time.time()))
        except subprocess.TimeoutExpired:
            proc.kill()
    for _p, f in client_pf:
        f.close()

    # survival + perf must be read while the stack is still alive.
    survival = (marte_proc.poll() is None
                and all(h.poll() is None for h, _ in hub_pf))
    perf_marte = proc_perf.snapshot(marte_proc.pid)
    perf_hubs = [proc_perf.snapshot(h.pid) for h, _ in hub_pf]

    _teardown([(marte_proc, marte_f)] + hub_pf)

    clients = []
    for cid, _ in targets:
        jp = os.path.join(work, f"stress_{sid}_c{cid}.json")
        if os.path.exists(jp):
            with open(jp) as f:
                clients.append(json.load(f))

    return _evaluate(case, survival, perf_marte, perf_hubs, clients)


def _rss_mb(rec):
    return rec.get("peak_rss_kb", 0) / 1024.0 if rec.get("avail") else 0.0


def _evaluate(case, survival, perf_marte, perf_hubs, clients):
    g = case["stress"].get("gate", {})
    fails = []

    if not survival:
        fails.append("server process died during run")
    if not clients:
        fails.append("no client results recorded")

    min_frames = g.get("min_frames", 1)
    for c in clients:
        cid = c.get("clientId")
        if c.get("frames", 0) < min_frames:
            fails.append(f"c{cid} frames {c.get('frames')} < {min_frames}")
        if not c.get("monotonic", False):
            fails.append(f"c{cid} non-monotonic time axis")
        if not c.get("wallclock", False):
            fails.append(f"c{cid} non-wallclock timestamps")

    marte_rss = _rss_mb(perf_marte)
    if "max_marte_rss_mb" in g and marte_rss > g["max_marte_rss_mb"]:
        fails.append(f"marte peakRSS {marte_rss:.0f}MB > {g['max_marte_rss_mb']}MB")
    hub_rss_max = max((_rss_mb(h) for h in perf_hubs), default=0.0)
    if "max_hub_rss_mb" in g and hub_rss_max > g["max_hub_rss_mb"]:
        fails.append(f"hub peakRSS {hub_rss_max:.0f}MB > {g['max_hub_rss_mb']}MB")

    p95s = [c["zoomP95ms"] for c in clients if c.get("zoomCount", 0) > 0]
    zoom_p95 = max(p95s) if p95s else 0.0
    p50s = [c["zoomP50ms"] for c in clients if c.get("zoomCount", 0) > 0]
    zoom_p50 = max(p50s) if p50s else 0.0
    if "max_zoom_p95_ms" in g and zoom_p95 > g["max_zoom_p95_ms"]:
        fails.append(f"zoom p95 {zoom_p95:.0f}ms > {g['max_zoom_p95_ms']}ms")

    st = case["stress"]
    return {
        "id": case["id"], "shape": case["shape"],
        "axis": st["axis"], "level": st["level"],
        "status": "PASS" if not fails else "FAIL",
        "survival": survival,
        "clients": len(clients),
        "min_frames": min(((c.get("frames", 0)) for c in clients), default=0),
        "marte_cpu_s": perf_marte.get("cpu_s", 0.0),
        "marte_rss_mb": round(marte_rss, 1),
        "hub_cpu_s": round(sum(h.get("cpu_s", 0.0) for h in perf_hubs), 2),
        "hub_rss_mb": round(hub_rss_max, 1),
        "zoom_count": sum(c.get("zoomCount", 0) for c in clients),
        "zoom_fail": sum(c.get("zoomFail", 0) for c in clients),
        "zoom_p50_ms": round(zoom_p50, 1),
        "zoom_p95_ms": round(zoom_p95, 1),
        "fails": fails,
    }


def main():
    p = argparse.ArgumentParser(description="Run the streaming-chain stress matrix")
    p.add_argument("--marte", required=True, help="MARTeApp.ex path")
    p.add_argument("--hub", required=True, help="StreamHub.ex path")
    p.add_argument("--client", required=True, help="chain-client path")
    p.add_argument("--work", required=True, help="scratch dir")
    p.add_argument("--out", required=True, help="report/log dir")
    p.add_argument("--only", default="", help="run a single case id")
    p.add_argument("--axis", default="", help="run only cases on this axis")
    args = p.parse_args()

    os.makedirs(args.work, exist_ok=True)
    os.makedirs(args.out, exist_ok=True)
    bins = {"marte": args.marte, "hub": args.hub, "client": args.client}

    cases = ST.STRESS_CASES
    if args.only:
        cases = [c for c in cases if c["id"] == args.only]
    if args.axis:
        cases = [c for c in cases if c["stress"]["axis"] == args.axis]
    if not cases:
        print("no stress cases selected", file=sys.stderr)
        sys.exit(1)

    results = []
    for c in cases:
        errs = ST.validate_case(c)
        if errs:
            print(f"══ {c['id']}: INVALID {errs} ══")
            results.append({"id": c["id"], "axis": c["stress"]["axis"],
                            "level": c["stress"]["level"], "status": "FAIL",
                            "fails": errs})
            continue
        print(f"\n══ stress {c['id']}  ({c['shape']} {c['stress']['axis']}="
              f"{c['stress']['level']}) ══")
        rec = run_case(c, bins, args.work, args.out)
        results.append(rec)
        tag = rec["status"]
        print(f"  {tag}  frames>={rec.get('min_frames')} "
              f"marteCPU={rec.get('marte_cpu_s', 0):.1f}s "
              f"marteRSS={rec.get('marte_rss_mb', 0):.0f}MB "
              f"hubRSS={rec.get('hub_rss_mb', 0):.0f}MB "
              f"zoom p50/p95={rec.get('zoom_p50_ms', 0):.0f}/"
              f"{rec.get('zoom_p95_ms', 0):.0f}ms")
        if rec.get("fails"):
            for fmsg in rec["fails"]:
                print(f"    - {fmsg}")

    overall = bool(results) and all(r["status"] == "PASS" for r in results)
    doc = {"overall": "PASS" if overall else "FAIL", "cases": results}
    rp = os.path.join(args.out, "stress_results.json")
    with open(rp, "w") as f:
        json.dump(doc, f, indent=2)
    npass = sum(r["status"] == "PASS" for r in results)
    print(f"\nstress_results.json: {npass}/{len(results)} pass → {doc['overall']}")
    sys.exit(0 if overall else 1)


if __name__ == "__main__":
    main()
