/* FLOWLINE — data-flow visualizer
   A force-directed node-link diagram of the ingest pipeline with animated
   particle flow. Levels:
     1. collapsed macro view  — API Sources → Data Engine → Normalized Model →
                                Detection Engine → Dashboard
     2. click a macro node    — it explodes into its members (each source, each
                                adapter, each event kind, each rule group)
     3. click a rule group    — it explodes into the individual rules
   Clicking any node or edge opens the side panel with that element's dense
   technical metadata. The particles double as a health monitor: they carry
   volume, they shed where events are dropped, and they stop dead on a broken
   feed.

   No external libraries: the force simulation, the particle system, and the
   canvas rendering are all local (S6 — nothing is fetched from a CDN). */

const { useMemo } = React;

/* ======  MODEL: collapse + aggregate  ====== */

/* Health states in which particles must not move. Mirrors HALTED_HEALTH. */
const HALTED = ["stale", "error", "off", "suppressed"];

const HEALTH_LABEL = {
  flowing: "Flowing",
  mock: "Mock data",
  waiting: "Awaiting first refresh",
  idle: "No data",
  stale: "Stale",
  error: "Errors",
  off: "Off",
  suppressed: "Suppressed",
};

/* Escalating states win on a single occurrence; off/idle only when unanimous. */
function worstHealth(states) {
  if (!states || states.length === 0) return "idle";
  const escalating = ["error", "stale", "suppressed", "waiting"];
  for (let i = 0; i < escalating.length; i++) {
    if (states.indexOf(escalating[i]) !== -1) return escalating[i];
  }
  const allOff = states.every((s) => s === "off");
  if (allOff) return "off";
  const allQuiet = states.every((s) => s === "off" || s === "idle");
  if (allQuiet) return "idle";
  if (states.indexOf("mock") !== -1) return "mock";
  if (states.indexOf("flowing") !== -1) return "flowing";
  return "idle";
}

function groupChildren(nodes) {
  const byParent = new Map();
  nodes.forEach((n) => {
    if (n.parent === null || n.parent === undefined) return;
    const list = byParent.get(n.parent) || [];
    list.push(n);
    byParent.set(n.parent, list);
  });
  return byParent;
}

/* The nodes currently on screen: walk down from the roots, descending only
   through expanded nodes. An expanded node is REPLACED by its children. */
function computeVisible(nodes, expanded) {
  const byParent = groupChildren(nodes);
  const roots = nodes.filter(
    (n) => n.parent === null || n.parent === undefined,
  );
  const out = [];
  const walk = (node) => {
    const kids = byParent.get(node.id);
    if (kids && kids.length > 0 && expanded.has(node.id)) {
      kids.forEach(walk);
    } else {
      out.push(node);
    }
  };
  roots.forEach(walk);
  return out;
}

/* Whether a node can be exploded further. */
function hasChildren(nodes, id) {
  return nodes.some((n) => n.parent === id);
}

/* Map any node id up to the nearest ancestor that is actually on screen. This
   is what lets one leaf-level link set serve every zoom level. */
function makeResolver(nodes, visibleIds) {
  const parentById = new Map();
  nodes.forEach((n) => parentById.set(n.id, n.parent));
  const memo = new Map();
  return function resolve(id) {
    if (memo.has(id)) return memo.get(id);
    let cur = id;
    const seen = new Set();
    while (cur && !visibleIds.has(cur)) {
      if (seen.has(cur)) {
        cur = null;
        break;
      }
      seen.add(cur);
      cur = parentById.get(cur);
    }
    const result = cur || null;
    memo.set(id, result);
    return result;
  };
}

/* Collapse the leaf links onto the visible nodes, summing volume and attrition
   and rolling health up worst-first. Links whose endpoints collapse into the
   same visible node disappear (they are internal to that node now). */
function aggregateLinks(links, resolve) {
  const byPair = new Map();
  links.forEach((link) => {
    const from = resolve(link.from);
    const to = resolve(link.to);
    if (!from || !to || from === to) return;
    const key = from + " > " + to;
    let agg = byPair.get(key);
    if (!agg) {
      agg = {
        id: key,
        from: from,
        to: to,
        volume: 0,
        dropped: 0,
        states: [],
        members: [],
      };
      byPair.set(key, agg);
    }
    agg.volume += link.volume;
    agg.dropped += link.dropped;
    agg.states.push(link.health);
    agg.members.push(link);
  });
  const out = [];
  byPair.forEach((agg) => {
    agg.health = worstHealth(agg.states);
    out.push(agg);
  });
  return out;
}

/* ======  THEME  ====== */

/* Read the palette out of the live CSS custom properties so the canvas follows
   the app's light/dark theme instead of hardcoding a second palette. */
function readPalette() {
  const cs = getComputedStyle(document.body);
  const v = (name, fallback) => {
    const raw = cs.getPropertyValue(name);
    const trimmed = raw ? raw.trim() : "";
    return trimmed || fallback;
  };
  return {
    ink: v("--ink", "#18181b"),
    ink2: v("--ink-2", "#52525b"),
    ink3: v("--ink-3", "#8a8a93"),
    surface: v("--surface", "#ffffff"),
    surface2: v("--surface-2", "#f7f7f8"),
    border: v("--border", "#e0e0e4"),
    borderStrong: v("--border-strong", "#cfcfd5"),
    accent: v("--accent", "#0f766e"),
    green: v("--green", "#15a34a"),
    yellow: v("--yellow", "#d39a00"),
    orange: v("--orange", "#e2620e"),
    red: v("--red", "#d4252b"),
  };
}

function healthColor(health, pal) {
  switch (health) {
    case "flowing":
      return pal.green;
    case "mock":
      return pal.yellow;
    case "waiting":
      return pal.accent;
    case "stale":
      return pal.orange;
    case "suppressed":
      return pal.orange;
    case "error":
      return pal.red;
    case "off":
      return pal.ink3;
    default:
      return pal.ink3;
  }
}

/* ======  GEOMETRY  ====== */

/* Stable pseudo-random in [0,1) from a string, so an edge's curvature and a
   particle's lane are consistent across frames and reloads. */
function hashUnit(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return ((h >>> 0) % 10000) / 10000;
}

function quadPoint(ax, ay, cx, cy, bx, by, t) {
  const mt = 1 - t;
  return {
    x: mt * mt * ax + 2 * mt * t * cx + t * t * bx,
    y: mt * mt * ay + 2 * mt * t * cy + t * t * by,
  };
}

/* Control point for an edge: offset perpendicular to the chord so parallel
   edges between the same stages fan out instead of overlapping. */
function controlPoint(a, b, link) {
  const mx = (a.x + b.x) / 2;
  const my = (a.y + b.y) / 2;
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const len = Math.sqrt(dx * dx + dy * dy) || 1;
  const bow = (hashUnit(link.id) - 0.5) * Math.min(90, len * 0.34);
  return { x: mx + (-dy / len) * bow, y: my + (dx / len) * bow };
}

/* ======  FORCE SIMULATION  ====== */

/* A small stage-anchored force layout.

   x is sprung to the node's pipeline stage and y is free, so the graph always
   reads left-to-right as a pipeline while still settling organically. A fully
   free layout tangles badly once the engine stage explodes into 33 rules and
   loses the sense of flow direction, which is the one thing this diagram has to
   communicate. Dragging a node pins it. */
function createSim(opts) {
  const state = {
    nodes: [],
    links: [],
    width: opts.width,
    height: opts.height,
    alpha: 1,
  };

  function stageX(stage, stageCount) {
    const pad = Math.max(70, state.width * 0.09);
    if (stageCount <= 1) return state.width / 2;
    return pad + (stage / (stageCount - 1)) * (state.width - pad * 2);
  }

  state.sync = function sync(nodes, links, stageCount) {
    const prev = new Map();
    state.nodes.forEach((n) => prev.set(n.id, n));
    state.nodes = nodes.map((node, i) => {
      const old = prev.get(node.id);
      const target = stageX(node.stage, stageCount);
      if (old) {
        old.data = node;
        old.targetX = target;
        old.r = node.__r;
        return old;
      }
      /* New nodes enter near their stage anchor, spread vertically by index so
         they do not all start stacked on one point (which makes the repulsion
         force explode on the first frame). */
      const spread = ((i % 7) - 3) * 26;
      return {
        id: node.id,
        data: node,
        x: target + (hashUnit(node.id) - 0.5) * 30,
        y: state.height / 2 + spread + (hashUnit(node.id + "y") - 0.5) * 40,
        vx: 0,
        vy: 0,
        r: node.__r,
        targetX: target,
        pinned: false,
      };
    });
    const byId = new Map();
    state.nodes.forEach((n) => byId.set(n.id, n));
    state.links = links
      .map((l) => ({ data: l, a: byId.get(l.from), b: byId.get(l.to) }))
      .filter((l) => l.a && l.b);
    state.alpha = 1;
    return state.nodes;
  };

  state.resize = function resize(width, height, stageCount) {
    state.width = width;
    state.height = height;
    state.nodes.forEach((n) => {
      n.targetX = stageX(n.data.stage, stageCount);
    });
    state.alpha = Math.max(state.alpha, 0.5);
  };

  state.step = function step() {
    const nodes = state.nodes;
    const n = nodes.length;
    if (n === 0) return;
    /* alpha cools the layout so it settles instead of jittering forever, but
       never reaches zero — dragging or expanding re-heats it. */
    state.alpha += (0.02 - state.alpha) * 0.02;
    const a = state.alpha;

    for (let i = 0; i < n; i++) {
      const p = nodes[i];
      /* stage spring on x */
      p.vx += (p.targetX - p.x) * 0.06;
      /* gentle pull to the vertical middle keeps the graph from drifting off */
      p.vy += (state.height / 2 - p.y) * 0.004;

      /* repulsion — O(n²) is fine at this scale (tens of nodes) */
      for (let j = i + 1; j < n; j++) {
        const q = nodes[j];
        let dx = q.x - p.x;
        let dy = q.y - p.y;
        let d2 = dx * dx + dy * dy;
        if (d2 === 0) {
          dx = 0.5;
          dy = 0.5;
          d2 = 0.5;
        }
        const minDist = p.r + q.r + 26;
        const d = Math.sqrt(d2);
        if (d < minDist * 3.4) {
          const force = ((minDist * 3.4 - d) / d) * 0.16;
          const fx = dx * force;
          const fy = dy * force;
          p.vx -= fx;
          p.vy -= fy;
          q.vx += fx;
          q.vy += fy;
        }
        /* hard separation so labels never sit on top of each other */
        if (d < minDist) {
          const push = ((minDist - d) / d) * 0.5;
          const fx = dx * push;
          const fy = dy * push;
          p.x -= fx;
          p.y -= fy;
          q.x += fx;
          q.y += fy;
        }
      }
    }

    /* link springs: pull connected nodes together on y only. Pulling on x too
       would fight the stage anchors and smear the pipeline columns. */
    state.links.forEach((l) => {
      const dy = l.b.y - l.a.y;
      const pull = dy * 0.012;
      l.a.vy += pull;
      l.b.vy -= pull;
    });

    const margin = 26;
    for (let i = 0; i < n; i++) {
      const p = nodes[i];
      if (p.pinned) {
        p.vx = 0;
        p.vy = 0;
        continue;
      }
      p.vx *= 0.82;
      p.vy *= 0.82;
      p.x += p.vx * a * 2.2;
      p.y += p.vy * a * 2.2;
      /* keep everything inside the viewport */
      p.y = Math.max(p.r + margin, Math.min(state.height - p.r - margin, p.y));
      p.x = Math.max(p.r + margin, Math.min(state.width - p.r - margin, p.x));
    }
  };

  return state;
}

/* ======  PARTICLE FLOW  ====== */

/* Particles per second for an edge, scaled by volume against the busiest edge
   on screen. log1p keeps a 10k-event feed from drowning out a 50-event one. */
function spawnRate(volume, maxVolume) {
  if (volume <= 0 || maxVolume <= 0) return 0;
  const share = Math.log1p(volume) / Math.log1p(maxVolume);
  return 1.2 + share * 9;
}

function createParticleField() {
  const byLink = new Map();
  return {
    /* Advance every edge's particles and spawn new ones. dt in seconds. */
    step: function step(links, dt, maxVolume, paused) {
      const live = new Set();
      links.forEach((link) => {
        live.add(link.id);
        let lane = byLink.get(link.id);
        if (!lane) {
          lane = { particles: [], pending: 0 };
          byLink.set(link.id, lane);
        }
        const halted = HALTED.indexOf(link.health) !== -1;
        /* Advance first, so a feed that just broke drains its in-flight
           particles instead of freezing them mid-edge. */
        for (let i = lane.particles.length - 1; i >= 0; i--) {
          const p = lane.particles[i];
          if (!paused) p.t += p.speed * dt;
          if (p.doomed && p.t > p.dieAt) {
            p.fade -= dt * 2.2;
            if (p.fade <= 0) {
              lane.particles.splice(i, 1);
              continue;
            }
          }
          if (p.t >= 1) lane.particles.splice(i, 1);
        }
        if (halted || paused) {
          lane.pending = 0;
          return;
        }
        lane.pending += spawnRate(link.volume, maxVolume) * dt;
        /* Cap in-flight particles per edge — visual density, not a data claim. */
        while (lane.pending >= 1 && lane.particles.length < 26) {
          lane.pending -= 1;
          const doomRatio = link.volume > 0 ? link.dropped / link.volume : 0;
          const doomed = Math.random() < doomRatio;
          lane.particles.push({
            t: 0,
            /* ~2.4s traversal, jittered so particles do not travel in lockstep */
            speed: 0.34 + Math.random() * 0.14,
            offset: (Math.random() - 0.5) * 7,
            doomed: doomed,
            /* Dropped events die at the junction, not at the destination. */
            dieAt: 0.55 + Math.random() * 0.12,
            fade: 1,
          });
        }
      });
      /* Forget edges that are no longer on screen. */
      byLink.forEach((_lane, id) => {
        if (!live.has(id)) byLink.delete(id);
      });
    },
    particlesFor: function particlesFor(id) {
      const lane = byLink.get(id);
      return lane ? lane.particles : [];
    },
    clear: function clear() {
      byLink.clear();
    },
  };
}

/* ======  CANVAS  ====== */

function nodeRadius(node, maxValue) {
  const base = node.kind === "stage" ? 26 : node.kind === "rule" ? 9 : 13;
  if (maxValue <= 0) return base;
  const share = Math.log1p(Math.max(0, node.value)) / Math.log1p(maxValue);
  return base + share * (node.kind === "stage" ? 16 : 11);
}

function FlowCanvas({
  nodes,
  links,
  stageCount,
  selection,
  onSelect,
  onExplode,
  expandable,
  paused,
}) {
  const wrapRef = useRef(null);
  const canvasRef = useRef(null);
  const simRef = useRef(null);
  const fieldRef = useRef(null);
  const stateRef = useRef({});
  /* Hover lives in a ref, not state: pointermove fires ~60×/s and the canvas
     already redraws every frame from this ref, so re-rendering the whole tab on
     each move would be pure waste. */
  const hoverRef = useRef(null);

  /* Everything the RAF loop needs, refreshed without restarting the loop. */
  stateRef.current.nodes = nodes;
  stateRef.current.links = links;
  stateRef.current.stageCount = stageCount;
  stateRef.current.selection = selection;
  stateRef.current.expandable = expandable;
  stateRef.current.paused = paused;
  stateRef.current.hover = hoverRef;

  /* Re-sync the simulation whenever the visible graph changes. */
  useEffect(() => {
    if (!simRef.current) return;
    const maxValue = nodes.reduce((m, n) => Math.max(m, n.value), 0);
    nodes.forEach((n) => {
      n.__r = nodeRadius(n, maxValue);
    });
    simRef.current.sync(nodes, links, stageCount);
  }, [nodes, links, stageCount]);

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrap = wrapRef.current;
    if (!canvas || !wrap) return;
    const ctx = canvas.getContext("2d");
    const reduceMotion =
      window.matchMedia &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = wrap.clientWidth || 900;
    let height = wrap.clientHeight || 520;
    const sim = createSim({ width: width, height: height });
    simRef.current = sim;
    fieldRef.current = createParticleField();

    let pal = readPalette();
    let palKey = document.body.className;
    let raf = 0;
    let last = performance.now();
    let disposed = false;

    const applySize = () => {
      const dpr = window.devicePixelRatio || 1;
      width = wrap.clientWidth || width;
      height = wrap.clientHeight || height;
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      canvas.style.width = width + "px";
      canvas.style.height = height + "px";
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      sim.resize(width, height, stateRef.current.stageCount);
    };
    applySize();

    /* Seed the layout so the first painted frame is already readable. */
    const seedNodes = stateRef.current.nodes;
    if (seedNodes && seedNodes.length) {
      const maxValue = seedNodes.reduce((m, n) => Math.max(m, n.value), 0);
      seedNodes.forEach((n) => {
        n.__r = nodeRadius(n, maxValue);
      });
      sim.sync(seedNodes, stateRef.current.links, stateRef.current.stageCount);
      for (let i = 0; i < 90; i++) sim.step();
    }

    const ro = window.ResizeObserver ? new ResizeObserver(applySize) : null;
    if (ro) ro.observe(wrap);

    /* --- hit testing --- */
    const nodeAt = (x, y) => {
      const list = sim.nodes;
      for (let i = list.length - 1; i >= 0; i--) {
        const p = list[i];
        const dx = x - p.x;
        const dy = y - p.y;
        if (dx * dx + dy * dy <= (p.r + 6) * (p.r + 6)) return p;
      }
      return null;
    };
    const linkAt = (x, y) => {
      let best = null;
      let bestDist = 11;
      sim.links.forEach((l) => {
        const c = controlPoint(l.a, l.b, l.data);
        for (let s = 1; s < 14; s++) {
          const pt = quadPoint(l.a.x, l.a.y, c.x, c.y, l.b.x, l.b.y, s / 14);
          const d = Math.hypot(pt.x - x, pt.y - y);
          if (d < bestDist) {
            bestDist = d;
            best = l;
          }
        }
      });
      return best;
    };

    /* --- pointer interaction --- */
    let drag = null;
    let downAt = null;

    const localPoint = (evt) => {
      const rect = canvas.getBoundingClientRect();
      return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
    };

    const onDown = (evt) => {
      const pt = localPoint(evt);
      const node = nodeAt(pt.x, pt.y);
      downAt = { x: pt.x, y: pt.y, moved: false, node: node };
      if (node) {
        drag = node;
        node.pinned = true;
        canvas.style.cursor = "grabbing";
      }
    };

    const onMove = (evt) => {
      const pt = localPoint(evt);
      if (downAt) {
        if (Math.hypot(pt.x - downAt.x, pt.y - downAt.y) > 4)
          downAt.moved = true;
      }
      if (drag) {
        drag.x = pt.x;
        drag.y = pt.y;
        drag.vx = 0;
        drag.vy = 0;
        sim.alpha = Math.max(sim.alpha, 0.35);
        return;
      }
      const node = nodeAt(pt.x, pt.y);
      if (node) {
        canvas.style.cursor = stateRef.current.expandable(node.id)
          ? "zoom-in"
          : "pointer";
        hoverRef.current = { type: "node", id: node.id };
        return;
      }
      const link = linkAt(pt.x, pt.y);
      canvas.style.cursor = link ? "pointer" : "default";
      hoverRef.current = link ? { type: "link", id: link.data.id } : null;
    };

    const onUp = (evt) => {
      const wasDrag = downAt && downAt.moved;
      if (drag) {
        drag = null;
        canvas.style.cursor = "pointer";
      }
      if (!wasDrag && downAt) {
        const pt = localPoint(evt);
        const node = nodeAt(pt.x, pt.y);
        if (node) {
          /* One click does both jobs: select it for the side panel, and — if it
             has members — explode it to the next level. */
          onSelect({ type: "node", id: node.id });
          if (stateRef.current.expandable(node.id)) onExplode(node.id);
        } else {
          const link = linkAt(pt.x, pt.y);
          onSelect(link ? { type: "link", id: link.data.id } : null);
        }
      }
      downAt = null;
    };

    const onLeave = () => {
      hoverRef.current = null;
      canvas.style.cursor = "default";
    };
    const onDblClick = (evt) => {
      /* Double-click releases a pinned node back to the simulation. */
      const pt = localPoint(evt);
      const node = nodeAt(pt.x, pt.y);
      if (node) {
        node.pinned = false;
        sim.alpha = Math.max(sim.alpha, 0.6);
      }
    };

    canvas.addEventListener("pointerdown", onDown);
    canvas.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    canvas.addEventListener("pointerleave", onLeave);
    canvas.addEventListener("dblclick", onDblClick);

    /* --- draw --- */
    const draw = (dt) => {
      const cur = stateRef.current;
      if (document.body.className !== palKey) {
        pal = readPalette();
        palKey = document.body.className;
      }
      const maxVolume = sim.links.reduce(
        (m, l) => Math.max(m, l.data.volume),
        0,
      );
      const sel = cur.selection;
      const hov = cur.hover ? cur.hover.current : null;

      if (fieldRef.current) {
        fieldRef.current.step(
          sim.links.map((l) => l.data),
          dt,
          maxVolume,
          cur.paused || reduceMotion,
        );
      }

      ctx.clearRect(0, 0, width, height);

      /* stage guide columns */
      ctx.save();
      ctx.strokeStyle = pal.border;
      ctx.globalAlpha = 0.5;
      ctx.lineWidth = 1;
      const seenX = new Set();
      sim.nodes.forEach((p) => {
        const gx = Math.round(p.targetX);
        if (seenX.has(gx)) return;
        seenX.add(gx);
        ctx.beginPath();
        ctx.setLineDash([3, 6]);
        ctx.moveTo(gx, 14);
        ctx.lineTo(gx, height - 14);
        ctx.stroke();
      });
      ctx.restore();

      /* edges */
      sim.links.forEach((l) => {
        const link = l.data;
        const c = controlPoint(l.a, l.b, link);
        const halted = HALTED.indexOf(link.health) !== -1;
        const isSel = sel && sel.type === "link" && sel.id === link.id;
        const isHov = hov && hov.type === "link" && hov.id === link.id;
        const color = healthColor(link.health, pal);
        ctx.save();
        ctx.beginPath();
        ctx.moveTo(l.a.x, l.a.y);
        ctx.quadraticCurveTo(c.x, c.y, l.b.x, l.b.y);
        ctx.strokeStyle = color;
        ctx.globalAlpha = isSel ? 0.95 : isHov ? 0.75 : halted ? 0.45 : 0.3;
        ctx.lineWidth = isSel ? 3 : isHov ? 2.4 : 1.4;
        if (link.health === "suppressed" || link.health === "off") {
          ctx.setLineDash([5, 5]);
        }
        ctx.stroke();
        ctx.restore();

        /* particles */
        const particles = fieldRef.current
          ? fieldRef.current.particlesFor(link.id)
          : [];
        particles.forEach((p) => {
          const pt = quadPoint(l.a.x, l.a.y, c.x, c.y, l.b.x, l.b.y, p.t);
          /* A doomed particle drifts downward as it fades — the visual reading
             of an event that was parsed but can never reach a rule. */
          const sink = p.doomed && p.t > p.dieAt ? (1 - p.fade) * 26 : 0;
          ctx.save();
          ctx.globalAlpha = Math.max(0, Math.min(1, p.fade)) * 0.95;
          ctx.fillStyle = p.doomed ? pal.orange : color;
          ctx.beginPath();
          ctx.arc(
            pt.x + p.offset * 0.2,
            pt.y + p.offset + sink,
            2.1,
            0,
            6.2832,
          );
          ctx.fill();
          ctx.restore();
        });
      });

      /* nodes */
      sim.nodes.forEach((p) => {
        const node = p.data;
        const isSel = sel && sel.type === "node" && sel.id === node.id;
        const isHov = hov && hov.type === "node" && hov.id === node.id;
        const color = healthColor(node.health, pal);
        ctx.save();
        /* halo on hover/selection */
        if (isSel || isHov) {
          ctx.beginPath();
          ctx.arc(p.x, p.y, p.r + (isSel ? 7 : 5), 0, 6.2832);
          ctx.fillStyle = color;
          ctx.globalAlpha = 0.16;
          ctx.fill();
          ctx.globalAlpha = 1;
        }
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, 6.2832);
        ctx.fillStyle = pal.surface;
        ctx.fill();
        ctx.globalAlpha = node.health === "off" ? 0.5 : 1;
        ctx.fillStyle = color;
        ctx.globalAlpha = node.health === "off" ? 0.12 : 0.2;
        ctx.fill();
        ctx.globalAlpha = 1;
        ctx.lineWidth = isSel ? 3 : 1.8;
        ctx.strokeStyle = color;
        if (node.health === "off" || node.health === "suppressed") {
          ctx.setLineDash([4, 3]);
        }
        ctx.stroke();
        ctx.restore();

        /* an expandable node gets a + so the next level is discoverable */
        if (stateRef.current.expandable(node.id)) {
          ctx.save();
          ctx.strokeStyle = pal.ink2;
          ctx.lineWidth = 1.5;
          ctx.globalAlpha = 0.75;
          const s = 3.5;
          ctx.beginPath();
          ctx.moveTo(p.x - s, p.y);
          ctx.lineTo(p.x + s, p.y);
          ctx.moveTo(p.x, p.y - s);
          ctx.lineTo(p.x, p.y + s);
          ctx.stroke();
          ctx.restore();
        }

        /* labels: always for big nodes, on hover/selection for small ones */
        const showLabel = node.kind !== "rule" || isSel || isHov;
        if (showLabel) {
          ctx.save();
          ctx.font =
            (node.kind === "stage" ? "600 12px " : "500 11px ") +
            "ui-monospace, SFMono-Regular, Menlo, monospace";
          ctx.textAlign = "center";
          ctx.textBaseline = "top";
          const label = node.label;
          const ly = p.y + p.r + 5;
          const w = ctx.measureText(label).width;
          ctx.fillStyle = pal.surface;
          ctx.globalAlpha = 0.82;
          ctx.fillRect(p.x - w / 2 - 3, ly - 1, w + 6, 13);
          ctx.globalAlpha = 1;
          ctx.fillStyle = pal.ink;
          ctx.fillText(label, p.x, ly);
          if (node.kind === "stage" || isSel || isHov) {
            ctx.font =
              "500 10px ui-monospace, SFMono-Regular, Menlo, monospace";
            ctx.fillStyle = pal.ink3;
            ctx.fillText(formatCount(node.value), p.x, ly + 13);
          }
          ctx.restore();
        }
      });
    };

    const loop = (now) => {
      if (disposed) return;
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      sim.step();
      draw(dt);
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (ro) ro.disconnect();
      canvas.removeEventListener("pointerdown", onDown);
      canvas.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      canvas.removeEventListener("pointerleave", onLeave);
      canvas.removeEventListener("dblclick", onDblClick);
    };
    /* Mounted once: the loop reads live values through stateRef, so it must not
       be torn down and rebuilt on every prop change. */
  }, []);

  return (
    <div ref={wrapRef} className="flow-canvas-wrap">
      <canvas ref={canvasRef} className="flow-canvas" />
    </div>
  );
}

/* ======  FORMATTING  ====== */

function formatCount(n) {
  if (n === null || n === undefined) return "—";
  if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
  return String(n);
}

function formatWhen(iso) {
  if (!iso) return "never";
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "—";
  const mins = Math.round((Date.now() - d.getTime()) / 60000);
  if (mins < 1) return "just now";
  if (mins < 60) return mins + "m ago";
  const hrs = Math.round(mins / 60);
  if (hrs < 48) return hrs + "h ago";
  return Math.round(hrs / 24) + "d ago";
}

function formatPct(x) {
  return Math.round(x * 100) + "%";
}

/* ======  SIDE PANEL  ====== */

function HealthPill({ health }) {
  return (
    <span className={"flow-pill flow-h-" + health}>
      {HEALTH_LABEL[health] || health}
    </span>
  );
}

function MetaRow({ label, value, mono }) {
  return (
    <div className="flow-meta-row">
      <div className="flow-meta-label">{label}</div>
      <div className={"flow-meta-value" + (mono ? " mono" : "")}>{value}</div>
    </div>
  );
}

/* Fill-rate bars: which normalized fields the adapter actually populates.
   Keys and percentages only — the endpoint never sends a value. */
function AttributeBars({ attributes }) {
  if (!attributes || attributes.length === 0) return null;
  return (
    <div className="flow-section">
      <div className="flow-section-title">
        Normalized fields · populated rate
      </div>
      <div className="flow-hint">
        Share of this kind's events where the field carries a real value. A low
        rate means the adapter is defaulting it away, so any rule reading it
        will never fire.
      </div>
      {attributes.map((a) => (
        <div key={a.key} className="flow-bar-row">
          <div className="flow-bar-key mono">{a.key}</div>
          <div className="flow-bar-track">
            <div
              className={
                "flow-bar-fill" +
                (a.fillRate === 0
                  ? " empty"
                  : a.fillRate < 0.5
                    ? " partial"
                    : "")
              }
              style={{ width: Math.max(2, a.fillRate * 100) + "%" }}
            />
          </div>
          <div className="flow-bar-pct mono">{formatPct(a.fillRate)}</div>
        </div>
      ))}
    </div>
  );
}

/* Hourly ingest sparkline for one source (or all of them). */
function Sparkline({ points, label }) {
  if (!points || points.length === 0) {
    return (
      <div className="flow-section">
        <div className="flow-section-title">{label}</div>
        <div className="flow-hint">No ingest recorded in this window.</div>
      </div>
    );
  }
  const max = points.reduce((m, p) => Math.max(m, p.count), 0) || 1;
  return (
    <div className="flow-section">
      <div className="flow-section-title">{label}</div>
      <div className="flow-spark">
        {points.map((p, i) => (
          <div
            key={i}
            className="flow-spark-bar"
            style={{ height: Math.max(2, (p.count / max) * 34) + "px" }}
            title={p.bucket + " · " + p.count + " events"}
          />
        ))}
      </div>
      <div className="flow-hint">
        peak {formatCount(max)} events · {points.length} buckets
      </div>
    </div>
  );
}

/* The raw → normalized worked example for one source. */
function ParseTracePanel({ trace }) {
  const [open, setOpen] = useState("mapping");
  if (!trace) return null;
  if (trace.error) {
    return (
      <div className="flow-section">
        <div className="flow-section-title">Parse trace</div>
        <div className="flow-warn">{trace.error}</div>
      </div>
    );
  }
  const originTone = {
    mapped: "ok",
    renamed: "warn",
    derived: "muted",
  };
  return (
    <div className="flow-section">
      <div className="flow-section-title">
        Parse trace · {trace.entryPoint}()
      </div>
      <div className="flow-hint">
        A worked example through this adapter, from its bundled fixture. GPS
        coordinates are masked; identifiers and field wiring are not.
      </div>
      <div className="flow-tabs">
        {["mapping", "raw", "normalized"].map((k) => (
          <button
            key={k}
            className={"flow-tab" + (open === k ? " active" : "")}
            onClick={() => setOpen(k)}
          >
            {k}
          </button>
        ))}
      </div>
      {open === "mapping" && (
        <table className="flow-map-table">
          <thead>
            <tr>
              <th>raw field</th>
              <th>normalized</th>
              <th>origin</th>
            </tr>
          </thead>
          <tbody>
            {trace.mapping.map((m) => (
              <tr key={m.normalizedPath}>
                <td className="mono">{m.rawPath || "—"}</td>
                <td className="mono">{m.normalizedPath}</td>
                <td>
                  <span className={"flow-origin " + originTone[m.origin]}>
                    {m.origin}
                  </span>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      {open === "raw" && (
        <pre className="flow-json">{JSON.stringify(trace.raw, null, 2)}</pre>
      )}
      {open === "normalized" && (
        <pre className="flow-json">
          {JSON.stringify(trace.normalized, null, 2)}
        </pre>
      )}
    </div>
  );
}

/* The edges touching the selected node, as buttons. Keeps connections reachable
   without hit-testing a curve on the canvas — the same detail a click on the
   line itself opens. */
function ConnectionList({ node, links, onSelect }) {
  const touching = links.filter((l) => l.from === node.id || l.to === node.id);
  if (touching.length === 0) return null;
  return (
    <div className="flow-section">
      <div className="flow-section-title">Connections</div>
      <div className="flow-hint">
        Open a connection for its ingest detail, adapter type, and parsing
        logic.
      </div>
      {touching.map((l) => {
        const outgoing = l.from === node.id;
        return (
          <button
            key={l.id}
            className="flow-conn"
            data-link-id={l.id}
            onClick={() => onSelect({ type: "link", id: l.id })}
          >
            <span className={"flow-dot flow-h-" + l.health} />
            <span className="flow-conn-dir mono">{outgoing ? "→" : "←"}</span>
            <span className="flow-conn-label">{outgoing ? l.to : l.from}</span>
            <span className="flow-conn-vol mono">{formatCount(l.volume)}</span>
          </button>
        );
      })}
    </div>
  );
}

function NodePanel({ node, graph, timeline, links, onSelect }) {
  const meta = node.meta || {};
  const trace =
    meta.sourceId && graph.parseTraces
      ? graph.parseTraces.find((t) => t.source === meta.sourceId)
      : null;
  const hourly =
    meta.sourceId && timeline
      ? timeline.hourly.filter((p) => p.source === meta.sourceId)
      : null;

  return (
    <div>
      <div className="flow-panel-head">
        <div>
          <div className="flow-panel-kind">{node.kind}</div>
          <div className="flow-panel-title">{node.label}</div>
          {node.sublabel && (
            <div className="flow-panel-sub mono">{node.sublabel}</div>
          )}
        </div>
        <HealthPill health={node.health} />
      </div>

      {node.kind === "source" && (
        <div className="flow-section">
          <MetaRow label="Source id" value={meta.sourceId} mono />
          <MetaRow label="Mode" value={String(meta.mode)} mono />
          <MetaRow label="Delivery" value={String(meta.delivery)} mono />
          <MetaRow label="Last refresh" value={formatWhen(meta.refreshedAt)} />
          <MetaRow
            label="Newest event"
            value={formatWhen(meta.newestReceivedAt)}
          />
          <MetaRow
            label="Refresh failures"
            value={formatCount(meta.lastFailures)}
          />
          <MetaRow label="Events stored" value={formatCount(node.value)} />
          {meta.pollIsNoOp === true && (
            <div className="flow-note">
              This source is live-only: its adapter <code>poll()</code> is a
              deliberate no-op and the real pull runs on its own scheduler. An
              empty poll here is not a broken feed.
            </div>
          )}
        </div>
      )}

      {node.kind === "adapter" && (
        <div className="flow-section">
          <MetaRow label="Entry point" value={meta.entryPoint + "()"} mono />
          <MetaRow label="Module" value={String(meta.module)} mono />
          <MetaRow label="Delivery" value={String(meta.delivery)} mono />
          <MetaRow
            label="Signature check"
            value={meta.signatureVerified ? "HMAC verified" : "n/a (poll)"}
          />
          <MetaRow label="Events parsed" value={formatCount(node.value)} />
        </div>
      )}

      {node.kind === "eventKind" && (
        <div className="flow-section">
          <MetaRow label="Event kind" value={String(meta.eventKind)} mono />
          <MetaRow label="Source" value={String(meta.sourceId)} mono />
          <MetaRow label="Stored" value={formatCount(meta.total)} />
          <MetaRow label="Attributed" value={formatCount(meta.attributed)} />
          <MetaRow
            label="Dropped"
            value={
              meta.dropped > 0
                ? formatCount(meta.dropped) + " (no entityId)"
                : "none"
            }
          />
          <MetaRow label="Oldest" value={formatWhen(meta.oldestOccurredAt)} />
          <MetaRow label="Newest" value={formatWhen(meta.newestOccurredAt)} />
        </div>
      )}

      {node.kind === "rule" && (
        <div className="flow-section">
          <MetaRow label="Rule id" value={String(meta.ruleId)} mono />
          <MetaRow label="Feeds" value={(meta.sources || []).join(", ")} mono />
          <MetaRow
            label="Exceptions"
            value={formatCount(meta.exceptionCount)}
          />
          {meta.description && (
            <div className="flow-note">{meta.description}</div>
          )}
          {meta.suppressed === true && (
            <div className="flow-warn">
              Suppressed — {(meta.suppressedBy || []).join(", ")} is switched
              off, so this rule is skipped entirely by the engine. It cannot
              produce exceptions until that feed is back on.
            </div>
          )}
        </div>
      )}

      {node.kind === "ruleGroup" && (
        <div className="flow-section">
          <MetaRow label="Rules" value={formatCount(meta.ruleCount)} />
          <MetaRow
            label="Suppressed"
            value={formatCount(meta.suppressedCount)}
          />
          <MetaRow label="Exceptions" value={formatCount(node.value)} />
          <div className="flow-note">
            Click this node in the graph to explode it into its individual
            rules.
          </div>
        </div>
      )}

      {node.kind === "severity" && (
        <div className="flow-section">
          <MetaRow label="Severity" value={String(meta.severity)} mono />
          <MetaRow label="Exceptions" value={formatCount(meta.count)} />
        </div>
      )}

      {node.kind === "stage" && (
        <div className="flow-section">
          <MetaRow label="Members" value={formatCount(meta.childCount)} />
          <MetaRow label="Carrying" value={formatCount(node.value)} />
          {meta.attributed !== undefined && (
            <MetaRow label="Attributed" value={formatCount(meta.attributed)} />
          )}
          {meta.dropped !== undefined && (
            <MetaRow label="Dropped" value={formatCount(meta.dropped)} />
          )}
          {meta.ruleCount !== undefined && (
            <MetaRow label="Rules" value={formatCount(meta.ruleCount)} />
          )}
          {meta.suppressedRules !== undefined && (
            <MetaRow
              label="Suppressed rules"
              value={formatCount(meta.suppressedRules)}
            />
          )}
          <div className="flow-note">
            Click to explode this stage into its members.
          </div>
        </div>
      )}

      {node.kind === "eventKind" && (
        <AttributeBars attributes={meta.attributes} />
      )}
      <ConnectionList node={node} links={links} onSelect={onSelect} />
      {hourly && <Sparkline points={hourly} label="Ingest · last 24h" />}
      {trace && <ParseTracePanel trace={trace} />}
    </div>
  );
}

function LinkPanel({ link, graph }) {
  const nodeById = useMemo(() => {
    const m = new Map();
    graph.nodes.forEach((n) => m.set(n.id, n));
    return m;
  }, [graph]);
  const from = nodeById.get(link.from);
  const to = nodeById.get(link.to);
  /* An aggregated edge stands for one or more leaf edges; show the leaf detail
     so a collapsed view is still inspectable. */
  const members = link.members || [];
  const dropRate = link.volume > 0 ? link.dropped / link.volume : 0;

  return (
    <div>
      <div className="flow-panel-head">
        <div>
          <div className="flow-panel-kind">connection</div>
          <div className="flow-panel-title">
            {from ? from.label : link.from} → {to ? to.label : link.to}
          </div>
          <div className="flow-panel-sub">
            {members.length === 1
              ? "1 pipeline edge"
              : members.length + " pipeline edges collapsed"}
          </div>
        </div>
        <HealthPill health={link.health} />
      </div>

      <div className="flow-section">
        <MetaRow label="Carrying" value={formatCount(link.volume)} />
        {link.dropped > 0 && (
          <MetaRow
            label="Dropped here"
            value={formatCount(link.dropped) + " (" + formatPct(dropRate) + ")"}
          />
        )}
        {HALTED.indexOf(link.health) !== -1 && (
          <div className="flow-warn">
            Flow halted — {HEALTH_LABEL[link.health]}. Nothing is moving across
            this connection right now.
          </div>
        )}
        {link.dropped > 0 && (
          <div className="flow-warn">
            {formatCount(link.dropped)} events are parsed and stored but carry
            no entityId, so every detection rule skips them. They are the
            particles that fall away mid-edge.
          </div>
        )}
      </div>

      <div className="flow-section">
        <div className="flow-section-title">Edge detail</div>
        <table className="flow-map-table">
          <thead>
            <tr>
              <th>edge</th>
              <th>carrying</th>
              <th>state</th>
            </tr>
          </thead>
          <tbody>
            {members.slice(0, 40).map((m) => (
              <tr key={m.id}>
                <td className="mono">{describeEdge(m)}</td>
                <td className="mono">{formatCount(m.volume)}</td>
                <td>
                  <span
                    className={
                      "flow-origin " +
                      (HALTED.indexOf(m.health) !== -1 ? "warn" : "ok")
                    }
                  >
                    {HEALTH_LABEL[m.health] || m.health}
                  </span>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        {members.length > 40 && (
          <div className="flow-hint">showing 40 of {members.length} edges</div>
        )}
      </div>
    </div>
  );
}

/* A short human description of one leaf edge, from its metadata. */
function describeEdge(edge) {
  const m = edge.meta || {};
  if (m.transport) return m.sourceId + " · " + m.transport;
  if (m.ruleId && m.eventKind) return m.eventKind + " → " + m.ruleId;
  if (m.eventKind) return m.sourceId + " → " + m.eventKind;
  if (m.severity) return m.ruleId + " → " + m.severity + " (" + m.status + ")";
  return edge.from + " → " + edge.to;
}

/* ======  TAB  ====== */

/* The macro nodes start collapsed — level 1, the layperson view. */
const INITIAL_EXPANDED = [];

function DataFlowTab({ onToast }) {
  const [graph, setGraph] = useState(null);
  const [error, setError] = useState(null);
  const [expanded, setExpanded] = useState(() => new Set(INITIAL_EXPANDED));
  const [selection, setSelection] = useState(null);
  const [paused, setPaused] = useState(false);

  const load = async () => {
    try {
      const g = await API.adminDataFlow();
      setGraph(g);
      setError(null);
    } catch (e) {
      const msg = e && e.message ? e.message : "Failed to load the data flow.";
      setError(msg);
      if (onToast) onToast(msg, "danger");
    }
  };

  useEffect(() => {
    load();
  }, []);

  const nodes = graph ? graph.nodes : [];
  const links = graph ? graph.links : [];

  const visible = useMemo(
    () => (graph ? computeVisible(nodes, expanded) : []),
    [graph, nodes, expanded],
  );
  const visibleIds = useMemo(() => {
    const s = new Set();
    visible.forEach((n) => s.add(n.id));
    return s;
  }, [visible]);
  const aggregated = useMemo(() => {
    if (!graph) return [];
    return aggregateLinks(links, makeResolver(nodes, visibleIds));
  }, [graph, nodes, links, visibleIds]);

  const stageCount = useMemo(() => {
    if (!graph) return 5;
    return nodes.reduce((m, n) => Math.max(m, n.stage + 1), 1);
  }, [graph, nodes]);

  const expandable = (id) => !expanded.has(id) && hasChildren(nodes, id);

  const explode = (id) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      next.add(id);
      return next;
    });
  };

  const collapseAll = () => {
    setExpanded(new Set());
    setSelection(null);
  };

  const explodeAll = () => {
    const next = new Set();
    nodes.forEach((n) => {
      if (hasChildren(nodes, n.id)) next.add(n.id);
    });
    setExpanded(next);
  };

  const collapseOne = (id) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  };

  const selected = useMemo(() => {
    if (!selection || !graph) return null;
    if (selection.type === "node") {
      const node = nodes.find((n) => n.id === selection.id);
      return node ? { type: "node", node: node } : null;
    }
    const link = aggregated.find((l) => l.id === selection.id);
    return link ? { type: "link", link: link } : null;
  }, [selection, graph, nodes, aggregated]);

  if (error && !graph) {
    return (
      <div className="empty" data-testid="flow-error">
        {error}
      </div>
    );
  }
  if (!graph) return <div className="empty">Loading the pipeline…</div>;

  const t = graph.totals;
  /* The trail of expanded stages, so a user three levels deep can get back. */
  const expandedList = nodes.filter((n) => expanded.has(n.id));

  return (
    <div className="flow-tab" data-testid="data-flow-tab">
      <div className="flow-head">
        <div className="view-desc" style={{ margin: 0, flex: 1 }}>
          Every source feeding Flowline, how each one is parsed, and what the
          detection engine does with it. Particles carry live volume — they shed
          where events are dropped and stop where a feed is broken. Click any
          node to explode it, or any connection for its technical detail.
        </div>
        <div className="flow-actions">
          <button className="btn sm" onClick={() => setPaused((p) => !p)}>
            {paused ? "Resume flow" : "Pause flow"}
          </button>
          <button className="btn sm" onClick={explodeAll}>
            Explode all
          </button>
          <button className="btn sm" onClick={collapseAll}>
            Collapse
          </button>
          <button className="btn sm" onClick={load}>
            Refresh
          </button>
        </div>
      </div>

      <div className="flow-stats">
        <FlowStat label="Events stored" value={formatCount(t.events)} />
        <FlowStat label="Attributed" value={formatCount(t.attributed)} />
        <FlowStat
          label="Dropped"
          value={formatCount(t.dropped)}
          tone={t.dropped > 0 ? "warn" : null}
        />
        <FlowStat label="Exceptions" value={formatCount(t.exceptions)} />
        <FlowStat label="Active sources" value={formatCount(t.activeSources)} />
        <FlowStat
          label="Suppressed rules"
          value={formatCount(t.suppressedRules)}
          tone={t.suppressedRules > 0 ? "warn" : null}
        />
      </div>

      {expandedList.length > 0 && (
        <div className="flow-crumbs">
          <span className="flow-crumb-label">Exploded:</span>
          {expandedList.map((n) => (
            <button
              key={n.id}
              className="flow-crumb"
              onClick={() => collapseOne(n.id)}
              title="Collapse this node"
            >
              {n.label} ✕
            </button>
          ))}
        </div>
      )}

      <div className="flow-body">
        <div className="flow-graph-col">
          <FlowCanvas
            nodes={visible}
            links={aggregated}
            stageCount={stageCount}
            selection={selection}
            onSelect={setSelection}
            onExplode={explode}
            expandable={expandable}
            paused={paused}
          />
          <div className="flow-legend">
            {[
              "flowing",
              "mock",
              "waiting",
              "idle",
              "stale",
              "error",
              "off",
              "suppressed",
            ].map((h) => (
              <span key={h} className="flow-legend-item">
                <span className={"flow-dot flow-h-" + h} />
                {HEALTH_LABEL[h]}
              </span>
            ))}
            <span className="flow-legend-item">
              <span className="flow-dot flow-h-stale" />
              falling particles = dropped events
            </span>
          </div>

          {/* Keyboard- and screen-reader-accessible mirror of the canvas. A
              canvas graph is invisible to assistive tech and unreachable by
              keyboard, so every node on screen is also a real button here —
              same behaviour as clicking it in the diagram. It doubles as a fast
              way to find a node once the graph is densely exploded. */}
          <div
            className="flow-nodes"
            role="list"
            aria-label="Nodes on screen"
            data-testid="flow-node-list"
          >
            {visible.map((n) => (
              <button
                key={n.id}
                role="listitem"
                className={
                  "flow-node-chip" +
                  (selection &&
                  selection.type === "node" &&
                  selection.id === n.id
                    ? " active"
                    : "")
                }
                data-node-id={n.id}
                title={n.sublabel || n.label}
                onClick={() => {
                  setSelection({ type: "node", id: n.id });
                  if (expandable(n.id)) explode(n.id);
                }}
              >
                <span className={"flow-dot flow-h-" + n.health} />
                <span className="flow-chip-label">{n.label}</span>
                <span className="flow-chip-count mono">
                  {formatCount(n.value)}
                </span>
                {expandable(n.id) && <span className="flow-chip-plus">+</span>}
              </button>
            ))}
          </div>
        </div>

        <div className="flow-panel" data-testid="flow-side-panel">
          {!selected && (
            <div className="flow-panel-empty">
              <div className="flow-panel-title">Pipeline overview</div>
              <div className="flow-hint">
                Click a node to explode it into the next level, or a connection
                line for its ingest timeline, adapter type, and parsing detail.
              </div>
              <Sparkline
                points={aggregateTimeline(graph.timeline.hourly)}
                label="All sources · ingest last 24h"
              />
              <Sparkline
                points={aggregateTimeline(graph.timeline.daily)}
                label="All sources · ingest last 7d"
              />
              <div className="flow-section">
                <MetaRow
                  label="Snapshot"
                  value={formatWhen(graph.generatedAt)}
                />
              </div>
            </div>
          )}
          {selected && selected.type === "node" && (
            <NodePanel
              node={selected.node}
              graph={graph}
              timeline={graph.timeline}
              links={aggregated}
              onSelect={setSelection}
            />
          )}
          {selected && selected.type === "link" && (
            <LinkPanel link={selected.link} graph={graph} />
          )}
        </div>
      </div>
    </div>
  );
}

/* Sum a per-source timeline into one series keyed by bucket. */
function aggregateTimeline(points) {
  if (!points) return [];
  const byBucket = new Map();
  points.forEach((p) => {
    byBucket.set(p.bucket, (byBucket.get(p.bucket) || 0) + p.count);
  });
  return [...byBucket.entries()]
    .sort((a, b) => (a[0] < b[0] ? -1 : 1))
    .map((e) => ({ bucket: e[0], count: e[1] }));
}

function FlowStat({ label, value, tone }) {
  return (
    <div className={"flow-stat" + (tone ? " " + tone : "")}>
      <div className="flow-stat-value mono">{value}</div>
      <div className="flow-stat-label">{label}</div>
    </div>
  );
}

/* Exposed for the browser tests, which exercise the collapse/aggregate model
   directly rather than inferring it from pixels. */
Object.assign(window, {
  DataFlowTab,
  FlowGraphModel: {
    computeVisible,
    aggregateLinks,
    makeResolver,
    worstHealth,
    hasChildren,
    spawnRate,
    aggregateTimeline,
    HALTED,
  },
});
