<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
import { useRouter } from "vue-router";
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
import { zoom, zoomIdentity } from "d3-zoom";
import { drag } from "d3-drag";
import { select } from "d3-selection";
import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";

const router = useRouter();

interface GraphNode extends SimulationNodeDatum {
  id: number | string;
  title: string;
  type: "note" | "task" | "tag";
  tags: string[];
  project_id: number | null;
  project_color: string | null;
  // computed
  degree?: number;
  radius?: number;
}

interface GraphEdge {
  source: number | string | GraphNode;
  target: number | string | GraphNode;
  type: "wikilink" | "tag";
}

interface Project {
  id: number;
  title: string;
  color: string | null;
}

const svgRef = ref<SVGSVGElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);

const loading = ref(false);
const error = ref<string | null>(null);
const nodes = ref<GraphNode[]>([]);
const edges = ref<GraphEdge[]>([]);
const projects = ref<Project[]>([]);

const projectFilter = ref<number | null>(null);
const showSharedTags = ref(true);
const hideOrphans = ref(false);

// Physics tuning knobs
const showPhysics = ref(false);
const repulsion = ref(110);   // displayed as positive; applied as negative charge
const linkDist = ref(80);
const linkStrength = ref(0.50);
const hubPull = ref(0.05);
const gravity = ref(0.03);   // forceX/Y strength — 0 = no pull, ~0.1 = strong

// tooltip state
const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode | null }>({
  visible: false,
  x: 0,
  y: 0,
  node: null,
});

// peek panel state
const peekNode = ref<GraphNode | null>(null);
const peekBody = ref("");
const peekLoading = ref(false);

const peekLinkedNodes = computed<GraphNode[]>(() => {
  if (!peekNode.value) return [];
  const pid = String(peekNode.value.id);
  const linkedIds = new Set<string>();
  for (const e of edges.value) {
    const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
    const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
    if (sid === pid) linkedIds.add(tid);
    if (tid === pid) linkedIds.add(sid);
  }
  return nodes.value.filter(
    (n) => linkedIds.has(String(n.id)) && n.type !== "tag"
  );
});

async function openPeek(node: GraphNode) {
  peekNode.value = node;
  peekBody.value = "";
  peekLoading.value = true;
  try {
    const endpoint = node.type === "task" ? `/api/tasks/${node.id}` : `/api/notes/${node.id}`;
    const data = await apiGet<{ body?: string }>(endpoint);
    peekBody.value = data.body ?? "";
  } catch {
    peekBody.value = "";
  } finally {
    peekLoading.value = false;
  }
}

function closePeek() {
  peekNode.value = null;
}

function handleKeyDown(e: KeyboardEvent) {
  if (e.key === "Escape" && peekNode.value) {
    closePeek();
    e.stopPropagation();
  }
}

let simulation: Simulation<GraphNode, undefined> | null = null;
let resizeObserver: ResizeObserver | null = null;

const statsText = computed(() => {
  if (!nodes.value.length) return "";
  const orphanCount = nodes.value.filter((n) => (n.degree ?? 0) === 0).length;
  const visibleNodes = hideOrphans.value ? nodes.value.length - orphanCount : nodes.value.length;
  return `${visibleNodes} nodes · ${edges.value.length} edges`;
});

async function fetchData() {
  loading.value = true;
  error.value = null;
  try {
    const params = new URLSearchParams();
    if (projectFilter.value !== null) params.set("project_id", String(projectFilter.value));
    if (showSharedTags.value) params.set("shared_tags", "true");
    const qs = params.toString() ? "?" + params.toString() : "";
    const data = await apiGet<{ nodes: GraphNode[]; edges: GraphEdge[] }>(`/api/notes/graph${qs}`);
    nodes.value = data.nodes;
    edges.value = data.edges;
  } catch (e: any) {
    error.value = e?.message ?? "Failed to load graph";
  } finally {
    loading.value = false;
  }
}

async function fetchProjects() {
  try {
    const data = await apiGet<{ projects: Project[] }>("/api/projects");
    projects.value = data.projects ?? [];
  } catch {
    // non-fatal
  }
}

function initGraph() {
  if (!svgRef.value || !containerRef.value) return;

  const svg = select(svgRef.value);
  svg.selectAll("*").remove();

  const w = containerRef.value.clientWidth || 800;
  const h = containerRef.value.clientHeight || 600;

  // Compute degrees
  const degreeMap = new Map<string, number>();
  for (const n of nodes.value) degreeMap.set(String(n.id), 0);
  for (const e of edges.value) {
    const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
    const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
    degreeMap.set(sid, (degreeMap.get(sid) ?? 0) + 1);
    degreeMap.set(tid, (degreeMap.get(tid) ?? 0) + 1);
  }

  const simNodes: GraphNode[] = nodes.value.map((n) => {
    const deg = degreeMap.get(String(n.id)) ?? 0;
    const isTag = n.type === "tag";
    return {
      ...n,
      degree: deg,
      radius: isTag ? 7 : Math.max(6, Math.min(18, 6 + deg * 1.5)),
      x: w / 2 + (Math.random() - 0.5) * 200,
      y: h / 2 + (Math.random() - 0.5) * 200,
    };
  });

  // Apply orphan filter (tag nodes always have edges so never filtered)
  const visibleNodes = hideOrphans.value
    ? simNodes.filter((n) => (n.degree ?? 0) > 0)
    : simNodes;
  const visibleIds = new Set(visibleNodes.map((n) => String(n.id)));

  const simEdges: SimulationLinkDatum<GraphNode>[] = edges.value
    .filter((e) => {
      const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
      const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
      return visibleIds.has(sid) && visibleIds.has(tid);
    })
    .map((e) => ({
      source: String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source),
      target: String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target),
      type: (e as GraphEdge).type,
    })) as SimulationLinkDatum<GraphNode>[] & { type?: string }[];

  // Defs — arrowhead marker
  const defs = svg.append("defs");
  defs.append("marker")
    .attr("id", "arrow")
    .attr("viewBox", "0 -4 8 8")
    .attr("refX", 8)
    .attr("refY", 0)
    .attr("markerWidth", 6)
    .attr("markerHeight", 6)
    .attr("orient", "auto")
    .append("path")
    .attr("d", "M0,-4L8,0L0,4")
    .attr("fill", "var(--fs-accent)")
    .attr("opacity", "0.6");

  // Zoom layer
  const zoomG = svg.append("g").attr("class", "zoom-layer");

  const zoomBehavior = zoom<SVGSVGElement, unknown>()
    .scaleExtent([0.1, 8])
    .on("zoom", (event) => {
      zoomG.attr("transform", event.transform);
    });

  svg
    .attr("width", w)
    .attr("height", h)
    .call(zoomBehavior)
    .on("dblclick.zoom", null);

  // Reset zoom to fit on initial load
  svg.call(zoomBehavior.transform, zoomIdentity.translate(0, 0).scale(1));

  // Edges
  const linkSel = zoomG
    .selectAll("line.graph-edge")
    .data(simEdges)
    .enter()
    .append("line")
    .attr("class", "graph-edge")
    .attr("stroke", (d: any) =>
      d.type === "wikilink" ? "var(--fs-accent)" : "var(--fs-text-tertiary)"
    )
    .attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
    .attr("stroke-dasharray", null)
    .attr("stroke-width", (d: any) => (d.type === "wikilink" ? 1.5 : 1))
    .attr("marker-end", (d: any) => (d.type === "wikilink" ? "url(#arrow)" : null));

  // Node groups
  const nodeSel = zoomG
    .selectAll("g.graph-node")
    .data(visibleNodes)
    .enter()
    .append("g")
    .attr("class", "graph-node")
    .style("cursor", "pointer")
    .on("click", (_event: MouseEvent, d: GraphNode) => {
      if (d.type === "tag") {
        router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
      } else {
        openPeek(d);
      }
    })
    .on("mouseover", function(event: MouseEvent, d: GraphNode) {
      const rect = containerRef.value!.getBoundingClientRect();
      tooltip.value = {
        visible: true,
        x: event.clientX - rect.left + 12,
        y: event.clientY - rect.top - 8,
        node: d,
      };
      select(this as SVGGElement).select("circle")
        .transition().duration(150)
        .attr("r", (d.radius ?? 8) * 1.3);
      select(this as SVGGElement).select("text")
        .transition().duration(150)
        .style("font-weight", "600")
        .style("font-size", "11px");
    })
    .on("mousemove", (event: MouseEvent) => {
      const rect = containerRef.value!.getBoundingClientRect();
      tooltip.value.x = event.clientX - rect.left + 12;
      tooltip.value.y = event.clientY - rect.top - 8;
    })
    .on("mouseout", function(_event: MouseEvent, d: GraphNode) {
      tooltip.value.visible = false;
      select(this as SVGGElement).select("circle")
        .transition().duration(150)
        .attr("r", d.radius ?? 8);
      select(this as SVGGElement).select("text")
        .transition().duration(150)
        .style("font-weight", null)
        .style("font-size", null);
    });

  nodeSel
    .append("circle")
    .attr("r", (d: GraphNode) => d.radius ?? 8)
    .attr("fill", (d: GraphNode) => {
      if (d.type === "tag") return "color-mix(in srgb, var(--fs-accent) 20%, transparent)";
      return d.project_color ?? "var(--fs-surface-raised)";
    })
    .attr("stroke", (d: GraphNode) =>
      d.type === "tag" ? "var(--fs-accent)" : "var(--fs-border-color)"
    )
    .attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
    .attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
    .attr("opacity", (d: GraphNode) =>
      !hideOrphans.value && (d.degree ?? 0) === 0 ? 0.35 : 1
    );

  nodeSel
    .append("text")
    .text((d: GraphNode) => {
      const label = d.type === "tag" ? `#${d.title}` : d.title;
      return label.length > 20 ? label.slice(0, 20) + "…" : label;
    })
    .attr("text-anchor", "middle")
    .attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
    .attr("font-size", "10px")
    .attr("fill", (d: GraphNode) =>
      d.type === "tag" ? "var(--fs-accent)" : "var(--fs-text-secondary)"
    )
    .attr("pointer-events", "none");

  // Drag
  const dragBehavior = drag<SVGGElement, GraphNode>()
    .on("start", (event, d) => {
      if (!event.active) simulation?.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    })
    .on("drag", (event, d) => {
      d.fx = event.x;
      d.fy = event.y;
    })
    .on("end", (event, d) => {
      if (!event.active) simulation?.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    });

  nodeSel.call(dragBehavior as any);

  // Invisible project hub nodes — one per project, attract all member nodes inward
  const projectHubs = new Map<number, GraphNode>();
  for (const n of visibleNodes) {
    if (n.project_id !== null && !projectHubs.has(n.project_id)) {
      projectHubs.set(n.project_id, {
        id: `hub:${n.project_id}`,
        title: "",
        type: "tag" as any, // unused — hubs are never rendered
        tags: [],
        project_id: n.project_id,
        project_color: null,
        degree: 0,
        radius: 0,
        x: w / 2 + (Math.random() - 0.5) * 100,
        y: h / 2 + (Math.random() - 0.5) * 100,
      });
    }
  }

  const hubNodes = [...projectHubs.values()];
  const allSimNodes = [...visibleNodes, ...hubNodes];
  const hubEdges: SimulationLinkDatum<GraphNode>[] = [];
  for (const n of visibleNodes) {
    if (n.project_id !== null && projectHubs.has(n.project_id)) {
      hubEdges.push({
        source: String(n.id),
        target: `hub:${n.project_id}`,
        type: "hub",
      } as any);
    }
  }
  const allSimEdges = [...simEdges, ...hubEdges];

  // Simulation
  simulation = forceSimulation<GraphNode>(allSimNodes)
    .force(
      "link",
      forceLink<GraphNode, SimulationLinkDatum<GraphNode>>(allSimEdges)
        .id((d) => String(d.id))
        .distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
        .strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value)
    )
    .force("charge", forceManyBody<GraphNode>().strength((d: any) =>
      String(d.id).startsWith("hub:") ? 0 : -repulsion.value
    ))
    .force("gravityX", forceX<GraphNode>(w / 2).strength(gravity.value))
    .force("gravityY", forceY<GraphNode>(h / 2).strength(gravity.value))
    .force("collide", forceCollide<GraphNode>().radius((d) => (d.radius ?? 8) + 4))
    .on("tick", () => {
      linkSel
        .attr("x1", (d: any) => (d.source as GraphNode).x ?? 0)
        .attr("y1", (d: any) => (d.source as GraphNode).y ?? 0)
        .attr("x2", (d: any) => {
          const s = d.source as GraphNode;
          const t = d.target as GraphNode;
          const dx = (t.x ?? 0) - (s.x ?? 0);
          const dy = (t.y ?? 0) - (s.y ?? 0);
          const dist = Math.sqrt(dx * dx + dy * dy) || 1;
          const r = (t.radius ?? 8) + 6;
          return (t.x ?? 0) - (dx / dist) * r;
        })
        .attr("y2", (d: any) => {
          const s = d.source as GraphNode;
          const t = d.target as GraphNode;
          const dx = (t.x ?? 0) - (s.x ?? 0);
          const dy = (t.y ?? 0) - (s.y ?? 0);
          const dist = Math.sqrt(dx * dx + dy * dy) || 1;
          const r = (t.radius ?? 8) + 6;
          return (t.y ?? 0) - (dy / dist) * r;
        });

      nodeSel.attr("transform", (d: GraphNode) => `translate(${d.x ?? 0},${d.y ?? 0})`);
    });
}

function applyPhysics() {
  if (!simulation) return;
  const r = repulsion.value;
  (simulation.force("charge") as any)?.strength((d: any) =>
    String(d.id).startsWith("hub:") ? 0 : -r
  );
  const lf = simulation.force("link") as any;
  if (lf) {
    lf.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
      .strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value);
  }
  (simulation.force("gravityX") as any)?.strength(gravity.value);
  (simulation.force("gravityY") as any)?.strength(gravity.value);
  simulation.alpha(0.3).restart();
}

watch([nodes, edges, hideOrphans], () => {
  initGraph();
});

watch([repulsion, linkDist, linkStrength, hubPull, gravity], () => {
  applyPhysics();
});

watch([projectFilter, showSharedTags], () => {
  fetchData();
});

function handleResize() {
  initGraph();
}

onMounted(async () => {
  await Promise.all([fetchData(), fetchProjects()]);
  initGraph();

  resizeObserver = new ResizeObserver(handleResize);
  if (containerRef.value) resizeObserver.observe(containerRef.value);
  window.addEventListener("keydown", handleKeyDown);
});

onUnmounted(() => {
  simulation?.stop();
  resizeObserver?.disconnect();
  window.removeEventListener("keydown", handleKeyDown);
});
</script>

<template>
  <main class="graph-page">
    <div class="graph-controls">
      <select
        v-model="projectFilter"
        class="graph-select"
        aria-label="Filter by project"
      >
        <option :value="null">All projects</option>
        <option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
      </select>

      <label class="graph-toggle">
        <input type="checkbox" v-model="showSharedTags" />
        Show tags
      </label>

      <label class="graph-toggle">
        <input type="checkbox" v-model="hideOrphans" />
        Hide orphans
      </label>

      <button class="graph-physics-btn" :class="{ active: showPhysics }" @click="showPhysics = !showPhysics">
        Physics
      </button>

      <span v-if="statsText" class="graph-stats">{{ statsText }}</span>
    </div>

    <div v-if="showPhysics" class="graph-physics-panel">
      <label class="physics-knob">
        <span class="knob-label">Repulsion <em>{{ repulsion }}</em></span>
        <input type="range" v-model.number="repulsion" min="10" max="400" step="5" />
      </label>
      <label class="physics-knob">
        <span class="knob-label">Link distance <em>{{ linkDist }}</em></span>
        <input type="range" v-model.number="linkDist" min="20" max="200" step="5" />
      </label>
      <label class="physics-knob">
        <span class="knob-label">Link strength <em>{{ linkStrength.toFixed(2) }}</em></span>
        <input type="range" v-model.number="linkStrength" min="0.05" max="1" step="0.05" />
      </label>
      <label class="physics-knob">
        <span class="knob-label">Project pull <em>{{ hubPull.toFixed(2) }}</em></span>
        <input type="range" v-model.number="hubPull" min="0" max="1" step="0.05" />
      </label>
      <label class="physics-knob">
        <span class="knob-label">Gravity <em>{{ gravity.toFixed(2) }}</em></span>
        <input type="range" v-model.number="gravity" min="0" max="0.2" step="0.01" />
      </label>
    </div>

    <div class="graph-wrap" ref="containerRef">
      <svg ref="svgRef" class="graph-svg" />

      <div v-if="loading" class="graph-overlay graph-loading">
        <span class="spinner"></span>
      </div>

      <div v-else-if="error" class="graph-overlay graph-empty">
        <p>{{ error }}</p>
      </div>

      <div v-else-if="!loading && nodes.length === 0" class="graph-overlay graph-empty">
        <p>No notes to display. Create some notes to see the graph.</p>
      </div>

      <!-- Peek panel -->
      <Transition name="peek-slide">
        <div v-if="peekNode" class="graph-peek-panel">
          <div class="peek-header">
            <span :class="['peek-type-badge', `peek-type-${peekNode.type}`]">{{ peekNode.type }}</span>
            <div class="peek-actions">
              <router-link
                :to="peekNode.type === 'task' ? `/tasks/${peekNode.id}` : `/notes/${peekNode.id}`"
                class="peek-link"
              >Open</router-link>
              <router-link
                :to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
                class="peek-link"
              >Edit</router-link>
              <button class="peek-close" @click="closePeek" title="Close (Esc)"><X :size="16" /></button>
            </div>
          </div>

          <h2 class="peek-title">{{ peekNode.title }}</h2>

          <div v-if="peekNode.tags?.length" class="peek-tags">
            <span v-for="tag in peekNode.tags" :key="tag" class="tag-chip">#{{ tag }}</span>
          </div>

          <div class="peek-body-wrap">
            <div v-if="peekLoading" class="peek-loading">Loading...</div>
            <div
              v-else-if="peekBody"
              class="prose peek-prose"
              v-html="renderMarkdown(peekBody)"
            />
            <div v-else class="peek-empty">No content.</div>
          </div>

          <div v-if="peekLinkedNodes.length" class="peek-linked">
            <div class="peek-linked-label">Linked notes</div>
            <ul class="peek-linked-list">
              <li
                v-for="n in peekLinkedNodes"
                :key="n.id"
                class="peek-linked-item"
                @click="openPeek(n)"
              >
                <span :class="['peek-linked-type', `peek-type-${n.type}`]">{{ n.type[0].toUpperCase() }}</span>
                {{ n.title }}
              </li>
            </ul>
          </div>
        </div>
      </Transition>

      <div
        v-if="tooltip.visible && tooltip.node"
        class="graph-tooltip"
        :style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
      >
        <div class="tooltip-title">{{ tooltip.node.type === 'tag' ? '#' + tooltip.node.title : tooltip.node.title }}</div>
        <div v-if="tooltip.node.tags.length" class="tooltip-tags">
          <span v-for="tag in tooltip.node.tags" :key="tag" class="tag-chip">{{ tag }}</span>
        </div>
        <div class="tooltip-meta">{{ tooltip.node.type === 'tag' ? 'tag' : tooltip.node.type }}</div>
      </div>
    </div>
  </main>
</template>

<style scoped>
.graph-page {
  display: flex;
  flex-direction: column;
  height: calc(100vh - var(--fs-layout-header));
  overflow: hidden;
}

.graph-controls {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.6rem 1rem;
  background: var(--fs-surface-raised);
  border-bottom: 1px solid var(--fs-border-color);
  flex-shrink: 0;
  flex-wrap: wrap;
}

.graph-select {
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  color: var(--fs-text-primary);
  font-size: 0.875rem;
  padding: 0.3rem 0.6rem;
  cursor: pointer;
  font-family: inherit;
}

.graph-toggle {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.875rem;
  color: var(--fs-text-secondary);
  cursor: pointer;
  user-select: none;
}

.graph-toggle input {
  cursor: pointer;
  accent-color: var(--fs-accent);
}

.graph-stats {
  margin-left: auto;
  font-size: 0.8rem;
  color: var(--fs-text-tertiary);
}

.graph-physics-btn {
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  color: var(--fs-text-secondary);
  font-size: 0.875rem;
  font-family: inherit;
  padding: 0.3rem 0.7rem;
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}
.graph-physics-btn:hover {
  border-color: var(--fs-accent);
  color: var(--fs-text-primary);
}
.graph-physics-btn.active {
  border-color: var(--fs-accent);
  color: var(--fs-accent);
}

.graph-physics-panel {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  padding: 0.6rem 1rem;
  background: var(--fs-surface-raised);
  border-bottom: 1px solid var(--fs-border-color);
  flex-shrink: 0;
}

.physics-knob {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  min-width: 140px;
  flex: 1;
  max-width: 220px;
}

.knob-label {
  font-size: 0.75rem;
  color: var(--fs-text-secondary);
  display: flex;
  justify-content: space-between;
}

.knob-label em {
  font-style: normal;
  color: var(--fs-text-tertiary);
  font-variant-numeric: tabular-nums;
}

.physics-knob input[type="range"] {
  width: 100%;
  accent-color: var(--fs-accent);
  cursor: pointer;
}

.graph-wrap {
  flex: 1;
  position: relative;
  overflow: hidden;
  background: var(--fs-surface-page);
}

.graph-svg {
  width: 100%;
  height: 100%;
  display: block;
}

.graph-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

.graph-empty p {
  color: var(--fs-text-tertiary);
  font-size: 0.95rem;
}

.spinner {
  width: 28px;
  height: 28px;
  border: 3px solid var(--fs-border-color);
  border-top-color: var(--fs-accent);
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.graph-tooltip {
  position: absolute;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  box-shadow: 0 4px 16px var(--color-shadow);
  padding: 0.5rem 0.75rem;
  pointer-events: none;
  z-index: 10;
  max-width: 240px;
}

.tooltip-title {
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--fs-text-primary);
  margin-bottom: 0.25rem;
}

.tooltip-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  margin-bottom: 0.25rem;
}

.tag-chip {
  font-size: 0.7rem;
  background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
  color: var(--fs-accent);
  border-radius: 999px;
  padding: 0.1rem 0.4rem;
}

.tooltip-meta {
  font-size: 0.75rem;
  color: var(--fs-text-tertiary);
  text-transform: capitalize;
}

/* ── Peek panel ── */
.graph-peek-panel {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 340px;
  background: var(--fs-surface-raised);
  border-left: 1px solid var(--fs-border-color);
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  z-index: 20;
}

.peek-slide-enter-active,
.peek-slide-leave-active {
  transition: transform 0.2s ease;
}
.peek-slide-enter-from,
.peek-slide-leave-to {
  transform: translateX(100%);
}

.peek-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.6rem 0.75rem;
  border-bottom: 1px solid var(--fs-border-color);
  flex-shrink: 0;
}

.peek-type-badge {
  font-size: 0.68rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.15rem 0.45rem;
  border-radius: 10px;
  border: 1px solid var(--fs-border-color);
  color: var(--fs-text-tertiary);
}
.peek-type-task { border-color: var(--fs-accent); color: var(--fs-accent); }

.peek-actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}

.peek-link {
  font-size: 0.78rem;
  color: var(--fs-accent);
  text-decoration: none;
}
.peek-link:hover { text-decoration: underline; }

.peek-close {
  background: none;
  border: none;
  color: var(--fs-text-tertiary);
  font-size: 0.8rem;
  cursor: pointer;
  padding: 0.1rem 0.25rem;
  border-radius: 3px;
}
.peek-close:hover { color: var(--fs-text-primary); }

.peek-title {
  margin: 0;
  padding: 0.75rem 0.75rem 0.4rem;
  font-size: 1rem;
  font-weight: 500;
  color: var(--fs-text-primary);
  flex-shrink: 0;
}

.peek-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  padding: 0 0.75rem 0.5rem;
  flex-shrink: 0;
}

.peek-body-wrap {
  flex: 1;
  overflow-y: auto;
  padding: 0 0.75rem 0.5rem;
  border-bottom: 1px solid var(--fs-border-color);
}

.peek-prose {
  font-size: 0.85rem;
}

.peek-loading,
.peek-empty {
  font-size: 0.82rem;
  color: var(--fs-text-tertiary);
  padding-top: 0.5rem;
}

.peek-linked {
  flex-shrink: 0;
  padding: 0.5rem 0.75rem 0.75rem;
}

.peek-linked-label {
  font-size: 0.7rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--fs-text-tertiary);
  margin-bottom: 0.4rem;
}

.peek-linked-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  max-height: 160px;
  overflow-y: auto;
}

.peek-linked-item {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.82rem;
  color: var(--fs-text-primary);
  cursor: pointer;
  padding: 0.25rem 0.4rem;
  border-radius: 4px;
}
.peek-linked-item:hover {
  background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
  color: var(--fs-accent);
}

.peek-linked-type {
  font-size: 0.65rem;
  font-weight: 500;
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  background: var(--fs-surface-page);
  border: 1px solid var(--fs-border-color);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  color: var(--fs-text-tertiary);
}
</style>
