(() => {
  'use strict';

  /* ── helpers ─────────────────────────────────────────────── */

  const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();

  const tagHue = (tag) => {
    let h = 0;
    for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) & 0xffffffff;
    return ((h % 360) + 360) % 360;
  };

  const nodeColor = (node) => {
    if (node.tags && node.tags.length) return `hsl(${tagHue(node.tags[0])},55%,50%)`;
    return cssVar('--graph-node') || '#0066cc';
  };

  /* ── force simulation ───────────────────────────────────── */

  const ForceGraph = (container, data, opts) => {
    opts = opts || {};
    const nodes = data.nodes;
    const edges = data.edges;
    const idMap = {};
    const currentId = opts.currentId || null;
    let W, H, dpr;

    const canvas = document.createElement('canvas');
    container.appendChild(canvas);
    const ctx = canvas.getContext('2d');

    const tooltip = document.createElement('div');
    tooltip.className = 'graph-tooltip';
    container.appendChild(tooltip);

    /* init node positions */
    for (let i = 0; i < nodes.length; i++) {
      const n = nodes[i];
      n.x = (Math.random() - 0.5) * 300;
      n.y = (Math.random() - 0.5) * 300;
      n.vx = 0;
      n.vy = 0;
      idMap[n.id] = n;
    }

    /* resolve edge references */
    const resolvedEdges = [];
    for (let e = 0; e < edges.length; e++) {
      const src = idMap[edges[e].source];
      const tgt = idMap[edges[e].target];
      if (src && tgt) resolvedEdges.push({ source: src, target: tgt });
    }

    /* camera */
    const cam = { x: 0, y: 0, zoom: 1 };
    let dragging = null;
    let panning = false;
    let panStart = { x: 0, y: 0, camX: 0, camY: 0 };
    let hovered = null;

    /* sizing */
    const resize = () => {
      dpr = window.devicePixelRatio || 1;
      W = container.clientWidth;
      H = container.clientHeight;
      canvas.width = W * dpr;
      canvas.height = H * dpr;
      canvas.style.width = `${W}px`;
      canvas.style.height = `${H}px`;
    };
    resize();
    window.addEventListener('resize', resize);

    /* coordinate transforms */
    const toScreen = (x, y) => ({
      x: (x - cam.x) * cam.zoom + W / 2,
      y: (y - cam.y) * cam.zoom + H / 2
    });
    const toWorld = (sx, sy) => ({
      x: (sx - W / 2) / cam.zoom + cam.x,
      y: (sy - H / 2) / cam.zoom + cam.y
    });

    /* physics */
    let alpha = 1;
    const REPULSION = 800;
    const ATTRACTION = 0.005;
    const CENTER_GRAVITY = 0.01;
    const DAMPING = 0.9;
    const NODE_RADIUS = 5;

    const tick = () => {
      if (alpha < 0.001) { alpha = 0; return; }
      alpha *= 0.995;

      for (let i = 0; i < nodes.length; i++) {
        const a = nodes[i];
        /* center gravity */
        a.vx -= a.x * CENTER_GRAVITY;
        a.vy -= a.y * CENTER_GRAVITY;

        /* repulsion */
        for (let j = i + 1; j < nodes.length; j++) {
          const b = nodes[j];
          const dx = a.x - b.x;
          const dy = a.y - b.y;
          const d2 = dx * dx + dy * dy || 1;
          const f = REPULSION / d2;
          a.vx += dx * f;
          a.vy += dy * f;
          b.vx -= dx * f;
          b.vy -= dy * f;
        }
      }

      /* attraction along edges */
      for (let e = 0; e < resolvedEdges.length; e++) {
        const edge = resolvedEdges[e];
        const dx2 = edge.target.x - edge.source.x;
        const dy2 = edge.target.y - edge.source.y;
        edge.source.vx += dx2 * ATTRACTION;
        edge.source.vy += dy2 * ATTRACTION;
        edge.target.vx -= dx2 * ATTRACTION;
        edge.target.vy -= dy2 * ATTRACTION;
      }

      /* integrate */
      for (let k = 0; k < nodes.length; k++) {
        const n = nodes[k];
        if (n === dragging) continue;
        n.vx *= DAMPING;
        n.vy *= DAMPING;
        n.x += n.vx * alpha;
        n.y += n.vy * alpha;
      }
    };

    /* rendering */
    const draw = () => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);

      const edgeColor = cssVar('--graph-edge') || '#ddd';
      const labelColor = cssVar('--graph-label') || '#333';
      const currentColor = cssVar('--graph-node-current') || '#333';

      /* edges */
      ctx.strokeStyle = edgeColor;
      ctx.lineWidth = 0.5;
      ctx.globalAlpha = 0.4;
      for (let e = 0; e < resolvedEdges.length; e++) {
        const s = toScreen(resolvedEdges[e].source.x, resolvedEdges[e].source.y);
        const t = toScreen(resolvedEdges[e].target.x, resolvedEdges[e].target.y);
        ctx.beginPath();
        ctx.moveTo(s.x, s.y);
        ctx.lineTo(t.x, t.y);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;

      /* nodes */
      const r = NODE_RADIUS;
      for (let i = 0; i < nodes.length; i++) {
        const n = nodes[i];
        const p = toScreen(n.x, n.y);
        const isCurrent = currentId && n.id === currentId;
        const isHovered = n === hovered;
        const radius = isCurrent ? r * 1.8 : (isHovered ? r * 1.4 : r);

        ctx.beginPath();
        ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
        ctx.fillStyle = isCurrent ? currentColor : nodeColor(n);
        ctx.fill();

        /* label for hovered or current */
        if (isHovered || isCurrent) {
          ctx.font = '12px sans-serif';
          ctx.fillStyle = labelColor;
          ctx.textAlign = 'center';
          ctx.fillText(n.title, p.x, p.y - radius - 4);
        }
      }
    };

    /* hit test */
    const nodeAt = (sx, sy) => {
      const w = toWorld(sx, sy);
      let best = null, bestD = Infinity;
      for (let i = 0; i < nodes.length; i++) {
        const dx = nodes[i].x - w.x;
        const dy = nodes[i].y - w.y;
        const d = Math.sqrt(dx * dx + dy * dy);
        const hitR = (NODE_RADIUS + 4) / cam.zoom;
        if (d < hitR && d < bestD) { best = nodes[i]; bestD = d; }
      }
      return best;
    };

    /* mouse events */
    canvas.addEventListener('mousedown', (ev) => {
      const rect = canvas.getBoundingClientRect();
      const sx = ev.clientX - rect.left;
      const sy = ev.clientY - rect.top;
      const hit = nodeAt(sx, sy);
      if (hit) {
        dragging = hit;
        alpha = Math.max(alpha, 0.3);
        ensureRunning();
      } else {
        panning = true;
        panStart = { x: ev.clientX, y: ev.clientY, camX: cam.x, camY: cam.y };
      }
    });

    canvas.addEventListener('mousemove', (ev) => {
      const rect = canvas.getBoundingClientRect();
      const sx = ev.clientX - rect.left;
      const sy = ev.clientY - rect.top;

      if (dragging) {
        const w = toWorld(sx, sy);
        dragging.x = w.x;
        dragging.y = w.y;
        dragging.vx = 0;
        dragging.vy = 0;
        alpha = Math.max(alpha, 0.3);
        ensureRunning();
      } else if (panning) {
        const dx = (ev.clientX - panStart.x) / cam.zoom;
        const dy = (ev.clientY - panStart.y) / cam.zoom;
        cam.x = panStart.camX - dx;
        cam.y = panStart.camY - dy;
        draw();
      } else {
        const hit = nodeAt(sx, sy);
        if (hit !== hovered) { hovered = hit; draw(); }
        canvas.style.cursor = hit ? 'pointer' : 'grab';
        if (hit) {
          tooltip.textContent = hit.title;
          tooltip.style.display = 'block';
          tooltip.style.left = `${sx + 12}px`;
          tooltip.style.top = `${sy - 8}px`;
        } else {
          tooltip.style.display = 'none';
        }
      }
    });

    window.addEventListener('mouseup', (ev) => {
      if (dragging) {
        /* click = navigate, drag = reposition */
        const rect = canvas.getBoundingClientRect();
        const sx = ev.clientX - rect.left;
        const sy = ev.clientY - rect.top;
        const hit = nodeAt(sx, sy);
        if (hit === dragging) {
          const moved = Math.abs(hit.vx) + Math.abs(hit.vy);
          if (moved < 0.1) window.location.href = base_url + hit.url;
        }
        dragging = null;
      }
      panning = false;
    });

    canvas.addEventListener('wheel', (ev) => {
      ev.preventDefault();
      const factor = ev.deltaY < 0 ? 1.1 : 0.9;
      cam.zoom = Math.min(Math.max(cam.zoom * factor, 0.1), 10);
      draw();
    }, { passive: false });

    /* animation loop */
    let animId = null;
    const loop = () => {
      tick();
      draw();
      if (alpha > 0.001) {
        animId = requestAnimationFrame(loop);
      } else {
        animId = null;
      }
    };
    const ensureRunning = () => {
      if (!animId) loop();
    };

    /* auto-fit after simulation settles */
    const autoFit = () => {
      if (nodes.length === 0) return;
      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      for (let i = 0; i < nodes.length; i++) {
        if (nodes[i].x < minX) minX = nodes[i].x;
        if (nodes[i].x > maxX) maxX = nodes[i].x;
        if (nodes[i].y < minY) minY = nodes[i].y;
        if (nodes[i].y > maxY) maxY = nodes[i].y;
      }
      cam.x = (minX + maxX) / 2;
      cam.y = (minY + maxY) / 2;
      const rangeX = (maxX - minX) || 100;
      const rangeY = (maxY - minY) || 100;
      cam.zoom = Math.min(W / (rangeX + 80), H / (rangeY + 80), 2);
    };

    /* run warmup then fit */
    for (let w = 0; w < 200; w++) tick();
    autoFit();
    loop();
  };

  /* ── init ────────────────────────────────────────────────── */

  const initGraph = (container, opts) => {
    const url = container.getAttribute('data-graph-url');
    if (!url) return;

    fetch(url).then((res) => {
      if (!res.ok) return;
      return res.json();
    }).then((data) => {
      if (!data) return;
      if (opts && opts.currentId) {
        data = filterNeighborhood(data, opts.currentId);
      }

      container.setAttribute('data-node-count', data.nodes.length);
      ForceGraph(container, data, opts);
    });
  };

  const filterNeighborhood = (data, centerId) => {
    const neighborIds = {};
    neighborIds[centerId] = true;
    for (let i = 0; i < data.edges.length; i++) {
      const e = data.edges[i];
      if (e.source === centerId) neighborIds[e.target] = true;
      if (e.target === centerId) neighborIds[e.source] = true;
    }
    const nodes = data.nodes.filter((n) => neighborIds[n.id]);
    const edges = data.edges.filter((e) => neighborIds[e.source] && neighborIds[e.target]);
    return { nodes, edges };
  };

  /* ── page init ──────────────────────────────────────────── */

  const init = () => {
    /* full graph page */
    const full = document.getElementById('graph-container');
    if (full && full.getAttribute('data-graph-url')) {
      initGraph(full, {});
    }

    /* local graph on zettel pages */
    const local = document.getElementById('local-graph-container');
    if (local && local.getAttribute('data-graph-url')) {
      const zettelId = local.getAttribute('data-zettel-id');
      if (zettelId) initGraph(local, { currentId: zettelId });
    }
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
