"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTheme } from "next-themes"
import {
  ReactFlow, Background, BackgroundVariant, Controls, MiniMap,
  useNodesState, useEdgesState,
  type Node, type Edge,
} from "@xyflow/react"
import { Toaster, toast } from "sonner"
import { LayoutGrid } from "lucide-react"
import {
  InputNode, VisualLabNode, AudioNode,
  ComposeNode, KeyframePanelNode,
  VideoFramePanelNode,
  type CanvasPanelDock,
  type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
import {
  addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
  deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, createProductFusionGuide,
  type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
} from "@/lib/api"

const NODE_TYPES = {
  input: InputNode,
  visual: VisualLabNode,
  audio: AudioNode,
  compose: ComposeNode,
  keyframePanel: KeyframePanelNode,
  videoFramePanel: VideoFramePanelNode,
}

const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
  balanced: "综合关键帧",
  subject: "清晰主体",
  transition: "转场变化",
  expression: "表情瞬间",
  motion: "动作峰值",
}
const FRAME_QUALITY_LABELS: Record<FrameExtractQuality, string> = {
  auto: "自动",
  fast: "快速",
  accurate: "精细",
  ultra: "极准",
}

// 合并 input + download + split 为一个节点
// 分叉：上路 input → visual lab ↘
//       下路 input → audio ──────────────────────────→ compose
const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number; w: number }> = [
  { id: "input",     type: "input",     x:   40, y: 240, w: 320 },
  { id: "visual",    type: "visual",    x:  460, y:  60, w: 620 },
  { id: "audio",     type: "audio",     x:  460, y: 440, w: 320 },
  { id: "compose",   type: "compose",   x: 1160, y: 240, w: 320 },
]

const NODE_SIZES_KEY = "skg-tk:node-sizes:v2"

type NodeSize = { w?: number; h?: number }

function loadNodeSizes(): Record<string, NodeSize> {
  if (typeof window === "undefined") return {}
  try {
    const raw = window.localStorage.getItem(NODE_SIZES_KEY)
    return raw ? JSON.parse(raw) : {}
  } catch {
    return {}
  }
}

const NODE_PINS_KEY = "skg-tk:node-pins:v1"

function loadNodePins(): string[] {
  if (typeof window === "undefined") return []
  try {
    const raw = window.localStorage.getItem(NODE_PINS_KEY)
    return raw ? JSON.parse(raw) : []
  } catch {
    return []
  }
}

const EDGES_RAW: Array<[string, string]> = [
  ["input", "visual"],
  ["input", "audio"],
  ["visual", "compose"],
  ["audio", "compose"],
]

export default function Home() {
  const { resolvedTheme } = useTheme()
  const [jobs, setJobs] = useState<Job[]>([])
  const [activeJobId, setActiveJobId] = useState<string | null>(null)
  const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
  const [submitting, setSubmitting] = useState(false)
  const [analyzing, setAnalyzing] = useState(false)
  const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
  const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
  const [frameQualities, setFrameQualities] = useState<Record<string, FrameExtractQuality>>({})
  const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
  const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
  const [framePanelScale, setFramePanelScale] = useState(1)
  const [framePanelDock, setFramePanelDock] = useState<CanvasPanelDock>("left")
  const framePanelPinned = framePanelDock !== "canvas"
  const [videoPanelJobId, setVideoPanelJobId] = useState<string | null>(null)
  const [videoPanelScale, setVideoPanelScale] = useState(1)
  const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
  const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
  const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
  const [workbenchOpen, setWorkbenchOpen] = useState(false)
  const [clipboard, setClipboard] = useState<ImageRef | null>(null)
  const flowRef = useRef<any>(null)
  const lastVideoPanelFocusKey = useRef("")

  // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
  const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => {
    setJobs((prev) => {
      const current = prev.find((j) => j.id === activeJobId) ?? null
      const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater
      if (!next) return prev
      const idx = prev.findIndex((j) => j.id === next.id)
      if (idx < 0) {
        setActiveJobId(next.id)
        return [...prev, next]
      }
      const arr = [...prev]
      arr[idx] = next
      return arr
    })
  }, [activeJobId])

  // 新增 job + 设为 active
  const addJob = useCallback((j: Job) => {
    setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j])
    setActiveJobId(j.id)
  }, [])

  const handleSwitchJob = useCallback((id: string) => {
    setActiveJobId(id)
    setSelectedFrames(new Set())
  }, [])
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)

  const handleSubmit = useCallback(async (url: string) => {
    setSubmitting(true)
    setSelectedFrames(new Set())
    try {
      const created = await createJob(url)
      addJob(created)
      toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
    } catch (e) {
      toast.error("提交失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubmitting(false)
    }
  }, [addJob])

  const handleUpload = useCallback(async (file: File) => {
    setSubmitting(true)
    setSelectedFrames(new Set())
    try {
      toast.info(`上传中：${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
      const created = await uploadJob(file)
      addJob(created)
      toast.success(`已上传 ${created.id.slice(0, 8)}`)
    } catch (e) {
      toast.error("上传失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setSubmitting(false)
    }
  }, [addJob])

  const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => {
    const targetJob = jobs.find((item) => item.id === jobId)
    if (!targetJob) return
    const frameTarget = frameTargets[jobId] ?? "balanced"
    const frameCount = frameCounts[jobId] ?? 5
    const frameQuality = frameQualities[jobId] ?? "auto"
    const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
    setActiveJobId(jobId)
    setAnalyzing(true)
    if (mode === "replace") setSelectedFrames(new Set())
    try {
      await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality)
      toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}：${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
      setJobs((prev) => prev.map((item) => item.id === jobId ? {
        ...item,
        status: "splitting",
        message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]}…`,
        progress: 30,
      } : item))
    } catch (e) {
      toast.error("解析触发失败：" + (e instanceof Error ? e.message : String(e)))
    } finally {
      setAnalyzing(false)
    }
  }, [jobs, frameCounts, frameQualities, frameTargets])

  const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
    if (!job) return
    await handleAnalyzeJob(job.id, options)
  }, [job?.id, handleAnalyzeJob])

  const handleFrameTargetChange = useCallback((jobId: string, target: FrameExtractTarget) => {
    setFrameTargets((prev) => ({ ...prev, [jobId]: target }))
  }, [])

  const handleFrameCountChange = useCallback((jobId: string, count: number) => {
    setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) }))
  }, [])

  const handleFrameQualityChange = useCallback((jobId: string, quality: FrameExtractQuality) => {
    setFrameQualities((prev) => ({ ...prev, [jobId]: quality }))
  }, [])

  const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
    try {
      const updated = await addManualFrame(jobId, t)
      setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
      setActiveJobId(updated.id)
      toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`)
    } catch (e) {
      toast.error("加帧失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [])

  const handleAddManualFrame = useCallback(async (t: number) => {
    if (!job) return
    await handleAddManualFrameForJob(job.id, t)
  }, [job?.id, handleAddManualFrameForJob])

  const handleOpenVideoPanel = useCallback((jobId: string) => {
    setActiveJobId(jobId)
    if (!videoPanelJobId) setVideoPanelDock("left")
    setVideoPanelJobId(jobId)
    setVideoPanelOpenTick((n) => n + 1)
  }, [videoPanelJobId])

  const handleVideoPanelScaleChange = useCallback((scale: number) => {
    setVideoPanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
  }, [])

  const handleToggleFrame = useCallback((idx: number) => {
    setSelectedFrames((prev) => {
      const next = new Set(prev)
      if (next.has(idx)) next.delete(idx)
      else next.add(idx)
      return next
    })
  }, [])

  const handleOpenFramePanel = useCallback((idx: number) => {
    if (expandedFrame === null) setFramePanelDock("left")
    setExpandedFrame(idx)
  }, [expandedFrame])

  const handleFramePanelScaleChange = useCallback((scale: number) => {
    setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
  }, [])

  const handleDeleteFrameForJob = useCallback(async (jobId: string, idx: number) => {
    const wasActive = activeJobId === jobId
    try {
      const updated = await deleteFrame(jobId, idx)
      setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
      setActiveJobId(updated.id)
      setSelectedFrames((prev) => {
        if (!wasActive) return new Set()
        if (!prev.has(idx)) return prev
        const next = new Set(prev)
        next.delete(idx)
        return next
      })
      if (!wasActive || expandedFrame === idx) setExpandedFrame(null)
      toast.success(`分镜 ${idx + 1} 已删除`)
    } catch (e) {
      toast.error("删除失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [activeJobId, expandedFrame])

  const handleDeleteFrame = useCallback(async (idx: number) => {
    if (!activeJobId) return
    await handleDeleteFrameForJob(activeJobId, idx)
  }, [activeJobId, handleDeleteFrameForJob])

  const handleDeleteGenerated = useCallback(async (frameIdx: number, genId: string) => {
    if (!activeJobId) return
    try {
      const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId)
      setJob(updated)
      toast.success("生成图已删除")
    } catch (e) {
      toast.error("删除失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [activeJobId, setJob])

  const handleDeleteVideo = useCallback(async (videoId: string) => {
    if (!activeJobId) return
    try {
      const updated = await deleteGeneratedVideo(activeJobId, videoId)
      setJob(updated)
      toast.success("视频任务已删除")
    } catch (e) {
      toast.error("删除失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [activeJobId, setJob])

  const handleDeleteJob = useCallback(async (jobId: string) => {
    try {
      await deleteJob(jobId)
      setVideoPanelJobId((prev) => prev === jobId ? null : prev)
      setJobs((prev) => {
        const idx = prev.findIndex((x) => x.id === jobId)
        const next = prev.filter((x) => x.id !== jobId)
        if (activeJobId === jobId) {
          const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null
          setActiveJobId(fallback?.id ?? null)
          setSelectedFrames(new Set())
          setExpandedFrame(null)
          setStoryboardFrame(null)
          setWorkbenchOpen(false)
        }
        return next
      })
      toast.success("输入视频已删除")
    } catch (e) {
      toast.error("删除失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [activeJobId])

  const handleDeleteCutout = useCallback(async (frameIdx: number, elementId: string, cutoutId: string) => {
    if (!activeJobId) return
    try {
      const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId)
      setJob(updated)
      toast.success("元素提取图已删除")
    } catch (e) {
      toast.error("删除失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [activeJobId, setJob])

  const handleCopyImage = useCallback((ref: ImageRef) => {
    setClipboard(ref)
    toast.success(`已复制：${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
  }, [])

  const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
    if (!job) return
    const frame = job.frames.find((f) => f.index === frameIdx)
    if (!frame) return

    const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
    const keyframeRef: ImageRef = {
      kind: "keyframe",
      frame_idx: frameIdx,
      label: `分镜 ${frameIdx + 1} 首帧`,
    }
    const orderedSelected = job.frames
      .filter((f) => selectedFrames.has(f.index))
      .sort((a, b) => a.timestamp - b.timestamp)
    const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null
    const defaultLastRef: ImageRef | null = nextFrame
      ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` }
      : null
    const firstRef = scene.first_image ?? keyframeRef
    const lastRef = scene.last_image ?? defaultLastRef
    const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
    const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
    const sourceScene = frame.description?.scene ? `参考画面识别：${frame.description.scene}` : ""
    const sourceStyle = frame.description?.style ? `参考风格：${frame.description.style}` : ""
    const sourceObjects = frame.description?.objects?.length
      ? `参考元素：${frame.description.objects.slice(0, 6).map((o) => o.name).join("、")}`
      : ""
    const subjectDirection = scene.subject?.trim()
      || "保留首尾帧里的主体位置、手部动作关系和镜头调度；如果参考主体是人形骷髅、透明骨骼人或卡通骨骼角色，可以保留为视觉隐喻，让它正确佩戴 SKG 颈部按摩仪后状态变好。不要做恐怖、血腥或严肃医疗治疗画面。"
    const productDirection = scene.product?.trim()
      || "以已上传 SKG 产品图为唯一产品真源，把参考视频或首尾帧里的任何非 SKG 产品替换成该产品；产品轮廓、颜色、材质、按键/接口/包装比例必须稳定，不要变成其他物体。"
    const sceneDirection = scene.scene?.trim()
      || "借鉴参考画面的构图、可信感和空间层次，但改造成适合 SKG 产品广告的现代家居、办公或零售场景。"
    const actionDirection = scene.action?.trim()
      || "按首帧到尾帧做平滑过渡，动作连续自然，镜头运动稳定，最后准确停在尾帧意图。"
    const productNature = [
      "产品性质：这是 SKG 白色 U 形可穿戴颈部/肩颈按摩仪，不是药品、护肤品、饮料、瓶罐、医疗器械镜头道具或普通项链。",
      "正确使用方式：产品应佩戴/环绕在人的脖子和肩颈位置，U 形机身落在肩颈两侧，内侧金属按摩触点贴合后颈或肩颈肌肉区域。",
      "可表现的交互：手拿起产品、展开并戴到脖子上、轻按侧边圆形按键/控制区、轻微调整贴合位置、闭眼放松、肩颈从紧绷变舒展。",
      "效果表达：使用后状态变好，表现为颈肩放松、姿态更自然、表情舒缓、精神恢复；如果主体是人形骷髅，可以通过放下揉脖子的手、抬头、肩颈舒展、表情/动作变轻松来表现改善。不要表现治愈疾病、骨骼修复、皮肤祛痘或夸张医疗效果。",
    ].join("\n")
    const prompt = [
      `竖屏 9:16，${duration.toFixed(1)} 秒，SKG 产品短视频广告。`,
      productNature,
      productRefs.length
        ? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源：视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
        : "未上传产品图时，仍需生成一个干净高级的 SKG 产品广告画面，但不得保留原视频里的竞品包装或平台元素。",
      "首帧和尾帧只用于控制画面起止、构图、场景和动作方向；如果首尾帧里有竞品、文字包装或非 SKG 产品，必须替换为上传的 SKG 产品参考。",
      "使用首帧和尾帧生成连续过渡视频：首帧必须严格作为视频开始画面，尾帧必须作为视频结束目标画面，中间只做自然运动补间。",
      "生成一段单镜头连续视频，一镜到底，从首帧平滑过渡到尾帧；不要跳切，不要突然换场景，不要突然换主体，不要蒙太奇，不要多镜头拼接。",
      "如果提供了原视频链接，把它只作为节奏、镜头运动、动作顺序和画面调度参考；不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
      "时间线：0%-15% 锁住首帧构图并轻微启动；15%-85% 做平滑连续运动；85%-100% 缓慢贴近尾帧并稳定收住。",
      `主体改造：${subjectDirection}`,
      `产品替换：${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示，不要放在脸上、手臂上、桌面当摆件，也不要变成瓶子、面霜、医疗设备或食品。`,
      `场景改造：${sceneDirection}`,
      `连续动作和镜头：${actionDirection}`,
      `首帧：${labelOf(firstRef, "当前分镜关键帧")}`,
      `尾帧：${labelOf(lastRef, "未指定，按首帧小幅自然运动收尾")}`,
      `SKG 产品参考：${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join("；") : "SKG 产品视觉主角"}`,
      sourceScene,
      sourceStyle,
      sourceObjects,
      "产品一致性要求：整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装；不要生成第二种产品，不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例，不要凭空增加屏幕、线缆、文字标签或说明书。",
      "产品呈现要求：至少一次让产品在脖子/肩颈位置清晰占据视觉中心，边缘清楚、材质真实、比例可信；手部接触产品时不要遮挡关键外观，产品不能融化、扭曲、穿帮或漂移。",
      "状态改善要求：画面应形成明确的使用前后感受变化：使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷；使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松，不要承诺治疗。",
      "运动要求：动作幅度小而连续，速度均匀，手部和产品位置前后一致，产品外形不变形，人物表情和姿态不漂移，背景只允许轻微景深和光影变化。",
      "商业质感：真实拍摄感，干净高级，柔和稳定打光，产品边缘清晰，材质真实，画面无抖动、无拉伸、无闪烁。",
      "禁止：字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
    ].join("\n")

    try {
      toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
      const sourceUrl = job.url?.trim()
      const updated = await generateStoryboardVideo(job.id, frameIdx, {
        prompt,
        duration,
        first_image: firstRef,
        last_image: lastRef,
        product_images: productRefs,
        subject_image: firstRef,
        scene_image: null,
        product_image: productRefs[0] ?? null,
        action_image: null,
        source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
        model,
        size: "720x1280",
      })
      setJob(updated)
      void navigator.clipboard?.writeText(prompt).catch(() => {})
      toast.success("视频任务已进入 Video Gen 节点")
    } catch (e) {
      toast.error("提交视频失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [job, selectedFrames, setJob])

  const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
    if (!job) return
    const frame = job.frames.find((f) => f.index === frameIdx)
    if (!frame) return
    if (!shot.product_image || !shot.person_image || !shot.scene_image || !shot.product_region || !shot.action_text?.trim()) {
      toast.error("产品融合镜头缺少产品图、人物图、区域、场景图或描述词")
      return
    }
    const duration = shot.duration && shot.duration > 0 ? shot.duration : 5
    const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
    try {
      toast.info(`生成融合引导图 · GPT Image 2 位置约束 · 镜头 ${shot.id || ""}`)
      const guideRef = await createProductFusionGuide(job.id, {
        ...shot,
        image_model: "gpt-image-2",
        video_model: "seedance",
      })
      const region = shot.product_region
      const prompt = [
        `竖屏 9:16，${duration.toFixed(1)} 秒，Seedance 产品融合视频。`,
        "图片模型固定为 GPT Image 2：已根据白底人物图和手动画框生成产品融合引导图；引导图是产品尺寸、位置、贴合关系和起始构图的最高优先级参考。",
        "视频模型固定为 Seedance：生成单镜头连续视频，不跳切，不换主体，不改变产品身份。",
        `产品区域：x=${region.x.toFixed(3)}, y=${region.y.toFixed(3)}, w=${region.w.toFixed(3)}, h=${region.h.toFixed(3)}。产品只能在这个框对应的人物/身体/手部区域内融合，不能漂移到其他位置，也不能明显超出框架。`,
        `产品图：${labelOf(shot.product_image, "SKG 白底产品图")}。严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例。`,
        `白底人物图：${labelOf(shot.person_image, "人物姿态参考")}。人物姿态、手部接触点和产品佩戴关系以这张图为准。`,
        `场景图：${labelOf(shot.scene_image, "场景参考")}。背景、空间、光线和气氛以这张图为准，但不要改变产品框内位置。`,
        `动作描述：${shot.action_text.trim()}`,
        "融合要求：产品必须按引导图位置自然贴合人物或手部，尺寸可信，透视一致，边缘清晰，不能悬浮、穿帮、融化、扭曲或变成其他物体。",
        "场景要求：把白底人物姿态自然放入场景图的环境中，光线方向和阴影要统一，背景不要出现水印、平台 UI、字幕或竞品包装。",
        "商业质感：真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
        "禁止：文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、产品超过指定融合区域。",
      ].join("\n")
      const updated = await generateStoryboardVideo(job.id, frameIdx, {
        prompt,
        duration,
        first_image: guideRef,
        last_image: null,
        product_images: [shot.product_image, shot.person_image, shot.scene_image].filter(Boolean) as ImageRef[],
        subject_image: shot.person_image,
        scene_image: shot.scene_image,
        product_image: shot.product_image,
        action_image: guideRef,
        source_ref: null,
        model: "seedance",
        size: "720x1280",
      })
      setJob(updated)
      void navigator.clipboard?.writeText(prompt).catch(() => {})
      toast.success("产品融合视频已进入 Video Gen 队列")
    } catch (e) {
      toast.error("产品融合生成失败：" + (e instanceof Error ? e.message : String(e)))
    }
  }, [job, setJob])

  // 启动恢复：URL ?job=xxx,yyy 优先；否则从后端拉全部历史（按 mtime 倒序，最新放末尾）
  useEffect(() => {
    const params = new URLSearchParams(window.location.search)
    const ids = (params.get("job") ?? "").split(",").filter(Boolean)
    const restore = async () => {
      let targetIds = ids
      if (targetIds.length === 0) {
        try {
          const list = await listJobs()
          targetIds = list.map((s) => s.id).reverse()
        } catch {
          return
        }
      }
      if (targetIds.length === 0) return
      const results = await Promise.all(targetIds.map((id) => getJob(id).catch(() => null)))
      const valid = results.filter((j): j is Job => !!j)
      if (valid.length > 0) {
        setJobs(valid)
        setActiveJobId(valid[valid.length - 1].id)
      }
    }
    void restore()
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  // 写回 URL（所有 jobs id 用 , 分隔）
  useEffect(() => {
    const url = new URL(window.location.href)
    if (jobs.length > 0) {
      url.searchParams.set("job", jobs.map((j) => j.id).join(","))
    } else {
      url.searchParams.delete("job")
    }
    window.history.replaceState({}, "", url.toString())
  }, [jobs.length])

  // 恢复已保存的分镜选择：刷新页面后，已有 storyboard 的帧仍应出现在顶部编排栏。
  useEffect(() => {
    if (!job || job.frames.length === 0) return
    const persisted = job.frames.filter((f) => !!f.storyboard).map((f) => f.index)
    if (persisted.length === 0) return
    setSelectedFrames((prev) => {
      let changed = false
      const next = new Set(prev)
      for (const idx of persisted) {
        if (!next.has(idx)) {
          next.add(idx)
          changed = true
        }
      }
      return changed ? next : prev
    })
  }, [job?.id, job?.frames])

  // 轮询 Job：任一视频在下载 / 抽帧 / 生视频时都继续轮询，支持多个抽帧任务排队。
  const prevStatusRef = useRef<string | null>(null)
  useEffect(() => {
    if (jobs.length === 0) return
    // 状态切到 downloaded 时提示用户点解析（仅一次）
    if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") {
      toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 })
    }
    prevStatusRef.current = job?.status ?? null

    const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
    const runningIds = jobs
      .filter((item) => {
        const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
        return runningVideo || !TERMINAL.includes(item.status)
      })
      .map((item) => item.id)

    if (runningIds.length === 0) {
      if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
      return
    }
    pollRef.current = setInterval(async () => {
      try {
        const latestJobs = await Promise.all(runningIds.map((id) => getJob(id).catch(() => null)))
        const byId = new Map(latestJobs.filter((item): item is Job => !!item).map((item) => [item.id, item]))
        if (byId.size > 0) {
          setJobs((prev) => prev.map((item) => byId.get(item.id) ?? item))
        }
      } catch { /* silent */ }
    }, 1500)
    return () => { if (pollRef.current) clearInterval(pollRef.current) }
  }, [
    job?.id,
    job?.status,
    jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
  ])

  const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
  const handleToggleNodePin = useCallback((id: string) => {
    setPinnedNodes((prev) => {
      const next = new Set(prev)
      if (next.has(id)) next.delete(id); else next.add(id)
      try { window.localStorage.setItem(NODE_PINS_KEY, JSON.stringify([...next])) } catch {}
      return next
    })
  }, [])

  const nodeData: NodeData = useMemo(() => ({
    job,
    jobs,
    activeJobId,
    submitting,
    analyzing,
    frameTargets,
    frameCounts,
    frameQualities,
    selectedFrames,
    expandedFrame,
    framePanelScale,
    framePanelPinned,
    framePanelDock,
    videoPanelJobId,
    videoPanelScale,
    videoPanelDock,
    onSubmitUrl: handleSubmit,
    onUploadFile: handleUpload,
    onAnalyze: handleAnalyze,
    onAnalyzeJob: handleAnalyzeJob,
    onFrameTargetChange: handleFrameTargetChange,
    onFrameCountChange: handleFrameCountChange,
    onFrameQualityChange: handleFrameQualityChange,
    onToggleFrame: handleToggleFrame,
    onExpandFrame: setExpandedFrame,
    onOpenFramePanel: handleOpenFramePanel,
    onFramePanelScaleChange: handleFramePanelScaleChange,
    onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"),
    onFramePanelDockChange: setFramePanelDock,
    onCloseExpandedFrame: () => setExpandedFrame(null),
    onAddManualFrame: handleAddManualFrame,
    onAddManualFrameForJob: handleAddManualFrameForJob,
    onOpenVideoPanel: handleOpenVideoPanel,
    onCloseVideoPanel: () => setVideoPanelJobId(null),
    onVideoPanelScaleChange: handleVideoPanelScaleChange,
    onVideoPanelDockChange: setVideoPanelDock,
    onSwitchJob: handleSwitchJob,
    onJobUpdate: setJob as any,
    onDeleteJob: handleDeleteJob,
    onDeleteFrame: handleDeleteFrame,
    onDeleteFrameForJob: handleDeleteFrameForJob,
    onDeleteGenerated: handleDeleteGenerated,
    onDeleteVideo: handleDeleteVideo,
    onDeleteCutout: handleDeleteCutout,
    onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
    onOpenWorkbench: (idx?: number) => {
      if (typeof idx === "number") setStoryboardFrame(idx)
      setWorkbenchOpen(true)
    },
    clipboard,
    onCopyImage: handleCopyImage,
    onGenerateProductFusionVideo: handleGenerateProductFusionVideo,
    pinnedNodes,
    onToggleNodePin: handleToggleNodePin,
  }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, clipboard, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin])

  // 用 useNodesState 让 ReactFlow 自己管位置（避免轮询时重置 drag）
  const savedSizes = useMemo(() => loadNodeSizes(), [])
  const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
    LAYOUT.map((n) => {
      const s = savedSizes[n.id] ?? {}
      const w = s.w ?? n.w
      const h = s.h
      const isPinned = pinnedNodes.has(n.id)
      return {
        id: n.id,
        type: n.type,
        position: { x: n.x, y: n.y },
        data: nodeData,
        draggable: !isPinned,
        width: w,
        ...(typeof h === "number" ? { height: h } : {}),
        style: { width: w, ...(typeof h === "number" ? { height: h } : {}) },
      }
    }),
  )

  // pinned 变化时同步每个节点 draggable
  useEffect(() => {
    setNodes((prev) => prev.map((n) =>
      FLOATING_PANEL_IDS.has(n.id) ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
    ))
  }, [pinnedNodes, setNodes])

  // 首次挂载、所有节点都被 ReactFlow 测量到后，自动整理一次（用户偏好：每次刷新自动归位）
  const initialLayoutDone = useRef(false)

  // 自动排版：保留每个节点的当前宽高（用户为方便看而调过的尺寸），只重新计算 position
  // 让卡片按管线列分组、列间和列内留出统一间距，不重叠。
  const handleResetLayout = useCallback(() => {
    const zoom = flowRef.current?.getZoom?.() ?? 1
    const measure = (id: string): { w: number; h: number } => {
      const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
      if (!el) return { w: 320, h: 220 }
      const r = el.getBoundingClientRect()
      return { w: r.width / zoom, h: r.height / zoom }
    }

    // 按管线列分组（顶 → 底）：图层 1 输入 → 5 合成
    const COLUMNS: string[][] = [
      ["input"],
      ["visual", "audio"],
      ["compose"],
    ]
    const GAP_X = 80
    const GAP_Y = 56
    const START_X = 40
    const START_Y = 60

    const positions: Record<string, { x: number; y: number }> = {}
    let cursorX = START_X
    for (const col of COLUMNS) {
      let cursorY = START_Y
      let colMaxW = 0
      for (const id of col) {
        const { w, h } = measure(id)
        positions[id] = { x: cursorX, y: cursorY }
        cursorY += h + GAP_Y
        if (w > colMaxW) colMaxW = w
      }
      cursorX += colMaxW + GAP_X
    }

    setNodes((prev) => prev.map((n) => {
      if (FLOATING_PANEL_IDS.has(n.id)) return n
      const p = positions[n.id]
      if (!p) return n
      return { ...n, position: { x: p.x, y: p.y } }
    }))
    setTimeout(() => flowRef.current?.fitView?.({ padding: 0.12, duration: 400 }), 30)
    toast.success("已自动排版 · 保留每个节点的尺寸")
  }, [setNodes])

  // 首次：等所有节点都被 ReactFlow 测量到（n.measured 出现）后自动排版一次，避免叠在一起
  useEffect(() => {
    if (initialLayoutDone.current) return
    const main = nodes.filter((n) => !FLOATING_PANEL_IDS.has(n.id))
    if (main.length === 0) return
    const allMeasured = main.every((n) => {
      const m = (n as any).measured as { width?: number; height?: number } | undefined
      return m && typeof m.width === "number" && typeof m.height === "number" && m.height > 0
    })
    if (!allMeasured) return
    initialLayoutDone.current = true
    setTimeout(() => handleResetLayout(), 80)
  }, [nodes, handleResetLayout])

  // 持久化每个节点宽 / 高到 localStorage（KeyframePanelNode 自己管尺寸，不写回）
  useEffect(() => {
    const sizes: Record<string, NodeSize> = {}
    for (const n of nodes) {
      if (FLOATING_PANEL_IDS.has(n.id)) continue
      const w = typeof n.width === "number" ? Math.round(n.width) : undefined
      const h = typeof n.height === "number" ? Math.round(n.height) : undefined
      if (w === undefined && h === undefined) continue
      sizes[n.id] = { ...(w !== undefined ? { w } : {}), ...(h !== undefined ? { h } : {}) }
    }
    try { window.localStorage.setItem(NODE_SIZES_KEY, JSON.stringify(sizes)) } catch {}
  }, [nodes])
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>(
    EDGES_RAW.map(([from, to], i) => ({
      id: `e${i}`, source: from, target: to, animated: false, type: "default",
    })),
  )

  // Job 数据变化时只更新节点 data 不动 position
  useEffect(() => {
    setNodes((prev) => prev.map((n) => ({ ...n, data: nodeData })))
  }, [nodeData, setNodes])

  // 关键帧详情面板是独立 ReactFlow 节点：可拖动、跟随画布缩放。
  // 已打开时点击其他关键帧只切换内容，不移动用户拖好的面板位置。
  useEffect(() => {
    if (!job || expandedFrame === null) {
      setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID))
      return
    }

    let shouldFocusNewPanel = false
    setNodes((prev) => {
      const visualNode = prev.find((n) => n.id === "visual")
      const inputNode = prev.find((n) => n.id === "input")
      const defaultPosition = {
        x: (inputNode?.position.x ?? 40) - 820,
        y: (visualNode?.position.y ?? 60),
      }
      const exists = prev.some((n) => n.id === KEYFRAME_PANEL_ID)
      if (exists) {
        return prev.map((n) => n.id === KEYFRAME_PANEL_ID
          ? {
              ...n,
              data: nodeData,
              draggable: !framePanelPinned,
              dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
            }
          : n,
        )
      }
      shouldFocusNewPanel = true
      return [
        ...prev,
        {
          id: KEYFRAME_PANEL_ID,
          type: "keyframePanel",
          position: defaultPosition,
          data: nodeData,
          draggable: !framePanelPinned,
          dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
          selectable: true,
        },
      ]
    })
    if (shouldFocusNewPanel && !framePanelPinned) {
      window.setTimeout(() => {
        flowRef.current?.fitView?.({
          nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "visual" }],
          padding: 0.18,
          duration: 260,
        })
      }, 0)
    }
  }, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes])

  // 视频抽帧面板也是独立 ReactFlow 节点：默认在 Input 附近打开，可拖动；吸附后走 portal 固定到屏幕边缘。
  useEffect(() => {
    const panelJob = videoPanelJobId ? jobs.find((j) => j.id === videoPanelJobId) ?? null : null
    if (!panelJob?.video_url) {
      setNodes((prev) => prev.filter((n) => n.id !== VIDEO_FRAME_PANEL_ID))
      return
    }

    const focusKey = `${videoPanelJobId}:${videoPanelOpenTick}:${videoPanelDock}`
    let panelWasCreated = false
    setNodes((prev) => {
      const inputNode = prev.find((n) => n.id === "input")
      const defaultPosition = {
        x: inputNode?.position.x ?? 40,
        y: (inputNode?.position.y ?? 240) - 650,
      }
      const exists = prev.some((n) => n.id === VIDEO_FRAME_PANEL_ID)
      if (exists) {
        return prev.map((n) => n.id === VIDEO_FRAME_PANEL_ID
          ? {
              ...n,
              data: nodeData,
              draggable: videoPanelDock === "canvas",
              dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined,
            }
          : n,
        )
      }
      panelWasCreated = true
      return [
        ...prev,
        {
          id: VIDEO_FRAME_PANEL_ID,
          type: "videoFramePanel",
          position: defaultPosition,
          data: nodeData,
          draggable: videoPanelDock === "canvas",
          dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined,
          selectable: true,
        },
      ]
    })

    if (videoPanelDock === "canvas" && (panelWasCreated || lastVideoPanelFocusKey.current !== focusKey)) {
      lastVideoPanelFocusKey.current = focusKey
      window.setTimeout(() => {
        flowRef.current?.fitView?.({
          nodes: [{ id: VIDEO_FRAME_PANEL_ID }, { id: "input" }],
          padding: 0.18,
          duration: 260,
        })
      }, 0)
    }
  }, [videoPanelJobId, videoPanelOpenTick, videoPanelDock, jobs, nodeData, setNodes])

  // 边的 animated 状态跟 Job 进度联动
  useEffect(() => {
    const doneOf: Record<string, boolean> = {
      input: !!job?.video_url,
      visual: !!job && (job.frames.length > 0 || (job.generated_videos?.length ?? 0) > 0),
      asr: !!job && job.transcript.length > 0,
      translate: !!job && (job.transcript.some((s) => s.zh) ?? false),
      rewrite: !!job && (job.transcript.some((s) => s.zh) ?? false),
    }
    setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] })))
  }, [job, setEdges])

  return (
    <>
      <div className="canvas-bg" />
      <main className="relative h-screen w-screen overflow-hidden flex">
        {/* 自动整理布局 — 主题切换上方，一键恢复默认位置和宽度 */}
        <div className="absolute z-30 pointer-events-auto" style={{ bottom: 228, left: 12 }}>
          <button
            type="button"
            onClick={handleResetLayout}
            className="glass-node h-9 w-9 inline-flex items-center justify-center rounded-xl"
            style={{ width: 36, height: 36, padding: 0, borderRadius: 12 }}
            title="自动排版 · 保留每个节点的尺寸，重新排好间距和列布局"
          >
            <LayoutGrid className="h-4 w-4" />
          </button>
        </div>
        {/* 主题切换 — 左下角 Controls 上方（错开） */}
        <div className="absolute z-30 pointer-events-auto" style={{ bottom: 180, left: 12 }}>
          <ThemeToggle />
        </div>

        {/* 右区：DAG 节点流图（原顶部 storyboard dock 已删除） */}
        <section className="relative flex-1 min-h-0 flex flex-col">
          <div className="relative flex-1 min-h-0">
          <ReactFlow
            nodes={nodes}
            edges={edges}
            onInit={(instance) => { flowRef.current = instance }}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            nodeTypes={NODE_TYPES}
            colorMode={resolvedTheme === "light" ? "light" : "dark"}
            fitView
            fitViewOptions={{ padding: 0.12 }}
            minZoom={0.2}
            maxZoom={1.5}
            proOptions={{ hideAttribution: true }}
          >
            <Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
            <Controls position="bottom-left" />
            <MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
          </ReactFlow>
          </div>
        </section>

        <Toaster theme="system" position="bottom-center" />

      </main>
    </>
  )
}
