"""
HTTP + WebSocket server for the AGIBOT X2 dashboard.

Binds 0.0.0.0 so the dashboard answers on every interface this machine holds.
The browser derives its API and WebSocket URLs from window.location, so opening
the page from a laptop, a phone, or a tablet all work with no configuration and
no hardcoded address anywhere.
"""

from __future__ import annotations

import asyncio
import contextlib
import hashlib
import re
import time
from pathlib import Path
from typing import Any

from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles

from . import netinfo, settings, voice_session, x2_spec
from .bridge_agent import AgentBridge
from .bridge_mock import MockBridge
from .hub import Hub
from .registry import Registry

ROOT = Path(__file__).resolve().parent.parent
WEB_DIR = ROOT / "web"

app = FastAPI(title="AGIBOT X2 Dashboard", version="1.0.0", docs_url="/api/docs")

# The dashboard is meant to be opened from any device on the LAN, so the browser
# origin varies with whichever IP was used to reach it.
app.add_middleware(
    CORSMiddleware,
    allow_origin_regex=r"https?://.*",
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.middleware("http")
async def track_clients(request: Request, call_next):
    """
    Remember which devices have actually reached this server.

    This is the only way to tell, without standing next to the person holding
    the phone, whether a failure is "the phone cannot resolve the name" or "the
    phone cannot reach this machine at all". The first is an mDNS/multicast
    problem; the second is the network or the firewall. They need opposite fixes.
    """
    client = request.client.host if request.client else None
    if client and client not in ("127.0.0.1", "::1"):
        seen = runtime.clients_seen.setdefault(client, {"first": time.time(), "hits": 0})
        seen["hits"] += 1
        seen["last"] = time.time()
        seen["agent"] = (request.headers.get("user-agent") or "")[:120]
    return await call_next(request)


@app.middleware("http")
async def no_stale_frontend(request: Request, call_next):
    """
    Stop browsers serving a stale frontend.

    The dashboard ships unversioned filenames, so a browser that cached
    /js/tabs/vision.js will happily keep running it against a newer API - which
    is exactly how the Vision tab ended up throwing "Cannot convert undefined or
    null to object" after the camera list changed shape.

    `no-store` rather than `no-cache`: no-cache still permits the browser to
    keep the entry and reuse it under heuristic freshness, which is how a copy
    stored *before* these headers existed survives a normal reload. no-store
    forbids storing it at all. Combined with the build-stamped import URLs in
    version_js() below, one ordinary reload is always enough.

    API responses are untouched; they set their own caching.
    """
    response = await call_next(request)
    path = request.url.path
    if path.startswith("/model/"):
        # The exception. /model/model.bin is 1.4 MB of baked geometry that only
        # changes when build_model.py is re-run, and the Twin tab fetches it on
        # every visit. Re-downloading that over the robot's Wi-Fi to redraw the
        # same unchanged robot is precisely the waste the rest of this dashboard
        # goes out of its way to avoid.
        response.headers["Cache-Control"] = "public, max-age=604800"
    elif not path.startswith("/api/") and path != "/ws":
        response.headers["Cache-Control"] = "no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
    return response


# --------------------------------------------------------------------------
# Frontend serving with build-stamped module URLs
# --------------------------------------------------------------------------

# Matches the module specifiers this codebase uses: `from './core.js'`,
# `from '../ui.js'`, `import('./tabs/x.js')`. Deliberately narrow - it only
# touches relative paths ending in .js that sit in an import position.
_IMPORT_RE = re.compile(
    r"""(\bfrom\s+|\bimport\s*\(\s*)(['"])(\.{1,2}/[^'"]+?\.js)\2"""
)


def frontend_build() -> str:
    """A short hash of every file under web/, so any edit changes the URL."""
    parts = []
    for path in sorted(WEB_DIR.rglob("*")):
        if path.is_file():
            stat = path.stat()
            parts.append(f"{path.name}:{stat.st_mtime_ns}:{stat.st_size}")
    return hashlib.sha1("|".join(parts).encode()).hexdigest()[:12]


def version_js(source: str, build: str) -> str:
    """
    Stamp every relative import with the current build.

    Versioning only the entry point is not enough: the browser would fetch a
    fresh main.js and then satisfy its `./core.js` import from cache. Rewriting
    the whole module graph means one navigation pulls a consistent set.
    """
    return _IMPORT_RE.sub(
        lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}?b={build}{m.group(2)}",
        source,
    )


def _safe_web_path(*parts: str) -> Path:
    target = (WEB_DIR.joinpath(*parts)).resolve()
    if not str(target).startswith(str(WEB_DIR.resolve())):
        raise HTTPException(status_code=404, detail="Not found")
    return target


@app.get("/js/{path:path}")
async def serve_module(path: str):
    target = _safe_web_path("js", path)
    if not target.is_file() or target.suffix != ".js":
        raise HTTPException(status_code=404, detail="Not found")
    body = version_js(target.read_text(encoding="utf-8"), frontend_build())
    return Response(body, media_type="text/javascript; charset=utf-8",
                    headers={"Cache-Control": "no-store"})


class Runtime:
    """Holds the live objects the request handlers reach for."""

    def __init__(self) -> None:
        config = settings.load()
        self.hub = Hub(history_seconds=config["history_seconds"],
                       sample_hz=config["telemetry_hz"])
        self.registry = Registry(self.hub, config)
        self.bridge = None
        self.bridge_error = ""
        self.started_at = time.time()
        self._scan_task: asyncio.Task | None = None
        self.announcer = None
        self.clients_seen: dict[str, dict] = {}

    # -- name advertisement -------------------------------------------------

    def start_announcer(self) -> None:
        """
        Publish <dashboard_name>.local so the link is about the robot.

        Deliberately runs inside this process rather than as a helper: on
        Windows the inbound firewall rules for UDP 5353 are program-scoped, and
        this interpreter is the one already permitted to receive traffic from
        other devices. A helper under a different interpreter would answer every
        local test and still be invisible to a phone.
        """
        config = settings.load()
        if not config.get("advertise_name"):
            return
        name = (config.get("dashboard_name") or "").strip()
        if not name:
            return

        from .announce import Announcer

        self.announcer = Announcer(
            name=name,
            port=int(config.get("port") or 8770),
            address_provider=netinfo.primary_address,
        )
        if not self.announcer.start():
            # Not fatal - the IP and the PC's own name still work.
            self.hub.log("warn", "network",
                         f"Could not advertise {self.announcer.name}: {self.announcer.error}")

    def stop_announcer(self) -> None:
        if self.announcer is not None:
            self.announcer.stop()
            self.announcer = None

    # -- live address watching ----------------------------------------------

    async def watch_address(self) -> None:
        """
        Re-check the LAN address continuously and push changes to open pages.

        The published URL is only useful if it is current. Roaming to another
        Wi-Fi, a DHCP renewal or simply unplugging changes it, and a browser
        left open on the Settings tab would otherwise keep showing an address
        that stopped working. Detection costs ~4 ms and is cached for 2 s, so
        this loop is effectively free.
        """
        last: str | None = None
        misses = 0

        while True:
            try:
                await asyncio.sleep(3.0)
                detail = await asyncio.to_thread(netinfo.address_detail)
                current = detail["ip"]

                # A brief gap is normal during a DHCP renewal or a roam, so do
                # not announce "no network" on the first empty read. But never
                # keep showing an address we know is gone.
                if current is None:
                    misses += 1
                    if misses < 2 and last is not None:
                        continue
                else:
                    misses = 0

                if current == last:
                    continue

                if last is not None:
                    where = current or detail["reason_text"] or "no network"
                    await self.hub.emit("info", "network",
                                        f"Dashboard address changed: {last} -> {where}")
                last = current

                config = settings.load()
                await self.hub.broadcast("network", {
                    "urls": netinfo.dashboard_urls(config["port"],
                                                   config.get("dashboard_name")),
                    "address": detail,
                    "port": config["port"],
                })

                # Keep the advertised name pointing at the new address.
                if self.announcer is not None and current:
                    self.announcer.ip = current
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                log_once = getattr(self, "_watch_warned", False)
                if not log_once:
                    self._watch_warned = True
                    await self.hub.emit("warn", "network",
                                        f"Address watcher error: {exc}")
                await asyncio.sleep(5.0)

    # -- bridge selection ---------------------------------------------------

    async def start_bridge(self) -> None:
        config = settings.load()
        mode = config.get("bridge_mode", "auto")
        self.bridge_error = ""

        if mode in ("auto", "agent"):
            # With auto-discovery on we can start without a saved address - the
            # bridge sweeps the network for the robot itself.
            if config.get("robot_host") or config.get("auto_discover"):
                try:
                    self.bridge = AgentBridge(self.hub, config)
                    await self.bridge.start()
                    self.registry.attach(self.bridge)
                    return
                except Exception as exc:
                    self.bridge_error = str(exc)
                    self.bridge = None
                    await self.hub.emit("error", "bridge", f"Agent bridge failed: {exc}")
                    if mode == "agent":
                        raise
            else:
                self.bridge_error = "No robot host is configured."
                if mode == "agent":
                    raise RuntimeError(
                        "bridge_mode is 'agent' but no robot_host is set. "
                        "Set it in the Settings tab or pass --robot <ip>."
                    )
                await self.hub.emit(
                    "warn", "bridge",
                    "No robot host configured - running in simulation. "
                    "Set the robot address in Settings to attach to the real X2.",
                )

        self.bridge = MockBridge(self.hub, config)
        await self.bridge.start()
        self.registry.attach(self.bridge)

    async def stop_bridge(self) -> None:
        if self.bridge is not None:
            with contextlib.suppress(Exception):
                await self.bridge.stop()
            self.bridge = None

    async def restart_bridge(self) -> dict:
        await self.stop_bridge()
        try:
            await self.start_bridge()
        except Exception as exc:
            self.bridge_error = str(exc)
            return {"ok": False, "message": str(exc)}
        await self.registry.load()
        return {"ok": True, "message": f"Bridge restarted ({self.bridge.name})",
                "detail": {"transport": self.bridge.name}}


runtime = Runtime()


def require_bridge():
    if runtime.bridge is None:
        raise HTTPException(status_code=503, detail="No bridge is running. Check the Settings tab.")
    return runtime.bridge


def result(command_result) -> JSONResponse:
    payload = command_result.as_dict()
    return JSONResponse(payload, status_code=200 if payload["ok"] else 400)


# --------------------------------------------------------------------------
# Lifecycle
# --------------------------------------------------------------------------

@app.on_event("startup")
async def _startup() -> None:
    runtime.start_announcer()
    runtime.address_task = asyncio.create_task(runtime.watch_address(), name="address-watch")
    await runtime.start_bridge()
    await runtime.registry.load()


@app.on_event("shutdown")
async def _shutdown() -> None:
    task = getattr(runtime, "address_task", None)
    if task:
        task.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await task
    runtime.stop_announcer()
    await runtime.registry.shutdown()
    await runtime.stop_bridge()


# --------------------------------------------------------------------------
# Bootstrap and state
# --------------------------------------------------------------------------

@app.get("/api/bootstrap")
async def bootstrap() -> dict:
    """One request the page makes on load: spec, config, state, plugins, network."""
    config = settings.load()
    return {
        "spec": x2_spec.client_spec(),
        "settings": settings.describe(),
        "state": runtime.bridge.snapshot() if runtime.bridge else None,
        "plugins": runtime.registry.manifest(),
        "events": runtime.hub.events(120),
        "bridge": {
            "name": runtime.bridge.name if runtime.bridge else "none",
            "simulated": runtime.bridge.simulated if runtime.bridge else True,
            "online": bool(runtime.bridge and runtime.bridge.state.connection.online),
            "error": runtime.bridge_error or (
                runtime.bridge.state.connection.error if runtime.bridge else ""),
            "agent": (runtime.bridge.state.custom.get("agent")
                      if runtime.bridge else None),
            "host": config.get("robot_host", ""),
            "agent_port": config.get("agent_port", x2_spec.AGENT_PORT),
        },
        "network": {
            "addresses": netinfo.local_addresses(),
            "networks": netinfo.local_networks(),
            "urls": netinfo.dashboard_urls(config["port"], config.get("dashboard_name")),
            "address": netinfo.address_detail(),
            "advertised": runtime.announcer.status() if runtime.announcer else None,
            "port": config["port"],
        },
        "server": {
            "version": app.version,
            "uptime_s": round(time.time() - runtime.started_at, 1),
            "clients": runtime.hub.client_count,
        },
    }


@app.get("/api/state")
async def get_state() -> dict:
    return require_bridge().snapshot()


@app.get("/api/series")
async def get_series(keys: str = "", limit: int = 600) -> dict:
    if keys:
        wanted = [k.strip() for k in keys.split(",") if k.strip()]
        return {k: runtime.hub.series(k, limit) for k in wanted}
    return runtime.hub.all_series(limit)


@app.get("/api/series/keys")
async def series_keys() -> dict:
    return {"keys": runtime.hub.series_keys()}


@app.get("/api/events")
async def get_events(limit: int = 200) -> dict:
    return {"events": runtime.hub.events(limit)}


@app.get("/api/topics")
async def get_topics(graph: bool = False) -> dict:
    """Topic liveness, plus the full ROS graph on request (it is a heavy call)."""
    bridge = require_bridge()
    payload = {
        "tracked": await bridge.list_topics(),
        "documented": x2_spec.SENSOR_TOPICS,
        "graph": {"topics": [], "services": [], "nodes": []},
    }
    if graph and hasattr(bridge, "graph"):
        try:
            payload["graph"] = await bridge.graph()
        except Exception as exc:
            payload["graph_error"] = str(exc)
    return payload


@app.get("/api/health")
async def health() -> dict:
    bridge = runtime.bridge
    return {
        "ok": bridge is not None and bridge.state.connection.online,
        "bridge": bridge.name if bridge else "none",
        "simulated": bridge.simulated if bridge else True,
        "clients": runtime.hub.client_count,
        "uptime_s": round(time.time() - runtime.started_at, 1),
    }


# --------------------------------------------------------------------------
# Settings and networking
# --------------------------------------------------------------------------

@app.get("/api/settings")
async def get_settings() -> dict:
    return settings.describe()


@app.post("/api/settings")
async def post_settings(payload: dict = Body(...)) -> dict:
    before = settings.load()
    after = settings.save(payload)

    # Changing any of these means the bridge has to be rebuilt to take effect.
    restart_keys = ("robot_host", "ros_domain_id", "rmw_implementation", "bridge_mode")
    needs_restart = any(before.get(k) != after.get(k) for k in restart_keys)

    if runtime.bridge is not None:
        await runtime.bridge.reconfigure(after)
    runtime.registry.config = after

    await runtime.hub.broadcast("settings", settings.describe())
    return {"ok": True, "settings": settings.describe(), "needs_restart": needs_restart}


@app.get("/api/network")
async def get_network() -> dict:
    config = settings.load()

    local = {a["address"] for a in netinfo.local_addresses()}
    others = [
        {"address": ip, **info}
        for ip, info in sorted(runtime.clients_seen.items())
        if ip not in local and ip != config.get("robot_host")
    ]

    return {
        "addresses": netinfo.local_addresses(),
        "networks": netinfo.local_networks(),
        "urls": netinfo.dashboard_urls(config["port"], config.get("dashboard_name")),
        "address": netinfo.address_detail(),
        "advertised": runtime.announcer.status() if runtime.announcer else None,
        # Devices other than this PC that have loaded the dashboard. Proof of
        # what a phone can and cannot reach.
        "other_devices": others,
        "port": config["port"],
        "robot_host": config["robot_host"],
        "pc1_warning": x2_spec.PC1_MOTION_CONTROL_IP,
    }


@app.post("/api/network/probe")
async def post_probe(payload: dict = Body(...)) -> dict:
    host = str(payload.get("host", "")).strip()
    if not host:
        raise HTTPException(status_code=400, detail="host is required")
    resolved = await netinfo.resolve(host)
    if resolved is None:
        return {"ok": False, "message": f"Could not resolve '{host}'"}
    info = await netinfo.probe_host(resolved)
    info["input"] = host
    return {"ok": info["reachable"], "detail": info,
            "message": (f"{resolved} answered on ports {info['open_ports']}"
                        if info["reachable"] else f"{resolved} did not answer on any known port")}


@app.post("/api/network/scan")
async def post_scan(payload: dict = Body(default={})) -> dict:
    """
    Sweep a subnet for the robot. Defaults to whichever networks this machine is
    on right now, so moving between Wi-Fi networks needs no reconfiguration.
    """
    cidrs = payload.get("networks") or netinfo.local_networks()
    if not cidrs:
        return {"ok": False, "message": "No usable network interface found", "hosts": []}

    async def progress(done: int, total: int) -> None:
        await runtime.hub.broadcast("scan_progress", {"done": done, "total": total})

    found: list[dict] = []
    for cidr in cidrs[:3]:
        try:
            found.extend(await netinfo.scan_subnet(cidr, progress=progress))
        except ValueError as exc:
            await runtime.hub.emit("warn", "network", str(exc))
        except Exception as exc:
            await runtime.hub.emit("error", "network", f"Scan of {cidr} failed: {exc}")

    local = {a["address"] for a in netinfo.local_addresses()}
    for host in found:
        host["is_self"] = host["host"] in local

    await runtime.hub.emit("info", "network",
                           f"Scan finished: {len(found)} responding host(s) across {len(cidrs[:3])} network(s)")
    return {"ok": True, "hosts": found, "networks": cidrs[:3],
            "message": f"{len(found)} host(s) responding"}


@app.post("/api/bridge/restart")
async def post_bridge_restart() -> dict:
    return await runtime.restart_bridge()


# --------------------------------------------------------------------------
# Robot commands
# --------------------------------------------------------------------------

@app.post("/api/mode")
async def post_mode(payload: dict = Body(...)):
    mode = str(payload.get("mode", ""))
    if mode == "PASSIVE_DEFAULT" and settings.get("require_confirm_zero_torque", True):
        if not payload.get("confirmed"):
            raise HTTPException(
                status_code=428,
                detail="Zero-torque drops all holding force. Re-send with confirmed=true.",
            )
    return result(await require_bridge().set_mode(mode))


@app.get("/api/mode")
async def get_mode():
    return result(await require_bridge().get_mode())


@app.post("/api/velocity")
async def post_velocity(payload: dict = Body(...)):
    config = settings.load()

    def clamp(value: Any, cap: float) -> float:
        try:
            value = float(value)
        except (TypeError, ValueError):
            return 0.0
        return max(-cap, min(cap, value))

    return result(await require_bridge().set_velocity(
        clamp(payload.get("forward", 0), config["max_forward_velocity"]),
        clamp(payload.get("lateral", 0), config["max_lateral_velocity"]),
        clamp(payload.get("angular", 0), config["max_angular_velocity"]),
    ))


@app.post("/api/stop")
async def post_stop():
    bridge = require_bridge()
    outcome = await bridge.stop_motion()
    await runtime.hub.emit("warn", "control", "Stop issued from dashboard")
    return result(outcome)


@app.post("/api/preset")
async def post_preset(payload: dict = Body(...)):
    key = payload.get("key")
    if key:
        preset = next((p for p in x2_spec.PRESET_MOTIONS if p["key"] == key), None)
        if preset is None:
            raise HTTPException(status_code=400, detail=f"Unknown preset '{key}'")
        motion, area = preset["motion"], preset["area"]
    else:
        motion, area = int(payload.get("motion", 0)), int(payload.get("area", 0))

    return result(await require_bridge().play_preset(
        motion, area, bool(payload.get("interrupt", True))
    ))


@app.post("/api/joints")
async def post_joints(payload: dict = Body(...)):
    group = str(payload.get("group", ""))
    mode = str(payload.get("mode", "position"))
    targets = payload.get("targets") or {}
    if not isinstance(targets, dict):
        raise HTTPException(status_code=400, detail="targets must be an object of joint -> value")
    return result(await require_bridge().set_joints(
        group, mode, targets,
        payload.get("stiffness"), payload.get("damping"),
    ))


@app.post("/api/hand")
async def post_hand(payload: dict = Body(...)):
    side = str(payload.get("side", "right"))
    preset_key = payload.get("preset")
    if preset_key:
        preset = next((p for p in x2_spec.HAND_PRESETS if p["key"] == preset_key), None)
        if preset is None:
            raise HTTPException(status_code=400, detail=f"Unknown hand preset '{preset_key}'")
        positions = preset["positions"]
    else:
        positions = payload.get("positions") or []
    return result(await require_bridge().set_hand(side, [float(v) for v in positions]))


@app.post("/api/speak")
async def post_speak(payload: dict = Body(...)):
    return result(await require_bridge().speak(
        str(payload.get("text", "")),
        int(payload.get("priority", 6)),
        bool(payload.get("interrupt", False)),
    ))


@app.post("/api/volume")
async def post_volume(payload: dict = Body(...)):
    return result(await require_bridge().set_volume(int(payload.get("volume", 60))))


@app.post("/api/mute")
async def post_mute(payload: dict = Body(...)):
    return result(await require_bridge().set_mute(bool(payload.get("muted", False))))


# --------------------------------------------------------------------------
# Voice session - the conversational loop (Muza / Lumi)
# --------------------------------------------------------------------------

@app.get("/api/voice/session")
async def get_voice_session() -> dict:
    return await asyncio.to_thread(voice_session.snapshot)


@app.post("/api/voice/session")
async def post_voice_session(payload: dict = Body(...)) -> dict:
    """Apply the operator's selection, then greet if it was switched on.

    systemctl blocks for a second or two, so it runs off the event loop - the
    telemetry websocket must keep flowing while the unit restarts.
    """
    outcome = await asyncio.to_thread(
        voice_session.apply,
        bool(payload.get("enabled", False)),
        payload.get("gender"),
        payload.get("language"),
        payload.get("model"),
    )

    if not outcome.get("ok"):
        raise HTTPException(status_code=400, detail=outcome.get("error", "apply failed"))

    # The dashboard deliberately does NOT speak the greeting. Both voice paths
    # now render it themselves in the selected character's voice - Gemini in
    # gemini/script.py, LinkSoul in linksoul/runner.py - because the only tool
    # available here is the robot's built-in PlayTts, whose request message has
    # `voice_id` commented out by the vendor. That engine has exactly one voice,
    # so it announced the female character in a male voice on every Apply.
    await runtime.hub.broadcast("voice_session", outcome["snapshot"])
    return outcome


@app.post("/api/emoji")
async def post_emoji(payload: dict = Body(...)):
    return result(await require_bridge().play_emoji(
        int(payload.get("emotion_id", 1)),
        int(payload.get("mode", 1)),
        int(payload.get("priority", 6)),
    ))


@app.post("/api/led")
async def post_led(payload: dict = Body(...)):
    def channel(name: str) -> int:
        return max(0, min(255, int(payload.get(name, 0))))

    # keep=True asks the bridge to re-assert this setting periodically, because
    # the robot's task_manager reclaims the strip on its own after about a
    # minute. See led_keepalive() in agent/x2_agent.py.
    return result(await require_bridge().set_led(
        int(payload.get("mode", 0)), channel("r"), channel("g"), channel("b"),
        int(payload.get("priority", x2_spec.LED_DEFAULT_PRIORITY)),
        keep=bool(payload.get("keep", True)),
    ))


@app.post("/api/input-source")
async def post_input_source(payload: dict = Body(...)):
    source = x2_spec.DASHBOARD_INPUT_SOURCE
    return result(await require_bridge().register_input_source(
        str(payload.get("name", source["name"])),
        int(payload.get("priority", source["priority"])),
        int(payload.get("timeout", source["timeout"])),
    ))


@app.post("/api/raw/publish")
async def post_raw_publish(payload: dict = Body(...)):
    return result(await require_bridge().publish_raw(
        str(payload.get("topic", "")),
        str(payload.get("type", "")),
        payload.get("fields") or {},
    ))


@app.post("/api/raw/service")
async def post_raw_service(payload: dict = Body(...)):
    return result(await require_bridge().call_service(
        str(payload.get("service", "")),
        str(payload.get("type", "")),
        payload.get("fields") or {},
    ))


# --------------------------------------------------------------------------
# Camera
# --------------------------------------------------------------------------

@app.get("/api/streams")
async def get_streams() -> dict:
    """Which feeds are subscribed on the robot right now."""
    bridge = require_bridge()
    outcome = await bridge.list_streams()
    return {
        "ok": outcome.ok,
        "message": outcome.message,
        "streams": (outcome.detail or {}).get("streams", {}) if outcome.ok else {},
        "cameras": x2_spec.CAMERAS,
        "lidar": x2_spec.LIDAR,
    }


@app.post("/api/streams/{key}")
async def post_stream(key: str, payload: dict = Body(default={})):
    """
    Switch a camera or the LiDAR on or off.

    Nothing is subscribed on the robot until this is called. A camera frame here
    is 170-430 KB and the LiDAR pushes 816 KB a scan, so leaving them all
    running would compete with the robot's own traffic for pictures nobody is
    looking at.
    """
    known = {c["key"] for c in x2_spec.CAMERAS} | {x2_spec.LIDAR["key"]}
    if key not in known:
        raise HTTPException(status_code=404, detail=f"Unknown stream '{key}'")
    return result(await require_bridge().set_stream(key, bool(payload.get("active", False))))


@app.get("/api/lidar/points")
async def get_lidar_points():
    outcome = await require_bridge().lidar_points()
    if not outcome.ok:
        # "Off" is an ordinary state, not a failure - the browser polls this and
        # a 4xx per poll would fill the console for something working correctly.
        return JSONResponse({"ok": False, "message": outcome.message,
                             "off": bool((outcome.detail or {}).get("off")),
                             "points": []}, status_code=200)
    return JSONResponse({"ok": True, **(outcome.detail or {})})


@app.get("/api/camera/{camera_key}/frame")
async def camera_frame(camera_key: str, stream: str = "rgb", flip: bool = False):
    bridge = require_bridge()
    if not any(c["key"] == camera_key for c in x2_spec.CAMERAS):
        raise HTTPException(status_code=404, detail=f"Unknown camera '{camera_key}'")

    data = await bridge.camera_frame(camera_key, stream, flip=flip)
    if not data:
        # 204, not an error status. A camera whose topic has no publisher is a
        # normal state on this robot (the perception debug feeds only run with
        # that module), and an error status makes every browser log a failed
        # request several times a second for something that is working as
        # intended. 404 above still covers a camera that does not exist.
        return Response(status_code=204, headers={"Cache-Control": "no-store"})

    content_type = "image/jpeg"
    getter = getattr(bridge, "frame_content_type", None)
    if callable(getter):
        try:
            content_type = getter(camera_key, stream)
        except TypeError:
            content_type = getter()

    return Response(content=data, media_type=content_type,
                    headers={"Cache-Control": "no-store"})


@app.get("/api/qr")
async def qr_code(url: str = "") -> Response:
    """
    A QR code for a dashboard URL.

    Typing an IP address into a phone is exactly the friction that makes people
    want a name in the first place. A QR of the numeric URL sidesteps the whole
    naming question: it needs no mDNS, no multicast and no DNS, so it works even
    on a Wi-Fi that isolates clients from each other.
    """
    config = settings.load()
    if not url:
        address = netinfo.primary_address()
        if not address:
            raise HTTPException(status_code=503, detail="No LAN address to encode")
        url = f"http://{address}:{config['port']}"

    try:
        import io

        import qrcode
        import qrcode.image.svg
    except ImportError:
        raise HTTPException(
            status_code=501,
            detail="qrcode is not installed - run: pip install -r requirements.txt",
        )

    image = qrcode.make(url, image_factory=qrcode.image.svg.SvgPathImage, box_size=10, border=2)
    buffer = io.BytesIO()
    image.save(buffer)
    return Response(buffer.getvalue(), media_type="image/svg+xml",
                    headers={"Cache-Control": "no-store"})


@app.post("/api/robot/find")
async def robot_find() -> dict:
    """
    Sweep the current network for the robot, from the offline screen.

    Reports both hosts already running the agent and hosts that merely answer
    on SSH, because the second kind can usually be started.
    """
    from . import recovery

    config = settings.load()
    port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
    ssh_port = int(config.get("robot_ssh_port") or 22)

    candidates = await recovery.find_robot(port, ssh_port)
    local = {a["address"] for a in netinfo.local_addresses()}
    for candidate in candidates:
        candidate["is_self"] = candidate["host"] in local

    return {
        "ok": True,
        "candidates": [c for c in candidates if not c["is_self"]],
        "current": config.get("robot_host", ""),
        "agent_port": port,
    }


@app.post("/api/robot/wake")
async def robot_wake(payload: dict = Body(default={})) -> dict:
    """Log into the robot and start the agent, then wait for it to come up."""
    from . import recovery

    config = settings.load()
    host = str(payload.get("host") or config.get("robot_host") or "").strip()
    if not host:
        raise HTTPException(status_code=400, detail="No robot address to start the agent on")

    port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
    ok, message = await recovery.start_agent(host, config)
    if not ok:
        return {"ok": False, "message": message}

    came_up = await recovery.wait_for_agent(host, port, timeout=45.0)
    if came_up and host != config.get("robot_host"):
        settings.save({"robot_host": host})
        await runtime.hub.broadcast("settings", settings.describe())

    return {
        "ok": came_up,
        "message": ("Agent started" if came_up
                    else "Start command ran, but the agent has not come up yet"),
        "host": host,
    }


@app.get("/api/robot/status")
async def robot_status() -> dict:
    """
    Cheap liveness probe the browser polls while the robot is off, so the
    offline gate can clear itself the moment the robot comes back.
    """
    bridge = runtime.bridge
    connection = bridge.state.connection if bridge else None
    config = settings.load()
    return {
        "online": bool(connection and connection.online),
        "simulated": bool(bridge and bridge.simulated),
        "host": config.get("robot_host", ""),
        "agent_port": config.get("agent_port", x2_spec.AGENT_PORT),
        "error": connection.error if connection else "No bridge running",
        "uptime_s": round(connection.uptime_s, 1) if connection else 0,
        "mode": bridge.state.mode if bridge else None,
        # What the automatic recovery is doing right now, so the offline screen
        # can show progress instead of a static "waiting".
        "recovery": (bridge.state.custom.get("recovery") or "") if bridge else "",
        "auto_discover": bool(config.get("auto_discover")),
        "auto_start_agent": bool(config.get("auto_start_agent")),
        "has_ssh_password": bool(config.get("robot_ssh_password")),
    }


# --------------------------------------------------------------------------
# Plugins
# --------------------------------------------------------------------------

@app.get("/api/plugins")
async def get_plugins() -> dict:
    return runtime.registry.manifest()


@app.post("/api/plugins/reload")
async def reload_plugins() -> dict:
    summary = await runtime.registry.load()
    manifest = runtime.registry.manifest()
    await runtime.hub.broadcast("plugins", manifest)
    return {"ok": True, "summary": summary, "manifest": manifest}


@app.post("/api/plugins/{plugin_id}/{control_key}")
async def run_plugin_control(plugin_id: str, control_key: str, payload: dict = Body(default={})):
    outcome = await runtime.registry.dispatch(plugin_id, control_key, payload.get("value"))
    return JSONResponse(outcome, status_code=200 if outcome.get("ok") else 400)


# --------------------------------------------------------------------------
# WebSocket
# --------------------------------------------------------------------------

@app.websocket("/ws")
async def websocket_endpoint(socket: WebSocket) -> None:
    await socket.accept()
    queue = await runtime.hub.register()

    try:
        if runtime.bridge is not None:
            await socket.send_json({"type": "state", "ts": time.time(),
                                    "data": runtime.bridge.snapshot()})

        async def pump() -> None:
            while True:
                message = await queue.get()
                await socket.send_text(message)

        sender = asyncio.create_task(pump())
        try:
            while True:
                # Inbound messages are the joystick's high-rate path - going over
                # the socket avoids an HTTP round trip per frame.
                message = await socket.receive_json()
                await _handle_socket_message(socket, message)
        finally:
            sender.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await sender

    except WebSocketDisconnect:
        pass
    except Exception:
        pass
    finally:
        await runtime.hub.unregister(queue)


async def _handle_socket_message(socket: WebSocket, message: dict) -> None:
    kind = message.get("type")

    if kind == "ping":
        await socket.send_json({"type": "pong", "ts": time.time(),
                                "data": {"echo": message.get("data")}})
        return

    if kind == "velocity" and runtime.bridge is not None:
        data = message.get("data") or {}
        config = settings.load()

        def clamp(value, cap):
            try:
                return max(-cap, min(cap, float(value)))
            except (TypeError, ValueError):
                return 0.0

        outcome = await runtime.bridge.set_velocity(
            clamp(data.get("forward", 0), config["max_forward_velocity"]),
            clamp(data.get("lateral", 0), config["max_lateral_velocity"]),
            clamp(data.get("angular", 0), config["max_angular_velocity"]),
        )
        if not outcome.ok:
            await socket.send_json({"type": "command_error", "ts": time.time(),
                                    "data": {"source": "velocity", "message": outcome.message}})
        return

    if kind == "stop" and runtime.bridge is not None:
        await runtime.bridge.stop_motion()
        return


# --------------------------------------------------------------------------
# Static frontend - mounted last so /api routes win
# --------------------------------------------------------------------------

@app.get("/")
async def index() -> Response:
    """Serve index.html with the entry module stamped with the current build."""
    build = frontend_build()
    html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
    html = html.replace('src="/js/main.js"', f'src="/js/main.js?b={build}"')
    # The placeholder is {{BUILD}}, not __BUILD__: replacing the latter would
    # also rewrite the `window.__BUILD__` identifier on the same line and
    # produce `window.<hash> = "<hash>"`.
    html = html.replace("{{BUILD}}", build)
    return Response(html, media_type="text/html; charset=utf-8",
                    headers={"Cache-Control": "no-store"})


@app.get("/favicon.ico")
async def favicon() -> Response:
    return Response(status_code=204)


if WEB_DIR.exists():
    app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
