#!/usr/bin/env python3
import argparse
import hashlib
import hmac
import json
import mimetypes
import os
import platform
import re
import secrets
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
from dataclasses import dataclass, field
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional


ROOT = Path(__file__).resolve().parents[1]
GUI_DIR = ROOT / "gui"
SCRIPT = ROOT / "scripts" / "run_local_session.py"
BOOTSTRAP_REPORT_ENV = "FIELDREPLAY_BOOTSTRAP_REPORT"
INSTALL_REPORT_ENV = "FIELDREPLAY_INSTALL_REPORT"
RUN_ID_RE = re.compile(r"[^A-Za-z0-9_.-]+")
ENV_KEY_RE = re.compile(r"^FR_[A-Z0-9_]+$")
ARTIFACT_NAMES = {
    "network_profile.json",
    "wan_emulator_scenario.json",
    "device_scenario.json",
    "network_model.json",
    "load_report.json",
    "load_report_peer.json",
    "forward_record.json",
    "forward_record_peer.json",
    "tcp_throughput.json",
    "selfcheck.json",
    "load_limits.json",
    "session_config.json",
    "collection_state.json",
    "agent_a_raw.ndjson",
    "agent_b_raw.ndjson",
    "initiator.out",
    "responder.out",
}
SECRET_KEY_RE = re.compile(r"(password|token|secret|authorization|psk)", re.IGNORECASE)
SECRET_ASSIGNMENT_RE = re.compile(r"(?i)((?:[A-Z0-9_]*)(?:password|token|secret|authorization)(?:[A-Z0-9_]*)\s*[:=]\s*)([^\s,;]+)")
BEARER_RE = re.compile(r"(?i)(bearer)\s+[^\s,;]+")


def strip_secret_keys(value):
    """Deep-copy a JSON value with secret-looking KEYS (password/token/secret/authorization/psk)
    removed, so a served or displayed artifact can never leak a credential — e.g. a legacy
    collection_state.json that still embeds peer_ctrl_token from before the token was split out.
    (Distinct from redact_secrets() below, which masks specific secret VALUES.)"""
    if isinstance(value, dict):
        return {k: strip_secret_keys(v) for k, v in value.items() if not SECRET_KEY_RE.search(k)}
    if isinstance(value, list):
        return [strip_secret_keys(item) for item in value]
    return value
SECRET_WORD_RE = re.compile(r"(?i)\b(password|token|secret|authorization|psk)\s+([^\s,;]+)")


def local_binary_candidates(name: str) -> list[Path]:
    # Product binaries renamed fr-* -> bperf-*; accept either prefix (prefer bperf-).
    variants = [name]
    for pre in ("fr-", "bperf-"):
        if name.startswith(pre):
            suffix = name[len(pre):]
            variants = [f"bperf-{suffix}", f"fr-{suffix}"]
            break
    filenames = []
    for v in variants:
        filenames += [v, f"{v}.exe"]
    candidates = []
    for filename in filenames:
        candidates.append(ROOT / "bin" / filename)
    for filename in filenames:
        candidates.append(ROOT / "build" / filename)
    for config in ["Release", "RelWithDebInfo", "Debug"]:
        for filename in filenames:
            candidates.append(ROOT / "build" / config / filename)
    return candidates


def find_local_agent_binary() -> Path:
    for candidate in local_binary_candidates("bperf-agent"):
        if candidate.is_file():
            return candidate
    return ROOT / "build" / "bperf-agent"


def runtime_platform() -> dict:
    return {
        "system": platform.system(),
        "release": platform.release(),
        "machine": platform.machine(),
    }


def read_release_metadata(root: Optional[Path] = None) -> dict:
    root = root or ROOT
    manifest_path = root / "release_manifest.json"
    runtime = runtime_platform()
    base = {
        "available": False,
        "manifest_path": str(manifest_path),
        "runtime_platform": runtime,
    }
    if not manifest_path.is_file():
        return base
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return {**base, "error": str(exc)}
    if not isinstance(manifest, dict):
        return {**base, "error": "release_manifest.json is not a JSON object"}
    package_platform = manifest.get("platform") if isinstance(manifest.get("platform"), dict) else {}
    system_match = not package_platform.get("system") or package_platform.get("system") == runtime["system"]
    machine_match = not package_platform.get("machine") or package_platform.get("machine") == runtime["machine"]
    bundled = manifest.get("bundled_binaries") if isinstance(manifest.get("bundled_binaries"), list) else []
    return {
        **base,
        "available": True,
        "schema": str(manifest.get("schema") or ""),
        "package_id": str(manifest.get("package_id") or ""),
        "artifact_name": str(manifest.get("artifact_name") or ""),
        "name": str(manifest.get("name") or ""),
        "version": str(manifest.get("version") or ""),
        "platform_label": str(manifest.get("platform_label") or ""),
        "package_platform": package_platform,
        "platform_match": bool(system_match and machine_match),
        "bundled_binary_count": len(bundled),
    }


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1048576), b""):
            digest.update(chunk)
    return digest.hexdigest()


def parse_byte_range(header: str, size: int):
    """Parse a single 'bytes=START-END' range against a file of `size` bytes.

    Returns an inclusive (start, end) tuple clamped to the file, or None if unsatisfiable
    (start beyond EOF / malformed). Only the first range of a set is honoured.
    """
    header = (header or "").strip()
    if not header.lower().startswith("bytes="):
        return None
    spec = header[len("bytes="):].split(",")[0].strip()
    if "-" not in spec:
        return None
    lo, hi = spec.split("-", 1)
    lo, hi = lo.strip(), hi.strip()
    try:
        if lo == "":  # suffix range: bytes=-N (last N bytes)
            n = int(hi)
            if n <= 0:
                return None
            start = max(0, size - n)
            return (start, size - 1) if size > 0 else None
        start = int(lo)
        end = int(hi) if hi != "" else size - 1
    except ValueError:
        return None
    if start < 0 or start >= size or end < start:
        return None
    return (start, min(end, size - 1))


def _live_sample_from_record(rec: dict) -> Optional[dict]:
    """Project a raw NDJSON record into a compact live-series point, or None to skip.

    Only records that carry live-meaningful series data are kept (per-window light/load
    summaries and per-second TCP progress). Everything else (per-packet probes, stream
    summaries, burst events) is ignored so the live series stays small and uniform.
    """
    if not isinstance(rec, dict):
        return None
    rtype = rec.get("type")
    if rtype == "tcp_progress" or ("up_mbps" in rec and "down_mbps" in rec):
        return {
            "kind": "tcp",
            "t_ms": int(float(rec.get("t_s", 0)) * 1000) if "t_s" in rec else int(rec.get("t_ms", 0)),
            "up_mbps": float(rec.get("up_mbps", 0.0)),
            "down_mbps": float(rec.get("down_mbps", 0.0)),
            "rtt_ms": float(rec.get("rtt_ms", 0.0)),
        }
    if rtype == "load_progress":
        return {
            "kind": "load",
            "t_ms": int(float(rec.get("t_s", 0)) * 1000),
            "sent_mbps": float(rec.get("sent_mbps", 0.0)),
            "recv_mbps": float(rec.get("recv_mbps", 0.0)),
            "sent_pps": float(rec.get("sent_pps", 0.0)),
            "recv_pps": float(rec.get("recv_pps", 0.0)),
            "loss_pct": float(rec.get("loss_pct", 0.0)),
        }
    if rec.get("sample_type") == "window_summary":
        return {
            "kind": "window",
            "from_agent_id": rec.get("from_agent_id"),
            "to_agent_id": rec.get("to_agent_id"),
            "t_ms": int(rec.get("t_ms", 0)),
            "duration_ms": int(rec.get("duration_ms", 1000)),  # so the GUI's first-window pps uses the real window length
            "loss_pct": float(rec.get("loss_pct", 0.0)),
            "rtt_ms_p50": float(rec.get("rtt_p50_ms", rec.get("rtt_ms_p50", 0.0))),  # v2 key, v1 fallback
            "rtt_ms_p95": float(rec.get("rtt_p95_ms", rec.get("rtt_ms_p95", 0.0))),
            "jitter_ms_p95": float(rec.get("jitter_p95_ms", rec.get("jitter_ms_p95", 0.0))),
            "received": int(rec.get("received", 0)),
            "expected": int(rec.get("expected", 0)),
        }
    return None


class LiveTail:
    """Incrementally tails in-progress NDJSON sample files for one run.

    Keeps a per-file byte offset so each poll reads only newly-appended bytes
    (O(new), never O(filesize)), tolerates a partial trailing line, and bounds
    the accumulated series to the most-recent ``max_points``.
    """

    def __init__(self, max_points: int = 1200) -> None:
        self.max_points = max_points
        self.offsets: dict[str, int] = {}
        self.series: list[dict] = []
        self._lock = threading.Lock()  # /live runs on a ThreadingHTTPServer: serialize ingest+read

    def snapshot(self, paths) -> list[dict]:
        """Ingest the given files and return a copy of the series, all under one lock -- otherwise two
        concurrent /live polls interleave offset/series mutations and corrupt both."""
        with self._lock:
            for p in paths:
                self.ingest_file(p)
            return list(self.series)

    def ingest_file(self, path: Path) -> int:
        key = str(path)
        try:
            size = path.stat().st_size
        except OSError:
            return 0
        offset = self.offsets.get(key, 0)
        if size <= offset:
            # File unchanged, or was truncated/rotated -> reset to read from start next time.
            if size < offset:
                self.offsets[key] = 0
            return 0
        try:
            with path.open("rb") as fh:
                fh.seek(offset)
                chunk = fh.read(size - offset)
        except OSError:
            return 0
        last_nl = chunk.rfind(b"\n")
        if last_nl < 0:
            # No complete line yet; leave offset so the partial line is re-read later.
            return 0
        consumed = chunk[: last_nl + 1]
        self.offsets[key] = offset + len(consumed)
        added = 0
        for line in consumed.split(b"\n"):
            if not line.strip():
                continue
            try:
                rec = json.loads(line.decode("utf-8"))
            except (ValueError, UnicodeDecodeError):
                continue
            sample = _live_sample_from_record(rec)
            if sample is None:
                continue
            self.series.append(sample)
            added += 1
        if len(self.series) > self.max_points:
            self.series = self.series[-self.max_points :]
        return added


LIVE_SAMPLE_FILES = ("agent_a_raw.ndjson", "agent_b_raw.ndjson", "tcp_progress.ndjson", "load_progress.ndjson")


def build_live_payload(run: "RunState", tail: LiveTail) -> dict:
    """Tail a run's in-progress NDJSON and return a compact live snapshot.

    Reuses the existing per-window (light/load) and per-second (tcp) writes — the
    controller only reads, so enabling the live view never perturbs the measurement.
    """
    series = tail.snapshot([run.artifact_dir / name for name in LIVE_SAMPLE_FILES])
    has_tcp = any(s.get("kind") == "tcp" for s in series)
    has_load = any(s.get("kind") == "load" for s in series)
    test_kind = "tcp" if has_tcp else "load" if has_load else ("stream" if series else "unknown")
    current = series[-1] if series else None
    end_ns = run.ended_at_unix_ns or time.time_ns()
    elapsed_sec = max(0.0, (end_ns - run.started_at_unix_ns) / 1e9)
    return {
        "run_id": run.run_id,
        "status": run.status,
        "test_kind": test_kind,
        "current": current,
        "series": series,
        "started_at_unix_ns": run.started_at_unix_ns,
        "elapsed_sec": round(elapsed_sec, 1),
        "duration_sec": _planned_duration_sec(run.artifact_dir),  # planned total, or None (no fixed duration)
        "point_count": len(series),
    }


def _planned_duration_sec(artifact_dir: Path):
    """The run's planned duration (sec) from the env it was launched with, or None if unbounded."""
    try:
        env = json.loads((artifact_dir / "controller_request.json").read_text(encoding="utf-8")).get("env", {})
    except (OSError, json.JSONDecodeError, AttributeError):
        return None
    for key in ("FR_LOAD_DURATION_SEC", "FR_TCP_DURATION_SEC", "FR_DURATION_SEC"):
        if env.get(key):
            try:
                return int(env[key])
            except (ValueError, TypeError):
                pass
    return None


@dataclass
class RunState:
    run_id: str
    artifact_dir: Path
    log_path: Path
    dry_run: bool
    status: str = "running"
    returncode: Optional[int] = None
    started_at_unix_ns: int = field(default_factory=time.time_ns)
    ended_at_unix_ns: Optional[int] = None
    process: Optional[subprocess.Popen] = None
    pgid: Optional[int] = None  # process-group id captured at spawn (POSIX): signal THIS, not a pgid
    #                             re-derived from a possibly-reaped+reused pid

    def to_json(self) -> dict:
        return {
            "run_id": self.run_id,
            "status": self.status,
            "returncode": self.returncode,
            "dry_run": self.dry_run,
            "artifact_dir": str(self.artifact_dir),
            "log_path": str(self.log_path),
            "started_at_unix_ns": self.started_at_unix_ns,
            "ended_at_unix_ns": self.ended_at_unix_ns,
            "log_tail": tail_text(self.log_path),
            "artifacts": list_artifacts(self.artifact_dir),
            "model_tags": read_model_tags(self.artifact_dir),
        }

    def to_summary_json(self) -> dict:
        return {
            "run_id": self.run_id,
            "status": self.status,
            "returncode": self.returncode,
            "dry_run": self.dry_run,
            "artifact_dir": str(self.artifact_dir),
            "started_at_unix_ns": self.started_at_unix_ns,
            "ended_at_unix_ns": self.ended_at_unix_ns,
            "artifacts": list_artifacts(self.artifact_dir),
            "model_tags": read_model_tags(self.artifact_dir),
        }


_DISCOVER_TTL_SEC = 2.0  # throttle full base-dir rescans (avoid a stat storm on every run-scoped request)


class ControllerState:
    def __init__(self) -> None:
        self.lock = threading.RLock()
        self.runs: dict[str, RunState] = {}
        self.tailers: dict[str, LiveTail] = {}
        self.collects: dict[str, subprocess.Popen] = {}  # run_id -> in-flight /collect process
        self._last_discover = float("-inf")  # monotonic ts of last rescan; -inf => the first call always scans

    def live_tail(self, run_id: str) -> LiveTail:
        with self.lock:
            tail = self.tailers.get(run_id)
            if tail is None:
                tail = LiveTail()
                self.tailers[run_id] = tail
            return tail

    def active_run_id(self) -> Optional[str]:
        with self.lock:
            for run_id, run in self.runs.items():
                if run.status == "running":
                    return run_id
        return None

    def discover_archived_runs(self, *, force: bool = False) -> None:
        # Throttle: this does a full base-dir iterate + per-artifact stat (archived_run_from_dir).
        # Running it on EVERY run-scoped request was a stat storm; a short TTL keeps it cheap while
        # still picking up externally-dropped run dirs within a couple of seconds.
        now = time.monotonic()
        if not force and (now - self._last_discover) < _DISCOVER_TTL_SEC:
            return
        self._last_discover = now
        archived = discover_archived_runs()
        with self.lock:
            for run in archived:
                existing = self.runs.get(run.run_id)
                if existing is not None and existing.status != "archived":
                    continue
                self.runs[run.run_id] = run


class BestPerfHandler(SimpleHTTPRequestHandler):
    server_version = "BestPerfController/0.1"

    def end_headers(self) -> None:
        # NO wildcard CORS. A wildcard Access-Control-Allow-Origin let any website the operator
        # visited read /api/identity cross-origin and steal the access token (-> full API takeover).
        # Echo the Origin ONLY when it is one of THIS controller's own origins (i.e. same-origin);
        # a cross-site Origin gets no CORS headers, so the browser blocks the read.
        origin = self._allowed_cors_origin()
        if origin is not None:
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Vary", "Origin")
            self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
            self.send_header("Access-Control-Allow-Headers", "Content-Type, X-FR-Token")
        super().end_headers()

    def _host_is_ours(self, host: str) -> bool:
        """True if `host` names this controller's own machine (loopback / localhost / one of our IPs).
        Used to gate CORS (same-origin only) and the Host header (anti-DNS-rebinding)."""
        host = (host or "").strip().lower()
        if host.startswith("[") and host.endswith("]"):
            host = host[1:-1]
        if host.startswith("::ffff:"):
            host = host[7:]
        if not host:
            return False
        if host in ("127.0.0.1", "localhost", "::1"):
            return True
        return host in getattr(self.server, "local_ips", frozenset())

    def _allowed_cors_origin(self):
        """The exact Origin to echo back, or None. Only same-origin (one of our own hosts) qualifies;
        a foreign Origin returns None so no CORS header is emitted and the browser can't read us."""
        origin = self.headers.get("Origin")
        if not origin:
            return None  # non-browser or same-origin simple request: no CORS needed
        try:
            host = urllib.parse.urlsplit(origin).hostname or ""
        except ValueError:
            return None
        return origin if self._host_is_ours(host) else None

    def host_header_ok(self) -> bool:
        """Reject a Host header naming a domain we don't own. A DNS-rebinding attack makes the
        attacker's page resolve to 127.0.0.1 (so it is 'same-origin' and CORS no longer protects us),
        but the browser still sends the attacker's domain as Host -- which we refuse here."""
        raw = self.headers.get("Host", "")
        if not raw:
            return True  # HTTP/1.0 client without Host: not a browser rebinding vector
        if raw.startswith("["):  # bracketed IPv6 literal, optionally :port
            host = raw[1:raw.find("]")] if "]" in raw else raw
        else:
            host = raw.rsplit(":", 1)[0] if raw.count(":") == 1 else raw  # strip :port (bare IPv6 has many ':')
        return self._host_is_ours(host)

    def _reject_bad_host(self) -> None:
        self.write_json({"error": "bad_host",
                         "hint": "Host header not recognized (rejected to prevent DNS-rebinding)"},
                        HTTPStatus.FORBIDDEN)

    def do_OPTIONS(self) -> None:
        if not self.host_header_ok():
            self._reject_bad_host()
            return
        self.send_response(HTTPStatus.NO_CONTENT)
        self.end_headers()

    def lan_permitted(self) -> bool:
        """Refuse non-loopback callers unless the operator enabled LAN access (the GUI toggle).
        The socket binds 0.0.0.0 so the toggle is instant, but until it's on every remote request
        is rejected here (before auth) — equivalent to loopback-only, plus the Windows firewall."""
        if lan_access_ok(self.client_is_local(), getattr(self.server, "lan_allowed", False)):
            return True
        self.write_json({"error": "lan_disabled",
                         "hint": "对端无法连接:请在本机控制台打开『允许被别人测向本机』开关"},
                        HTTPStatus.FORBIDDEN)
        return False

    def _server_error(self, exc: BaseException) -> None:
        """Reply 500 for an unexpected handler error (e.g. a malformed/truncated .json artifact)
        instead of letting the exception drop the connection with no response."""
        sys.stderr.write(f"[controller] request error on {self.command} {self.path}: {exc!r}\n")
        try:
            self.write_json({"error": "internal_error"}, HTTPStatus.INTERNAL_SERVER_ERROR)
        except Exception:
            pass  # response already partially written; nothing more we can safely send

    def do_GET(self) -> None:
        if not self.host_header_ok():
            self._reject_bad_host()
            return
        if not self.lan_permitted():
            return
        try:
            if self.path.startswith("/api/"):
                self.handle_api_get()
                return
            self.serve_static()
        except Exception as exc:  # never drop the connection silently on a bad artifact
            self._server_error(exc)

    def do_HEAD(self) -> None:
        # HEAD is supported for artifact endpoints so a collector can fetch size +
        # X-Content-SHA256 once before a resumable ranged download.
        if not self.host_header_ok():
            self.send_response(HTTPStatus.FORBIDDEN)
            self.end_headers()
            return
        if not lan_access_ok(self.client_is_local(), getattr(self.server, "lan_allowed", False)):
            self.send_response(HTTPStatus.FORBIDDEN)
            self.end_headers()
            return
        if not self.require_auth():
            self.send_response(HTTPStatus.UNAUTHORIZED)
            self.end_headers()
            return
        parsed = urllib.parse.urlparse(self.path)
        parts = [urllib.parse.unquote(p) for p in parsed.path.split("/") if p]
        if len(parts) == 5 and parts[:2] == ["api", "runs"] and parts[3] == "artifacts":
            run = self.get_run(parts[2])
            if run is None:
                self.send_response(HTTPStatus.NOT_FOUND)
                self.end_headers()
                return
            self.serve_artifact(run, parts[4], head=True)
            return
        self.send_response(HTTPStatus.METHOD_NOT_ALLOWED)
        self.end_headers()

    def do_POST(self) -> None:
        if not self.host_header_ok():
            self._reject_bad_host()
            return
        if not self.lan_permitted():
            return
        if not self.require_auth():  # all mutating endpoints require the token
            self.send_unauthorized()
            return
        try:
            if self.path == "/api/expose":
                self.handle_expose()
                return
            if self.path == "/api/runs":
                self.handle_start_run()
                return
            # GUI 暂不调用:供 API / 测试 / 手动把磁盘上已有的 run 目录注册进控制器(非死代码)。
            if self.path == "/api/runs/import":
                self.handle_import_run()
                return
            parsed = urllib.parse.urlparse(self.path)
            parts = [urllib.parse.unquote(p) for p in parsed.path.split("/") if p]
            if len(parts) == 4 and parts[:2] == ["api", "runs"] and parts[3] in ("collect", "pin", "unpin", "stop"):
                self.handle_run_action(parts[2], parts[3])
                return
            if len(parts) == 5 and parts[:2] == ["api", "runs"] and parts[3] == "collect" and parts[4] == "pause":
                self.handle_collect_pause(parts[2])
                return
            self.write_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
        except Exception as exc:  # never drop the connection silently
            self._server_error(exc)

    def do_DELETE(self) -> None:
        if not self.host_header_ok():
            self._reject_bad_host()
            return
        if not self.lan_permitted():
            return
        if not self.require_auth():
            self.send_unauthorized()
            return
        parsed = urllib.parse.urlparse(self.path)
        parts = [urllib.parse.unquote(p) for p in parsed.path.split("/") if p]
        if len(parts) == 3 and parts[:2] == ["api", "runs"]:
            self.handle_delete_run(parts[2])
            return
        self.write_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)

    def handle_delete_run(self, run_id: str) -> None:
        run = self.get_run(run_id)
        if run is None:
            self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
            return
        if run.status == "running":
            self.write_json({"error": "run_active", "run_id": run_id}, HTTPStatus.CONFLICT)
            return
        target = run.artifact_dir.resolve()
        # Safety: only ever remove a controller-owned run dir — it must be a DIRECT CHILD of the
        # artifact base AND named bestperf-*. (A run can be registered via /import with an
        # arbitrary artifact_dir; the parent-equality check stops a delete from escaping the base
        # to rmtree any bestperf-* dir elsewhere on disk.)
        base = artifact_base_dir().resolve()
        if target.parent != base or not target.name.startswith("bestperf-"):
            self.write_json({"error": "refusing_to_delete", "dir": str(target)}, HTTPStatus.BAD_REQUEST)
            return
        with self.state.lock:
            proc = self.state.collects.pop(run_id, None)
            self.state.runs.pop(run_id, None)
            self.state.tailers.pop(run_id, None)
        if proc is not None and proc.poll() is None:
            terminate_process_tree(proc)  # whole detached group, else the collect's grandchild orphans
        if target.is_dir():
            shutil.rmtree(target, ignore_errors=True)
        self.write_json({"run_id": run_id, "deleted": True})

    def handle_run_action(self, run_id: str, action: str) -> None:
        run = self.get_run(run_id)
        if run is None:
            self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
            return
        if action == "stop":
            # Force-stop a running (possibly hung) run: terminate its process tree and free the
            # active-run slot so the next test isn't rejected with 409 run_already_active.
            stopped = stop_run(self.state, run_id)
            self.write_json({"run_id": run_id, "stopped": stopped is not None,
                             "status": stopped.status if stopped else "unknown"})
            return
        if action == "pin":
            (run.artifact_dir / ".collect_pin").write_text("1", encoding="utf-8")
            self.write_json({"run_id": run_id, "pinned": True})
            return
        if action == "unpin":
            pin = run.artifact_dir / ".collect_pin"
            if pin.exists():
                pin.unlink()
            self.write_json({"run_id": run_id, "pinned": False})
            return
        # action == "collect": resume the deferred peer-log collection for this run.
        # Touch the pin so an ACTIVE collection keeps its retention exemption fresh (the pin's
        # mtime is its age; only an abandoned pin ages past FR_COLLECT_PIN_MAX_AGE_HOURS).
        _pin = run.artifact_dir / ".collect_pin"
        if _pin.exists():
            try:
                _pin.touch()
            except OSError:
                pass
        if not (run.artifact_dir / "collection_state.json").is_file():
            self.write_json({"error": "no_pending_collection", "run_id": run_id}, HTTPStatus.BAD_REQUEST)
            return
        with self.state.lock:
            existing = self.state.collects.get(run_id)
            if existing is not None and existing.poll() is None:
                self.write_json({"error": "collect_already_running", "run_id": run_id}, HTTPStatus.CONFLICT)
                return
            self.state.collects[run_id] = start_collect(run)
        self.write_json({"run_id": run_id, "collecting": True,
                         "collection_state": read_collection_state_dir(run.artifact_dir)},
                        HTTPStatus.ACCEPTED)

    def handle_collect_pause(self, run_id: str) -> None:
        run = self.get_run(run_id)
        if run is None:
            self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
            return
        with self.state.lock:
            proc = self.state.collects.get(run_id)
        if proc is not None and proc.poll() is None:
            terminate_process_tree(proc)  # whole detached group; leaves a resumable partial + state on disk
        mark_collection_paused(run.artifact_dir)
        self.write_json({"run_id": run_id, "paused": True,
                         "collection_state": read_collection_state_dir(run.artifact_dir)})

    @property
    def state(self) -> ControllerState:
        return self.server.state

    def client_is_loopback(self) -> bool:
        ip = (self.client_address[0] if self.client_address else "")
        return ip in ("127.0.0.1", "::1", "::ffff:127.0.0.1")

    def client_is_local(self) -> bool:
        """Same-host caller: loopback OR one of THIS machine's own LAN IPs. A TCP connection can't
        complete with a spoofed source address, so an established request whose source is one of our
        own IPs genuinely originates on this machine. This lets the local operator's browser get the
        token (and bypass the LAN gate) whether they opened 127.0.0.1 OR the machine's LAN address."""
        ip = (self.client_address[0] if self.client_address else "")
        return is_same_host_ip(ip, getattr(self.server, "local_ips", frozenset()))

    def require_auth(self) -> bool:
        """True if the request carries the correct access token (constant-time compare)."""
        token = getattr(self.server, "token", "")
        if not token:
            return True  # auth disabled (no token configured)
        provided = self.headers.get("X-FR-Token", "")
        # Compare as BYTES: hmac.compare_digest raises TypeError on a str containing non-ASCII
        # characters, so a header like `X-FR-Token: 你好` would otherwise crash the request instead
        # of returning a clean 401 (do_POST/do_DELETE call require_auth OUTSIDE their try, so the
        # uncaught error drops the connection).
        try:
            return hmac.compare_digest(provided.encode("utf-8"), token.encode("utf-8"))
        except (UnicodeError, TypeError, AttributeError):
            return False  # fail CLOSED (deny) on any encoding/type oddity; never raise to the handler

    def send_unauthorized(self) -> None:
        self.write_json({"error": "unauthorized", "hint": "missing/invalid X-FR-Token"},
                        HTTPStatus.UNAUTHORIZED)

    def handle_api_get(self) -> None:
        parsed = urllib.parse.urlparse(self.path)
        parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
        # Open endpoints (no token): liveness + self-identity. The token is returned ONLY to a
        # loopback caller so the local operator can read it; a remote caller never sees it.
        if parts == ["api", "health"]:
            self.write_json({
                "ok": True,
                "cwd": str(ROOT),
                "script_exists": SCRIPT.exists(),
                "active_run_id": self.state.active_run_id(),
                "auth_required": bool(getattr(self.server, "token", "")),
            })
            return
        if parts == ["api", "identity"]:
            token = getattr(self.server, "token", "") if self.client_is_local() else None
            self.write_json(build_identity(getattr(self.server, "bind_host", "127.0.0.1"),
                                           getattr(self.server, "port", 8768), token,
                                           lan_allowed=getattr(self.server, "lan_allowed", False),
                                           agent_udp_port=getattr(self.server, "agent_udp_port", None)))
            return
        # Everything else under /api requires the token.
        if not self.require_auth():
            self.send_unauthorized()
            return
        # GUI 暂不调用:透出启动器(bestperf_bootstrap)生成的环境自检报告,供 API / 诊断用(非死代码)。
        if parts == ["api", "bootstrap"]:
            self.write_json(read_bootstrap_report())
            return
        if parts == ["api", "runs"]:
            self.state.discover_archived_runs(force=True)  # explicit list -> always a fresh scan
            with self.state.lock:
                runs = sorted(self.state.runs.values(), key=lambda item: item.started_at_unix_ns, reverse=True)
                payload = [run.to_summary_json() for run in runs]
            self.write_json({"runs": payload})
            return
        if len(parts) == 3 and parts[:2] == ["api", "runs"]:
            run = self.get_run(parts[2])
            if run is None:
                self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
                return
            self.write_json(run.to_json())
            return
        if len(parts) == 4 and parts[:2] == ["api", "runs"] and parts[3] == "live":
            run = self.get_run(parts[2])
            if run is None:
                self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
                return
            self.write_json(build_live_payload(run, self.state.live_tail(run.run_id)))
            return
        if len(parts) == 4 and parts[:2] == ["api", "runs"] and parts[3] == "artifacts":
            run = self.get_run(parts[2])
            if run is None:
                self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
                return
            self.write_json({"run_id": run.run_id, "artifacts": list_artifacts(run.artifact_dir)})
            return
        if len(parts) == 5 and parts[:2] == ["api", "runs"] and parts[3] == "artifacts":
            run = self.get_run(parts[2])
            if run is None:
                self.write_json({"error": "run_not_found"}, HTTPStatus.NOT_FOUND)
                return
            self.serve_artifact(run, parts[4])
            return
        self.write_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)

    def handle_start_run(self) -> None:
        body = self.read_json_body()
        if body is None:
            self.write_json({"error": "invalid_json"}, HTTPStatus.BAD_REQUEST)
            return
        # A remote (token-holding) caller must not direct artifact writes outside the controller's own
        # base: an attacker-chosen FR_LOCAL_OUT_DIR makes start_run mkdir + write controller.log /
        # controller_request.json (and the subprocess write its artifacts) at ANY path the controller
        # can reach. Mirror the H-8 import/delete guards — drop a remote-supplied out-dir so it falls
        # back to default_artifact_dir under the base. The local operator keeps full control.
        if isinstance(body.get("env"), dict) and body["env"].get("FR_LOCAL_OUT_DIR") and not self.client_is_local():
            body["env"].pop("FR_LOCAL_OUT_DIR", None)
        with self.state.lock:
            active = self.state.active_run_id()
            if active:
                self.write_json({"error": "run_already_active", "run_id": active}, HTTPStatus.CONFLICT)
                return
            run = start_run(self.state, body)
            self.state.runs[run.run_id] = run
        # Prune OUTSIDE the global lock: prune_old_run_dirs does a full base-dir rglob + disk deletes,
        # which held the lock long enough to freeze every other API request. Each per-run dict cleanup
        # still takes the lock, but only briefly. (start_run's Popen stays in the lock above, where the
        # active-run check + reservation must be atomic.)
        for pruned_id in prune_old_run_dirs(run.run_id):  # cap accumulated run dirs
            with self.state.lock:
                self.state.runs.pop(pruned_id, None)
                self.state.tailers.pop(pruned_id, None)  # else LiveTail dict leaks over a long campaign
                proc = self.state.collects.pop(pruned_id, None)
            if proc is not None and proc.poll() is None:
                terminate_process_tree(proc)  # whole detached group, else the pruned collect's grandchild orphans
        self.write_json(run.to_json(), HTTPStatus.ACCEPTED)

    def handle_expose(self) -> None:
        """Toggle whether LAN peers may reach this controller. Loopback-only: only the local
        operator (the GUI on this machine) controls exposure, never a remote peer. Persisted so
        the choice survives a restart."""
        if not self.client_is_local():
            self.write_json({"error": "local_only",
                             "hint": "只有本机控制台能改这个开关"}, HTTPStatus.FORBIDDEN)
            return
        body = self.read_json_body()
        if body is None or "enabled" not in body:
            self.write_json({"error": "invalid_json", "hint": 'body: {"enabled": true|false}'},
                            HTTPStatus.BAD_REQUEST)
            return
        enabled = bool(body.get("enabled"))
        self.server.lan_allowed = enabled
        save_lan_allowed(enabled)
        self.write_json({"lan_allowed": enabled, "exposed": enabled})

    def handle_import_run(self) -> None:
        body = self.read_json_body()
        if body is None:
            self.write_json({"error": "invalid_json"}, HTTPStatus.BAD_REQUEST)
            return
        artifact_dir = body.get("artifact_dir")
        if not artifact_dir:
            self.write_json({"error": "missing_artifact_dir"}, HTTPStatus.BAD_REQUEST)
            return
        directory = Path(str(artifact_dir)).expanduser().resolve()
        if not directory.is_dir():
            self.write_json({"error": "invalid_artifact_dir", "artifact_dir": str(directory)}, HTTPStatus.BAD_REQUEST)
            return
        # H-8: a remote caller must not register an arbitrary path and read back its allowlisted
        # files. Local operator: any dir; remote: only under the controller's own artifact base.
        if not import_dir_authorized(self.client_is_local(), directory, artifact_base_dir()):
            self.write_json({"error": "import_forbidden",
                             "hint": "registering an arbitrary disk path is a local-operator action; "
                                     "a remote peer may only import a run under this controller's "
                                     "artifact directory"},
                            HTTPStatus.FORBIDDEN)
            return
        run = archived_run_from_dir(directory, requested_run_id=body.get("run_id"))
        if run is None:
            self.write_json({"error": "no_importable_artifacts", "artifact_dir": str(directory)}, HTTPStatus.BAD_REQUEST)
            return
        with self.state.lock:
            existing = self.state.runs.get(run.run_id)
            if existing is not None and existing.status != "archived":
                self.write_json({"error": "run_id_conflict", "run_id": run.run_id}, HTTPStatus.CONFLICT)
                return
            if existing is not None and existing.artifact_dir.resolve() != run.artifact_dir.resolve():
                self.write_json({"error": "run_id_conflict", "run_id": run.run_id}, HTTPStatus.CONFLICT)
                return
            self.state.runs[run.run_id] = run
        self.write_json(run.to_json(), HTTPStatus.CREATED)

    def read_json_body(self) -> Optional[dict]:
        try:
            length = int(self.headers.get("content-length", "0") or "0")
        except (TypeError, ValueError):
            return None  # non-numeric Content-Length -> 400, not a 500/crash
        if length < 0 or length > 4 * 1024 * 1024:  # cap the body at 4 MiB (DoS guard)
            return None
        try:
            raw = self.rfile.read(length) if length else b"{}"
            value = json.loads(raw.decode("utf-8"))
            return value if isinstance(value, dict) else None
        except (UnicodeDecodeError, json.JSONDecodeError):
            return None

    def get_run(self, run_id: str) -> Optional[RunState]:
        self.state.discover_archived_runs()
        with self.state.lock:
            return self.state.runs.get(run_id)

    def serve_static(self) -> None:
        parsed = urllib.parse.urlparse(self.path)
        requested = parsed.path.lstrip("/") or "index.html"
        target = (GUI_DIR / requested).resolve()
        if not path_within(target, GUI_DIR) or not target.is_file():
            self.write_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
            return
        content_type, _ = mimetypes.guess_type(str(target))
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", content_type or "application/octet-stream")
        self.send_header("Cache-Control", "no-store, max-age=0")
        self.send_header("Pragma", "no-cache")
        self.send_header("Content-Length", str(target.stat().st_size))
        self.end_headers()
        with target.open("rb") as handle:
            self.wfile.write(handle.read())

    def serve_artifact(self, run: RunState, name: str, head: bool = False) -> None:
        if name not in ARTIFACT_NAMES:
            self.write_json({"error": "artifact_not_allowed"}, HTTPStatus.BAD_REQUEST)
            return
        path = (run.artifact_dir / name).resolve()
        if not path_within(path, run.artifact_dir) or not path.is_file():
            self.write_json({"error": "artifact_not_found"}, HTTPStatus.NOT_FOUND)
            return
        # collection_state.json may carry a legacy embedded peer token. Serve a SECRET-STRIPPED body on
        # EVERY path (HEAD, Range, full GET) -- the collector always uses HEAD/Range, which would
        # otherwise stream the raw file below and leak the token. Size + integrity hash are computed
        # over the stripped bytes so the collector's manifest matches exactly what it downloads.
        if name == "collection_state.json":
            try:
                stripped = strip_secret_keys(json.loads(path.read_text(encoding="utf-8")))
            except (OSError, ValueError):
                self.write_json({"error": "artifact_unreadable"}, HTTPStatus.INTERNAL_SERVER_ERROR)
                return
            body = json.dumps(stripped, ensure_ascii=False).encode("utf-8")
            size = len(body)
            rng_h = self.headers.get("Range")
            if rng_h:
                rng = parse_byte_range(rng_h, size)
                if rng is None:
                    self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
                    self.send_header("Content-Range", f"bytes */{size}")
                    self.send_header("Accept-Ranges", "bytes")
                    self.end_headers()
                    return
                start, end = rng
                self.send_response(HTTPStatus.PARTIAL_CONTENT)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
                self.send_header("Content-Length", str(end - start + 1))
                self.send_header("Accept-Ranges", "bytes")
                self.end_headers()
                if not head:
                    self.wfile.write(body[start:end + 1])
                return
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(size))
            self.send_header("Accept-Ranges", "bytes")
            self.send_header("X-Content-SHA256", hashlib.sha256(body).hexdigest())
            self.end_headers()
            if not head:
                self.wfile.write(body)
            return
        range_header = self.headers.get("Range")
        # Plain GUI GET of a .json artifact: keep the pretty-printed JSON body (back-compat).
        # Collection always uses HEAD/Range, which take the raw-bytes path below so the
        # X-Content-SHA256 integrity hash matches exactly the bytes transferred.
        if path.suffix == ".json" and not range_header and not head:
            data = json.loads(path.read_text(encoding="utf-8"))
            if name == "collection_state.json":
                data = strip_secret_keys(data)  # never serve a legacy embedded peer token
            self.write_json(data)
            return
        content_type = content_type_for_artifact(name)
        size = path.stat().st_size

        if range_header:
            rng = parse_byte_range(range_header, size)
            if rng is None:  # unsatisfiable
                self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
                self.send_header("Content-Range", f"bytes */{size}")
                self.send_header("Accept-Ranges", "bytes")
                self.end_headers()
                return
            start, end = rng  # inclusive
            length = end - start + 1
            self.send_response(HTTPStatus.PARTIAL_CONTENT)
            self.send_header("Content-Type", content_type)
            self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
            self.send_header("Content-Length", str(length))
            self.send_header("Accept-Ranges", "bytes")
            self.end_headers()
            if not head:
                self.stream_file_range(path, start, length)
            return

        # Full file (or HEAD): expose size + integrity hash so the collector can verify.
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(size))
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("X-Content-SHA256", sha256_file(path))
        self.end_headers()
        if not head:
            self.stream_file_range(path, 0, size)

    def stream_file_range(self, path: Path, start: int, length: int) -> None:
        remaining = length
        with path.open("rb") as handle:
            handle.seek(start)
            while remaining > 0:
                chunk = handle.read(min(262144, remaining))
                if not chunk:
                    break
                self.wfile.write(chunk)
                remaining -= len(chunk)

    def write_json(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, format: str, *args) -> None:
        return


class BestPerfServer(ThreadingHTTPServer):
    def __init__(self, server_address):
        super().__init__(server_address, BestPerfHandler)
        self.state = ControllerState()
        self.bind_host = server_address[0]
        self.port = server_address[1]
        self.token = controller_token()
        self.lan_allowed = load_lan_allowed()
        # This machine's own addresses -> a request from any of them is same-host (see client_is_local).
        self.local_ips = frozenset(lan_ips()) | {"127.0.0.1", "::1"}
        # The UDP measurement port we advertise (39700, or the next free one if it's busy).
        self.agent_udp_port = select_agent_udp_port()


def controller_token() -> str:
    """The access token (ToDesk-style 'password'). Env override, else a persisted random token."""
    env = os.environ.get("FR_CONTROLLER_TOKEN")
    if env and env.strip():
        return env.strip()
    path = Path.home() / ".bestperf" / "controller_token"
    try:
        if path.is_file():
            existing = path.read_text(encoding="utf-8").strip()
            if existing:
                return existing
    except OSError:
        pass
    token = secrets.token_urlsafe(12)
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(token, encoding="utf-8")
        try:
            os.chmod(path, 0o600)
        except OSError:
            pass
    except OSError:
        pass  # non-persistent (read-only home) -> token lives for this process only
    return token


def _lan_allowed_path() -> Path:
    override = os.environ.get("FR_LAN_ALLOWED_FILE")
    return Path(override) if override else Path.home() / ".bestperf" / "lan_allowed"


def load_lan_allowed() -> bool:
    """Whether LAN (non-loopback) callers are allowed. The socket always binds 0.0.0.0 so the
    GUI toggle can flip this instantly without a restart; until the operator turns it on, every
    non-loopback request is refused (403) before auth. Env FR_LAN_ALLOWED=1 forces it on for
    headless/automation; otherwise it's read from a persisted file (set by the GUI toggle)."""
    env = os.environ.get("FR_LAN_ALLOWED")
    if env is not None and env.strip() != "":
        return env.strip().lower() in ("1", "true", "yes", "on")
    try:
        return _lan_allowed_path().read_text(encoding="utf-8").strip() in ("1", "true", "yes", "on")
    except OSError:
        return False


def save_lan_allowed(value: bool) -> None:
    path = _lan_allowed_path()
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("1" if value else "0", encoding="utf-8")
        try:
            os.chmod(path, 0o600)
        except OSError:
            pass
    except OSError:
        pass  # read-only home -> the flag lives for this process only


def lan_access_ok(is_loopback: bool, lan_allowed: bool) -> bool:
    """A request may proceed iff it is from loopback (the local operator) OR LAN access is enabled."""
    return bool(is_loopback or lan_allowed)


def is_same_host_ip(client_ip: str, local_ips) -> bool:
    """True iff the request's source IP is loopback OR one of THIS machine's own addresses, i.e. it
    originates on this machine. The token is handed out (and the LAN gate bypassed) only for such
    same-host callers; a different LAN host always sees the card WITHOUT the token and must paste it.
    Safe boundary: a TCP connection cannot complete with a spoofed source, so an established request
    whose source is one of our own IPs is genuinely local."""
    ip = client_ip[7:] if client_ip.startswith("::ffff:") else client_ip
    if not ip:
        return False
    if ip in ("127.0.0.1", "::1") or ip.startswith("127."):
        return True
    return ip in set(local_ips)


def pick_free_port(preferred: int, is_free, allow_auto: bool = True, span: int = 40) -> int:
    """Pick a usable port: the preferred one if free; otherwise (when auto-fallback is allowed) the
    first free port within `span` above it; else fall back to the preferred. Pure (takes an is_free
    predicate) so the choice is unit-testable without touching real sockets."""
    if is_free(preferred):
        return preferred
    if not allow_auto:
        return preferred
    for candidate in range(preferred + 1, preferred + 1 + span):
        if candidate <= 65535 and is_free(candidate):
            return candidate
    return preferred


def udp_port_free(port: int, host: str = "0.0.0.0") -> bool:
    """True if a UDP socket can bind host:port right now (best-effort freeness probe)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((host, port))
        return True
    except OSError:
        return False
    finally:
        sock.close()


def select_agent_udp_port() -> int:
    """The UDP port this machine advertises for measurement traffic. Defaults to 39700; if that's
    busy it auto-falls-back to the next free port (so a collision doesn't hard-fail). If the operator
    pinned FR_DEFAULT_AGENT_PORT explicitly, respect it as-is (no fallback)."""
    raw = os.environ.get("FR_DEFAULT_AGENT_PORT", "")
    explicit = bool(raw.strip())
    try:
        preferred = int(raw or "39700")
    except ValueError:
        preferred, explicit = 39700, False  # non-numeric override -> documented graceful fallback
    return pick_free_port(preferred, udp_port_free, allow_auto=not explicit)


def primary_lan_ip() -> str:
    """Best-effort primary egress IPv4 (no packet is actually sent by connect() on UDP)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.connect(("8.8.8.8", 80))
        return sock.getsockname()[0]
    except OSError:
        return "127.0.0.1"
    finally:
        sock.close()


def _is_shareable_ip(ip: str) -> bool:
    """Exclude addresses a LAN peer can't use: loopback, link-local, and the RFC 2544
    benchmark range 198.18.0.0/15 — the latter is what Clash/Surge-style TUN proxies assign to
    their virtual gateway (e.g. 198.18.0.1), which is not a real reachable interface."""
    if ip.startswith("127.") or ip.startswith("169.254."):
        return False
    if ip.startswith("198.18.") or ip.startswith("198.19."):
        return False
    return True


def _lan_rank(ip: str) -> int:
    """Prefer typical peer-reachable private LAN ranges over VPN/benchmark/other addresses."""
    if ip.startswith("192.168."):
        return 0
    if ip.startswith("10."):
        return 1
    if ip.startswith("172."):
        try:
            if 16 <= int(ip.split(".")[1]) <= 31:
                return 2
        except (IndexError, ValueError):
            pass
    return 5  # VPN/utun (e.g. 198.18.x), link-local, public, etc.


def lan_ips() -> list:
    ips = []
    primary = primary_lan_ip()
    if _is_shareable_ip(primary):
        ips.append(primary)
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
            ip = info[4][0]
            if _is_shareable_ip(ip) and ip not in ips:
                ips.append(ip)
    except OSError:
        pass
    # Stable sort by LAN-likelihood so console_url (ips[0]) favours a peer-reachable address.
    ips.sort(key=_lan_rank)
    return ips or [primary]  # last resort: keep the egress IP even if not "shareable"


def build_identity(bind_host: str, port: int, token: Optional[str] = None,
                   lan_allowed: bool = False, agent_udp_port: Optional[int] = None) -> dict:
    ips = lan_ips()
    # The socket always binds 0.0.0.0; "exposed" is the operator's in-GUI consent toggle
    # (lan_allowed), NOT the bind address. When off, peers are refused (403) before auth.
    exposed = bool(lan_allowed)
    # When not exposed, present the address that actually works (127.0.0.1) so the card and the
    # "not reachable by peers" warning don't contradict each other.
    console_ip = (ips[0] if ips else "127.0.0.1") if exposed else "127.0.0.1"
    payload = {
        "hostname": socket.gethostname(),
        "lan_ips": ips,
        "port": port,
        "bind_host": bind_host,
        "exposed": exposed,
        "lan_allowed": exposed,
        "console_url": f"http://{console_ip}:{port}",
        "agent_udp_port": int(agent_udp_port if agent_udp_port is not None
                              else os.environ.get("FR_DEFAULT_AGENT_PORT", "39700") or "39700"),
    }
    if token is not None:
        payload["token"] = token
    cal = read_selfcheck_calibration()
    if cal is not None:
        payload["selfcheck"] = cal
    return payload


def read_selfcheck_calibration():
    """The machine's last local performance self-check (UDP ceiling + latency-under-load), if any.
    Persisted by run_local_session.mode_selfcheck so the 本机信息 card can show it and peer load
    results can be flagged when they're limited by this device rather than the network."""
    try:
        path = Path.home() / ".bestperf" / "selfcheck.json"
        if not path.is_file():
            return None
        with path.open(encoding="utf-8") as fh:
            return json.load(fh)
    except Exception:
        return None


def safe_run_id(value: Optional[str] = None) -> str:
    base = value or time.strftime("gui-%Y%m%d-%H%M%S")
    cleaned = RUN_ID_RE.sub("-", base).strip("-")
    return cleaned or f"gui-{time.time_ns()}"


def filtered_env(input_env: dict) -> dict:
    out = {}
    for key, value in input_env.items():
        if ENV_KEY_RE.match(key):
            out[key] = str(value)
    return out


def default_artifact_dir(run_id: str) -> Path:
    base = os.environ.get("FIELDREPLAY_CONTROLLER_OUT_BASE") or os.environ.get("TMPDIR") or tempfile.gettempdir()
    return (Path(base) / f"bestperf-{run_id}").resolve()


def artifact_base_dir() -> Path:
    base = os.environ.get("FIELDREPLAY_CONTROLLER_OUT_BASE") or os.environ.get("TMPDIR") or tempfile.gettempdir()
    return Path(base).resolve()


def path_within(child: Path, base: Path) -> bool:
    """True iff `child` resolves to `base` itself or a path INSIDE it. Uses real path containment
    (relative_to), NOT a string prefix: 'gui-evil' must not be treated as contained in 'gui'."""
    try:
        child.resolve().relative_to(base.resolve())
        return True
    except (ValueError, OSError):
        return False


def import_dir_authorized(client_is_local: bool, artifact_dir: Path, base_dir: Path) -> bool:
    """Authorize registering a disk directory via /api/runs/import (H-8).

    Registering an arbitrary directory and then GETting its allowlisted-named files is an arbitrary
    file read. The LOCAL operator may import any directory (their own files -- no capability they
    don't already have). A REMOTE (LAN) caller may only (re)register a directory that is a DIRECT
    CHILD of the controller's own artifact base -- so a token-holding peer can never point the
    controller at e.g. /home/<operator>/private and read it back. Mirrors the delete-path guard."""
    if client_is_local:
        return True
    try:
        return artifact_dir.resolve().parent == base_dir.resolve()
    except OSError:
        return False


def run_id_from_artifact_dir(directory: Path) -> Optional[str]:
    name = directory.name
    if not name.startswith("bestperf-"):
        return None
    run_id = name[len("bestperf-"):]
    return safe_run_id(run_id) if run_id else None


def read_controller_request(directory: Path) -> dict:
    path = directory / "controller_request.json"
    if not path.is_file():
        return {}
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    return value if isinstance(value, dict) else {}


def archived_run_from_dir(directory: Path, requested_run_id: Optional[str] = None) -> Optional[RunState]:
    directory = directory.resolve()
    artifacts = list_artifacts(directory)
    if not artifacts:
        return None
    request = read_controller_request(directory)
    run_id = safe_run_id(str(requested_run_id or request.get("run_id") or run_id_from_artifact_dir(directory) or directory.name))
    if not run_id:
        return None
    latest_mtime_ns = max((directory / item["name"]).stat().st_mtime_ns for item in artifacts)
    log_path = directory / "controller.log"
    if log_path.is_file():
        latest_mtime_ns = max(latest_mtime_ns, log_path.stat().st_mtime_ns)
    return RunState(
        run_id=run_id,
        artifact_dir=directory,
        log_path=log_path,
        dry_run=bool(request.get("dry_run", False)),
        status="archived",
        returncode=0,
        started_at_unix_ns=latest_mtime_ns,
        ended_at_unix_ns=latest_mtime_ns,
        process=None,
    )


def _dir_size_bytes(path: Path) -> int:
    total = 0
    for p in path.rglob("*"):
        try:
            if p.is_file():
                total += p.stat().st_size
        except OSError:
            pass
    return total


def prune_old_run_dirs(active_run_id: str) -> list[str]:
    """Cap accumulated run directories so a long monitoring campaign can't fill the disk:
      - FR_RETAIN_RUNS     keep at most this many (default 200; <=0 disables the count cap)
      - FR_RETAIN_MAX_AGE_SEC  delete dirs older than this (default 0 = disabled)
      - FR_RETAIN_MAX_BYTES    delete oldest until total size is under this (default 0 = disabled)
    Deletes oldest first, never the just-started run. Returns the pruned run_ids.
    (The peer machine runs its own controller and self-prunes with the same knobs.)"""
    def env_int(name: str, default: int) -> int:
        try:
            return int(os.environ.get(name, str(default)))
        except ValueError:
            return default

    keep = env_int("FR_RETAIN_RUNS", 200)
    max_age_sec = env_int("FR_RETAIN_MAX_AGE_SEC", 0)
    max_bytes = env_int("FR_RETAIN_MAX_BYTES", 0)

    base = artifact_base_dir()
    if not base.is_dir():
        return []
    dirs = [d for d in base.iterdir() if d.is_dir() and run_id_from_artifact_dir(d) is not None]
    dirs.sort(key=lambda d: d.stat().st_mtime, reverse=True)  # newest first

    to_delete: set = set()
    if keep > 0 and len(dirs) > keep:
        to_delete.update(dirs[keep:])
    if max_age_sec > 0:
        now = time.time()
        for d in dirs:
            try:
                if now - d.stat().st_mtime > max_age_sec:
                    to_delete.add(d)
            except OSError:
                pass
    if max_bytes > 0:
        running = 0
        for d in dirs:  # newest first: keep newest within budget, drop the overflow tail
            running += _dir_size_bytes(d)
            if running > max_bytes:
                to_delete.add(d)

    pruned = []
    for d in dirs:
        if d not in to_delete:
            continue
        rid = run_id_from_artifact_dir(d)
        if rid == active_run_id:
            continue
        pin = d / ".collect_pin"
        if pin.exists():
            # An initiator is still (or will be) collecting this peer's data — keep it. But only
            # for a bounded time: an ABANDONED collection (initiator never came back to finish and
            # unpin) would otherwise exempt this dir from every retention knob forever, leaking
            # disk on the responder. Pin age == file mtime; a live resume re-touches it.
            try:
                age_sec = time.time() - pin.stat().st_mtime
            except OSError:
                age_sec = 0.0
            try:
                max_age_h = float(os.environ.get("FR_COLLECT_PIN_MAX_AGE_HOURS") or (7 * 24.0))
            except ValueError:
                max_age_h = 7 * 24.0
            if age_sec < max_age_h * 3600.0:
                continue
        try:
            shutil.rmtree(d)
            pruned.append(rid)
        except OSError:
            pass
    return pruned


def discover_archived_runs() -> list[RunState]:
    base = artifact_base_dir()
    if not base.is_dir():
        return []
    runs = []
    try:
        candidates = sorted(base.iterdir(), key=lambda item: item.name)
    except OSError:
        return []
    for directory in candidates:
        if not directory.is_dir() or run_id_from_artifact_dir(directory) is None:
            continue
        run = archived_run_from_dir(directory)
        if run is not None:
            runs.append(run)
    return runs


def detached_spawn_kwargs() -> dict:
    """Popen kwargs that put a spawned run/collect in its OWN process group/session, so the whole
    tree (including the C++ agent grandchild) can be signalled together and never orphans when the
    controller exits or stops the run. Mirrors how the bootstrap detaches the controller itself."""
    if os.name == "nt":
        return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
    return {"start_new_session": True}


def terminate_process_tree(process: Optional[subprocess.Popen], grace_sec: float = 3.0,
                           pgid: Optional[int] = None) -> None:
    """Terminate a spawned run/collect AND its descendants. Spawned detached (see
    detached_spawn_kwargs), so we signal the whole process group — killing only the Python child
    would orphan the agent grandchild, which keeps holding the UDP ports and wedges the next run.
    Pass the pgid captured at spawn: re-deriving it here via getpgid(process.pid) races the
    wait_for_run reaper, and on a reaped+reused pid would signal an unrelated group."""
    if process is None or process.poll() is not None:
        return
    if os.name == "nt":
        subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)], capture_output=True)
        try:
            process.wait(timeout=grace_sec)
        except subprocess.TimeoutExpired:
            pass
        return
    try:
        group = pgid if pgid is not None else os.getpgid(process.pid)
        os.killpg(group, signal.SIGTERM)
        try:
            process.wait(timeout=grace_sec)
        except subprocess.TimeoutExpired:
            os.killpg(group, signal.SIGKILL)  # didn't go down gracefully -> force the whole group
    except ProcessLookupError:
        return  # already gone
    except OSError:
        try:
            process.terminate()  # fall back to a plain terminate if group signalling failed
        except OSError:
            pass


def stop_run(state: ControllerState, run_id: str) -> Optional[RunState]:
    """Force-stop a run: terminate its process tree and mark it 'stopped' so it no longer pins
    active_run_id (a hung run otherwise 409s every future start). Returns the run, or None if
    unknown. Idempotent — stopping an already-finished run just returns it."""
    with state.lock:
        run = state.runs.get(run_id)
        if run is None:
            return None
        # Set the terminal status BEFORE terminating, under the lock. Otherwise wait_for_run's
        # process.wait() unblocks the instant the kill lands and may write 'failed'/'exited' first;
        # the old post-terminate "if status == running" check would then refuse to set 'stopped',
        # mislabeling an operator stop. wait_for_run keeps a "don't clobber stopped" guard, so this wins.
        if run.status == "running":
            run.status = "stopped"
            run.returncode = run.returncode if run.returncode is not None else -1
            if run.ended_at_unix_ns is None:
                run.ended_at_unix_ns = time.time_ns()
        proc = run.process
        run_pgid = run.pgid
        collect = state.collects.pop(run_id, None)
    terminate_process_tree(proc, pgid=run_pgid)
    terminate_process_tree(collect)  # collect: short-lived, no stored pgid -> getpgid fallback
    return run


def shutdown_active_processes(state: ControllerState) -> None:
    """On controller exit, terminate every in-flight run/collect tree so we never leave orphaned
    agents holding the UDP ports for the next session."""
    with state.lock:
        procs = [(r.process, r.pgid) for r in state.runs.values() if r.process and r.process.poll() is None]
        procs += [(p, None) for p in state.collects.values() if p and p.poll() is None]
    for proc, pgid in procs:
        terminate_process_tree(proc, pgid=pgid)


def start_run(state: "ControllerState", body: dict) -> RunState:
    dry_run = bool(body.get("dry_run", False))
    requested_env = filtered_env(body.get("env", {}) if isinstance(body.get("env", {}), dict) else {})
    run_id = safe_run_id(requested_env.get("FR_RUN_ID"))
    requested_env["FR_RUN_ID"] = run_id
    artifact_dir = Path(requested_env.get("FR_LOCAL_OUT_DIR") or default_artifact_dir(run_id)).resolve()
    requested_env["FR_LOCAL_OUT_DIR"] = str(artifact_dir)
    if dry_run:
        requested_env["FR_DRY_RUN"] = "1"

    artifact_dir.mkdir(parents=True, exist_ok=True)
    log_path = artifact_dir / "controller.log"
    # Persist a REDACTED record: the env may carry a peer controller token / password, and this file
    # sits on disk for the run's lifetime. Strip secret-looking keys so no secret is stored at rest
    # (the launched subprocess below still gets the full, unredacted env). Readers only need run_id /
    # planned-duration keys. (Medium: peer token plaintext at rest)
    (artifact_dir / "controller_request.json").write_text(json.dumps({
        "run_id": run_id,
        "dry_run": dry_run,
        "env": strip_secret_keys(requested_env),
    }, indent=2), encoding="utf-8")

    env = os.environ.copy()
    env.update(requested_env)
    log_file = log_path.open("w", encoding="utf-8")
    try:
        process = subprocess.Popen(
            [sys.executable, str(SCRIPT)],
            cwd=str(ROOT),
            env=env,
            stdout=log_file,
            stderr=subprocess.STDOUT,
            text=True,
            **detached_spawn_kwargs(),  # own process group: terminable as a tree, never orphans
        )
    except Exception:
        log_file.close()
        raise
    pgid = None
    if os.name != "nt":
        try:
            pgid = os.getpgid(process.pid)  # safe HERE: just spawned, wait_for_run not started yet
        except OSError:
            pgid = None
    run = RunState(run_id=run_id, artifact_dir=artifact_dir, log_path=log_path, dry_run=dry_run,
                   process=process, pgid=pgid)
    thread = threading.Thread(target=wait_for_run, args=(run, log_file, state), daemon=True)
    thread.start()
    return run


def _read_collection_state_raw(artifact_dir: Path) -> Optional[dict]:
    path = artifact_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 read_collection_state_dir(artifact_dir: Path) -> Optional[dict]:
    """Display-safe read: secret-looking keys (e.g. a legacy embedded peer_ctrl_token) are stripped
    so status responses and the served artifact never leak a credential."""
    raw = _read_collection_state_raw(artifact_dir)
    return None if raw is None else strip_secret_keys(raw)


def mark_collection_paused(artifact_dir: Path) -> None:
    # Raw read for the read-modify-write: preserve the file faithfully (don't strip a legacy
    # in-file token here — it isn't served, and a pre-split resume may still need it).
    state = _read_collection_state_raw(artifact_dir)
    if state is None:
        return
    state["status"] = "paused"
    state["updated_at_unix"] = int(time.time())
    (artifact_dir / "collection_state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")


def start_collect(run: RunState) -> subprocess.Popen:
    """Spawn run_local_session in FR_MODE=collect to resume a deferred peer-log collection.

    Writes to collect.log in the run dir; the GUI tracks progress via collection_state.json.
    """
    env = os.environ.copy()
    env.update({"FR_MODE": "collect", "FR_RUN_ID": run.run_id, "FR_LOCAL_OUT_DIR": str(run.artifact_dir)})
    log_file = (run.artifact_dir / "collect.log").open("w", encoding="utf-8")
    proc = subprocess.Popen([sys.executable, str(SCRIPT)], cwd=str(ROOT), env=env,
                            stdout=log_file, stderr=subprocess.STDOUT, text=True,
                            **detached_spawn_kwargs())
    threading.Thread(target=lambda: (proc.wait(), log_file.close()), daemon=True).start()
    return proc


def wait_for_run(run: RunState, log_file, state: "ControllerState") -> None:
    try:
        returncode = run.process.wait() if run.process else 1  # blocks: must stay OUTSIDE the lock
        with state.lock:  # serialize the terminal-state write against stop_run's check-then-set
            run.returncode = returncode
            if run.status != "stopped":  # a user stop already set the terminal status; don't clobber it
                run.status = "exited" if returncode == 0 else "failed"
            if run.ended_at_unix_ns is None:
                run.ended_at_unix_ns = time.time_ns()
    except Exception:  # noqa: BLE001
        # Never leave a run stuck at "running": that would pin active_run_id forever and
        # reject every future run with 409. Mark it failed so the controller stays usable.
        with state.lock:
            run.returncode = run.returncode if run.returncode is not None else 1
            if run.status != "stopped":
                run.status = "failed"
            if run.ended_at_unix_ns is None:
                run.ended_at_unix_ns = time.time_ns()
    finally:
        log_file.close()


def redact_secrets(value, secrets: list[str]):
    active = [secret for secret in secrets if secret]
    if not active:
        return value
    if isinstance(value, dict):
        return {key: redact_secrets(item, active) for key, item in value.items()}
    if isinstance(value, list):
        return [redact_secrets(item, active) for item in value]
    if isinstance(value, str):
        text = value
        for secret in active:
            text = text.replace(secret, "***")
        return text
    return value


def redact_support_value(value):
    if isinstance(value, dict):
        out = {}
        for key, item in value.items():
            if SECRET_KEY_RE.search(str(key)) and not isinstance(item, bool):
                out[key] = item if item in ("", None) else "***"
            else:
                out[key] = redact_support_value(item)
        return out
    if isinstance(value, list):
        return [redact_support_value(item) for item in value]
    if isinstance(value, str):
        return redact_support_text(value)
    return value


def redact_support_text(text: str) -> str:
    text = BEARER_RE.sub(lambda match: f"{match.group(1)} ***", text)
    text = SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}***", text)
    return SECRET_WORD_RE.sub(lambda match: f"{match.group(1)} ***", text)


def bootstrap_report_path() -> Optional[Path]:
    value = os.environ.get(BOOTSTRAP_REPORT_ENV, "").strip()
    if not value:
        return None
    return Path(value).expanduser().resolve()


def install_report_path() -> Optional[Path]:
    value = os.environ.get(INSTALL_REPORT_ENV, "").strip()
    if value:
        return Path(value).expanduser().resolve()
    candidate = ROOT / "install_report.latest.json"
    return candidate if candidate.is_file() else None


def _copy_dict_keys(source: dict, keys: list[str]) -> dict:
    return {key: source[key] for key in keys if key in source}


def safe_release_payload(source: dict) -> dict:
    return _copy_dict_keys(source, [
        "available",
        "manifest_path",
        "schema",
        "package_id",
        "artifact_name",
        "name",
        "version",
        "platform_label",
        "package_platform",
        "runtime_platform",
        "platform_match",
        "bundled_binary_count",
        "error",
    ])


def read_bootstrap_report(path: Optional[Path] = None) -> dict:
    report_path = path if path is not None else bootstrap_report_path()
    payload = {
        "schema": "field_replay.bootstrap_controller_report.v1",
        "available": False,
        "status": "not_configured",
        "report_path": str(report_path) if report_path else "",
    }
    if report_path is None:
        return payload
    if not report_path.is_file():
        payload["status"] = "missing"
        payload["detail"] = "Launcher report has not been written yet."
        return payload
    try:
        report = json.loads(report_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        payload["status"] = "invalid"
        payload["detail"] = str(exc)
        return payload
    if not isinstance(report, dict):
        payload["status"] = "invalid"
        payload["detail"] = "Launcher report is not a JSON object."
        return payload

    target = report.get("target") if isinstance(report.get("target"), dict) else {}
    payload.update({
        "available": True,
        "status": str(report.get("status") or "unknown"),
        "dry_run": bool(report.get("dry_run", False)),
        "source_schema": str(report.get("schema") or ""),
        "generated_at_unix_ns": report.get("generated_at_unix_ns"),
        "platform": report.get("platform") if isinstance(report.get("platform"), dict) else {},
        "release": safe_release_payload(report.get("release") if isinstance(report.get("release"), dict) else {}),
        "install": read_install_report(),
        "target": _copy_dict_keys(target, [
            "host",
            "port",
            "requested_port",
            "port_auto_selected",
            "port_selection_detail",
            "url",
            "out_dir",
            "report_path",
            "log_path",
            "agent_binary",
        ]),
        "checks": report.get("checks") if isinstance(report.get("checks"), list) else [],
        "steps": report.get("steps") if isinstance(report.get("steps"), list) else [],
        "action_plan": report.get("action_plan") if isinstance(report.get("action_plan"), list) else [],
    })
    return payload


def read_install_report(path: Optional[Path] = None) -> dict:
    report_path = path if path is not None else install_report_path()
    payload = {
        "schema": "field_replay.install_controller_report.v1",
        "available": False,
        "status": "not_configured",
        "report_path": str(report_path) if report_path else "",
    }
    if report_path is None:
        return payload
    if not report_path.is_file():
        payload["status"] = "missing"
        payload["detail"] = "Install report has not been written yet."
        return payload
    try:
        report = json.loads(report_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        payload["status"] = "invalid"
        payload["detail"] = str(exc)
        return payload
    if not isinstance(report, dict):
        payload["status"] = "invalid"
        payload["detail"] = "Install report is not a JSON object."
        return payload

    desktop_entries = {}
    if isinstance(report.get("desktop_entries"), dict):
        for role in ["main", "diagnose", "support"]:
            value = report["desktop_entries"].get(role)
            if value:
                desktop_entries[role] = str(value)
    if not desktop_entries and report.get("desktop_entry"):
        desktop_entries["main"] = str(report.get("desktop_entry"))

    operator_guide = {}
    if isinstance(report.get("operator_guide"), dict):
        for role in ["start", "diagnose", "support"]:
            value = report["operator_guide"].get(role)
            if isinstance(value, dict):
                operator_guide[role] = redact_support_value({
                    "label": str(value.get("label") or ""),
                    "path": str(value.get("path") or ""),
                    "detail": "",
                })

    payload.update({
        "available": True,
        "status": str(report.get("status") or "unknown"),
        "dry_run": bool(report.get("dry_run", False)),
        "source_schema": str(report.get("schema") or ""),
        "generated_at_unix_ns": report.get("generated_at_unix_ns"),
        "summary": report.get("summary") if isinstance(report.get("summary"), dict) else {},
        "install_dir": str(report.get("install_dir") or ""),
        "desktop_entry": str(report.get("desktop_entry") or ""),
        "desktop_entries": desktop_entries,
        "operator_guide": operator_guide,
        "report_path": str(report.get("report_path") or report_path),
        "log_path": str(report.get("log_path") or report_path.with_name("install.latest.log")),
        "checks": redact_support_value(report.get("checks")) if isinstance(report.get("checks"), list) else [],
        "action_plan": redact_support_value(report.get("action_plan")) if isinstance(report.get("action_plan"), list) else [],
    })
    return payload
def tail_text(path: Path, max_lines: int = 240) -> str:
    if not path.exists():
        return ""
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return ""
    return "\n".join(lines[-max_lines:])


def list_artifacts(directory: Path) -> list[dict]:
    artifacts = []
    for name in sorted(ARTIFACT_NAMES):
        path = directory / name
        if path.exists():
            artifacts.append({"name": name, "bytes": path.stat().st_size})
    return artifacts


def read_model_tags(directory: Path):
    """Best-effort capture_context + a one-line metric summary for the sample library."""
    path = directory / "network_model.json"
    if not path.is_file():
        return None
    try:
        m = json.loads(path.read_text(encoding="utf-8"))
    except Exception:  # noqa: BLE001
        return None
    ctx = m.get("capture_context", {}) if isinstance(m, dict) else {}
    tags = {
        "access_type": ctx.get("access_type", "unknown"),
        "operator": ctx.get("operator", ""),
        "location": ctx.get("location", ""),
        "note": ctx.get("note", ""),
        "test_kind": ctx.get("test_kind", "load"),
    }
    a = (m.get("directions", {}) or {}).get("a_to_b") or {}
    if isinstance(a, dict) and a:
        tags["capacity_mbps"] = a.get("capacity_mbps")
        tags["loss_pct"] = (a.get("working_point") or {}).get("loss_pct")
    tcp = m.get("tcp") or {}
    if isinstance(tcp, dict) and tcp:
        tags["up_mbps"] = tcp.get("up_mbps")
        tags["down_mbps"] = tcp.get("down_mbps")
    prof = m.get("profile") or {}
    if isinstance(prof, dict) and prof:
        rtt = (prof.get("summary", {}) or {}).get("combined_rtt_ms", {}) or {}
        tags["rtt_p50_ms"] = rtt.get("p50")
        dirs = prof.get("directions", []) or []
        if dirs:
            tags["loss_pct"] = (dirs[0].get("loss", {}) or {}).get("avg_loss_pct")
    return tags


def content_type_for_artifact(name: str) -> str:
    if name.endswith(".zip"):
        return "application/zip"
    if name.endswith(".html"):
        return "text/html; charset=utf-8"
    return "text/plain; charset=utf-8"


def main() -> int:
    # Windows consoles default to a legacy code page (GBK/cp936) that can't encode
    # symbols like ⚠ — printing the startup banner would raise UnicodeEncodeError and
    # kill the controller on launch. Force UTF-8 (errors='replace' as a belt) so the
    # banner prints safely on every platform.
    for _stream in (sys.stdout, sys.stderr):
        try:
            _stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
        except Exception:
            pass
    parser = argparse.ArgumentParser(description="BestPerf local GUI controller")
    # Bind all interfaces by default: this tool's purpose is two machines measuring each other,
    # so the peer must be able to reach this controller. Token auth (X-FR-Token) protects it.
    # Use --host 127.0.0.1 for a local-only controller.
    parser.add_argument("--host", default="0.0.0.0")
    parser.add_argument("--port", type=int, default=8768)
    args = parser.parse_args()
    server = BestPerfServer((args.host, args.port))
    # Graceful shutdown: turn SIGTERM into the same clean path as Ctrl-C so the `finally` below
    # runs and terminates in-flight run trees (no orphaned agents holding the UDP ports).
    def _graceful_stop(*_):  # noqa: ANN001,ANN202
        raise KeyboardInterrupt
    try:
        signal.signal(signal.SIGTERM, _graceful_stop)
    except (ValueError, OSError):
        pass
    ident = build_identity(server.bind_host, server.port, server.token,
                           lan_allowed=server.lan_allowed, agent_udp_port=server.agent_udp_port)
    print(f"BestPerf controller listening on http://{args.host}:{args.port}", flush=True)
    print("=" * 60, flush=True)
    print(f"  控制台地址 (Console): {ident['console_url']}", flush=True)
    print(f"  访问令牌   (Token)  : {server.token}", flush=True)
    print(f"  本机 IP    (LAN)    : {', '.join(ident['lan_ips'])}", flush=True)
    if not ident["exposed"]:
        print("  ⚠ 默认不允许对端访问;要让别人测向本机,请在控制台打开『允许被别人测向本机』开关",
              flush=True)
    else:
        print("  ✓ 已允许对端访问(可被别人测向本机)", flush=True)
    print("  把【地址 + 令牌】发给要测你的那台机器的操作员,登记成探针即可。", flush=True)
    print("=" * 60, flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        return 130
    finally:
        try:
            shutdown_active_processes(server.state)  # kill in-flight run trees before exit
        finally:
            server.server_close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
