import React from 'react'
import { IconCopy, IconCut, IconFolderPlus, IconX } from '@tabler/icons-react'
import { WorkbenchButton, WorkbenchIconButton } from '../../../design'
import { toast } from '../../../ui/toast'
import { cn } from '../../../utils/cn'
import CanvasToolbar, { NodeAddMenu } from './CanvasToolbar'
import { importImageFilesToGenerationCanvas } from '../adapters/assetImportAdapter'
import { getDesktopBridge } from '../../../desktop/bridge'
import { WORKSPACE_FILE_DRAG_MIME, buildWorkspaceFileUrl, parseWorkspaceFileDrag } from '../../explorer/workspaceFileDrag'
import type { GenerationNodeKind } from '../model/generationCanvasTypes'
import { getGenerationNodeComponent } from '../nodes/renderRegistry'
import { completeNodeConnection } from '../nodes/completeNodeConnection'
import { useGenerationCanvasStore } from '../store/generationCanvasStore'
import { notifyModelOptionsRefresh, useModelOptionsState } from '../../../config/useModelOptions'
import { useWorkbenchStore } from '../../workbenchStore'
import { GroupFrameList } from './GroupFrame'
import { useAutoFitOnLoad } from './useAutoFitOnLoad'
import {
  centerNodeOffset,
  clampNumber,
  createInitialViewport,
  getCanvasGroupBoxes,
  getSelectedBounds,
  getWheelZoomFactor,
} from './generationCanvasGeometry'
import { findScrollableAncestor } from './canvasScroll'
import { GENERATION_DEFAULT_BASE_URL, GENERATION_PROVIDER, readProviderSetting, writeProviderSettings } from '../services/providerSettings'
import CanvasEdgeLayer, { type ActiveEdge } from './CanvasEdgeLayer'
import '../styles/generationCanvas.css'

const OPEN_MODEL_CATALOG_EVENT = 'nomi-open-model-catalog'
const FOCUS_GENERATION_NODE_EVENT = 'nomi-focus-generation-node'

type GenerationCanvasProps = {
  readOnly?: boolean
}

export default function GenerationCanvas({ readOnly = false }: GenerationCanvasProps): JSX.Element {
  const isReady = useGenerationCanvasStore((state) => state.isReady)
  const allNodes = useGenerationCanvasStore((state) => state.nodes)
  const allEdges = useGenerationCanvasStore((state) => state.edges)
  const allGroups = useGenerationCanvasStore((state) => state.groups)
  const activeCategoryId = useWorkbenchStore((state) => state.activeCategoryId)
  const setActiveCategoryId = useWorkbenchStore((state) => state.setActiveCategoryId)
  // Phase E3: filter nodes by active sub-canvas. Nodes with no categoryId
  // fall back to the project default ("shots") so legacy projects keep
  // rendering until E4 migrates them.
  const nodes = React.useMemo(() => {
    if (!activeCategoryId) return allNodes
    return allNodes.filter((node) => (node.categoryId || 'shots') === activeCategoryId)
  }, [allNodes, activeCategoryId])
  const visibleNodeIds = React.useMemo(() => new Set(nodes.map((n) => n.id)), [nodes])
  const edges = React.useMemo(
    () => allEdges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)),
    [allEdges, visibleNodeIds],
  )
  const groups = React.useMemo(
    () => allGroups.filter((group) => group.categoryId === activeCategoryId),
    [activeCategoryId, allGroups],
  )
  const selectedNodeIds = useGenerationCanvasStore((state) => state.selectedNodeIds)
  const addNode = useGenerationCanvasStore((state) => state.addNode)
  const clearSelection = useGenerationCanvasStore((state) => state.clearSelection)
  const setCanvasTransform = useGenerationCanvasStore((state) => state.setCanvasTransform)
  const deleteSelectedNodes = useGenerationCanvasStore((state) => state.deleteSelectedNodes)
  const copySelectedNodes = useGenerationCanvasStore((state) => state.copySelectedNodes)
  const cutSelectedNodes = useGenerationCanvasStore((state) => state.cutSelectedNodes)
  const pasteNodes = useGenerationCanvasStore((state) => state.pasteNodes)
  const selectNode = useGenerationCanvasStore((state) => state.selectNode)
  const groupSelectedNodes = useGenerationCanvasStore((state) => state.groupSelectedNodes)
  const ungroupGroups = useGenerationCanvasStore((state) => state.ungroupGroups)
  const moveGroupNodes = useGenerationCanvasStore((state) => state.moveGroupNodes)
  const captureHistory = useGenerationCanvasStore((state) => state.captureHistory)
  const commitPersistedChange = useGenerationCanvasStore((state) => state.commitPersistedChange)
  const disconnectEdge = useGenerationCanvasStore((state) => state.disconnectEdge)
  const pendingConnectionSourceId = useGenerationCanvasStore((state) => state.pendingConnectionSourceId)
  const cancelConnection = useGenerationCanvasStore((state) => state.cancelConnection)
  const undo = useGenerationCanvasStore((state) => state.undo)
  const redo = useGenerationCanvasStore((state) => state.redo)
  const markReady = useGenerationCanvasStore((state) => state.markReady)
  const selectedSet = React.useMemo(() => new Set(selectedNodeIds), [selectedNodeIds])
  const nodeById = React.useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes])
  const selectedBounds = React.useMemo(() => getSelectedBounds(nodes, selectedNodeIds), [nodes, selectedNodeIds])
  const groupBoxes = React.useMemo(() => getCanvasGroupBoxes(groups, nodes), [groups, nodes])
  const selectedGroupIds = React.useMemo(() => {
    const selected = new Set(selectedNodeIds)
    return groups
      .filter((group) => group.nodeIds.some((nodeId) => selected.has(nodeId)))
      .map((group) => group.id)
  }, [groups, selectedNodeIds])
  const draggingGroupRef = React.useRef<{ groupId: string; clientX: number; clientY: number; moved: boolean } | null>(null)
  const [settingsOpen, setSettingsOpen] = React.useState(false)
  const [apiKey, setApiKey] = React.useState(() => readProviderSetting('apiKey'))
  const [baseUrl, setBaseUrl] = React.useState(() => readProviderSetting('baseUrl'))
  const [settingsSaved, setSettingsSaved] = React.useState(false)
  const hasApiKey = apiKey.trim().length > 0
  const imageModelOptionsState = useModelOptionsState('image')
  const videoModelOptionsState = useModelOptionsState('video')
  const imageModelOptions = imageModelOptionsState.options
  const videoModelOptions = videoModelOptionsState.options
  const modelOptionsStatusMessage = imageModelOptionsState.statusMessage || videoModelOptionsState.statusMessage

  // Pan/zoom state
  const initialViewport = React.useMemo(() => createInitialViewport(), [])
  const rememberCategoryViewport = useWorkbenchStore((state) => state.rememberCategoryViewport)
  const categoryViewports = useWorkbenchStore((state) => state.categoryViewports)
  // Phase E3: each graph-canvas category preserves its own zoom + offset
  const seedViewport = React.useMemo(() => {
    const remembered = categoryViewports[activeCategoryId]
    return remembered || initialViewport
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeCategoryId])
  const [viewport, setViewport] = React.useState(() => ({
    zoom: seedViewport.zoom,
    offset: seedViewport.offset,
  }))
  const zoom = viewport.zoom
  const offset = viewport.offset
  const lastCategoryRef = React.useRef(activeCategoryId)
  React.useEffect(() => {
    if (lastCategoryRef.current === activeCategoryId) return
    rememberCategoryViewport(lastCategoryRef.current, { zoom, offset }) // save outgoing
    const next = categoryViewports[activeCategoryId] || initialViewport // load incoming
    setViewport({ zoom: next.zoom, offset: next.offset })
    lastCategoryRef.current = activeCategoryId
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeCategoryId])
  const isPanningRef = React.useRef(false)
  const panStartRef = React.useRef<{ clientX: number; clientY: number; offsetX: number; offsetY: number } | null>(null)
  const offsetFrameRef = React.useRef<number | null>(null)
  const pendingOffsetRef = React.useRef<{ x: number; y: number } | null>(null)
  const stageRef = React.useRef<HTMLDivElement>(null)
  // E8 P0 G1: viewport-aware virtualization.
  // When node count exceeds the threshold, we filter the render list to only
  // nodes whose bounding box intersects the visible viewport (in canvas coords)
  // expanded by VIRTUALIZATION_BUFFER_PX on every side. Below the threshold
  // we keep current behavior (render every node) so small projects pay zero
  // overhead.
  const VIRTUALIZATION_THRESHOLD = 50
  const VIRTUALIZATION_BUFFER_PX = 400
  const [stageSize, setStageSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 })
  React.useEffect(() => {
    const el = stageRef.current
    if (!el || typeof ResizeObserver === 'undefined') return
    const update = () => setStageSize({ width: el.clientWidth, height: el.clientHeight })
    update()
    const ro = new ResizeObserver(update)
    ro.observe(el)
    return () => ro.disconnect()
  }, [])
  const visibleNodesForRender = React.useMemo(() => {
    if (nodes.length <= VIRTUALIZATION_THRESHOLD || stageSize.width === 0 || stageSize.height === 0) {
      return nodes
    }
    // Compute viewport in canvas coordinates
    const z = zoom || 1
    const viewLeft = -offset.x / z - VIRTUALIZATION_BUFFER_PX
    const viewTop = -offset.y / z - VIRTUALIZATION_BUFFER_PX
    const viewRight = viewLeft + stageSize.width / z + VIRTUALIZATION_BUFFER_PX * 2
    const viewBottom = viewTop + stageSize.height / z + VIRTUALIZATION_BUFFER_PX * 2
    return nodes.filter((node) => {
      const nx = node.position.x
      const ny = node.position.y
      const nw = node.size?.width || 300
      const nh = node.size?.height || 220
      // AABB intersection test
      return nx + nw >= viewLeft && nx <= viewRight && ny + nh >= viewTop && ny <= viewBottom
    })
  }, [nodes, zoom, offset, stageSize])
  const [contextNodeMenu, setContextNodeMenu] = React.useState<{
    stageX: number
    stageY: number
    canvasX: number
    canvasY: number
  } | null>(null)
  const [pendingCursorPos, setPendingCursorPos] = React.useState<{ x: number; y: number } | null>(null)
  const [isPanning, setIsPanning] = React.useState(false)
  const [activeEdge, setActiveEdge] = React.useState<ActiveEdge | null>(null)
  const activeEdgeId = activeEdge?.id ?? null
  const [focusFlashNodeId, setFocusFlashNodeId] = React.useState<string | null>(null)
  const [pendingFocusNodeId, setPendingFocusNodeId] = React.useState<string | null>(null)
  const focusFlashTimerRef = React.useRef<number | null>(null)

  React.useEffect(() => {
    markReady()
  }, [markReady])

  React.useEffect(() => {
    if (!activeEdgeId || edges.some((edge) => edge.id === activeEdgeId)) return
    setActiveEdge(null)
  }, [activeEdgeId, edges])

  // Refs so drag-connection effect can read latest values without re-subscribing
  const offsetRef = React.useRef(offset)
  offsetRef.current = offset
  const zoomRef = React.useRef(zoom)
  zoomRef.current = zoom
  const nodesRef = React.useRef(nodes)
  nodesRef.current = nodes
  const allNodesRef = React.useRef(allNodes)
  allNodesRef.current = allNodes
  const stageSizeRef = React.useRef(stageSize)
  stageSizeRef.current = stageSize

  const scheduleOffset = React.useCallback((nextOffset: { x: number; y: number }) => {
    offsetRef.current = nextOffset
    pendingOffsetRef.current = nextOffset
    if (offsetFrameRef.current !== null) return
    offsetFrameRef.current = window.requestAnimationFrame(() => {
      offsetFrameRef.current = null
      const pending = pendingOffsetRef.current
      pendingOffsetRef.current = null
      if (pending) setViewport((current) => ({ ...current, offset: pending }))
    })
  }, [])

  const setViewportTransform = React.useCallback((nextZoom: number, nextOffset: { x: number; y: number }) => {
    if (offsetFrameRef.current !== null) {
      window.cancelAnimationFrame(offsetFrameRef.current)
      offsetFrameRef.current = null
    }
    pendingOffsetRef.current = null
    zoomRef.current = nextZoom
    offsetRef.current = nextOffset
    setViewport({ zoom: nextZoom, offset: nextOffset })
  }, [])

  const zoomAtStagePoint = React.useCallback((nextZoom: number, point: { x: number; y: number }) => {
    const currentZoom = zoomRef.current || 1
    const currentOffset = offsetRef.current
    const zoomRatio = nextZoom / currentZoom
    setViewportTransform(nextZoom, {
      x: point.x - (point.x - currentOffset.x) * zoomRatio,
      y: point.y - (point.y - currentOffset.y) * zoomRatio,
    })
  }, [setViewportTransform])

  React.useEffect(() => () => {
    if (offsetFrameRef.current !== null) {
      window.cancelAnimationFrame(offsetFrameRef.current)
      offsetFrameRef.current = null
    }
  }, [])

  React.useEffect(() => {
    const handleFocusNode = (event: Event) => {
      const detail = (event as CustomEvent<{ nodeId?: unknown }>).detail
      const nodeId = typeof detail?.nodeId === 'string' ? detail.nodeId : ''
      if (!nodeId) return
      const target = allNodesRef.current.find((node) => node.id === nodeId)
      if (!target) {
        toast('源节点已不存在', 'warning')
        return
      }
      const targetCategoryId = target.categoryId || 'shots'
      setActiveCategoryId(targetCategoryId)
      selectNode(nodeId)
      setPendingFocusNodeId(nodeId)
    }
    window.addEventListener(FOCUS_GENERATION_NODE_EVENT, handleFocusNode)
    return () => {
      window.removeEventListener(FOCUS_GENERATION_NODE_EVENT, handleFocusNode)
      if (focusFlashTimerRef.current !== null) {
        window.clearTimeout(focusFlashTimerRef.current)
        focusFlashTimerRef.current = null
      }
    }
  }, [selectNode, setActiveCategoryId])

  React.useEffect(() => {
    if (!pendingFocusNodeId) return
    const target = allNodes.find((node) => node.id === pendingFocusNodeId)
    if (!target) {
      setPendingFocusNodeId(null)
      return
    }
    const targetCategoryId = target.categoryId || 'shots'
    if (targetCategoryId !== activeCategoryId) return
    const targetZoom = categoryViewports[targetCategoryId]?.zoom || zoomRef.current || 1
    const targetOffset = centerNodeOffset(target, stageSizeRef.current, targetZoom)
    setViewportTransform(targetZoom, targetOffset)
    setFocusFlashNodeId(pendingFocusNodeId)
    setPendingFocusNodeId(null)
    if (focusFlashTimerRef.current !== null) window.clearTimeout(focusFlashTimerRef.current)
    focusFlashTimerRef.current = window.setTimeout(() => {
      setFocusFlashNodeId((current) => (current === pendingFocusNodeId ? null : current))
      focusFlashTimerRef.current = null
    }, 1400)
  }, [activeCategoryId, allNodes, categoryViewports, pendingFocusNodeId])

  React.useEffect(() => {
    if (readOnly) return undefined
    const handleMove = (event: PointerEvent) => {
      const drag = draggingGroupRef.current
      if (!drag) return
      const scale = zoomRef.current || 1
      const delta = {
        x: (event.clientX - drag.clientX) / scale,
        y: (event.clientY - drag.clientY) / scale,
      }
      if (delta.x === 0 && delta.y === 0) return
      drag.clientX = event.clientX
      drag.clientY = event.clientY
      drag.moved = true
      moveGroupNodes(drag.groupId, delta, { persist: false })
    }
    const handleUp = () => {
      const drag = draggingGroupRef.current
      if (!drag) return
      draggingGroupRef.current = null
      if (drag.moved) commitPersistedChange()
    }
    window.addEventListener('pointermove', handleMove)
    window.addEventListener('pointerup', handleUp)
    window.addEventListener('blur', handleUp)
    return () => {
      window.removeEventListener('pointermove', handleMove)
      window.removeEventListener('pointerup', handleUp)
      window.removeEventListener('blur', handleUp)
    }
  }, [commitPersistedChange, moveGroupNodes, readOnly])

  // Keep the store viewport in sync so nodes can read the same zoom/pan model.
  React.useEffect(() => {
    setCanvasTransform(zoom, offset)
  }, [offset, setCanvasTransform, zoom])

  React.useEffect(() => {
    if (!contextNodeMenu) return undefined
    const closeMenu = () => setContextNodeMenu(null)
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') closeMenu()
    }
    window.addEventListener('pointerdown', closeMenu)
    window.addEventListener('keydown', handleKeyDown)
    window.addEventListener('blur', closeMenu)
    return () => {
      window.removeEventListener('pointerdown', closeMenu)
      window.removeEventListener('keydown', handleKeyDown)
      window.removeEventListener('blur', closeMenu)
    }
  }, [contextNodeMenu])

  // Drag-to-connect: track pointer while a connection is being drawn
  React.useEffect(() => {
    if (readOnly) return undefined
    if (!pendingConnectionSourceId) {
      setPendingCursorPos(null)
      return
    }
    const handleMove = (event: PointerEvent) => {
      if (!stageRef.current) return
      const rect = stageRef.current.getBoundingClientRect()
      const o = offsetRef.current
      const z = zoomRef.current
      setPendingCursorPos({
        x: (event.clientX - rect.left - o.x) / z,
        y: (event.clientY - rect.top - o.y) / z,
      })
    }
    const handleUp = (event: PointerEvent) => {
      if (!stageRef.current) return
      const rect = stageRef.current.getBoundingClientRect()
      const canvasX = (event.clientX - rect.left - offsetRef.current.x) / zoomRef.current
      const canvasY = (event.clientY - rect.top - offsetRef.current.y) / zoomRef.current
      const targetNode = nodesRef.current.find((n) => {
        const w = n.size?.width || 300
        const h = n.size?.height || 220
        return canvasX >= n.position.x && canvasX <= n.position.x + w &&
               canvasY >= n.position.y && canvasY <= n.position.y + h
      })
      if (targetNode && targetNode.id !== pendingConnectionSourceId) {
        completeNodeConnection(targetNode.id)
      } else {
        cancelConnection()
      }
      setPendingCursorPos(null)
    }
    document.addEventListener('pointermove', handleMove)
    document.addEventListener('pointerup', handleUp)
    return () => {
      document.removeEventListener('pointermove', handleMove)
      document.removeEventListener('pointerup', handleUp)
    }
  }, [pendingConnectionSourceId, cancelConnection, readOnly])

  const handleGroupSelectedNodes = React.useCallback(() => {
    const group = groupSelectedNodes(activeCategoryId)
    if (!group) return
    toast(`已创建「${group.name}」`, 'success')
  }, [activeCategoryId, groupSelectedNodes])

  const handleUngroupSelectedNodes = React.useCallback(() => {
    if (!selectedGroupIds.length) return
    ungroupGroups(selectedGroupIds)
    toast('已解组并保留节点', 'success')
  }, [selectedGroupIds, ungroupGroups])

  const handleGroupFramePointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>, groupId: string) => {
    if (readOnly || event.button !== 0) return
    event.preventDefault()
    event.stopPropagation()
    captureHistory()
    draggingGroupRef.current = {
      groupId,
      clientX: event.clientX,
      clientY: event.clientY,
      moved: false,
    }
  }, [captureHistory, readOnly])

  React.useEffect(() => {
    if (readOnly) return undefined
    const handleKeyDown = (event: KeyboardEvent) => {
      const target = event.target instanceof HTMLElement ? event.target : null
      if (target?.closest('input, textarea, select, [contenteditable="true"]')) return
      const key = event.key.toLowerCase()
      const mod = event.metaKey || event.ctrlKey
      if (event.key === 'Escape') {
        setActiveEdge(null)
        cancelConnection()
        return
      }
      if (event.key === 'Backspace' || event.key === 'Delete') {
        if (!selectedNodeIds.length) return
        event.preventDefault()
        deleteSelectedNodes()
        return
      }
      if (!mod) return
      if (key === 'g' && event.shiftKey) {
        if (!selectedGroupIds.length) return
        event.preventDefault()
        handleUngroupSelectedNodes()
        return
      }
      if (key === 'g') {
        if (selectedNodeIds.length < 2) return
        event.preventDefault()
        handleGroupSelectedNodes()
        return
      }
      // v0.7.5: Cmd+A 全选当前分类
      if (key === 'a') {
        event.preventDefault()
        useGenerationCanvasStore.getState().selectAllNodes(activeCategoryId)
        return
      }
      if (key === 'c') {
        event.preventDefault()
        copySelectedNodes()
        return
      }
      if (key === 'x') {
        event.preventDefault()
        cutSelectedNodes()
        return
      }
      if (key === 'v') {
        event.preventDefault()
        pasteNodes()
        return
      }
      if (key === 'z' && event.shiftKey) {
        event.preventDefault()
        redo()
        return
      }
      if (key === 'z') {
        event.preventDefault()
        undo()
        return
      }
      if (key === 'y') {
        event.preventDefault()
        redo()
      }
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [
    cancelConnection,
    copySelectedNodes,
    cutSelectedNodes,
    deleteSelectedNodes,
    handleGroupSelectedNodes,
    handleUngroupSelectedNodes,
    pasteNodes,
    readOnly,
    redo,
    selectedGroupIds.length,
    selectedNodeIds.length,
    undo,
  ])

  React.useEffect(() => {
    const handleOpenSettings = () => {
      setSettingsOpen(true)
      setSettingsSaved(false)
    }
    window.addEventListener('nomi-open-generation-settings', handleOpenSettings)
    return () => window.removeEventListener('nomi-open-generation-settings', handleOpenSettings)
  }, [])

  const handleSaveSettings = () => {
    writeProviderSettings(apiKey, baseUrl)
    const desktop = getDesktopBridge()
    if (desktop) {
      desktop.modelCatalog.upsertVendor({
        key: GENERATION_PROVIDER,
        name: 'ChatFire OpenAI Compatible',
        enabled: true,
        baseUrlHint: baseUrl.trim() || GENERATION_DEFAULT_BASE_URL,
        authType: 'bearer',
      })
      if (apiKey.trim()) {
        desktop.modelCatalog.upsertVendorApiKey(GENERATION_PROVIDER, { apiKey: apiKey.trim(), enabled: true })
      }
      notifyModelOptionsRefresh('all')
    }
    setApiKey(readProviderSetting('apiKey'))
    setBaseUrl(readProviderSetting('baseUrl'))
    setSettingsSaved(true)
  }

  const handleStageDrop = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
    if (readOnly) return
    const rect = event.currentTarget.getBoundingClientRect()
    const basePosition = {
      x: (event.clientX - rect.left - offset.x) / zoom,
      y: (event.clientY - rect.top - offset.y) / zoom,
    }

    // 1) 项目文件树拖入：文件已在项目里，直接用 nomi-local 协议引用，创建图片节点。
    const workspaceDrag = parseWorkspaceFileDrag(event.dataTransfer.getData(WORKSPACE_FILE_DRAG_MIME))
    if (workspaceDrag) {
      event.preventDefault()
      event.stopPropagation()
      const url = buildWorkspaceFileUrl(workspaceDrag.projectId, workspaceDrag.relativePath)
      const store = useGenerationCanvasStore.getState()
      const node = store.addNode({
        kind: 'asset',
        title: workspaceDrag.name.replace(/\.[^.]+$/, '') || '本地素材',
        prompt: '',
        position: { x: Math.max(40, Math.round(basePosition.x)), y: Math.max(40, Math.round(basePosition.y)) },
        categoryId: activeCategoryId,
      })
      const result = { id: `workspace-${node.id}-${Date.now()}`, type: 'image' as const, url, createdAt: Date.now() }
      store.updateNode(node.id, {
        result,
        history: [result],
        status: 'success',
        meta: { ...(node.meta || {}), source: 'workspace-file', fileName: workspaceDrag.name, workspaceRelativePath: workspaceDrag.relativePath },
      })
      return
    }

    // 2) OS 文件拖入：复制进项目并上传，创建图片节点。
    const files = Array.from(event.dataTransfer.files || []).filter((file) => file.type.startsWith('image/'))
    if (!files.length) return
    event.preventDefault()
    event.stopPropagation()
    void importImageFilesToGenerationCanvas(files, { basePosition, categoryId: activeCategoryId })
  }, [activeCategoryId, offset, readOnly, zoom])

  const handleStagePanStart = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return
    setContextNodeMenu(null)
    setActiveEdge(null)
    const target = event.target instanceof Element ? event.target : null
    if (target?.closest(
      '.generation-canvas-v2-node, .generation-canvas-v2-toolbar, .generation-canvas-v2__zoom-bar, .generation-canvas-v2__selection-toolbar, .generation-canvas-v2__edge-hit, .generation-canvas-v2__edge-cut, button, input, textarea, select, [role="menu"], [role="menuitem"]',
    )) {
      return
    }
    if (pendingConnectionSourceId && !readOnly) {
      cancelConnection()
    }
    // Left drag on empty canvas = pan
    isPanningRef.current = true
    setIsPanning(true)
    panStartRef.current = {
      clientX: event.clientX,
      clientY: event.clientY,
      offsetX: offset.x,
      offsetY: offset.y,
    }
    if (typeof event.currentTarget.setPointerCapture === 'function') {
      event.currentTarget.setPointerCapture(event.pointerId)
    }
  }

  const handleStagePanMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!isPanningRef.current || !panStartRef.current) return
    scheduleOffset({
      x: panStartRef.current.offsetX + (event.clientX - panStartRef.current.clientX),
      y: panStartRef.current.offsetY + (event.clientY - panStartRef.current.clientY),
    })
  }

  const handleStagePanEnd = (event?: React.PointerEvent<HTMLDivElement>) => {
    if (isPanningRef.current && panStartRef.current && event) {
      const dx = Math.abs(event.clientX - panStartRef.current.clientX)
      const dy = Math.abs(event.clientY - panStartRef.current.clientY)
      // Pure click (no drag) on empty canvas = clear selection
      if (dx < 4 && dy < 4) clearSelection()
    }
    isPanningRef.current = false
    setIsPanning(false)
    panStartRef.current = null
    if (offsetFrameRef.current !== null) {
      window.cancelAnimationFrame(offsetFrameRef.current)
      offsetFrameRef.current = null
    }
    if (pendingOffsetRef.current) {
      const pending = pendingOffsetRef.current
      setViewport((current) => ({ ...current, offset: pending }))
      pendingOffsetRef.current = null
    }
    if (event &&
      typeof event.currentTarget.hasPointerCapture === 'function' &&
      typeof event.currentTarget.releasePointerCapture === 'function' &&
      event.currentTarget.hasPointerCapture(event.pointerId)
    ) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
  }

  const handleStagePointerDownCapture = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!activeEdgeId) return
    const target = event.target instanceof Element ? event.target : null
    if (target?.closest('.generation-canvas-v2__edge-hit, .generation-canvas-v2__edge-cut')) return
    setActiveEdge(null)
  }

  const getCanvasPointFromClientPoint = React.useCallback((clientX: number, clientY: number) => {
    if (!stageRef.current) return null
    const rect = stageRef.current.getBoundingClientRect()
    return {
      x: (clientX - rect.left - offsetRef.current.x) / zoomRef.current,
      y: (clientY - rect.top - offsetRef.current.y) / zoomRef.current,
    }
  }, [])

  const handleWheel = React.useCallback((event: WheelEvent) => {
    // 滚轮命中卡内可滚区（提示词编辑器等）时交给原生滚动，别被 stage 的缩放监听吞掉（一处覆盖所有入口，P2）。
    if (event.target instanceof Element && findScrollableAncestor(event.target, stageRef.current, event.deltaY)) return
    event.preventDefault()
    setContextNodeMenu(null)
    if (!stageRef.current) return
    const rect = stageRef.current.getBoundingClientRect()
    const nextZoom = clampNumber(zoomRef.current * getWheelZoomFactor(event), 0.2, 3)
    zoomAtStagePoint(nextZoom, { x: event.clientX - rect.left, y: event.clientY - rect.top })
  }, [zoomAtStagePoint])

  React.useEffect(() => {
    const stage = stageRef.current
    if (!stage) return undefined
    stage.addEventListener('wheel', handleWheel, { passive: false })
    return () => stage.removeEventListener('wheel', handleWheel)
  }, [handleWheel])

  const handleStageContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
    if (readOnly || !stageRef.current) return
    const target = event.target instanceof Element ? event.target : null
    if (target?.closest(
      '.generation-canvas-v2-node, .generation-canvas-v2-toolbar, .generation-canvas-v2__zoom-bar, .generation-canvas-v2__selection-toolbar, .generation-canvas-v2__edge, .generation-canvas-v2__edge-preview, button, input, textarea, select, [role="menu"], [role="menuitem"]',
    )) {
      return
    }
    event.preventDefault()
    event.stopPropagation()
    clearSelection()
    const rect = stageRef.current.getBoundingClientRect()
    const stageX = event.clientX - rect.left
    const stageY = event.clientY - rect.top
    const canvasPoint = getCanvasPointFromClientPoint(event.clientX, event.clientY)
    if (!canvasPoint) return
    const menuWidth = 148
    const menuHeight = 330
    setContextNodeMenu({
      stageX: clampNumber(stageX, 8, Math.max(8, rect.width - menuWidth - 8)),
      stageY: clampNumber(stageY, 8, Math.max(8, rect.height - menuHeight - 8)),
      canvasX: Math.round(canvasPoint.x),
      canvasY: Math.round(canvasPoint.y),
    })
  }

  const handleAddContextNode = (kind: GenerationNodeKind) => {
    if (!contextNodeMenu) return
    addNode({
      kind,
      position: { x: contextNodeMenu.canvasX, y: contextNodeMenu.canvasY },
      categoryId: activeCategoryId,
    })
    setContextNodeMenu(null)
  }

  const fitView = React.useCallback(() => {
    if (!nodes.length || !stageRef.current) return
    const rect = stageRef.current.getBoundingClientRect()
    const padding = 80
    const minX = Math.min(...nodes.map((n) => n.position.x))
    const minY = Math.min(...nodes.map((n) => n.position.y))
    const maxX = Math.max(...nodes.map((n) => n.position.x + (n.size?.width || 300)))
    const maxY = Math.max(...nodes.map((n) => n.position.y + (n.size?.height || 220)))
    const contentW = maxX - minX + padding * 2
    const contentH = maxY - minY + padding * 2
    const nextZoom = Math.min(1.2, Math.min(rect.width / contentW, rect.height / contentH))
    setViewportTransform(nextZoom, {
      x: (rect.width - contentW * nextZoom) / 2 - (minX - padding) * nextZoom,
      y: (rect.height - contentH * nextZoom) / 2 - (minY - padding) * nextZoom,
    })
  }, [nodes, setViewportTransform])

  // 项目/分类首次加载时自动适应视图（含「历史视口框不住任何节点」的自愈式适应，
  // 防止图都在视口外、用户误以为「图消失」）。逻辑抽到 useAutoFitOnLoad（防巨壳）。
  useAutoFitOnLoad({ nodes, activeCategoryId, categoryViewports, fitView, stageRef, zoomRef, offsetRef })

  const getToolbarInsertionPosition = React.useCallback(() => {
    const rect = stageRef.current?.getBoundingClientRect()
    const viewportAnchor = rect
      ? { x: rect.width * 0.38, y: rect.height * 0.42 }
      : { x: 360, y: 280 }
    const basePosition = {
      x: Math.round((viewportAnchor.x - offset.x) / zoom),
      y: Math.round((viewportAnchor.y - offset.y) / zoom),
    }
    const occupied = new Set(nodes.map((node) => `${Math.round(node.position.x)},${Math.round(node.position.y)}`))
    for (let index = 0; index < 12; index += 1) {
      const candidate = {
        x: basePosition.x + index * 34,
        y: basePosition.y + index * 28,
      }
      if (!occupied.has(`${candidate.x},${candidate.y}`)) return candidate
    }
    return basePosition
  }, [nodes, offset.x, offset.y, zoom])

  const zoomPercent = Math.round(zoom * 100)
  const selectedCount = selectedNodeIds.length

  // E.2C-13: 删除 viewType 分支。5 个分类全部走同一画布底座。
  // 节点渲染样式差异由 NodeRenderKind 分发（E.2C-14/15+ 实现）。

  return (
    <section
      className={cn(
        'generation-canvas-v2',
        'grid grid-rows-[minmax(0,1fr)] w-full h-full min-w-0 min-h-0 bg-[#f7f7f9] text-workbench-ink',
      )}
      aria-label="AI 影像创作画布"
      data-ready={isReady ? 'true' : undefined}
    >
      <div className={cn('generation-canvas-v2__main', 'relative w-full h-full min-w-0 min-h-0')}>
        {settingsOpen ? (
          <div
            className={cn(
              'generation-canvas-v2__provider-popover',
              'absolute top-4 right-4 z-[12] grid gap-[10px]',
              'w-[min(360px,calc(100vw-40px))] p-3',
              'border border-workbench-border rounded-[12px]',
              'bg-white/[0.98] shadow-workbench-pop pointer-events-auto',
            )}
            onPointerDown={(event) => event.stopPropagation()}
          >
            <div
              className={cn(
                'flex items-center justify-between gap-2 pb-2',
                'border-b border-workbench-border/[0.58] text-workbench-muted text-xs',
              )}
              aria-label="模型目录状态"
            >
              <span>系统模型目录</span>
              <strong className="text-workbench-ink text-xs font-[650]">{imageModelOptions.length} 图 / {videoModelOptions.length} 视频</strong>
              <WorkbenchButton onClick={() => { window.dispatchEvent(new CustomEvent(OPEN_MODEL_CATALOG_EVENT)) }}>接入模型</WorkbenchButton>
            </div>
            <p className={cn('m-0 text-workbench-muted text-xs leading-[1.45]')}>
              {modelOptionsStatusMessage
                ? modelOptionsStatusMessage
                : '可选模型来自模型目录；没有模型时请打开"模型接入"，让 Agent 根据官方文档生成草案并确认写入。'}
            </p>
            <label className="grid gap-[5px] text-workbench-muted text-xs">
              <span>API Key</span>
              <input
                className={cn(
                  'h-[34px] min-w-0 px-[10px]',
                  'border border-workbench-border rounded-workbench-control',
                  'bg-workbench-surface-solid text-workbench-ink font-[inherit] text-[13px]',
                )}
                type="password"
                value={apiKey}
                placeholder="粘贴生成渠道 API Key"
                onChange={(event) => {
                  setApiKey(event.target.value)
                  setSettingsSaved(false)
                }}
              />
            </label>
            <label className="grid gap-[5px] text-workbench-muted text-xs">
              <span>Base URL</span>
              <input
                className={cn(
                  'h-[34px] min-w-0 px-[10px]',
                  'border border-workbench-border rounded-workbench-control',
                  'bg-workbench-surface-solid text-workbench-ink font-[inherit] text-[13px]',
                )}
                value={baseUrl}
                placeholder={GENERATION_DEFAULT_BASE_URL}
                onChange={(event) => {
                  setBaseUrl(event.target.value)
                  setSettingsSaved(false)
                }}
              />
            </label>
            <div className={cn('flex justify-end gap-2')}>
              <WorkbenchButton onClick={handleSaveSettings}>保存</WorkbenchButton>
              <WorkbenchButton onClick={() => setSettingsOpen(false)}>关闭</WorkbenchButton>
            </div>
            <p className="m-0 text-xs" data-tone={hasApiKey ? 'success' : 'error'}>
              {settingsSaved ? '已保存生成渠道配置。' : hasApiKey ? '当前已配置生成渠道 Key。' : '旧渠道 Key 未配置；新模型优先通过"模型接入"写入模型目录。'}
            </p>
          </div>
        ) : null}
        {!readOnly ? <CanvasToolbar getInsertionPosition={getToolbarInsertionPosition} categoryId={activeCategoryId} /> : null}
        <div
          className="generation-canvas-v2__stage"
          ref={stageRef}
          data-panning={isPanning ? 'true' : undefined}
          onPointerDownCapture={handleStagePointerDownCapture}
          onPointerDown={handleStagePanStart}
          onPointerMove={handleStagePanMove}
          onPointerUp={handleStagePanEnd}
          onContextMenu={handleStageContextMenu}
          onDragOver={(event) => {
            if (readOnly) return
            const types = Array.from(event.dataTransfer.types)
            if (types.includes('Files') || types.includes(WORKSPACE_FILE_DRAG_MIME)) {
              event.preventDefault()
              event.dataTransfer.dropEffect = 'copy'
            }
          }}
          onDrop={handleStageDrop}
        >
          <div
            className={cn('generation-canvas-v2__canvas', 'absolute inset-0 origin-top-left')}
            style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})` }}
          >
            <CanvasEdgeLayer
              edges={edges}
              nodeById={nodeById}
              activeEdge={activeEdge}
              readOnly={readOnly}
              pendingConnectionSourceId={pendingConnectionSourceId}
              pendingCursorPos={pendingCursorPos}
              onSetActiveEdge={setActiveEdge}
              onDisconnectEdge={disconnectEdge}
              getCanvasPointFromClientPoint={getCanvasPointFromClientPoint}
            />
            <div className={cn('generation-canvas-v2__nodes', 'absolute top-0 left-0 w-[4000px] h-[3000px]')}>
              {/* E.2C-30: GroupFrame 抽离为独立组件 */}
              <GroupFrameList boxes={groupBoxes} onPointerDown={handleGroupFramePointerDown} />
              <React.Suspense fallback={null}>
                {visibleNodesForRender.map((node) => {
                  const NodeComponent = getGenerationNodeComponent(node.kind)
                  return (
                    <NodeComponent
                      key={node.id}
                      node={node}
                      selected={selectedSet.has(node.id)}
                      readOnly={readOnly}
                      focusFlash={focusFlashNodeId === node.id}
                    />
                  )
                })}
              </React.Suspense>
            </div>
            {selectedBounds && selectedCount > 1 && !readOnly ? (
              <div
                className={cn(
                  'generation-canvas-v2__selection-toolbar',
                  'absolute z-[11] inline-flex items-center gap-1 px-[6px] py-1',
                  'border border-nomi-line rounded-full',
                  'bg-nomi-paper/[0.96] shadow-nomi-md pointer-events-auto',
                )}
                style={{
                  transform: `translate(${Math.round(selectedBounds.minX + selectedBounds.width / 2)}px, ${Math.round(Math.max(24, selectedBounds.minY - 44))}px) translateX(-50%)`,
                }}
                aria-label="选中区域操作"
                onPointerDown={(event) => event.stopPropagation()}
              >
                <span className={cn('px-[6px] text-nomi-ink-60 text-[11px] whitespace-nowrap')}>{selectedCount} 个节点</span>
                <WorkbenchIconButton label="创建分组 (⌘G)" icon={<IconFolderPlus size={14} />} onClick={handleGroupSelectedNodes} />
                <WorkbenchIconButton label="复制选中节点" icon={<IconCopy size={14} />} onClick={copySelectedNodes} />
                <WorkbenchIconButton label="剪切选中节点" icon={<IconCut size={14} />} onClick={cutSelectedNodes} />
                <WorkbenchIconButton label="清除选择" icon={<IconX size={14} />} onClick={clearSelection} />
              </div>
            ) : null}
          </div>
          {/* E.2C-24: 空状态 CTA（spec 决策 4）— 分类感知的引导按钮 */}
          {nodes.length === 0 ? (() => {
            const categoryNameById: Record<string, string> = {
              shots: '画面',
              cast: '角色',
              scene: '场景',
              prop: '道具',
              audio: '声音',
            }
            const activeCategoryName = categoryNameById[activeCategoryId] || '节点'
            const handleEmptyCtaClick = () => {
              // 默认创建 image kind 节点（声音分类暂用 image 占位，audio kind 待 future iteration）
              addNode({
                kind: 'image',
                position: { x: 240, y: 240 },
                categoryId: activeCategoryId,
                select: true,
              })
            }
            return (
              <div className={cn(
                'absolute top-[44%] left-1/2 grid gap-3 place-items-center',
                'text-workbench-muted text-[13px] text-center',
                '-translate-x-1/2 -translate-y-1/2',
              )}>
                <strong className="text-[14px] text-nomi-ink">这里还没有{activeCategoryName}</strong>
                <span className="text-[12px] text-nomi-ink-60 max-w-[300px]">
                  添加第一个节点开始创作，之后可以拖动、分组、跨分类复制。
                </span>
                <WorkbenchButton
                  className={cn(
                    'mt-2 inline-flex items-center gap-1.5 min-h-[28px] px-4',
                    'rounded-full border-0 bg-nomi-ink text-nomi-paper',
                    'font-[inherit] text-[12px] font-medium',
                    'hover:enabled:bg-nomi-accent',
                  )}
                  aria-label={`新建一个${activeCategoryName}节点`}
                  onClick={handleEmptyCtaClick}
                >
                  + 新建{activeCategoryName}
                </WorkbenchButton>
              </div>
            )
          })() : null}
          {contextNodeMenu ? (
            <NodeAddMenu
              className={cn('generation-canvas-v2__context-node-menu', 'z-[20]')}
              style={{ left: contextNodeMenu.stageX, top: contextNodeMenu.stageY }}
              onPointerDown={(event) => event.stopPropagation()}
              onContextMenu={(event) => event.preventDefault()}
              onAddNode={handleAddContextNode}
            />
          ) : null}
        </div>
        <div
          className={cn(
            'generation-canvas-v2__zoom-bar',
            'absolute left-4 bottom-6 z-[8] inline-flex items-center gap-[2px]',
            'min-h-9 p-1 border border-workbench-border rounded-nomi',
            'bg-nomi-paper shadow-workbench-sm',
          )}
          aria-label="画布缩放"
        >
          <WorkbenchButton aria-label="适应视图" title={nodes.length === 0 ? '画布为空' : '适应视图'} disabled={nodes.length === 0} onClick={fitView}>⌖</WorkbenchButton>
          <WorkbenchButton
            aria-label="重置视图"
            title="重置视图"
            onClick={() => setViewportTransform(1, { x: 0, y: 0 })}
          >▦</WorkbenchButton>
          <input
            className="w-[78px] accent-workbench-accent"
            type="range"
            min="20"
            max="300"
            value={zoomPercent}
            aria-label="缩放比例"
            onChange={(event) => {
              const nextZoom = Number(event.target.value) / 100
              const rect = stageRef.current?.getBoundingClientRect()
              if (!rect) {
                setViewportTransform(nextZoom, offsetRef.current)
                return
              }
              zoomAtStagePoint(nextZoom, { x: rect.width / 2, y: rect.height / 2 })
            }}
          />
          <WorkbenchButton aria-label="画布帮助" title="画布帮助" onClick={() => toast('快捷键：S 分割 · Cmd+D 复制 · Delete 删除 · ← → 移动播放头 · Ctrl+滚轮 缩放', 'info')}>?</WorkbenchButton>
        </div>
      </div>
    </section>
  )
}
