#!/usr/bin/env python3
import argparse
import ipaddress
import json
import os
import pathlib
import platform
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import webbrowser
from typing import Optional


ROOT = pathlib.Path(__file__).resolve().parents[1]
SCHEMA = "field_replay.bootstrap_report.v1"

# Controller health/LAN probes must NOT route through the system HTTP proxy: a Clash/Surge proxy on
# 127.0.0.1:7897 502s LAN addresses, which would falsely read as "controller did not answer".
# Mirrors run_local_session._DIRECT_OPENER.
_DIRECT_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))


def env_flag(name: str) -> bool:
    return os.environ.get(name, "0") == "1"


def default_out_dir() -> pathlib.Path:
    base = os.environ.get("FIELDREPLAY_BOOTSTRAP_OUT") or tempfile.gettempdir()
    return (pathlib.Path(base) / f"bestperf-bootstrap-{time.strftime('%Y%m%d-%H%M%S')}").resolve()


def latest_artifact_dir(out_dir: pathlib.Path, explicit_out: bool) -> pathlib.Path:
    return out_dir if explicit_out else out_dir.parent


def write_latest_artifacts(report: dict,
                           report_path: pathlib.Path,
                           log_path: pathlib.Path,
                           latest_report_path: pathlib.Path,
                           latest_summary_path: pathlib.Path,
                           latest_log_path: pathlib.Path) -> None:
    latest_report_path.parent.mkdir(parents=True, exist_ok=True)
    latest_summary_path.parent.mkdir(parents=True, exist_ok=True)
    latest_log_path.parent.mkdir(parents=True, exist_ok=True)
    latest_report_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
    latest_summary_path.write_text(render_summary(report), encoding="utf-8")
    if log_path.is_file():
        shutil.copy2(log_path, latest_log_path)
    else:
        latest_log_path.write_text("No bootstrap command output was written for this run.\n", encoding="utf-8")


def check_item(check_id: str, label: str, status: str, detail: str, action: str = "") -> dict:
    item = {
        "id": check_id,
        "label": label,
        "status": status,
        "detail": detail,
    }
    if action:
        item["action"] = action
    return item


def step_item(step_id: str, label: str, status: str, detail: str, command: Optional[list[str]] = None) -> dict:
    item = {
        "id": step_id,
        "label": label,
        "status": status,
        "detail": detail,
    }
    if command:
        item["command"] = command
    return item


def action_item(action_id: str,
                label: str,
                severity: str,
                action: str,
                detail: str = "",
                command: Optional[list[str]] = None) -> dict:
    item = {
        "id": action_id,
        "label": label,
        "severity": severity,
        "action": action,
    }
    if detail:
        item["detail"] = detail
    if command:
        item["command"] = command
    return item


def action_id_for(prefix: str, item: dict) -> str:
    item_id = str(item.get("id") or "item").replace("-", "_").replace(" ", "_").lower()
    return f"{prefix}_{item_id}"


def build_action_plan(report: dict) -> list[dict]:
    target = report.get("target", {})
    checks = report.get("checks", [])
    steps = report.get("steps", [])
    actions = []

    for item in checks:
        if item.get("status") == "fail" and item.get("action"):
            actions.append(action_item(
                action_id_for("fix", item),
                f"Fix {item.get('label', item.get('id', 'check'))}",
                "blocker",
                item["action"],
                item.get("detail", ""),
            ))

    has_failed_check = any(item.get("status") == "fail" for item in checks)
    for item in steps:
        if item.get("status") == "fail" and not has_failed_check:
            actions.append(action_item(
                action_id_for("inspect", item),
                f"Inspect {item.get('label', item.get('id', 'step'))}",
                "blocker",
                item.get("detail", "Check this failed startup step."),
                " ".join(item.get("command", [])) if item.get("command") else "",
                item.get("command"),
            ))

    if any(item.get("status") == "warn" for item in checks + steps):
        for item in checks + steps:
            if item.get("status") == "warn" and item.get("action"):
                actions.append(action_item(
                    action_id_for("review", item),
                    f"Review {item.get('label', item.get('id', 'warning'))}",
                    "warning",
                    item["action"],
                    item.get("detail", ""),
                ))
                break

    log_path = str(target.get("log_path") or "")
    if actions and log_path:
        actions.append(action_item(
            "inspect_bootstrap_log",
            "Open bootstrap log",
            "info",
            f"Open {log_path} if the fix above is not enough.",
            "The log contains the exact configure/build/controller command output.",
        ))

    if not actions and report.get("dry_run"):
        actions.append(action_item(
            "run_launcher",
            "Run launcher",
            "next",
            "Run BestPerf again without dry-run to build, start the controller, and open the GUI.",
        ))

    if not actions:
        url = str(target.get("url") or "the BestPerf GUI")
        actions.append(action_item(
            "open_gui",
            "Open GUI",
            "next",
            f"Open {url} and click Doctor before recording a network.",
        ))

    return actions[:5]


def first_issue(report: dict) -> str:
    for collection_name in ["checks", "steps"]:
        collection = report.get(collection_name) if isinstance(report.get(collection_name), list) else []
        for item in collection:
            if not isinstance(item, dict):
                continue
            if item.get("status") not in {"pass", "skipped", "skipped_user"}:
                label = item.get("label") or item.get("id") or "Startup check"
                detail = item.get("detail") or item.get("action") or ""
                return f"{label}: {detail}".strip()
    return ""


def short_issue_text(issue: str) -> str:
    if not issue:
        return ""
    return issue.split(":", 1)[0].strip() or issue


def status_counts(items: list[dict]) -> dict[str, int]:
    counts = {"pass": 0, "warn": 0, "fail": 0}
    for item in items:
        if not isinstance(item, dict):
            continue
        status = str(item.get("status") or "")
        if status == "pass":
            counts["pass"] += 1
        elif status in {"warn", "skipped_dry_run", "skipped_user", "skipped"}:
            counts["warn"] += 1
        elif status == "fail":
            counts["fail"] += 1
    return counts


def zh_status_label(status: str) -> str:
    labels = {
        "ready": "启动成功",
        "review": "需要确认",
        "blocked": "启动失败",
        "failed": "启动失败",
    }
    return labels.get(status or "", status or "未知")


def zh_next_action(action: dict) -> str:
    label = str(action.get("label") or "")
    detail = str(action.get("action") or "")
    text = f"{label} {detail}".lower()
    if "python 3.9" in text:
        return "安装 Python 3.9 或更新版本"
    if action.get("id") == "run_launcher":
        return "正常启动 BestPerf"
    if action.get("id") == "open_gui":
        return "打开 BestPerf 控制台"
    if "cmake" in text:
        return "安装 CMake"
    if "compiler" in text or "visual studio" in text or "xcode" in text:
        return "安装编译工具"
    if label:
        return label
    return "查看启动报告"


def zh_action_steps(report: dict, action: dict) -> list[str]:
    action_text = zh_next_action(action)
    action_id = action.get("id")
    detail = str(action.get("action") or "")
    if "python 3.9" in f"{action_text} {detail}".lower():
        return [
            "安装 Python 3.9 或更新版本。",
            "重新双击 BestPerf 启动入口。",
            "如果还是失败，把整个文件夹压缩后连同截图发给支持人员。",
        ]
    if action_id == "run_launcher":
        return [
            "关闭这个诊断窗口。",
            "重新双击 BestPerf 启动入口。",
            "如果还是失败，把整个文件夹压缩后连同截图发给支持人员。",
        ]
    if action_id == "open_gui":
        url = (report.get("target") or {}).get("url") or "BestPerf 控制台"
        return [
            f"打开 {url}。",
            "点击“开始自动准备”。",
            "检查通过后，按录像机、播放器、验片机的顺序操作。",
        ]
    return [
        f"{action_text}。",
        "重新双击 BestPerf 启动入口。",
        "如果还是失败，把整个文件夹压缩后连同截图发给支持人员。",
    ]


def render_summary(report: dict) -> str:
    target = report.get("target") if isinstance(report.get("target"), dict) else {}
    action_plan = report.get("action_plan") if isinstance(report.get("action_plan"), list) else []
    next_action = action_plan[0] if action_plan and isinstance(action_plan[0], dict) else {}
    checks = report.get("checks") if isinstance(report.get("checks"), list) else []
    steps = report.get("steps") if isinstance(report.get("steps"), list) else []
    check_counts = status_counts(checks)
    step_counts = status_counts(steps)
    access = target.get("access") if isinstance(target.get("access"), dict) else {}
    zh_lines = [
        "BestPerf 启动摘要",
        "",
        f"状态: {zh_status_label(str(report.get('status') or 'unknown'))}",
        "普通用户只看这一段:",
    ]
    for index, step in enumerate(zh_action_steps(report, next_action), 1):
        zh_lines.append(f"{index}. {step}")
    issue = first_issue(report)
    if issue and str(report.get("status") or "") in {"blocked", "failed"}:
        zh_lines.append(f"问题原因: {short_issue_text(issue)}")
    zh_lines.extend([
        "",
        "支持人员再看:",
        "给支持人员: bootstrap_report.latest.json 和 bootstrap.latest.log",
    ])
    lines = [
        *zh_lines,
        "BestPerf Startup Summary",
        "",
        f"Status: {report.get('status', 'unknown')}",
        f"Next action: {next_action.get('label') or 'Review startup report'}",
        f"Next action detail: {next_action.get('action') or ''}",
        "",
        f"Controller URL: {target.get('url') or '-'}",
        f"Controller access: {access.get('label') or '-'} {access.get('detail') or ''}".rstrip(),
        f"Controller bind: {target.get('bind_url') or access.get('bind_url') or '-'}",
        f"Report: {target.get('latest_report_path') or target.get('report_path') or '-'}",
        f"Log: {target.get('latest_log_path') or target.get('log_path') or '-'}",
        "",
        f"Checks: pass={check_counts['pass']} warn={check_counts['warn']} fail={check_counts['fail']}",
        f"Steps: pass={step_counts['pass']} warn={step_counts['warn']} fail={step_counts['fail']}",
    ]
    if issue:
        lines.append(f"First issue: {issue}")
    return "\n".join(lines) + "\n"


def find_agent_binary() -> Optional[pathlib.Path]:
    # Release packages ship PREBUILT binaries in bin/ under the current name (bperf-agent); a dev
    # tree builds them under build/. Check bin/ first (so an installed package never thinks it must
    # rebuild and demands CMake), keep the legacy fr-agent name as a fallback.
    names = ["bperf-agent", "fr-agent"]
    dirs = [
        ROOT / "bin",
        ROOT / "build",
        ROOT / "build" / "Release",
        ROOT / "build" / "Debug",
        ROOT / "build" / "RelWithDebInfo",
    ]
    candidates = []
    for d in dirs:
        for nm in names:
            candidates.append(d / nm)
            candidates.append(d / (nm + ".exe"))
    for candidate in candidates:
        if candidate.is_file():
            return candidate
    return None


def find_cxx_compiler() -> Optional[str]:
    env_cxx = os.environ.get("CXX")
    if env_cxx:
        return env_cxx
    candidates = ["cl", "clang-cl", "c++", "clang++", "g++"]
    for name in candidates:
        found = shutil.which(name)
        if found:
            return found
    return None


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


def read_release_metadata(root: Optional[pathlib.Path] = None) -> dict:
    root = root or ROOT
    runtime = runtime_platform()
    manifest_path = root / "release_manifest.json"
    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 url_for(host: str, port: int, path: str = "/") -> str:
    display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
    return f"http://{display_host}:{port}{path}"


def is_wildcard_host(host: str) -> bool:
    return str(host or "").strip().lower() in {"0.0.0.0", "::", "[::]"}


def is_loopback_host(host: str) -> bool:
    value = str(host or "").strip().lower()
    return value in {"localhost", "::1", "[::1]"} or value.startswith("127.")


def controller_probe_host(host: str) -> str:
    return "127.0.0.1" if is_wildcard_host(host) or str(host).lower() == "localhost" else host


def is_preferred_lan_ip(value: str) -> bool:
    try:
        ip = ipaddress.ip_address(str(value or "").strip())
    except ValueError:
        return False
    if ip.version != 4:
        return False
    if ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
        return False
    if ip in ipaddress.ip_network("198.18.0.0/15"):
        return False
    return ip.is_private


def pick_lan_ip(candidates: list[str]) -> str:
    for candidate in candidates:
        if is_preferred_lan_ip(candidate):
            return str(ipaddress.ip_address(candidate.strip()))
    return ""


def collect_lan_ip_candidates() -> list[str]:
    candidates = []
    if os.environ.get("FIELDREPLAY_LAN_IP"):
        candidates.append(os.environ["FIELDREPLAY_LAN_IP"])

    try:
        for item in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
            candidates.append(item[4][0])
    except OSError:
        pass

    if platform.system() == "Darwin":
        for interface in ["en0", "en1", "en2", "en3", "bridge0", "bridge100"]:
            try:
                output = subprocess.check_output(
                    ["ipconfig", "getifaddr", interface],
                    text=True,
                    stderr=subprocess.DEVNULL,
                    timeout=0.4,
                ).strip()
                if output:
                    candidates.append(output)
            except (OSError, subprocess.SubprocessError):
                pass

    hostname_bin = shutil.which("hostname")
    if hostname_bin:
        try:
            output = subprocess.check_output(
                [hostname_bin, "-I"],
                text=True,
                stderr=subprocess.DEVNULL,
                timeout=0.4,
            )
            candidates.extend(output.split())
        except (OSError, subprocess.SubprocessError):
            pass

    ip_bin = shutil.which("ip")
    if ip_bin:
        try:
            output = subprocess.check_output(
                [ip_bin, "-o", "-4", "addr", "show", "scope", "global"],
                text=True,
                stderr=subprocess.DEVNULL,
                timeout=0.4,
            )
            words = output.replace("/", " /").split()
            for index, word in enumerate(words):
                if word == "inet" and index + 1 < len(words):
                    candidates.append(words[index + 1].split("/", 1)[0])
        except (OSError, subprocess.SubprocessError):
            pass

    if platform.system() == "Windows":
        try:
            output = subprocess.check_output(
                ["ipconfig"],
                text=True,
                stderr=subprocess.DEVNULL,
                timeout=0.8,
            )
            for line in output.splitlines():
                if "IPv4" in line and ":" in line:
                    candidates.append(line.rsplit(":", 1)[1].strip())
        except (OSError, subprocess.SubprocessError):
            pass

    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.connect(("8.8.8.8", 80))
            candidates.append(sock.getsockname()[0])
    except OSError:
        pass
    return candidates


def detect_lan_ip() -> str:
    return pick_lan_ip(collect_lan_ip_candidates())


def describe_controller_access(host: str, port: int) -> dict:
    bind_url = url_for(host, port, "/")
    if is_loopback_host(host):
        return {
            "scope": "local",
            "label": "本机控制台",
            "url": url_for("127.0.0.1", port, "/"),
            "bind_url": bind_url,
            "detail": "只能在这台 Agent 上打开；其他 Agent 可以各自启动自己的控制台。",
        }

    if is_wildcard_host(host):
        lan_ip = detect_lan_ip()
        access_host = lan_ip or "<this-agent-ip>"
        return {
            "scope": "lan",
            "label": "局域网控制台",
            "url": url_for(access_host, port, "/"),
            "bind_url": bind_url,
            "detail": "局域网内其他电脑可打开这台 Agent 的控制台；只在可信网络使用，并确认防火墙放行。",
        }

    return {
        "scope": "lan",
        "label": "局域网控制台",
        "url": bind_url,
        "bind_url": bind_url,
        "detail": "浏览器可打开这台 Agent 的控制台；只在可信网络使用，并确认防火墙放行。",
    }


def controller_health(host: str, port: int, timeout: float = 0.6) -> bool:
    try:
        with _DIRECT_OPENER.open(url_for(host, port, "/api/health"), timeout=timeout) as response:
            payload = json.loads(response.read().decode("utf-8"))
            return response.status == 200 and payload.get("ok") is True
    except (OSError, urllib.error.URLError, json.JSONDecodeError, TimeoutError):
        return False


def controller_bind_host(host: str, port: int, timeout: float = 0.6) -> str:
    """The bind host a running controller reports via /api/identity ("" if unreachable/unknown).
    /api/identity is an open endpoint (no token) and always echoes bind_host, so the launcher can
    tell a wildcard-bound (LAN-reachable) controller from a loopback-only one."""
    try:
        with _DIRECT_OPENER.open(url_for(host, port, "/api/identity"), timeout=timeout) as response:
            payload = json.loads(response.read().decode("utf-8"))
            return str(payload.get("bind_host") or "")
    except (OSError, urllib.error.URLError, json.JSONDecodeError, TimeoutError):
        return ""


def controller_reusable(probe_host: str, port: int, want_wildcard: bool) -> bool:
    """True if an already-healthy controller on probe_host:port serves the access we're asking for.
    For a wildcard (LAN) request, ONLY a controller that is itself wildcard-bound qualifies -- reusing
    a loopback-only one would leave peers unable to connect, so we'd rather start a fresh LAN one. For
    a loopback request any healthy controller works. (controller_health short-circuits, so the extra
    identity probe only runs when something is actually answering on the port.)"""
    if not controller_health(probe_host, port):
        return False
    if not want_wildcard:
        return True
    return is_wildcard_host(controller_bind_host(probe_host, port))


def port_is_free(host: str, port: int) -> bool:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            sock.bind((host, port))
        except (OSError, OverflowError):  # OverflowError: port outside 0-65535 (NOT an OSError subclass)
            return False
    return True


def select_controller_port(host: str, requested_port: int, allow_auto: bool, max_attempts: int = 20) -> dict:
    probe_host = controller_probe_host(host)
    wildcard_bind = is_wildcard_host(host)
    # Reuse an already-healthy controller that SERVES THE ACCESS WE WANT. The old `not wildcard_bind`
    # guard skipped reuse for the default 0.0.0.0 host entirely, so every relaunch found the port busy
    # and auto-selected the NEXT one — stacking a second controller (8768, 8769, …) the firewall rule
    # and saved peer configs never point at. We now reuse a wildcard-bound controller (the common
    # relaunch case) but still start fresh past a LOOPBACK-only one when LAN access was requested.
    if controller_reusable(probe_host, requested_port, wildcard_bind):
        return {
            "requested_port": requested_port,
            "port": requested_port,
            "auto_selected": False,
            "detail": f"BestPerf controller already healthy on {requested_port}",
        }
    if port_is_free(host, requested_port):
        return {
            "requested_port": requested_port,
            "port": requested_port,
            "auto_selected": False,
            "detail": f"port {requested_port} is available",
        }
    if not allow_auto:
        return {
            "requested_port": requested_port,
            "port": requested_port,
            "auto_selected": False,
            "detail": f"port {requested_port} is busy",
        }

    for offset in range(1, max_attempts + 1):
        candidate = requested_port + offset
        if candidate > 65535:
            break
        if controller_reusable(probe_host, candidate, wildcard_bind) or port_is_free(host, candidate):
            return {
                "requested_port": requested_port,
                "port": candidate,
                "auto_selected": True,
                "detail": f"port {requested_port} is busy; selected {candidate}",
            }

    return {
        "requested_port": requested_port,
        "port": requested_port,
        "auto_selected": False,
        "detail": f"port {requested_port} is busy and no fallback port was available",
    }


def run_command(command: list[str], cwd: pathlib.Path, log_path: pathlib.Path, timeout: int = 900) -> subprocess.CompletedProcess:
    with log_path.open("a", encoding="utf-8") as log:
        log.write("$ " + " ".join(command) + "\n")
        log.flush()
        proc = subprocess.run(
            command,
            cwd=str(cwd),
            text=True,
            stdout=log,
            stderr=subprocess.STDOUT,
            timeout=timeout,
        )
        log.write(f"exit={proc.returncode}\n")
        return proc


def start_controller(host: str,
                     port: int,
                     log_path: pathlib.Path,
                     bootstrap_report_path: Optional[pathlib.Path] = None) -> subprocess.Popen:
    command = [
        sys.executable,
        str(ROOT / "scripts" / "bestperf_controller.py"),
        "--host",
        host,
        "--port",
        str(port),
    ]
    log = log_path.open("a", encoding="utf-8")
    log.write("$ " + " ".join(command) + "\n")
    log.flush()
    kwargs = {}
    if os.name == "nt":
        kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
    else:
        kwargs["start_new_session"] = True
    env = os.environ.copy()
    if bootstrap_report_path is not None:
        env["FIELDREPLAY_BOOTSTRAP_REPORT"] = str(bootstrap_report_path)
    try:
        return subprocess.Popen(
            command,
            cwd=str(ROOT),
            env=env,
            stdout=log,
            stderr=subprocess.STDOUT,
            text=True,
            **kwargs,
        )
    finally:
        log.close()


def wait_for_controller(host: str, port: int, deadline_sec: float = 8.0) -> bool:
    deadline = time.time() + deadline_sec
    while time.time() < deadline:
        if controller_health(host, port, timeout=0.8):
            return True
        time.sleep(0.2)
    return False


def overall_status(checks: list[dict], steps: list[dict], dry_run: bool) -> str:
    statuses = [item.get("status") for item in checks + steps]
    if any(status == "fail" for status in statuses):
        return "blocked"
    if dry_run or any(status in {"warn", "skipped_dry_run", "skipped_user"} for status in statuses):
        return "review"
    return "ready"


def build_report(args: argparse.Namespace) -> dict:
    dry_run = args.dry_run or env_flag("FR_DRY_RUN")
    explicit_out = bool(args.out)
    out_dir = pathlib.Path(args.out).expanduser().resolve() if args.out else default_out_dir()
    out_dir.mkdir(parents=True, exist_ok=True)
    log_path = out_dir / "bootstrap.log"
    report_path = out_dir / "bootstrap_report.json"
    summary_path = out_dir / "bootstrap_summary.txt"
    latest_dir = latest_artifact_dir(out_dir, explicit_out)
    latest_report_path = latest_dir / "bootstrap_report.latest.json"
    latest_summary_path = latest_dir / "bootstrap_summary.latest.txt"
    latest_log_path = latest_dir / "bootstrap.latest.log"
    controller_access = describe_controller_access(args.host, args.port)
    controller_url = controller_access["url"]
    probe_host = controller_probe_host(args.host)
    port_selection_detail = getattr(args, "port_selection_detail", "")

    release = read_release_metadata()
    agent = find_agent_binary()
    cmake = shutil.which("cmake")
    compiler = find_cxx_compiler()
    controller_script = ROOT / "scripts" / "bestperf_controller.py"
    needs_build = args.rebuild or agent is None

    checks = [
        check_item(
            "python",
            "Python",
            "pass" if sys.version_info >= (3, 9) else "fail",
            f"{platform.python_implementation()} {platform.python_version()}",
            "Install Python 3.9 or newer" if sys.version_info < (3, 9) else "",
        ),
        check_item(
            "cmake",
            "CMake",
            "pass" if cmake else ("fail" if needs_build else "warn"),
            cmake or "not found",
            "Install CMake, then run BestPerf again" if not cmake else "",
        ),
        check_item(
            "cxx_compiler",
            "C++ compiler",
            "pass" if compiler else ("fail" if needs_build else "warn"),
            compiler or "not found",
            "Install Visual Studio Build Tools, Xcode Command Line Tools, clang, or g++" if not compiler else "",
        ),
        check_item(
            "controller_script",
            "Controller script",
            "pass" if controller_script.is_file() else "fail",
            str(controller_script),
            "Run this launcher from the BestPerf project folder" if not controller_script.is_file() else "",
        ),
    ]
    if getattr(args, "port_auto_selected", False):
        checks.append(check_item(
            "controller_port",
            "Controller port",
            "pass",
            port_selection_detail or f"selected {args.port}",
        ))
    if release.get("available"):
        package_platform = release.get("package_platform", {})
        checks.append(check_item(
            "release_package",
            "Release package",
            "pass" if release.get("platform_match") else "warn",
            f"{release.get('name') or 'BestPerf'} {release.get('version') or '-'} for {package_platform.get('system', '-')}/{package_platform.get('machine', '-')}",
            "Use a BestPerf package built for this operating system and CPU architecture" if not release.get("platform_match") else "",
        ))

    steps = []
    configure = ["cmake", "-S", str(ROOT), "-B", str(ROOT / "build")]
    build = ["cmake", "--build", str(ROOT / "build"), "--config", "Release"]
    dependency_failed = any(item["status"] == "fail" for item in checks)

    if dry_run:
        steps.append(step_item("configure_build", "Configure build", "skipped_dry_run", "dry-run only", configure))
        steps.append(step_item("build_binaries", "Build binaries", "skipped_dry_run", "dry-run only", build))
    elif needs_build and dependency_failed:
        steps.append(step_item("configure_build", "Configure build", "fail", "required dependency missing", configure))
        steps.append(step_item("build_binaries", "Build binaries", "fail", "required dependency missing", build))
    elif needs_build:
        proc = run_command(configure, ROOT, log_path)
        steps.append(step_item(
            "configure_build",
            "Configure build",
            "pass" if proc.returncode == 0 else "fail",
            f"exit {proc.returncode}",
            configure,
        ))
        if proc.returncode == 0:
            proc = run_command(build, ROOT, log_path)
            steps.append(step_item(
                "build_binaries",
                "Build binaries",
                "pass" if proc.returncode == 0 else "fail",
                f"exit {proc.returncode}",
                build,
            ))
            agent = find_agent_binary()
        else:
            steps.append(step_item("build_binaries", "Build binaries", "fail", "configure failed", build))
    else:
        steps.append(step_item("configure_build", "Configure build", "skipped", "agent binary already exists", configure))
        steps.append(step_item("build_binaries", "Build binaries", "skipped", str(agent), build))

    start_command = [
        sys.executable,
        str(controller_script),
        "--host",
        args.host,
        "--port",
        str(args.port),
    ]
    build_failed = any(item["id"] in {"configure_build", "build_binaries"} and item["status"] == "fail" for item in steps)
    wildcard_bind = is_wildcard_host(args.host)
    if dry_run:
        steps.append(step_item("start_controller", "Start GUI controller", "skipped_dry_run", "dry-run only", start_command))
    elif args.no_start:
        steps.append(step_item("start_controller", "Start GUI controller", "skipped_user", "disabled by --no-start", start_command))
    elif controller_reusable(probe_host, args.port, wildcard_bind):
        steps.append(step_item("start_controller", "Start GUI controller", "pass", "already running", start_command))
    elif not port_is_free(args.host, args.port):
        steps.append(step_item("start_controller", "Start GUI controller", "fail", f"port {args.port} is already in use", start_command))
    elif build_failed:
        steps.append(step_item("start_controller", "Start GUI controller", "fail", "build failed", start_command))
    else:
        proc = start_controller(args.host, args.port, log_path, report_path)
        started = wait_for_controller(probe_host, args.port)
        steps.append(step_item(
            "start_controller",
            "Start GUI controller",
            "pass" if started else "fail",
            f"pid {proc.pid}" if started else f"controller did not answer on {controller_url}",
            start_command,
        ))

    if dry_run:
        steps.append(step_item("open_browser", "Open browser", "skipped_dry_run", "dry-run only"))
    elif args.no_open:
        steps.append(step_item("open_browser", "Open browser", "skipped", "disabled by --no-open"))
    elif any(item["id"] == "start_controller" and item["status"] == "pass" for item in steps):
        # Always open the LOCAL operator's console on loopback (127.0.0.1) even when the controller
        # binds 0.0.0.0. The controller hands the access token ONLY to a loopback caller, so opening
        # the LAN IP here would make the local GUI ask the operator to paste their own token. The LAN
        # URL (controller_url) is for peers; the local browser must stay on loopback.
        local_url = url_for(probe_host, args.port, "/")
        opened = webbrowser.open(local_url)
        steps.append(step_item("open_browser", "Open browser", "pass" if opened else "warn", local_url))
    else:
        steps.append(step_item("open_browser", "Open browser", "fail", "controller is not ready"))

    report = {
        "schema": SCHEMA,
        "status": "review",
        "dry_run": dry_run,
        "generated_at_unix_ns": time.time_ns(),
        "platform": runtime_platform(),
        "release": release,
        "target": {
            "host": args.host,
            "port": args.port,
            "requested_port": getattr(args, "requested_port", args.port),
            "port_auto_selected": bool(getattr(args, "port_auto_selected", False)),
            "port_selection_detail": port_selection_detail,
            "url": controller_url,
            "bind_url": controller_access["bind_url"],
            "access": controller_access,
            "out_dir": str(out_dir),
            "report_path": str(report_path),
            "summary_path": str(summary_path),
            "log_path": str(log_path),
            "latest_report_path": str(latest_report_path),
            "latest_summary_path": str(latest_summary_path),
            "latest_log_path": str(latest_log_path),
            "agent_binary": str(agent) if agent else "",
        },
        "checks": checks,
        "steps": steps,
    }
    report["status"] = overall_status(checks, steps, dry_run)
    report["action_plan"] = build_action_plan(report)
    report_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
    summary_path.write_text(render_summary(report), encoding="utf-8")
    write_latest_artifacts(report, report_path, log_path, latest_report_path, latest_summary_path, latest_log_path)
    return report


def main() -> int:
    parser = argparse.ArgumentParser(
        description="One-click BestPerf setup and GUI launcher",
        epilog="Writes bootstrap_summary.txt, bootstrap_report.json, and bootstrap.log with dependency checks, build/start steps, and the GUI URL.",
    )
    # Bind 0.0.0.0 by default so the in-GUI "allow others to test me" toggle works instantly
    # (no restart). Until the operator turns it on, the controller refuses every non-loopback
    # request (403) before auth — so binding wide is safe; LAN access is the toggle, not the bind.
    parser.add_argument("--host", default=os.environ.get("FIELDREPLAY_HOST", "0.0.0.0"), help="GUI controller bind host")
    parser.add_argument("--port", type=int, default=None, help="GUI controller port")
    parser.add_argument("--out", default="", help="Directory for bootstrap_report.json and bootstrap.log")
    parser.add_argument("--dry-run", action="store_true", help="Check and report without building or starting services")
    parser.add_argument("--no-open", action="store_true", help="Do not open the browser")
    parser.add_argument("--no-start", action="store_true", help="Do not start the GUI controller")
    parser.add_argument("--rebuild", action="store_true", help="Force CMake configure/build even if fr-agent exists")
    parser.add_argument("--json", action="store_true", help="Print the full JSON report")
    args = parser.parse_args()

    env_port = os.environ.get("FIELDREPLAY_PORT")
    explicit_port = args.port is not None or env_port is not None
    try:
        requested_port = int(args.port if args.port is not None else env_port or "8768")
        if not (1 <= requested_port <= 65535):  # numeric but out-of-range -> bind() would OverflowError
            raise ValueError(f"port {requested_port} out of range 1-65535")
    except (ValueError, TypeError):
        # A non-numeric OR out-of-range FIELDREPLAY_PORT shouldn't crash the launcher with a raw
        # traceback: fall back to the default and let auto-select pick a free port.
        requested_port, explicit_port = 8768, False
    port_selection = select_controller_port(args.host, requested_port, allow_auto=not explicit_port)
    args.requested_port = port_selection["requested_port"]
    args.port = port_selection["port"]
    args.port_auto_selected = port_selection["auto_selected"]
    args.port_selection_detail = port_selection["detail"]

    report = build_report(args)
    if args.json:
        print(json.dumps(report, indent=2, sort_keys=True))
    else:
        print(f"DRY_RUN={1 if report['dry_run'] else 0}")
        print(f"status={report['status']}")
        print(f"controller_url={report['target']['url']}")
        print(f"controller_access={report['target'].get('access', {}).get('label', '')}")
        print(f"controller_bind={report['target'].get('bind_url', '')}")
        print(f"bootstrap_report={report['target']['report_path']}")
        print(f"bootstrap_summary={report['target']['summary_path']}")
        print(f"latest_report={report['target']['latest_report_path']}")
        print(f"latest_summary={report['target']['latest_summary_path']}")
        print(f"latest_log={report['target']['latest_log_path']}")
        if report.get("action_plan"):
            print(f"next_action={report['action_plan'][0]['action']}")
    return 0 if report["status"] in {"ready", "review"} else 2


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