import { useState, useEffect, useMemo, useRef, type ChangeEvent } from "react";
import { useParams, useNavigate } from "react-router";
import {
  Plus, Wand2, Play, Trash2,
  Loader2, ArrowRight, Layers, Download, X,
  ChevronDown, ChevronUp, ChevronRight, Volume2, VolumeX,
  RefreshCw, Image as ImageIcon, Info, Check, Sparkles,
  MoreHorizontal,
} from "lucide-react";
import {
  useStoryboards,
  useCreateStoryboard,
  useGenerateStoryboards,
  useDeleteStoryboard,
  useAssemblyTask,
  useStartAssembly,
  useGenerateStructuredVideo,
  useUpdateStoryboard,
  useUploadStoryboardFrameImage,
  useVideoTasks,
  useCharacters,
  useAllCharacterStates,
  useScenes,
  useEpisodes,
  useGenerateCharacterImage,
  useSaveCharacterState,
  useGenerateCharacterStateImage,
} from "../../hooks/useAi";
import type { Storyboard, Character, CharacterState, Scene, Episode, AiTask } from "../../lib/api/ai";
import { useOperationCost } from "../../hooks/useUsage";

const MIN_VIDEO_DURATION = 5;
const MAX_VIDEO_DURATION = 15;
const DEFAULT_VIDEO_DURATION = 11;
const DURATION_OPTIONS = Array.from(
  { length: MAX_VIDEO_DURATION - MIN_VIDEO_DURATION + 1 },
  (_, index) => MIN_VIDEO_DURATION + index
);
const RATIO_OPTIONS = [
  { value: "16:9", label: "16:9 横屏" },
  { value: "9:16", label: "9:16 竖屏" },
  { value: "1:1", label: "1:1 方形" },
];
const MAX_CHARACTERS = 5;
const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80;
type VideoGenerationMode = "reference" | "frames";

// 分镜编辑器草稿存到 localStorage，按 (project, episode, storyboard) 分 key。
// 跨页面切换/刷新都不会丢；展开分镜时优先用草稿，没草稿才回退到上一次任务。
// v3: Agent 分镜会从后端落地素材 key，旧草稿可能携带导入阶段的正文噪声，bump 版本号让它失效。
const SB_DRAFT_KEY_PREFIX = "yaoai:sb-draft:v3";
const sbDraftKey = (pid: string, eid: string, sbId: string) =>
  `${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`;

interface SbDraft {
  generationMode?: VideoGenerationMode;
  shortDescription: string;
  characterKeys: string[];
  sceneKey: string;
  propKeys: string[];
  firstFrameImageKey: string;
  firstFrameImageUrl: string;
  lastFrameImageKey: string;
  lastFrameImageUrl: string;
  freeformPrompt: string;
  selectedDuration: number;
  selectedRatio: string;
  audioOn: boolean;
}

function loadSbDraft(key: string): SbDraft | null {
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as SbDraft) : null;
  } catch {
    return null;
  }
}

function saveSbDraft(key: string, d: SbDraft) {
  try {
    localStorage.setItem(key, JSON.stringify(d));
  } catch {
    // quota / private mode — 静默失败
  }
}
const FOCUS_RING = "focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focus:border-[#1c64ff]/45";
const CONTROL_SURFACE = "border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
const SOFT_PANEL = "border border-slate-200/80 bg-white/95 shadow-[0_8px_24px_rgba(15,23,42,0.05)]";

function normalizeVideoDuration(value: number | null | undefined) {
  if (typeof value !== "number" || Number.isNaN(value)) {
    return DEFAULT_VIDEO_DURATION;
  }
  return Math.min(MAX_VIDEO_DURATION, Math.max(MIN_VIDEO_DURATION, Math.round(value)));
}

const FREEFORM_PLACEHOLDER = `【镜头1】 时长2s | 蒙太奇快切 | 固定机位快速切出
画面定格在角色刚转头的瞬间，背景虚化为暖色光斑

【镜头2】 时长3s | 推近 | 缓慢推到面部特写
角色微微抬眼，眼神坚定，发丝随风轻拂

【镜头3】 时长2s | 拉远 | 从面部拉到全景
镜头快速拉远，呈现完整环境氛围`;

function characterFrontKey(c: Character): string | null {
  return c.frontImageTosKey ?? c.imageTosKey ?? null;
}
function characterFrontUrl(c: Character): string | null {
  return c.frontImageUrl ?? c.imageUrl ?? null;
}

interface CharacterRefOption {
  key: string;
  name: string;
  imageUrl: string | null;
  kind: "base" | "state";
  character?: Character;
  state?: CharacterState;
}

function refTokens(value?: string | null): string[] {
  if (!value) return [];
  return value.split(",").map((token) => token.trim()).filter(Boolean);
}

function inferSceneRefFromCharacters(characters: string | null | undefined, scenes: Scene[]): string | null {
  const sceneRefs = new Set(
    scenes
      .map((scene) => scene.name?.trim())
      .filter(Boolean)
      .map((name) => `@${name}`)
  );
  return refTokens(characters).find((token) => sceneRefs.has(token)) ?? null;
}

function sceneKeyForRef(sceneRef: string | null | undefined, scenes: Scene[]): string {
  if (!sceneRef) return "";
  const name = sceneRef.replace(/^@/, "").trim();
  const scene = scenes.find((item) =>
    item.imageTosKey && (
      item.name?.trim() === name
      || normalizeSceneMatchText(item.name) === normalizeSceneMatchText(name)
    )
  );
  return scene?.imageTosKey ?? "";
}

const SCENE_MATCH_STOP_WORDS = new Set([
  "场景", "画面", "空间", "室内", "室外", "全景", "中景", "近景", "特写", "深夜", "夜晚", "白天", "氛围",
]);

function normalizeSceneMatchText(value?: string | null): string {
  return (value ?? "")
    .toLowerCase()
    .replace(/@/g, "")
    .replace(/[\s　,，、。；;：:|/\\()（）【】[\]《》"“”'‘’\-_.#]+/g, "")
    .trim();
}

function sceneMatchTokens(scene: Scene): string[] {
  const raw = `${scene.name ?? ""} ${scene.description ?? ""}`;
  return Array.from(new Set(
    raw
      .split(/[\s,，、。；;：:|/\\()（）【】[\]《》"“”'‘’]+/g)
      .map((token) => token.trim())
      .filter((token) => token.length >= 2)
      .filter((token) => !SCENE_MATCH_STOP_WORDS.has(normalizeSceneMatchText(token)))
  ));
}

function sceneMatchScore(scene: Scene, normalizedText: string): number {
  if (!scene.imageTosKey || !normalizedText) return 0;
  let score = 0;
  const name = normalizeSceneMatchText(scene.name);
  if (name && normalizedText.includes(name)) {
    score += 100 + Math.min(20, name.length);
  }
  sceneMatchTokens(scene).forEach((token) => {
    const normalizedToken = normalizeSceneMatchText(token);
    if (normalizedToken.length >= 2 && normalizedText.includes(normalizedToken)) {
      score += normalizedToken.length >= 4 ? 4 : 2;
    }
  });
  return score;
}

function inferSceneKeyForStoryboard(sb: Storyboard, scenes: Scene[]): string {
  const explicit = explicitSceneKeyForStoryboard(sb, scenes);
  if (explicit) return explicit;

  const normalizedText = normalizeSceneMatchText([
    sb.sceneNumber,
    sb.shortDescription,
    sb.detailedDescription,
    sb.startFramePrompt,
    sb.motionScript,
    sb.dialogues,
  ].filter(Boolean).join("\n"));

  let bestScene: Scene | null = null;
  let bestScore = 0;
  scenes.forEach((scene) => {
    const score = sceneMatchScore(scene, normalizedText);
    if (score > bestScore) {
      bestScore = score;
      bestScene = scene;
    }
  });
  return bestScore >= 6 && bestScene?.imageTosKey ? bestScene.imageTosKey : "";
}

function explicitSceneKeyForStoryboard(sb: Storyboard, scenes: Scene[]): string {
  return sceneKeyForRef(sb.sceneRef, scenes)
    || sceneKeyForRef(inferSceneRefFromCharacters(sb.characters, scenes), scenes);
}

function sceneNameForKey(key: string, scenes: Scene[]): string {
  return scenes.find((scene) => scene.imageTosKey === key)?.name ?? "";
}

function inferCharacterKeysFromRefs(
  refs: string | null | undefined,
  options: CharacterRefOption[]
): string[] {
  const names = refTokens(refs).map((token) => token.replace(/^@/, "").trim()).filter(Boolean);
  const keys: string[] = [];
  names.forEach((name) => {
    const exactBase = options.find((option) =>
      option.kind === "base" && option.character?.name?.trim() === name
    );
    const fallback = exactBase ?? options.find((option) =>
      option.character?.name?.trim() === name || option.name.split("·")[0]?.trim() === name
    );
    if (fallback?.key && !keys.includes(fallback.key)) {
      keys.push(fallback.key);
    }
  });
  return keys.slice(0, MAX_CHARACTERS);
}

function nonEmptyKeys(keys?: string[] | null): string[] {
  return (keys ?? []).filter((key): key is string => Boolean(key && key.trim()));
}

function buildCharacterRefOptions(characters: Character[], states: CharacterState[]): CharacterRefOption[] {
  const characterById = new Map(characters.map((character) => [String(character.id), character]));
  const options: CharacterRefOption[] = [];
  characters.forEach((character) => {
    const key = characterFrontKey(character);
    if (!key) return;
    options.push({
      key,
      name: character.name,
      imageUrl: characterFrontUrl(character),
      kind: "base",
      character,
    });
  });
  states.forEach((state) => {
    if (!state.imageTosKey) return;
    const parent = characterById.get(String(state.characterId));
    options.push({
      key: state.imageTosKey,
      name: `${parent?.name ?? state.characterName ?? "角色"} · ${state.name}`,
      imageUrl: state.imageUrl,
      kind: "state",
      character: parent,
      state,
    });
  });
  return options;
}

export function StoryboardWorkspace() {
  const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>();
  const navigate = useNavigate();
  const pid = projectId ?? "";

  const { data: episodes = [], isLoading: episodesLoading } = useEpisodes(pid);
  const [activeEpisodeId, setActiveEpisodeId] = useState<string>("");

  useEffect(() => {
    if (urlEpisodeId) {
      setActiveEpisodeId(urlEpisodeId);
    } else if (episodes.length > 0 && !activeEpisodeId) {
      setActiveEpisodeId(episodes[0].id);
    }
  }, [urlEpisodeId, episodes.length]);

  const eid = activeEpisodeId;

  const { data: storyboards = [], isLoading } = useStoryboards(pid, eid);
  const { data: videoTasks = [], isFetched: videoTasksFetched } = useVideoTasks(pid);
  const { data: characters = [], isFetched: charactersFetched } = useCharacters(pid);
  const { data: characterStates = [], isFetched: characterStatesFetched } = useAllCharacterStates(pid);
  const { data: scenes = [], isFetched: scenesFetched } = useScenes(pid);
  const { data: assemblyTask } = useAssemblyTask(pid, eid);
  const generateSbs = useGenerateStoryboards(pid);
  const createSb = useCreateStoryboard(pid, eid);
  const deleteSb = useDeleteStoryboard(pid, eid);
  const updateSb = useUpdateStoryboard(pid, eid);
  const uploadFrameImage = useUploadStoryboardFrameImage(pid, eid);
  const generateStructured = useGenerateStructuredVideo(pid);
  const startAssembly = useStartAssembly(pid, eid);
  const videoCredits = useOperationCost("video_generate");

  // ─── Editor state (bound to currently expanded card) ───
  const [expandedId, setExpandedId] = useState<string | null>(null);
  const [characterKeys, setCharacterKeys] = useState<string[]>([]);
  const [sceneKey, setSceneKey] = useState<string>("");
  const [propKeys, setPropKeys] = useState<string[]>([]);
  const [firstFrameImageKey, setFirstFrameImageKey] = useState<string>("");
  const [firstFrameImageUrl, setFirstFrameImageUrl] = useState<string>("");
  const [lastFrameImageKey, setLastFrameImageKey] = useState<string>("");
  const [lastFrameImageUrl, setLastFrameImageUrl] = useState<string>("");
  const [styleKey] = useState<string>("");  // reserved; not exposed in this UI
  const [generationMode, setGenerationMode] = useState<VideoGenerationMode>("reference");
  const [shortDescription, setShortDescription] = useState<string>("");
  const [freeformPrompt, setFreeformPrompt] = useState<string>("");
  const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION);
  const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
  const [audioOn, setAudioOn] = useState(true);
  const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);

  // Modals
  const [showCharacterPicker, setShowCharacterPicker] = useState(false);
  const [showPromptDetails, setShowPromptDetails] = useState(false);
  const [activeVideoSbId, setActiveVideoSbId] = useState<string | null>(null);

  const expanded = useMemo(
    () => storyboards.find((s) => s.id === expandedId) ?? null,
    [storyboards, expandedId]
  );

  const characterRefOptions = useMemo(
    () => buildCharacterRefOptions(characters, characterStates),
    [characters, characterStates]
  );

  // key → character/state reference lookup.
  const keyToCharacterRef = useMemo(() => {
    const m = new Map<string, CharacterRefOption>();
    characterRefOptions.forEach((option) => m.set(option.key, option));
    return m;
  }, [characterRefOptions]);

  const sceneByKey = useMemo(() => {
    const m = new Map<string, Scene>();
    scenes.forEach((s) => { if (s.imageTosKey) m.set(s.imageTosKey, s); });
    return m;
  }, [scenes]);

  const getTaskForSb = (sb: Storyboard): AiTask | undefined => {
    const byId = videoTasks.filter((t) => t.storyboardId === sb.id);
    return byId.find((t) => t.status === "succeeded")
      ?? byId.find((t) => t.status === "running" || t.status === "submitted" || t.status === "pending")
      ?? byId[0];
  };

  const getDotColor = (sb: Storyboard) => {
    const task = getTaskForSb(sb);
    if (!task) return "bg-gray-300 dark:bg-gray-600";
    if (task.status === "succeeded") return "bg-green-500";
    if (task.status === "failed") return "bg-red-400";
    return "bg-orange-400 animate-pulse";
  };

  // 编辑器状态只在切换分镜卡片时从最近任务回填一次。
  // 之后输入框完全归用户掌控，不会被 videoTasks 刷新（如点击生成后 mutation 失效）覆盖。
  const lastSyncedExpandedIdRef = useRef<string | null>(null);
  // sync effect 触发 setStuff 后，本轮 render 内 save effect 会用陈旧 state 误存。
  // 设这个 ref 让 save effect 跳过 sync 后的第一次触发，等下一轮 render 拿到新 state 再存。
  const skipNextDraftSaveRef = useRef(false);
  const skipNextRemoteSaveRef = useRef(false);
  const remoteSaveTimerRef = useRef<number | null>(null);

  // Reset on episode change
  useEffect(() => {
    setExpandedId(null);
    setFreeformPrompt("");
    setCharacterKeys([]);
    setSceneKey("");
    setPropKeys([]);
    setFirstFrameImageKey("");
    setFirstFrameImageUrl("");
    setLastFrameImageKey("");
    setLastFrameImageUrl("");
    setGenerationMode("reference");
    setShortDescription("");
    setActiveVideoSbId(null);
    lastSyncedExpandedIdRef.current = null;
  }, [activeEpisodeId]);

  // Auto-expand first storyboard on load
  useEffect(() => {
    if (storyboards.length > 0 && (!expandedId || !storyboards.some((sb) => sb.id === expandedId))) {
      setExpandedId(storyboards[0].id);
      setActiveVideoSbId(storyboards[0].id);
    }
  }, [storyboards, expandedId]);

  useEffect(() => {
    if (!expanded) return;
    if (!videoTasksFetched) return; // 等首次拉到任务列表再回填，避免空数据回填后被锁
    if (!charactersFetched || !characterStatesFetched || !scenesFetched) return;
    if (lastSyncedExpandedIdRef.current === expandedId) return;
    lastSyncedExpandedIdRef.current = expandedId;
    skipNextDraftSaveRef.current = true; // 跳过本轮陈旧 state 的误存
    skipNextRemoteSaveRef.current = true;

    // 优先从 localStorage 草稿恢复（跨页面切换/刷新都不丢）
    const dKey = pid && eid ? sbDraftKey(pid, eid, expanded.id) : null;
    const draft = dKey ? loadSbDraft(dKey) : null;
    if (draft) {
      const explicitSceneKey = explicitSceneKeyForStoryboard(expanded, scenes);
      const inferredSceneKey = inferSceneKeyForStoryboard(expanded, scenes);
      const nextSceneKey = explicitSceneKey || draft.sceneKey || inferredSceneKey;
      setShortDescription(draft.shortDescription);
      setCharacterKeys(draft.characterKeys);
      setSceneKey(nextSceneKey);
      setPropKeys(draft.propKeys);
      setFirstFrameImageKey(draft.firstFrameImageKey || expanded.firstFrameImageKey || "");
      setFirstFrameImageUrl(draft.firstFrameImageUrl || expanded.firstFrameImageUrl || "");
      setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || "");
      setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || "");
      setGenerationMode(draft.generationMode ?? "reference");
      setFreeformPrompt(draft.freeformPrompt);
      setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
      setSelectedRatio(draft.selectedRatio);
      setAudioOn(draft.audioOn);
      if (nextSceneKey && draft.sceneKey !== nextSceneKey) {
        updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
      }
      return;
    }

    // 没草稿才回退到 Agent 预填；再回退到上一次视频任务。
    setShortDescription(expanded.shortDescription ?? "");
    const storyboardCharacterKeys = nonEmptyKeys(expanded.characterImageKeys);
    const storyboardPropKeys = nonEmptyKeys(expanded.propImageKeys);
    const lastTask = getTaskForSb(expanded);
    const taskCharacterKeys = lastTask?.characterImageKeys
      ?? (lastTask?.characterImageKey ? [lastTask.characterImageKey] : []);
    const refCharacterKeys = inferCharacterKeysFromRefs(expanded.characters, characterRefOptions);
    const initialChars = storyboardCharacterKeys.length > 0
      ? storyboardCharacterKeys
      : (taskCharacterKeys.length > 0 ? taskCharacterKeys : refCharacterKeys);
    setCharacterKeys(initialChars);
    const storedSceneKey = expanded.sceneImageKey || lastTask?.sceneImageKey || "";
    const explicitSceneKey = explicitSceneKeyForStoryboard(expanded, scenes);
    const inferredSceneKey = inferSceneKeyForStoryboard(expanded, scenes);
    const nextSceneKey = explicitSceneKey || storedSceneKey || inferredSceneKey;
    setSceneKey(nextSceneKey);
    if (nextSceneKey && storedSceneKey !== nextSceneKey) {
      updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
    }
    setPropKeys(storyboardPropKeys.length > 0 ? storyboardPropKeys : (lastTask?.propImageKeys ?? []));
    setFirstFrameImageKey(expanded.firstFrameImageKey ?? "");
    setFirstFrameImageUrl(expanded.firstFrameImageUrl ?? "");
    setLastFrameImageKey(expanded.lastFrameImageKey ?? "");
    setLastFrameImageUrl(expanded.lastFrameImageUrl ?? "");
    setGenerationMode("reference");
    setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
    if (typeof lastTask?.videoDuration === "number") {
      setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
    }
    if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio);
    if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
  }, [
    expandedId, videoTasksFetched,
    charactersFetched, characterStatesFetched, scenesFetched,
    characterRefOptions, scenes,
  ]);

  // 编辑器任意字段变化即写入 localStorage 草稿
  useEffect(() => {
    if (!expandedId || !pid || !eid) return;
    if (skipNextDraftSaveRef.current) {
      skipNextDraftSaveRef.current = false;
      return;
    }
    saveSbDraft(sbDraftKey(pid, eid, expandedId), {
      generationMode,
      shortDescription,
      characterKeys,
      sceneKey,
      propKeys,
      firstFrameImageKey,
      firstFrameImageUrl,
      lastFrameImageKey,
      lastFrameImageUrl,
      freeformPrompt,
      selectedDuration,
      selectedRatio,
      audioOn,
    });
  }, [
    expandedId, pid, eid,
    generationMode, shortDescription, characterKeys, sceneKey, propKeys,
    firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
    freeformPrompt,
    selectedDuration, selectedRatio, audioOn,
  ]);

  useEffect(() => {
    if (!expandedId || !pid || !eid) return;
    if (skipNextRemoteSaveRef.current) {
      skipNextRemoteSaveRef.current = false;
      return;
    }
    if (remoteSaveTimerRef.current) {
      window.clearTimeout(remoteSaveTimerRef.current);
    }
    remoteSaveTimerRef.current = window.setTimeout(() => {
      updateSb.mutate({
        id: expandedId,
        patch: {
          shortDescription,
          characterImageKeys: characterKeys.length > 0 ? JSON.stringify(characterKeys) : "",
          sceneImageKey: sceneKey,
          propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
          firstFrameImageKey,
          firstFrameImageUrl,
          lastFrameImageKey,
          lastFrameImageUrl,
          videoPrompt: freeformPrompt,
          durationSeconds: selectedDuration,
        },
      });
    }, 900);
    return () => {
      if (remoteSaveTimerRef.current) {
        window.clearTimeout(remoteSaveTimerRef.current);
      }
    };
  }, [
    expandedId, pid, eid,
    shortDescription, characterKeys, sceneKey, propKeys,
    firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
    freeformPrompt, selectedDuration,
  ]);

  const handleEpisodeSelect = (ep: Episode) => {
    setActiveEpisodeId(ep.id);
    navigate(`/project/${pid}/storyboard/${ep.id}`, { replace: true });
  };

  const handleGenerateAll = async () => {
    if (!eid) return;
    const result = await generateSbs.mutateAsync(eid);
    if (result.length > 0) {
      setExpandedId(result[0].id);
      setActiveVideoSbId(result[0].id);
    }
  };

  const handleCreate = async () => {
    const newSb = await createSb.mutateAsync({
      sequenceNum: storyboards.length + 1,
      sceneNumber: `场景${storyboards.length + 1}`,
      shortDescription: "新镜头",
      detailedDescription: "",
      characters: "",
      dialogues: "",
      cameraDirection: "",
      compositionGuide: "",
      durationSeconds: 5,
      startFramePrompt: "",
      motionScript: "",
      notes: "",
      status: "draft",
    } as unknown as Storyboard);
    setExpandedId(newSb.id);
    setActiveVideoSbId(newSb.id);
  };

  const handleDelete = async (sb: Storyboard) => {
    if (!confirm(`确定删除分镜 #${sb.sequenceNum}？`)) return;
    await deleteSb.mutateAsync(sb.id);
    if (expandedId === sb.id) {
      const fallback = storyboards.find((s) => s.id !== sb.id);
      setExpandedId(fallback?.id ?? null);
      setActiveVideoSbId(fallback?.id ?? null);
    }
  };

  const togglePropKey = (key: string) => {
    setPropKeys((prev) => {
      if (prev.includes(key)) return prev.filter((k) => k !== key);
      if (prev.length >= MAX_PROPS) return prev;
      return [...prev, key];
    });
  };

  const handleFrameUpload = async (frameType: "first" | "last", file: File) => {
    if (!expanded) return;
    const updated = await uploadFrameImage.mutateAsync({ id: expanded.id, frameType, file });
    if (frameType === "first") {
      setFirstFrameImageKey(updated.firstFrameImageKey ?? "");
      setFirstFrameImageUrl(updated.firstFrameImageUrl ?? "");
    } else {
      setLastFrameImageKey(updated.lastFrameImageKey ?? "");
      setLastFrameImageUrl(updated.lastFrameImageUrl ?? "");
    }
  };

  const handleFrameClear = (frameType: "first" | "last") => {
    if (frameType === "first") {
      setFirstFrameImageKey("");
      setFirstFrameImageUrl("");
      return;
    }
    setLastFrameImageKey("");
    setLastFrameImageUrl("");
  };

  const removeCharacter = (key: string) => {
    setCharacterKeys((prev) => prev.filter((k) => k !== key));
  };

  const swapCharacter = (oldKey: string, newKey: string) => {
    if (!newKey || oldKey === newKey) return;
    setCharacterKeys((prev) => {
      if (prev.includes(newKey)) {
        return prev.filter((k) => k !== oldKey);
      }
      return prev.map((k) => (k === oldKey ? newKey : k));
    });
  };

  const promptHasContent = freeformPrompt.trim().length > 0;
  const frameReferenceCount = (firstFrameImageKey ? 1 : 0) + (lastFrameImageKey ? 1 : 0);
  const referenceImageCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length;
  const hasRequiredImages = generationMode === "frames"
    ? !!firstFrameImageKey && !!lastFrameImageKey
    : referenceImageCount > 0;
  const canGenerate = !!expanded && hasRequiredImages && promptHasContent && !generateStructured.isPending;
  const referenceCount = generationMode === "frames" ? frameReferenceCount : referenceImageCount;

  const handleGenerate = async () => {
    if (!expanded || !canGenerate) return;
    setGeneratingVideoId(expanded.id);
    try {
      await updateSb.mutateAsync({
        id: expanded.id,
        patch: {
          shortDescription,
          characterImageKeys: JSON.stringify(characterKeys),
          sceneImageKey: sceneKey,
          propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
          firstFrameImageKey,
          firstFrameImageUrl,
          lastFrameImageKey,
          lastFrameImageUrl,
          videoPrompt: freeformPrompt.trim(),
          durationSeconds: selectedDuration,
        },
      });
      await generateStructured.mutateAsync({
        episodeId: expanded.episodeId,
        storyboardId: expanded.id,
        characterImageKeys: generationMode === "reference" ? characterKeys : [],
        sceneImageKey: generationMode === "reference" ? sceneKey : "",
        propImageKeys: generationMode === "reference" && propKeys.length > 0 ? propKeys : null,
        firstFrameImageKey: generationMode === "frames" ? firstFrameImageKey : null,
        lastFrameImageKey: generationMode === "frames" ? lastFrameImageKey : null,
        styleImageKey: styleKey || null,
        freeformPrompt: freeformPrompt.trim(),
        duration: selectedDuration,
        ratio: selectedRatio,
        generateAudio: audioOn,
      });
      setActiveVideoSbId(expanded.id);
    } finally {
      setGeneratingVideoId(null);
    }
  };

  // The video shown in the right preview tracks `activeVideoSbId` (independent
  // from accordion expansion) so user can browse other videos via the timeline
  // without losing editor state.
  const previewSb = useMemo(
    () => storyboards.find((s) => s.id === activeVideoSbId) ?? expanded,
    [storyboards, activeVideoSbId, expanded]
  );
  const previewDisplaySb = previewSb ?? null;
  const previewTask = previewDisplaySb ? getTaskForSb(previewDisplaySb) : null;

  const promptPreviewText = freeformPrompt.trim();
  const sceneRefDisplay = expanded
    ? expanded.sceneRef?.trim()
      || inferSceneRefFromCharacters(expanded.characters, scenes)
      || (sceneKey ? `@${sceneNameForKey(sceneKey, scenes)}` : "")
      || "未标注"
    : "";
  const charactersDisplay = expanded?.characters?.trim() || "未标注";
  const startFrameDisplay = expanded?.startFramePrompt?.trim() || "未生成";
  const motionScriptDisplay = expanded?.motionScript?.trim() || "未生成";
  const videoPromptDisplay = expanded?.videoPrompt?.trim() || "未生成";
  const storyboardInfoFields = expanded ? [
    { label: "出镜角色", value: charactersDisplay },
    { label: "分镜场景", value: sceneRefDisplay },
    { label: "首帧提示词", value: startFrameDisplay },
    { label: "镜头运动", value: motionScriptDisplay },
    { label: "视频提示词", value: videoPromptDisplay, multiline: true },
  ] : [];
  const currentEpisode = episodes.find((ep) => ep.id === activeEpisodeId) ?? episodes[0] ?? null;
  const currentEpisodeTitle = currentEpisode
    ? currentEpisode.title || `第${currentEpisode.episodeNumber}集`
    : "暂无分集";
  const currentEpisodeSummary = currentEpisode?.summary
    || "当前项目还没有可用分集。生成分集后，可以在这里继续生成分镜。";
  const selectedScene = sceneKey ? sceneByKey.get(sceneKey) ?? null : null;
  const generationModeLabel = generationMode === "frames" ? "首尾帧生成" : "参考图生成";
  const generateRequirementText = generationMode === "frames"
    ? "需要：上传首帧和尾帧，并填写提示词"
    : "需要：至少 1 张参考图（角色/场景/道具）+ 提示词";

  return (
    <div className="h-full flex overflow-hidden bg-[#f7f8fb] text-[#111827]">

      {/* ─── Far Left: Episode list strip ─── */}
      <div className="flex-shrink-0 border-r border-[#e5e8f0] bg-[#fbfcff] flex flex-col items-center overflow-hidden py-4" style={{ width: 56 }}>
        <span className="mb-3 text-[13px] font-medium text-[#475467]">
          集数
        </span>
        <div
          className="flex-1 flex flex-col items-center gap-3.5 overflow-y-auto w-full [&::-webkit-scrollbar]:hidden"
          style={{ scrollbarWidth: "none" }}
        >
          {episodesLoading ? (
            <div className="flex items-center justify-center pt-4">
              <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
            </div>
          ) : episodes.length === 0 ? (
            <div className="px-2 pt-4 text-center text-[11px] leading-4 text-[#98a2b3]">
              暂无分集
            </div>
          ) : (
            episodes.map((ep) => {
              const active = activeEpisodeId === ep.id;
              return (
                <button
                  key={ep.id}
                  onClick={() => handleEpisodeSelect(ep)}
                  title={ep.title ? `第${ep.episodeNumber}集 · ${ep.title}` : `第${ep.episodeNumber}集`}
                  className={`flex items-center justify-center font-bold tabular-nums transition-all duration-200 flex-shrink-0 ${
                    active ? "" : "hover:bg-[#f0f4fb] hover:text-[#475569] hover:shadow-sm"
                  }`}
                  style={{
                    width: 34,
                    height: 34,
                    borderRadius: 6,
                    fontSize: 14,
                    backgroundColor: active ? "#7657ff" : "#ffffff",
                    color: active ? "#ffffff" : "#8b95a7",
                    boxShadow: active ? "0 8px 18px rgba(118, 87, 255, 0.28)" : "0 1px 2px rgba(15, 23, 42, 0.05)",
                    border: active ? "1px solid rgba(118, 87, 255, 0.45)" : "1px solid rgba(148, 163, 184, 0.25)",
                  }}
                >
                  {ep.episodeNumber}
                </button>
              );
            })
          )}
        </div>
      </div>

      <div className="flex min-w-0 flex-1 flex-col overflow-hidden px-5 pb-5 pt-4">
        <div className="mb-3 flex items-start gap-4">
          <button className="h-8 rounded-md border border-[#dfe3ee] bg-white px-4 text-sm font-medium text-[#111827] shadow-sm">
            分镜表
          </button>
          <div className="min-w-0 flex-1">
            <h2 className="text-base font-semibold text-[#111827]">{currentEpisodeTitle}</h2>
            <p className="mt-1 line-clamp-1 text-xs leading-5 text-[#475467]">{currentEpisodeSummary}</p>
          </div>
          <button
            onClick={handleGenerateAll}
            disabled={generateSbs.isPending || !eid}
            className="flex h-9 items-center gap-2 rounded-md border border-[#dde3ee] bg-white px-4 text-sm font-medium text-[#111827] shadow-sm transition hover:border-[#b9c3d6] disabled:opacity-50"
          >
            {generateSbs.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Layers className="h-4 w-4" />}
            批量生成视频
          </button>
        </div>

        <div className="mb-2 flex items-center justify-between">
          <div className="flex items-center gap-5 text-sm">
            <button className="flex h-8 items-center gap-2 rounded-md px-2 text-[#111827] hover:bg-white">
              <span className="text-[#98a2b3]">⋮⋮</span>
              {expanded ? `分镜${expanded.sequenceNum}` : "暂无分镜"}
            </button>
            <span className="flex h-8 items-center gap-2 rounded-md border border-[#d7ceff] bg-[#f4f0ff] px-3 text-[#6147e8]">
              <span className="h-2.5 w-2.5 rounded-full bg-[#7657ff]" />
              全能参考生成视频
            </span>
          </div>
          <div className="flex items-center gap-4 text-[#475467]">
            <button onClick={handleCreate} disabled={createSb.isPending || !eid} className="hover:text-[#7657ff]" aria-label="新增分镜">
              {createSb.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
            </button>
            <button className="hover:text-[#7657ff]" aria-label="复制分镜">
              <Layers className="h-4 w-4" />
            </button>
            <button onClick={() => expanded && handleDelete(expanded)} disabled={!expanded} className="hover:text-red-500 disabled:opacity-40" aria-label="删除分镜">
              <Trash2 className="h-4 w-4" />
            </button>
          </div>
        </div>

        <div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-[#e4e8f0] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">

      {/* ─── Column 2: Storyboard accordion list (top: prompt summary, then cards) ─── */}
      <div className="flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-[#fbfcff]" style={{ width: 394 }}>
        {/* Action row: new + AI generate */}
        <div className="h-11 px-3 border-b border-[#edf0f5] flex items-center gap-2.5 flex-shrink-0 bg-white">
          <button
            onClick={handleCreate}
            disabled={createSb.isPending || !eid}
            className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-md border border-[#dde3ee] bg-white text-xs font-medium text-[#111827] transition hover:border-[#b9c3d6] disabled:opacity-50"
          >
            {createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
            新建分镜
          </button>
          <button
            onClick={handleGenerateAll}
            disabled={generateSbs.isPending || !eid}
            title="AI批量生成分镜"
            className={`flex h-8 w-11 items-center justify-center rounded-md text-[#475467] hover:bg-[#f6f9ff] hover:border-[#7657ff]/30 hover:text-[#7657ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`}
          >
            {generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
          </button>
        </div>

        {/* Cards (accordion) */}
        <div className="flex-1 overflow-y-auto px-3 py-2 bg-[#fbfcff]">
          {!eid ? (
            <div className="flex h-full min-h-[360px] flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-white px-6 text-center">
              <Layers className="mb-3 h-8 w-8 text-[#98a2b3]" />
              <p className="text-sm font-medium text-[#111827]">暂无分集数据</p>
              <p className="mt-1 text-xs leading-5 text-[#98a2b3]">请先生成或选择分集，再继续创建分镜。</p>
            </div>
          ) : isLoading ? (
            <div className="flex items-center justify-center py-10">
              <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
            </div>
          ) : storyboards.length === 0 ? (
            <div className="flex h-full min-h-[360px] flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-white px-6 text-center">
              <Layers className="mb-3 h-8 w-8 text-[#98a2b3]" />
              <p className="text-sm font-medium text-[#111827]">暂无分镜数据</p>
              <p className="mt-1 text-xs leading-5 text-[#98a2b3]">点击“新建分镜”手动添加，或使用 AI 批量生成。</p>
            </div>
          ) : (
            storyboards.map((sb) => (
              <StoryboardCard
                key={sb.id}
                sb={sb}
                isExpanded={expandedId === sb.id}
                dotClass={getDotColor(sb)}
                onToggle={() => {
                  const next = expandedId === sb.id ? null : sb.id;
                  setExpandedId(next);
                  if (next) setActiveVideoSbId(next);
                }}
                onDelete={() => handleDelete(sb)}
                shortDescription={expandedId === sb.id ? shortDescription : sb.shortDescription ?? ""}
                onShortDescriptionChange={setShortDescription}
                characterKeys={expandedId === sb.id ? characterKeys : []}
                onRemoveCharacter={removeCharacter}
                onSwapCharacter={swapCharacter}
                onAddCharacter={() => setShowCharacterPicker(true)}
                keyToCharacterRef={keyToCharacterRef}
                characterRefOptions={characterRefOptions}
                sceneKey={expandedId === sb.id ? sceneKey : ""}
                onSelectScene={setSceneKey}
                propKeys={expandedId === sb.id ? propKeys : []}
                onTogglePropKey={togglePropKey}
                generationMode={expandedId === sb.id ? generationMode : "reference"}
                onGenerationModeChange={setGenerationMode}
                firstFrameImageKey={expandedId === sb.id ? firstFrameImageKey : sb.firstFrameImageKey ?? ""}
                firstFrameImageUrl={expandedId === sb.id ? firstFrameImageUrl : sb.firstFrameImageUrl ?? ""}
                lastFrameImageKey={expandedId === sb.id ? lastFrameImageKey : sb.lastFrameImageKey ?? ""}
                lastFrameImageUrl={expandedId === sb.id ? lastFrameImageUrl : sb.lastFrameImageUrl ?? ""}
                onUploadFrame={handleFrameUpload}
                onClearFrame={handleFrameClear}
                frameUploading={uploadFrameImage.isPending}
                scenes={scenes}
                sceneByKey={sceneByKey}
              />
            ))
          )}
        </div>
      </div>

      {/* ─── Column 3: Video generation panel (5-section prompt + duration + button) ─── */}
      <div className="ml-3 flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-white" style={{ width: 407 }}>
        <div className="h-12 px-4 flex items-center flex-shrink-0 bg-white">
          <span className="text-sm font-semibold text-[#111827] tracking-tight">分镜视频生成</span>
          {expanded && (
            <span className="ml-2 text-[11px] text-muted-foreground tabular-nums">
              · 分镜 {String(expanded.sequenceNum).padStart(3, "0")}
            </span>
          )}
        </div>

        <div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
          {!expanded ? (
            <div className="flex-1 min-h-[420px] rounded-md border border-dashed border-[#d9dee8] bg-white px-6 py-8 text-center flex flex-col items-center justify-center">
              <Play className="mb-3 h-8 w-8 text-[#98a2b3]" />
              <p className="text-sm font-medium text-[#111827]">暂无可生成的视频分镜</p>
              <p className="mt-1 text-xs leading-5 text-[#98a2b3]">创建或选择分镜后，这里会显示视频生成提示词。</p>
            </div>
          ) : (
            <>
              <div className="flex items-start gap-1.5 rounded-md bg-[#f8fafc] px-2.5 py-1.5">
                <Info className="w-3.5 h-3.5 text-[#98a2b3] mt-0.5 flex-shrink-0" />
                <p className="text-[11px] text-[#98a2b3] leading-relaxed">
                  {generationMode === "frames"
                    ? `已上传 ${frameReferenceCount}/2 张关键帧，将按首帧、尾帧顺序参与生成`
                    : referenceCount > 0
                      ? `已预选 ${referenceCount} 个参考素材，可继续手工调整`
                      : "可手工选择参考素材；AI 批量生成会自动预选可用角色/场景"}
                </p>
              </div>
              <textarea
                value={freeformPrompt}
                onChange={(e) => setFreeformPrompt(e.target.value)}
                placeholder={FREEFORM_PLACEHOLDER}
                className={`flex-1 min-h-[420px] px-3 py-2.5 rounded-md border border-[#d9dee8] bg-white text-sm text-[#111827] leading-relaxed resize-none shadow-inner shadow-slate-100/80 ${FOCUS_RING}`}
              />
            </>
          )}
        </div>

        <div className="px-3 py-3 flex-shrink-0 bg-white space-y-2">
          <div className="flex items-center gap-2">
            <div className="relative flex-1">
              <select
                value={selectedRatio}
                onChange={(e) => setSelectedRatio(e.target.value)}
                disabled={!expanded}
                className={`w-full appearance-none pl-3 pr-7 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
              >
                {RATIO_OPTIONS.map((r) => (
                  <option key={r.value} value={r.value}>{r.label}</option>
                ))}
              </select>
              <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
            </div>
            <div className="relative">
              <select
                value={selectedDuration}
                onChange={(e) => setSelectedDuration(normalizeVideoDuration(Number(e.target.value)))}
                disabled={!expanded}
                className={`appearance-none pl-2 pr-6 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer w-16 disabled:opacity-50 transition-colors tabular-nums ${CONTROL_SURFACE} ${FOCUS_RING}`}
              >
                {DURATION_OPTIONS.map((d) => (
                  <option key={d} value={d}>{d}</option>
                ))}
              </select>
              <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
            </div>
            <button
              onClick={() => setAudioOn((v) => !v)}
              disabled={!expanded}
              title={audioOn ? "已开启配音" : "已关闭配音"}
              className={`p-2 rounded-lg border text-xs transition-colors disabled:opacity-50 ${
                audioOn
                  ? "border-[#1c64ff] bg-[#1c64ff]/10 text-[#1c64ff] shadow-[0_4px_12px_rgba(28,100,255,0.12)]"
                  : "border-slate-200/80 bg-[#f8fafc] text-muted-foreground hover:bg-[#eef4ff] hover:text-[#1c64ff]"
              }`}
            >
              {audioOn ? <Volume2 className="w-3.5 h-3.5" /> : <VolumeX className="w-3.5 h-3.5" />}
            </button>
          </div>

          <button
            onClick={handleGenerate}
            disabled={!canGenerate || generatingVideoId === expanded?.id}
            title={!canGenerate ? generateRequirementText : ""}
            className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
          >
            {generatingVideoId === expanded?.id ? (
              <Loader2 className="w-3.5 h-3.5 animate-spin" />
            ) : (
              <Play className="w-3.5 h-3.5" />
            )}
            {generationModeLabel}视频{videoCredits != null ? ` (${videoCredits.toLocaleString()}积分)` : ""}
          </button>
        </div>
      </div>

      {/* ─── Column 4: Big video preview + thumbnail timeline ─── */}
      <div className="ml-3 flex-1 flex flex-col overflow-hidden min-w-0 rounded-lg bg-white">
        <div className="h-12 px-5 flex items-center justify-between flex-shrink-0 bg-white">
          <span className="text-[13px] font-semibold text-foreground tracking-tight">
            视频预览
            {previewDisplaySb && (
              <span className="ml-2 text-[11px] font-normal text-muted-foreground tabular-nums">
                · 分镜信息{String(previewDisplaySb.sequenceNum).padStart(3, "0")}
              </span>
            )}
          </span>
          <div className="flex items-center gap-2">
            {assemblyTask?.status === "succeeded" && assemblyTask.downloadUrl && (
              <a
                href={assemblyTask.downloadUrl}
                target="_blank"
                rel="noreferrer"
                className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-medium shadow-[0_6px_14px_rgba(16,185,129,0.22)] hover:bg-emerald-600 hover:shadow-[0_8px_18px_rgba(16,185,129,0.28)] transition-all duration-150"
              >
                <Download className="w-3.5 h-3.5" />
                下载合集
              </a>
            )}
            <button
              onClick={() => startAssembly.mutate()}
              disabled={
                startAssembly.isPending ||
                assemblyTask?.status === "running" ||
                assemblyTask?.status === "pending" ||
                storyboards.length === 0
              }
              className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-foreground hover:bg-[#f6f9ff] hover:border-[#1c64ff]/30 hover:text-[#1c64ff] transition-colors disabled:opacity-50 disabled:hover:bg-white disabled:hover:border-slate-200 disabled:hover:text-foreground ${CONTROL_SURFACE}`}
            >
              {startAssembly.isPending || assemblyTask?.status === "running" ? (
                <Loader2 className="w-3.5 h-3.5 animate-spin" />
              ) : (
                <Layers className="w-3.5 h-3.5" />
              )}
              合成本集
            </button>
            <button
              onClick={() => navigate(`/project/${projectId}/video`)}
              className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-foreground hover:bg-[#f6f9ff] hover:border-[#1c64ff]/30 hover:text-[#1c64ff] transition-colors ${CONTROL_SURFACE}`}
            >
              视频管理
              <ArrowRight className="w-3.5 h-3.5" />
            </button>
          </div>
        </div>

        <div className="flex-1 min-h-0 overflow-hidden bg-white px-5 pb-4 pt-2">
          {!previewDisplaySb ? (
            <div className="h-full flex flex-col items-center justify-center gap-4">
              <div className="flex w-full max-w-[920px] aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-[#fbfcff]">
                <ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
                <p className="text-sm font-medium text-[#111827]">暂无视频预览</p>
                <p className="mt-1 text-xs text-[#98a2b3]">生成分镜视频后会在这里播放。</p>
              </div>
            </div>
          ) : previewTask?.status === "succeeded" && previewTask.resultVideoUrl ? (
            <div className="flex h-full flex-col justify-center gap-3">
              <div className="overflow-hidden rounded-lg bg-black shadow-[0_18px_45px_rgba(15,23,42,0.16)] ring-1 ring-black/10">
                <video
                  key={previewTask.resultVideoUrl}
                  src={previewTask.resultVideoUrl}
                  controls
                  autoPlay={false}
                  className="aspect-video w-full object-contain"
                />
              </div>
              <a
                href={previewTask.resultVideoUrl}
                target="_blank"
                rel="noreferrer"
                className="inline-flex items-center gap-1.5 text-xs text-[#1c64ff] hover:underline font-medium"
              >
                <Download className="w-3.5 h-3.5" />
                下载此镜头视频
              </a>
            </div>
          ) : previewTask?.status === "running" || previewTask?.status === "submitted" || previewTask?.status === "pending" ? (
            <div className={`h-full flex flex-col items-center justify-center gap-4 rounded-2xl ${SOFT_PANEL}`}>
              <div className="relative w-16 h-16 flex items-center justify-center">
                <span className="absolute inset-0 rounded-full bg-[#1c64ff]/10 animate-pulse ring-8 ring-[#1c64ff]/5" />
                <Loader2 className="w-8 h-8 text-[#1c64ff] animate-spin relative" />
              </div>
              <p className="text-sm font-semibold text-foreground tracking-tight">视频生成中…</p>
              <p className="text-xs text-muted-foreground">通常需要 30-60 秒</p>
            </div>
          ) : previewTask?.status === "failed" ? (
            <div className="rounded-2xl border border-red-200 bg-red-50 p-6 shadow-[0_8px_24px_rgba(185,28,28,0.08)]">
              <div className="flex items-center gap-2 mb-2">
                <X className="w-4 h-4 text-red-500" />
                <span className="text-sm font-medium text-red-700">生成失败</span>
              </div>
              <p className="text-xs text-red-600">{previewTask.errorMessage || "未知错误"}</p>
            </div>
          ) : (
            <div className="h-full flex flex-col items-center justify-center gap-4">
              <div className="relative w-full max-w-[920px] aspect-video overflow-hidden rounded-lg bg-[#101319] shadow-[0_18px_45px_rgba(15,23,42,0.16)]">
                {selectedScene?.imageUrl ? (
                  <img src={selectedScene.imageUrl} alt={selectedScene.name} className="h-full w-full object-cover" />
                ) : (
                  <div className="flex h-full w-full flex-col items-center justify-center bg-[#fbfcff]">
                    <ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
                    <p className="text-sm font-medium text-[#111827]">暂无生成视频</p>
                    <p className="mt-1 text-xs text-[#98a2b3]">点击生成视频后会显示结果。</p>
                  </div>
                )}
                {selectedScene?.imageUrl && (
                  <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 to-transparent px-4 pb-3 pt-14">
                    <div className="flex items-center justify-between text-white">
                      <div className="flex items-center gap-3 text-xs font-mono">
                        <Play className="h-4 w-4 fill-white" />
                        <span>0:00 / 0:{String(selectedDuration).padStart(2, "0")}</span>
                      </div>
                      <span className="text-[11px] text-white/75">等待生成</span>
                    </div>
                  </div>
                )}
              </div>
            </div>
          )}
        </div>

        {/* Thumbnail timeline */}
        {storyboards.length > 0 && (
          <div className="bg-white flex-shrink-0">
            <div className="flex items-center justify-start gap-3 overflow-x-auto px-3 py-3">
              {storyboards.map((sb) => {
                const t = getTaskForSb(sb);
                const isActive = activeVideoSbId === sb.id || (!activeVideoSbId && previewDisplaySb?.id === sb.id);
                const url = t?.resultVideoUrl;
                return (
                  <button
                    key={sb.id}
                    onClick={() => {
                      setActiveVideoSbId(sb.id);
                      setExpandedId(sb.id);
                    }}
                    title={`分镜 ${String(sb.sequenceNum).padStart(3, "0")}`}
                    className={`relative flex-shrink-0 w-[70px] h-[40px] rounded-md overflow-hidden border transition-all duration-150 ${
                      isActive
                        ? "border-[#2f80ff] ring-2 ring-[#2f80ff]/25 shadow-[0_8px_18px_rgba(47,128,255,0.20)]"
                        : "border-slate-200 hover:border-[#2f80ff]/40 hover:-translate-y-px hover:shadow-sm"
                    }`}
                  >
                    {url ? (
                      <video
                        src={url}
                        muted
                        preload="metadata"
                        className="w-full h-full object-cover pointer-events-none"
                      />
                    ) : (
                      <div className="w-full h-full bg-[#f8fafc] flex items-center justify-center">
                        <ImageIcon className="w-4 h-4 text-muted-foreground/40" />
                      </div>
                    )}
                    <span className="absolute bottom-0 left-0 right-0 bg-black/65 text-white text-[10px] font-medium tabular-nums py-0.5 text-center">
                      {String(sb.sequenceNum).padStart(3, "0")}
                    </span>
                    <span className={`absolute top-1 right-1 w-1.5 h-1.5 rounded-full ring-2 ring-white/80 ${getDotColor(sb)}`} />
                  </button>
                );
              })}
            </div>
          </div>
        )}
      </div>
        </div>
      </div>

      {/* ─── Modals ─── */}
      {showCharacterPicker && expanded && (
        <CharacterPickerModal
          projectId={pid}
          characters={characters}
          characterRefs={characterRefOptions}
          currentKeys={characterKeys}
          onPick={(k) => {
            if (!characterKeys.includes(k) && characterKeys.length < MAX_CHARACTERS) {
              setCharacterKeys((prev) => [...prev, k]);
            }
          }}
          onClose={() => setShowCharacterPicker(false)}
        />
      )}
      {showPromptDetails && (
        <PromptDetailsModal
          text={freeformPrompt}
          agentInfo={storyboardInfoFields}
          onClose={() => setShowPromptDetails(false)}
        />
      )}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// StoryboardCard — accordion item with self-contained editor when expanded
// ────────────────────────────────────────────────────────────────────────────
function StoryboardInfoField({
  label,
  value,
  multiline = false,
  compact = false,
}: {
  label: string;
  value: string;
  multiline?: boolean;
  compact?: boolean;
}) {
  return (
    <div className={compact ? "px-3 py-2" : "px-3 py-2.5"}>
      <div className="flex items-start gap-2">
        <span className="w-16 flex-shrink-0 text-[11px] font-medium text-muted-foreground leading-relaxed">
          {label}
        </span>
        <span
          className={`min-w-0 flex-1 text-[11px] leading-relaxed text-foreground/85 ${
            multiline ? "line-clamp-3 break-words" : "truncate"
          }`}
          title={value}
        >
          {value}
        </span>
      </div>
    </div>
  );
}

interface StoryboardCardProps {
  sb: Storyboard;
  isExpanded: boolean;
  dotClass: string;
  onToggle: () => void;
  onDelete: () => void;
  shortDescription: string;
  onShortDescriptionChange: (v: string) => void;
  characterKeys: string[];
  onRemoveCharacter: (key: string) => void;
  onSwapCharacter: (oldKey: string, newKey: string) => void;
  onAddCharacter: () => void;
  keyToCharacterRef: Map<string, CharacterRefOption>;
  characterRefOptions: CharacterRefOption[];
  sceneKey: string;
  onSelectScene: (key: string) => void;
  propKeys: string[];
  onTogglePropKey: (key: string) => void;
  generationMode: VideoGenerationMode;
  onGenerationModeChange: (mode: VideoGenerationMode) => void;
  firstFrameImageKey: string;
  firstFrameImageUrl: string;
  lastFrameImageKey: string;
  lastFrameImageUrl: string;
  onUploadFrame: (frameType: "first" | "last", file: File) => void | Promise<void>;
  onClearFrame: (frameType: "first" | "last") => void;
  frameUploading: boolean;
  scenes: Scene[];
  sceneByKey: Map<string, Scene>;
}

function StoryboardCard(props: StoryboardCardProps) {
  const { sb, isExpanded, dotClass, onToggle, onDelete } = props;
  const [descCollapsed, setDescCollapsed] = useState(false);
  const modeOptions = [
    { value: "reference", label: "参考图生成", icon: Sparkles },
    { value: "frames", label: "首尾帧生成", icon: ImageIcon },
  ] as const;

  return (
    <div className={`my-2 rounded-lg border transition-all duration-200 ${
      isExpanded
        ? "border-[#e4e8f0] bg-white shadow-sm"
        : "border-[#edf0f5] bg-white hover:bg-white hover:border-[#cfd6e4]"
    }`}>
      <button
        onClick={onToggle}
        className="w-full px-3 pt-3 pb-2.5 flex items-center gap-2 text-left rounded-t-lg"
      >
        <span className="text-sm font-semibold text-foreground flex-1 truncate">
          分镜信息{String(sb.sequenceNum).padStart(3, "0")}
        </span>
        <span className={`w-2 h-2 rounded-full flex-shrink-0 ring-4 ring-white ${dotClass}`} />
      </button>

      {isExpanded && (
        <div className="px-3 pb-3 space-y-3 border-t border-[#f1f3f7]">
          <div className="pt-3">
            <div className="grid grid-cols-2 gap-1 rounded-lg border border-[#dfe3ee] bg-[#f8fafc] p-1">
              {modeOptions.map((option) => {
                const Icon = option.icon;
                const active = props.generationMode === option.value;
                return (
                  <button
                    key={option.value}
                    type="button"
                    onClick={() => props.onGenerationModeChange(option.value)}
                    className={`flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] font-medium transition-all ${
                      active
                        ? "bg-white text-[#7657ff] shadow-sm ring-1 ring-[#7657ff]/20"
                        : "text-muted-foreground hover:bg-white/70 hover:text-[#475467]"
                    }`}
                  >
                    <Icon className="h-3.5 w-3.5" />
                    {option.label}
                  </button>
                );
              })}
            </div>
          </div>

          {/* 分镜描述 */}
          <div>
            <button
              onClick={() => setDescCollapsed((v) => !v)}
              className="w-full flex items-center justify-between mb-1.5 rounded-md text-left"
            >
              <span className="text-xs font-medium text-foreground">分镜描述</span>
              {descCollapsed
                ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
                : <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" />}
            </button>
            {!descCollapsed && (
              <textarea
                rows={4}
                value={props.shortDescription}
                onChange={(e) => props.onShortDescriptionChange(e.target.value)}
                placeholder="简要描述此分镜的剧情或画面"
                className={`w-full px-3 py-2 rounded-md border border-[#d9dee8] bg-white text-xs text-[#111827] leading-relaxed resize-none shadow-inner shadow-slate-100/80 ${FOCUS_RING}`}
              />
            )}
          </div>

          {props.generationMode === "reference" ? (
            <>
              {/* 出镜角色 */}
              <CharacterRowList
                characterKeys={props.characterKeys}
                keyToCharacterRef={props.keyToCharacterRef}
                characterRefOptions={props.characterRefOptions}
                onRemove={props.onRemoveCharacter}
                onSwap={props.onSwapCharacter}
                onAdd={props.onAddCharacter}
              />

              {/* 分镜场景 */}
              <SceneSection
                scenes={props.scenes}
                selectedKey={props.sceneKey}
                onSelect={props.onSelectScene}
              />

              {/* 场景道具 */}
              <PropGrid
                scenes={props.scenes}
                sceneKey={props.sceneKey}
                selectedKeys={props.propKeys}
                onToggle={props.onTogglePropKey}
              />
            </>
          ) : (
            <FrameReferenceSection
              firstFrameImageKey={props.firstFrameImageKey}
              firstFrameImageUrl={props.firstFrameImageUrl}
              lastFrameImageKey={props.lastFrameImageKey}
              lastFrameImageUrl={props.lastFrameImageUrl}
              onUpload={props.onUploadFrame}
              onClear={props.onClearFrame}
              uploading={props.frameUploading}
            />
          )}

          {/* 删除按钮 */}
          <div className="pt-1 flex justify-end">
            <button
              onClick={onDelete}
              className="text-[11px] text-destructive hover:text-red-700 flex items-center gap-1 rounded-md px-2 py-1 hover:bg-red-50 transition-colors"
            >
              <Trash2 className="w-3 h-3" />
              删除该分镜
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// CharacterRowList — selected character variants as horizontal rows:
//   [avatar | name dropdown | voice dropdown | 删除 | 重绘]
// ────────────────────────────────────────────────────────────────────────────
interface CharacterRowListProps {
  characterKeys: string[];
  keyToCharacterRef: Map<string, CharacterRefOption>;
  characterRefOptions: CharacterRefOption[];
  onRemove: (key: string) => void;
  onSwap: (oldKey: string, newKey: string) => void;
  onAdd: () => void;
}

function CharacterRowList({
  characterKeys, keyToCharacterRef, characterRefOptions, onRemove, onSwap, onAdd,
}: CharacterRowListProps) {
  return (
    <div className="rounded-md bg-white p-0">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs font-medium text-foreground">出镜角色</span>
        <button
          onClick={onAdd}
          disabled={characterKeys.length >= MAX_CHARACTERS}
          className="flex items-center gap-0.5 text-[11px] text-[#1c64ff] hover:text-[#164fd0] font-medium disabled:opacity-40"
        >
          <Plus className="w-3 h-3" />
          添加
        </button>
      </div>
      {characterKeys.length === 0 ? (
        <p className="text-[11px] text-muted-foreground italic">点击「添加」从角色库挑选本体或状态图</p>
      ) : (
        <div className="space-y-2">
          {characterKeys.map((k) => (
            <CharacterRow
              key={k}
              tosKey={k}
              refOption={keyToCharacterRef.get(k) ?? null}
              allRefOptions={characterRefOptions}
              currentKeys={characterKeys}
              onRemove={() => onRemove(k)}
              onSwap={(newKey) => onSwap(k, newKey)}
            />
          ))}
        </div>
      )}
    </div>
  );
}

interface CharacterRowProps {
  tosKey: string;
  refOption: CharacterRefOption | null;
  allRefOptions: CharacterRefOption[];
  currentKeys: string[];
  onRemove: () => void;
  onSwap: (newKey: string) => void;
}

function CharacterRow({
  tosKey, refOption, allRefOptions, currentKeys, onRemove, onSwap,
}: CharacterRowProps) {
  const { projectId } = useParams<{ projectId: string }>();
  const regen = useGenerateCharacterImage(projectId ?? "");
  const regenState = useGenerateCharacterStateImage(projectId ?? "");

  const handleRegen = () => {
    if (!refOption) return;
    if (refOption.kind === "state" && refOption.state) {
      regenState.mutate({ characterId: refOption.state.characterId, stateId: refOption.state.id });
      return;
    }
    if (refOption.character) {
      regen.mutate(refOption.character.id);
    }
  };

  const url = refOption?.imageUrl ?? null;
  const isRegenerating = regen.isPending || regenState.isPending;

  // Options: this character + any others not already used in the storyboard
  const options = allRefOptions.filter((option) => option.key === tosKey || !currentKeys.includes(option.key));

  return (
    <div className="flex items-center gap-1.5 rounded-md bg-white py-1">
      <div className="w-10 h-10 rounded-md overflow-hidden border border-slate-200 bg-slate-100 flex-shrink-0">
        {url ? (
          <img src={url} alt={refOption?.name ?? ""} className="w-full h-full object-contain bg-white" />
        ) : (
          <div className="w-full h-full flex items-center justify-center text-[8px] text-muted-foreground">无图</div>
        )}
      </div>
      <div className="relative flex-1 min-w-0">
        <select
          value={tosKey}
          onChange={(e) => onSwap(e.target.value)}
          className={`w-full appearance-none pl-2 pr-6 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] cursor-pointer truncate ${FOCUS_RING}`}
        >
          {!refOption && <option value={tosKey}>(已失效)</option>}
          {options.map((option) => (
            <option key={option.key} value={option.key}>
              {option.name}{option.kind === "state" ? "（状态）" : ""}
            </option>
          ))}
        </select>
        <ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
      </div>
      <div className="relative flex-1 min-w-0">
        <select
          disabled
          className="w-full appearance-none pl-2 pr-6 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] cursor-not-allowed truncate"
        >
          <option>未配置音色</option>
        </select>
        <ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
      </div>
      <button
        onClick={onRemove}
        className="px-2 py-1.5 rounded-md border border-[#dfe3ee] text-[11px] text-[#475467] hover:bg-red-50 hover:text-red-600 transition-colors"
      >
        删除
      </button>
      <button
        onClick={handleRegen}
        disabled={!refOption || isRegenerating}
        className="px-2 py-1.5 rounded-md border border-[#dfe3ee] text-[11px] text-[#475467] hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 flex items-center gap-1 transition-colors"
      >
        {isRegenerating && <Loader2 className="w-3 h-3 animate-spin" />}
        重绘
      </button>
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// SceneSection — large preview + scene picker dropdown + more menu
// ────────────────────────────────────────────────────────────────────────────
interface SceneSectionProps {
  scenes: Scene[];
  selectedKey: string;
  onSelect: (key: string) => void;
}
function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
  const [showPicker, setShowPicker] = useState(false);
  const [menuOpen, setMenuOpen] = useState(false);
  const usable = scenes.filter((s) => !!s.imageTosKey);
  const selected = usable.find((s) => s.imageTosKey === selectedKey) ?? null;

  return (
    <div className="rounded-md bg-white p-0">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs font-medium text-foreground">分镜场景</span>
        {usable.length > 0 && (
          <button
            onClick={() => setShowPicker((v) => !v)}
            className="flex items-center gap-0.5 text-[11px] text-[#1c64ff] hover:text-[#164fd0] font-medium"
          >
            <Plus className="w-3 h-3" />
            {selected ? "更换" : "选择"}
          </button>
        )}
      </div>

      {/* Big preview — clickable to open picker */}
      <button
        type="button"
        onClick={() => usable.length > 0 && setShowPicker((v) => !v)}
        disabled={usable.length === 0}
        className="w-full aspect-video rounded-md overflow-hidden border border-[#d9dee8] bg-slate-100 mb-2 block hover:border-[#7657ff]/40 hover:shadow-sm transition disabled:cursor-not-allowed"
      >
        {selected?.imageUrl ? (
          <img src={selected.imageUrl} alt={selected.name} className="w-full h-full object-cover" />
        ) : (
          <div className="w-full h-full flex flex-col items-center justify-center gap-1 text-muted-foreground">
            <ImageIcon className="w-5 h-5" />
            <span className="text-[11px]">
              {usable.length === 0 ? "先到「场景」页创建场景" : "点击选择一张场景图"}
            </span>
          </div>
        )}
      </button>

      {/* Bottom row: selected scene text + actions menu */}
      <div className="flex items-center gap-1.5">
        <div className="flex-1 min-w-0 px-1 py-1.5 text-xs text-[#111827] truncate">
          {selected?.name || <span className="text-muted-foreground">未选择场景</span>}
        </div>
        <div className="relative flex-shrink-0">
          <button
            onClick={() => setMenuOpen((v) => !v)}
            disabled={usable.length === 0}
            className="p-1.5 rounded-md border border-[#dfe3ee] bg-white text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 transition-colors"
          >
            <MoreHorizontal className="w-3.5 h-3.5" />
          </button>
          {menuOpen && usable.length > 0 && (
            <>
              <button
                className="fixed inset-0 z-10 cursor-default"
                onClick={() => setMenuOpen(false)}
              />
              <div className="absolute right-0 top-full mt-1 z-20 bg-white border border-slate-200/80 rounded-xl shadow-lg overflow-hidden min-w-[100px]">
                <button
                  onClick={() => { setShowPicker(true); setMenuOpen(false); }}
                  className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-[#f6f9ff]"
                >
                  场景图
                </button>
                {selected && (
                  <button
                    onClick={() => { onSelect(""); setMenuOpen(false); }}
                    className="w-full px-3 py-1.5 text-left text-xs text-destructive hover:bg-red-50"
                  >
                    清除选择
                  </button>
                )}
              </div>
            </>
          )}
        </div>
      </div>

      {showPicker && (
        <div className="mt-2 rounded-xl border border-slate-200/80 bg-white/80 p-2">
          {usable.length === 0 ? (
            <p className="text-[11px] text-muted-foreground italic text-center py-2">先到「场景」页创建场景</p>
          ) : (
            <>
              <div className="mb-2 flex items-center justify-between">
                <span className="text-[11px] font-medium text-[#344054]">选择一个场景图</span>
                <span className="text-[10px] text-muted-foreground">单选</span>
              </div>
              <div className="grid grid-cols-3 gap-2">
                {usable.map((s) => {
                  const active = selectedKey === s.imageTosKey;
                  return (
                    <button
                      key={s.id}
                      onClick={() => {
                        onSelect(s.imageTosKey!);
                        setShowPicker(false);
                      }}
                      title={s.name}
                      className={`relative aspect-square rounded-md overflow-hidden border-2 text-left transition ${
                        active ? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25" : "border-slate-200 hover:border-[#1c64ff]/40"
                      }`}
                    >
                      {s.imageUrl ? (
                        <img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
                      ) : (
                        <div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
                      )}
                      <div className="absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white/90 shadow-sm ring-1 ring-black/10">
                        <span className={`h-2.5 w-2.5 rounded-full ${active ? "bg-[#1c64ff]" : "bg-transparent ring-1 ring-slate-400"}`} />
                      </div>
                      <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-1 pt-3 pb-0.5">
                        <span className="text-[10px] text-white truncate block">{s.name}</span>
                      </div>
                    </button>
                  );
                })}
              </div>
            </>
          )}
        </div>
      )}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// PropGrid — selected props as 3-col card grid + inline picker drawer
// ────────────────────────────────────────────────────────────────────────────
interface PropGridProps {
  scenes: Scene[];
  sceneKey: string;
  selectedKeys: string[];
  onToggle: (key: string) => void;
}
function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
  const [showPicker, setShowPicker] = useState(false);
  const allCandidates = scenes.filter((s) => !!s.imageTosKey && s.imageTosKey !== sceneKey);
  const selected = selectedKeys
    .map((k) => allCandidates.find((s) => s.imageTosKey === k))
    .filter((s): s is Scene => !!s);
  const available = allCandidates.filter((s) => !selectedKeys.includes(s.imageTosKey!));
  const canAddMore = selectedKeys.length < MAX_PROPS;

  return (
    <div className="rounded-md bg-white p-0">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs font-medium text-foreground">场景道具</span>
        <button
          onClick={() => setShowPicker((v) => !v)}
          disabled={!canAddMore && !showPicker}
          className="flex items-center gap-0.5 text-[11px] text-[#1c64ff] hover:text-[#164fd0] font-medium disabled:opacity-40"
        >
          <Plus className="w-3 h-3" />
          添加
        </button>
      </div>

      {selected.length === 0 ? (
        <p className="text-[11px] text-muted-foreground italic mb-2">从场景库中可挑选最多 5 张作为道具</p>
      ) : (
        <div className="grid grid-cols-3 gap-2 mb-2">
          {selected.map((s) => (
            <button
              key={s.id}
              onClick={() => onToggle(s.imageTosKey!)}
              title={`移除 ${s.name}`}
            className="relative aspect-square rounded-md overflow-hidden border border-[#dfe3ee] bg-slate-100 group hover:shadow-sm transition-shadow"
            >
              {s.imageUrl ? (
                <img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
              ) : (
                <div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
              )}
              <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/40 to-transparent px-1.5 pt-3 pb-1 text-left">
                <span className="text-[11px] text-white font-medium truncate block">{s.name}</span>
              </div>
              <div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
                <X className="w-2.5 h-2.5" />
              </div>
            </button>
          ))}
        </div>
      )}

      {showPicker && (
        <div className="rounded-md border border-[#dfe3ee] bg-white/80 p-2">
          {available.length === 0 ? (
            <p className="text-[11px] text-muted-foreground italic text-center py-2">
              {allCandidates.length === 0 ? "先到「场景」页创建场景" : "已添加全部可选道具"}
            </p>
          ) : (
            <div className="grid grid-cols-4 gap-1.5">
              {available.map((s) => (
                <button
                  key={s.id}
                  onClick={() => {
                    onToggle(s.imageTosKey!);
                    if (selectedKeys.length + 1 >= MAX_PROPS) setShowPicker(false);
                  }}
                  disabled={!canAddMore}
                  title={s.name}
                  className="relative aspect-square rounded-md overflow-hidden border border-slate-200 bg-white hover:border-[#7657ff]/40 hover:shadow-sm disabled:opacity-40 transition"
                >
                  {s.imageUrl ? (
                    <img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
                  ) : (
                    <div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
                  )}
                  <div className="absolute inset-x-0 bottom-0 bg-black/60 px-1 py-0.5">
                    <span className="text-[9px] text-white truncate block">{s.name}</span>
                  </div>
                </button>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// FrameReferenceSection — first/last frame images used as extra video references
// ────────────────────────────────────────────────────────────────────────────
interface FrameReferenceSectionProps {
  firstFrameImageKey: string;
  firstFrameImageUrl: string;
  lastFrameImageKey: string;
  lastFrameImageUrl: string;
  onUpload: (frameType: "first" | "last", file: File) => void | Promise<void>;
  onClear: (frameType: "first" | "last") => void;
  uploading: boolean;
}

function FrameReferenceSection({
  firstFrameImageKey,
  firstFrameImageUrl,
  lastFrameImageKey,
  lastFrameImageUrl,
  onUpload,
  onClear,
  uploading,
}: FrameReferenceSectionProps) {
  return (
    <div className="rounded-md bg-white p-0">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs font-medium text-foreground">首尾帧图</span>
        <span className="text-[10px] text-muted-foreground">作为视频参考图</span>
      </div>
      <div className="grid grid-cols-2 gap-2">
        <FrameReferenceTile
          label="上传首帧"
          imageKey={firstFrameImageKey}
          imageUrl={firstFrameImageUrl}
          frameType="first"
          uploading={uploading}
          onUpload={onUpload}
          onClear={onClear}
        />
        <FrameReferenceTile
          label="上传尾帧"
          imageKey={lastFrameImageKey}
          imageUrl={lastFrameImageUrl}
          frameType="last"
          uploading={uploading}
          onUpload={onUpload}
          onClear={onClear}
        />
      </div>
      <p className="mt-2 rounded-md border border-[#dbeafe] bg-[#eff6ff] px-2 py-1 text-[10px] leading-4 text-[#1d4ed8]">
        关键帧会按首帧、尾帧顺序参与视频生成，可随时替换。
      </p>
    </div>
  );
}

interface FrameReferenceTileProps {
  label: string;
  imageKey: string;
  imageUrl: string;
  frameType: "first" | "last";
  uploading: boolean;
  onUpload: (frameType: "first" | "last", file: File) => void | Promise<void>;
  onClear: (frameType: "first" | "last") => void;
}

function FrameReferenceTile({
  label,
  imageKey,
  imageUrl,
  frameType,
  uploading,
  onUpload,
  onClear,
}: FrameReferenceTileProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSelect = async (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (!file) return;
    await onUpload(frameType, file);
  };

  return (
    <div className="relative rounded-md border border-[#dfe3ee] bg-white p-1.5">
      <button
        type="button"
        onClick={() => inputRef.current?.click()}
        disabled={uploading}
        className="relative block w-full aspect-[4/3] overflow-hidden rounded-md border border-[#e5e7ef] bg-slate-100 disabled:opacity-60"
        title={imageKey ? "点击替换图片" : label}
      >
        {imageUrl ? (
          <img src={imageUrl} alt={label} className="h-full w-full object-cover" />
        ) : (
          <div className="flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground">
            {uploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <ImageIcon className="h-5 w-5" />}
            <span className="text-[11px] font-medium">{label}</span>
          </div>
        )}
      </button>
      <div className="mt-1 flex items-center justify-between gap-1">
        <button
          type="button"
          onClick={() => inputRef.current?.click()}
          disabled={uploading}
          className="min-w-0 flex-1 truncate rounded-md px-1.5 py-1 text-left text-[10px] font-medium text-[#344054] hover:bg-[#f6f9ff] disabled:opacity-50"
        >
          {imageKey ? (frameType === "first" ? "首帧图" : "尾帧图") : label}
        </button>
        {imageKey && (
          <button
            type="button"
            onClick={() => onClear(frameType)}
            disabled={uploading}
            className="rounded-md p-1 text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
            title="清除"
          >
            <Trash2 className="h-3 w-3" />
          </button>
        )}
      </div>
      <input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleSelect} />
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// CharacterPickerModal — pick base characters or generated character states
// ────────────────────────────────────────────────────────────────────────────
interface CharacterPickerModalProps {
  projectId: string;
  characters: Character[];
  characterRefs: CharacterRefOption[];
  currentKeys: string[];
  onPick: (key: string) => void;
  onClose: () => void;
}
function CharacterPickerModal({
  projectId, characters, characterRefs, currentKeys, onPick, onClose,
}: CharacterPickerModalProps) {
  const [createMode, setCreateMode] = useState(false);

  if (createMode) {
    return (
      <AddCharacterStateModal
        projectId={projectId}
        characters={characters}
        onClose={() => setCreateMode(false)}
        onAdded={(key) => {
          onPick(key);
          setCreateMode(false);
          onClose();
        }}
      />
    );
  }

  const usable = characterRefs.filter((option) => !!option.key);

  return (
    <div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
      <div
        className="bg-white rounded-2xl shadow-[0_24px_70px_rgba(15,23,42,0.28)] ring-1 ring-white/60 w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center justify-between px-5 py-3 border-b border-slate-200/80 bg-[#f8fafc]">
          <h3 className="text-sm font-semibold text-foreground">添加出镜角色</h3>
          <button onClick={onClose} className="p-1 hover:bg-white rounded-lg transition-colors">
            <X className="w-4 h-4 text-muted-foreground" />
          </button>
        </div>

        <div className="flex-1 overflow-y-auto p-5">
          <p className="text-xs text-muted-foreground mb-3">从角色本体或状态图中挑选，状态图会继承同一角色基准图。</p>
          {usable.length === 0 ? (
            <p className="text-xs text-muted-foreground italic text-center py-8">
              当前项目还没有可用角色图，请先生成角色三视图
            </p>
          ) : (
            <div className="grid grid-cols-3 gap-3">
              {usable.map((option) => {
                const isAdded = currentKeys.includes(option.key);
                return (
                  <button
                    key={option.key}
                    disabled={isAdded}
                    onClick={() => { onPick(option.key); onClose(); }}
                    className={`relative rounded-lg overflow-hidden border-2 transition ${
                      isAdded
                        ? "border-[#1c64ff] opacity-50 cursor-not-allowed"
                        : "border-slate-200 hover:border-[#1c64ff]/60 hover:shadow-sm"
                    }`}
                  >
                    <div className="aspect-video bg-white border-b border-slate-200/70">
                      {option.imageUrl ? (
                        <img src={option.imageUrl} alt={option.name} className="w-full h-full object-contain" />
                      ) : (
                        <div className="w-full h-full flex items-center justify-center text-[10px] text-muted-foreground">无图</div>
                      )}
                    </div>
                    <div className="px-2 py-1.5 text-left">
                      <p className="text-xs font-medium text-foreground truncate">{option.name}</p>
                      <p className="text-[10px] text-muted-foreground">{option.kind === "state" ? "状态图" : "角色本体"}</p>
                    </div>
                    {isAdded && (
                      <div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-[#1c64ff] flex items-center justify-center shadow-sm">
                        <Check className="w-3 h-3 text-white" />
                      </div>
                    )}
                  </button>
                );
              })}
            </div>
          )}
        </div>

        <div className="px-5 py-3 border-t border-slate-200/80 bg-[#f8fafc] flex justify-between items-center gap-2">
          <button
            onClick={() => setCreateMode(true)}
            className="px-3 py-1.5 rounded-lg border border-[#1c64ff]/35 text-[#1c64ff] text-xs hover:bg-[#1c64ff]/10 flex items-center gap-1.5 transition-colors"
          >
            <Plus className="w-3 h-3" />
            创建角色状态图
          </button>
          <button
            onClick={onClose}
            className="px-4 py-1.5 rounded-lg border border-slate-200/80 bg-white text-xs text-foreground hover:bg-[#f6f9ff] transition-colors"
          >
            完成
          </button>
        </div>
      </div>
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// AddCharacterStateModal — create a state image from an existing character reference
// ────────────────────────────────────────────────────────────────────────────
interface AddCharacterStateModalProps {
  projectId: string;
  characters: Character[];
  onClose: () => void;
  onAdded: (key: string) => void;
}
function AddCharacterStateModal({ projectId, characters, onClose, onAdded }: AddCharacterStateModalProps) {
  const usableCharacters = characters.filter((character) => !!characterFrontKey(character));
  const [characterId, setCharacterId] = useState(usableCharacters[0]?.id ?? "");
  const [name, setName] = useState("");
  const [stateType, setStateType] = useState("costume");
  const [description, setDescription] = useState("");
  const [imagePrompt, setImagePrompt] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const saveState = useSaveCharacterState(projectId);
  const genState = useGenerateCharacterStateImage(projectId);

  const handleSubmit = async () => {
    if (!characterId) {
      setError("请先选择一个已有角色");
      return;
    }
    if (!name.trim()) {
      setError("请输入状态名称");
      return;
    }
    if (!imagePrompt.trim()) {
      setError("请输入状态提示词");
      return;
    }
    setError(null);
    setBusy(true);
    try {
      const saved = await saveState.mutateAsync({
        characterId,
        state: {
          name: name.trim(),
          stateType,
          description: description.trim() || null,
          prompt: imagePrompt.trim(),
          status: "draft",
        },
      });
      const withImage = await genState.mutateAsync({ characterId, stateId: saved.id });
      if (withImage.imageTosKey) {
        onAdded(withImage.imageTosKey);
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "状态图创建失败");
    } finally {
      setBusy(false);
    }
  };

  return (
    <div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-50 flex items-center justify-center p-4">
      <div className="bg-white rounded-2xl shadow-[0_24px_70px_rgba(15,23,42,0.28)] ring-1 ring-white/60 w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden">
        <div className="flex items-center justify-between px-5 py-3 border-b border-slate-200/80 bg-[#f8fafc]">
          <h3 className="text-sm font-semibold text-foreground">新增角色状态图</h3>
          <button onClick={onClose} className="p-1 hover:bg-white rounded-lg transition-colors">
            <X className="w-4 h-4 text-muted-foreground" />
          </button>
        </div>

        <div className="flex-1 overflow-y-auto p-5 space-y-3">
          <p className="text-[11px] text-muted-foreground">从已有角色基准图生成换装、受伤、妆容或情绪状态。</p>
          <div>
            <label className="block text-xs text-foreground mb-1">基准角色 *</label>
            <select
              value={characterId}
              onChange={(e) => setCharacterId(e.target.value)}
              className={`w-full px-3 py-2 rounded-xl border border-slate-200/80 bg-white text-sm ${FOCUS_RING}`}
            >
              {usableCharacters.map((character) => (
                <option key={character.id} value={character.id}>{character.name}</option>
              ))}
            </select>
          </div>
          <div>
            <label className="block text-xs text-foreground mb-1">状态名称 *</label>
            <input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              placeholder="例如：白衣、雨夜伤痕、黑裙"
              className={`w-full px-3 py-2 rounded-xl border border-slate-200/80 bg-white text-sm ${FOCUS_RING}`}
            />
          </div>
          <div>
            <label className="block text-xs text-foreground mb-1">状态类型 *</label>
            <select
              value={stateType}
              onChange={(e) => setStateType(e.target.value)}
              className={`w-full px-3 py-2 rounded-xl border border-slate-200/80 bg-white text-sm ${FOCUS_RING}`}
            >
              <option value="costume">服装</option>
              <option value="accessory">饰品</option>
              <option value="expression">表情</option>
              <option value="injury">伤痕</option>
              <option value="makeup">妆容</option>
              <option value="skin">肤色</option>
              <option value="hair">发型细节</option>
              <option value="age">年龄状态</option>
              <option value="other">其他</option>
            </select>
          </div>
          <div>
            <label className="block text-xs text-foreground mb-1">状态描述</label>
            <input
              type="text"
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              placeholder="例如：保留同一张脸，只更换白色长袍"
              className={`w-full px-3 py-2 rounded-xl border border-slate-200/80 bg-white text-sm ${FOCUS_RING}`}
            />
          </div>
          <div>
            <label className="block text-xs text-foreground mb-1">状态提示词 *</label>
            <textarea
              rows={4}
              value={imagePrompt}
              onChange={(e) => setImagePrompt(e.target.value)}
              placeholder="例如：keep same face and body, change to white robe, calm expression, no identity change"
              className={`w-full px-3 py-2 rounded-xl border border-slate-200/80 bg-white text-sm leading-relaxed resize-none ${FOCUS_RING}`}
            />
          </div>
          {error && <p className="text-xs text-red-500">{error}</p>}
        </div>

        <div className="px-5 py-3 border-t border-slate-200/80 bg-[#f8fafc] flex justify-end gap-2">
          <button
            onClick={onClose}
            disabled={busy}
            className="px-4 py-1.5 rounded-lg border border-slate-200/80 bg-white text-xs text-foreground hover:bg-[#f6f9ff] disabled:opacity-50 transition-colors"
          >
            取消
          </button>
          <button
            onClick={handleSubmit}
            disabled={busy}
            className="px-4 py-1.5 rounded-lg bg-[#1c64ff] text-white text-xs shadow-[0_6px_14px_rgba(28,100,255,0.22)] hover:bg-[#1857e8] hover:shadow-[0_8px_18px_rgba(28,100,255,0.28)] disabled:opacity-50 flex items-center gap-1.5 transition-all"
          >
            {busy && <Loader2 className="w-3 h-3 animate-spin" />}
            创建状态图
          </button>
        </div>
      </div>
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// PromptDetailsModal — full prompt content
// ────────────────────────────────────────────────────────────────────────────
interface PromptDetailsModalProps {
  text: string;
  agentInfo: Array<{ label: string; value: string; multiline?: boolean }>;
  onClose: () => void;
}
function PromptDetailsModal({ text, agentInfo, onClose }: PromptDetailsModalProps) {
  const trimmed = text.trim();
  return (
    <div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
      <div
        className="bg-white rounded-2xl shadow-[0_24px_70px_rgba(15,23,42,0.28)] ring-1 ring-white/60 w-full max-w-lg max-h-[80vh] flex flex-col overflow-hidden"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center justify-between px-5 py-3 border-b border-slate-200/80 bg-[#f8fafc]">
          <h3 className="text-sm font-semibold text-foreground">分镜提示词全文</h3>
          <button onClick={onClose} className="p-1 hover:bg-white rounded-lg transition-colors">
            <X className="w-4 h-4 text-muted-foreground" />
          </button>
        </div>
        <div className="flex-1 overflow-y-auto p-5 space-y-4">
          {trimmed ? (
            <pre className="text-sm text-foreground leading-relaxed whitespace-pre-wrap font-mono rounded-xl border border-slate-200/80 bg-[#f8fafc] p-4">
              {trimmed}
            </pre>
          ) : (
            <p className="text-sm text-muted-foreground italic">（未填写提示词）</p>
          )}
          {agentInfo.length > 0 && (
            <div className="rounded-xl border border-slate-200/80 bg-white divide-y divide-slate-100 overflow-hidden">
              <div className="px-3 py-2 flex items-center justify-between bg-[#f8fafc]">
                <span className="text-xs font-medium text-foreground">Agent 分镜信息</span>
                <span className="text-[10px] text-muted-foreground">仅作参考</span>
              </div>
              {agentInfo.map((item) => (
                <StoryboardInfoField
                  key={item.label}
                  label={item.label}
                  value={item.value}
                  multiline={item.multiline}
                />
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
