/* ============================================================================
 * AESTRION · Network Visualization v2 (alternate)
 * ----------------------------------------------------------------------------
 * Story: same as v1 (today / peacetime / crisis) but with:
 *  - Main + Lite hubs visually distinct as real street objects
 *  - A labelled LoRa MESH band makes "every hub is a mesh hub" obvious
 *  - Speech-bubble messages emit from sources, travel the lines, fade
 *  - A live EVENT LOG panel on the right scrolls every dispatched message
 *
 * Mounts into #netviz-v2-mount.
 * ============================================================================ */

const { useState, useEffect, useRef, useMemo } = React;

const V2 = (() => {

/* ---------------------------------------------------------------------------
   Topology
   --------------------------------------------------------------------------- */
const STAGE_W = 1080;
const STAGE_H = 600;

const HUB_Y = 300;
// Main hubs at H-02 and H-04; rest are Lite. Mesh links every adjacent pair.
const HUBS = [
  { id: 'h1', x: 160, y: HUB_Y, label: 'H-01', kind: 'lite' },
  { id: 'h2', x: 360, y: HUB_Y, label: 'H-02', kind: 'main' },
  { id: 'h3', x: 540, y: HUB_Y, label: 'H-03', kind: 'lite' },
  { id: 'h4', x: 720, y: HUB_Y, label: 'H-04', kind: 'main' },
  { id: 'h5', x: 920, y: HUB_Y, label: 'H-05', kind: 'lite' },
];
const HUB_BY_ID = Object.fromEntries(HUBS.map(h => [h.id, h]));
const MESH_EDGES = [['h1','h2'],['h2','h3'],['h3','h4'],['h4','h5']];

const PROJ_Y = 470;
const PROJECTS = [
  { id: 'air',     x: 130, y: PROJ_Y, name: 'Air sensor',   glyph: 'A', hub: 'h1', loc: 'Anexertisias' },
  { id: 'water',   x: 230, y: PROJ_Y, name: 'Sewerage',     glyph: 'S', hub: 'h1', loc: 'Sewerage 37' },
  { id: 'park',    x: 330, y: PROJ_Y, name: 'Parking',      glyph: 'P', hub: 'h2', loc: 'Marina-1' },
  { id: 'light',   x: 430, y: PROJ_Y, name: 'Lighting',     glyph: 'L', hub: 'h3', loc: 'Makarios Ave' },
  { id: 'info',    x: 530, y: PROJ_Y, name: 'Info point',   glyph: 'I', hub: 'h3', loc: 'Old Town' },
  { id: 'transit', x: 640, y: PROJ_Y, name: 'Transit',      glyph: 'T', hub: 'h4', loc: 'Bus Stop 12' },
  { id: 'pay',     x: 740, y: PROJ_Y, name: 'Payments',     glyph: 'E', hub: 'h4', loc: 'Plaza-2' },
  { id: 'wifi',    x: 840, y: PROJ_Y, name: 'Public WiFi',  glyph: 'W', hub: 'h5', loc: 'Beachfront' },
  { id: 'noise',   x: 940, y: PROJ_Y, name: 'Noise',        glyph: 'N', hub: 'h5', loc: 'Germasogeia' },
];
const PROJECT_BY_ID = Object.fromEntries(PROJECTS.map(p => [p.id, p]));

const CITIZEN_Y = 540;
const CITIZENS = [
  { id: 'c1', x: 200, y: CITIZEN_Y, hub: 'h1' },
  { id: 'c2', x: 360, y: CITIZEN_Y, hub: 'h2' },
  { id: 'c3', x: 540, y: CITIZEN_Y, hub: 'h3' },
  { id: 'c4', x: 720, y: CITIZEN_Y, hub: 'h4' },
  { id: 'c5', x: 880, y: CITIZEN_Y, hub: 'h5' },
];

const AGGREGATOR = { x: 540, y: 100 };
const SATELLITE  = { x: 540, y: 60  };
const DISPATCH   = { x: 860, y: 100 };

/* ---------------------------------------------------------------------------
   States & message catalogues
   --------------------------------------------------------------------------- */
const NetState = { TODAY: 'today', PEACE: 'peace', CRISIS: 'crisis' };

const STATES = [
  {
    id: NetState.TODAY,
    label: '◆ Today',
    title: 'Today · every project rents its own pipe',
    blurb: 'Every smart-city project pays for its own carrier link. Each one is a separate dependency, separate bill, separate failure mode.',
  },
  {
    id: NetState.PEACE,
    label: '◆ AESTRION · peacetime',
    title: 'One mesh · every project plugs in',
    blurb: 'Hub Lites and Main Hubs share a single LoRa mesh. Sensors, citizens, civic projects all ride the same network. The Mains bridge to municipal systems and the open internet.',
  },
  {
    id: NetState.CRISIS,
    label: '◆ Crisis',
    title: 'Same network · now the lifeline',
    blurb: 'Power and ISPs go dark. The mesh keeps running. Main Hubs become the city\u2019s only internet bridge via Starlink. Citizens reach dispatch through the same network that carried sensor data ten minutes ago.',
  },
];

// Message catalogue: each message has source/route/text/kind
// kind: 'sensor' | 'citizen' | 'dispatch' | 'ops'
// route: which path it travels — 'project-to-hub-to-aggregator' (peace),
//                                'citizen-to-hub-to-dispatch' (crisis), etc.
const MESSAGES_PEACE = [
  { src: 'park',    text: 'No available parking at Marina-1',          kind: 'sensor' },
  { src: 'air',     text: 'Air quality dropping at Anexertisias',     kind: 'sensor' },
  { src: 'noise',   text: 'Noise spike near Germasogeia · 78 dB',     kind: 'sensor' },
  { src: 'transit', text: 'Bus 12 delayed · 4 min',                   kind: 'sensor' },
  { src: 'light',   text: 'Lamp 042 reporting low voltage',           kind: 'sensor' },
  { src: 'water',   text: 'Sewerage 37 · normal flow',                kind: 'sensor' },
  { src: 'pay',     text: 'Plaza-2 · 14 transactions / hr',           kind: 'sensor' },
  { src: 'info',    text: 'Old Town info kiosk · 23 queries',         kind: 'sensor' },
];

// Crisis traffic mixes standalone events (sensor alerts, dispatch broadcasts,
// citizen pings that don't need a personal reply) with Q/A exchanges where a
// targeted reply makes sense. Each entry is either:
//   { kind: 'solo', msg: { ... } }
//   { kind: 'pair', q: { ... }, a: { ..., dest: '<id>' } }
const MESSAGES_CRISIS = [
  // Sensor alert \u2014 city-wide situational awareness, no personal reply
  { kind: 'solo', msg: { src: 'water', text: 'Water rising in Sewerage 37 \u2014 flow at 240% normal', kind: 'alert' } },

  // Dispatch broadcasts a public advisory, addressed to no one in particular
  { kind: 'solo', msg: { src: 'dispatch', dest: null, text: 'PUBLIC ADVISORY: flash flood warning, sectors B and C', kind: 'dispatch' } },

  // Citizen Q \u2192 dispatch A
  { kind: 'pair',
    q: { src: 'c1', text: 'Where is the nearest shelter?', kind: 'citizen' },
    a: { src: 'dispatch', dest: 'c1', text: 'Shelter open at Plaza-2 \u00b7 capacity 220. Walk via Makarios.', kind: 'dispatch' },
  },

  // Standalone sensor reading
  { kind: 'solo', msg: { src: 'air', text: 'Smoke detected at Anexertisias \u00b7 PM2.5 \u00b7 184 \u00b5g/m\u00b3', kind: 'alert' } },

  // Standalone citizen ping that doesn't need a reply (just venting / status)
  { kind: 'solo', msg: { src: 'c5', text: 'OK we are safe at home, no power but everyone fine', kind: 'citizen' } },

  // Citizen Q \u2192 dispatch A
  { kind: 'pair',
    q: { src: 'c3', text: 'Is the A2 highway open?', kind: 'citizen' },
    a: { src: 'dispatch', dest: 'c3', text: 'A2 closed both directions. Use B6 detour.', kind: 'dispatch' },
  },

  // Standalone dispatch broadcast \u2014 city update
  { kind: 'solo', msg: { src: 'dispatch', dest: null, text: 'Power restoration ETA 4h \u00b7 prioritising hospital + sectors A,B', kind: 'dispatch' } },

  // Standalone sensor alert
  { kind: 'solo', msg: { src: 'noise', text: 'Siren detected near Germasogeia \u00b7 emergency vehicles inbound', kind: 'alert' } },

  // Targeted Q/A
  { kind: 'pair',
    q: { src: 'c4', text: 'Is the fire near our village contained?', kind: 'citizen' },
    a: { src: 'dispatch', dest: 'c4', text: 'Contained at perimeter. No evacuation needed in your area.', kind: 'dispatch' },
  },

  // Standalone citizen ping \u2014 community report, no reply needed
  { kind: 'solo', msg: { src: 'c2', text: 'Tree down on Marina road, blocking northbound lane', kind: 'citizen' } },

  // Standalone sensor heartbeat
  { kind: 'solo', msg: { src: 'transit', text: 'All transit suspended \u00b7 last bus parked Bus Stop 12', kind: 'alert' } },
];

/* ---------------------------------------------------------------------------
   Geometry helpers
   --------------------------------------------------------------------------- */
function pathProjectToHub(p, h) {
  const cornerY = HUB_Y + 30;
  return `M ${p.x},${p.y} L ${p.x},${cornerY} L ${h.x},${cornerY} L ${h.x},${HUB_Y + 14}`;
}
function pathCitizenToHub(c, h) {
  return `M ${c.x},${c.y} L ${c.x},${HUB_Y + 36} L ${h.x},${HUB_Y + 36} L ${h.x},${HUB_Y + 14}`;
}
function pathHubToAggregator(h) {
  // Up to aggregator, via mid-y
  const midY = (h.y + AGGREGATOR.y) / 2 + 20;
  return h.x === AGGREGATOR.x
    ? `M ${h.x},${h.y - 14} L ${h.x},${AGGREGATOR.y + 14}`
    : `M ${h.x},${h.y - 14} L ${h.x},${midY} L ${AGGREGATOR.x},${midY} L ${AGGREGATOR.x},${AGGREGATOR.y + 14}`;
}
function pathHubToSat(h) {
  const midY = (h.y + SATELLITE.y) / 2 + 10;
  return h.x === SATELLITE.x
    ? `M ${h.x},${h.y - 14} L ${h.x},${SATELLITE.y + 10}`
    : `M ${h.x},${h.y - 14} L ${h.x},${midY} L ${SATELLITE.x},${midY} L ${SATELLITE.x},${SATELLITE.y + 10}`;
}
function pathDispatchToHub(h) {
  const midY = (h.y + DISPATCH.y) / 2 + 20;
  return h.x === DISPATCH.x
    ? `M ${DISPATCH.x},${DISPATCH.y + 14} L ${DISPATCH.x},${h.y - 14}`
    : `M ${DISPATCH.x},${DISPATCH.y + 14} L ${DISPATCH.x},${midY} L ${h.x},${midY} L ${h.x},${h.y - 14}`;
}

/* ---------------------------------------------------------------------------
   Animated packet dots along an SVG path
   --------------------------------------------------------------------------- */
function PacketDot({ d, color = '#6FE3FF', dur = 2.4, delay = 0, r = 2.0, opacity = 0.9 }) {
  const ref = useRef(null);
  return (
    <g>
      <path ref={ref} d={d} fill="none" stroke="none" id={`pd-${Math.random().toString(36).slice(2,8)}`} />
      <circle r={r} fill={color} opacity={opacity}>
        <animateMotion dur={`${dur}s`} repeatCount="indefinite" begin={`${delay}s`} path={d}/>
      </circle>
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Speech bubble that travels along a path then fades
   --------------------------------------------------------------------------- */
function TravelingBubble({ d, text, kind, dur = 2.6, onDone }) {
  // Render an SVG <text>+rect inside a <g> with animateMotion
  const palette = {
    sensor:   { bg: 'rgba(0,212,255,0.14)',  st: '#00D4FF', tx: '#E8EDF5' },
    citizen:  { bg: 'rgba(232,237,245,0.14)', st: '#E8EDF5', tx: '#FFFFFF' },
    dispatch: { bg: 'rgba(255,180,84,0.16)', st: '#FFB454', tx: '#FFE7C6' },
    alert:    { bg: 'rgba(255,92,92,0.18)',   st: '#FF5C5C', tx: '#FFD8D8' },
    ops:      { bg: 'rgba(0,212,255,0.14)',  st: '#00D4FF', tx: '#E8EDF5' },
  }[kind] || { bg: 'rgba(0,212,255,0.14)', st: '#00D4FF', tx: '#E8EDF5' };

  // Estimate width based on text length (roughly 6px per char @ 9px font)
  const w = Math.max(120, Math.min(280, text.length * 5.6 + 24));
  const h = 22;

  useEffect(() => {
    const t = setTimeout(onDone, dur * 1000 + 300);
    return () => clearTimeout(t);
  }, [dur, onDone]);

  return (
    <g>
      {/* The motion group rides along the path */}
      <g>
        <animateMotion dur={`${dur}s`} repeatCount="1" rotate="0" path={d} fill="freeze"/>
        {/* Bubble offset upward so it doesn't sit on the line */}
        <g transform="translate(0,-22)">
          <rect x={-w/2} y={-h/2} width={w} height={h}
                fill={palette.bg} stroke={palette.st} strokeWidth={1}
                rx={2} ry={2}/>
          {/* small tail down toward the line */}
          <path d={`M -4 ${h/2} L 0 ${h/2 + 5} L 4 ${h/2} Z`}
                fill={palette.bg} stroke={palette.st} strokeWidth={1}/>
          <text x={0} y={3} textAnchor="middle"
                fontFamily="'IBM Plex Mono', monospace" fontSize="9"
                letterSpacing="0.5" fill={palette.tx}>
            {text}
          </text>
          {/* fade out near the end */}
          <animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1"
                   dur={`${dur}s`} repeatCount="1" fill="freeze"/>
        </g>
      </g>
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Hub markers — Main and Lite, both with prominent LoRa antenna
   --------------------------------------------------------------------------- */
function MeshAntenna({ active, color = '#00D4FF' }) {
  // concentric arcs above the hub indicating always-on mesh radio
  return (
    <g opacity={active ? 0.95 : 0.45}>
      <path d="M -7 0 A 7 7 0 0 1 7 0" fill="none" stroke={color} strokeWidth={0.9}>
        {active && <animate attributeName="opacity" values="0.4;1;0.4" dur="1.8s" repeatCount="indefinite"/>}
      </path>
      <path d="M -10 0 A 10 10 0 0 1 10 0" fill="none" stroke={color} strokeWidth={0.7} opacity={0.7}>
        {active && <animate attributeName="opacity" values="0.2;0.7;0.2" dur="1.8s" begin="0.25s" repeatCount="indefinite"/>}
      </path>
      <line x1={0} y1={0} x2={0} y2={5} stroke={color} strokeWidth={0.9}/>
    </g>
  );
}

function HubMain({ h, mode, satActive }) {
  const active = mode !== NetState.TODAY;
  const stroke = active ? '#00D4FF' : '#4A5264';
  const fill   = active ? 'rgba(0,212,255,0.10)' : 'rgba(255,255,255,0.02)';
  const satColor = satActive ? '#FF9450' : (active ? '#6FE3FF' : '#4A5264');

  return (
    <g transform={`translate(${h.x},${h.y})`}>
      {/* Starlink dish on top */}
      <g transform="translate(0,-32)">
        <ellipse cx={0} cy={0} rx={12} ry={4}
                 fill={satActive ? 'rgba(255,148,80,0.18)' : 'rgba(0,212,255,0.06)'}
                 stroke={satColor} strokeWidth={1}/>
        <circle cx={0} cy={0} r={1.4} fill={satColor}/>
        <line x1={0} y1={4} x2={0} y2={9} stroke={satColor} strokeWidth={0.9}/>
        {satActive && (
          <>
            <path d="M -14 -3 A 14 14 0 0 1 14 -3" fill="none" stroke={satColor} strokeWidth={0.9} opacity={0.7}>
              <animate attributeName="opacity" values="0.2;0.9;0.2" dur="1.6s" repeatCount="indefinite"/>
            </path>
            <path d="M -18 -5 A 18 18 0 0 1 18 -5" fill="none" stroke={satColor} strokeWidth={0.6} opacity={0.5}>
              <animate attributeName="opacity" values="0.05;0.6;0.05" dur="1.6s" begin="0.3s" repeatCount="indefinite"/>
            </path>
          </>
        )}
      </g>

      {/* Mesh antenna glyph next to dish */}
      <g transform="translate(-22,-30)">
        <MeshAntenna active={active} color={stroke}/>
      </g>

      {/* Tall standalone enclosure */}
      <rect x={-15} y={-18} width={30} height={36}
            fill={fill} stroke={stroke} strokeWidth={1.2}/>
      {/* Screen pane */}
      <rect x={-12} y={-15} width={24} height={14}
            fill={active ? 'rgba(0,212,255,0.20)' : 'rgba(255,255,255,0.04)'}
            stroke={stroke} strokeOpacity={0.6} strokeWidth={0.5}/>
      <line x1={-9} y1={-11} x2={9}  y2={-11} stroke={stroke} strokeOpacity={0.7} strokeWidth={0.5}/>
      <line x1={-9} y1={-8}  x2={4}  y2={-8}  stroke={stroke} strokeOpacity={0.7} strokeWidth={0.5}/>
      <line x1={-9} y1={-5}  x2={7}  y2={-5}  stroke={stroke} strokeOpacity={0.7} strokeWidth={0.5}/>
      {/* Charging row */}
      <rect x={-12} y={2} width={24} height={6}
            fill="none" stroke={stroke} strokeOpacity={0.4} strokeWidth={0.5}/>
      <text x={-7} y={13} textAnchor="middle" fontSize="6.5" fill={stroke}
            fontFamily="'IBM Plex Mono',monospace">⚡</text>
      <text x={0} y={13} textAnchor="middle" fontSize="6.5" fill={stroke}
            fontFamily="'IBM Plex Mono',monospace">⚡</text>
      <text x={7} y={13} textAnchor="middle" fontSize="6.5" fill={stroke}
            fontFamily="'IBM Plex Mono',monospace">⚡</text>
      {/* Base flange */}
      <line x1={-18} y1={18} x2={18} y2={18} stroke={stroke} strokeWidth={1.4}/>

      {/* Label */}
      <text x={0} y={34} textAnchor="middle"
            fontFamily="'IBM Plex Mono', monospace" fontSize="8"
            letterSpacing="2" fill={active ? '#FFE7C6' : '#4A5264'}
            fontWeight="600">{h.label} · MAIN</text>

      {/* Halo */}
      {active && (
        <circle r={20} fill="none" stroke={stroke} strokeWidth={0.6} opacity={0.4}>
          <animate attributeName="r" from="14" to="32" dur="2.4s" repeatCount="indefinite"/>
          <animate attributeName="opacity" from="0.4" to="0" dur="2.4s" repeatCount="indefinite"/>
        </circle>
      )}
    </g>
  );
}

function HubLite({ h, mode }) {
  const active = mode !== NetState.TODAY;
  const stroke = active ? '#00D4FF' : '#4A5264';
  const fill   = active ? 'rgba(0,212,255,0.08)' : 'rgba(255,255,255,0.02)';

  return (
    <g transform={`translate(${h.x},${h.y})`}>
      {/* Solar panel above */}
      <g transform="translate(0,-26)" opacity={active ? 0.9 : 0.45}>
        <path d="M -9 3 L -7 -4 L 9 -4 L 7 3 Z"
              fill="rgba(0,212,255,0.08)" stroke={stroke} strokeWidth={0.9}/>
        <line x1={-7} y1={-0.5} x2={7} y2={-0.5} stroke={stroke} strokeOpacity={0.45} strokeWidth={0.4}/>
        <line x1={-4} y1={-4} x2={-5} y2={3} stroke={stroke} strokeOpacity={0.45} strokeWidth={0.4}/>
        <line x1={0} y1={-4} x2={0} y2={3} stroke={stroke} strokeOpacity={0.45} strokeWidth={0.4}/>
        <line x1={4} y1={-4} x2={5} y2={3} stroke={stroke} strokeOpacity={0.45} strokeWidth={0.4}/>
        <line x1={0} y1={3} x2={0} y2={7} stroke={stroke} strokeWidth={0.7}/>
      </g>

      {/* Mesh antenna glyph beside the box top */}
      <g transform="translate(13,-15)">
        <MeshAntenna active={active} color={stroke}/>
      </g>

      {/* Compact pole-mounted enclosure */}
      <circle cx={0} cy={0} r={11} fill={fill} stroke={stroke} strokeWidth={1.1}/>
      <line x1={-5} y1={-3} x2={5} y2={-3} stroke={stroke} strokeOpacity={0.7} strokeWidth={0.6}/>
      <line x1={-5} y1={0}  x2={5} y2={0}  stroke={stroke} strokeOpacity={0.7} strokeWidth={0.6}/>
      <line x1={-5} y1={3}  x2={5} y2={3}  stroke={stroke} strokeOpacity={0.7} strokeWidth={0.6}/>
      {/* status LED */}
      <circle cx={-7} cy={-7} r={1.2} fill={stroke}>
        {active && <animate attributeName="opacity" values="0.3;1;0.3" dur="2.2s" repeatCount="indefinite"/>}
      </circle>

      {/* Pole */}
      <line x1={0} y1={11} x2={0} y2={22} stroke={stroke} strokeOpacity={0.6} strokeWidth={0.9}/>
      <line x1={-3} y1={22} x2={3} y2={22} stroke={stroke} strokeOpacity={0.6} strokeWidth={0.9}/>

      {/* Label */}
      <text x={0} y={35} textAnchor="middle"
            fontFamily="'IBM Plex Mono', monospace" fontSize="8"
            letterSpacing="2" fill={active ? '#6FE3FF' : '#4A5264'}>
        {h.label} · LITE
      </text>
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Project + citizen markers
   --------------------------------------------------------------------------- */
function ProjectMarker({ p, dim }) {
  return (
    <g transform={`translate(${p.x},${p.y})`} opacity={dim ? 0.45 : 1}>
      <circle r={9} fill="rgba(255,255,255,0.04)" stroke="rgba(232,237,245,0.35)"
              strokeWidth={0.8}/>
      <text x={0} y={3} textAnchor="middle"
            fontFamily="'Inter Tight', sans-serif" fontSize="10" fontWeight="500"
            fill="#E8EDF5">{p.glyph}</text>
      <text x={0} y={-14} textAnchor="middle"
            fontFamily="'IBM Plex Mono', monospace" fontSize="7"
            letterSpacing="1.4" fill="#8A93A6"
            style={{textTransform:'uppercase'}}>{p.name}</text>
    </g>
  );
}

function CitizenMarker({ c }) {
  return (
    <g transform={`translate(${c.x},${c.y})`}>
      {/* phone glyph */}
      <rect x={-3.5} y={-5} width={7} height={12} rx={1.2}
            fill="rgba(232,237,245,0.10)" stroke="#E8EDF5" strokeWidth={0.7}/>
      <line x1={-1.8} y1={-3} x2={1.8} y2={-3} stroke="#E8EDF5" strokeWidth={0.5}/>
      <circle cx={0} cy={5} r={0.8} fill="#E8EDF5"/>
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Aggregator / Dispatch / Satellite endpoint markers
   --------------------------------------------------------------------------- */
function EndpointBox({ x, y, label, sub, kind }) {
  // kind: 'cyan' | 'orange' | 'red'
  const palette = {
    cyan:   { st: '#00D4FF', bg: 'rgba(0,212,255,0.08)', tx: '#6FE3FF' },
    orange: { st: '#FFB454', bg: 'rgba(255,180,84,0.10)', tx: '#FFB454' },
    red:    { st: '#FF5C5C', bg: 'rgba(255,92,92,0.08)', tx: '#FF8A8A' },
  }[kind] || { st: '#00D4FF', bg: 'rgba(0,212,255,0.08)', tx: '#6FE3FF' };
  return (
    <g transform={`translate(${x},${y})`}>
      <rect x={-58} y={-14} width={116} height={28}
            fill={palette.bg} stroke={palette.st} strokeWidth={1}/>
      <text x={0} y={4} textAnchor="middle"
            fontFamily="'IBM Plex Mono', monospace" fontSize="9"
            letterSpacing="2" fill={palette.tx}>{label}</text>
      {sub && (
        <text x={0} y={28} textAnchor="middle"
              fontFamily="'IBM Plex Mono', monospace" fontSize="7"
              letterSpacing="1.6" fill="#8A93A6"
              style={{textTransform:'uppercase'}}>{sub}</text>
      )}
    </g>
  );
}

function SatelliteIcon() {
  return (
    <g transform={`translate(${SATELLITE.x},${SATELLITE.y})`}>
      <rect x={-7} y={-5} width={14} height={10} fill="#FFB454" opacity={0.95}/>
      <rect x={-22} y={-4} width={11} height={8} fill="none" stroke="#FFB454" strokeWidth={0.9}/>
      <rect x={11}  y={-4} width={11} height={8} fill="none" stroke="#FFB454" strokeWidth={0.9}/>
      <line x1={-11} y1={0} x2={-7} y2={0} stroke="#FFB454" strokeWidth={1}/>
      <line x1={7} y1={0} x2={11} y2={0} stroke="#FFB454" strokeWidth={1}/>
      <text x={0} y={-12} textAnchor="middle"
            fontFamily="'IBM Plex Mono', monospace" fontSize="8"
            letterSpacing="2" fill="#FFB454">◆ SAT</text>
      <circle r={20} fill="none" stroke="#FFB454" strokeWidth={0.6} opacity={0.5}>
        <animate attributeName="r" from="14" to="38" dur="2.4s" repeatCount="indefinite"/>
        <animate attributeName="opacity" from="0.6" to="0" dur="2.4s" repeatCount="indefinite"/>
      </circle>
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Mesh ring — visual band linking all hubs, labelled
   --------------------------------------------------------------------------- */
function MeshBand({ active, crisis }) {
  const stroke = active ? '#00D4FF' : '#4A5264';
  // Build a wide rounded-rect band that hugs the hub spine
  const left  = HUBS[0].x - 30;
  const right = HUBS[HUBS.length - 1].x + 30;
  const top   = HUB_Y - 60;
  const bot   = HUB_Y + 60;
  return (
    <g>
      {/* Mesh substrate band — subtle gradient fill behind hubs */}
      <rect x={left} y={top} width={right - left} height={bot - top}
            fill="url(#meshGrad)" stroke={stroke} strokeOpacity={active ? 0.45 : 0.2}
            strokeWidth={1} strokeDasharray="2 4"/>
      <text x={left + 8} y={top + 14}
            fontFamily="'IBM Plex Mono', monospace" fontSize="9"
            letterSpacing="2.5" fill={active ? '#6FE3FF' : '#4A5264'}>
        ◆ LoRa MESH · ALL HUBS
      </text>
      <text x={right - 8} y={top + 14} textAnchor="end"
            fontFamily="'IBM Plex Mono', monospace" fontSize="8"
            letterSpacing="2" fill="#8A93A6">
        {active ? (crisis ? 'ALWAYS-ON · LIFELINE' : 'ALWAYS-ON · NETWORK') : 'UNUSED'}
      </text>
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Scene · Today (every project to its own carrier silo)
   --------------------------------------------------------------------------- */
const SILOS = [
  { id: 'fiber',  x: 220, y: 90, label: 'Carrier · Fiber' },
  { id: 'lte-1',  x: 540, y: 70, label: 'Carrier · LTE-1' },
  { id: 'lte-2',  x: 860, y: 90, label: 'Carrier · LTE-2' },
];
const SILO_FOR = ['fiber','lte-1','lte-2','fiber','lte-1','lte-2','fiber','lte-1','lte-2'];
const CARRIER_COLOR = { 'fiber': '#FF8458', 'lte-1': '#FFB454', 'lte-2': '#C58CF0' };

function bezier(a, b, lift = 60){
  const mx = (a.x + b.x)/2;
  const my = (a.y + b.y)/2 - lift;
  return `M ${a.x},${a.y} Q ${mx},${my} ${b.x},${b.y}`;
}

function SceneToday() {
  return (
    <g>
      {SILOS.map(s => (
        <g key={s.id} transform={`translate(${s.x},${s.y})`}>
          <path d="M -22 6 Q -22 -8 -8 -8 Q -2 -16 10 -12 Q 22 -12 22 0 Q 26 8 16 8 L -14 8 Q -22 8 -22 6 Z"
                fill="rgba(255,255,255,0.025)" stroke="rgba(255,255,255,0.22)" strokeWidth={0.8}/>
          <text x={0} y={26} textAnchor="middle"
                fontFamily="'IBM Plex Mono', monospace" fontSize="7.5"
                letterSpacing="1.8" fill="#8A93A6"
                style={{textTransform:'uppercase'}}>{s.label}</text>
        </g>
      ))}
      {PROJECTS.map((p, i) => {
        const silo = SILOS.find(s => s.id === SILO_FOR[i]);
        const color = CARRIER_COLOR[SILO_FOR[i]];
        const d = bezier(p, silo, 80);
        return (
          <g key={p.id}>
            <path d={d} fill="none" stroke={color} strokeOpacity={0.22}
                  strokeWidth={1} strokeDasharray="3 3"/>
            <PacketDot d={d} color={color} dur={3.2} delay={Math.random()*3.2} r={2.0}/>
          </g>
        );
      })}
      {PROJECTS.map(p => <ProjectMarker key={p.id} p={p}/>)}
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Scene · Peace + Crisis
   --------------------------------------------------------------------------- */
function ScenePeace({ crisis }) {
  return (
    <g>
      {/* Mesh band */}
      <MeshBand active={true} crisis={crisis}/>

      {/* Mesh edges — bright, animated */}
      {MESH_EDGES.map(([a,b], i) => {
        const A = HUB_BY_ID[a], B = HUB_BY_ID[b];
        const d = `M ${A.x},${A.y} L ${B.x},${B.y}`;
        const color = crisis ? '#6FE3FF' : '#6FE3FF';
        return (
          <g key={i}>
            <line x1={A.x} y1={A.y} x2={B.x} y2={B.y}
                  stroke={color} strokeOpacity={0.7} strokeWidth={1.6}/>
            <PacketDot d={d} color="#FFFFFF" dur={2.2} delay={i*0.6} r={1.8}/>
          </g>
        );
      })}

      {/* Sensor uplinks (peacetime: bright cyan; crisis: dimmed but still active) */}
      {PROJECTS.map(p => {
        const h = HUB_BY_ID[p.hub];
        const d = pathProjectToHub(p, h);
        return (
          <g key={p.id} opacity={crisis ? 0.45 : 1}>
            <path d={d} fill="none" stroke="#00D4FF" strokeOpacity={crisis ? 0.3 : 0.45}
                  strokeWidth={0.9}/>
            <PacketDot d={d} color="#6FE3FF" dur={2.4} delay={(p.x % 7)*0.18} r={1.4}/>
          </g>
        );
      })}

      {/* PEACETIME upstream: a single Main hub forwards to AGGREGATOR */}
      {!crisis && (() => {
        const main = HUBS.find(h => h.kind === 'main');
        const d = pathHubToAggregator(main);
        return (
          <g>
            <path d={d} fill="none" stroke="#00D4FF" strokeOpacity={0.7} strokeWidth={1.4}/>
            <PacketDot d={d} color="#E8EDF5" dur={2.0} r={2.2}/>
          </g>
        );
      })()}

      {/* CRISIS upstream: Main hubs reach Satellite, and bidirectional to Dispatch */}
      {crisis && (
        <g>
          {HUBS.filter(h => h.kind === 'main').map((h, i) => {
            const d = pathHubToSat(h);
            return (
              <g key={`sat-${h.id}`}>
                <path d={d} fill="none" stroke="#FFB454" strokeOpacity={0.75} strokeWidth={1.4}/>
                <PacketDot d={d} color="#FFB454" dur={2.0} delay={i*0.4} r={2.2}/>
              </g>
            );
          })}
          {/* Dispatch line from rightmost main hub */}
          {(() => {
            const h = [...HUBS].reverse().find(x => x.kind === 'main');
            const d = pathDispatchToHub(h);
            return (
              <g>
                <path d={d} fill="none" stroke="#FFB454" strokeOpacity={0.7} strokeWidth={1.3}/>
                <PacketDot d={d} color="#FFB454" dur={2.0} r={2.0}/>
              </g>
            );
          })()}
        </g>
      )}

      {/* CRISIS · severed ISP stubs at edges */}
      {crisis && (
        <g>
          {[HUBS[0], HUBS[HUBS.length-1]].map((h, i) => {
            const stubX = i === 0 ? h.x - 80 : h.x + 80;
            const stubY = 130;
            return (
              <g key={i}>
                <path d={`M ${h.x},${h.y - 16} L ${h.x},${(h.y + stubY)/2} L ${stubX},${(h.y + stubY)/2} L ${stubX},${stubY}`}
                      fill="none" stroke="#FF5C5C" strokeOpacity={0.4}
                      strokeWidth={1} strokeDasharray="3 4"/>
                <g transform={`translate(${stubX},${stubY - 4})`}>
                  <line x1={-5} y1={-5} x2={5} y2={5} stroke="#FF5C5C" strokeWidth={1.3}/>
                  <line x1={5} y1={-5} x2={-5} y2={5} stroke="#FF5C5C" strokeWidth={1.3}/>
                </g>
                <text x={stubX} y={stubY - 14} textAnchor="middle"
                      fontFamily="'IBM Plex Mono', monospace" fontSize="8"
                      letterSpacing="2" fill="#FF5C5C">◆ ISP DOWN</text>
              </g>
            );
          })}
        </g>
      )}

      {/* CRISIS · citizen uplinks */}
      {crisis && CITIZENS.map(c => {
        const h = HUB_BY_ID[c.hub];
        const d = pathCitizenToHub(c, h);
        return (
          <g key={c.id}>
            <path d={d} fill="none" stroke="#E8EDF5" strokeOpacity={0.4} strokeWidth={0.8}/>
            <PacketDot d={d} color="#FFFFFF" dur={1.8} delay={(c.x % 11)*0.14} r={1.4}/>
            <CitizenMarker c={c}/>
          </g>
        );
      })}

      {/* Project markers (dim in crisis since less prominent) */}
      {PROJECTS.map(p => <ProjectMarker key={p.id} p={p} dim={crisis}/>)}

      {/* Endpoints */}
      {!crisis && <EndpointBox x={AGGREGATOR.x} y={AGGREGATOR.y} label="◆ AGGREGATOR" sub="municipal systems · integrations" kind="cyan"/>}
      {crisis && <SatelliteIcon/>}
      {crisis && <EndpointBox x={DISPATCH.x} y={DISPATCH.y} label="◆ DISPATCH" sub="emergency ops · via sat" kind="orange"/>}
    </g>
  );
}

/* ---------------------------------------------------------------------------
   Main component
   --------------------------------------------------------------------------- */
function NetworkVizV2() {
  const [stateIdx, setStateIdx] = useState(0);
  const [playing, setPlaying] = useState(true);
  const [bubbles, setBubbles] = useState([]); // active traveling bubbles
  const [logEntries, setLogEntries] = useState([]);
  const current = STATES[stateIdx];
  const bubbleIdRef = useRef(0);
  const logRef = useRef(null);

  // Auto-advance every 9s while playing (longer because messages need time)
  useEffect(() => {
    if (!playing) return;
    const t = setTimeout(() => setStateIdx(i => (i + 1) % STATES.length), 9000);
    return () => clearTimeout(t);
  }, [stateIdx, playing]);

  // Reset log when state changes
  useEffect(() => {
    setLogEntries([]);
    setBubbles([]);
  }, [stateIdx]);

  // Message dispatcher
  // - PEACE: one sensor reading every 3s
  // - CRISIS: question/reply EXCHANGES — fire Q, then fire A 2.6s later;
  //   start the next exchange ~5.5s after the Q so messages don't pile up.
  useEffect(() => {
    if (current.id === NetState.TODAY) return;

    let cancelled = false;
    const timers = [];
    const schedule = (fn, ms) => {
      const t = setTimeout(() => { if (!cancelled) fn(); }, ms);
      timers.push(t);
    };

    if (current.id === NetState.PEACE) {
      let i = 0;
      const tick = () => {
        if (cancelled) return;
        emitMessage(MESSAGES_PEACE[i % MESSAGES_PEACE.length], current.id);
        i += 1;
      };
      tick();
      const iv = setInterval(tick, 3000);
      return () => { cancelled = true; clearInterval(iv); timers.forEach(clearTimeout); };
    }

    // CRISIS \u2014 mix of solo events and Q/A pairs
    let i = 0;
    const fireEntry = () => {
      if (cancelled) return;
      const entry = MESSAGES_CRISIS[i % MESSAGES_CRISIS.length];
      i += 1;
      if (entry.kind === 'pair') {
        const threadId = ++bubbleIdRef.current;
        emitMessage(entry.q, current.id, { threadId, role: 'q' });
        schedule(() => emitMessage(entry.a, current.id, { threadId, role: 'a' }), 2400);
        schedule(fireEntry, 4800);
      } else {
        emitMessage(entry.msg, current.id);
        schedule(fireEntry, 2800);
      }
    };
    fireEntry();
    return () => { cancelled = true; timers.forEach(clearTimeout); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [stateIdx]);

  function emitMessage(msg, stateId, meta = {}) {
    const id = ++bubbleIdRef.current;
    let path = null;
    let fromLabel = '';
    let toLabel = null;

    if (msg.kind === 'sensor' || msg.kind === 'alert') {
      const p = PROJECT_BY_ID[msg.src];
      if (!p) return;
      const h = HUB_BY_ID[p.hub];
      path = pathProjectToHub(p, h);
      fromLabel = `${p.name.toUpperCase()} \u00b7 ${p.loc}`;
    } else if (msg.kind === 'citizen') {
      const c = CITIZENS.find(x => x.id === msg.src);
      if (!c) return;
      const h = HUB_BY_ID[c.hub];
      path = pathCitizenToHub(c, h);
      fromLabel = `CITIZEN \u00b7 NEAR ${HUB_BY_ID[c.hub].label}`;
    } else if (msg.kind === 'dispatch') {
      // Broadcast (no dest): dispatch -> main hub -> along mesh, fades at far end.
      // Targeted reply (with dest): dispatch -> main hub -> mesh -> target hub -> target.
      const mainHub = [...HUBS].reverse().find(x => x.kind === 'main');
      let target = null;
      let targetHub = null;
      if (msg.dest && msg.dest.startsWith('c')) {
        target = CITIZENS.find(x => x.id === msg.dest);
        if (target) targetHub = HUB_BY_ID[target.hub];
      } else if (msg.dest && PROJECT_BY_ID[msg.dest]) {
        target = PROJECT_BY_ID[msg.dest];
        targetHub = HUB_BY_ID[target.hub];
      }
      if (target && targetHub) {
        const dispToMain = pathDispatchToHub(mainHub);
        const meshLeg = `L ${targetHub.x},${HUB_Y - 14}`;
        const isCitizen = target.y === CITIZEN_Y;
        const drop = isCitizen
          ? `L ${targetHub.x},${HUB_Y + 36} L ${target.x},${HUB_Y + 36} L ${target.x},${target.y}`
          : `L ${targetHub.x},${HUB_Y + 30} L ${target.x},${HUB_Y + 30} L ${target.x},${target.y}`;
        path = `${dispToMain} ${meshLeg} ${drop}`;
        toLabel = isCitizen
          ? `\u2192 CITIZEN \u00b7 NEAR ${targetHub.label}`
          : `\u2192 ${target.name.toUpperCase()} \u00b7 ${target.loc}`;
      } else {
        const dispToMain = pathDispatchToHub(mainHub);
        const leftHub = HUBS[0];
        const meshLeg = `L ${leftHub.x},${HUB_Y - 14}`;
        path = `${dispToMain} ${meshLeg}`;
        toLabel = '\u2192 BROADCAST \u00b7 ALL HUBS';
      }
      fromLabel = 'DISPATCH \u00b7 EMERGENCY OPS';
    }

    if (!path) return;

    // Replies travel a longer chained path \u2014 give them more time on screen.
    const dur = msg.kind === 'dispatch' ? 3.6 : 2.6;

    setBubbles(b => [...b, { id, path, text: msg.text, kind: msg.kind, dur }]);

    setLogEntries(l => {
      const entry = {
        id,
        ts: nowStamp(),
        kind: msg.kind,
        from: fromLabel,
        to: toLabel,
        text: msg.text,
        threadId: meta.threadId || id,
        role: meta.role || null,
      };
      const next = [entry, ...l];
      return next.slice(0, 30);
    });
  }

  function removeBubble(id) {
    setBubbles(b => b.filter(x => x.id !== id));
  }

  function nowStamp() {
    const d = new Date();
    const pad = n => n.toString().padStart(2,'0');
    return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
  }

  return (
    <div className="netviz-v2">
      <div className="nv2-head">
        <div className="nv2-tabs" role="tablist">
          {STATES.map((s, i) => (
            <button key={s.id} role="tab"
                    className={`nv2-tab ${i === stateIdx ? 'on' : ''}`}
                    onClick={() => { setStateIdx(i); setPlaying(false); }}>
              <span className="ix">{`0${i+1}`}</span>
              <span className="lb">{s.label}</span>
            </button>
          ))}
        </div>
        <div className="nv2-ctrl">
          <button className="nv2-play" onClick={() => setPlaying(p => !p)}>
            {playing ? '⏸ AUTO' : '▶ AUTO'}
          </button>
        </div>
      </div>

      <div className="nv2-body">
        <div className="nv2-stage">
          <svg viewBox={`0 0 ${STAGE_W} ${STAGE_H}`} preserveAspectRatio="xMidYMid meet">
            <defs>
              <linearGradient id="meshGrad" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="rgba(0,212,255,0.04)"/>
                <stop offset="100%" stopColor="rgba(0,212,255,0.0)"/>
              </linearGradient>
            </defs>

            {/* horizon strip */}
            <line x1={40} y1={STAGE_H-22} x2={STAGE_W-40} y2={STAGE_H-22}
                  stroke="rgba(255,255,255,0.08)" strokeWidth={0.8}/>

            {/* Hubs always present (faint in 'today') */}
            {HUBS.map(h => h.kind === 'main'
              ? <HubMain key={h.id} h={h} mode={current.id}
                         satActive={current.id === NetState.CRISIS}/>
              : <HubLite key={h.id} h={h} mode={current.id}/>
            )}

            {/* Scene */}
            {current.id === NetState.TODAY && <SceneToday/>}
            {current.id === NetState.PEACE && <ScenePeace crisis={false}/>}
            {current.id === NetState.CRISIS && <ScenePeace crisis={true}/>}

            {/* Active traveling bubbles */}
            {bubbles.map(b => (
              <TravelingBubble key={b.id} d={b.path} text={b.text} kind={b.kind}
                               dur={b.dur || 2.6} onDone={() => removeBubble(b.id)}/>
            ))}

            {/* Watermark */}
            <text x={STAGE_W - 30} y={STAGE_H - 30} textAnchor="end"
                  fontFamily="'IBM Plex Mono', monospace" fontSize="9"
                  letterSpacing="2.5" fill="rgba(255,255,255,0.18)">
              ◆ STATE · {current.label.replace('◆ ','').toUpperCase()}
            </text>
          </svg>
        </div>

        <aside className="nv2-log">
          <div className="nv2-log-head">
            <span className="dot"/> <span className="ttl">EVENT LOG</span>
            <span className="ct">{current.id === NetState.TODAY ? '— · IDLE' : `· LIVE (${logEntries.length})`}</span>
          </div>
          <div className="nv2-log-body" ref={logRef}>
            {current.id === NetState.TODAY && (
              <div className="nv2-log-empty">
                In today's setup, each project's data flows to its own private silo.<br/>
                There is no shared event surface for the city.
              </div>
            )}
            {current.id !== NetState.TODAY && logEntries.length === 0 && (
              <div className="nv2-log-empty">Listening on the mesh\u2026</div>
            )}
            {logEntries.map((e, i) => {
              // Is the entry above this one part of the same thread? (log is
              // newest-first, so 'above' = lower index)
              const above = logEntries[i - 1];
              const sameThreadAbove = above && above.threadId === e.threadId && above.id !== e.id;
              return (
                <div key={e.id}
                     className={`nv2-log-row k-${e.kind} ${e.role === 'a' ? 'is-reply' : ''} ${sameThreadAbove ? 'thread-up' : ''}`}>
                  <div className="meta">
                    <span className="ts">{e.ts}</span>
                    <span className="kd">{e.role === 'a' ? '\u21b3 REPLY' : '\u25c6 ' + e.kind.toUpperCase()}</span>
                  </div>
                  <div className="from">
                    {e.from}
                    {e.to && <span className="to"> {e.to}</span>}
                  </div>
                  <div className="msg">{e.text}</div>
                </div>
              );
            })}
          </div>
        </aside>
      </div>

      <div className="nv2-caption">
        <div className="nv2-cap-title">{current.title}</div>
        <div className="nv2-cap-blurb">{current.blurb}</div>
      </div>
    </div>
  );
}

return NetworkVizV2;
})();

// Mount
const mountEl = document.getElementById('netviz-v2-mount');
if (mountEl) {
  const root = ReactDOM.createRoot(mountEl);
  root.render(<V2/>);
}
