"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { type NodeProps, useReactFlow } from "@xyflow/react"
import {
  Link2, Upload, Download, Scissors, Image as ImageIcon,
  Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, Maximize2,
  Copy, Trash2,
} from "lucide-react"
import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import {
  type Job, type ImageRef,
  apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"

export interface NodeData {
  job: Job | null            // 当前 active job
  jobs: Job[]                // 所有 job 列表
  activeJobId: string | null
  submitting: boolean
  analyzing: boolean
  selectedFrames: Set<number>
  expandedFrame: number | null
  framePanelScale?: number
  framePanelPinned?: boolean
  onSubmitUrl: (url: string) => void
  onUploadFile: (file: File) => void
  onAnalyze: () => void
  onToggleFrame: (idx: number) => void
  onExpandFrame: (idx: number) => void
  onOpenFramePanel?: (idx: number) => void  // 打开/找回画布内关键帧详情面板
  onFramePanelScaleChange?: (scale: number) => void
  onFramePanelPinnedChange?: (pinned: boolean) => void
  onCloseExpandedFrame: () => void
  onAddManualFrame: (t: number) => void
  onOpenVideoLightbox: () => void
  onSwitchJob: (id: string) => void
  onJobUpdate: (j: Job) => void
  onOpenPanel?: (key: string) => void  // 控制 sidebar 哪个 drawer 展开
  onDeleteFrame?: (idx: number) => void  // 删整张关键帧
  onDeleteGenerated?: (frameIdx: number, genId: string) => void  // 删单张生成图
  onDeleteVideo?: (videoId: string) => void  // 删 Video Gen 任务
  onOpenStoryboard?: (frameIdx: number) => void  // 打开分镜头编排专属面板
  onOpenWorkbench?: (frameIdx?: number) => void  // 展开顶部分镜编排内嵌面板
  onCopyImage?: (ref: ImageRef) => void  // 复制图片到全局剪贴板（粘贴到分镜头编排插槽）
}

/* ---- 状态映射工具 ---- */
function inputStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  return "done"
}
function downloadStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  if (job.status === "failed" && job.progress < 30) return "failed"
  if (job.status === "downloading") return "running"
  if (job.video_url) return "done"
  return "pending"
}
function splitStatus(job: Job | null): NodeStatus {
  if (!job || !job.video_url) return "pending"
  if (job.status === "failed" && job.progress >= 20 && job.progress < 50) return "failed"
  if (job.status === "splitting") return "running"
  if (["frames_extracted", "transcribing", "transcribed"].includes(job.status)) return "done"
  return "pending"
}
function keyframeStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  if (job.status === "failed" && job.progress >= 50 && job.progress < 70) return "failed"
  if (job.frames.length === 0 && job.status === "splitting") return "running"
  if (job.frames.length > 0) return "done"
  return "pending"
}
function asrStatus(job: Job | null): NodeStatus {
  if (!job) return "pending"
  if (job.status === "transcribing") return "running"
  if (job.transcript.length > 0) return "done"
  if (job.status === "failed" && job.progress >= 70) return "failed"
  return "pending"
}

/* ============================================================
   1. InputNode — TK 链接 / 上传
   ============================================================ */
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
  const d: NodeData = data
  const [url, setUrl] = useState("")
  const [videoT, setVideoT] = useState(0)
  const [addingFrame, setAddingFrame] = useState(false)
  const [videoExpanded, setVideoExpanded] = useState(false)
  const fileRef = useRef<HTMLInputElement>(null)
  const videoRef = useRef<HTMLVideoElement>(null)
  const job = d.job

  // 是否已下载 → 显示视频 + 解析按钮
  const hasVideo = !!job?.video_url
  const isDownloading = job?.status === "downloading" || job?.status === "created"
  const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status)
  const isDone = job?.status === "transcribed"
  const hasFrames = (job?.frames.length ?? 0) > 0
  const inputLocked = isDownloading || d.submitting

  return (
    <div className="relative" style={{ width: "100%", height: "100%" }}>
      {/* 多视频缩略图浮条 — 「+」在最左，job 按时间倒序（最新靠左高亮），统一高度 64，宽度按视频原比例，一行横滚。
          浮条宽度 = 节点宽度（节点拖宽后浮条同步变宽，可见更多缩略图，少滚动）。 */}
      {!videoExpanded && d.jobs.length > 0 && (
        <div
          className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
          style={{ bottom: "calc(100% + 12px)" }}
        >
          {/* + 再上传一个（放在最前面） */}
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
            title="再上传一个视频"
            className="shrink-0 rounded-md border border-dashed border-white/30 hover:border-white/50 bg-white/[0.04] hover:bg-white/[0.08] inline-flex items-center justify-center text-white/60 hover:text-white transition"
            style={{ width: 36, height: 64 }}
          >
            <Plus className="h-4 w-4" />
          </button>
          {[...d.jobs].reverse().map((j) => {
            const isActive = j.id === d.activeJobId
            const ready = !!j.video_url
            return (
              <button
                key={j.id}
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  if (isActive && ready) setVideoExpanded(true)
                  else d.onSwitchJob(j.id)
                }}
                title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · ${isActive ? "点击展开" : "点击切换"}` : "下载中…"}
                className={`shrink-0 group relative rounded-md overflow-hidden border shadow-lg transition hover:-translate-y-0.5 ${
                  isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
                }`}
                style={{ height: 64, aspectRatio: ready ? `${j.width}/${j.height}` : "9/16" }}
              >
                {ready ? (
                  <video
                    src={videoUrl(j.id)}
                    muted
                    loop
                    playsInline
                    preload="metadata"
                    className="block w-full h-full object-cover bg-black"
                    onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
                    onMouseLeave={(e) => {
                      const v = e.target as HTMLVideoElement
                      v.pause()
                      v.currentTime = 0
                    }}
                  />
                ) : (
                  <div className="w-full h-full bg-black/60 flex items-center justify-center">
                    <Loader2 className="h-4 w-4 animate-spin text-white/60" />
                  </div>
                )}
                <div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
                  {ready ? `${j.duration.toFixed(1)}s` : "…"}
                </div>
              </button>
            )
          })}
        </div>
      )}

      {/* 展开态 — 稍微放大（360 宽），含 controls + 加帧按钮，不全屏 */}
      {hasVideo && job && videoExpanded && (
        <div
          className="absolute left-0 right-0 flex justify-center"
          style={{ bottom: "calc(100% + 12px)" }}
        >
          <div
            onClick={(e) => e.stopPropagation()}
            className="relative rounded-xl overflow-hidden border border-white/25 shadow-2xl bg-black"
            style={{ width: 360, animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
          >
            <video
              ref={videoRef}
              src={videoUrl(job.id)}
              controls
              autoPlay
              playsInline
              preload="auto"
              onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
              className="block w-full bg-black"
              style={{ aspectRatio: `${job.width}/${job.height}`, maxHeight: "60vh" }}
            />
            <div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md gap-2">
              <button
                type="button"
                disabled={addingFrame}
                onClick={async (e) => {
                  e.stopPropagation()
                  const t = videoRef.current?.currentTime ?? videoT
                  setAddingFrame(true)
                  try { await d.onAddManualFrame(t) } finally { setAddingFrame(false) }
                }}
                className="flex-1 text-[11.5px] py-1.5 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 inline-flex items-center justify-center gap-1.5 font-medium"
              >
                {addingFrame ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
                + 把 {videoT.toFixed(1)}s 加为关键帧
              </button>
              <button
                type="button"
                onClick={(e) => { e.stopPropagation(); setVideoExpanded(false) }}
                className="px-2.5 py-1.5 text-[11px] rounded-md bg-white/10 hover:bg-white/20 text-white"
              >
                收起
              </button>
            </div>
          </div>
        </div>
      )}

    <NodeShell
      type="input" status={inputStatus(job)}
      icon={<Link2 className="h-4 w-4" />}
      title="输入 · Input"
      subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
      selected={selected}
      hasTarget={false}
    >
      {/* URL + 上传入口 — 一直显示（即使已有视频，也可以继续加新的） */}
      <>
          <input
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            placeholder={hasVideo ? "再加一个 TK 链接" : "粘贴 TikTok 链接"}
            disabled={inputLocked}
            className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/60 dark:bg-black/40 border border-black/10 dark:border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
          />
          <div className="mt-2 flex gap-1.5">
            <button
              type="button"
              disabled={inputLocked || !url.trim()}
              onClick={() => { d.onSubmitUrl(url.trim()); setUrl("") }}
              className="flex-1 text-[11.5px] py-1.5 rounded-md bg-black text-white dark:bg-white dark:text-black hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center gap-1"
            >
              {(d.submitting || isDownloading) ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
              {isDownloading ? "下载中…" : hasVideo ? "+ 加链接" : "提交链接"}
            </button>
            <button
              type="button"
              disabled={inputLocked}
              onClick={() => fileRef.current?.click()}
              className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/60 dark:bg-white/[0.06] border border-black/10 dark:border-white/15 hover:bg-white/80 dark:hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
            >
              <Upload className="h-3 w-3" /> {hasVideo ? "再传一个" : "上传"}
            </button>
            <input
              ref={fileRef}
              type="file"
              accept="video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0]
                if (f) d.onUploadFile(f)
                e.target.value = ""
              }}
            />
          </div>
        </>

      {/* 已下载：仅元数据（视频缩略图浮在节点上方，点击进 lightbox） */}
      {hasVideo && job && (
        <>
          <div className="rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2 flex items-center justify-between text-[10.5px] font-mono">
            <span className="text-[var(--text-strong)]">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
            <span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
          </div>

          <button
            type="button"
            disabled={isAnalyzing || d.analyzing}
            onClick={d.onAnalyze}
            className={`mt-2 w-full text-[14px] py-3 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-semibold shadow-lg shadow-violet-500/30 ${
              !isAnalyzing && !d.analyzing && !isDone && !hasFrames ? "animate-[pulse_2s_ease-in-out_infinite] ring-2 ring-violet-400/40 ring-offset-2 ring-offset-transparent" : ""
            }`}
          >
            {(isAnalyzing || d.analyzing) ? (
              <><Loader2 className="h-4 w-4 animate-spin" /> 解析中…</>
            ) : isDone || hasFrames ? (
              "重新解析"
            ) : (
              <>▶ 点这里开始解析</>
            )}
          </button>
        </>
      )}
    </NodeShell>
    </div>
  )
}

/* ============================================================
   2. DownloadNode
   ============================================================ */
export function DownloadNode({ data, selected }: any) {
  const d: NodeData = data
  const st = downloadStatus(d.job)
  return (
    <NodeShell
      type="process" status={st}
      icon={<Download className="h-4 w-4" />}
      title="下载 · Download"
      subtitle="STEP 2 · yt-dlp"
      selected={selected}
    >
      <div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
        {d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
      </div>
      {d.job && st === "done" && (
        <div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] font-mono text-[var(--text-faint)]">
          <div>分辨率<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.width}×{d.job.height}</span></div>
          <div>时长<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.duration.toFixed(1)}s</span></div>
        </div>
      )}
    </NodeShell>
  )
}

/* ============================================================
   3. SplitNode
   ============================================================ */
export function SplitNode({ data, selected }: any) {
  const d: NodeData = data
  return (
    <NodeShell
      type="process" status={splitStatus(d.job)}
      icon={<Scissors className="h-4 w-4" />}
      title="拆分 · Split"
      subtitle="STEP 3 · ffmpeg"
      selected={selected}
    >
      <div className="grid grid-cols-2 gap-2 text-[11px]">
        <div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
          <div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">视频流</div>
          <div className="text-[var(--text-strong)] mt-0.5">→ 关键帧</div>
        </div>
        <div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
          <div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">音频流</div>
          <div className="text-[var(--text-strong)] mt-0.5">→ ASR</div>
        </div>
      </div>
    </NodeShell>
  )
}

/* ============================================================
   4. KeyframeNode — 缩略图横排浮在节点上方，点击展开 lightbox
   ============================================================ */
const KEYFRAME_WIDTH = 360
const THUMB_W = 64
const THUMB_GAP = 6

export function KeyframeNode({ data, selected }: any) {
  const d: NodeData = data
  const st = keyframeStatus(d.job)
  const frames = d.job?.frames ?? []
  const jobId = d.job?.id
  const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16"

  return (
    <div className="relative" style={{ width: "100%", height: "100%" }}>
      {/* 缩略图浮条（节点上方，最多 5 个一行，多行向上扩展） */}
      {frames.length > 0 && jobId && (
        <div
          className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
          style={{ bottom: "calc(100% + 12px)" }}
        >
          {frames.map((f) => {
            const isSel = d.selectedFrames.has(f.index)
            return (
              <div
                key={f.index}
                className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
                  isSel
                    ? "border-emerald-400 ring-2 ring-emerald-400/60"
                    : "border-white/30 dark:border-white/20"
                }`}
                style={{
                  aspectRatio: d.job && d.job.height > 0
                    ? `${d.job.width}/${d.job.height}`
                    : "16/9",
                }}
              >
              <button
                onClick={(e) => {
                  e.stopPropagation()
                  ;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
                }}
                title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · hover 看大图 · 点击打开 / 找回详情面板`}
                className="absolute inset-0 w-full h-full"
              >
                <img
                  src={effectiveFrameUrl(jobId, f)}
                  alt={`frame ${f.index}`}
                  className="absolute inset-0 w-full h-full object-cover rounded-md"
                />
                {isSel && (
                  <div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
                )}
                {(f.cleaned_url || (f.elements?.some((e) => hasCutout(e)))) && (
                  <div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
                    {f.cleaned_url && (
                      <span title="已清洗" className="bg-cyan-500/85 text-white text-[8px] font-bold px-1 py-0.5 rounded-sm">✨</span>
                    )}
                    {(() => {
                      const cutN = f.elements?.filter((e) => hasCutout(e)).length ?? 0
                      return cutN > 0 ? (
                        <span title={`${cutN} 个元素已抠图`} className="bg-violet-500/85 text-white text-[8px] font-mono font-bold px-1 py-0.5 rounded-sm">
                          {cutN}
                        </span>
                      ) : null
                    })()}
                  </div>
                )}
                <div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
                  {f.timestamp.toFixed(1)}s
                </div>
              </button>
              {/* 复制按钮：常驻可见 — 复制该关键帧到剪贴板 */}
              {d.onCopyImage && (
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    d.onCopyImage?.({
                      kind: "keyframe",
                      frame_idx: f.index,
                      label: `分镜 ${f.index + 1} 关键帧`,
                    })
                  }}
                  title="📋 复制此图（到分镜头编排工作台插槽粘贴）"
                  className="absolute top-1 left-1 h-5 w-5 rounded-full bg-violet-500/90 backdrop-blur text-white shadow-md hover:bg-violet-400 hover:scale-110 inline-flex items-center justify-center transition z-[70] text-[10px] leading-none"
                >
                  📋
                </button>
              )}
              {/* 删除按钮：hover 时右上角浮出 */}
              {d.onDeleteFrame && (
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    if (confirm(`删除分镜 ${f.index + 1}（${f.timestamp.toFixed(1)}s）？相关清洗 / 抠图 / 生成图都会一并清除。`)) {
                      d.onDeleteFrame?.(f.index)
                    }
                  }}
                  title="删除该关键帧"
                  className="absolute top-1 right-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70]"
                >
                  <X className="h-3 w-3" />
                </button>
              )}
              {/* hover 预览 — absolute 浮在缩略图上方，跟着 ReactFlow 画布缩放平移 */}
              <div
                className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
                style={{
                  bottom: "calc(100% + 10px)",
                  left: "50%",
                  transform: "translateX(-50%)",
                  transformOrigin: "bottom center",
                }}
              >
                <div className="rounded-lg overflow-hidden border-2 border-orange-300/50 bg-black shadow-2xl" style={{ width: 280 }}>
                  <div style={{ aspectRatio: aspectStr }}>
                    <img src={effectiveFrameUrl(jobId, f)} alt="" className="w-full h-full object-cover" />
                  </div>
                  <div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
                    <span>分镜 {f.index + 1}</span>
                    <span className="text-white/60 font-mono">{f.timestamp.toFixed(2)}s</span>
                  </div>
                </div>
              </div>
              </div>
            )
          })}
        </div>
      )}

      <NodeShell
        type="process" status={st}
      icon={<ImageIcon className="h-4 w-4" />}
        title="镜头拆解 · 元素提取"
        subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
        selected={selected}
      >
        {frames.length > 0 ? (() => {
          const cleanedCount = frames.filter((x) => x.cleaned_url).length
          const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0)
          const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
          return (
            <div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
              自动 <span className="text-[var(--text-strong)] font-medium">{frames.length}</span> 张
              {" · "}
              <span className={cleanedCount > 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗</span>
              {" · "}
              <span className={cutoutCount > 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图</span>
              <br />
              <span className="text-[10.5px] text-[var(--text-faint)]">
                点缩略图 → 清洗水印 / 提取可借鉴元素 → 改造成 SKG 画面素材
              </span>
            </div>
          )
        })() : (
          <div className="text-[11.5px] text-[var(--text-faint)] py-1">
            等待解析（默认 5 张）
          </div>
        )}
      </NodeShell>

    </div>
  )
}

/* ============================================================
   4b. KeyframePanelNode — 画布内可移动详情面板
   ============================================================ */
export function KeyframePanelNode({ data }: any) {
  const d: NodeData = data
  const { getZoom } = useReactFlow()
  const panelRef = useRef<HTMLDivElement>(null)
  const [pinRect, setPinRect] = useState<{ left: number; top: number }>({ left: 24, top: 72 })
  const scale = d.framePanelScale ?? 1
  const pinned = d.framePanelPinned ?? false

  const getStoryboardDockTop = () => {
    if (typeof window === "undefined") return 64
    const dock = document.querySelector<HTMLElement>('[data-storyboard-dock="true"]')
    const bar = document.querySelector<HTMLElement>('[data-storyboard-bar="true"]')
    const bottom = (dock ?? bar)?.getBoundingClientRect().bottom ?? 52
    return Math.max(56, Math.min(window.innerHeight - 120, bottom + 10))
  }

  useEffect(() => {
    if (!pinned || typeof window === "undefined") return

    const syncDock = () => {
      setPinRect({ left: 16, top: getStoryboardDockTop() })
    }

    syncDock()
    const bar = document.querySelector<HTMLElement>('[data-storyboard-dock="true"]')
      ?? document.querySelector<HTMLElement>('[data-storyboard-bar="true"]')
    let observer: ResizeObserver | null = null
    if (bar && "ResizeObserver" in window) {
      observer = new ResizeObserver(syncDock)
      observer.observe(bar)
    }
    window.addEventListener("resize", syncDock)

    return () => {
      observer?.disconnect()
      window.removeEventListener("resize", syncDock)
    }
  }, [pinned])

  if (!d.job || d.expandedFrame === null) return null
  const active = d.job.frames.find((f) => f.index === d.expandedFrame)
  const panelWidth = Math.round(760 * scale)
  const panelHeight = Math.round(746 * scale)
  const bodyHeight = Math.max(520, panelHeight - 27)
  const setScale = (next: number) => {
    const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
    d.onFramePanelScaleChange?.(clamped)
  }

  const togglePinned = () => {
    if (!pinned) {
      const zoom = getZoom()
      setScale(scale * zoom)
      setPinRect({ left: 16, top: getStoryboardDockTop() })
    }
    d.onFramePanelPinnedChange?.(!pinned)
  }

  const startResize = (e: React.PointerEvent) => {
    e.preventDefault()
    e.stopPropagation()
    const startX = e.clientX
    const startY = e.clientY
    const startScale = scale
    const zoom = pinned ? 1 : getZoom()
    const onMove = (ev: PointerEvent) => {
      const dx = (ev.clientX - startX) / zoom
      const dy = (ev.clientY - startY) / zoom
      const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 746
      setScale(startScale + delta)
    }
    const onUp = () => {
      window.removeEventListener("pointermove", onMove)
      window.removeEventListener("pointerup", onUp)
    }
    window.addEventListener("pointermove", onMove)
    window.addEventListener("pointerup", onUp)
  }

  const panel = (
    <div
      ref={panelRef}
      className="relative rounded-2xl border border-white/15 bg-black/70 shadow-2xl overflow-hidden"
      style={{ width: panelWidth, height: panelHeight, boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)" }}
    >
      <div className={`keyframe-panel-drag flex h-7 items-center justify-between bg-gradient-to-r from-orange-500 to-red-500 px-3 text-white ${pinned ? "cursor-default" : "cursor-move"}`}>
        <div className="flex min-w-0 items-center gap-2">
          <ImageIcon className="h-3.5 w-3.5 shrink-0" />
          <span className="truncate text-[12px] font-semibold">关键帧详情 · 元素提取</span>
          <span className="shrink-0 text-[10px] font-mono text-white/65">
            {active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
          </span>
        </div>
        <div className="flex items-center gap-1.5">
          <span className="mr-1 text-[10px] text-white/60">
            {pinned ? "已钉住左侧 · 不跟画布" : "拖动标题栏移动 · 可钉住"}
          </span>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); togglePinned() }}
            className={`nodrag h-5 w-5 rounded inline-flex items-center justify-center transition ${
              pinned
                ? "bg-white text-orange-600 shadow"
                : "bg-white/10 text-white/85 hover:bg-white/20 hover:text-white"
            }`}
            title={pinned ? "取消钉住，回到画布节点" : "钉住到左侧，脱离画布缩放"}
          >
            <Pin className="h-3 w-3" />
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
            className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
            title="缩小面板"
          >
            -
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); setScale(1) }}
            className="nodrag h-5 min-w-9 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
            title="重置为 100%"
          >
            {Math.round(scale * 100)}%
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }}
            className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
            title="放大面板"
          >
            +
          </button>
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
            className="nodrag h-5 w-5 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
            title="关闭"
          >
            <X className="h-3 w-3" />
          </button>
        </div>
      </div>
      <div className="nodrag nowheel" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
        <FrameLightbox
          embedded
          jobId={d.job.id}
          frames={d.job.frames}
          activeIndex={d.expandedFrame}
          selected={d.selectedFrames}
          onClose={d.onCloseExpandedFrame}
          onChange={d.onExpandFrame}
          onToggleSelect={d.onToggleFrame}
          onJobUpdate={d.onJobUpdate}
          onCopyImage={d.onCopyImage}
        />
      </div>
      <button
        type="button"
        onPointerDown={startResize}
        className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-orange-400/35 hover:text-white inline-flex items-center justify-center"
        title="拖动右下角缩放面板"
      >
        <Maximize2 className="h-3.5 w-3.5" />
      </button>
    </div>
  )

  if (pinned && typeof document !== "undefined") {
    return createPortal(
      <div
        className="fixed z-[240]"
        style={{ left: pinRect.left, top: pinRect.top }}
      >
        {panel}
      </div>,
      document.body,
    )
  }

  return panel
}

/* ============================================================
   5. ASRNode — Gemini 转录
   ============================================================ */
export function ASRNode({ data, selected }: any) {
  const d: NodeData = data
  return (
    <NodeShell
      type="ai" status={asrStatus(d.job)}
      icon={<Mic className="h-4 w-4" />}
      title="声音文案 · ASR"
      subtitle="STEP 3 · 可选文案轨"
      selected={selected}
    >
      <div className="text-[11.5px] text-[var(--text-soft)]">
        Gemini 2.5 · 英文带时间戳分段
      </div>
      {d.job && d.job.transcript.length > 0 && (
        <div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
          {d.job.transcript.slice(0, 3).map((s) => (
            <div key={s.index} className="leading-snug">
              <span className="text-[var(--text-faint)] font-mono text-[10px] mr-1">
                {s.start.toFixed(1)}s
              </span>
              {s.en.slice(0, 60)}
              {s.en.length > 60 && "…"}
            </div>
          ))}
          {d.job.transcript.length > 3 && (
            <div className="text-[var(--text-faint)] text-[10px]">还有 {d.job.transcript.length - 3} 段…</div>
          )}
        </div>
      )}
    </NodeShell>
  )
}

/* ============================================================
   6. TranslateNode
   ============================================================ */
export function TranslateNode({ data, selected }: any) {
  const d: NodeData = data
  const hasZh = d.job?.transcript.some((s) => s.zh) ?? false
  const st: NodeStatus = !d.job ? "pending" :
    d.job.status === "transcribing" ? "running" :
    hasZh ? "done" :
    d.job.status === "failed" ? "failed" : "pending"
  return (
    <NodeShell
      type="ai" status={st}
      icon={<Languages className="h-4 w-4" />}
      title="翻译理解 · Translate"
      subtitle="STEP 4 · EN → ZH"
      selected={selected}
    >
      <div className="text-[11.5px] text-[var(--text-soft)]">
        中文翻译 · 段落级 · 实时输出
      </div>
      {hasZh && d.job && (
        <div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
          {d.job.transcript.slice(0, 3).map((s) => (
            <div key={s.index} className="leading-snug">{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}</div>
          ))}
        </div>
      )}
    </NodeShell>
  )
}

/* ============================================================
   7. RewriteNode (placeholder)
   ============================================================ */
export function RewriteNode({ selected }: any) {
  return (
    <NodeShell
      type="ai" status="pending"
      icon={<FileEdit className="h-4 w-4" />}
      title="产品文案 · Rewrite"
      subtitle="STEP 5 · 接 SKG 卖点"
      selected={selected}
    >
      <textarea
        placeholder="粘贴 SKG 产品信息 / 关键卖点（可作为视频脚本和镜头动作参考）"
        rows={3}
        disabled
        className="w-full text-[11.5px] px-2.5 py-2 rounded-md bg-white/30 dark:bg-white/[0.03] border border-dashed border-black/15 dark:border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
      />
      <div className="mt-1.5 text-[10px] text-[var(--text-faint)]">下一冲刺接入</div>
    </NodeShell>
  )
}

/* ============================================================
   6. StoryboardNode — 元素改造 + 分镜编排入口
   ============================================================ */
const IMAGEGEN_WIDTH = 360

export function StoryboardNode({ data, selected }: any) {
  const d: NodeData = data
  const job = d?.job

  // 上方浮条 = 所有 frame 的 elements 已提取图（"分镜头编排"的输入素材）
  type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; frameSrc: string; timestamp: number }
  const elementCrops: ElPreview[] = job
    ? job.frames.flatMap((f) =>
        (f.elements ?? [])
          .filter((e) => hasCutout(e))
          .map((e) => {
            const src = representativeCutoutUrl(job.id, f.index, e) || ""
            const cid = (e.cutouts && e.cutouts.length > 0)
              ? e.cutouts[e.cutouts.length - 1]
              : (e.cutout_id ?? "")
            return {
              frameIdx: f.index,
              elementId: e.id,
              name: e.name_zh,
              src,
              cid,
              frameSrc: effectiveFrameUrl(job.id, f),
              timestamp: f.timestamp,
            }
          })
          .filter((p) => p.src),
      )
    : []

  const totalElements = elementCrops.length
  const storyboardCount = job?.frames.filter((f) => d.selectedFrames.has(f.index)).length ?? 0
  const status: NodeStatus = !job ? "pending" : storyboardCount > 0 || totalElements > 0 ? "done" : "pending"
  const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"

  return (
    <div className="relative" style={{ width: "100%", height: "100%" }}>
      {/* 节点上方：所有元素 crop 图（编排输入素材）· 跟 keyframe 节点样式一致 */}
      {elementCrops.length > 0 && job && (
        <div
          className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
          style={{ bottom: "calc(100% + 12px)" }}
        >
          {elementCrops.map((p) => {
            const key = `${p.frameIdx}_${p.elementId}`
            return (
              <div
                key={key}
                className="group relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-white"
                style={{ aspectRatio: aspect }}
              >
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    if (!d.selectedFrames.has(p.frameIdx)) {
                      d.onToggleFrame(p.frameIdx)
                    }
                    d.onOpenStoryboard?.(p.frameIdx)
                    d.onOpenWorkbench?.(p.frameIdx)
                  }}
                  title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 点击进入分镜编排`}
                  className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-white"
                >
                  <img
                    src={p.src}
                    alt={p.name}
                    className="absolute inset-0 w-full h-full object-contain"
                  />
                </button>
                {/* 复制按钮：常驻可见 — 复制元素提取图到剪贴板 */}
                {d.onCopyImage && (
                  <button
                    onClick={(e) => {
                      e.stopPropagation()
                      d.onCopyImage?.({
                        kind: "cutout",
                        frame_idx: p.frameIdx,
                        element_id: p.elementId,
                        cutout_id: p.cid,
                        label: p.name,
                      })
                    }}
                    title="📋 复制此图（到分镜头编排工作台插槽粘贴）"
                    className="absolute top-1 left-1 h-5 w-5 rounded-full bg-violet-500/90 backdrop-blur text-white shadow-md hover:bg-violet-400 hover:scale-110 inline-flex items-center justify-center transition z-[70] text-[10px] leading-none"
                  >
                    📋
                  </button>
                )}
                {/* hover 预览 — 和关键帧节点保持一致：只看来源原帧，不额外展示元素面板 */}
                <div
                  className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
                  style={{
                    bottom: "calc(100% + 10px)",
                    left: "50%",
                    transform: "translateX(-50%)",
                    transformOrigin: "bottom center",
                  }}
                >
                  <div className="rounded-lg overflow-hidden border-2 border-violet-300/60 bg-black shadow-2xl" style={{ width: 280 }}>
                    <div style={{ aspectRatio: aspect }}>
                      <img src={p.frameSrc} alt="" className="w-full h-full object-cover" />
                    </div>
                    <div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
                      <span>分镜 {p.frameIdx + 1}</span>
                      <span className="shrink-0 font-mono text-white/55">{p.timestamp.toFixed(2)}s</span>
                    </div>
                  </div>
                </div>
              </div>
            )
          })}
        </div>
      )}

      <NodeShell
        type="ai" status={status}
        icon={<LayoutGrid className="h-4 w-4" />}
        title="元素改造 · Storyboard"
        subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
        selected={selected}
      >
        <div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
          不是复刻原视频：先把参考图里的主体 / 场景 / 动作 / 道具拆出来，再替换成 SKG 产品画面。
          <br />
          <span className="text-[10.5px] text-[var(--text-faint)]">
            已有 {totalElements} 个提取元素 · {storyboardCount} 个分镜进入编排
          </span>
        </div>
        <button
          type="button"
          onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.() }}
          disabled={!job || storyboardCount === 0}
          className="mt-2 w-full rounded-md bg-gradient-to-r from-violet-500 to-pink-500 px-3 py-2 text-[12px] font-semibold text-white shadow-lg shadow-violet-500/25 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-35"
          title={storyboardCount === 0 ? "先在关键帧节点选用分镜" : "进入 4 图槽分镜编排"}
        >
          进入分镜编排
        </button>
      </NodeShell>

    </div>
  )
}

/* ============================================================
   9. VideoGenNode (placeholder)
   ============================================================ */
export function VideoGenNode({ data, selected }: any) {
  const d: NodeData = data
  const videos = d.job?.generated_videos ?? []
  const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
  const completed = videos.filter((v) => v.status === "completed" && v.url)
  const failed = videos.some((v) => v.status === "failed")
  const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
  const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
    ? `${d.job.width}/${d.job.height}`
    : "9/16"
  const modelLabel = (model: string) => {
    const m = model.toLowerCase()
    if (m.includes("kling")) return "Kling"
    if (m.includes("veo")) return "Veo 3"
    if (m.includes("seedance")) return "Seedance"
    return model || "Video"
  }
  const readableVideoError = (error?: string) => {
    const e = error || "生成失败"
    if (e.includes("/videos") && e.includes("404")) {
      return "模型已提交，但当前 /videos 入口返回 404；需要配置实际视频生成入口"
    }
    return e
  }
  return (
    <div className="relative" style={{ width: "100%", height: "100%" }}>
      {videos.length > 0 && (
        <div
          className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
          style={{ bottom: "calc(100% + 12px)" }}
        >
          {videos.slice(0, 6).map((v, i) => {
            const videoSrc = apiAssetUrl(v.url)
            const posterSrc = apiAssetUrl(v.poster_url)
            const ready = v.status === "completed" && !!videoSrc
            const progress = Math.max(0, Math.min(100, v.progress || 0))
            return (
            <div
              key={v.id}
              className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 bg-black ${
                ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
              }`}
              style={{ aspectRatio: aspect }}
            >
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  void navigator.clipboard?.writeText(v.prompt).catch(() => {})
                  toast.success("已复制视频 prompt")
                }}
                title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
                className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
              >
                {ready ? (
                  <video
                    src={videoSrc}
                    poster={posterSrc}
                    muted
                    loop
                    playsInline
                    preload="metadata"
                    className="absolute inset-0 h-full w-full object-cover"
                    onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
                    onMouseLeave={(e) => {
                      const el = e.target as HTMLVideoElement
                      el.pause()
                      el.currentTime = 0
                    }}
                  />
                ) : posterSrc ? (
                  <img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
                ) : (
                  <div className="absolute inset-0 bg-violet-950/50" />
                )}
                {!ready && (
                  <div className="absolute inset-0 flex items-center justify-center bg-black/35">
                    {v.status === "failed" ? (
                      <X className="h-4 w-4 text-rose-200" />
                    ) : (
                      <Loader2 className="h-4 w-4 animate-spin text-white/85" />
                    )}
                  </div>
                )}
                <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
                  <div className="truncate text-[9.5px] font-semibold text-white">视频 {i + 1}</div>
                  <div className="truncate text-[8.5px] font-mono text-white/60">
                    {ready ? `${v.duration.toFixed(0)}s` : v.status === "failed" ? "failed" : `${progress}%`}
                  </div>
                </div>
              </button>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  void navigator.clipboard?.writeText(v.prompt).catch(() => {})
                  toast.success("已复制视频 prompt")
                }}
                className="absolute left-1 top-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-violet-400 hover:scale-110"
                title="复制视频 prompt"
              >
                <Copy className="h-2.5 w-2.5" />
              </button>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  d.onDeleteVideo?.(v.id)
                }}
                className="absolute right-1 top-1 z-[70] h-5 w-5 rounded-full bg-rose-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-rose-400 hover:scale-110"
                title="删除这个视频任务"
              >
                <Trash2 className="h-2.5 w-2.5" />
              </button>
              <div
                className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
                style={{
                  bottom: "calc(100% + 10px)",
                  left: "50%",
                  transform: "translateX(-50%)",
                  transformOrigin: "bottom center",
                }}
              >
                <div className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
                  <div style={{ aspectRatio: aspect }}>
                    {ready ? (
                      <video src={videoSrc} poster={posterSrc} muted loop autoPlay playsInline controls className="h-full w-full object-cover" />
                    ) : posterSrc ? (
                      <img src={posterSrc} alt="" className="w-full h-full object-cover" />
                    ) : (
                      <div className="h-full w-full bg-violet-950/60" />
                    )}
                  </div>
                  <div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
                    <div className="flex items-center justify-between gap-2 text-[10.5px]">
                      <span className="truncate">分镜 {v.frame_idx + 1}</span>
                      <span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
                    </div>
                    <div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
                      {v.status === "failed" ? readableVideoError(v.error) : v.prompt}
                    </div>
                  </div>
                </div>
              </div>
            </div>
          )})}
        </div>
      )}
      <NodeShell
        type="ai" status={status}
        icon={<Film className="h-4 w-4" />}
        title="生成视频 · Video Gen"
        subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
        selected={selected}
      >
        <div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
          {["Seedance", "Kling", "Veo 3"].map((m) => (
            <div key={m} className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-center text-[var(--text-faint)]">
              <span className="text-[var(--text-strong)] text-[11px]">{m}</span>
            </div>
          ))}
        </div>
        {videos.length > 0 && (
          <div className="mt-2 rounded-md border border-rose-300/25 bg-rose-500/10 px-2 py-1.5 text-[10.5px] text-[var(--text-soft)]">
            已提交 {videos.length} 个视频任务 · 完成 {completed.length} 个{running ? " · 生成中" : ""}
          </div>
        )}
      </NodeShell>
    </div>
  )
}

/* ============================================================
   10. ComposeNode (placeholder)
   ============================================================ */
export function ComposeNode({ selected }: any) {
  return (
    <NodeShell
      type="output" status="pending"
      icon={<FileVideo className="h-4 w-4" />}
      title="合成成品 · Compose"
      subtitle="STEP 8 · ffmpeg + 字幕"
      selected={selected}
      hasSource={false}
    >
      <div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
        视频片段 + 字幕 / TTS<br />→ 最终 mp4 输出
      </div>
    </NodeShell>
  )
}
