/* psyc — Docker topology force-directed graph.
 *
 * Networks are hubs, containers radiate around them, edges = membership.
 * Pure vanilla JS, no deps. Layout settles in ~250 ticks then runs an idle
 * animation loop that only redraws while velocities matter. Drag a node to
 * pin/rearrange; the "re-settle" button kicks the simulation again.
 */

(function () {
  "use strict";

  const dataEl = document.getElementById("topo-data");
  const svg = document.getElementById("topology-graph");
  if (!dataEl || !svg) return;

  const data = JSON.parse(dataEl.textContent);

  // Build nodes + edges from the topology snapshot.
  const nodes = [];
  const nodeById = Object.create(null);

  // Synthetic host node — the physical machine. Networks gateway to it; published
  // ports leave the container through this node to the outside world.
  const host = {
    id: "h:host", type: "host",
    label: (data.host && data.host.name) || "docker host",
    sub: data.host && data.host.os ? data.host.os : "",
    r: 36, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
    tooltip: `host: ${(data.host && data.host.name) || "docker host"}\n${(data.host && data.host.os) || ""}`,
  };
  nodes.push(host); nodeById[host.id] = host;

  for (const net of data.networks || []) {
    const r = 26 + Math.sqrt((net.containers || []).length) * 4.5;
    const n = {
      id: "n:" + net.name, type: "net",
      label: net.name, driver: (net.driver || "bridge").toLowerCase(),
      sub: net.subnet || "",
      gateway: net.gateway || "",
      internal: !!net.internal,
      r, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
      tooltip: `switch: ${net.name}\ndriver: ${net.driver} (${net.scope})${net.subnet ? "\nsubnet: " + net.subnet : ""}${net.gateway ? "\ngateway: " + net.gateway : ""}\ncontainers: ${(net.containers || []).length}`,
    };
    nodes.push(n); nodeById[n.id] = n;
  }
  for (const c of data.containers || []) {
    const n = {
      id: "c:" + c.name, type: "cont",
      label: c.name, state: c.state || "unknown",
      published_ports: c.published_ports || [],
      r: 11, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
      tooltip: `${c.name}\n${c.image}\n${c.status}${c.published_ports && c.published_ports.length ? "\npublishes: " + c.published_ports.join(", ") : ""}`,
    };
    nodes.push(n); nodeById[n.id] = n;
  }

  const edges = [];
  // 1) container -> network, labeled with the container's IP on that network
  for (const c of data.containers || []) {
    for (const cn of c.networks || []) {
      const sid = "c:" + c.name;
      const tid = "n:" + cn.name;
      if (nodeById[sid] && nodeById[tid]) {
        edges.push({ source: sid, target: tid, kind: "wire", label: cn.ip || "" });
      }
    }
  }
  // 2) non-internal network -> host (uplink with the gateway as label)
  for (const net of data.networks || []) {
    if (net.internal) continue;
    const tid = "n:" + net.name;
    if (nodeById[tid]) {
      edges.push({ source: host.id, target: tid, kind: "uplink", label: net.gateway || "" });
    }
  }
  // 3) container -> host for published ports (ingress paths from the world)
  for (const c of data.containers || []) {
    if (!c.published_ports || !c.published_ports.length) continue;
    const sid = "c:" + c.name;
    if (nodeById[sid]) {
      edges.push({ source: sid, target: host.id, kind: "publish", label: c.published_ports.join(" ") });
    }
  }

  // Viewport.
  function viewport() {
    const W = svg.clientWidth || 900;
    const H = parseInt(getComputedStyle(svg).height, 10) || 560;
    return { W, H };
  }
  let { W, H } = viewport();
  svg.setAttribute("viewBox", `0 0 ${W} ${H}`);

  // Seed positions in a circle around the center so the initial frame isn't a glob.
  (function seed() {
    const cx = W / 2, cy = H / 2;
    nodes.forEach((n, i) => {
      const ang = (i / nodes.length) * Math.PI * 2;
      const rad = (n.type === "net" ? 60 : 180) + Math.random() * 40;
      n.x = cx + rad * Math.cos(ang);
      n.y = cy + rad * Math.sin(ang);
    });
  })();

  // Force-sim parameters — tuned for ~50 nodes.
  const REPULSION = 1400;
  const SPRING_K = 0.045;
  const SPRING_REST = 110;
  const DAMP = 0.82;
  const CENTER_PULL = 0.004;

  function tick() {
    // Repulsion (O(n^2) — fine here).
    for (let i = 0; i < nodes.length; i++) {
      const a = nodes[i];
      for (let j = i + 1; j < nodes.length; j++) {
        const b = nodes[j];
        const dx = b.x - a.x, dy = b.y - a.y;
        const d2 = dx * dx + dy * dy + 0.1;
        const d = Math.sqrt(d2);
        const f = REPULSION / d2;
        const fx = (dx / d) * f, fy = (dy / d) * f;
        if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
        if (!b.fixed) { b.vx += fx; b.vy += fy; }
      }
    }
    // Spring attraction along edges.
    for (const e of edges) {
      const a = nodeById[e.source], b = nodeById[e.target];
      const dx = b.x - a.x, dy = b.y - a.y;
      const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
      const f = (d - SPRING_REST) * SPRING_K;
      const fx = (dx / d) * f, fy = (dy / d) * f;
      if (!a.fixed) { a.vx += fx; a.vy += fy; }
      if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
    }
    // Gentle center gravity + damping + integrate.
    for (const n of nodes) {
      if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
      n.vx += (W / 2 - n.x) * CENTER_PULL;
      n.vy += (H / 2 - n.y) * CENTER_PULL;
      n.vx *= DAMP; n.vy *= DAMP;
      n.x += n.vx; n.y += n.vy;
      n.x = Math.max(n.r, Math.min(W - n.r, n.x));
      n.y = Math.max(n.r, Math.min(H - n.r, n.y));
    }
  }

  // Pre-settle so the first frame isn't chaos.
  for (let i = 0; i < 300; i++) tick();

  // ---------- rendering ----------------------------------------------------
  const ns = "http://www.w3.org/2000/svg";
  const edgesG = document.createElementNS(ns, "g");
  const nodesG = document.createElementNS(ns, "g");
  edgesG.setAttribute("class", "topo-edges");
  nodesG.setAttribute("class", "topo-nodes");
  svg.appendChild(edgesG);
  svg.appendChild(nodesG);

  const edgeEls = edges.map(e => {
    const grp = document.createElementNS(ns, "g");
    grp.setAttribute("class", "topo-edge-grp topo-kind-" + e.kind);
    const ln = document.createElementNS(ns, "line");
    ln.setAttribute("class", "topo-edge");
    grp.appendChild(ln);
    const lbl = document.createElementNS(ns, "text");
    lbl.setAttribute("class", "topo-edge-label");
    lbl.textContent = e.label || "";
    grp.appendChild(lbl);
    edgesG.appendChild(grp);
    return { line: ln, label: lbl };
  });

  function _classFor(n) {
    if (n.type === "host") return "topo-node topo-host";
    if (n.type === "net") return "topo-node topo-net topo-driver-" + n.driver + (n.internal ? " topo-internal" : "");
    return "topo-node topo-cont topo-state-" + n.state;
  }

  const nodeEls = nodes.map(n => {
    const g = document.createElementNS(ns, "g");
    g.setAttribute("class", _classFor(n));
    g.dataset.id = n.id;

    if (n.type === "host") {
      // Render the host as a rounded square so it visually reads as the "outside world" anchor.
      const sz = n.r;
      const rect = document.createElementNS(ns, "rect");
      rect.setAttribute("x", -sz); rect.setAttribute("y", -sz);
      rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2);
      rect.setAttribute("rx", 8); rect.setAttribute("ry", 8);
      g.appendChild(rect);
    } else if (n.type === "net") {
      // Switches: hexagon-ish double-stroke for a "device" feel.
      const c = document.createElementNS(ns, "circle");
      c.setAttribute("r", n.r); g.appendChild(c);
      const c2 = document.createElementNS(ns, "circle");
      c2.setAttribute("r", n.r - 5); c2.setAttribute("class", "topo-net-inner"); g.appendChild(c2);
    } else {
      const c = document.createElementNS(ns, "circle");
      c.setAttribute("r", n.r); g.appendChild(c);
    }

    const text = document.createElementNS(ns, "text");
    text.setAttribute("class", "topo-label");
    text.setAttribute("dy", (n.type === "host" ? n.r + 14 : n.r + 13));
    text.textContent = n.label;
    g.appendChild(text);

    if (n.sub) {
      const sub = document.createElementNS(ns, "text");
      sub.setAttribute("class", "topo-sublabel");
      sub.setAttribute("dy", (n.type === "host" ? n.r + 26 : n.r + 24));
      sub.textContent = n.sub;
      g.appendChild(sub);
    }

    const title = document.createElementNS(ns, "title");
    title.textContent = n.tooltip;
    g.appendChild(title);

    nodesG.appendChild(g);
    return g;
  });

  function paint() {
    for (let i = 0; i < edges.length; i++) {
      const e = edges[i], a = nodeById[e.source], b = nodeById[e.target];
      const els = edgeEls[i];
      els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
      els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
      if (els.label.textContent) {
        const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
        els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
      }
    }
    for (let i = 0; i < nodes.length; i++) {
      nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
    }
  }
  paint();

  // ---------- drag + click ------------------------------------------------
  let dragging = null, dragOffset = { x: 0, y: 0 };
  let pressedNode = null, pressedAt = null, moved = false;
  function svgPoint(clientX, clientY) {
    const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
    return pt.matrixTransform(svg.getScreenCTM().inverse());
  }
  nodeEls.forEach((g, i) => {
    g.addEventListener("mousedown", ev => {
      ev.preventDefault();
      pressedNode = nodes[i];
      pressedAt = { x: ev.clientX, y: ev.clientY };
      moved = false;
      dragging = nodes[i];
      const p = svgPoint(ev.clientX, ev.clientY);
      dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
      if (currentLayout === "force") dragging.fixed = true;
      g.classList.add("dragging");
    });
  });
  document.addEventListener("mousemove", ev => {
    if (pressedAt) {
      const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
      if (dx * dx + dy * dy > 16) moved = true;  // > 4px = drag, not click
    }
    if (!dragging) return;
    const p = svgPoint(ev.clientX, ev.clientY);
    dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
    dragging.vx = 0; dragging.vy = 0;
    energyBudget = 80;
  });
  document.addEventListener("mouseup", () => {
    if (dragging) {
      const g = nodesG.querySelector(`[data-id="${CSS.escape(dragging.id)}"]`);
      if (g) g.classList.remove("dragging");
      if (currentLayout === "force") dragging.fixed = false;
      dragging = null;
    }
    if (pressedNode && !moved) selectNode(pressedNode);
    pressedNode = null; pressedAt = null;
  });
  // Click on empty graph area clears selection.
  svg.addEventListener("click", ev => {
    if (!ev.target.closest(".topo-node")) clearSelection();
  });

  // ---------- spec panel (click any node) --------------------------------
  const detailEl = document.getElementById("topo-detail");

  function esc(s) {
    return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
  }

  function selectNode(n) {
    nodeEls.forEach(el => el.classList.remove("selected"));
    const me = nodesG.querySelector(`[data-id="${CSS.escape(n.id)}"]`);
    if (me) me.classList.add("selected");
    renderDetail(n);
  }

  function clearSelection() {
    nodeEls.forEach(el => el.classList.remove("selected"));
    if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
  }

  function _kvRow(k, v) { return `<dt>${esc(k)}</dt><dd>${v}</dd>`; }
  function _stateChip(s) {
    const cls = s === "running" ? "state-running" : (s === "exited" || s === "dead") ? "state-exited" : (s === "paused" || s === "restarting") ? "state-paused" : "";
    return `<span class="state-badge ${cls}">${esc(s || "?")}</span>`;
  }

  function renderDetail(n) {
    if (!detailEl) return;
    const kind = n.id.slice(0, 1);
    const name = n.id.slice(2);
    let html = "";

    if (kind === "h") {
      const h = data.host || {};
      html = `<div class="td-head"><span class="td-kind td-kind-host">HOST</span>
                <h3 class="td-title">${esc(h.name || "docker host")}</h3>
                <button type="button" class="td-close" aria-label="close">×</button></div>
              <dl class="td-kv">
                ${_kvRow("OS", esc(h.os || "—"))}
                ${_kvRow("CPUs", h.ncpu != null ? esc(h.ncpu) : "—")}
                ${_kvRow("Containers", h.containers != null ? `${esc(h.containers)} (running: ${esc(h.containers_running)})` : "—")}
              </dl>`;
    } else if (kind === "n") {
      const net = (data.networks || []).find(x => x.name === name) || {};
      const conts = net.containers || [];
      html = `<div class="td-head"><span class="td-kind td-kind-net">SWITCH</span>
                <h3 class="td-title">${esc(net.name)}</h3>
                <button type="button" class="td-close" aria-label="close">×</button></div>
              <dl class="td-kv">
                ${_kvRow("ID", esc(net.id))}
                ${_kvRow("Driver", esc(net.driver) + " · " + esc(net.scope))}
                ${_kvRow("Subnet", esc(net.subnet || "—"))}
                ${_kvRow("Gateway", esc(net.gateway || "—"))}
                ${_kvRow("Internal", net.internal ? "yes" : "no")}
              </dl>
              <h4 class="td-sec">Attached containers (${conts.length})</h4>
              ${conts.length ? `<table class="td-tbl">
                <thead><tr><th>Name</th><th>IPv4</th><th>MAC</th></tr></thead>
                <tbody>${conts.map(c => `<tr><td><a href="#" class="td-jump" data-id="c:${esc(c.name)}">${esc(c.name)}</a></td><td>${esc(c.ip || "—")}</td><td>${esc(c.mac || "—")}</td></tr>`).join("")}</tbody>
              </table>` : `<p class="empty">No containers attached.</p>`}`;
    } else {
      const c = (data.containers || []).find(x => x.name === name) || {};
      const nets = c.networks || [];
      const pub = c.published_ports || [];
      html = `<div class="td-head"><span class="td-kind td-kind-cont">CONTAINER</span>
                <h3 class="td-title">${esc(c.name)}</h3>
                ${_stateChip(c.state)}
                <button type="button" class="td-close" aria-label="close">×</button></div>
              <dl class="td-kv">
                ${_kvRow("ID", esc(c.id))}
                ${_kvRow("Image", `<code>${esc(c.image)}</code>`)}
                ${_kvRow("Status", esc(c.status))}
                ${_kvRow("Published", pub.length ? pub.map(p => `<span class="port-pill">${esc(p)}</span>`).join(" ") : "—")}
              </dl>
              <h4 class="td-sec">Networks (${nets.length})</h4>
              ${nets.length ? `<table class="td-tbl">
                <thead><tr><th>Switch</th><th>IPv4</th><th>MAC</th><th>Gateway</th></tr></thead>
                <tbody>${nets.map(nn => `<tr><td><a href="#" class="td-jump" data-id="n:${esc(nn.name)}">${esc(nn.name)}</a></td><td>${esc(nn.ip || "—")}</td><td>${esc(nn.mac || "—")}</td><td>${esc(nn.gateway || "—")}</td></tr>`).join("")}</tbody>
              </table>` : `<p class="empty">No networks attached.</p>`}
              ${(c.ports || []).length ? `<h4 class="td-sec">All ports</h4>
                <ul class="td-portlist">${c.ports.map(p => `<li>${esc(p)}</li>`).join("")}</ul>` : ""}`;
    }
    detailEl.innerHTML = html;
    detailEl.classList.add("has-selection");
    // Wire close + cross-jumps.
    const close = detailEl.querySelector(".td-close");
    if (close) close.addEventListener("click", clearSelection);
    detailEl.querySelectorAll(".td-jump").forEach(a => {
      a.addEventListener("click", ev => {
        ev.preventDefault();
        const id = a.dataset.id;
        const target = nodeById[id];
        if (target) selectNode(target);
      });
    });
    // Smooth scroll the panel into view.
    detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
  }

  // ---------- idle animation ---------------------------------------------
  let energyBudget = 40;
  function loop() {
    let moving = false;
    for (const n of nodes) {
      if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
    }
    if (moving || energyBudget > 0 || dragging) {
      tick(); paint();
      if (energyBudget > 0) energyBudget--;
    }
    requestAnimationFrame(loop);
  }
  loop();

  // ---------- traffic flow on edges --------------------------------------
  // An edge is "alive" if its container endpoint is running, or if it's an
  // uplink (host↔switch) — those are considered backbone. Dead edges fade.
  function markEdgeLiveness() {
    edges.forEach((e, i) => {
      const a = nodeById[e.source], b = nodeById[e.target];
      const contAlive =
        (a.type === "cont" && a.state === "running") ||
        (b.type === "cont" && b.state === "running");
      const alwaysOn = e.kind === "uplink";
      const alive = contAlive || alwaysOn;
      const ln = edgeEls[i].line;
      ln.classList.toggle("alive", alive);
      ln.classList.toggle("dead", !alive);
    });
  }
  markEdgeLiveness();

  const flowToggle = document.getElementById("topo-flow");
  function applyFlowToggle() {
    svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked));
  }
  applyFlowToggle();
  if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle);

  // ---------- layout modes (force | hierarchical | radial) ----------------
  function unfix() { for (const n of nodes) n.fixed = false; }
  function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } }

  function applyForce() {
    unfix();
    for (const n of nodes) { n.vx = (Math.random() - 0.5) * 5; n.vy = (Math.random() - 0.5) * 5; }
    energyBudget = 300;
  }

  function applyHierarchical() {
    const switches = nodes.filter(n => n.type === "net");
    const conts = nodes.filter(n => n.type === "cont");
    // Group containers by their primary (first) connected switch.
    const groups = {};
    for (const c of conts) {
      const edge = edges.find(e => e.source === c.id && nodeById[e.target] && nodeById[e.target].type === "net");
      const swId = edge ? edge.target : "_unattached";
      (groups[swId] = groups[swId] || []).push(c);
    }
    host.x = W / 2; host.y = 60; host.fixed = true;
    switches.forEach((sw, i) => {
      sw.x = W * (i + 1) / (switches.length + 1);
      sw.y = H * 0.36;
      sw.fixed = true;
    });
    for (const [swId, group] of Object.entries(groups)) {
      const sw = nodeById[swId];
      const cx = sw ? sw.x : 40;
      const cy = sw ? sw.y + 90 : H - 50;
      const cols = Math.max(1, Math.ceil(Math.sqrt(group.length)));
      group.forEach((c, idx) => {
        const col = idx % cols, row = Math.floor(idx / cols);
        c.x = cx + (col - (cols - 1) / 2) * 38;
        c.y = cy + row * 38;
        c.fixed = true;
      });
    }
    clearVel(); paint();
  }

  function applyRadial() {
    const switches = nodes.filter(n => n.type === "net");
    const conts = nodes.filter(n => n.type === "cont");
    const R1 = Math.min(W, H) * 0.22;
    const R2 = Math.min(W, H) * 0.42;
    host.x = W / 2; host.y = H / 2; host.fixed = true;
    switches.forEach((sw, i) => {
      const a = (i / switches.length) * Math.PI * 2 - Math.PI / 2;
      sw.x = W / 2 + R1 * Math.cos(a);
      sw.y = H / 2 + R1 * Math.sin(a);
      sw._angle = a; sw.fixed = true;
    });
    // Bucket containers by their primary switch.
    const groups = {};
    for (const c of conts) {
      const edge = edges.find(e => e.source === c.id && nodeById[e.target] && nodeById[e.target].type === "net");
      const swId = edge ? edge.target : "_unattached";
      (groups[swId] = groups[swId] || []).push(c);
    }
    const slice = switches.length ? (Math.PI * 2) / switches.length : Math.PI;
    for (const [swId, group] of Object.entries(groups)) {
      const sw = nodeById[swId];
      const baseAng = sw ? sw._angle : Math.PI;
      group.forEach((c, idx) => {
        const t = group.length === 1 ? 0 : (idx / (group.length - 1) - 0.5);
        const a = baseAng + t * slice * 0.75;
        c.x = W / 2 + R2 * Math.cos(a);
        c.y = H / 2 + R2 * Math.sin(a);
        c.fixed = true;
      });
    }
    clearVel(); paint();
  }

  const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
  let currentLayout = "force";
  document.querySelectorAll(".topo-layout").forEach(btn => {
    btn.addEventListener("click", () => {
      const mode = btn.dataset.layout;
      if (!LAYOUTS[mode] || mode === currentLayout) return;
      document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn));
      currentLayout = mode;
      LAYOUTS[mode]();
    });
  });

  // ---------- controls + resize -------------------------------------------
  const resetBtn = document.getElementById("topo-reset");
  if (resetBtn) {
    resetBtn.addEventListener("click", () => {
      if (currentLayout === "force") {
        for (const n of nodes) { n.vx = (Math.random() - 0.5) * 6; n.vy = (Math.random() - 0.5) * 6; }
        energyBudget = 200;
      } else {
        LAYOUTS[currentLayout]();
      }
    });
  }
  // Wheel zoom on the SVG (changes viewBox).
  let zoom = 1, panX = 0, panY = 0;
  svg.addEventListener("wheel", ev => {
    ev.preventDefault();
    const delta = ev.deltaY > 0 ? 1.1 : 0.9;
    zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
    const vw = W / zoom, vh = H / zoom;
    svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
  }, { passive: false });
  window.addEventListener("resize", () => {
    const v = viewport();
    W = v.W; H = v.H;
    svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
    energyBudget = 60;
  });
})();
