#!/usr/bin/env python3
"""Run a BestPerf measurement using the local C++ agent -- no SSH, no fixed lab.

Modes (FR_MODE):
  * loopback  (FR_LOOPBACK=1, or peer host is a loopback address):
      start a local responder, run the initiator against it, then build profile + scenario.
  * responder:
      run only a responder on FR_RESPONDER_PORT, writing agent_b_raw.ndjson, and exit when
      the session ends. A peer controller drives this so the initiating side can collect the
      log afterwards.
  * peer (FR_PEER_CTRL_URL set):
      ask the peer's controller to start a responder, run the local initiator, fetch the
      peer's raw log over HTTP, then build profile + scenario -- a full two-probe test with
      no SSH.
  * (unset, non-loopback fallback):
      run the initiator only and write agent_a_raw.ndjson; profile build is deferred.
"""
import hashlib
import json
import math
import os
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}


def env(name, default):
    value = os.environ.get(name)
    return value if value not in (None, "") else default


def env_int(name, default):
    try:
        return int(env(name, str(default)))
    except ValueError:
        return default


def env_float(name, default):
    try:
        return float(env(name, str(default)))
    except ValueError:
        return default


def csv_int_list(value):
    return [int(x.strip()) for x in value.split(",") if x.strip()]


def _binary_name_variants(name):
    """Product binaries renamed fr-* -> bperf-*. Accept either prefix (prefer bperf-) so a freshly
    built tree and an older deployed one both resolve."""
    for pre in ("fr-", "bperf-"):
        if name.startswith(pre):
            suffix = name[len(pre):]
            return [f"bperf-{suffix}", f"fr-{suffix}"]
    return [name]


def find_binary(name):
    names = []
    for v in _binary_name_variants(name):
        names += [v, f"{v}.exe"]
    dirs = [ROOT / "bin", ROOT / "build"] + [ROOT / "build" / c for c in ("Release", "RelWithDebInfo", "Debug")]
    for d in dirs:
        for n in names:  # within a dir, prefer bperf-* then fall back to legacy fr-*
            c = d / n
            if c.is_file():
                return c
    raise FileNotFoundError(f"{name} not found; run: cmake --build build")


def log(message):
    print(f"[bestperf-local] {message}", flush=True)


def _payload_sizes_with_warning():
    sizes = csv_int_list(env("FR_PAYLOAD_SIZES", "1200")) or [1200]
    if len(sizes) > 1:
        # Deliberate agent behavior (probe_scheduler): ONE packet size per test, so RTT/loss/jitter
        # stay attributable to a size — but accepting a list end-to-end and then using only the
        # first was SILENT. Say so, so the operator runs one test per size instead of assuming a sweep.
        log(f"warning: FR_PAYLOAD_SIZES lists {len(sizes)} sizes; the probe uses only the first "
            f"({sizes[0]}B) — run one test per size for a per-size comparison")
    return sizes


def build_session_config(out_dir):
    local_id = env_int("FR_LOCAL_AGENT_ID", 102)
    peer_id = env_int("FR_PEER_AGENT_ID", 100)
    return {
        "schema": "field_replay.session_config.v1",
        "session_name": env("FR_SESSION_NAME", f"agent{local_id}_to_agent{peer_id}"),
        "duration_sec": env_int("FR_DURATION_SEC", 6),
        "window_ms": env_int("FR_WINDOW_MS", 1000),
        "local_agent": {
            "id": local_id,
            "name": env("FR_LOCAL_AGENT_NAME", f"agent_{local_id}"),
            "listen_ip": env("FR_LISTEN_IP", "0.0.0.0"),
            "listen_port": env_int("FR_INITIATOR_PORT", 39701),
        },
        "peer_agent": {
            "id": peer_id,
            "name": env("FR_PEER_AGENT_NAME", f"agent_{peer_id}"),
            "host": env("FR_PEER_HOST", "127.0.0.1"),
            "port": env_int("FR_RESPONDER_PORT", 39700),
        },
        "probe": {
            "transport": "udp",
            "echo_interval_ms": env_int("FR_ECHO_INTERVAL_MS", 50),
            "stream_interval_ms": env_int("FR_STREAM_INTERVAL_MS", 50),
            "timeout_ms": env_int("FR_TIMEOUT_MS", 500),
            "payload_sizes_bytes": _payload_sizes_with_warning(),
        },
        "security": {"auth": "none", "psk_id": "", "psk": ""},
        "output": {
            "raw_log_path": str(out_dir / "agent_a_raw.ndjson"),
            "summary_path": str(out_dir / "agent_a_summary.json"),
        },
    }


def resolved_mode(config):
    mode = env("FR_MODE", "").strip().lower()
    if mode:
        return mode
    if env("FR_LOOPBACK", "0") in ("1", "true", "True"):
        return "loopback"
    if config["peer_agent"]["host"] in LOOPBACK_HOSTS:
        return "loopback"
    if env("FR_PEER_CTRL_URL", ""):
        return "peer"
    return "initiator_only"


def run_initiator(out_dir, config_path):
    agent = find_binary("fr-agent")
    with (out_dir / "initiator.out").open("w", encoding="utf-8") as out:
        proc = subprocess.run([str(agent), "initiator", "--config", str(config_path)],
                              cwd=str(ROOT), stdout=out, stderr=subprocess.STDOUT, text=True)
    return proc.returncode


def start_responder(out_dir, listen_ip, port, agent_id, agent_name):
    agent = find_binary("fr-agent")
    log_file = (out_dir / "responder.out").open("w", encoding="utf-8")
    try:
        proc = subprocess.Popen(
            [str(agent), "responder", "--listen", f"{listen_ip}:{port}",
             "--agent-id", str(agent_id), "--agent-name", agent_name,
             "--out", str(out_dir / "agent_b_raw.ndjson")],
            cwd=str(ROOT), stdout=log_file, stderr=subprocess.STDOUT, text=True,
        )
    except Exception:
        log_file.close()  # don't leak the fd if the agent binary is missing / spawn fails
        raise
    return proc, log_file


def build_profile_and_scenario(out_dir, config_path, window_ms):
    peer_log = out_dir / "agent_b_raw.ndjson"
    if not peer_log.is_file():
        log("peer log not present locally -- profile build skipped")
        return 0
    log("build profile")
    profile_path = out_dir / "network_profile.json"
    proc = subprocess.run(
        [str(find_binary("fr-profile-build")),
         "--agent-a-log", str(out_dir / "agent_a_raw.ndjson"),
         "--agent-b-log", str(peer_log),
         "--out", str(profile_path),
         "--window-ms", str(window_ms),
         "--session-config", str(config_path)],
        cwd=str(ROOT), text=True, capture_output=True,
    )
    if proc.returncode != 0 or not profile_path.is_file():
        log(f"error: profile build failed (rc={proc.returncode}): {(proc.stderr or proc.stdout).strip()}")
        return 4
    log("export scenario")
    scenario_path = out_dir / "wan_emulator_scenario.json"
    capabilities = env("FR_CAPABILITIES", str(ROOT / "examples" / "emulator_capabilities.example.json"))
    proc = subprocess.run(
        [str(find_binary("fr-scenario-export")),
         "--profile", str(profile_path), "--capabilities", capabilities,
         "--out", str(scenario_path)],
        cwd=str(ROOT), text=True, capture_output=True,
    )
    if proc.returncode != 0 or not scenario_path.is_file():
        log(f"error: scenario export failed (rc={proc.returncode}): {(proc.stderr or proc.stdout).strip()}")
        return 5
    # The profile built, but a light run can read all-zero when the peer never replied (dead /
    # contended responder, firewall). Flag that loudly into the profile so the GUI shows a banner
    # instead of presenting silent zeros as a clean network. Done before emit_network_model so the
    # bundled model carries the warning too.
    annotate_degenerate(out_dir, peer_log_present=True)
    emit_network_model(out_dir)  # light measurement becomes a library sample too
    cleanup_raw_samples(out_dir)
    log("done")
    return 0


# ---------- peer controller HTTP helpers ----------
# Controller-to-controller coordination always targets the LAN peer directly. Never route it
# through a system HTTP proxy (e.g. Clash/Surge on 127.0.0.1): the proxy can't reach a private
# LAN address and answers 502, which used to surface as a confusing "could not start peer
# responder" failure. This opener ignores HTTP(S)_PROXY/NO_PROXY entirely.
_DIRECT_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))


def _auth_headers(token, extra=None):
    h = dict(extra or {})
    tok = token or os.environ.get("FR_PEER_CTRL_TOKEN")  # peer controller's access token
    if tok:
        h["X-FR-Token"] = tok
    return h


def http_post_json(url, body, timeout=15, token=None):
    req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"),
                                 headers=_auth_headers(token, {"Content-Type": "application/json"}),
                                 method="POST")
    with _DIRECT_OPENER.open(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def http_get_json(url, timeout=15, token=None):
    req = urllib.request.Request(url, headers=_auth_headers(token))
    with _DIRECT_OPENER.open(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


# ---------- resumable, integrity-checked artifact collection ----------
def _sha256_file(path, _chunk=1048576):
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for blk in iter(lambda: fh.read(_chunk), b""):
            h.update(blk)
    return h.hexdigest()


def fetch_artifact_manifest(url, opener=None, timeout=15, token=None):
    """HEAD the artifact to learn its total size + integrity hash (one-time, before download)."""
    opener = opener or _DIRECT_OPENER
    req = urllib.request.Request(url, method="HEAD", headers=_auth_headers(token))
    with opener.open(req, timeout=timeout) as resp:
        total = int(resp.headers.get("Content-Length", "0"))
        sha = resp.headers.get("X-Content-SHA256")
    return total, sha


def resume_download(url, dest, total, expected_sha=None, opener=None, timeout=60, token=None):
    """Download `url` to `dest`, resuming from any existing partial via HTTP Range.

    Returns {complete, bytes_done, total, sha_ok}. NEVER deletes a partial on failure, so a
    later call resumes from disk. `complete` means all `total` bytes are present; `sha_ok` means
    the assembled file's sha256 matches `expected_sha` (the integrity gate). A transient network
    error leaves a resumable partial and returns complete=False.
    """
    opener = opener or _DIRECT_OPENER
    dest = Path(dest)
    dest.parent.mkdir(parents=True, exist_ok=True)
    have = dest.stat().st_size if dest.is_file() else 0
    if have > total:  # stale/corrupt longer-than-expected partial -> restart cleanly
        dest.unlink()
        have = 0
    if have < total:
        req = urllib.request.Request(url, headers=_auth_headers(token, {"Range": f"bytes={have}-"}))
        try:
            with opener.open(req, timeout=timeout) as resp, open(dest, "ab") as out:
                while True:
                    block = resp.read(262144)
                    if not block:
                        break
                    out.write(block)
                    have += len(block)
        except Exception as exc:  # noqa: BLE001  (network hiccup -> resumable partial remains)
            log(f"collect: download interrupted at {have}/{total} bytes: {exc}")
        have = dest.stat().st_size if dest.is_file() else 0
    complete = have >= total
    # An empty peer artifact (total == 0, e.g. a responder that received no packets) skips the
    # download branch entirely and never creates `dest`. Materialize the 0-byte file so the sha
    # gate can hash it (sha256 of empty == expected) instead of crashing on a missing path.
    if complete and not dest.is_file():
        dest.touch()
    # A peer that omits X-Content-SHA256 leaves expected_sha None; treat a fully-transferred file as
    # complete-but-UNVERIFIED rather than failing the sha gate forever (which wedged collection in
    # permanent "pending"). Log it loudly so the missing integrity check is visible.
    if complete and expected_sha is None:
        log(f"collect: {dest.name} fully transferred but peer sent no X-Content-SHA256 — accepting UNVERIFIED")
    sha_ok = bool(complete and (expected_sha is None or _sha256_file(dest) == expected_sha))
    # Complete-but-corrupt (full byte count, wrong hash): a stale same-size partial left by a reused
    # run_id, or a peer file that changed between HEAD and GET. A plain resume would see have==total
    # and SKIP the download forever -> permanent "pending" wedge with no self-heal. Discard the bad
    # file so the next attempt re-downloads from byte 0 (the caller also re-HEADs for a changed size).
    if complete and expected_sha is not None and not sha_ok:
        log(f"collect: {dest.name} transferred fully but failed integrity "
            f"({_sha256_file(dest)[:12]}… != {expected_sha[:12]}…); discarding for a clean re-fetch")
        try:
            dest.unlink()
        except OSError:
            pass
        have = 0  # report 0 done so the next resume restarts cleanly
    # Discard-path semantics are intentional: complete=True (all bytes DID arrive this attempt) +
    # sha_ok=False + bytes_done=0 (nothing reusable left on disk). The caller keys on (complete and
    # sha_ok) for success, and uses complete=True to clear the cached size/hash and re-HEAD next time.
    return {"complete": complete, "bytes_done": have, "total": total, "sha_ok": sha_ok}


COLLECT_TOKEN_FILE = "collect_token"  # sibling secret; deliberately NOT in ARTIFACT_NAMES


def write_collection_state(out_dir, **fields):
    """Persist the resumable-collection state so the GUI can show it and /collect can resume.

    The peer controller's access token is that peer machine's password and is NEVER written here:
    collection_state.json is a served artifact (in ARTIFACT_NAMES, fetched by the GUI and any
    authorized operator). The token is persisted separately via write_collect_token()."""
    fields.pop("peer_ctrl_token", None)  # defense-in-depth: never serialize a secret into a served file
    state = {"schema": "field_replay.collection_state.v1", "updated_at_unix": int(time.time())}
    state.update(fields)
    (Path(out_dir) / "collection_state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
    return state


def read_collection_state(out_dir):
    path = Path(out_dir) / "collection_state.json"
    if not path.is_file():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None


def write_collect_token(out_dir, token):
    """Persist the peer controller token next to (not inside) collection_state.json so a later
    /collect can resume, without ever exposing it through the served-artifact endpoint."""
    path = Path(out_dir) / COLLECT_TOKEN_FILE
    if not token:
        try:
            path.unlink()  # no token for this peer -> leave no stale secret behind
        except FileNotFoundError:
            pass
        return
    path.write_text(token, encoding="utf-8")
    try:
        path.chmod(0o600)  # best-effort owner-only on POSIX
    except OSError:
        pass


def read_collect_token(out_dir):
    path = Path(out_dir) / COLLECT_TOKEN_FILE
    try:
        token = path.read_text(encoding="utf-8").strip()
    except OSError:
        return None
    return token or None


def _write_collect_state(out_dir, status, ctrl, peer_run_id, kind, items, note="", token=None):
    done = sum(int(it.get("done", 0) or 0) for it in items)
    total = sum(int(it.get("total", 0) or 0) for it in items)
    write_collect_token(out_dir, token)  # secret -> sibling file, never into the served state
    write_collection_state(out_dir, status=status, peer_ctrl_url=ctrl, peer_run_id=peer_run_id,
                           kind=kind, items=items, bytes_done=done,
                           total_bytes=total, note=note,
                           artifact=(items[0]["remote"] if items else None))


def _finalize_collection(out_dir, kind):
    """Rebuild the deliverables once all peer artifacts are verified-complete."""
    out_dir = Path(out_dir)
    if kind == "load":
        fr_load = find_binary("fr-load")

        def rec(report, fwd, label):
            rp, fp = out_dir / report, out_dir / fwd
            if rp.is_file() and fp.is_file():
                r = subprocess.run([str(fr_load), "recompute", "--report", str(rp),
                                    "--forward-record", str(fp)],
                                   cwd=str(ROOT), text=True, capture_output=True)
                log(f"recompute {label}: {(r.stdout or r.stderr).strip()}")
        rec("load_report.json", "forward_record_peer.json", "A->B (forward truth from peer)")
        rec("load_report_peer.json", "forward_record.json", "B->A (forward truth from us)")
        emit_load_scenario(out_dir)
        log("done")
        return 0
    # light
    config_path = out_dir / "session_config.json"
    window_ms = 1000
    try:
        window_ms = int(json.loads(config_path.read_text(encoding="utf-8")).get("window_ms", 1000))
    except (OSError, json.JSONDecodeError, ValueError, AttributeError):
        pass
    return build_profile_and_scenario(out_dir, config_path, window_ms)


def _safe_join(base, name):
    """Join base/name but refuse to escape base. `dest` values are tool-written constants today, but
    they ride through collection_state.json on disk; an absolute or ``../`` dest would otherwise let a
    write land outside the run dir (pathlib silently honors both). Fail closed instead."""
    base_r = Path(base).resolve()
    target = (base_r / str(name)).resolve()
    if target != base_r and base_r not in target.parents:
        raise ValueError(f"unsafe artifact dest {name!r}")
    return target


def _collect_engine(out_dir, ctrl, peer_run_id, kind, items, token=None):
    """Resume + integrity-verify every peer artifact in `items`, then finalize. No fallback.

    Each item: {remote, dest, [total, sha, done]}. A single byte over HTTP Range with a sha256
    gate per artifact; if ANY is incomplete/mismatched the run stays `pending` (data preserved on
    both ends) for a later resume — never a round_trip downgrade. Works for any size (the load
    forward_record bitmap up to 256 MB transfers the same way the light log does). `token` is the
    peer controller's access token (persisted so /collect can resume later).
    """
    out_dir = Path(out_dir)
    _pin_peer(ctrl, peer_run_id, pin=True, token=token)
    all_ok, note = True, ""
    for it in items:
        # Fail closed on a tampered/unsafe dest or remote (defense-in-depth; server also allowlists
        # remote via ARTIFACT_NAMES). A bad item is marked pending, not allowed to escape out_dir.
        try:
            target = _safe_join(out_dir, it["dest"])
            remote = str(it["remote"])
            if "/" in remote or remote in ("", ".", ".."):
                raise ValueError(f"unsafe artifact remote {remote!r}")
        except (ValueError, KeyError) as exc:
            all_ok = False
            note = str(exc)
            it.setdefault("done", 0)
            continue
        url = f"{ctrl.rstrip('/')}/api/runs/{peer_run_id}/artifacts/{remote}"
        if not it.get("total") or not it.get("sha"):
            try:
                it["total"], it["sha"] = fetch_artifact_manifest(url, token=token)
            except Exception as exc:  # noqa: BLE001
                all_ok = False
                note = f"manifest unavailable ({it['remote']}): {exc}"
                it.setdefault("done", 0)
                continue
        _write_collect_state(out_dir, "collecting", ctrl, peer_run_id, kind, items, token=token)
        res = resume_download(url, target, it["total"], it["sha"], token=token)
        it["done"] = res["bytes_done"]
        if not (res["complete"] and res["sha_ok"]):
            all_ok = False
            if res["complete"]:  # complete-but-corrupt: drop the cached size/hash so the next
                it.pop("sha", None)   # resume re-HEADs (the peer's file may have changed) and
                it.pop("total", None)  # re-downloads the discarded file instead of wedging forever
            note = (f"integrity mismatch ({it['remote']})" if res["complete"]
                    else f"incomplete transfer ({it['remote']})")
    if all_ok:
        _write_collect_state(out_dir, "complete", ctrl, peer_run_id, kind, items, token=token)
        _pin_peer(ctrl, peer_run_id, pin=False, token=token)
        log(f"collected peer artifacts (verified): {', '.join(it['dest'] for it in items)}")
        return _finalize_collection(out_dir, kind)
    _write_collect_state(out_dir, "pending", ctrl, peer_run_id, kind, items, note, token=token)
    log(f"collect: incomplete ({note}); marked pending, NO fallback (resume later)")
    return 0


def collect_peer_artifact(out_dir, config_path, window_ms, ctrl, peer_run_id,
                          artifact="agent_b_raw.ndjson", token=None):
    """Light path: collect the peer's window-summary log, then rebuild the bidir profile."""
    return _collect_engine(out_dir, ctrl, peer_run_id, "light",
                           [{"remote": artifact, "dest": artifact}], token=token)


def collect_peer_load(out_dir, ctrl, peer_run_id, token=None, direction="both"):
    """Load path: collect the peer artifacts needed for this direction, then recompute forward-truth
    loss + export the load scenario. The item list is direction-aware so we never wait on a file the
    peer didn't produce:
      both   -> peer's B->A report + its A->B-truth forward_record (both sides sent)
      a_to_b -> only the peer's forward_record (peer responded only; OUR report is the A->B one)
      b_to_a -> only the peer's report (peer was the sender; OUR forward_record is the B->A truth)"""
    items = []
    if direction != "a_to_b":
        items.append({"remote": "load_report.json", "dest": "load_report_peer.json"})
    if direction != "b_to_a":
        items.append({"remote": "forward_record.json", "dest": "forward_record_peer.json"})
    return _collect_engine(out_dir, ctrl, peer_run_id, "load", items, token=token)


def _pin_peer(ctrl, peer_run_id, pin, token=None):
    """Best-effort: pin/unpin the peer's run dir so its data survives retention until collected."""
    action = "pin" if pin else "unpin"
    try:
        http_post_json(f"{ctrl.rstrip('/')}/api/runs/{peer_run_id}/{action}", {}, token=token)
    except Exception:  # noqa: BLE001
        pass


def mode_collect(out_dir):
    """Resume a deferred peer-artifact collection for an existing run dir (invoked by /collect)."""
    # The collector runs in a FRESH controller env: the original run's capture-context tags
    # (FR_NET_ACCESS/OPERATOR/LOCATION/NOTE, 接入类型/运营商/地点/备注) are not in it, so the model
    # finalized after a deferred collection silently lost its environment labels. Re-load the
    # original run's NON-SECRET env (controller_request.json is secret-stripped at rest) without
    # overriding anything explicitly set on this invocation.
    try:
        saved = json.loads((out_dir / "controller_request.json").read_text(encoding="utf-8")).get("env", {})
        for k, v in saved.items():
            if isinstance(k, str) and isinstance(v, str) and k.startswith("FR_"):
                os.environ.setdefault(k, v)
    except (OSError, json.JSONDecodeError, AttributeError):
        pass
    state = read_collection_state(out_dir)
    if not state:
        log("collect: no collection_state.json in run dir; nothing to do")
        return 0
    if state.get("status") == "complete":
        log("collect: already complete")
        return 0
    ctrl = state.get("peer_ctrl_url")
    peer_run_id = state.get("peer_run_id")
    if not ctrl or not peer_run_id:
        log("collect: state missing peer_ctrl_url/peer_run_id; cannot resume")
        return 0
    kind = state.get("kind", "light")
    # Token lives in the sibling secret file now; env override next; legacy in-state token last
    # (for runs created before the secret was split out).
    token = (read_collect_token(out_dir) or env("FR_PEER_CTRL_TOKEN", "")
             or state.get("peer_ctrl_token") or None)
    items = state.get("items")
    if not items:  # legacy single-artifact (light) state
        art = state.get("artifact", "agent_b_raw.ndjson")
        items = [{"remote": art, "dest": art, "total": state.get("total_bytes"),
                  "sha": state.get("sha256"), "done": state.get("bytes_done", 0)}]
    log(f"collect: resuming kind={kind} from {ctrl} run {peer_run_id} "
        f"({', '.join(it['remote'] for it in items)})")
    return _collect_engine(out_dir, ctrl, peer_run_id, kind, items, token=token)


# ---------- modes ----------
def mode_loopback(out_dir, config, config_path):
    proc, log_file = start_responder(out_dir, "127.0.0.1", config["peer_agent"]["port"],
                                     config["peer_agent"]["id"], config["peer_agent"]["name"])
    time.sleep(0.5)
    try:
        if proc.poll() is not None:
            log("error: local responder exited before the run started")
            return 3
        log("run initiator (loopback)")
        rc = run_initiator(out_dir, config_path)
        if rc != 0:
            log(f"error: initiator exited {rc}")
            return rc
    finally:
        time.sleep(0.3)
        terminate(proc)
        log_file.close()
    return build_profile_and_scenario(out_dir, config_path, config["window_ms"])


def mode_responder(out_dir):
    port = env_int("FR_RESPONDER_PORT", 39700)
    agent_id = env_int("FR_LOCAL_AGENT_ID", 100)
    name = env("FR_LOCAL_AGENT_NAME", f"agent_{agent_id}")
    duration = env_int("FR_DURATION_SEC", 6)
    log(f"responder on 0.0.0.0:{port} agent_id={agent_id} (waiting for initiator)")
    proc, log_file = start_responder(out_dir, env("FR_LISTEN_IP", "0.0.0.0"), port, agent_id, name)
    rc = 0
    try:
        proc.wait(timeout=duration + 15)
        # Exited on its own before the window. Clean stop (from the initiator) is 0; a nonzero
        # exit means it failed to start — almost always the port already in use.
        if proc.returncode:
            rc = proc.returncode
            log(f"error: responder exited {rc} (port {port} in use / bind failed?)")
    except subprocess.TimeoutExpired:
        log("responder reached time limit; stopping")
        terminate(proc)  # expected safety stop, not a failure
    finally:
        log_file.close()
    log("responder done")
    return rc


def mode_peer(out_dir, config, config_path):
    ctrl = env("FR_PEER_CTRL_URL", "").rstrip("/")
    if not ctrl:
        log("error: peer mode requires FR_PEER_CTRL_URL")
        return 6
    peer = config["peer_agent"]
    peer_run_id = (env("FR_RUN_ID", "peer") + "-responder")
    log(f"ask peer controller {ctrl} to start a responder on :{peer['port']}")
    try:
        http_post_json(ctrl + "/api/runs", {"dry_run": False, "env": {
            "FR_MODE": "responder",
            "FR_RUN_ID": peer_run_id,
            "FR_RESPONDER_PORT": str(peer["port"]),
            "FR_LOCAL_AGENT_ID": str(peer["id"]),
            "FR_LOCAL_AGENT_NAME": peer["name"],
            "FR_DURATION_SEC": str(config["duration_sec"]),
        }})
    except Exception as exc:  # noqa: BLE001
        log(f"error: could not start peer responder: {exc}")
        return 6
    time.sleep(2.0)  # let the remote responder bind before HELLO

    log(f"run initiator against {peer['host']}:{peer['port']}")
    rc = run_initiator(out_dir, config_path)
    if rc != 0:
        log(f"error: initiator exited {rc}")
        return rc

    # wait for the peer responder run to finish, then collect its raw log (resumable + verified)
    deadline = time.time() + config["duration_sec"] + 20
    while time.time() < deadline:
        try:
            run = http_get_json(f"{ctrl}/api/runs/{peer_run_id}")
            if run.get("status") not in ("running", None):
                break
        except Exception:  # noqa: BLE001
            pass
        time.sleep(0.6)
    return collect_peer_artifact(out_dir, config_path, config["window_ms"], ctrl, peer_run_id,
                                 token=env("FR_PEER_CTRL_TOKEN", "") or None)


def mode_initiator_only(out_dir, config, config_path):
    log(f"run initiator against {config['peer_agent']['host']}:{config['peer_agent']['port']}")
    rc = run_initiator(out_dir, config_path)
    if rc != 0:
        log(f"error: initiator exited {rc}")
        return rc
    log("initiator complete; peer log not collected (set FR_PEER_CTRL_URL for a full two-probe run)")
    return 0


def summarize_selfcheck(report):
    """Turn a loopback load_report into a compact device self-calibration.

    A loopback ramp has NO network, so its ceiling and latency are 100% this machine's own UDP
    send+receive cost. We surface: the UDP throughput ceiling, the best-case (floor) latency, the
    rate at which latency starts climbing (device queueing onset), a grade, and a plain verdict.
    Peer load tests can later compare their knee against this ceiling to tell "limited by the
    device" apart from "limited by the network".
    """
    ramp = report.get("ramp", []) or []
    ceiling = round(float(report.get("capacity_mbps", 0.0) or 0.0), 1)
    curve = [
        {
            "mbps": lv.get("offered_mbps", 0.0),
            "rtt_p50_ms": lv.get("rtt_p50_ms", 0.0),
            "loss_pct": lv.get("loss_pct", 0.0),
        }
        for lv in ramp
    ]
    rtts = [lv.get("rtt_p50_ms", 0.0) for lv in ramp if lv.get("rtt_p50_ms", 0.0) > 0]
    floor = round(min(rtts), 2) if rtts else 0.0
    # Onset = first offered rate whose RTT clearly departs the floor (device queueing begins).
    onset = None
    if rtts:
        threshold = max(floor * 2.0, floor + 2.0)
        for lv in ramp:
            if lv.get("rtt_p50_ms", 0.0) > threshold:
                onset = lv.get("offered_mbps")
                break

    if ceiling >= 800:
        grade = "strong"
        verdict = (f"本机 UDP 处理很强(约 {ceiling:.0f} Mbps),不会拖累测试;"
                   f"负载测试的拐点基本反映网络本身。")
    elif ceiling >= 200:
        grade = "moderate"
        verdict = (f"本机 UDP 处理中等(约 {ceiling:.0f} Mbps)。若某次负载测试的拐点接近该值,"
                   f"那条结果可能部分受本机性能限制,而非网络。")
    else:
        grade = "weak"
        onset_txt = f",约 {onset:g} Mbps 起延迟明显上升" if onset else ""
        verdict = (f"本机 UDP 处理较弱(约 {ceiling:.0f} Mbps{onset_txt})。"
                   f"若负载测试的拐点接近此值,说明结果受本机性能限制,而不是网络瓶颈。")

    return {
        "schema": "field_replay.selfcheck.v1",
        "udp_ceiling_mbps": ceiling,
        "rate_layer": "l1_wire",  # ceiling derives from load capacity_mbps -> L1 wire rate (incl frame overhead)
        "latency_floor_ms": floor,
        "latency_onset_mbps": onset,
        "grade": grade,
        "verdict": verdict,
        "rtt_curve": curve,
    }


def mode_load(out_dir):
    """Run a load==measurement session via fr-load (loopback for now), writing load_report.json."""
    fr_load = find_binary("fr-load")
    report = out_dir / "load_report.json"
    # Single source of truth for the flags: the hand-copied list here drifted (it was missing the
    # refine flags, so FR_LOAD_REFINE_ITERS/REFINE_DWELL_MS were silently ignored in this mode).
    cmd = [str(fr_load), "loopback", "--out", str(report)] + _load_cli_params()
    log(f"run load test (max {env('FR_LOAD_MAX_MBPS', '50')}Mbps, {env('FR_LOAD_DURATION_SEC', '5')}s)")
    proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    if not report.is_file():
        log(f"error: load test failed: {(proc.stderr or proc.stdout).strip()}")
        return 8
    emit_load_scenario(out_dir)
    log("done")
    return 0


def selfcheck_limit_note(capacity_mbps, local_ceiling, peer_ceiling, threshold=0.7):
    """Decide whether a load test's capacity is limited by an ENDPOINT DEVICE rather than the
    network. The weaker endpoint's UDP self-check ceiling is the bottleneck candidate; if the
    measured capacity reaches ~that ceiling, the result reflects the device, not the link.
    Returns None when neither endpoint is calibrated or there's no capacity to assess."""
    ceilings = {}
    if local_ceiling and local_ceiling > 0:
        ceilings["local"] = float(local_ceiling)
    if peer_ceiling and peer_ceiling > 0:
        ceilings["peer"] = float(peer_ceiling)
    if not ceilings or not capacity_mbps or capacity_mbps <= 0:
        return None
    who, ceil = min(ceilings.items(), key=lambda kv: kv[1])  # weakest endpoint = bottleneck candidate
    device_limited = capacity_mbps >= threshold * ceil
    side = "本机" if who == "local" else "对端"
    if device_limited:
        note = (f"负载拐点 {capacity_mbps:.0f} Mbps 接近{side}的 UDP 处理上限 {ceil:.0f} Mbps —— "
                f"该容量很可能受{side}设备性能限制,而不是网络瓶颈。")
    else:
        note = (f"负载拐点 {capacity_mbps:.0f} Mbps 明显低于两端设备的 UDP 处理上限,"
                f"结果反映网络本身。")
    return {
        "schema": "field_replay.load_limits.v1",
        "device_limited": device_limited,
        "limiting_endpoint": who if device_limited else None,
        "capacity_mbps": round(float(capacity_mbps), 1),
        "ceilings_mbps": {k: round(v, 1) for k, v in ceilings.items()},
        "threshold": threshold,
        "note": note,
    }


def _local_selfcheck_ceiling():
    """This machine's calibrated UDP ceiling, or None if it hasn't self-checked."""
    try:
        path = Path.home() / ".bestperf" / "selfcheck.json"
        if path.is_file():
            with path.open(encoding="utf-8") as fh:
                return json.load(fh).get("udp_ceiling_mbps")
    except Exception:
        pass
    return None


def _peer_selfcheck_ceiling():
    """The peer's calibrated UDP ceiling via its controller /api/identity (best-effort)."""
    url = env("FR_PEER_CTRL_URL", "").rstrip("/")
    if not url:
        return None
    try:
        data = http_get_json(f"{url}/api/identity", timeout=5, token=env("FR_PEER_CTRL_TOKEN", ""))
        sc = (data or {}).get("selfcheck") or {}
        return sc.get("udp_ceiling_mbps")
    except Exception:
        return None


def write_load_limits(out_dir):
    """Annotate the load report with a device-vs-network verdict, using both endpoints' self-check
    calibrations. Writes load_limits.json (best-effort; absent if neither side is calibrated)."""
    report = out_dir / "load_report.json"
    if not report.is_file():
        return
    try:
        with report.open(encoding="utf-8") as fh:
            cap = json.load(fh).get("capacity_mbps")
    except Exception:
        return
    note = selfcheck_limit_note(cap, _local_selfcheck_ceiling(), _peer_selfcheck_ceiling())
    if note is not None:
        (out_dir / "load_limits.json").write_text(
            json.dumps(note, ensure_ascii=False, indent=2), encoding="utf-8")


def mode_selfcheck(out_dir):
    """Local performance self-check: a loopback load ramp (NO network) to learn THIS machine's own
    UDP throughput ceiling + latency-under-load. Writes selfcheck.json and persists it as the
    machine's calibration so peer load tests aren't misread as network limits when the device is
    actually the bottleneck."""
    import platform
    import socket as _socket
    fr_load = find_binary("fr-load")
    report = out_dir / "load_report.json"
    live = out_dir / "load_progress.ndjson"
    cmd = [str(fr_load), "loopback", "--out", str(report), "--live", str(live),
           "--start-mbps", env("FR_SELFCHECK_START_MBPS", "5"),
           "--max-mbps", env("FR_SELFCHECK_MAX_MBPS", "2000"),
           "--duration", env("FR_SELFCHECK_DURATION_SEC", "3"),
           "--level-dwell-ms", env("FR_SELFCHECK_LEVEL_DWELL_MS", "600"),
           "--packet-bytes", env("FR_SELFCHECK_PACKET_BYTES", "1200")]
    log("run local performance self-check (loopback ramp, no network)")
    proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    if not report.is_file():
        log(f"error: self-check failed: {(proc.stderr or proc.stdout).strip()}")
        return 8
    try:
        with report.open() as f:
            rep = json.load(f)
    except Exception as e:
        log(f"error: self-check report unreadable: {e}")
        return 8
    sc = summarize_selfcheck(rep)
    sc["hostname"] = _socket.gethostname()
    sc["platform"] = platform.system()
    # Always UTF-8: the verdict is Chinese and readers (controller serve_artifact /
    # read_selfcheck_calibration) decode as UTF-8. On Windows write_text() would otherwise default
    # to GBK, producing bytes that fail UTF-8 decode (silent None / empty artifact).
    (out_dir / "selfcheck.json").write_text(json.dumps(sc, ensure_ascii=False, indent=2), encoding="utf-8")
    # Persist as this machine's calibration for later annotation (best-effort).
    try:
        cal_dir = Path.home() / ".bestperf"
        cal_dir.mkdir(parents=True, exist_ok=True)
        (cal_dir / "selfcheck.json").write_text(json.dumps(sc, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception as e:
        log(f"warn: could not persist calibration: {e}")
    log(f"self-check done: UDP ceiling ~{sc['udp_ceiling_mbps']:.0f} Mbps ({sc['grade']})")
    return 0


def _load_cli_params():
    params = ["--start-mbps", env("FR_LOAD_START_MBPS", "1"),
              "--max-mbps", env("FR_LOAD_MAX_MBPS", "50"),
              "--duration", env("FR_LOAD_DURATION_SEC", "5"),
              "--level-dwell-ms", str(effective_level_dwell_ms()),
              "--timeline-window-ms", env("FR_LOAD_TIMELINE_WINDOW_MS", "1000"),
              "--packet-bytes", env("FR_LOAD_PACKET_BYTES", "1200"),
              # Pin capacity-refinement so the responder's serve-budget (_load_serve_secs) matches
              # exactly what the sender runs -- the two must not drift (a mismatch made the responder
              # clock out mid-characterize -> phantom 100% working-point loss on capped paths).
              "--refine-iters", env("FR_LOAD_REFINE_ITERS", "4"),
              "--refine-dwell-ms", env("FR_LOAD_REFINE_DWELL_MS", "0")]
    if env("FR_LOAD_WORK_PERCENT", ""):
        params += ["--work-percent", env("FR_LOAD_WORK_PERCENT", "")]
    return params


# Role-specific keys must NOT be forwarded blindly: they are computed and set explicitly per role
# by each call site. A stale value inherited from the coordinator's own process env (e.g. the
# operator exported FR_LOAD_NO_INITIATE in this shell for a manual peer run earlier) would
# otherwise leak into a role that must not have it (a both-direction peer with NO_INITIATE never
# sends its return stream).
_LOAD_ENV_ROLE_KEYS = ("FR_LOAD_NO_INITIATE", "FR_LOAD_RESPONDER_SEC", "FR_LOAD_LIVE")


def _forward_load_env():
    """Every FR_LOAD_* knob the coordinator received, verbatim, for the peer's run env. The peer's
    sender/responder must run the SAME plan as ours — hand-maintained whitelists silently dropped
    newer knobs (WORK_PERCENT, DWELL_TARGET_MB, DWELL_CAP_MS, TIMELINE_WINDOW_MS), so the user's
    setting applied to one side only and the dwell budgets drifted (the phantom-loss class the
    serve budget exists to prevent). Role-specific computed values (RESPONDER_SEC etc.) are merged
    AFTER this dict, so they always win."""
    return {k: v for k, v in os.environ.items()
            if k.startswith("FR_LOAD_") and k not in _LOAD_ENV_ROLE_KEYS}


def effective_level_dwell_ms():
    """Adaptive per-level hold: auto-size it to deliver enough VOLUME to drain a generous token-bucket
    burst, so the steady-state tail the knee/capacity-refinement reasons about is POST-burst.

    A token-bucket shaper hands out an above-sustained burst at the start of each hold (the bucket
    draining); a hold shorter than that burst is masked clean and capacity over-reads (e.g. a 10M link
    read 20M on a 3s hold). The burst is bounded by the bucket (a few MB), so a hold that delivers a
    generous fixed VOLUME at the level's rate always outlasts it. The slowest level to reach that
    volume is the LOWEST ramp rate (FR_LOAD_START_MBPS), so size from it (every higher level then
    delivers the volume sooner -> also adequate). Never below the configured dwell; capped so a hold
    can't run away. Zero-config: small/bursty links auto-correct without the user tuning the dwell.
    """
    configured = env_int("FR_LOAD_LEVEL_DWELL_MS", 1000)
    start = max(1.0, env_float("FR_LOAD_START_MBPS", 1.0))
    target_mb = env_float("FR_LOAD_DWELL_TARGET_MB", 8.0)   # deliver ~this much at `start` -> drains a few-MB bucket + post-burst tail
    cap_ms = env_int("FR_LOAD_DWELL_CAP_MS", 12000)          # never extend a single level past this
    need_ms = int(target_mb * 8.0 * 1000.0 / start)          # ms to offer target_mb at `start` Mbps
    return max(configured, min(need_ms, cap_ms))


def _load_serve_secs():
    """Seconds a responder must stay up to cover the sender's WHOLE run, so the working-point
    characterize hold (the part the profile is built from) always has a live peer on the far end.

    sender run = ramp (≈log2 levels × dwell) + capacity refinement (refine_iters × refine_dwell)
                 + working-point characterize (duration) + drain margin.
    The refinement term used to be omitted here; on bandwidth-capped paths a knee is found so
    refinement runs (~16 s), the responder exited mid-characterize, and the still-sending
    working-point packets hit a dead peer -> recorded as loss ("phantom" 100% working-point loss).
    """
    start = max(1.0, env_float("FR_LOAD_START_MBPS", 1.0))
    # FR_LOAD_MAX_MBPS=0 is a DOCUMENTED C++ value ("ramp until knee", sane 10 Gbps ceiling,
    # load_plan.cpp) — max(start, 0) used to collapse the budget to ONE level and the responder
    # clocked out mid-ramp. Budget the worst case (the full ceiling) instead.
    mx_env = env_float("FR_LOAD_MAX_MBPS", 50.0)
    mx = max(start, mx_env) if mx_env > 0 else 10000.0
    # Mirror load_plan.cpp ramp_rates EXACTLY: geometric ×2 levels up to mx (capped at 32), PLUS
    # the appended ceiling level whenever the last geometric step fell short of mx. int(log2)+1
    # under-counted by that appended level for every non-power-of-two max/start — including the
    # DEFAULT 50/1 — shaving one dwell off the responder budget.
    levels, rate, last = 0, start, start
    while rate <= mx + 1e-9 and levels < 32:
        last = rate
        levels += 1
        rate *= 2.0
    if mx_env > 0 and last < mx - 1e-9:
        levels += 1
    levels = max(1, levels)
    dwell_ms_eff = effective_level_dwell_ms()  # adaptive: budget the (possibly auto-extended) hold
    dwell_s = dwell_ms_eff / 1000.0
    refine_iters = max(0, env_int("FR_LOAD_REFINE_ITERS", 4))
    refine_dwell_ms = env_int("FR_LOAD_REFINE_DWELL_MS", 0)
    # 0 = auto, matching load_session.cpp: max(level_dwell_ms, 4000)
    refine_dwell_s = (refine_dwell_ms if refine_dwell_ms > 0
                      else max(dwell_ms_eff, 4000)) / 1000.0
    ramp_s = levels * dwell_s
    refine_s = refine_iters * refine_dwell_s
    duration_s = env_int("FR_LOAD_DURATION_SEC", 5)
    # Working-point latency back-off (load_session.cpp) can RE-HOLD the working point many times:
    # up to 5 step-down re-holds + 4 bisection probes, EACH a full characterize hold. The responder
    # must stay up for ALL of them -- otherwise the late holds (incl. the final working point) hit a
    # dead peer -> 0 reflections -> rtt_p50=0 AND the back-off decides on empty data (the .106 0-sample
    # bug). Budget the worst case so the responder outlives the whole back-off.
    # step-down (steps<5) + bisection (it<4) + the dedicated FINAL kWorkingLevel re-hold
    # (load_session.cpp). The +1 for the final hold was missing, so a long-duration run that hit a
    # full back-off lost one whole characterize hold of responder budget (the phantom-loss class
    # this budget exists to prevent re-appears once duration exceeds the +15s tail margin).
    backoff_max_holds = 5 + 4 + 1
    backoff_s = backoff_max_holds * duration_s
    return int(ramp_s + refine_s + duration_s + backoff_s + 15)


def emit_load_scenario(out_dir):
    """Turn the load report(s) into a replayable WAN-emulator scenario (best-effort).
    Both-direction runs have load_report.json (A->B) + load_report_peer.json (B->A). A single
    b_to_a run only has the peer report (B->A); use it as the primary so we still export."""
    a = out_dir / "load_report.json"
    b = out_dir / "load_report_peer.json"
    report = a if a.is_file() else b
    if not report.is_file():
        return
    fr_load = find_binary("fr-load")
    cmd = [str(fr_load), "scenario", "--report", str(report),
           "--out", str(out_dir / "wan_emulator_scenario.json")]
    peer = b if (report == a and b.is_file()) else None
    if peer:
        cmd += ["--report-peer", str(peer)]
    caps = env("FR_EMULATOR_CAPS", "")
    if caps:
        cmd += ["--caps", caps]
    r = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    log(f"scenario: {(r.stdout or r.stderr).strip()}")

    # Also emit the emulator's NATIVE scenario.json (ready to import into the WAN emulator).
    dev = [str(fr_load), "device-scenario", "--report", str(report),
           "--out", str(out_dir / "device_scenario.json")]
    if peer:
        dev += ["--report-peer", str(peer)]
    rd = subprocess.run(dev, cwd=str(ROOT), text=True, capture_output=True)
    log(f"device-scenario: {(rd.stdout or rd.stderr).strip()}")

    emit_network_model(out_dir)
    write_load_limits(out_dir)  # device-vs-network verdict from both endpoints' self-checks


def tool_version():
    vf = ROOT / "VERSION"
    if vf.is_file():
        try:
            return vf.read_text().strip()
        except Exception:  # noqa: BLE001
            pass
    return env("FR_TOOL_VERSION", "0.1.0")


def _read_json(path):
    try:
        return json.loads(path.read_text())
    except Exception:  # noqa: BLE001
        return None


def _probe_saturation_warning(rtt, directions):
    """Detect the probe overwhelming a link slower than itself (the default light probe is ~1 Mbps
    per direction). The signature is substantial loss TOGETHER WITH a hugely inflated RTT tail:
    when the probe overflows a slow link the queue fills (latency balloons) THEN drops, so loss and
    a swollen tail appear together. Genuine random network loss instead keeps the queue from
    building, so its tail stays flat -- that is what tells the two apart. Conservative thresholds
    (>=8% loss AND p95 >= max(4*p50, p50+150ms)) avoid false-flagging slow-but-clean links (e.g.
    satellite: high p50, light loss) or lightly-lossy links. Returns a warning dict or None."""
    if not isinstance(rtt, dict):
        return None
    if (rtt.get("sample_count") or 0) < 8:
        return None  # too few round-trip samples for the tail percentile to mean anything
    p50 = rtt.get("p50") or 0.0
    p95 = rtt.get("p95") or 0.0
    max_loss = 0.0
    for d in directions or []:
        if isinstance(d, dict):
            lp = (d.get("loss") or {}).get("avg_loss_pct")
            if isinstance(lp, (int, float)):
                max_loss = max(max_loss, lp)
    tail_inflated = p95 >= max(4.0 * p50, p50 + 150.0)
    if max_loss >= 8.0 and tail_inflated:
        return {
            "code": "probe_may_saturate_link",
            "severity": "warning",
            "message": (f"探测流量可能已压满此链路:丢包伴随排队时延暴涨(RTT 尾部 p95≈{p95:.0f}ms"
                        f" 远高于 p50≈{p50:.0f}ms),测得的丢包可能含探测自压成分,不一定是链路本身"
                        "的丢包。建议降低探测频率 / 减小报文大小后重测(窄带链路尤其如此)。"),
        }
    return None


def light_run_warnings(profile, peer_log_present=True):
    """Warnings for a degenerate light run that produced no usable measurement.

    A light test can finish "successfully" yet measure nothing -- the peer responder never
    started, was killed or contended on its UDP port (a leftover bperf process), or a firewall
    dropped the probes. The initiator then records only timeouts and the profile reads all-zero.
    Rather than present silent zeros + "done" as a result (which looks like a clean, lossless
    network), surface a clear, actionable warning.

    Returns a list of {code, severity, message} dicts (empty when the run looks healthy)."""
    if not peer_log_present:
        return [{
            "code": "peer_log_missing",
            "severity": "error",
            "message": ("对端没有回传接收记录:对端 responder 很可能没启动或不可达"
                        "(检查对端控制台/残留进程,以及防火墙是否放行 UDP)。本次结果不可用。"),
        }]
    rtt = {}
    directions = []
    if isinstance(profile, dict):
        rtt = (profile.get("summary") or {}).get("combined_rtt_ms") or {}
        directions = profile.get("directions") or []
    sample_count = rtt.get("sample_count") or 0
    timeout_count = rtt.get("timeout_count") or 0
    if sample_count == 0:
        # NB: the streaming light profile carries no timeout_count, so only name it when present.
        detail = f"{timeout_count} 个探测全部超时。" if timeout_count else ""
        return [{
            "code": "no_replies",
            "severity": "error",
            "message": (f"没有收到任何回包:{detail}对端没收到、"
                        "或收到了但回不来 —— 通常是对端 responder 没启动 / 被残留进程占了 UDP 端口,"
                        "或防火墙挡了 UDP。请清理对端残留进程或检查防火墙后重测。本次统计无意义。"),
        }]
    # Per-direction: a stream direction that measured ZERO packets is DEAD even when the combined
    # (round-trip) RTT -- fed by the OTHER direction -- looks fine. The combined number masks the
    # half-dead run, so a one-direction-dead light test would otherwise be presented as clean.
    if isinstance(directions, list) and len(directions) >= 2:
        def _alive(d):
            return isinstance(d, dict) and ((d.get("loss") or {}).get("loss_samples") or 0) > 0
        dead = [d for d in directions if not _alive(d)]
        if dead and len(dead) < len(directions):  # some dead, some alive -> half-dead (combined hides it)
            names = ", ".join(str(d.get("direction")
                                  or f"{d.get('from_agent_id')}->{d.get('to_agent_id')}") for d in dead)
            return [{
                "code": "direction_dead",
                "severity": "error",
                "message": (f"有方向完全没有测到数据({names}):该方向一个包都没收到 —— 通常是该方向的"
                            " responder 没起来或防火墙单向挡了 UDP。综合 RTT 看着正常,是因为另一方向有数据"
                            "掩盖了它;该方向的丢包/抖动结果不可用。"),
            }]
    # Not degenerate (real bidirectional data). Last check: did the probe itself saturate a slow
    # link and manufacture the loss? (A narrow-link user must not be told their link is terrible
    # when the measurement traffic caused it.)
    sat = _probe_saturation_warning(rtt, directions)
    return [sat] if sat else []


def annotate_degenerate(out_dir, peer_log_present=True):
    """Inspect the built light profile; if the run was degenerate (no replies / no peer log),
    log it loudly and persist a `warnings` array into network_profile.json so the GUI surfaces a
    banner instead of silent zeros. Returns the warnings found (empty when healthy)."""
    prof_path = out_dir / "network_profile.json"
    profile = _read_json(prof_path) if prof_path.is_file() else None
    warnings = light_run_warnings(profile, peer_log_present=peer_log_present)
    if not warnings:
        return []
    for w in warnings:
        log("WARNING: " + w["message"])
    if isinstance(profile, dict):
        profile["warnings"] = warnings
        # A self-saturated probe makes the loss/RTT untrustworthy as a link reading -> drop the
        # confidence so the GUI presents it as low-confidence, matching the banner.
        if any(w.get("code") == "probe_may_saturate_link" for w in warnings):
            quality = profile.get("quality")
            if isinstance(quality, dict):
                quality["profile_confidence"] = "low"
        prof_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
    return warnings


def detect_access_type():
    """Best-effort: classify the local default-route interface as wifi / wired_lan / cellular.
    Used to auto-fill the capture tag when the user left it 'unknown'. 'unknown' on any failure."""
    import platform as _p
    sysname = _p.system()
    try:
        if sysname == "Darwin":
            out = subprocess.run(["route", "-n", "get", "default"], capture_output=True,
                                 text=True, timeout=3).stdout
            dev = next((ln.split(":")[1].strip() for ln in out.splitlines() if "interface:" in ln), "")
            if not dev:
                return "unknown"
            ports = subprocess.run(["networksetup", "-listallhardwareports"], capture_output=True,
                                   text=True, timeout=3).stdout
            cur = ""
            for ln in ports.splitlines():
                ln = ln.strip()
                if ln.startswith("Hardware Port:"):
                    cur = ln.split(":", 1)[1].strip().lower()
                elif ln.startswith("Device:") and ln.split(":", 1)[1].strip() == dev:
                    if "wi-fi" in cur or "airport" in cur:
                        return "wifi"
                    if "iphone" in cur or "usb" in cur:
                        return "cellular"
                    if any(k in cur for k in ("ethernet", "lan", "thunderbolt")):
                        return "wired_lan"
                    return "unknown"
            return "unknown"
        if sysname == "Linux":
            out = subprocess.run(["ip", "route", "show", "default"], capture_output=True,
                                 text=True, timeout=3).stdout.split()
            dev = out[out.index("dev") + 1] if "dev" in out else ""
            if not dev:
                return "unknown"
            if os.path.exists(f"/sys/class/net/{dev}/wireless") or dev.startswith("wl"):
                return "wifi"
            if dev.startswith(("ww", "wwan", "ppp")):
                return "cellular"
            if dev.startswith(("en", "eth")):
                return "wired_lan"
    except Exception:  # noqa: BLE001
        return "unknown"
    return "unknown"


def cleanup_raw_samples(out_dir):
    """Drop the bulky per-packet raw logs once the profile/model summarize them. They can be
    hundreds of MB on long runs and the summaries carry everything needed. Set FR_KEEP_RAW=1
    to keep them for debugging."""
    if env("FR_KEEP_RAW", "0") in ("1", "true", "True"):
        return
    removed = 0
    for name in ("agent_a_raw.ndjson", "agent_b_raw.ndjson"):
        p = out_dir / name
        try:
            if p.is_file():
                p.unlink()
                removed += 1
        except Exception:  # noqa: BLE001
            pass
    if removed:
        log(f"cleaned {removed} raw sample log(s) (FR_KEEP_RAW=1 to keep)")


def emit_network_model(out_dir):
    """Bundle a measurement + capture context into a classifiable, library-ready sample
    (field_replay.network_model.v1). Works for a load measurement (embeds the load report(s) as
    directions), a TCP felt-throughput measurement (embeds tcp), or a light measurement (embeds
    the network profile). The neutral 'network model' unit -- device scenarios render from it."""
    load = out_dir / "load_report.json"
    tcp = out_dir / "tcp_throughput.json"
    prof = out_dir / "network_profile.json"
    has_load, has_tcp, has_prof = load.is_file(), tcp.is_file(), prof.is_file()
    if not (has_load or has_tcp or has_prof):
        return
    kind = "load" if has_load else ("tcp" if has_tcp else "light")
    access = env("FR_NET_ACCESS", "unknown")
    auto_access = False
    if access in ("", "unknown"):  # user didn't pick one -> auto-detect the local link
        detected = detect_access_type()
        if detected != "unknown":
            access, auto_access = detected, True
    model = {
        "schema": "field_replay.network_model.v1",
        "model_id": env("FR_RUN_ID", "model"),
        "tool_version": tool_version(),
        "capture_context": {
            "access_type": access,  # wifi/4g/5g/satellite/enterprise_wan/... (auto when left unknown)
            "access_auto": auto_access,
            "operator": env("FR_NET_OPERATOR", ""),           # 运营商 / 链路
            "location": env("FR_NET_LOCATION", ""),           # 地点(粗粒度)
            "note": env("FR_NET_NOTE", ""),
            "test_kind": kind,
        },
    }
    if has_load:
        model["directions"] = {
            "a_to_b": _read_json(load),
            "b_to_a": _read_json(out_dir / "load_report_peer.json"),
        }
    if has_tcp:
        model["tcp"] = _read_json(tcp)
    if has_prof:
        model["profile"] = _read_json(prof)
    # Lift any degenerate-run warning (e.g. light run got no replies) to the top level so a
    # consumer reading the bundled model surfaces it without digging into the profile.
    embedded = model.get("profile") if isinstance(model.get("profile"), dict) else None
    if embedded and embedded.get("warnings"):
        model["warnings"] = embedded["warnings"]
    (out_dir / "network_model.json").write_text(json.dumps(model, indent=2), encoding="utf-8")
    log("network_model: wrote network_model.json")


def mode_load_bidir_peer(out_dir, config):
    """Peer side of a bidirectional load test: discover the initiator, load it back.
    Single-direction (a_to_b): FR_LOAD_NO_INITIATE=1 -> respond only (the coordinator is the sole
    sender); we just reflect + record forward truth, write only forward_record.json (no report)."""
    fr_load = find_binary("fr-load")
    report = out_dir / "load_report.json"
    forward = out_dir / "forward_record.json"  # our record of the peer->us working stream
    no_initiate = env("FR_LOAD_NO_INITIATE", "0") in ("1", "true", "True")
    cmd = [str(fr_load), "bidir-peer",
           "--listen-port", env("FR_RESPONDER_PORT", "39700"),
           "--peer-responder-port", env("FR_PEER_RESPONDER_PORT", "39710"),
           "--sender-port", env("FR_INITIATOR_PORT", "39711"),
           "--discover-timeout-ms", "15000",
           "--out", str(report),
           "--forward-out", str(forward)]
    # Always pass the serve budget: --respond-seconds is the respond-only WAIT and, in bidir
    # (initiate) mode, the responder FLOOR so we outlive a peer's longer working-point back-off
    # (else the peer's late back-off holds hit our dead responder -> 0 reflections).
    cmd += ["--respond-seconds", str(env_int("FR_LOAD_RESPONDER_SEC", 120))]
    if no_initiate:
        cmd += ["--no-initiate"]
    cmd += _load_cli_params()
    log("run bidir-peer (respond only)" if no_initiate else "run bidir-peer (discover initiator, load back)")
    proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    # respond-only writes only forward_record.json; the report comes from the coordinator (sender).
    if not no_initiate and not report.is_file():
        log(f"error: bidir-peer failed: {(proc.stderr or proc.stdout).strip()}")
        return 8
    if no_initiate and not forward.is_file():
        log(f"error: bidir-peer respond-only produced no forward record: {(proc.stderr or proc.stdout).strip()}")
        return 8
    log("done")
    return 0


def mode_load_bidir_sender(out_dir, config):
    """Peer side of a single-direction (b_to_a) load test: WE are the sole sender. The coordinator
    (the other machine) is respond-only and passed us its address via FR_PEER_HOST. Load it, write
    our report + forward_record locally for the coordinator to collect + recompute."""
    fr_load = find_binary("fr-load")
    target_host = env("FR_PEER_HOST", "").strip()
    if not target_host:
        log("error: load_bidir_sender requires FR_PEER_HOST (the coordinator's IP)")
        return 6
    target_port = env("FR_PEER_RESPONDER_PORT", "39710")  # coordinator's responder (we send here)
    report = out_dir / "load_report.json"
    forward = out_dir / "forward_record.json"
    cmd = [str(fr_load), "bidir",
           "--listen-port", env("FR_RESPONDER_PORT", "39700"),
           "--peer", f"{target_host}:{target_port}",
           "--sender-port", env("FR_INITIATOR_PORT", "39711"),
           "--direction-label", "B_TO_A",   # this stream is peer->coordinator = B->A
           "--out", str(report),
           "--forward-out", str(forward),
           "--live", str(out_dir / "load_progress.ndjson")] + _load_cli_params()
    log(f"run bidir sender (single-direction load to coordinator {target_host}:{target_port})")
    proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    if not report.is_file():
        log(f"error: bidir sender failed: {(proc.stderr or proc.stdout).strip()}")
        return 8
    log("done")
    return 0


def mode_load_bidir(out_dir, config):
    """Coordinator side: start the peer's bidir-peer endpoint, then load + reflect simultaneously."""
    ctrl = env("FR_PEER_CTRL_URL", "").rstrip("/")
    if not ctrl:
        log("error: load_bidir requires FR_PEER_CTRL_URL")
        return 6
    peer = config["peer_agent"]
    my_responder = env("FR_LOCAL_RESPONDER_PORT", "39710")
    peer_sender = env("FR_PEER_INITIATOR_PORT", "39711")
    responder_sec = _load_serve_secs()  # covers ramp + refinement + characterize (see helper)
    peer_run_id = env("FR_RUN_ID", "loadbidir") + "-bidirpeer"

    # Direction selector: both (default) loads A->B and B->A at once; a_to_b / b_to_a load only one
    # direction (the other side responds only), so the reverse stream doesn't contend.
    direction = (env("FR_DIRECTION", "both") or "both").strip().lower()
    if direction not in ("both", "a_to_b", "b_to_a"):
        # A typo'd direction silently became a BOTH-direction simultaneous load (2x the traffic,
        # results attributed to the wrong question) — refuse instead.
        log(f"error: invalid FR_DIRECTION '{direction}' (use both | a_to_b | b_to_a)")
        return 6
    if direction in ("a_to_b", "b_to_a"):
        fr_load_s = find_binary("fr-load")
        report_s = out_dir / "load_report.json"
        forward_s = out_dir / "forward_record.json"
        # Respond-only side must stay up for the sender's WHOLE run: ramp + capacity refinement +
        # working-point characterize (+ margin). _load_serve_secs() includes the refinement term --
        # omitting it made the responder exit mid-characterize on capped paths (phantom 100% loss).
        respond_secs = _load_serve_secs()
        peer_load_env = {
            **_forward_load_env(),
            "FR_RUN_ID": peer_run_id,
            "FR_LOAD_RESPONDER_SEC": str(respond_secs),
            "FR_LOAD_LIVE": "1",
        }

        def _wait_peer_then_collect():
            deadline = time.time() + respond_secs + 30
            while time.time() < deadline:
                try:
                    st = http_get_json(f"{ctrl}/api/runs/{peer_run_id}").get("status")
                except Exception:  # noqa: BLE001
                    st = None
                if st in ("exited", "failed"):
                    break
                time.sleep(0.5)
            return collect_peer_load(out_dir, ctrl, peer_run_id, token=env("FR_PEER_CTRL_TOKEN", "") or None, direction=direction)

        if direction == "a_to_b":
            # We send A->B; the peer responds only (records A->B forward truth).
            penv = dict(peer_load_env)
            penv.update({"FR_MODE": "load_bidir_peer", "FR_LOAD_NO_INITIATE": "1",
                         "FR_RESPONDER_PORT": str(peer["port"]),
                         "FR_PEER_RESPONDER_PORT": my_responder,
                         "FR_INITIATOR_PORT": peer_sender})
            log(f"a_to_b: ask peer {ctrl} to respond-only on :{peer['port']}")
            try:
                http_post_json(ctrl + "/api/runs", {"dry_run": False, "env": penv})
            except Exception as exc:  # noqa: BLE001
                log(f"error: could not start peer responder: {exc}"); return 6
            time.sleep(2.0)
            cmd = [str(fr_load_s), "bidir", "--listen-port", my_responder,
                   "--peer", f"{peer['host']}:{peer['port']}",
                   "--sender-port", str(config["local_agent"]["listen_port"]),
                   "--out", str(report_s), "--forward-out", str(forward_s),
                   "--live", str(out_dir / "load_progress.ndjson")] + _load_cli_params()
            log(f"a_to_b: run initiator against {peer['host']}:{peer['port']}")
            proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
            if not report_s.is_file():
                log(f"error: a_to_b load failed: {(proc.stderr or proc.stdout).strip()}"); return 8
            return _wait_peer_then_collect()

        # b_to_a: the peer sends to us; we respond only (record B->A forward truth).
        local_host = env("FR_LOCAL_HOST", "").strip()
        if not local_host:
            log("error: b_to_a load requires FR_LOCAL_HOST (this machine's LAN IP)"); return 6
        penv = dict(peer_load_env)
        penv.update({"FR_MODE": "load_bidir_sender",
                     "FR_PEER_HOST": local_host,                # peer targets us
                     "FR_PEER_RESPONDER_PORT": my_responder,    # our responder (peer sends here)
                     "FR_RESPONDER_PORT": str(peer["port"]),    # peer's own responder
                     "FR_INITIATOR_PORT": peer_sender})
        log(f"b_to_a: ask peer {ctrl} to send to us at {local_host}:{my_responder}")
        try:
            http_post_json(ctrl + "/api/runs", {"dry_run": False, "env": penv})
        except Exception as exc:  # noqa: BLE001
            log(f"error: could not start peer sender: {exc}"); return 6
        time.sleep(2.0)
        cmd = [str(fr_load_s), "bidir", "--no-initiate", "--respond-seconds", str(respond_secs),
               "--listen-port", my_responder,
               "--out", str(report_s), "--forward-out", str(forward_s)] + _load_cli_params()
        log(f"b_to_a: respond-only on :{my_responder} (peer {peer['host']} sends to us)")
        subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
        return _wait_peer_then_collect()

    log(f"ask peer controller {ctrl} to start a bidir-peer endpoint on :{peer['port']}")
    try:
        http_post_json(ctrl + "/api/runs", {"dry_run": False, "env": {
            **_forward_load_env(),
            "FR_MODE": "load_bidir_peer",
            "FR_RUN_ID": peer_run_id,
            "FR_RESPONDER_PORT": str(peer["port"]),       # peer listens here (we send here)
            "FR_PEER_RESPONDER_PORT": my_responder,        # peer sends back to our responder
            "FR_INITIATOR_PORT": peer_sender,
            "FR_LOAD_RESPONDER_SEC": str(responder_sec),
            "FR_LOAD_LIVE": "1",  # peer also streams per-second load_progress.ndjson for its live view
        }})
    except Exception as exc:  # noqa: BLE001
        log(f"error: could not start peer bidir endpoint: {exc}")
        return 6
    time.sleep(2.0)

    fr_load = find_binary("fr-load")
    report = out_dir / "load_report.json"
    forward = out_dir / "forward_record.json"  # our record of the peer->us (B->A) working stream
    cmd = [str(fr_load), "bidir",
           "--listen-port", my_responder,
           "--peer", f"{peer['host']}:{peer['port']}",
           "--sender-port", str(config["local_agent"]["listen_port"]),
           # Responder FLOOR: stay alive for the whole serve budget so the peer's (possibly longer)
           # working-point back-off keeps hitting a live responder instead of starving (0 reflections).
           "--respond-seconds", str(responder_sec),
           "--out", str(report),
           "--forward-out", str(forward),
           "--live", str(out_dir / "load_progress.ndjson")] + _load_cli_params()  # per-second live progress
    log(f"run bidir initiator against {peer['host']}:{peer['port']}")
    proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    if not report.is_file():
        log(f"error: bidir load failed: {(proc.stderr or proc.stdout).strip()}")
        return 8
    # The peer's bidir-peer run discovers us, then drains; it can finish slightly after we do.
    # Wait for it to exit before pulling its artifacts (else a 404 race loses forward truth).
    deadline = time.time() + 30
    while time.time() < deadline:
        try:
            st = http_get_json(f"{ctrl}/api/runs/{peer_run_id}").get("status")
        except Exception:  # noqa: BLE001
            st = None
        if st in ("exited", "failed"):
            break
        time.sleep(0.5)
    # Collect the peer's (B->A) report + its forward_record (the A->B-truth bitmap, potentially
    # large) over the RESUMABLE + integrity-checked link, then recompute forward truth + export.
    # If the transfer can't finish/verify now the run stays `pending` (no round_trip fallback)
    # and is resumable later via /collect.
    return collect_peer_load(out_dir, ctrl, peer_run_id, token=env("FR_PEER_CTRL_TOKEN", "") or None)




def mode_tcp_server(out_dir):
    """Peer side of a TCP felt-throughput test: run the server for a bounded window."""
    fr_tcp = find_binary("fr-tcp")
    port = env_int("FR_TCP_PORT", 39800)
    seconds = env_int("FR_TCP_SERVER_SEC", 120)
    log(f"tcp server on 0.0.0.0:{port} for up to {seconds}s")
    log_file = (out_dir / "tcp_server.out").open("w", encoding="utf-8")
    try:
        proc = subprocess.Popen([str(fr_tcp), "server", "--listen", f"0.0.0.0:{port}"],
                                cwd=str(ROOT), stdout=log_file, stderr=subprocess.STDOUT, text=True)
    except Exception:
        log_file.close()  # don't leak the fd if spawn fails
        raise
    rc = 0
    try:
        proc.wait(timeout=seconds)
        if proc.returncode:
            rc = proc.returncode
            log(f"error: tcp server exited {rc} (port {port} in use / bind failed?)")
    except subprocess.TimeoutExpired:
        terminate(proc)  # expected safety stop
    finally:
        log_file.close()
    log("tcp server done")
    return rc


def mode_tcp(out_dir, config):
    """Initiating side of a TCP felt-throughput test: start the peer's server, then probe."""
    ctrl = env("FR_PEER_CTRL_URL", "").rstrip("/")
    if not ctrl:
        log("error: tcp requires FR_PEER_CTRL_URL")
        return 6
    peer = config["peer_agent"]
    port = env("FR_TCP_PORT", "39800")
    server_sec = env_int("FR_TCP_DURATION_SEC", 10) * 2 + 60
    peer_run_id = env("FR_RUN_ID", "tcp") + "-tcpserver"
    log(f"ask peer controller {ctrl} to start a tcp server on :{port}")
    try:
        http_post_json(ctrl + "/api/runs", {"dry_run": False, "env": {
            "FR_MODE": "tcp_server",
            "FR_RUN_ID": peer_run_id,
            "FR_TCP_PORT": port,
            "FR_TCP_SERVER_SEC": str(server_sec),
        }})
    except Exception as exc:  # noqa: BLE001
        log(f"error: could not start peer tcp server: {exc}")
        return 6
    time.sleep(2.0)  # let the remote server bind

    fr_tcp = find_binary("fr-tcp")
    report = out_dir / "tcp_throughput.json"
    cmd = [str(fr_tcp), "client", "--peer", f"{peer['host']}:{port}", "--out", str(report),
           "--duration", env("FR_TCP_DURATION_SEC", "10"),
           "--rtt-rounds", env("FR_TCP_RTT_ROUNDS", "20"),
           # Stream per-second progress into the run dir so the console can show it live.
           # Off the blast/drain hot path (separate writer thread) -> no measurement perturbation.
           "--live", str(out_dir / "tcp_progress.ndjson")]
    log(f"run tcp client against {peer['host']}:{port}")
    proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True)
    if not report.is_file():
        log(f"error: tcp probe failed: {(proc.stderr or proc.stdout).strip()}")
        return 8
    emit_network_model(out_dir)
    log("done")
    return 0


def terminate(proc):
    proc.terminate()
    try:
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()


def main():
    out_dir = Path(env("FR_LOCAL_OUT_DIR", str(ROOT / "out" / f"local-{int(time.time())}"))).resolve()
    out_dir.mkdir(parents=True, exist_ok=True)

    # Resume a deferred collection BEFORE rebuilding session_config (which would clobber the
    # original measurement config this run dir already holds).
    if env("FR_MODE", "").strip().lower() == "collect":
        log(f"out_dir={out_dir} mode=collect")
        return mode_collect(out_dir)

    config = build_session_config(out_dir)
    config_path = out_dir / "session_config.json"
    config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
    mode = resolved_mode(config)
    log(f"out_dir={out_dir} mode={mode} duration_sec={config['duration_sec']}")

    if env("FR_DRY_RUN", "0") in ("1", "true", "True"):
        log("dry run: resolved configuration only")
        return 0

    if mode == "selfcheck":
        return mode_selfcheck(out_dir)
    if mode == "load":
        return mode_load(out_dir)
    if mode == "load_bidir":
        return mode_load_bidir(out_dir, config)
    if mode == "load_bidir_peer":
        return mode_load_bidir_peer(out_dir, config)
    if mode == "load_bidir_sender":
        return mode_load_bidir_sender(out_dir, config)
    if mode == "tcp":
        return mode_tcp(out_dir, config)
    if mode == "tcp_server":
        return mode_tcp_server(out_dir)
    if mode == "loopback":
        return mode_loopback(out_dir, config, config_path)
    if mode == "responder":
        return mode_responder(out_dir)
    if mode == "peer":
        return mode_peer(out_dir, config, config_path)
    if mode == "initiator_only":
        return mode_initiator_only(out_dir, config, config_path)
    if env("FR_MODE", "").strip():
        # An EXPLICIT but unrecognized FR_MODE (a typo like "load_bdir") used to fall through and
        # silently run a light initiator-only test — the user thinks their load/tcp test ran.
        log(f"error: unknown FR_MODE '{mode}' — refusing to silently run a light initiator-only test "
            "(valid: selfcheck load load_bidir load_bidir_peer load_bidir_sender tcp tcp_server "
            "loopback responder peer collect initiator_only)")
        return 6
    return mode_initiator_only(out_dir, config, config_path)


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