/**
 * Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
 * Rendered inside the platform when a recollection is selected.
 */

import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import {
  ReactFlow,
  ReactFlowProvider,
  Controls,
  Background,
  MiniMap,
  addEdge,
  applyNodeChanges,
  applyEdgeChanges,
  useNodesInitialized,
  useReactFlow,
  type Node,
  type Edge,
  type Connection,
  type ColorMode,
  type NodeChange,
  type EdgeChange,
} from '@xyflow/react'
import { AnimatedEdge } from '@/components/graph/AnimatedEdge'
import {
  GraphContext,
  ConnectionPathContext,
  FlowUIContext,
} from '@/lib/graph/flowContext'
import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { useRecollectionTitleData } from '@/app/recollections/RecollectionTitleDataContext'
import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogContent,
  DialogTitle,
} from '@/components/ui/dialog'
import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import {
  getRegisteredNodeTypes,
  getRegisteredNodeTypeIds,
  getDefaultStyle,
  getNodeType,
  getConnectionLabelForTarget,
  isConnectionAllowed,
} from '@/lib/graph/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { toast } from 'sonner'
import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'

const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 }
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
  x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
  y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
})

function FlowFitViewOnLoad() {
  const nodesInitialized = useNodesInitialized()
  const { fitView } = useReactFlow()
  React.useEffect(() => {
    if (nodesInitialized) fitView?.({ duration: 200 })
  }, [nodesInitialized, fitView])
  return null
}

type FullscreenNodeOverlayProps = {
  nodeId: string
  nodes: AppNode[]
  onClose: () => void
}

function FullscreenNodeOverlay({ nodeId, nodes, onClose }: FullscreenNodeOverlayProps) {
  const open = nodeId != null
  const node = nodes.find((n) => n.id === nodeId)
  if (!nodeId) return null

  return (
    <Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
      <DialogContent
        className="max-w-[94vw] w-[94vw] max-h-[90vh] h-[90vh] p-0 gap-0 overflow-hidden flex flex-col border rounded-lg shadow-xl bg-card"
        aria-describedby={undefined}
        hideCloseButton
      >
        <DialogTitle className="sr-only">Node: {node?.id ?? nodeId}</DialogTitle>
        {node && node.type && (
          <FullscreenNodeContent node={node} />
        )}
      </DialogContent>
    </Dialog>
  )
}

function FullscreenNodeContent({ node }: { node: AppNode }) {
  const descriptor = getNodeType(node.type ?? '')
  const NodeComponent = descriptor?.component as React.ComponentType<{
    id: string
    data: Record<string, unknown>
    type?: string
    selected?: boolean
    width?: number
    height?: number
  }> | undefined
  if (!NodeComponent) return null

  const w = typeof window !== 'undefined' ? Math.round(window.innerWidth * 0.94) : 1200
  const h = typeof window !== 'undefined' ? Math.round(window.innerHeight * 0.9) : 800

  const fullscreenNodeForStore: AppNode = {
    ...node,
    position: node.position ?? { x: 0, y: 0 },
    style: { ...(node.style as object), width: w, height: h },
  }

  return (
    <div className="flex-1 min-h-0 overflow-hidden flex">
      <ReactFlowProvider initialNodes={[fullscreenNodeForStore]} initialEdges={[]}>
        <NodeComponent
          id={node.id}
          data={(node.data as Record<string, unknown>) ?? {}}
          type={node.type}
          selected={false}
          width={w}
          height={h}
        />
      </ReactFlowProvider>
    </div>
  )
}

export type CanvasPageProps = {
  /** Optional recollection id for per-recollection graph loading */
  recollectionId?: string
}

export function CanvasPage({ recollectionId }: CanvasPageProps) {
  const { theme } = useTheme()
  const { showMinimap } = usePlatform()
  const {
    nodes,
    edges,
    setNodes,
    setEdges,
    setNodesSilent,
    applyGraph,
    saveForDragEnd,
    commitDragEnd,
    undo,
    redo,
    canUndo,
    canRedo,
    setStateImmediate,
    save,
    saveStatus,
  } = useCanvasGraph(recollectionId)

  const importInputRef = useRef<HTMLInputElement | null>(null)
  const [rfInstance, setRfInstance] = React.useState<unknown>(null)
  const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
  const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
  const wrapperRef = useRef<HTMLDivElement | null>(null)
  const canvasWrapperRef = useRef<HTMLDivElement | null>(null)
  const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
  const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
  const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
  const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
  const [isPanning, setIsPanning] = React.useState(false)
  const [isSelecting, setIsSelecting] = React.useState(false)
  const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
  const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)

  const graphApplyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
  const GRAPH_APPLY_DEBOUNCE_MS = 300
  useEffect(() => {
    if (graphApplyTimeoutRef.current) clearTimeout(graphApplyTimeoutRef.current)
    graphApplyTimeoutRef.current = setTimeout(() => {
      graphApplyTimeoutRef.current = null
      dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
    }, GRAPH_APPLY_DEBOUNCE_MS)
    return () => {
      if (graphApplyTimeoutRef.current) {
        clearTimeout(graphApplyTimeoutRef.current)
        graphApplyTimeoutRef.current = null
      }
    }
  }, [nodes, edges])

  const connectionPath = useCanvasConnectionPathFromStore()

  const nodesRef = useRef(nodes)
  nodesRef.current = nodes
  const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
  graphRef.current.nodes = nodes
  graphRef.current.edges = edges

  const pendingChangesRef = useRef<NodeChange<Node>[]>([])
  const rafRef = useRef<number | null>(null)
  const onNodesChange = useCallback(
    (changes: NodeChange<Node>[]) => {
      if (changes.length === 0) return
      const pending = pendingChangesRef.current
      for (const c of changes) {
        const id = (c as { id?: string }).id
        if (id != null) {
          const i = pending.findIndex((p) => (p as { id?: string }).id === id)
          if (i >= 0) pending[i] = c
          else pending.push(c)
        } else pending.push(c)
      }
      if (rafRef.current === null) {
        rafRef.current = requestAnimationFrame(() => {
          rafRef.current = null
          const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
          if (toApply.length === 0) return
          setNodesSilent((nds) => {
            // Drop dimension-only changes that don't change the node (e.g. React Flow re-reporting on visibility).
            // Avoids graph/apply and store churn when nodes become visible with onlyRenderVisibleElements.
            const filtered = toApply.filter((c) => {
              const ch = c as NodeChange<Node> & { type?: string; dimensions?: { width?: number; height?: number } }
              if (ch.type !== 'dimensions' || ch.dimensions == null) return true
              const node = nds.find((n) => n.id === (ch as { id?: string }).id)
              if (!node) return true
              const nw = (node as Node & { width?: number }).width
              const nh = (node as Node & { height?: number }).height
              return nw !== ch.dimensions.width || nh !== ch.dimensions.height
            })
            if (filtered.length === 0) return nds
            return applyNodeChanges(filtered, nds)
          })
        })
      }
    },
    [setNodesSilent]
  )

  const nodeTypes = useMemo(
    () =>
      Object.fromEntries(
        getRegisteredNodeTypes().map((r) => [r.id, createContextualNode(r.component)])
      ),
    []
  )
  const edgeTypes = useMemo(() => ({ animated: AnimatedEdge }), [])
  const defaultEdgeOptions = useMemo(() => ({ type: 'animated' as const }), [])

  const onEdgesChange = useCallback(
    (changes: EdgeChange<Edge>[]) => {
      if (changes.length === 0) return
      setEdges((eds) => applyEdgeChanges(changes, eds))
    },
    [setEdges]
  )

  const onConnect = useCallback(
    (params: Connection) => {
      const targetType =
        nodesRef.current.find((n) => n.id === params.target)?.type ?? ''
      const conn = { ...params, data: { targetType } as Record<string, unknown> }
      setEdges((eds) => addEdge(conn, eds))
    },
    [setEdges]
  )

  const isValidConnection = useCallback((connection: Connection | AppEdge) => {
    const src = 'source' in connection ? connection.source : undefined
    const tgt = 'target' in connection ? connection.target : undefined
    if (typeof src !== 'string' || typeof tgt !== 'string') return false
    const currentNodes = nodesRef.current
    const sourceNode = currentNodes.find((n) => n.id === src)
    const targetNode = currentNodes.find((n) => n.id === tgt)
    const sourceType = sourceNode?.type
    const targetType = targetNode?.type
    if (!sourceType || !targetType) return false
    if (sourceType === 'render' && targetType === 'config') {
      const targetData = targetNode?.data as { configType?: string } | undefined
      if (targetData?.configType !== 'markdown') return false
    }
    return isConnectionAllowed(sourceType, targetType, src, tgt)
  }, [])

  const onConnectStart = useCallback(
    (
      _: React.MouseEvent<Element> | React.TouchEvent<Element> | MouseEvent | TouchEvent,
      params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }
    ) => {
      if (params.handleType !== 'source' || !params.nodeId) {
        setConnectionFrom(null)
        return
      }
      setConnectionFrom({ nodeId: params.nodeId, sourceHandle: params.handleId ?? undefined })
    },
    []
  )
  const onConnectEnd = useCallback(() => setConnectionFrom(null), [])

  const onInit = useCallback((instance: unknown) => setRfInstance(instance), [])

  const onMoveStart = useCallback(() => setIsPanning(true), [])
  const onMoveEnd = useCallback(() => setIsPanning(false), [])
  const onSelectionDragStart = useCallback(() => setIsSelecting(true), [])
  const onSelectionDragStop = useCallback(() => setIsSelecting(false), [])

  const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
  const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])

  const handleExportRecollection = useCallback(() => {
    const state = { version: RECOLLECTION_VERSION, nodes, edges }
    const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `recollection${RECOLLECTION_FILE_EXT}`
    a.click()
    URL.revokeObjectURL(url)
    toast.success('Recollection exported')
  }, [nodes, edges])

  const handleImportRecollection = useCallback(() => importInputRef.current?.click(), [])

  const handleLoadExample = useCallback(() => {
    const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
    setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
    toast.success('Example loaded')
  }, [setStateImmediate])

  const onImportFileChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0]
      e.target.value = ''
      if (!file) return
      const reader = new FileReader()
      reader.onload = () => {
        try {
          const text = reader.result as string
          const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
          if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
            toast.error('Invalid file: expected nodes and edges arrays')
            return
          }
          const nodes = state.nodes as AppNode[]
          const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
          setStateImmediate({ nodes, edges })
          if (state.version != null && state.version > RECOLLECTION_VERSION) {
            toast.error('Recollection was created with a newer app version')
          } else {
            toast.success('Recollection loaded')
          }
        } catch {
          toast.error('Invalid file: not valid JSON')
        }
      }
      reader.readAsText(file)
    },
    [setStateImmediate]
  )

  const selectedNodes = useMemo(
    () => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
    [nodes]
  )

  const handleDuplicate = useCallback(() => {
    if (selectedNodes.length === 0 || !setNodes) return
    setNodes((nds: Node[]) => {
      const existingIds = nds.map((n) => n.id)
      const toAdd: Node[] = []
      for (const node of selectedNodes) {
        const n = node as Node & { selected?: boolean }
        const pos = n.position ?? { x: 0, y: 0 }
        const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
        existingIds.push(newId)
        const newNode: Node = {
          id: newId,
          type: n.type,
          position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
          data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
          style: getDefaultStyle(String(n.type)),
        }
        if (
          newNode.data &&
          typeof newNode.data === 'object' &&
          'title' in newNode.data &&
          String(n.type) === 'config'
        ) {
          ; (newNode.data as Record<string, unknown>).title = `${newId}`
        }
        toAdd.push(newNode)
      }
      return nds.concat(toAdd)
    })
  }, [selectedNodes, setNodes])

  const handleCopy = useCallback(() => {
    if (selectedNodes.length !== 1) return
    const node = selectedNodes[0] as Node & { selected?: boolean }
    const copy = {
      id: node.id,
      type: node.type,
      data: node.data,
      position: node.position,
      style: node.style,
    }
    navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
  }, [selectedNodes])

  const handlePaste = useCallback(() => {
    flowActionsRef.current?.pasteAtViewportCenter?.()
  }, [])

  const { pathname } = useLocation()
  const isFluxActive = pathname.endsWith('/flux')
  const { setTitleData } = useRecollectionTitleData()
  useEffect(() => {
    setTitleData({
      saveStatus,
      onSave: recollectionId
        ? () => {
            save()
            toast.success('Saved')
          }
        : undefined,
      canSave: Boolean(recollectionId),
      onImport: handleImportRecollection,
      onExport: handleExportRecollection,
    })
    return () => setTitleData(null)
  }, [
    setTitleData,
    saveStatus,
    recollectionId,
    save,
    handleImportRecollection,
    handleExportRecollection,
  ])

  const fluxMenubarProps = useMemo(
    () => ({
      undo,
      redo,
      canUndo,
      canRedo,
      onDuplicate: handleDuplicate,
      onCopy: handleCopy,
      onPaste: handlePaste,
      canDuplicate: selectedNodes.length > 0,
      canCopy: selectedNodes.length === 1,
      onFitView: () => flowActionsRef.current?.fitView?.(),
    }),
    [
      canUndo,
      canRedo,
      selectedNodes.length,
      undo,
      redo,
      handleDuplicate,
      handleCopy,
      handlePaste,
    ]
  )

  const graphContextValue = useMemo(
    () => ({ setNodes, setEdges, graphRef, edges }),
    [setNodes, setEdges, edges]
  )
  const connectionPathContextValue = useMemo(
    () => ({
      connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds,
      connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds,
      addConnectionPathTrigger: connectionPath.addConnectionPathTrigger,
      connectionPathNodeIds: connectionPath.connectionPathNodeIds,
      connectionPathPausedSegmentNodeIds: connectionPath.connectionPathPausedSegmentNodeIds,
      connectionPathActiveSegmentNodeIds: connectionPath.connectionPathActiveSegmentNodeIds,
      connectionPathPausedNodeIds: connectionPath.connectionPathPausedNodeIds,
      addConnectionPathPausedNode: connectionPath.addConnectionPathPausedNode,
      removeConnectionPathPausedNode: connectionPath.removeConnectionPathPausedNode,
      connectionPathErrorNodeIds: connectionPath.connectionPathErrorNodeIds,
      addConnectionPathError: connectionPath.addConnectionPathError,
      removeConnectionPathError: connectionPath.removeConnectionPathError,
      startConnectionPathUpdate: connectionPath.startConnectionPathUpdate,
      endConnectionPathUpdate: connectionPath.endConnectionPathUpdate,
    }),
    [connectionPath]
  )
  const flowUIContextValue = useMemo(
    () => ({
      renamingNodeId,
      setRenamingNodeId,
      connectionFrom,
      setConnectionFrom,
      isValidConnection,
      flowActionsRef,
      fullscreenNodeId,
      setFullscreenNodeId,
    }),
    [
      renamingNodeId,
      setRenamingNodeId,
      connectionFrom,
      setConnectionFrom,
      isValidConnection,
      flowActionsRef,
      fullscreenNodeId,
      setFullscreenNodeId,
    ]
  )

  const prevNodesRef = useRef<AppNode[]>([])
  const prevNodesForFlowRef = useRef<Node[]>([])
  const nodesForFlow = useMemo(() => {
    const prev = prevNodesRef.current
    if (nodes === prev) return prevNodesForFlowRef.current
    const prevById = new Map(prev.map((n) => [n.id, n]))
    const prevWrappedById = new Map(
      prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w])
    )
    const result = nodes.map((n) => {
      const prevNode = prevById.get(n.id)
      if (prevNode === n && prevWrappedById.has(n.id)) {
        return prevWrappedById.get(n.id)!
      }
      return {
        ...n,
        className: [n.className, 'nowheel'].filter(Boolean).join(' '),
      }
    })
    prevNodesRef.current = nodes
    prevNodesForFlowRef.current = result
    return result
  }, [nodes])
  const edgesForFlow = useMemo(
    () =>
      edges.map((e) => {
        const targetType =
          (typeof e.data === 'object' && e.data !== null && (e.data as Record<string, unknown>).targetType != null
            ? (e.data as Record<string, unknown>).targetType
            : '') as string
        const connectionLabel = getConnectionLabelForTarget(targetType)
        const baseData =
          typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
        return {
          ...e,
          data: { ...baseData, connectionLabel },
        }
      }),
    [edges]
  )

  const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
    const target = ev.target as HTMLElement
    if (target.closest('.react-flow__node')) {
      ev.preventDefault()
      ev.stopPropagation()
    }
  }, [])

  const onCanvasContextMenu = useCallback((ev: React.MouseEvent) => {
    ev.preventDefault()
    lastClickRef.current = { clientX: ev.clientX, clientY: ev.clientY }
    setContextTarget({ type: 'canvas', clientX: ev.clientX, clientY: ev.clientY })
  }, [])

  const getMenuPosition = useCallback(() => {
    if (!rfInstance) return null
    const click = contextTarget ?? lastClickRef.current
    const clientX = click?.clientX ?? window.innerWidth / 2
    const clientY = click?.clientY ?? window.innerHeight / 2
    try {
      type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
      const inst = rfInstance as { screenToFlowPosition?: ScreenToFlow; [k: string]: unknown }
      const screenToFlow = inst.screenToFlowPosition ?? (inst['project'] as ScreenToFlow | undefined)
      const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
      return p ? snapToGrid(p.x, p.y) : null
    } catch {
      return snapToGrid(clientX, clientY)
    }
  }, [rfInstance, contextTarget])

  const createNode = useCallback(
    (type: string) => {
      const position = getMenuPosition()
      if (position == null || typeof type !== 'string') return
      const existingIds = nodesRef.current.map((n) => n.id).filter((id): id is string => id != null)
      const newId = getNextNodeId(type, existingIds)
      const dataMap = getDefaultDataForType(type, newId)
      const style = getDefaultStyle(type)
      const newNode: Node = {
        id: newId,
        type: type as Node['type'],
        position: { x: position.x, y: position.y },
        data: dataMap,
        style,
      }
      setNodes((nds) => nds.concat(newNode))
      setLastCreatedNodeId(newId)
      lastClickRef.current = null
      setContextTarget(null)
    },
    [getMenuPosition, setNodes]
  )

  React.useEffect(() => {
    if (lastCreatedNodeId == null) return
    const id = lastCreatedNodeId
    const raf = requestAnimationFrame(() => {
      const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
      if (nodeEl) {
        setAriaAnnouncement('Node created')
        nodeEl.setAttribute('tabindex', '-1')
        nodeEl.focus({ preventScroll: false })
        setTimeout(() => setAriaAnnouncement(null), 1000)
      }
      setLastCreatedNodeId(null)
    })
    return () => cancelAnimationFrame(raf)
  }, [lastCreatedNodeId, nodes])

  const pasteNode = useCallback(async () => {
    const position = getMenuPosition()
    if (position == null) return
    try {
      const text = await navigator.clipboard?.readText()
      if (!text) return
      const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; style?: unknown }
      const validIds = getRegisteredNodeTypeIds()
      if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
      const nodeType = raw.type
      setNodes((nds) => {
        const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
        const newId = getNextNodeId(nodeType, existingIds)
        const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
        if (nodeType === 'config' && data && 'title' in data) data.title = `config-${newId}`
        const style = getDefaultStyle(nodeType)
        const newNode: Node = {
          id: newId,
          type: nodeType as Node['type'],
          position: { x: position.x, y: position.y },
          data,
          style,
        }
        return nds.concat(newNode)
      })
      lastClickRef.current = null
      setContextTarget(null)
    } catch {
      /* ignore */
    }
  }, [getMenuPosition, setNodes])

  const deleteNode = useCallback(
    (id: string | undefined) => {
      if (!id) return
      applyGraph(({ nodes: nds, edges: eds }) => ({
        nodes: nds.filter((n) => n.id !== id),
        edges: eds.filter((e) => e.source !== id && e.target !== id),
      }))
      setContextTarget(null)
    },
    [applyGraph]
  )

  return (
    <div
      className="reactflow-wrapper flex flex-col h-full"
      ref={wrapperRef}
      onContextMenuCapture={onContextMenuCapture}
      onContextMenu={onCanvasContextMenu}
      style={{ position: 'relative' }}
    >
      {isFluxActive && <FluxMenubarContent {...fluxMenubarProps} />}
      <div role="status" aria-live="polite" aria-atomic className="sr-only">
        {ariaAnnouncement}
      </div>
      <input
        ref={importInputRef}
        type="file"
        accept=".json,.zui.json,application/json"
        className="hidden"
        onChange={onImportFileChange}
        aria-hidden
      />
      <div className="flex-1 min-h-0 relative flex flex-col">
        <div className="flex-1 min-h-0 flex flex-col">
          <GraphContext.Provider value={graphContextValue}>
          <ConnectionPathContext.Provider value={connectionPathContextValue}>
          <FlowUIContext.Provider value={flowUIContextValue}>
            <ContextMenu>
              <ContextMenuTrigger asChild>
                <div
                  ref={canvasWrapperRef}
                  className="flex-1 min-h-0 w-full relative"
                  role="application"
                  aria-label="Graph canvas"
                  tabIndex={0}
                >
                  {nodes.length === 0 && (
                    <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
                      <div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
                        <Empty className="p-6 md:p-8">
                          <EmptyHeader>
                            <EmptyMedia variant="icon">
                              <CircleDotDashed className="size-6" />
                            </EmptyMedia>
                            <EmptyTitle>Start adding a new node!</EmptyTitle>
                            <EmptyDescription>
                              Right‑click to add nodes. <br />
                              Import a recollection or paste a node.
                            </EmptyDescription>
                          </EmptyHeader>
                          <EmptyContent className="flex-row flex-wrap justify-center gap-2">
                            <Button onClick={handleImportRecollection} variant="outline" size="sm">
                              <FolderOpen className="size-4" />
                              Import…
                            </Button>
                            <Button onClick={handleLoadExample} variant="outline" size="sm">
                              <FileStack className="size-4" />
                              Load example
                            </Button>
                          </EmptyContent>
                        </Empty>
                      </div>
                    </div>
                  )}
                  <ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
                    <ViewportDisplayProvider>
                      <FlowFitViewOnLoad />
                      <FlowKeyboardShortcuts />
                      <ReactFlow
                      className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
                      nodes={nodesForFlow}
                      edges={edgesForFlow}
                      onNodesChange={onNodesChange}
                      onEdgesChange={onEdgesChange}
                      onConnect={onConnect}
                      onConnectStart={onConnectStart}
                      onConnectEnd={onConnectEnd}
                      onNodeDragStart={onNodeDragStart}
                      onNodeDragStop={onNodeDragStop}
                      onMoveStart={onMoveStart}
                      onMoveEnd={onMoveEnd}
                      onSelectionDragStart={onSelectionDragStart}
                      onSelectionDragStop={onSelectionDragStop}
                      isValidConnection={isValidConnection}
                      nodeTypes={nodeTypes}
                      edgeTypes={edgeTypes}
                      defaultEdgeOptions={defaultEdgeOptions}
                      colorMode={theme as ColorMode}
                      minZoom={0.1}
                      maxZoom={2}
                      snapToGrid
                      snapGrid={SNAP_GRID}
                      fitView
                      onInit={onInit}
                      nodeDragThreshold={1}
                      onlyRenderVisibleElements
                      nodeOrigin={[0, 0]}
                      nodesDraggable
                      nodesConnectable
                      elementsSelectable
                      panOnScroll
                      zoomOnScroll
                      zoomActivationKeyCode="Meta"
                      panOnDrag={[1]}
                      selectionOnDrag
                    >
                      {/* BackgroundVariant from @xyflow/system expects enum; 'dots' is valid at runtime */}
                      <Background variant={'dots' as React.ComponentProps<typeof Background>['variant']} gap={20} />
                      <div role="group" aria-label="Canvas controls: zoom and fit view">
                        <Controls />
                      </div>
                      {showMinimap && nodes.length > 5 && (
                        <div role="region" aria-label="Minimap: overview of the graph">
                          <MiniMap />
                        </div>
                      )}
                    </ReactFlow>
                    </ViewportDisplayProvider>
                  </ReactFlowProvider>
                </div>
              </ContextMenuTrigger>
              <CanvasContextMenuContent onCreateNode={createNode} onPaste={pasteNode} />
            </ContextMenu>
            {fullscreenNodeId && (
              <FullscreenNodeOverlay
                nodeId={fullscreenNodeId}
                nodes={nodes}
                onClose={() => setFullscreenNodeId(null)}
              />
            )}
          </FlowUIContext.Provider>
          </ConnectionPathContext.Provider>
          </GraphContext.Provider>
        </div>
      </div>
    </div>
  )
}
