<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import {
    nodes, ports, links, connected, patchbay,
    portById,
    initGraph, destroyGraph,
    connectPorts, disconnectPorts,
    togglePin,
    addHideRule, removeHideRule,
    addMergeRule, removeMergeRule,
    setActivated, setExclusive,
    setAutoPin, setAutoDisconnect,
    saveProfile, loadProfile, deleteProfile,
    setNodeVolume, setNodeMute,
    createNullSink, createLoopback, loadModule,
  } from '../lib/stores';
  import type { Node, Port, Link } from '../lib/types';

  // Viewport
  let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
  let svgEl: SVGElement | null = null;

  // Interaction
  let dragging = $state<{ type: string; startX: number; startY: number; origX: number; origY: number; nodeId?: string } | null>(null);
  let connecting = $state<{ outputPortId: number; outputX: number; outputY: number; mouseX: number; mouseY: number; portType: string } | null>(null);
  let hoveredPort = $state<number | null>(null);
  let selectedEdge = $state<string | null>(null);
  let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
  let nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);

  // Filters
  let showAudio = $state(true);
  let showMidi = $state(true);
  let showVideo = $state(true);
  let showOther = $state(true);

  // Dialogs
  let showHideDialog = $state(false);
  let showMergeDialog = $state(false);
  let showProfileDialog = $state(false);
  let showRuleDialog = $state(false);
  let showVirtualMenu = $state(false);
  let newHideRule = $state('');
  let newMergeRule = $state('');
  let newProfileName = $state('');

  // Positions
  let nodePositions = $state<Record<string, { x: number; y: number }>>({});
  const POS_KEY = 'pwweb_positions';

  function loadPositions() {
    try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; }
    fetch('/api/positions').then(r => r.json()).then(data => {
      if (data && typeof data === 'object' && !data.error) {
        nodePositions = { ...data, ...nodePositions };
      }
    }).catch(() => {});
  }

  function savePositions() {
    try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
    fetch('/api/positions', {
      method: 'PUT', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(nodePositions),
    }).catch(() => {});
  }

  // Helpers
  function portColor(pt: string): string {
    switch (pt) {
      case 'audio': return '#4a9';
      case 'midi':  return '#a44';
      case 'video': return '#49a';
      default:      return '#999';
    }
  }

  function shortName(name: string): string {
    const idx = name.lastIndexOf(':');
    return idx >= 0 ? name.substring(idx + 1) : name;
  }

  function isPortVisible(pt: string): boolean {
    switch (pt) {
      case 'audio': return showAudio;
      case 'midi':  return showMidi;
      case 'video': return showVideo;
      default:      return showOther;
    }
  }

  function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
    const dx = Math.max(Math.abs(x2 - x1) * 0.5, 30);
    return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
  }

  function svgPoint(e: MouseEvent): { x: number; y: number } {
    if (!svgEl) return { x: e.clientX, y: e.clientY };
    const pt = (svgEl as any).createSVGPoint();
    pt.x = e.clientX; pt.y = e.clientY;
    const ctm = (svgEl as SVGGraphicsElement).getScreenCTM();
    if (!ctm) return { x: e.clientX, y: e.clientY };
    return pt.matrixTransform(ctm.inverse());
  }

  function isNodeHidden(nodeName: string): boolean {
    for (const rule of $patchbay.hide_rules) {
      try {
        if (new RegExp(rule, 'i').test(nodeName)) return true;
      } catch {
        if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true;
      }
    }
    return false;
  }

  function getMergedName(nodeName: string): string {
    for (const prefix of $patchbay.merge_rules) {
      if (nodeName.startsWith(prefix)) return prefix;
    }
    return nodeName;
  }

  // Build computed layout
  let graphNodes = $derived.by(() => {
    const n = $nodes;
    const p = $ports;
    const pb = $patchbay;
    const portMap = new Map<number, Port>();
    for (const port of p) portMap.set(port.id, port);

    // Filter hidden nodes
    let visible = n.filter(nd => !isNodeHidden(nd.name));

    // Merge nodes by prefix
    const merged = new Map<string, typeof visible[0]>();
    for (const nd of visible) {
      const mergedName = getMergedName(nd.name);
      const existing = merged.get(mergedName);
      if (existing) {
        // Merge port IDs
        existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])];
        existing.name = mergedName;
      } else {
        merged.set(mergedName, { ...nd, name: mergedName });
      }
    }
    visible = Array.from(merged.values());

    const out = visible.filter(nd => nd.mode === 'output');
    const inp = visible.filter(nd => nd.mode === 'input');
    const other = visible.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');

    const defaults: Record<string, { x: number; y: number }> = {};
    let i = 0;
    for (const nd of out) { defaults[String(nd.id)] = { x: 0, y: (i % 10) * 90 }; i++; }
    i = 0;
    for (const nd of other) { defaults[String(nd.id)] = { x: 280, y: (i % 10) * 90 }; i++; }
    i = 0;
    for (const nd of inp) { defaults[String(nd.id)] = { x: 560, y: (i % 10) * 90 }; i++; }

    const pos = { ...defaults, ...nodePositions };

    return visible.map(nd => {
      const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
      const allInPorts = ndPorts.filter(pp => pp.mode === 'input');
      const allOutPorts = ndPorts.filter(pp => pp.mode === 'output');

      const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
      const headerH = 22;
      const portH = 16;
      const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1);
      const height = headerH + maxPorts * portH + 20; // extra 20 for volume slider
      const width = 220;

      const portPositions = new Map<number, { x: number; y: number }>();
      let yi = headerH;
      for (const port of allInPorts) {
        portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
        yi += portH;
      }
      let yo = headerH;
      for (const port of allOutPorts) {
        portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 });
        yo += portH;
      }

      return { ...nd, x: nodePos.x, y: nodePos.y, width, height, inPorts: allInPorts, outPorts: allOutPorts, portPositions };
    });
  });

  let graphLinks = $derived.by(() => {
    const l = $links;
    const p = $ports;
    const pb = $patchbay;
    const portMap = new Map<number, Port>();
    for (const port of p) portMap.set(port.id, port);
    return l.map(link => {
      const outPort = portMap.get(link.output_port_id);
      const inPort = portMap.get(link.input_port_id);
      if (!outPort || !inPort) return null;
      // Check if both endpoint nodes are visible
      const outNode = $nodes.find(n => n.id === outPort.node_id);
      const inNode = $nodes.find(n => n.id === inPort.node_id);
      if (outNode && isNodeHidden(outNode.name)) return null;
      if (inNode && isNodeHidden(inNode.name)) return null;
      const pinned = pb.pinned_connections.includes(link.id);
      return { ...link, outPort, inPort, pinned };
    }).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>;
  });

  function getPortPos(portId: number): { x: number; y: number } | null {
    for (const node of graphNodes) {
      const pos = node.portPositions.get(portId);
      if (pos) return pos;
    }
    return null;
  }

  // Volume drag state
  let volumeDragging = $state<{ nodeId: number } | null>(null);

  function applyVolumeAtMouse(e: MouseEvent, nodeId: number) {
    const nd = graphNodes.find(n => n.id === nodeId);
    if (!nd || !svgEl) return;
    const volX = nd.x + 8;
    const volW = nd.width - 36;
    const svgRect = svgEl.getBoundingClientRect();
    // Convert mouse X to SVG coordinate
    const mouseSvgX = viewBox.x + (e.clientX - svgRect.left) * viewBox.w / svgRect.width;
    const ratio = (mouseSvgX - volX) / volW;
    const clamped = Math.max(0, Math.min(1, ratio));
    setNodeVolume(nodeId, clamped);
  }

  // Mouse handlers
  function onMouseDown(e: MouseEvent) {
    // Ignore clicks outside the SVG (toolbar, dialogs, etc.)
    if (svgEl && !svgEl.contains(e.target as Node)) return;

    contextMenu = null;
    nodeContextMenu = null;
    showVirtualMenu = false;
    if (e.button === 2) return;
    const pt = svgPoint(e);
    const target = e.target as HTMLElement;

    // Volume slider - high priority
    if (target.classList.contains('vol-handle') || target.classList.contains('vol-hitarea')) {
      const nodeEl = target.closest('.node-group');
      if (nodeEl) {
        const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
        volumeDragging = { nodeId };
        // Immediately apply volume at click position
        applyVolumeAtMouse(e, nodeId);
      }
      return;
    }

    // Mute button
    if (target.classList.contains('mute-btn') || target.classList.contains('mute-text')) {
      const nodeEl = target.closest('.node-group');
      if (nodeEl) {
        const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
        const nd = graphNodes.find(n => n.id === nodeId);
        if (nd) setNodeMute(nodeId, !nd.mute);
      }
      return;
    }

    if (target.classList.contains('port-circle')) {
      const portId = Number(target.dataset.portId);
      const port = $portById.get(portId);
      if (port && port.mode === 'output') {
        const pos = getPortPos(portId);
        if (pos) {
          connecting = { outputPortId: portId, outputX: pos.x, outputY: pos.y, mouseX: pt.x, mouseY: pt.y, portType: port.port_type };
        }
      }
      return;
    }

    if (target.closest('.node-group')) {
      const nodeGroup = target.closest('.node-group') as HTMLElement;
      const nodeId = nodeGroup.dataset.nodeId!;
      const nd = graphNodes.find(n => String(n.id) === nodeId);
      if (nd) {
        dragging = { type: 'node', startX: pt.x, startY: pt.y, origX: nd.x, origY: nd.y, nodeId };
      }
      return;
    }

    if (target.classList.contains('edge-path')) {
      selectedEdge = target.dataset.edgeId || null;
      return;
    }

    selectedEdge = null;
    dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
  }

  function onMouseMove(e: MouseEvent) {
    // Volume dragging takes priority
    if (volumeDragging) {
      applyVolumeAtMouse(e, volumeDragging.nodeId);
      return;
    }
    if (!dragging && !connecting) return;
    const pt = svgPoint(e);
    if (connecting) {
      connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y };
      return;
    }
    if (!dragging) return;
    if (dragging.type === 'node' && dragging.nodeId) {
      const dx = pt.x - dragging.startX;
      const dy = pt.y - dragging.startY;
      nodePositions[dragging.nodeId] = { x: dragging.origX + dx, y: dragging.origY + dy };
    } else if (dragging.type === 'pan') {
      const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1));
      const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1));
      viewBox = { ...viewBox, x: dragging.origX - dx, y: dragging.origY - dy };
    }
  }

  function onMouseUp(e: MouseEvent) {
    if (volumeDragging) {
      volumeDragging = null;
      return;
    }
    if (connecting) {
      const target = e.target as HTMLElement;
      if (target.classList.contains('port-circle')) {
        const inputPortId = Number(target.dataset.portId);
        const port = $portById.get(inputPortId);
        if (port && port.mode === 'input' && port.port_type === connecting.portType) {
          connectPorts(connecting.outputPortId, inputPortId);
        }
      }
      connecting = null;
    }
    if (dragging?.type === 'node' && dragging.nodeId) savePositions();
    dragging = null;
  }

  function onWheel(e: WheelEvent) {
    e.preventDefault();
    const pt = svgPoint(e);
    const factor = e.deltaY > 0 ? 1.1 : 0.9;
    viewBox = {
      x: pt.x - (pt.x - viewBox.x) * factor,
      y: pt.y - (pt.y - viewBox.y) * factor,
      w: viewBox.w * factor,
      h: viewBox.h * factor,
    };
  }

  function onContextMenu(e: MouseEvent) {
    e.preventDefault();
    const target = e.target as HTMLElement;

    // Right-click on edge
    if (target.classList.contains('edge-path')) {
      const edgeId = target.dataset.edgeId;
      const link = $links.find(l => String(l.id) === edgeId);
      if (link) {
        selectedEdge = edgeId || null;
        contextMenu = {
          x: e.clientX, y: e.clientY,
          linkId: link.id,
          outputPortId: link.output_port_id,
          inputPortId: link.input_port_id,
          pinned: $patchbay.pinned_connections.includes(link.id),
        };
      }
      return;
    }

    // Right-click on node
    const nodeGroup = target.closest('.node-group') as HTMLElement;
    if (nodeGroup) {
      const nodeId = Number(nodeGroup.dataset.nodeId);
      const nd = $nodes.find(n => n.id === nodeId);
      if (nd) {
        nodeContextMenu = { x: e.clientX, y: e.clientY, nodeId, nodeName: nd.name };
      }
    }
  }

  function unloadNodeModule() {
    if (!nodeContextMenu) return;
    // Try to find and unload the module that created this node
    // Use pactl to unload by looking up the module
    fetch('/api/load-module', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ module: 'module-null-sink', args: '' }),
    }).catch(() => {});

    // Actually, just use the destroy approach via pactl
    // We need to find the module ID. Let's use a different endpoint.
    // For now, use pw-cli to destroy the node
    const name = nodeContextMenu.nodeName;
    fetch('/api/destroy-node', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ node_id: nodeContextMenu.nodeId }),
    }).catch(() => {});
    nodeContextMenu = null;
  }

  function onKey(e: KeyboardEvent) {
    if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) {
      const link = $links.find(l => String(l.id) === selectedEdge);
      if (link) { disconnectPorts(link.output_port_id, link.input_port_id); selectedEdge = null; }
    }
  }

  onMount(() => { initGraph(); loadPositions(); });
  onDestroy(() => { destroyGraph(); });
</script>

<svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; nodeContextMenu = null; }} />

<div class="wrap">
  <div class="toolbar">
    <span class="dot" class:on={$connected}></span>
    <span>{$connected ? 'Connected' : 'Disconnected'}</span>
    <span class="sep"></span>

    <!-- Port type filters -->
    <label class="filter" class:active={showAudio}><input type="checkbox" bind:checked={showAudio} /> <span class="fc audio"></span> Audio</label>
    <label class="filter" class:active={showMidi}><input type="checkbox" bind:checked={showMidi} /> <span class="fc midi"></span> MIDI</label>
    <label class="filter" class:active={showVideo}><input type="checkbox" bind:checked={showVideo} /> <span class="fc video"></span> Video</label>
    <label class="filter" class:active={showOther}><input type="checkbox" bind:checked={showOther} /> <span class="fc other"></span> Other</label>
    <span class="sep"></span>

    <!-- Patchbay toggles -->
    <button class="toggle" class:active={$patchbay.activated} onclick={() => setActivated(!$patchbay.activated)} title="Activated Mode: auto-restore connections">
      {$patchbay.activated ? 'Active' : 'Inactive'}
    </button>
    <button class="toggle" class:active={$patchbay.exclusive} onclick={() => setExclusive(!$patchbay.exclusive)} title="Exclusive Mode: disconnect unprofiled links">
      {$patchbay.exclusive ? 'Exclusive' : 'Permissive'}
    </button>
    <button class="toggle" class:active={$patchbay.auto_pin} onclick={() => setAutoPin(!$patchbay.auto_pin)} title="Auto-Pin new connections">
      {$patchbay.auto_pin ? 'Auto-Pin ON' : 'Auto-Pin OFF'}
    </button>
    <button class="toggle" class:active={$patchbay.auto_disconnect} onclick={() => setAutoDisconnect(!$patchbay.auto_disconnect)} title="Auto-Disconnect pinned on deactivate">
      {$patchbay.auto_disconnect ? 'Auto-Disc ON' : 'Auto-Disc OFF'}
    </button>
    <span class="sep"></span>

    <!-- Dialogs -->
    <button onclick={() => { showHideDialog = !showHideDialog; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node hiding rules">Hide Nodes</button>
    <button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
    <button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
    <button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
    <button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>

    <span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
  </div>

  <!-- Virtual device dropdown -->
  {#if showVirtualMenu}
    <div class="virt-menu">
      <div class="virt-header">Add Virtual Device</div>
      <button onclick={async () => { showVirtualMenu = false; await createNullSink('pwweb-null-' + Date.now().toString(36)); }}>Null Sink (Virtual Output)</button>
      <button onclick={async () => { showVirtualMenu = false; await createLoopback('pwweb-loop-' + Date.now().toString(36)); }}>Loopback Device</button>
      <div class="virt-sep"></div>
      <button onclick={async () => { showVirtualMenu = false; await loadModule('module-native-protocol-tcp', 'auth-anonymous=1 port=4713'); }}>TCP Network Server</button>
      <button onclick={async () => { showVirtualMenu = false; await loadModule('module-tunnel-sink', 'server=tcp:127.0.0.1:4713'); }}>TCP Tunnel Sink</button>
      <button onclick={async () => { showVirtualMenu = false; await loadModule('module-tunnel-source', 'server=tcp:127.0.0.1:4713'); }}>TCP Tunnel Source</button>
    </div>
  {/if}

  <!-- svelte-ignore a11y_no_static_element_interactions -->
  <svg
    bind:this={svgEl}
    viewBox="{viewBox.x} {viewBox.y} {viewBox.w} {viewBox.h}"
    onmousedown={onMouseDown}
    onmousemove={onMouseMove}
    onmouseup={onMouseUp}
    onwheel={onWheel}
    oncontextmenu={onContextMenu}
    class="canvas"
  >
    <defs>
      <filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
        <feDropShadow dx="1" dy="1" stdDeviation="2" flood-opacity="0.4" />
      </filter>
    </defs>

    <!-- Edges -->
    {#each graphLinks as link (link.id)}
      {@const outPos = getPortPos(link.output_port_id)}
      {@const inPos = getPortPos(link.input_port_id)}
      {#if outPos && inPos}
        {@const color = portColor(link.outPort.port_type)}
        {@const isSelected = selectedEdge === String(link.id)}
        <path
          d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
          stroke={isSelected ? '#fff' : color}
          stroke-width={isSelected ? 3.5 : link.pinned ? 2.5 : 2}
          fill="none"
          class="edge-path"
          data-edge-id={String(link.id)}
          stroke-linecap="round"
          stroke-dasharray={link.pinned ? 'none' : 'none'}
          opacity={link.pinned ? 1 : 0.7}
        />
        {#if link.pinned}
          <circle cx={(outPos.x + inPos.x) / 2} cy={(outPos.y + inPos.y) / 2} r="3" fill="#ff0" opacity="0.8" />
        {/if}
      {/if}
    {/each}

    <!-- Connecting line -->
    {#if connecting}
      <path
        d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
        stroke={portColor(connecting.portType)}
        stroke-width="2"
        stroke-dasharray="6 3"
        fill="none"
        opacity="0.7"
      />
    {/if}

    <!-- Nodes -->
    {#each graphNodes as nd (nd.id)}
      {@const isSource = nd.mode === 'output'}
      {@const isSink = nd.mode === 'input'}
      {@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : '#1e1e2e'}
      {@const border = isSource ? '#4a9' : isSink ? '#a44' : '#555'}
      {@const headerBg = isSource ? '#263826' : isSink ? '#382626' : '#262638'}

      <g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
        <rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
        <rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
        <rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
        <text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
          {nd.nick || nd.name}
        </text>
        <text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
          [{nd.node_type}]
        </text>

        {#each nd.inPorts as port, i (port.id)}
          {@const py = nd.y + 22 + i * 16 + 8}
          {#if isPortVisible(port.port_type)}
            <circle
              cx={nd.x} cy={py} r="4"
              fill={portColor(port.port_type)}
              stroke={hoveredPort === port.id ? '#fff' : '#333'}
              stroke-width={hoveredPort === port.id ? 2 : 0.5}
              class="port-circle"
              data-port-id={String(port.id)}
              onmouseenter={() => { hoveredPort = port.id; }}
              onmouseleave={() => { hoveredPort = null; }}
            />
            <text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">{shortName(port.name)}</text>
          {:else}
            <circle cx={nd.x} cy={py} r="3" fill="#333" />
          {/if}
        {/each}

        {#each nd.outPorts as port, i (port.id)}
          {@const py = nd.y + 22 + i * 16 + 8}
          {#if isPortVisible(port.port_type)}
            <text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">{shortName(port.name)}</text>
            <circle
              cx={nd.x + nd.width} cy={py} r="4"
              fill={portColor(port.port_type)}
              stroke={hoveredPort === port.id ? '#fff' : '#333'}
              stroke-width={hoveredPort === port.id ? 2 : 0.5}
              class="port-circle"
              data-port-id={String(port.id)}
              onmouseenter={() => { hoveredPort = port.id; }}
              onmouseleave={() => { hoveredPort = null; }}
            />
          {:else}
            <circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
          {/if}
        {/each}

        <!-- Volume slider + mute button at bottom of node -->
        <rect
          class="mute-btn"
          x={nd.x + nd.width - 24} y={nd.y + nd.height - 17}
          width="16" height="12" rx="2"
          fill={nd.mute ? '#a44' : '#2a2a3e'}
          stroke="#555" stroke-width="0.5"
        />
        <text
          class="mute-text"
          x={nd.x + nd.width - 16} y={nd.y + nd.height - 8}
          font-size="8" font-family="monospace"
          fill={nd.mute ? '#fff' : '#888'}
          text-anchor="middle"
        >{nd.mute ? 'M' : 'm'}</text>

        <!-- Volume bar background (visual only) -->
        <rect x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="4" rx="2" fill="#333" />
        <!-- Volume bar fill (visual only, clamped to 1.0) -->
        <rect x={nd.x + 8} y={nd.y + nd.height - 12} width={(nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} height="4" rx="2" fill={nd.mute ? '#666' : '#4a9'} />
        <!-- Volume click/drag hit area (tall transparent rect over the whole slider) -->
        <rect class="vol-hitarea" x={nd.x + 8} y={nd.y + nd.height - 18} width={nd.width - 36} height="14" fill="transparent" />
        <!-- Volume handle circle -->
        <circle class="vol-handle" cx={nd.x + 8 + (nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} cy={nd.y + nd.height - 10} r="3.5" fill={nd.mute ? '#888' : '#6cb'} stroke="#fff" stroke-width="1" />
        <!-- Volume % label -->
        <text x={nd.x + 8} y={nd.y + nd.height - 15} font-size="7" font-family="monospace" fill="#888">
          {Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}%
        </text>

      </g>
    {/each}
  </svg>

  <!-- Context menu -->
  {#if contextMenu}
    <div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
      <button onclick={() => { disconnectPorts(contextMenu!.outputPortId, contextMenu!.inputPortId); contextMenu = null; }}>Disconnect</button>
      <button onclick={() => { togglePin(contextMenu!.linkId); contextMenu = null; }}>{contextMenu.pinned ? 'Unpin' : 'Pin'}</button>
    </div>
  {/if}

  {#if nodeContextMenu}
    <div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
      <div class="ctx-title">{nodeContextMenu.nodeName}</div>
      <button onclick={() => {
        fetch('/api/destroy-node', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ node_id: nodeContextMenu!.nodeId }),
        }).catch(() => {});
        nodeContextMenu = null;
      }}>Destroy Node</button>
    </div>
  {/if}

  <!-- Hide Nodes Dialog -->
  {#if showHideDialog}
    <div class="dialog">
      <div class="dialog-header">
        <span>Node Hiding Rules</span>
        <button class="close" onclick={() => { showHideDialog = false; }}>X</button>
      </div>
      <div class="dialog-body">
        <p class="hint">Hide nodes matching pattern (regex or plain text).</p>
        <div class="input-row">
          <input bind:value={newHideRule} placeholder="e.g. Dummy|Freewheel or .*Driver.*" class="dlg-input" />
          <button onclick={() => { if (newHideRule.trim()) { addHideRule(newHideRule.trim()); newHideRule = ''; } }}>Add</button>
        </div>
        <div class="rule-list">
          {#each $patchbay.hide_rules as rule}
            <div class="rule-item">
              <span>{rule}</span>
              <button onclick={() => removeHideRule(rule)}>Remove</button>
            </div>
          {/each}
          {#if $patchbay.hide_rules.length === 0}
            <div class="empty">No hiding rules.</div>
          {/if}
        </div>
      </div>
    </div>
  {/if}

  <!-- Merge Nodes Dialog -->
  {#if showMergeDialog}
    <div class="dialog">
      <div class="dialog-header">
        <span>Node Merging Rules</span>
        <button class="close" onclick={() => { showMergeDialog = false; }}>X</button>
      </div>
      <div class="dialog-body">
        <p class="hint">Merge nodes sharing a common name prefix into one block.</p>
        <div class="input-row">
          <input bind:value={newMergeRule} placeholder="e.g. Built-in Audio" class="dlg-input" />
          <button onclick={() => { if (newMergeRule.trim()) { addMergeRule(newMergeRule.trim()); newMergeRule = ''; } }}>Add</button>
        </div>
        <div class="rule-list">
          {#each $patchbay.merge_rules as rule}
            <div class="rule-item">
              <span>{rule}</span>
              <button onclick={() => removeMergeRule(rule)}>Remove</button>
            </div>
          {/each}
          {#if $patchbay.merge_rules.length === 0}
            <div class="empty">No merge rules.</div>
          {/if}
        </div>
      </div>
    </div>
  {/if}

  <!-- Profile Dialog -->
  {#if showProfileDialog}
    <div class="dialog">
      <div class="dialog-header">
        <span>Profile Management</span>
        <button class="close" onclick={() => { showProfileDialog = false; }}>X</button>
      </div>
      <div class="dialog-body">
        <div class="input-row">
          <input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" />
          <button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); } }}>Save Current</button>
        </div>
        <div class="rule-list">
          {#each Object.entries($patchbay.profiles) as [name, profile]}
            <div class="rule-item">
              <span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
              <button onclick={() => loadProfile(name)}>Load</button>
              <button onclick={() => deleteProfile(name)}>Delete</button>
            </div>
          {/each}
          {#if Object.keys($patchbay.profiles).length === 0}
            <div class="empty">No saved profiles.</div>
          {/if}
        </div>
      </div>
    </div>
  {/if}

  <!-- Rule Management Dialog -->
  {#if showRuleDialog}
    <div class="dialog">
      <div class="dialog-header">
        <span>Rule Management</span>
        <button class="close" onclick={() => { showRuleDialog = false; }}>X</button>
      </div>
      <div class="dialog-body">
        {#if $patchbay.active_profile && $patchbay.profiles[$patchbay.active_profile]}
          {@const profile = $patchbay.profiles[$patchbay.active_profile]}
          <p class="hint">Active profile: <strong>{profile.name}</strong> ({profile.connections.length} rules)</p>
          <div class="rule-list scrollable">
            {#each profile.connections as rule, i}
              <div class="rule-item small">
                <span>{rule.output_node_name}:{shortName(rule.output_port_name)} => {rule.input_node_name}:{shortName(rule.input_port_name)}</span>
                <span class="type-badge {rule.output_port_type}">{rule.output_port_type}</span>
                {#if rule.pinned}<span class="pin-badge">PIN</span>{/if}
              </div>
            {/each}
          </div>
        {:else}
          <div class="empty">No active profile. Save a profile first via Profiles.</div>
        {/if}
      </div>
    </div>
  {/if}
</div>

<style>
  .wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
  .canvas { width: 100%; height: 100%; display: block; cursor: default; position: absolute; top: 0; left: 0; z-index: 1; }

  .toolbar {
    position: absolute; top: 0; left: 0; right: 0; z-index: 10;
    display: flex; align-items: center; gap: 6px;
    padding: 4px 10px;
    background: rgba(16, 16, 24, 0.97);
    border-bottom: 1px solid #333;
    font-size: 11px; color: #aaa; font-family: monospace;
    flex-wrap: wrap;
  }
  .dot { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; }
  .dot.on { background: #4a9; }
  .sep { width: 1px; height: 16px; background: #444; margin: 0 2px; flex-shrink: 0; }

  .filter {
    display: flex; align-items: center; gap: 3px; cursor: pointer;
    padding: 2px 5px; border-radius: 3px; font-size: 10px; user-select: none;
  }
  .filter:hover { background: rgba(255,255,255,0.05); }
  .filter input { display: none; }
  .filter .fc { width: 8px; height: 8px; border-radius: 2px; }
  .filter .fc.audio { background: #4a9; }
  .filter .fc.midi { background: #a44; }
  .filter .fc.video { background: #49a; }
  .filter .fc.other { background: #999; }
  .filter:not(.active) { opacity: 0.4; }
  .filter:not(.active) .fc { background: #555 !important; }

  .toolbar button, .toggle {
    padding: 2px 7px; background: #2a2a3e; border: 1px solid #444;
    color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
    white-space: nowrap;
  }
  .toolbar button:hover, .toggle:hover { background: #3a3a4e; }
  .toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }

  .stats { margin-left: auto; color: #555; }

  /* Context menu */
  .ctx {
    position: fixed; z-index: 100;
    background: #2a2a3e; border: 1px solid #555;
    border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
  }
  .ctx button {
    display: block; width: 100%; padding: 5px 20px;
    background: none; border: none; color: #ccc;
    font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
  }
  .ctx button:hover { background: #444; }
  .ctx-title {
    padding: 4px 16px; font-size: 9px; color: #666;
    border-bottom: 1px solid #444; font-family: monospace;
    max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }

  /* Dialogs */
  .dialog {
    position: absolute; top: 40px; right: 10px; z-index: 20;
    background: #1e1e2e; border: 1px solid #555; border-radius: 6px;
    box-shadow: 0 4px 16px rgba(0,0,0,0.6);
    width: 360px; max-height: 80vh; overflow: hidden;
    display: flex; flex-direction: column;
    font-family: monospace;
  }
  .dialog-header {
    display: flex; justify-content: space-between; align-items: center;
    padding: 8px 12px; background: #2a2a3e; border-bottom: 1px solid #444;
    font-size: 12px; color: #ddd; font-weight: bold;
  }
  .close { background: none; border: none; color: #888; cursor: pointer; font-size: 14px; padding: 0 4px; }
  .close:hover { color: #fff; }
  .dialog-body { padding: 10px; overflow-y: auto; }
  .hint { font-size: 10px; color: #666; margin: 0 0 8px; }

  .input-row { display: flex; gap: 6px; margin-bottom: 8px; }
  .dlg-input {
    flex: 1; padding: 4px 8px; background: #14141e; border: 1px solid #444;
    color: #ccc; font-size: 11px; border-radius: 3px; font-family: monospace;
  }
  .input-row button {
    padding: 4px 10px; background: #2a2a3e; border: 1px solid #444;
    color: #aaa; font-size: 11px; cursor: pointer; border-radius: 3px; font-family: monospace;
  }
  .input-row button:hover { background: #3a3a4e; }

  .rule-list { display: flex; flex-direction: column; gap: 2px; }
  .rule-list.scrollable { max-height: 300px; overflow-y: auto; }
  .rule-item {
    display: flex; align-items: center; gap: 6px;
    padding: 3px 6px; background: #14141e; border-radius: 3px;
    font-size: 10px; color: #aaa;
  }
  .rule-item span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .rule-item button {
    padding: 1px 6px; background: #2a2a3e; border: 1px solid #444;
    color: #888; font-size: 9px; cursor: pointer; border-radius: 2px; font-family: monospace;
    flex-shrink: 0;
  }
  .rule-item button:hover { color: #f88; border-color: #a44; }
  .rule-item.small { font-size: 9px; }
  .active-profile { color: #6c9; font-weight: bold; }

  .type-badge {
    font-size: 8px; padding: 0 4px; border-radius: 2px; color: #fff;
  }
  .type-badge.audio { background: #4a9; }
  .type-badge.midi { background: #a44; }
  .type-badge.video { background: #49a; }
  .pin-badge { font-size: 8px; padding: 0 3px; background: #aa4; color: #000; border-radius: 2px; }

  .empty { font-size: 11px; color: #555; padding: 8px 0; text-align: center; }

  .port-circle { cursor: crosshair; }
  .port-circle:hover { filter: brightness(1.5); }
  .edge-path { cursor: pointer; pointer-events: stroke; }
  .edge-path:hover { stroke-width: 4; }

  .mute-btn { cursor: pointer; }
  .mute-btn:hover { filter: brightness(1.3); }
  .mute-text { pointer-events: none; }
  .vol-bg { pointer-events: none; }
  .vol-bar { pointer-events: none; }
  .vol-hitarea { cursor: pointer; }
  .vol-handle { cursor: ew-resize; pointer-events: none; }
  .vol-handle:hover { filter: brightness(1.3); }
  .add-btn { background: #1a3a2a !important; border-color: #4a9 !important; color: #6c9 !important; }

  .virt-menu {
    position: absolute; top: 30px; z-index: 30;
    right: 80px;
    background: #2a2a3e; border: 1px solid #555;
    border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
  }
  .virt-header {
    padding: 4px 16px; font-size: 9px; color: #666; text-transform: uppercase;
    letter-spacing: 1px; border-bottom: 1px solid #444;
  }
  .virt-sep { height: 1px; background: #444; margin: 2px 0; }
  .virt-menu button {
    display: block; width: 100%; padding: 6px 16px;
    background: none; border: none; color: #ccc;
    font-size: 11px; cursor: pointer; text-align: left; font-family: monospace;
    white-space: nowrap;
  }
  .virt-menu button:hover { background: #444; }

</style>
