"""Chatixia Hub — centralized monitoring dashboard for multi-agent networks."""

from __future__ import annotations

import ipaddress
import math
import platform
import re
import socket
import subprocess
import threading
import time
from typing import Any

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, PlainTextResponse
from pydantic import BaseModel

app = FastAPI(title="Chatixia Hub", version="0.1.0")
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# ---------------------------------------------------------------------------
# In-memory registries
# ---------------------------------------------------------------------------

_agents: dict[str, dict[str, Any]] = {}
_agent_skills: dict[str, list[str]] = {}  # agent_id → skill names
_tasks: dict[str, dict[str, Any]] = {}  # task_id → task dict
_rtt: dict[str, float] = {}  # ip → latency_ms (rolling average, hub→agent)
_hostnames: dict[str, str] = {}  # ip → reverse DNS hostname
_distance_matrix: dict[tuple[str, str], float] = {}  # (ip_a, ip_b) → RTT ms
_mds_positions: dict[str, tuple[float, float]] = {}  # ip → (x, y) normalized 0-1
_DEFAULT_STALE_MULTIPLIER = 3
_SCAN_INTERVAL = 15  # seconds between ping sweeps

# BLE / IoT device registry
_ble_devices: dict[str, dict[str, Any]] = {}  # mac_prefix → device info
_ble_scan_stop = threading.Event()
_BLE_SCAN_INTERVAL = 30  # seconds between BLE scans

# Hub 2 light level → lux mapping
_LIGHT_LUX = {
    1: 0,
    2: 10,
    3: 20,
    4: 30,
    5: 40,
    6: 50,
    7: 60,
    8: 70,
    9: 80,
    10: 90,
    11: 105,
    12: 205,
    13: 317,
    14: 416,
    15: 510,
    16: 610,
    17: 707,
    18: 801,
    19: 897,
    20: 1023,
    21: 1091,
}
_SWITCHBOT_MFR_ID = 2409


class PingResult(BaseModel):
    target_ip: str
    rtt_ms: float  # -1 if unreachable


class Heartbeat(BaseModel):
    agent_id: str
    hostname: str = ""
    ip: str = ""
    port: int = 8000
    status: str = "running"
    mode: str = "interactive"
    skills_count: int = 0
    goals_count: int = 0
    uptime_seconds: int = 0
    timestamp: str = ""
    skill_names: list[str] = []
    ping_results: list[PingResult] = []


class TaskSubmission(BaseModel):
    skill: str = ""
    target_agent_id: str = ""
    source_agent_id: str = ""
    payload: dict = {}
    ttl: int = 300


class TaskUpdate(BaseModel):
    state: str
    result: str = ""
    error: str = ""


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _expire_tasks() -> None:
    """Fail tasks past TTL and reassign tasks from offline agents."""
    now = time.time()
    for task in _tasks.values():
        if task["state"] in ("completed", "failed"):
            continue
        # TTL expiry
        if now - task["created_at"] > task["ttl"]:
            task["state"] = "failed"
            task["error"] = "TTL expired"
            task["updated_at"] = now
            continue
        # Reassign from offline agents
        assigned = task.get("assigned_agent_id", "")
        if assigned and task["state"] == "assigned":
            agent = _agents.get(assigned)
            if agent:
                age = now - agent.get("_received_at", 0)
                if age > _DEFAULT_STALE_MULTIPLIER * 90:
                    task["state"] = "pending"
                    task["assigned_agent_id"] = ""
                    task["updated_at"] = now


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------


@app.post("/api/hub/heartbeat")
async def receive_heartbeat(hb: Heartbeat) -> dict[str, Any]:
    _agents[hb.agent_id] = {
        **hb.model_dump(exclude={"skill_names", "ping_results"}),
        "_received_at": time.time(),
    }
    if hb.skill_names:
        _agent_skills[hb.agent_id] = hb.skill_names

    # Ingest agent→agent ping results into the distance matrix
    if hb.ping_results and hb.ip:
        for pr in hb.ping_results:
            if pr.rtt_ms >= 0:
                pair = sorted([hb.ip, pr.target_ip])
                key = (pair[0], pair[1])
                prev = _distance_matrix.get(key)
                _distance_matrix[key] = pr.rtt_ms if prev is None else prev * 0.7 + pr.rtt_ms * 0.3

    # Expire stale tasks
    _expire_tasks()

    # Find pending tasks for this agent
    agent_skills = _agent_skills.get(hb.agent_id, [])
    pending: list[dict[str, Any]] = []
    for task in _tasks.values():
        if task["state"] != "pending":
            continue
        if task["target_agent_id"] == hb.agent_id or (task["skill"] and task["skill"] in agent_skills):
            task["state"] = "assigned"
            task["assigned_agent_id"] = hb.agent_id
            task["updated_at"] = time.time()
            pending.append(task)

    # Tell this agent which other IPs to ping for triangulation
    all_ips = {a.get("ip", "") for a in _agents.values()}
    all_ips.discard("")
    all_ips.discard(hb.ip)  # don't ping yourself
    ping_targets = list(all_ips)

    return {"status": "ok", "pending_tasks": pending, "ping_targets": ping_targets}


@app.get("/api/hub/agents")
async def list_agents() -> list[dict[str, Any]]:
    now = time.time()
    result = []
    for agent in _agents.values():
        received = agent.get("_received_at", 0)
        age = now - received
        if age > _DEFAULT_STALE_MULTIPLIER * 90:
            health = "offline"
        elif age > _DEFAULT_STALE_MULTIPLIER * 30:
            health = "stale"
        else:
            health = "active"
        result.append({**agent, "health": health})
    return result


# ---------------------------------------------------------------------------
# Task endpoints
# ---------------------------------------------------------------------------


@app.post("/api/hub/tasks")
async def submit_task(sub: TaskSubmission) -> dict[str, str]:
    """Submit a task to the hub for delegation."""
    import uuid as _uuid

    task_id = _uuid.uuid4().hex[:12]
    now = time.time()
    _tasks[task_id] = {
        "id": task_id,
        "skill": sub.skill,
        "target_agent_id": sub.target_agent_id,
        "source_agent_id": sub.source_agent_id,
        "payload": sub.payload,
        "state": "pending",
        "assigned_agent_id": "",
        "result": "",
        "error": "",
        "created_at": now,
        "updated_at": now,
        "ttl": sub.ttl,
    }
    return {"task_id": task_id}


@app.get("/api/hub/tasks/all")
async def list_tasks(format: str = "json") -> Any:
    """List all tasks. Use ?format=table for a plain-text table."""
    _expire_tasks()
    tasks = list(_tasks.values())
    if format != "table":
        return tasks

    if not tasks:
        return PlainTextResponse("No tasks.\n")

    cols = ["id", "state", "skill", "source_agent_id", "target_agent_id"]
    widths = {c: len(c) for c in cols}
    for t in tasks:
        for c in cols:
            widths[c] = max(widths[c], len(str(t.get(c, ""))))

    header = "  ".join(c.upper().ljust(widths[c]) for c in cols)
    sep = "  ".join("-" * widths[c] for c in cols)
    rows = ["  ".join(str(t.get(c, "")).ljust(widths[c]) for c in cols) for t in tasks]
    return PlainTextResponse("\n".join([header, sep, *rows]) + "\n")


@app.get("/api/hub/tasks/{task_id}")
async def get_task(task_id: str) -> dict[str, Any]:
    """Get task status and result."""
    task = _tasks.get(task_id)
    if not task:
        return {"error": "not found"}
    return task


@app.patch("/api/hub/tasks/{task_id}")
async def update_task(task_id: str, update: TaskUpdate) -> dict[str, str]:
    """Update task state/result (called by executing agent)."""
    task = _tasks.get(task_id)
    if not task:
        return {"error": "not found"}
    task["state"] = update.state
    if update.result:
        task["result"] = update.result
    if update.error:
        task["error"] = update.error
    task["updated_at"] = time.time()
    return {"status": "ok"}


# ---------------------------------------------------------------------------
# Agent endpoints (continued)
# ---------------------------------------------------------------------------


@app.get("/api/hub/agents/{agent_id}")
async def get_agent(agent_id: str) -> dict[str, Any]:
    agent = _agents.get(agent_id)
    if not agent:
        return {"error": "not found"}
    now = time.time()
    age = now - agent.get("_received_at", 0)
    if age > _DEFAULT_STALE_MULTIPLIER * 90:
        health = "offline"
    elif age > _DEFAULT_STALE_MULTIPLIER * 30:
        health = "stale"
    else:
        health = "active"
    return {**agent, "health": health}


@app.get("/", response_class=HTMLResponse)
async def dashboard() -> str:
    return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>chatixia // hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  :root {
    --bg-0: #0a0e14; --bg-1: #0d1117; --bg-2: #131921; --bg-3: #1a2233;
    --border: #1e2a3a; --border-hi: #2a3a4e;
    --fg: #c5cdd8; --fg-dim: #6b7d93; --fg-muted: #4a5568;
    --green: #4ade80; --green-dim: #22c55e; --green-bg: rgba(34,197,94,.08);
    --yellow: #fbbf24; --yellow-bg: rgba(251,191,36,.08);
    --red: #f87171; --red-bg: rgba(248,113,113,.08);
    --blue: #60a5fa; --blue-bg: rgba(96,165,250,.08);
    --mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
    --canvas-ring: rgba(30,42,58,.25); --canvas-ring-label: #333d4d;
    --canvas-edge: rgba(30,42,58,.5); --canvas-edge-sec: rgba(30,42,58,.15);
    --canvas-node-label: #e6edf3; --canvas-node-sub: #6b7d93;
    --canvas-subnet: rgba(42,58,78,.35); --canvas-subnet-label: #3d4f63;
    --canvas-ip-ring: rgba(96,165,250,.2); --canvas-ip-label: #4a6580;
    --canvas-rtt-label: #4a6580; --canvas-empty: #4a5568;
    --canvas-ble-ring: rgba(192,132,252,.12); --canvas-ble-label: #6b5b95;
    --canvas-ble-edge: rgba(192,132,252,.3); --canvas-ble-dist: #9d7cd8;
    --map-legend-bg: rgba(10,14,20,.85);
  }
  [data-theme="light"] {
    --bg-0: #f5f6f8; --bg-1: #ffffff; --bg-2: #eef0f4; --bg-3: #e2e5eb;
    --border: #d1d5db; --border-hi: #b8bfc9;
    --fg: #1f2937; --fg-dim: #4b5563; --fg-muted: #9ca3af;
    --green: #16a34a; --green-dim: #15803d; --green-bg: rgba(22,163,74,.08);
    --yellow: #ca8a04; --yellow-bg: rgba(202,138,4,.08);
    --red: #dc2626; --red-bg: rgba(220,38,38,.08);
    --blue: #2563eb; --blue-bg: rgba(37,99,235,.08);
    --canvas-ring: rgba(0,0,0,.08); --canvas-ring-label: #9ca3af;
    --canvas-edge: rgba(0,0,0,.12); --canvas-edge-sec: rgba(0,0,0,.06);
    --canvas-node-label: #1f2937; --canvas-node-sub: #6b7280;
    --canvas-subnet: rgba(0,0,0,.06); --canvas-subnet-label: #9ca3af;
    --canvas-ip-ring: rgba(37,99,235,.12); --canvas-ip-label: #6b7280;
    --canvas-rtt-label: #6b7280; --canvas-empty: #9ca3af;
    --canvas-ble-ring: rgba(147,51,234,.1); --canvas-ble-label: #7c3aed;
    --canvas-ble-edge: rgba(147,51,234,.25); --canvas-ble-dist: #7c3aed;
    --map-legend-bg: rgba(255,255,255,.9);
  }
  body { font-family: var(--mono); font-size: 13px; line-height: 1.5; background: var(--bg-0); color: var(--fg); transition: background .2s, color .2s; }

  /* Header */
  .header { display: flex; align-items: center; gap: 16px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--bg-1); }
  .logo { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid var(--green-dim); color: var(--green); font-size: 11px; font-weight: 700; border-radius: 3px; flex-shrink: 0; }
  .header h1 { font-size: 14px; font-weight: 600; letter-spacing: .04em; }
  .header .sep { color: var(--fg-muted); }
  .header .subtitle { color: var(--fg-dim); font-size: 12px; }
  .header .pulse { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--fg-dim); }
  .header .pulse-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); animation: blink 2s ease-in-out infinite; }
  @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }

  /* Stats bar */
  .stats { display: flex; gap: 1px; background: var(--border); border-bottom: 1px solid var(--border); }
  .stat { flex: 1; padding: 12px 20px; background: var(--bg-1); }
  .stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-muted); margin-bottom: 4px; }
  .stat-value { font-size: 20px; font-weight: 700; }
  .stat-value.green { color: var(--green); }
  .stat-value.yellow { color: var(--yellow); }
  .stat-value.red { color: var(--red); }
  .stat-value.blue { color: var(--blue); }

  /* Content */
  .content { padding: 20px 24px; }
  .section-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
  .section-header h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-dim); }
  .section-header .count { font-size: 11px; color: var(--fg-muted); background: var(--bg-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 3px; }

  /* Table */
  table { width: 100%; border-collapse: collapse; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
  th, td { padding: 8px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 12px; }
  th { background: var(--bg-2); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-muted); white-space: nowrap; }
  tr { background: var(--bg-1); transition: background .1s; }
  tr:hover { background: var(--bg-2); }
  tr:last-child td { border-bottom: none; }

  /* Status indicators */
  .status { display: inline-flex; align-items: center; gap: 6px; }
  .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
  .dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
  .dot.stale { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
  .dot.offline { background: var(--red); box-shadow: 0 0 4px var(--red); }
  .status-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; }
  .status-label.active { color: var(--green); }
  .status-label.stale { color: var(--yellow); }
  .status-label.offline { color: var(--red); }

  /* Badges */
  .badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
  .badge.interactive { background: var(--blue-bg); color: var(--blue); border: 1px solid rgba(96,165,250,.15); }
  .badge.daemon { background: var(--green-bg); color: var(--green); border: 1px solid rgba(34,197,94,.15); }
  .badge.oneshot { background: var(--yellow-bg); color: var(--yellow); border: 1px solid rgba(251,191,36,.15); }

  .agent-id { font-weight: 600; color: var(--fg); }
  .mono-dim { color: var(--fg-dim); font-family: var(--mono); }
  .num { font-variant-numeric: tabular-nums; text-align: right; color: var(--fg-dim); }
  .num strong { color: var(--fg); }

  .empty { text-align: center; padding: 40px; color: var(--fg-muted); }
  .empty-icon { font-size: 24px; margin-bottom: 8px; opacity: .4; }
  .empty p { font-size: 12px; }

  /* Tasks section */
  .tasks-section { margin-top: 24px; }
  .task-state { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding: 2px 6px; border-radius: 3px; }
  .task-state.pending { background: var(--yellow-bg); color: var(--yellow); }
  .task-state.assigned { background: var(--blue-bg); color: var(--blue); }
  .task-state.completed { background: var(--green-bg); color: var(--green); }
  .task-state.failed { background: var(--red-bg); color: var(--red); }

  /* Task detail row */
  .task-row { cursor: pointer; }
  .task-row:hover td { background: var(--bg-2); }
  .task-detail-row td { padding: 0 !important; border-top: none !important; }
  .task-detail { background: var(--bg-1); border: 1px solid var(--border); border-radius: 4px; margin: 4px 12px 12px; padding: 12px 16px; font-size: 12px; }
  .task-detail dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin: 0; }
  .task-detail dt { color: var(--fg-muted); font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; padding-top: 2px; }
  .task-detail dd { margin: 0; color: var(--fg-dim); font-family: var(--mono); word-break: break-all; white-space: pre-wrap; }

  /* Network map */
  .map-section { margin-top: 24px; }
  .map-container { position: relative; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-1); overflow: hidden; }
  #netmap { width: 100%; height: 420px; display: block; }
  .map-legend { position: absolute; bottom: 10px; right: 14px; display: flex; gap: 14px; font-size: 10px; color: var(--fg-muted); background: var(--map-legend-bg); padding: 4px 10px; border-radius: 3px; border: 1px solid var(--border); }
  .map-legend span { display: inline-flex; align-items: center; gap: 4px; }
  .map-legend .swatch { width: 8px; height: 8px; border-radius: 50%; }
  .map-empty { display: flex; align-items: center; justify-content: center; height: 420px; color: var(--fg-muted); font-size: 12px; }

  /* Theme toggle */
  .theme-toggle { background: var(--bg-2); border: 1px solid var(--border); color: var(--fg-dim); font-family: var(--mono); font-size: 11px; padding: 3px 10px; border-radius: 3px; cursor: pointer; transition: all .15s; }
  .theme-toggle:hover { border-color: var(--border-hi); color: var(--fg); }

  /* Footer */
  .footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 8px 24px; border-top: 1px solid var(--border); background: var(--bg-1); display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--fg-muted); }
</style>
</head>
<body>
<div class="header">
  <div class="logo">&gt;_</div>
  <h1>chatixia <span class="sep">//</span> hub</h1>
  <span class="subtitle">agent network monitor</span>
  <button class="theme-toggle" id="theme-btn" onclick="toggleTheme()">light</button>
  <div class="pulse"><div class="pulse-dot"></div> <span id="clock"></span></div>
</div>
<div class="stats">
  <div class="stat"><div class="stat-label">agents online</div><div class="stat-value green" id="s-active">0</div></div>
  <div class="stat"><div class="stat-label">stale</div><div class="stat-value yellow" id="s-stale">0</div></div>
  <div class="stat"><div class="stat-label">offline</div><div class="stat-value red" id="s-offline">0</div></div>
  <div class="stat"><div class="stat-label">pending tasks</div><div class="stat-value blue" id="s-tasks">0</div></div>
</div>
<div class="content">
  <div class="section-header"><h2>connected agents</h2><span class="count" id="agent-count">0</span></div>
  <table>
    <thead><tr><th>status</th><th>agent</th><th>host</th><th>endpoint</th><th>mode</th><th style="text-align:right">skills</th><th style="text-align:right">goals</th><th style="text-align:right">uptime</th><th>last seen</th></tr></thead>
    <tbody id="agents"><tr><td colspan="9" class="empty"><div class="empty-icon">&#x2026;</div><p>waiting for agent heartbeats</p></td></tr></tbody>
  </table>
  <div class="tasks-section">
    <div class="section-header"><h2>task queue</h2><span class="count" id="task-count">0</span></div>
    <table>
      <thead><tr><th>id</th><th>state</th><th>skill</th><th>source</th><th>target</th><th>age</th></tr></thead>
      <tbody id="tasks"><tr><td colspan="6" class="empty"><p>no tasks in queue</p></td></tr></tbody>
    </table>
  </div>
  <div class="map-section">
    <div class="section-header"><h2>network topology</h2><span class="count" id="map-count">0 nodes</span></div>
    <div class="map-container">
      <canvas id="netmap"></canvas>
      <div class="map-legend">
        <span><span class="swatch" style="background:var(--green)"></span> active</span>
        <span><span class="swatch" style="background:var(--yellow)"></span> stale</span>
        <span><span class="swatch" style="background:var(--red)"></span> offline</span>
        <span><span class="swatch" style="background:var(--blue)"></span> hub</span>
        <span><span class="swatch" style="background:#c084fc"></span> BLE</span>
      </div>
    </div>
  </div>
</div>
<div class="footer">
  <span>chatixia hub v0.1.0</span>
  <span>refresh: 5s</span>
</div>
<script>
function fmt(s){if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'}
function ago(t){const d=Date.now()/1000-t;if(d<10)return 'now';if(d<60)return Math.floor(d)+'s';if(d<3600)return Math.floor(d/60)+'m';return Math.floor(d/3600)+'h'}
function clock(){document.getElementById('clock').textContent=new Date().toLocaleTimeString('en',{hour12:false})}
clock();setInterval(clock,1000);

// Theme toggle
function toggleTheme(){
  const html=document.documentElement;
  const current=html.getAttribute('data-theme');
  const next=current==='light'?'dark':'light';
  html.setAttribute('data-theme',next==='dark'?'':'light');
  document.getElementById('theme-btn').textContent=next==='dark'?'light':'dark';
  localStorage.setItem('chatixia-theme',next);
}
(function(){
  const saved=localStorage.getItem('chatixia-theme');
  if(saved==='light'){
    document.documentElement.setAttribute('data-theme','light');
    document.getElementById('theme-btn').textContent='dark';
  }
})();
function cv(prop){return getComputedStyle(document.documentElement).getPropertyValue(prop).trim()}

async function refresh(){
  try{
    const [ar,tr]=await Promise.all([fetch('/api/hub/agents'),fetch('/api/hub/tasks/all').catch(()=>null)]);
    const agents=await ar.json();
    const tasks=tr?await tr.json():[];

    // Stats
    let ac=0,st=0,of=0;
    agents.forEach(a=>{if(a.health==='active')ac++;else if(a.health==='stale')st++;else of++});
    document.getElementById('s-active').textContent=ac;
    document.getElementById('s-stale').textContent=st;
    document.getElementById('s-offline').textContent=of;
    document.getElementById('agent-count').textContent=agents.length;

    const pending=Array.isArray(tasks)?tasks.filter(t=>t.state==='pending').length:0;
    document.getElementById('s-tasks').textContent=pending;
    document.getElementById('task-count').textContent=Array.isArray(tasks)?tasks.length:0;

    // Agents table
    const tb=document.getElementById('agents');
    if(!agents.length){tb.innerHTML='<tr><td colspan="9" class="empty"><div class="empty-icon">&#x2026;</div><p>no agents connected</p></td></tr>';return}
    tb.innerHTML=agents.map(a=>`<tr>
      <td><span class="status"><span class="dot ${a.health}"></span><span class="status-label ${a.health}">${a.health}</span></span></td>
      <td><span class="agent-id">${a.agent_id}</span></td>
      <td class="mono-dim">${a.hostname||'—'}</td>
      <td class="mono-dim">${a.ip}:${a.port}</td>
      <td><span class="badge ${a.mode}">${a.mode}</span></td>
      <td class="num"><strong>${a.skills_count}</strong></td>
      <td class="num"><strong>${a.goals_count}</strong></td>
      <td class="num">${fmt(a.uptime_seconds)}</td>
      <td class="mono-dim">${ago(a._received_at)} ago</td>
    </tr>`).join('');

    // Tasks table
    const ttb=document.getElementById('tasks');
    if(!Array.isArray(tasks)||!tasks.length){ttb.innerHTML='<tr><td colspan="6" class="empty"><p>no tasks in queue</p></td></tr>';return}
    ttb.innerHTML=tasks.map(t=>{
      const payload=typeof t.payload==='object'?JSON.stringify(t.payload,null,2):String(t.payload||'—');
      const result=t.result||'—';
      const error=t.error||'—';
      const created=t.created_at?new Date(t.created_at*1000).toLocaleString():'—';
      const updated=t.updated_at?new Date(t.updated_at*1000).toLocaleString():'—';
      return `<tr class="task-row" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'':'none'">
      <td class="mono-dim">${t.id}</td>
      <td><span class="task-state ${t.state}">${t.state}</span></td>
      <td>${t.skill||'—'}</td>
      <td class="mono-dim">${t.source_agent_id||'—'}</td>
      <td class="mono-dim">${t.target_agent_id||t.assigned_agent_id||'—'}</td>
      <td class="mono-dim">${ago(t.created_at)} ago</td>
    </tr><tr class="task-detail-row" style="display:none"><td colspan="6"><div class="task-detail"><dl>
      <dt>payload</dt><dd>${payload.replace(/</g,'&lt;')}</dd>
      <dt>result</dt><dd>${String(result).replace(/</g,'&lt;')}</dd>
      <dt>error</dt><dd>${String(error).replace(/</g,'&lt;')}</dd>
      <dt>assigned to</dt><dd>${t.assigned_agent_id||'—'}</dd>
      <dt>created</dt><dd>${created}</dd>
      <dt>updated</dt><dd>${updated}</dd>
      <dt>ttl</dt><dd>${t.ttl||'—'}s</dd>
    </dl></div></td></tr>`}).join('');
  }catch(e){console.error(e)}
}
refresh();setInterval(refresh,5000);

// ── Network topology map (RTT-driven) ───────────────────────────────────
(function(){
  const canvas=document.getElementById('netmap');
  const ctx=canvas.getContext('2d');
  const DPR=window.devicePixelRatio||1;
  let W,H,nodes=[],edges=[];
  function COLORS(){return{active:cv('--green'),stale:cv('--yellow'),offline:cv('--red'),hub:cv('--blue'),iot:'#c084fc'}}

  function resize(){
    const rect=canvas.parentElement.getBoundingClientRect();
    W=rect.width; H=420;
    canvas.width=W*DPR; canvas.height=H*DPR;
    canvas.style.width=W+'px'; canvas.style.height=H+'px';
    ctx.setTransform(DPR,0,0,DPR,0,0);
  }
  window.addEventListener('resize',resize); resize();

  // Convert RTT to distance from hub center (pixels)
  // <1ms = very close (local), 1-10ms = LAN, 10-100ms = remote
  function rttToDistance(rtt){
    if(rtt<0) return Math.min(W,H)*0.3; // no RTT data yet — default ring
    // Log scale: 0.1ms→40px, 1ms→80px, 10ms→120px, 100ms→160px
    const d=40+Math.log10(Math.max(rtt,0.01)+1)*80;
    return Math.min(d, Math.min(W,H)*0.42);
  }

  let useMDS=false; // toggled when hub reports triangulation data

  function buildGraph(topoNodes, hubInfo, triInfo, bleDevices){
    // Preserve existing node positions so we don't reset the physics sim
    const oldPos={};
    nodes.forEach(n=>{oldPos[n.id]={x:n.x,y:n.y,vx:n.vx,vy:n.vy}});
    nodes=[]; edges=[];
    bleDevices=bleDevices||[];

    useMDS=triInfo&&triInfo.enabled&&triInfo.edges_measured>topoNodes.length;

    // Hub position
    const hx=useMDS&&hubInfo?(hubInfo.mds_x*W*0.8+W*0.1):(W/2);
    const hy=useMDS&&hubInfo?(hubInfo.mds_y*H*0.8+H*0.1):(H/2);
    nodes.push({id:'__hub__',label:'HUB',type:'hub',x:hx,y:hy,vx:0,vy:0,r:18,pinned:true,rtt:-1,subnet:'',ip:hubInfo?hubInfo.ip:''});

    // Group by IP for same-host clustering
    const byIp={};
    topoNodes.forEach(a=>{
      const ip=a.ip||'unknown';
      if(!byIp[ip]) byIp[ip]=[];
      byIp[ip].push(a);
    });

    const ips=Object.keys(byIp).sort();

    if(useMDS){
      // ── MDS-driven layout: use server-computed positions ──
      const pad=0.1; // 10% margin
      ips.forEach(ip=>{
        const members=byIp[ip];
        const rep=members[0]; // representative for MDS coords (same IP = same pos)
        const mx=rep.mds_x>=0?rep.mds_x:0.5;
        const my=rep.mds_y>=0?rep.mds_y:0.5;
        const cx=mx*W*(1-2*pad)+W*pad;
        const cy=my*H*(1-2*pad)+H*pad;

        const nodeR=10;
        const fan=members.length===1?0:Math.max(nodeR*2.5, members.length*nodeR*1.2);
        const mStep=2*Math.PI/Math.max(members.length,1);

        members.forEach((a,mi)=>{
          const ma=mStep*mi;
          const tid=a.agent_id;
          const old=oldPos[tid];
          const ttx=cx+Math.cos(ma)*fan, tty=cy+Math.sin(ma)*fan;
          const node={
            id:tid,
            label:tid.length>12?tid.slice(0,10)+'..':tid,
            sublabel:a.hostname||a.reverse_dns||a.ip||'',
            type:a.health||'offline', subnet:a.subnet||'', ip:ip,
            rtt:a.rtt_ms,
            tx:ttx, ty:tty,
            x:old?old.x:ttx,
            y:old?old.y:tty,
            vx:0,vy:0, r:10, skills:a.skills_count||0, sameHost:a.same_host||[]
          };
          nodes.push(node);
          edges.push({from:'__hub__',to:tid,rtt:a.rtt_ms});
        });
      });
    } else {
      // ── Radial RTT fallback (original layout) ──
      const angleStep=2*Math.PI/Math.max(ips.length,1);
      ips.forEach((ip,gi)=>{
        const members=byIp[ip];
        const rtt=members[0].rtt_ms;
        const dist=rttToDistance(rtt);
        const angle=angleStep*gi - Math.PI/2;
        const cx=W/2+Math.cos(angle)*dist;
        const cy=H/2+Math.sin(angle)*dist;

        const nodeR=10;
        const fan=members.length===1?0:Math.max(nodeR*2.5, members.length*nodeR*1.2);
        const mStep=2*Math.PI/Math.max(members.length,1);

        members.forEach((a,mi)=>{
          const ma=mStep*mi;
          const tid=a.agent_id;
          const old=oldPos[tid];
          const ttx=cx+Math.cos(ma)*fan, tty=cy+Math.sin(ma)*fan;
          const node={
            id:tid,
            label:tid.length>12?tid.slice(0,10)+'..':tid,
            sublabel:a.hostname||a.reverse_dns||a.ip||'',
            type:a.health||'offline', subnet:a.subnet||'', ip:ip,
            rtt:rtt,
            tx:ttx, ty:tty,
            x:old?old.x:ttx,
            y:old?old.y:tty,
            vx:0,vy:0, r:10, skills:a.skills_count||0, sameHost:a.same_host||[]
          };
          nodes.push(node);
          edges.push({from:'__hub__',to:a.agent_id,rtt:rtt});
        });
      });
    }

    // Also add agent↔agent edges when we have triangulation data
    if(useMDS){
      const agentNodes=nodes.filter(n=>n.type!=='hub');
      for(let i=0;i<agentNodes.length;i++){
        for(let j=i+1;j<agentNodes.length;j++){
          if(agentNodes[i].ip!==agentNodes[j].ip){
            edges.push({from:agentNodes[i].id,to:agentNodes[j].id,rtt:-1,secondary:true});
          }
        }
      }
    }

    // Add BLE/IoT device nodes — RSSI-based radial placement around the hub
    // Log-distance path loss: d = 10^((TxPower - RSSI) / (10*n))
    // TxPower ~ -59 dBm at 1m, n ~ 2 (free space)
    function rssiToPixels(rssi){
      if(!rssi||rssi>=0) return Math.min(W,H)*0.25; // fallback
      const TX=-59, N=2;
      const distM=Math.pow(10,(TX-rssi)/(10*N)); // meters
      // Map: 0.5m→50px, 1m→70px, 3m→110px, 10m→160px (log scale)
      const px=50+Math.log10(Math.max(distM,0.1)+0.5)*90;
      return Math.min(px, Math.min(W,H)*0.4);
    }
    function rssiToMeters(rssi){
      if(!rssi||rssi>=0) return -1;
      const TX=-59, N=2;
      return Math.pow(10,(TX-rssi)/(10*N));
    }
    if(bleDevices.length){
      // Place BLE devices on the bottom-right arc (opposite agents which start at top)
      const hub=nodes[0];
      const angleRange=Math.PI*0.7; // 126° arc
      const startAngle=Math.PI*0.15; // start at ~27° (bottom-right)
      const step=bleDevices.length>1?angleRange/(bleDevices.length-1):0;
      bleDevices.forEach((d,i)=>{
        const dist=rssiToPixels(d.rssi);
        const angle=startAngle+step*i;
        const tx=hub.x+Math.cos(angle)*dist;
        const ty=hub.y+Math.sin(angle)*dist;
        const label=d.name||d.type;
        const distM=rssiToMeters(d.rssi);
        let sublabel=distM>0?'~'+distM.toFixed(1)+'m  '+d.rssi+'dBm':'';
        if(d.sensors){
          sublabel=d.sensors.temperature_c.toFixed(1)+'°C '+d.sensors.humidity+'% '+d.sensors.illuminance_lux+'lux';
        }
        const nodeId='ble_'+d.mac;
        const old=oldPos[nodeId];
        nodes.push({id:nodeId,label:label,sublabel:sublabel,type:'iot',x:old?old.x:tx,y:old?old.y:ty,tx:tx,ty:ty,vx:0,vy:0,r:8,pinned:true,rtt:-1,subnet:'BLE',ip:'',rssi:d.rssi,sensors:d.sensors,ble:true,distM:distM});
        edges.push({from:'__hub__',to:nodeId,rtt:-1,ble:true,rssi:d.rssi,distM:distM});
      });
    }

    const total=topoNodes.length+bleDevices.length;
    document.getElementById('map-count').textContent=total+' node'+(total!==1?'s':'')+(bleDevices.length?' ('+bleDevices.length+' BLE)':'')+(useMDS?' (triangulated)':' (radial)');
  }

  // Smooth lerp toward target — no physics simulation, no jitter
  function tick(){
    for(let i=0;i<nodes.length;i++){
      const n=nodes[i];
      if(n.pinned||n.tx===undefined) continue;
      const dx=n.tx-n.x, dy=n.ty-n.y;
      if(dx*dx+dy*dy<0.25){n.x=n.tx;n.y=n.ty;continue;}
      // Ease toward target (converges in ~10 frames)
      n.x+=dx*0.15;
      n.y+=dy*0.15;
    }
  }

  function draw(){
    ctx.clearRect(0,0,W,H);
    // Fill canvas background (matches page bg)
    ctx.fillStyle=cv('--bg-1'); ctx.fillRect(0,0,W,H);

    // RTT distance rings (only in radial mode)
    const hub=nodes[0];
    if(hub&&!useMDS){
      [0.1,1,10,100].forEach(ms=>{
        const r=rttToDistance(ms);
        ctx.strokeStyle=cv('--canvas-ring');
        ctx.setLineDash([2,4]);
        ctx.beginPath(); ctx.arc(hub.x,hub.y,r,0,Math.PI*2); ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle=cv('--canvas-ring-label'); ctx.font='8px JetBrains Mono,monospace'; ctx.textAlign='left';
        ctx.fillText(ms>=1?ms+'ms':ms*1000+'us',hub.x+r+4,hub.y-4);
      });
    }
    // BLE distance rings (meters)
    if(hub&&nodes.some(n=>n.ble)){
      [1,3,10].forEach(m=>{
        const r=50+Math.log10(Math.max(m,0.1)+0.5)*90;
        ctx.strokeStyle=cv('--canvas-ble-ring');
        ctx.setLineDash([2,4]);
        ctx.beginPath(); ctx.arc(hub.x,hub.y,r,0,Math.PI*2); ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle=cv('--canvas-ble-label'); ctx.font='8px JetBrains Mono,monospace'; ctx.textAlign='right';
        ctx.fillText(m+'m',hub.x-r-4,hub.y+4);
      });
    }
    // Triangulation indicator
    if(useMDS){
      ctx.fillStyle=cv('--canvas-rtt-label'); ctx.font='10px JetBrains Mono,monospace'; ctx.textAlign='left';
      ctx.fillText('\\u25C9 triangulated positions',10,16);
    }

    // Draw edges with RTT labels
    ctx.lineWidth=1;
    edges.forEach(e=>{
      const a=nodes.find(n=>n.id===e.from);
      const b=nodes.find(n=>n.id===e.to);
      if(!a||!b) return;

      if(e.ble){
        ctx.strokeStyle=cv('--canvas-ble-edge');
        ctx.setLineDash([3,5]);
        ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
        ctx.setLineDash([]);
        if(e.distM>0){
          const mx=(a.x+b.x)/2, my=(a.y+b.y)/2;
          ctx.fillStyle=cv('--canvas-ble-dist'); ctx.font='bold 8px JetBrains Mono,monospace'; ctx.textAlign='center';
          ctx.fillText('~'+e.distM.toFixed(1)+'m',mx,my-10);
          ctx.fillStyle=cv('--canvas-ble-label'); ctx.font='8px JetBrains Mono,monospace';
          ctx.fillText(e.rssi+'dBm',mx,my);
        }
      } else if(e.secondary){
        ctx.strokeStyle=cv('--canvas-edge-sec');
        ctx.setLineDash([2,4]);
        ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
        ctx.setLineDash([]);
      } else {
        ctx.strokeStyle=cv('--canvas-edge');
        ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();

        // Animated pulse
        const t=(Date.now()%3000)/3000;
        const px=a.x+(b.x-a.x)*t, py=a.y+(b.y-a.y)*t;
        ctx.fillStyle=cv('--blue'); ctx.globalAlpha=0.3;
        ctx.beginPath(); ctx.arc(px,py,2,0,Math.PI*2); ctx.fill();
        ctx.globalAlpha=1;

        if(e.rtt>=0){
          const mx=(a.x+b.x)/2, my=(a.y+b.y)/2;
          const label=e.rtt<1?e.rtt.toFixed(2)+'ms':e.rtt.toFixed(1)+'ms';
          ctx.fillStyle=cv('--canvas-rtt-label'); ctx.font='bold 8px JetBrains Mono,monospace'; ctx.textAlign='center';
          ctx.fillText(label,mx,my-6);
        }
      }
    });

    // Subnet group outlines
    const groups={};
    nodes.forEach(n=>{if(n.subnet&&n.type!=='hub'){if(!groups[n.subnet])groups[n.subnet]=[];groups[n.subnet].push(n)}});
    Object.values(groups).forEach(g=>{
      if(g.length<2) return;
      let mx=0,my=0; g.forEach(n=>{mx+=n.x;my+=n.y}); mx/=g.length; my/=g.length;
      let maxR=0; g.forEach(n=>{const d=Math.hypot(n.x-mx,n.y-my);if(d>maxR)maxR=d});
      ctx.strokeStyle=cv('--canvas-subnet');
      ctx.setLineDash([4,4]);
      ctx.beginPath(); ctx.arc(mx,my,maxR+22,0,Math.PI*2); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle=cv('--canvas-subnet-label'); ctx.font='9px JetBrains Mono,monospace'; ctx.textAlign='center';
      ctx.fillText(g[0].subnet,mx,my-maxR-28);
    });

    // Same-host grouping
    const ipGroups={};
    nodes.forEach(n=>{if(n.ip&&n.type!=='hub'){if(!ipGroups[n.ip])ipGroups[n.ip]=[];ipGroups[n.ip].push(n)}});
    Object.entries(ipGroups).forEach(([ip,g])=>{
      if(g.length<2) return;
      let mx=0,my=0; g.forEach(n=>{mx+=n.x;my+=n.y}); mx/=g.length; my/=g.length;
      let maxR=0; g.forEach(n=>{const d=Math.hypot(n.x-mx,n.y-my);if(d>maxR)maxR=d});
      ctx.strokeStyle=cv('--canvas-ip-ring');
      ctx.setLineDash([2,3]);
      ctx.beginPath(); ctx.arc(mx,my,maxR+16,0,Math.PI*2); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle=cv('--canvas-ip-label'); ctx.font='8px JetBrains Mono,monospace'; ctx.textAlign='center';
      ctx.fillText(ip,mx,my+maxR+28);
    });

    // Draw nodes
    const C=COLORS();
    nodes.forEach(n=>{
      const color=C[n.type]||C.offline;
      ctx.shadowColor=color; ctx.shadowBlur=n.type==='hub'?14:8;
      ctx.fillStyle=color;
      if(n.ble){
        // Diamond shape for IoT devices
        ctx.beginPath();
        ctx.moveTo(n.x,n.y-n.r-2); ctx.lineTo(n.x+n.r+2,n.y);
        ctx.lineTo(n.x,n.y+n.r+2); ctx.lineTo(n.x-n.r-2,n.y);
        ctx.closePath(); ctx.fill();
      } else {
        ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.fill();
      }
      ctx.shadowBlur=0;
      ctx.strokeStyle='rgba(255,255,255,.15)'; ctx.lineWidth=1;
      if(n.ble){
        ctx.beginPath();
        ctx.moveTo(n.x,n.y-n.r-2); ctx.lineTo(n.x+n.r+2,n.y);
        ctx.lineTo(n.x,n.y+n.r+2); ctx.lineTo(n.x-n.r-2,n.y);
        ctx.closePath(); ctx.stroke();
      } else {
        ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.stroke();
      }
      // Label
      ctx.fillStyle=cv('--canvas-node-label'); ctx.font='bold 9px JetBrains Mono,monospace'; ctx.textAlign='center';
      ctx.fillText(n.label,n.x,n.y+n.r+14);
      if(n.sublabel){
        ctx.fillStyle=cv('--canvas-node-sub'); ctx.font='8px JetBrains Mono,monospace';
        const sl=n.sublabel.length>20?n.sublabel.slice(0,18)+'..':n.sublabel;
        ctx.fillText(sl,n.x,n.y+n.r+24);
      }
    });

    // Empty state
    if(nodes.length<=1){
      ctx.fillStyle=cv('--canvas-empty'); ctx.font='12px JetBrains Mono,monospace'; ctx.textAlign='center';
      ctx.fillText('scanning network...',W/2,H/2);
    }
  }

  // Fetch from real topology endpoint
  let lastKey='';
  function topoKey(data){
    // Only rebuild graph when node set changes, not on health/RTT changes
    const nk=(data.nodes||[]).map(n=>n.agent_id).sort().join(',');
    const bk=(data.ble_devices||[]).map(d=>d.mac+':'+(d.name||'')).sort().join(',');
    return nk+'|'+bk;
  }
  function fetchTopology(){
    fetch('/api/hub/network/topology').then(r=>r.json()).then(data=>{
      const k=topoKey(data);
      const structChanged=k!==lastKey;
      if(structChanged) lastKey=k;
      // Always update target positions and labels; only full rebuild on structural change
      if(structChanged){
        buildGraph(data.nodes||[], data.hub||null, data.triangulation||null, data.ble_devices||[]);
      } else {
        // Smoothly update target positions and metadata without resetting positions
        (data.nodes||[]).forEach(a=>{
          const n=nodes.find(nd=>nd.id===a.agent_id);
          if(n){ n.type=a.health||'offline'; n.rtt=a.rtt_ms; n.sublabel=a.hostname||a.reverse_dns||a.ip||''; }
        });
      }
    }).catch(()=>{});
  }
  fetchTopology();
  setInterval(fetchTopology,5000);

  function animate(){
    for(let i=0;i<3;i++) tick();
    draw();
    requestAnimationFrame(animate);
  }
  animate();

  // Hover tooltip
  let hovered=null;
  canvas.addEventListener('mousemove',e=>{
    const rect=canvas.getBoundingClientRect();
    const mx=e.clientX-rect.left, my=e.clientY-rect.top;
    hovered=null;
    for(const n of nodes){
      if(Math.hypot(n.x-mx,n.y-my)<n.r+4){hovered=n;break}
    }
    canvas.style.cursor=hovered?'pointer':'default';
    const parts=[];
    if(hovered){
      parts.push(hovered.label);
      if(hovered.sublabel) parts.push(hovered.sublabel);
      if(hovered.rtt>=0) parts.push(hovered.rtt.toFixed(2)+'ms RTT');
      if(hovered.rssi) parts.push(hovered.rssi+'dBm'+(hovered.distM>0?' ~'+hovered.distM.toFixed(1)+'m':''));
      if(hovered.sensors) parts.push(hovered.sensors.temperature_c.toFixed(1)+'°C '+hovered.sensors.humidity+'% '+hovered.sensors.illuminance_lux+'lux');
      if(hovered.skills) parts.push(hovered.skills+' skills');
      if(hovered.sameHost&&hovered.sameHost.length) parts.push('co-located: '+hovered.sameHost.join(', '));
    }
    canvas.title=parts.join(' | ');
  });
})();
</script>
</body>
</html>"""


# ---------------------------------------------------------------------------
# Network scanner — real ICMP ping + reverse DNS
# ---------------------------------------------------------------------------


def _ping(ip: str) -> float | None:
    """Ping an IP once, return RTT in ms or None if unreachable."""
    flag_c = "-c" if platform.system() != "Windows" else "-n"
    flag_w = "-W" if platform.system() == "Darwin" else "-w"
    try:
        r = subprocess.run(
            ["ping", flag_c, "1", flag_w, "1", ip],
            capture_output=True,
            text=True,
            timeout=3,
        )
        if r.returncode == 0:
            m = re.search(r"time[=<](\d+\.?\d*)\s*ms", r.stdout)
            if m:
                return float(m.group(1))
    except Exception:
        pass
    return None


def _reverse_dns(ip: str) -> str:
    """Reverse DNS lookup, returns hostname or empty string."""
    try:
        return socket.gethostbyaddr(ip)[0]
    except (socket.herror, socket.gaierror, OSError):
        return ""


def _subnet_of(ip: str) -> str:
    """Return /24 subnet string for an IP."""
    try:
        return str(ipaddress.IPv4Network(f"{ip}/24", strict=False))
    except ValueError:
        return "unknown"


def _classical_mds(labels: list[str], dist: dict[tuple[str, str], float]) -> dict[str, tuple[float, float]]:
    """Classical MDS: project a distance matrix into 2D coordinates.

    Uses the double-centering approach (Torgerson MDS).  Pure Python, no numpy.
    Returns {label: (x, y)} normalized to 0..1 range.
    """
    n = len(labels)
    if n < 2:
        return {labels[0]: (0.5, 0.5)} if n == 1 else {}

    idx = {label: i for i, label in enumerate(labels)}

    # Build squared-distance matrix D²
    D2 = [[0.0] * n for _ in range(n)]
    for (a, b), d in dist.items():
        if a in idx and b in idx:
            i, j = idx[a], idx[b]
            D2[i][j] = d * d
            D2[j][i] = d * d

    # Double centering: B = -0.5 * J * D² * J  where J = I - 1/n * 11'
    # Row means, col means, grand mean
    row_mean = [sum(D2[i]) / n for i in range(n)]
    col_mean = [sum(D2[i][j] for i in range(n)) / n for j in range(n)]
    grand_mean = sum(row_mean) / n

    B = [[0.0] * n for _ in range(n)]
    for i in range(n):
        for j in range(n):
            B[i][j] = -0.5 * (D2[i][j] - row_mean[i] - col_mean[j] + grand_mean)

    # Power iteration to find top 2 eigenvectors of B
    def _eigen(mat: list[list[float]], dim: int) -> list[tuple[float, list[float]]]:
        """Extract top `dim` eigenvalue/eigenvector pairs via deflated power iteration."""
        results: list[tuple[float, list[float]]] = []
        m = [row[:] for row in mat]
        sz = len(m)
        for _ in range(dim):
            # Random-ish initial vector
            v = [1.0 + 0.1 * i for i in range(sz)]
            norm = math.sqrt(sum(x * x for x in v))
            v = [x / norm for x in v]
            eigenval = 0.0
            for _it in range(200):
                # Multiply: w = M @ v
                w = [sum(m[i][j] * v[j] for j in range(sz)) for i in range(sz)]
                eigenval = math.sqrt(sum(x * x for x in w))
                if eigenval < 1e-12:
                    break
                v = [x / eigenval for x in w]
            results.append((eigenval, v))
            # Deflate: M = M - eigenval * v * v'
            for i in range(sz):
                for j in range(sz):
                    m[i][j] -= eigenval * v[i] * v[j]
        return results

    eigens = _eigen(B, 2)

    # Coordinates: X_i = sqrt(lambda_k) * v_k[i]
    coords: dict[str, tuple[float, float]] = {}
    for i, label in enumerate(labels):
        x = math.sqrt(max(eigens[0][0], 0)) * eigens[0][1][i] if len(eigens) > 0 else 0
        y = math.sqrt(max(eigens[1][0], 0)) * eigens[1][1][i] if len(eigens) > 1 else 0
        coords[label] = (x, y)

    # Normalize to 0..1
    if coords:
        xs = [c[0] for c in coords.values()]
        ys = [c[1] for c in coords.values()]
        xmin, xmax = min(xs), max(xs)
        ymin, ymax = min(ys), max(ys)
        xrange = xmax - xmin if xmax - xmin > 1e-9 else 1.0
        yrange = ymax - ymin if ymax - ymin > 1e-9 else 1.0
        coords = {k: ((v[0] - xmin) / xrange, (v[1] - ymin) / yrange) for k, v in coords.items()}

    return coords


def _recompute_mds() -> None:
    """Rebuild MDS positions from the full distance matrix (hub + agents)."""
    hub_ip = _get_hub_ip()
    all_ips = {hub_ip} | {a.get("ip", "") for a in _agents.values()}
    all_ips.discard("")
    labels = sorted(all_ips)

    if len(labels) < 2:
        _mds_positions.clear()
        return

    # Build combined distance dict using hub→agent (_rtt) and agent↔agent (_distance_matrix)
    combined: dict[tuple[str, str], float] = {}
    for ip in labels:
        if ip != hub_ip and ip in _rtt:
            key = tuple(sorted([hub_ip, ip]))
            combined[(key[0], key[1])] = _rtt[ip]
    combined.update(_distance_matrix)

    positions = _classical_mds(labels, combined)
    _mds_positions.clear()
    _mds_positions.update(positions)


_hub_ip_cache: str = ""


def _get_hub_ip() -> str:
    global _hub_ip_cache
    if not _hub_ip_cache:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            _hub_ip_cache = s.getsockname()[0]
            s.close()
        except Exception:
            _hub_ip_cache = "127.0.0.1"
    return _hub_ip_cache


def _scan_once() -> None:
    """Ping all known agent IPs and update _rtt + _hostnames + distance matrix."""
    # Collect unique IPs from registered agents
    ips = {a.get("ip", "") for a in _agents.values()}
    ips.discard("")

    hub_ip = _get_hub_ip()
    for ip in ips:
        rtt = _ping(ip)
        if rtt is not None:
            # Exponential moving average (alpha=0.3) for smooth readings
            prev = _rtt.get(ip)
            _rtt[ip] = rtt if prev is None else prev * 0.7 + rtt * 0.3
            # Also store in distance matrix (hub↔agent)
            key = tuple(sorted([hub_ip, ip]))
            _distance_matrix[(key[0], key[1])] = _rtt[ip]
        # Reverse DNS (only if we don't have it yet)
        if ip not in _hostnames:
            _hostnames[ip] = _reverse_dns(ip)

    # Recompute MDS positions after each scan
    if len(ips) >= 1:
        _recompute_mds()


def _scan_loop(stop: threading.Event) -> None:
    """Background loop that runs _scan_once every _SCAN_INTERVAL seconds."""
    while not stop.is_set():
        try:
            _scan_once()
        except Exception:
            pass
        stop.wait(_SCAN_INTERVAL)


_scan_stop = threading.Event()


# ---------------------------------------------------------------------------
# BLE scanner — discover SwitchBot IoT devices in range
# ---------------------------------------------------------------------------


def _parse_hub2_sensors(mfr_data: bytes) -> dict[str, Any]:
    """Parse Hub 2 sensor data from manufacturer advertisement bytes."""
    if len(mfr_data) < 16:
        return {}
    status = mfr_data[12]
    td = mfr_data[13:16]
    sign = 1 if td[1] & 0x80 else -1
    temp_c = sign * ((td[1] & 0x7F) + (td[0] & 0x0F) / 10)
    humidity = td[2] & 0x7F
    light_level = status & 0x1F
    if temp_c == 0 and humidity == 0:
        return {}
    return {
        "temperature_c": temp_c,
        "humidity": humidity,
        "light_level": light_level,
        "illuminance_lux": _LIGHT_LUX.get(min(light_level, 21), 0),
    }


def _ble_scan_once() -> None:
    """Run a single BLE scan and update _ble_devices registry."""
    import asyncio
    import logging

    logger = logging.getLogger("chatixia.hub.ble")

    try:
        from bleak import BleakScanner
    except ImportError:
        logger.warning("bleak not installed, BLE scanning disabled")
        return

    async def _scan() -> list[tuple[str, str, int, bytes]]:
        results = []
        devices = await BleakScanner.discover(timeout=10, return_adv=True)
        for _addr, (dev, adv) in devices.items():
            md = adv.manufacturer_data or {}
            if _SWITCHBOT_MFR_ID in md:
                raw = md[_SWITCHBOT_MFR_ID]
                mac = ":".join(f"{b:02X}" for b in raw[:6])
                results.append((mac, dev.name or "", adv.rssi, raw))
        return results

    try:
        loop = asyncio.get_event_loop()
        results = loop.run_until_complete(_scan())
    except Exception as e:
        logger.warning("BLE scan failed: %s", e)
        return

    now = time.time()
    for mac, name, rssi, raw in results:
        # Determine device type from name or data length
        if name == "WoPlug" or (len(raw) == 12):
            dev_type = "plug"
        elif len(raw) >= 16:
            dev_type = "hub2"
        else:
            dev_type = "unknown"

        entry: dict[str, Any] = {
            "mac": mac,
            "name": name or dev_type,
            "type": dev_type,
            "rssi": rssi,
            "last_seen": now,
        }

        # Parse sensor data for Hub 2
        if dev_type == "hub2":
            sensors = _parse_hub2_sensors(raw)
            if sensors:
                entry["sensors"] = sensors

        _ble_devices[mac[:8]] = entry  # key by first 3 octets


def _ble_scan_loop(stop_event: threading.Event) -> None:
    """Background thread: periodically scan for BLE devices with its own event loop."""
    import asyncio

    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        while not stop_event.is_set():
            _ble_scan_once()
            stop_event.wait(_BLE_SCAN_INTERVAL)
    finally:
        loop.close()


@app.get("/api/hub/network/topology")
async def network_topology() -> dict[str, Any]:
    """Return agents enriched with RTT, subnet, same-host grouping, and MDS positions."""
    now = time.time()
    nodes: list[dict[str, Any]] = []
    hub_ip = _get_hub_ip()
    has_mds = len(_mds_positions) >= 2

    for agent in _agents.values():
        ip = agent.get("ip", "")
        received = agent.get("_received_at", 0)
        age = now - received
        if age > _DEFAULT_STALE_MULTIPLIER * 90:
            health = "offline"
        elif age > _DEFAULT_STALE_MULTIPLIER * 30:
            health = "stale"
        else:
            health = "active"

        # MDS-computed position (normalized 0-1), or -1 if not yet available
        mds_pos = _mds_positions.get(ip)

        nodes.append(
            {
                "agent_id": agent.get("agent_id", ""),
                "ip": ip,
                "port": agent.get("port", 8000),
                "hostname": agent.get("hostname", ""),
                "reverse_dns": _hostnames.get(ip, ""),
                "mode": agent.get("mode", ""),
                "skills_count": agent.get("skills_count", 0),
                "health": health,
                "rtt_ms": round(_rtt.get(ip, -1), 2),
                "subnet": _subnet_of(ip) if ip else "unknown",
                "mds_x": round(mds_pos[0], 4) if mds_pos else -1,
                "mds_y": round(mds_pos[1], 4) if mds_pos else -1,
            }
        )

    # Group by IP to identify same-host agents
    by_ip: dict[str, list[str]] = {}
    for n in nodes:
        by_ip.setdefault(n["ip"], []).append(n["agent_id"])
    for n in nodes:
        peers = by_ip.get(n["ip"], [])
        n["same_host"] = [p for p in peers if p != n["agent_id"]]

    # Hub's own MDS position
    hub_mds = _mds_positions.get(hub_ip)

    return {
        "nodes": nodes,
        "hub": {
            "ip": hub_ip,
            "mds_x": round(hub_mds[0], 4) if hub_mds else 0.5,
            "mds_y": round(hub_mds[1], 4) if hub_mds else 0.5,
        },
        "triangulation": {
            "enabled": has_mds,
            "edges_measured": len(_distance_matrix),
            "total_possible": max(1, len(_rtt) * (len(_rtt) + 1) // 2),
        },
        "scanner": {
            "ips_tracked": len(_rtt),
            "last_scan": "active" if not _scan_stop.is_set() else "stopped",
        },
        "ble_devices": [
            {
                "mac": d["mac"],
                "name": d["name"],
                "type": d["type"],
                "rssi": d["rssi"],
                "sensors": d.get("sensors"),
                "age_s": round(now - d["last_seen"], 1),
            }
            for d in _ble_devices.values()
        ],
    }


def _start_discovery(port: int) -> None:
    """Start hub announcer and listener for LAN discovery."""
    from chatixia.discovery import (
        MSG_AGENT_ANNOUNCE,
        MSG_HUB_ANNOUNCE,
        Announcer,
        Listener,
    )

    # Announce hub on the LAN so agents with hub: auto can find us
    announcer = Announcer(
        msg_type=MSG_HUB_ANNOUNCE,
        payload={"port": port},
        interval=10,
    )
    announcer.start()

    # Listen for agent announcements and register them
    def on_message(msg: dict[str, Any]) -> None:
        if msg.get("type") != MSG_AGENT_ANNOUNCE:
            return
        agent_id = msg.get("agent_id", "")
        if not agent_id:
            return
        _agents[agent_id] = {
            "agent_id": agent_id,
            "hostname": msg.get("hostname", ""),
            "ip": msg.get("ip", ""),
            "port": msg.get("port", 8000),
            "status": msg.get("status", "running"),
            "mode": msg.get("mode", "interactive"),
            "skills_count": msg.get("skills_count", 0),
            "goals_count": msg.get("goals_count", 0),
            "uptime_seconds": msg.get("uptime_seconds", 0),
            "timestamp": msg.get("timestamp", ""),
            "_received_at": time.time(),
        }

    listener = Listener(on_message)
    listener.start()

    # Start network scanner thread
    _scan_stop.clear()
    scan_thread = threading.Thread(target=_scan_loop, args=(_scan_stop,), daemon=True, name="network-scanner")
    scan_thread.start()

    # Start BLE scanner thread
    _ble_scan_stop.clear()
    ble_thread = threading.Thread(target=_ble_scan_loop, args=(_ble_scan_stop,), daemon=True, name="ble-scanner")
    ble_thread.start()


def run_hub(host: str = "0.0.0.0", port: int = 8002) -> None:
    """Start the hub server with LAN discovery."""
    import uvicorn

    _start_discovery(port)
    uvicorn.run(app, host=host, port=port)
