import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type MouseEvent as ReactMouseEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
  AlertCircle,
  ChevronDown,
  ChevronRight,
  CircleDot,
  Eye,
  EyeOff,
  GitBranch,
  Keyboard,
  Layers,
  Map as MapIcon,
  Network,
  RefreshCw,
} from "lucide-react";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Drawer } from "@/components/ui/drawer";
import { EmptyState } from "@/components/ui/empty-state";
import { Tooltip } from "@/components/ui/tooltip";
import {
  FilterPanel,
  FilterSearch,
  FilterSelect,
  FilterRange,
  FilterToggle,
  FilterChips,
  type ActiveChip,
} from "@/components/ui/filter-panel";
import {
  ShortcutsOverlay,
  type ShortcutGroup,
} from "@/components/ui/shortcuts-overlay";
import { PageHeader } from "@/components/layout/PageHeader";
import { useGraphNeighborhood } from "@/hooks/usePlatform";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useProject } from "@/hooks/useProjects";
import { GraphEdge, GraphNode } from "@/lib/api/platform";
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";

const COLORS = [
  "#2563eb",
  "#059669",
  "#d97706",
  "#7c3aed",
  "#db2777",
  "#0891b2",
  "#4b5563",
  "#dc2626",
];

const VIEW_W = 860;
const VIEW_H = 600;
const GRAPH_PADDING = 64;
const CLUSTER_CELL_W = 220;
const CLUSTER_CELL_H = 176;
const NODE_MIN_R = 8;
const NODE_MAX_R = 16;
const LITERAL_MIN_R = 6;
const LITERAL_MAX_R = 11;

interface GraphCanvas {
  width: number;
  height: number;
}

interface VisualNode {
  id: string;
  name: string;
  type: string;
  x: number;
  y: number;
  radius: number;
  color: string;
  source: "entity" | "literal";
  isHub?: boolean;
  raw?: GraphNode;
}

interface VisualEdge {
  id: string;
  source: string;
  target: string;
  predicate: string;
  confidence: number;
  raw: GraphEdge;
}

function nodeColor(type: string, types: string[]): string {
  const index = Math.max(types.indexOf(type), 0);
  return COLORS[index % COLORS.length];
}

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

function hashToUnit(input: string) {
  let hash = 2166136261;
  for (let i = 0; i < input.length; i += 1) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  return (hash >>> 0) / 4294967295;
}

function predicateAngle(predicate: string) {
  const normalized = predicate.toLowerCase();
  if (normalized.includes("brand")) return -Math.PI / 6;
  if (normalized.includes("price") || normalized.includes("amount")) {
    return Math.PI / 2;
  }
  if (normalized.includes("note") || normalized.includes("description")) {
    return Math.PI;
  }
  if (normalized.includes("occasion") || normalized.includes("tag")) {
    return -Math.PI / 2;
  }
  return hashToUnit(predicate) * Math.PI * 2;
}

function layoutGraph(nodes: VisualNode[], edges: VisualEdge[]): GraphCanvas {
  const count = nodes.length;
  if (!count) return { width: VIEW_W, height: VIEW_H };
  nodes.forEach((node) => {
    node.isHub = false;
  });
  if (count === 1) {
    const centerX = VIEW_W / 2;
    const centerY = VIEW_H / 2;
    nodes[0].x = centerX;
    nodes[0].y = centerY;
    return { width: VIEW_W, height: VIEW_H };
  }

  const nodeById = new Map(nodes.map((node) => [node.id, node]));
  const outgoing = new Map<string, VisualEdge[]>();
  const incoming = new Map<string, VisualEdge[]>();
  const degree = new Map<string, number>();
  edges.forEach((edge) => {
    outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]);
    incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge]);
    degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
    degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
  });

  let centers = nodes.filter(
    (node) => node.source === "entity" && (outgoing.get(node.id)?.length ?? 0) > 0,
  );
  if (!centers.length) {
    centers = [...nodes]
      .sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0))
      .slice(0, Math.max(1, Math.ceil(Math.sqrt(count))));
  }
  centers.sort((a, b) => {
    const scoreA = (outgoing.get(a.id)?.length ?? 0) * 3 + (degree.get(a.id) ?? 0);
    const scoreB = (outgoing.get(b.id)?.length ?? 0) * 3 + (degree.get(b.id) ?? 0);
    return scoreB - scoreA || a.name.localeCompare(b.name);
  });

  const centerIds = new Set(centers.map((node) => node.id));
  centers.forEach((node) => {
    node.isHub = true;
    node.radius = clamp(node.radius + 2, NODE_MIN_R + 3, NODE_MAX_R + 2);
  });

  const columns = centers.length <= 2 ? centers.length : Math.ceil(Math.sqrt(centers.length * 1.35));
  const rows = Math.ceil(centers.length / columns);
  const canvasWidth = Math.max(
    VIEW_W,
    GRAPH_PADDING * 2 + columns * CLUSTER_CELL_W,
  );
  const canvasHeight = Math.max(
    VIEW_H,
    GRAPH_PADDING * 2 + rows * CLUSTER_CELL_H,
  );
  const centerX = canvasWidth / 2;
  const centerY = canvasHeight / 2;
  const width = canvasWidth - GRAPH_PADDING * 2;
  const height = canvasHeight - GRAPH_PADDING * 2;
  const cellW = width / Math.max(columns, 1);
  const cellH = height / Math.max(rows, 1);
  const anchors = new Map<string, { x: number; y: number; strength: number }>();

  centers.forEach((node, index) => {
    const row = Math.floor(index / columns);
    const col = index % columns;
    node.x = GRAPH_PADDING + cellW * (col + 0.5);
    node.y = GRAPH_PADDING + cellH * (row + 0.5);
    if (rows === 1) {
      node.y = centerY;
    }
    anchors.set(node.id, { x: node.x, y: node.y, strength: 0.55 });
  });

  centers.forEach((center) => {
    const childEdges = (outgoing.get(center.id) ?? [])
      .filter((edge) => !centerIds.has(edge.target) && nodeById.has(edge.target))
      .sort((a, b) => {
        const angleA = predicateAngle(a.predicate);
        const angleB = predicateAngle(b.predicate);
        return angleA - angleB || a.predicate.localeCompare(b.predicate);
      });
    const groups = new Map<string, VisualEdge[]>();
    childEdges.forEach((edge) => {
      groups.set(edge.predicate, [...(groups.get(edge.predicate) ?? []), edge]);
    });

    Array.from(groups.entries()).forEach(([predicate, group]) => {
      const baseAngle = predicateAngle(predicate);
      const spread = Math.min(0.78, 0.18 * Math.max(group.length - 1, 0));
      group.forEach((edge, index) => {
        const child = nodeById.get(edge.target);
        if (!child) return;
        const parentCount = (incoming.get(child.id) ?? []).filter((incomingEdge) =>
          centerIds.has(incomingEdge.source),
        ).length;
        if (parentCount > 1) return;
        const offset =
          group.length === 1
            ? 0
            : -spread / 2 + (spread * index) / Math.max(group.length - 1, 1);
        const distance =
          76 +
          Math.min(childEdges.length, 8) * 3 +
          hashToUnit(`${center.id}:${child.id}`) * 20;
        const x = center.x + Math.cos(baseAngle + offset) * distance;
        const y = center.y + Math.sin(baseAngle + offset) * distance;
        child.x = x;
        child.y = y;
        anchors.set(child.id, { x, y, strength: 0.4 });
      });
    });
  });

  nodes
    .filter((node) => !centerIds.has(node.id))
    .forEach((node) => {
      if (anchors.has(node.id)) return;
      const parentEdges = (incoming.get(node.id) ?? []).filter((edge) =>
        centerIds.has(edge.source),
      );
      if (parentEdges.length) {
        const parents = parentEdges
          .map((edge) => nodeById.get(edge.source))
          .filter((parent): parent is VisualNode => Boolean(parent));
        const avgX =
          parents.reduce((sum, parent) => sum + parent.x, 0) / parents.length;
        const avgY =
          parents.reduce((sum, parent) => sum + parent.y, 0) / parents.length;
        const isSharedTarget = parentEdges.length > 1;
        const angle = predicateAngle(parentEdges[0].predicate);
        const x = isSharedTarget ? avgX : avgX + Math.cos(angle) * 58;
        const y = isSharedTarget ? avgY : avgY + Math.sin(angle) * 58;
        node.x = x;
        node.y = y;
        anchors.set(node.id, { x, y, strength: isSharedTarget ? 0.46 : 0.32 });
        return;
      }

      const relatedEdges = edges.filter(
        (edge) => edge.source === node.id || edge.target === node.id,
      );
      const related = relatedEdges
        .map((edge) =>
          edge.source === node.id
            ? nodeById.get(edge.target)
            : nodeById.get(edge.source),
        )
        .filter((relatedNode): relatedNode is VisualNode => Boolean(relatedNode));
      if (related.length) {
        const avgX =
          related.reduce((sum, relatedNode) => sum + relatedNode.x, 0) /
          related.length;
        const avgY =
          related.reduce((sum, relatedNode) => sum + relatedNode.y, 0) /
          related.length;
        const angle = hashToUnit(node.id) * Math.PI * 2;
        node.x = avgX + Math.cos(angle) * 64;
        node.y = avgY + Math.sin(angle) * 64;
      } else {
        const angle = hashToUnit(node.id) * Math.PI * 2;
        node.x = centerX + Math.cos(angle) * 120;
        node.y = centerY + Math.sin(angle) * 120;
      }
      anchors.set(node.id, { x: node.x, y: node.y, strength: 0.24 });
    });

  for (let tick = 0; tick < 80; tick += 1) {
    nodes.forEach((node) => {
      const anchor = anchors.get(node.id);
      if (!anchor) return;
      node.x += (anchor.x - node.x) * anchor.strength * 0.12;
      node.y += (anchor.y - node.y) * anchor.strength * 0.12;
    });

    for (let i = 0; i < count; i += 1) {
      const a = nodes[i];
      for (let j = i + 1; j < count; j += 1) {
        const b = nodes[j];
        let dx = a.x - b.x;
        let dy = a.y - b.y;
        let distance = Math.sqrt(dx * dx + dy * dy);
        if (distance < 0.01) {
          dx = hashToUnit(`${a.id}:${b.id}:x`) - 0.5;
          dy = hashToUnit(`${a.id}:${b.id}:y`) - 0.5;
          distance = Math.sqrt(dx * dx + dy * dy);
        }
        const minDistance = a.radius + b.radius + 22;
        if (distance >= minDistance) continue;
        const force = ((minDistance - distance) / distance) * 0.32;
        const ax = dx * force;
        const ay = dy * force;
        const aAnchor = anchors.get(a.id)?.strength ?? 0.2;
        const bAnchor = anchors.get(b.id)?.strength ?? 0.2;
        a.x += ax * (1 - aAnchor);
        a.y += ay * (1 - aAnchor);
        b.x -= ax * (1 - bAnchor);
        b.y -= ay * (1 - bAnchor);
      }
    }

    nodes.forEach((node) => {
      node.x = clamp(node.x, GRAPH_PADDING, canvasWidth - GRAPH_PADDING);
      node.y = clamp(node.y, GRAPH_PADDING, canvasHeight - GRAPH_PADDING);
    });
  }
  return { width: canvasWidth, height: canvasHeight };
}

function buildGraph(
  nodes: GraphNode[],
  edges: GraphEdge[],
  options: {
    search: string;
    predicate: string;
    entityType: string;
    minConfidence: number;
  },
) {
  const search = options.search.trim().toLowerCase();
  const types = Array.from(
    new Set(nodes.map((node) => node.type || "Entity").concat("Literal")),
  ).sort();
  const nodeMap = new Map<string, VisualNode>();
  nodes.forEach((node) => {
    const type = node.type || "Entity";
    nodeMap.set(String(node.id), {
      id: String(node.id),
      name: node.name,
      type,
      x: 0,
      y: 0,
      radius: 18,
      color: nodeColor(type, types),
      source: "entity",
      raw: node,
    });
  });

  const visualEdges: VisualEdge[] = [];
  edges.forEach((edge) => {
    const confidence = edge.confidence ?? 0;
    if (confidence < options.minConfidence) return;
    if (options.predicate && edge.predicate !== options.predicate) return;
    const source = String(edge.source);
    const target =
      edge.target !== null && edge.target !== undefined
        ? String(edge.target)
        : `literal-${edge.claim_id}`;
    if (!nodeMap.has(source)) return;
    if (!nodeMap.has(target)) {
      const label = humanizeValue(edge.target_value ?? edge.object?.name);
      nodeMap.set(target, {
        id: target,
        name: label || "(empty value)",
        type: "Literal",
        x: 0,
        y: 0,
        radius: 14,
        color: nodeColor("Literal", types),
        source: "literal",
      });
    }
    visualEdges.push({
      id: String(edge.claim_id),
      source,
      target,
      predicate: edge.predicate,
      confidence,
      raw: edge,
    });
  });

  let visualNodes = Array.from(nodeMap.values()).filter((node) => {
    if (options.entityType && node.type !== options.entityType) return false;
    if (!search) return true;
    return (
      node.name.toLowerCase().includes(search) ||
      node.type.toLowerCase().includes(search)
    );
  });
  const visibleIds = new Set(visualNodes.map((node) => node.id));
  const filteredEdges = visualEdges.filter(
    (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
  );
  const connectedIds = new Set<string>();
  filteredEdges.forEach((edge) => {
    connectedIds.add(edge.source);
    connectedIds.add(edge.target);
  });
  if (options.predicate || options.minConfidence > 0 || search) {
    visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
  }

  const degree = new Map<string, number>();
  filteredEdges.forEach((edge) => {
    degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
    degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
  });
  visualNodes.forEach((node) => {
    const links = degree.get(node.id) ?? 0;
    const base =
      node.source === "literal"
        ? clamp(LITERAL_MIN_R + Math.sqrt(links) * 1.4, LITERAL_MIN_R, LITERAL_MAX_R)
        : clamp(NODE_MIN_R + Math.sqrt(links) * 1.8, NODE_MIN_R, NODE_MAX_R);
    node.radius = base;
  });
  const canvas = layoutGraph(visualNodes, filteredEdges);
  return { nodes: visualNodes, edges: filteredEdges, types, canvas };
}

function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
  const source = nodes.find((node) => node.id === edge.source);
  const target = nodes.find((node) => node.id === edge.target);
  return { source, target };
}

function edgeLinePoints(source: VisualNode, target: VisualNode) {
  const dx = target.x - source.x;
  const dy = target.y - source.y;
  const distance = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
  const nx = dx / distance;
  const ny = dy / distance;
  const x1 = source.x + nx * (source.radius + 2);
  const y1 = source.y + ny * (source.radius + 2);
  const x2 = target.x - nx * (target.radius + 7);
  const y2 = target.y - ny * (target.radius + 7);
  return {
    x1,
    y1,
    x2,
    y2,
    midX: (x1 + x2) / 2,
    midY: (y1 + y2) / 2,
  };
}

export default function GraphViewPage() {
  const navigate = useNavigate();
  const { projectId } = useParams<{ projectId: string }>();
  const projectName = projectId ?? "";
  const { data: project } = useProject(projectName);
  const [statusScope, setStatusScope] = useState("validated_claim");
  const [includeCandidates, setIncludeCandidates] = useState(true);
  const [search, setSearch] = useState("");
  const [predicate, setPredicate] = useState("");
  const [entityType, setEntityType] = useState("");
  const [minConfidence, setMinConfidence] = useState(0);
  const [selected, setSelected] = useState<VisualNode | VisualEdge | null>(null);
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
  const [showLegend, setShowLegend] = useState(true);
  const [legendCollapsed, setLegendCollapsed] = useState(true);
  const [showMinimap, setShowMinimap] = useState(true);
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
  const canvasRef = useRef<HTMLDivElement | null>(null);
  const panRef = useRef({
    active: false,
    x: 0,
    y: 0,
    scrollLeft: 0,
    scrollTop: 0,
  });

  const graph = useGraphNeighborhood(projectName, {
    includeCandidates: includeCandidates && statusScope === "validated_claim",
    limit: 300,
    status:
      includeCandidates && statusScope === "validated_claim"
        ? undefined
        : statusScope,
  });

  const predicates = useMemo(
    () =>
      Array.from(
        new Set((graph.data?.edges ?? []).map((edge) => edge.predicate)),
      ).sort(),
    [graph.data?.edges],
  );
  const visual = useMemo(
    () =>
      buildGraph(graph.data?.nodes ?? [], graph.data?.edges ?? [], {
        search,
        predicate,
        entityType,
        minConfidence,
      }),
    [
      entityType,
      graph.data?.edges,
      graph.data?.nodes,
      minConfidence,
      predicate,
      search,
    ],
  );

  // Filter out types the user toggled off in the legend
  const visibleNodes = useMemo(
    () => visual.nodes.filter((n) => !hiddenTypes.has(n.type)),
    [visual.nodes, hiddenTypes],
  );
  const visibleNodeIds = useMemo(
    () => new Set(visibleNodes.map((n) => n.id)),
    [visibleNodes],
  );
  const visibleEdges = useMemo(
    () =>
      visual.edges.filter(
        (e) => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target),
      ),
    [visual.edges, visibleNodeIds],
  );
  const typeCounts = useMemo(() => {
    const counts = new Map<string, number>();
    visual.nodes.forEach((n) =>
      counts.set(n.type, (counts.get(n.type) ?? 0) + 1),
    );
    return counts;
  }, [visual.nodes]);

  const averageConfidence = visibleEdges.length
    ? visibleEdges.reduce((sum, edge) => sum + edge.confidence, 0) /
      visibleEdges.length
    : 0;
  const showEdgeLabels = visibleEdges.length <= 70;
  const showNodeLabels = visibleNodes.length <= 180;

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || graph.isLoading || visibleNodes.length === 0) return;
    window.requestAnimationFrame(() => {
      canvas.scrollLeft = Math.max(0, (canvas.scrollWidth - canvas.clientWidth) / 2);
      canvas.scrollTop = Math.max(0, (canvas.scrollHeight - canvas.clientHeight) / 2);
    });
  }, [
    graph.isLoading,
    projectName,
    visibleNodes.length,
    visual.canvas.height,
    visual.canvas.width,
  ]);

  const startCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    const target = event.target as HTMLElement;
    if (target.closest("[data-graph-node], [data-graph-edge]")) return;
    panRef.current = {
      active: true,
      x: event.clientX,
      y: event.clientY,
      scrollLeft: event.currentTarget.scrollLeft,
      scrollTop: event.currentTarget.scrollTop,
    };
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const moveCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!panRef.current.active) return;
    event.currentTarget.scrollLeft =
      panRef.current.scrollLeft - (event.clientX - panRef.current.x);
    event.currentTarget.scrollTop =
      panRef.current.scrollTop - (event.clientY - panRef.current.y);
    event.preventDefault();
  };

  const endCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!panRef.current.active) return;
    panRef.current.active = false;
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
  };

  const jumpCanvasTo = (x: number, y: number) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.scrollTo({
      left: clamp(
        x - canvas.clientWidth / 2,
        0,
        Math.max(0, canvas.scrollWidth - canvas.clientWidth),
      ),
      top: clamp(
        y - canvas.clientHeight / 2,
        0,
        Math.max(0, canvas.scrollHeight - canvas.clientHeight),
      ),
      behavior: "smooth",
    });
  };

  const selectNode = (node: VisualNode | VisualEdge) => {
    setSelected(node);
    setDrawerOpen(true);
  };

  const toggleType = (type: string) => {
    setHiddenTypes((prev) => {
      const next = new Set(prev);
      if (next.has(type)) next.delete(type);
      else next.add(type);
      return next;
    });
  };

  // Page-level shortcuts (disabled while drawer or overlay is open)
  useKeyboardShortcuts(
    {
      l: () => setShowLegend((v) => !v),
      m: () => setShowMinimap((v) => !v),
      r: () => graph.refetch(),
      "Shift+?": () => setShortcutsOpen(true),
      Escape: () => {
        if (drawerOpen) {
          setDrawerOpen(false);
          setSelected(null);
        }
      },
    },
    { disabled: shortcutsOpen },
  );

  const pageShortcutGroups: ShortcutGroup[] = [
    {
      title: "Graph View",
      items: [
        { keys: "l", label: "Toggle legend" },
        { keys: "m", label: "Toggle minimap" },
        { keys: "r", label: "Refresh graph" },
        { keys: "Escape", label: "Close detail drawer" },
      ],
    },
    {
      title: "Global",
      items: [
        { keys: "Mod+K", label: "Command palette" },
        { keys: "Shift+?", label: "Show this panel" },
        { keys: "g g", label: "Jump to Graph View" },
      ],
    },
  ];

  return (
    <>
      <PageHeader
        icon={Network}
        breadcrumbs={[
          { label: "Dashboard", to: "/" },
          { label: projectName, to: `/sources/${projectName}` },
          { label: "Graph View" },
        ]}
        title="Graph View"
        description={`${project?.name ?? projectName} — relation graph, candidates, and edge evidence.`}
        actions={
          <Tooltip content="Refresh graph data" shortcut="R">
            <Button variant="outline" onClick={() => graph.refetch()}>
              <RefreshCw className="h-4 w-4" />
              Refresh
            </Button>
          </Tooltip>
        }
      />

      <div className="mx-auto max-w-7xl px-6 py-6">
        {graph.isError && (
          <div className="mb-4 flex items-center gap-2 rounded-md border border-danger-border bg-danger-subtle px-4 py-3 text-sm text-danger">
            <AlertCircle className="h-4 w-4" />
            {(graph.error as Error).message}
          </div>
        )}

        {/* Filter bar — unified FilterPanel */}
        {(() => {
          const statusLabels: Record<string, string> = {
            validated_claim: "Validated",
            candidate_claim: "Candidate",
            rule_candidate: "Rule candidate",
            active: "Active",
            all: "All statuses",
          };
          const chips: ActiveChip[] = [];
          if (search)
            chips.push({
              id: "search",
              label: "Search",
              value: `"${search}"`,
              onClear: () => setSearch(""),
            });
          if (entityType)
            chips.push({
              id: "type",
              label: "Type",
              value: entityType,
              onClear: () => setEntityType(""),
            });
          if (predicate)
            chips.push({
              id: "predicate",
              label: "Predicate",
              value: predicate,
              onClear: () => setPredicate(""),
            });
          if (statusScope !== "validated_claim")
            chips.push({
              id: "status",
              label: "Status",
              value: statusLabels[statusScope] ?? statusScope,
              onClear: () => setStatusScope("validated_claim"),
            });
          if (minConfidence > 0)
            chips.push({
              id: "conf",
              label: "Min confidence",
              value: formatPercent(minConfidence),
              onClear: () => setMinConfidence(0),
            });
          if (!includeCandidates && statusScope === "validated_claim")
            chips.push({
              id: "blend",
              label: "Blend candidates",
              value: "off",
              onClear: () => setIncludeCandidates(true),
            });

          const clearAll = () => {
            setSearch("");
            setEntityType("");
            setPredicate("");
            setStatusScope("validated_claim");
            setMinConfidence(0);
            setIncludeCandidates(true);
          };

          return (
            <FilterPanel
              className="mb-4"
              chips={chips.length > 0 ? <FilterChips chips={chips} /> : undefined}
              onClearAll={chips.length > 0 ? clearAll : undefined}
            >
              <FilterSearch
                value={search}
                onChange={setSearch}
                placeholder="Search node or type…"
              />
              <FilterSelect
                label="Type"
                value={entityType}
                onChange={setEntityType}
                placeholder="All types"
                options={visual.types.map((t) => ({
                  value: t,
                  label: t,
                  hint: typeCounts.get(t),
                }))}
              />
              <FilterSelect
                label="Predicate"
                value={predicate}
                onChange={setPredicate}
                placeholder="All predicates"
                options={predicates.map((p) => ({ value: p, label: p }))}
              />
              <FilterSelect
                label="Status"
                value={statusScope === "validated_claim" ? "" : statusScope}
                onChange={(v) => setStatusScope(v || "validated_claim")}
                placeholder="Validated"
                allowEmpty
                options={Object.entries(statusLabels)
                  .filter(([k]) => k !== "validated_claim")
                  .map(([k, v]) => ({ value: k, label: v }))}
              />
              <FilterRange
                label="Min confidence"
                value={minConfidence}
                onChange={setMinConfidence}
                format={(v) => `${Math.round(v * 100)}%`}
              />
              <FilterToggle
                label="Blend candidates"
                checked={includeCandidates}
                onChange={setIncludeCandidates}
                disabled={statusScope !== "validated_claim"}
              />
            </FilterPanel>
          );
        })()}

        {/* Canvas */}
        <div className="relative mb-6 overflow-hidden rounded-lg border border-border bg-surface">
          <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
            <div className="text-sm">
              <span className="font-medium text-foreground">
                Ontology Network
              </span>
              <span className="ml-2 text-muted-foreground">
                {visibleNodes.length} nodes / {visibleEdges.length} edges shown
              </span>
            </div>
            <div className="flex items-center gap-1">
              <Tooltip
                content={showLegend ? "Hide legend" : "Show legend"}
                shortcut="L"
              >
                <Button
                  variant="ghost"
                  size="icon"
                  aria-label="Toggle legend"
                  onClick={() => setShowLegend((v) => !v)}
                >
                  <Layers className="h-4 w-4" />
                </Button>
              </Tooltip>
              <Tooltip
                content={showMinimap ? "Hide minimap" : "Show minimap"}
                shortcut="M"
              >
                <Button
                  variant="ghost"
                  size="icon"
                  aria-label="Toggle minimap"
                  onClick={() => setShowMinimap((v) => !v)}
                >
                  <MapIcon className="h-4 w-4" />
                </Button>
              </Tooltip>
              <Tooltip content="Show keyboard shortcuts" shortcut="?">
                <Button
                  variant="ghost"
                  size="icon"
                  aria-label="Keyboard shortcuts"
                  onClick={() => setShortcutsOpen(true)}
                >
                  <Keyboard className="h-4 w-4" />
                </Button>
              </Tooltip>
            </div>
          </div>

          <div className="relative">
            {graph.isLoading ? (
              <Skeleton className="h-[640px]" />
            ) : visibleNodes.length === 0 ? (
              <EmptyState
                icon={GitBranch}
                title="No graph to show"
                description="No nodes match the current filters."
                variant="muted"
                compact
                className="h-[500px]"
              />
            ) : (
              <>
                <div
                  ref={canvasRef}
                  className="relative h-[640px] cursor-grab overflow-auto bg-background-subtle active:cursor-grabbing"
                  onPointerDown={startCanvasPan}
                  onPointerMove={moveCanvasPan}
                  onPointerUp={endCanvasPan}
                  onPointerCancel={endCanvasPan}
                >
                  <svg
                    viewBox={`0 0 ${visual.canvas.width} ${visual.canvas.height}`}
                    width={visual.canvas.width}
                    height={visual.canvas.height}
                    className="block min-h-full min-w-full text-muted-foreground"
                    role="img"
                    aria-label="Ontology graph"
                >
                  <defs>
                    <marker
                      id="arrow"
                      markerWidth="6"
                      markerHeight="6"
                      refX="5.5"
                      refY="2.5"
                      orient="auto"
                      markerUnits="strokeWidth"
                    >
                      <path d="M0,0 L0,5 L5.5,2.5 z" fill="currentColor" />
                    </marker>
                  </defs>
                  {visibleEdges.map((edge) => {
                    const { source, target } = edgeEndpoint(edge, visibleNodes);
                    if (!source || !target) return null;
                    const { x1, y1, x2, y2, midX, midY } = edgeLinePoints(
                      source,
                      target,
                    );
                    const isSelected =
                      selected && "predicate" in selected && selected.id === edge.id;
                    return (
                      <g
                        key={edge.id}
                        data-graph-edge
                        className="cursor-pointer"
                        onClick={() => selectNode(edge)}
                      >
                        <line
                          x1={x1}
                          y1={y1}
                          x2={x2}
                          y2={y2}
                          stroke="currentColor"
                          strokeWidth={isSelected ? 1.7 : 0.65 + edge.confidence * 0.75}
                          strokeOpacity={isSelected ? 0.95 : 0.42}
                          markerEnd="url(#arrow)"
                          className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
                        />
                        {(showEdgeLabels || isSelected) && (
                          <text
                            x={midX}
                            y={midY - 2}
                            textAnchor="middle"
                            className="fill-muted-foreground text-[7px]"
                            opacity={isSelected ? 0.95 : 0.62}
                            paintOrder="stroke"
                            stroke="hsl(var(--background))"
                            strokeWidth="2"
                            strokeLinejoin="round"
                          >
                            {edge.predicate.length > 18
                              ? `${edge.predicate.slice(0, 18)}...`
                              : edge.predicate}
                          </text>
                        )}
                      </g>
                    );
                  })}
                  {visibleNodes.map((node) => {
                    const isSelected =
                      selected && "id" in selected && selected.id === node.id;
                    return (
                      <g
                        key={node.id}
                        data-graph-node
                        className="cursor-pointer"
                        onClick={() => selectNode(node)}
                      >
                        <circle
                          cx={node.x}
                          cy={node.y}
                          r={node.radius}
                          fill={node.color}
                          stroke={
                            isSelected
                              ? "hsl(var(--foreground))"
                              : node.isHub
                                ? "hsl(var(--muted-foreground))"
                                : "hsl(var(--background))"
                          }
                          strokeWidth={isSelected ? 2.5 : node.isHub ? 2 : 1.3}
                        />
                        {(showNodeLabels || isSelected) && (
                          <text
                            x={node.x}
                            y={node.y + node.radius + 10}
                            textAnchor="middle"
                            className={
                              node.isHub
                                ? "fill-foreground text-[8px] font-semibold"
                                : "fill-foreground text-[8px] font-medium"
                            }
                            opacity={node.source === "literal" ? 0.74 : 0.86}
                            paintOrder="stroke"
                            stroke="hsl(var(--background))"
                            strokeWidth="2"
                            strokeLinejoin="round"
                          >
                            {node.name.length > 18
                              ? `${node.name.slice(0, 18)}...`
                              : node.name}
                          </text>
                        )}
                      </g>
                    );
                  })}
                  </svg>
                </div>

                {/* Legend overlay (top-right) */}
                {showLegend && (
                  <LegendPanel
                    types={visual.types}
                    typeCounts={typeCounts}
                    hiddenTypes={hiddenTypes}
                    onToggle={toggleType}
                    collapsed={legendCollapsed}
                    onCollapsedChange={setLegendCollapsed}
                  />
                )}

                {/* Minimap overlay (bottom-right) */}
                {showMinimap && visibleNodes.length > 0 && (
                  <Minimap
                    nodes={visibleNodes}
                    edges={visibleEdges}
                    onJump={jumpCanvasTo}
                  />
                )}
              </>
            )}
          </div>
        </div>

        {/* Bottom stats row */}
        <div className="grid gap-4 lg:grid-cols-2">
          <Card>
            <CardHeader>
              <CardTitle className="text-sm">Graph Health</CardTitle>
            </CardHeader>
            <CardContent className="space-y-3">
              <HealthRow label="Visible nodes" value={visibleNodes.length} />
              <HealthRow label="Visible edges" value={visibleEdges.length} />
              <HealthRow
                label="Avg confidence"
                value={
                  visibleEdges.length ? formatPercent(averageConfidence) : "—"
                }
              />
              <Progress value={averageConfidence * 100} />
            </CardContent>
          </Card>

          <Card>
            <CardHeader>
              <CardTitle className="flex items-center gap-2 text-sm">
                <CircleDot className="h-4 w-4" />
                Predicate Counts
              </CardTitle>
            </CardHeader>
            <CardContent>
              <ul className="grid gap-1.5 sm:grid-cols-2">
                {predicates.map((name) => {
                  const count = (graph.data?.edges ?? []).filter(
                    (edge) => edge.predicate === name,
                  ).length;
                  return (
                    <li
                      key={name}
                      className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
                    >
                      <span className="truncate">{name}</span>
                      <span className="font-medium tabular-nums">{count}</span>
                    </li>
                  );
                })}
                {predicates.length === 0 && (
                  <li className="col-span-full py-4 text-center text-sm text-muted-foreground">
                    No predicates found.
                  </li>
                )}
              </ul>
            </CardContent>
          </Card>
        </div>
      </div>

      {/* Node / Edge detail drawer */}
      <Drawer
        open={drawerOpen && !!selected}
        onOpenChange={(open) => {
          setDrawerOpen(open);
          if (!open) setSelected(null);
        }}
        size="md"
        title={
          selected
            ? "predicate" in selected
              ? `Edge · ${selected.predicate}`
              : `Node · ${selected.name}`
            : ""
        }
        description={
          selected && "predicate" in selected
            ? "Claim evidence and confidence"
            : "Entity details and metadata"
        }
        headerExtra={
          selected && (
            <Badge variant={"predicate" in selected ? "secondary" : "outline"}>
              {"predicate" in selected ? selected.predicate : selected.type}
            </Badge>
          )
        }
        footer={
          <Button
            variant="ghost"
            onClick={() => {
              setDrawerOpen(false);
              setSelected(null);
            }}
          >
            Close
          </Button>
        }
      >
        {selected && "predicate" in selected && (
          <div className="space-y-3">
            <Detail label="Claim" value={`#${selected.id}`} />
            <Detail label="Status" value={selected.raw.status} />
            <Detail
              label="Confidence"
              value={formatPercent(selected.confidence)}
            />
            <Detail
              label="Object"
              value={selected.raw.object?.name ?? selected.raw.target_value}
            />
            <Detail
              label="Last seen"
              value={formatDateTime(selected.raw.last_seen_at)}
            />
            <Detail
              label="Metadata"
              value={humanizeValue(selected.raw.metadata)}
            />
          </div>
        )}
        {selected && !("predicate" in selected) && (
          <div className="space-y-3">
            <Detail label="Name" value={selected.name} />
            <Detail label="Source" value={selected.source} />
            <Detail
              label="Canonical"
              value={selected.raw?.canonical_name}
            />
            <Detail
              label="Metadata"
              value={humanizeValue(selected.raw?.metadata)}
            />
            <Button
              variant="outline"
              size="sm"
              className="w-full"
              onClick={() => navigate(`/editor/${projectName}`)}
            >
              Open in Entity Manager
            </Button>
          </div>
        )}
      </Drawer>

      <ShortcutsOverlay
        open={shortcutsOpen}
        onOpenChange={setShortcutsOpen}
        groups={pageShortcutGroups}
        title="Graph View shortcuts"
      />
    </>
  );
}

/* ============================================================
 * Legend Panel — overlay, toggle visibility per type
 * ========================================================== */
function LegendPanel({
  types,
  typeCounts,
  hiddenTypes,
  onToggle,
  collapsed,
  onCollapsedChange,
}: {
  types: string[];
  typeCounts: Map<string, number>;
  hiddenTypes: Set<string>;
  onToggle: (type: string) => void;
  collapsed: boolean;
  onCollapsedChange: (collapsed: boolean) => void;
}) {
  return (
    <div
      className={
        collapsed
          ? "absolute right-3 top-3 w-32 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
          : "absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
      }
    >
      <button
        type="button"
        onClick={() => onCollapsedChange(!collapsed)}
        className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-2xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent/60"
        aria-expanded={!collapsed}
      >
        <span>Legend</span>
        <span className="flex items-center gap-1 font-mono normal-case">
          {collapsed && types.length}
          {collapsed ? (
            <ChevronRight className="h-3.5 w-3.5" />
          ) : (
            <ChevronDown className="h-3.5 w-3.5" />
          )}
        </span>
      </button>
      {!collapsed && (
        <ul className="max-h-64 overflow-y-auto border-t border-border py-1">
          {types.map((type) => {
            const hidden = hiddenTypes.has(type);
            const count = typeCounts.get(type) ?? 0;
            return (
              <li key={type}>
                <button
                  type="button"
                  onClick={() => onToggle(type)}
                  className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
                  aria-pressed={!hidden}
                >
                  <span
                    className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
                    style={{
                      backgroundColor: nodeColor(type, types),
                      opacity: hidden ? 0.3 : 1,
                    }}
                  />
                  <span
                    className={
                      hidden
                        ? "flex-1 truncate text-muted-foreground/60 line-through"
                        : "flex-1 truncate text-foreground"
                    }
                  >
                    {type}
                  </span>
                  <span className="text-2xs text-muted-foreground tabular-nums">
                    {count}
                  </span>
                  {hidden ? (
                    <EyeOff className="h-3 w-3 text-muted-foreground/60" />
                  ) : (
                    <Eye className="h-3 w-3 text-muted-foreground" />
                  )}
                </button>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

/* ============================================================
 * Minimap — scaled overview of the whole graph
 * ========================================================== */
function Minimap({
  nodes,
  edges,
  onJump,
}: {
  nodes: VisualNode[];
  edges: VisualEdge[];
  onJump: (x: number, y: number) => void;
}) {
  const W = 180;
  const H = 130;
  // Bounds of visible nodes (with padding so circles don't clip)
  const pad = 24;
  const xs = nodes.map((n) => n.x);
  const ys = nodes.map((n) => n.y);
  const minX = Math.min(...xs) - pad;
  const maxX = Math.max(...xs) + pad;
  const minY = Math.min(...ys) - pad;
  const maxY = Math.max(...ys) + pad;
  const w = Math.max(maxX - minX, 1);
  const h = Math.max(maxY - minY, 1);
  const scale = Math.min(W / w, H / h);
  const offsetX = (W - w * scale) / 2;
  const offsetY = (H - h * scale) / 2;
  const tx = (x: number) => (x - minX) * scale + offsetX;
  const ty = (y: number) => (y - minY) * scale + offsetY;
  const jumpFromMinimap = (event: ReactMouseEvent<SVGSVGElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const localX = event.clientX - rect.left;
    const localY = event.clientY - rect.top;
    const graphX = clamp((localX - offsetX) / scale + minX, minX, maxX);
    const graphY = clamp((localY - offsetY) / scale + minY, minY, maxY);
    onJump(graphX, graphY);
  };

  return (
    <div className="absolute bottom-3 right-3 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
      <div className="flex items-center justify-between border-b border-border px-2.5 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
        <span>Minimap</span>
        <span className="font-mono normal-case">
          {nodes.length}·{edges.length}
        </span>
      </div>
      <svg
        width={W}
        height={H}
        className="block cursor-crosshair text-muted-foreground"
        role="button"
        aria-label="Jump to minimap position"
        tabIndex={0}
        onClick={jumpFromMinimap}
      >
        {edges.map((e) => {
          const s = nodes.find((n) => n.id === e.source);
          const t = nodes.find((n) => n.id === e.target);
          if (!s || !t) return null;
          return (
            <line
              key={e.id}
              x1={tx(s.x)}
              y1={ty(s.y)}
              x2={tx(t.x)}
              y2={ty(t.y)}
              stroke="currentColor"
              strokeOpacity="0.35"
              strokeWidth="0.5"
            />
          );
        })}
        {nodes.map((n) => (
          <circle
            key={n.id}
            cx={tx(n.x)}
            cy={ty(n.y)}
            r={1.8}
            fill={n.color}
          />
        ))}
      </svg>
    </div>
  );
}

function Detail({ label, value }: { label: string; value: unknown }) {
  return (
    <div className="rounded-md border border-border bg-background-subtle px-3 py-2 text-sm">
      <div className="text-2xs uppercase tracking-wider text-muted-foreground">
        {label}
      </div>
      <div className="mt-1 break-words font-medium text-foreground">
        {humanizeValue(value)}
      </div>
    </div>
  );
}

function HealthRow({ label, value }: { label: string; value: number | string }) {
  return (
    <div className="flex items-center justify-between text-sm">
      <span className="text-muted-foreground">{label}</span>
      <span className="font-medium tabular-nums">
        {typeof value === "number" ? value.toLocaleString() : value}
      </span>
    </div>
  );
}
