<!-- 短剧创作工作台，负责编排剧本解析、分镜编辑、资产和媒体生成。 -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Edit, Setting } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import {
  ArrowLeft,
  CircleAlert,
  Download,
  FileText,
  Film,
  Image,
  LoaderCircle,
  Lock,
  Play,
  Plus,
  Save,
  Trash2,
  Unlock,
  Volume2,
  X,
} from "@/icons";
import { useRoute } from "vue-router";
import AssetPanel from "@/components/creative/AssetPanel.vue";
import AssetPromptEditor from "@/components/creative/AssetPromptEditor.vue";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
import ModelConfigDialog from "@/components/creative/ModelConfigDialog.vue";
import ProjectDialog from "@/components/creative/ProjectDialog.vue";
import {
  creativeApi,
  type CreativeOptions,
  type DramaEpisodeSource,
  type DramaParseTask,
  type GenerationOutput,
  type GenerationTask,
  type ProjectAsset,
  type ProjectPayload,
  type Storyboard,
  type WorkbenchData,
} from "@/api/creative";
import { apiError } from "@/api/http";
import { premiumDramaPromptTypes, useModelPrompts } from "@/composables/useModelPrompts";
import { useToastStore } from "@/stores/toast";
import { readAudioDurationMs } from "@/utils/media";
import { downloadBlob } from "@/utils/download";
const route = useRoute();
const toast = useToastStore();
const projectId = String(route.params.projectId);
const episodeId = String(route.params.episodeId);
const data = ref<WorkbenchData>();
const options = ref<CreativeOptions>({ styles: [], models: [] });
const source = ref<DramaEpisodeSource>();
const sourceText = ref("");
const selectedId = ref("");
const tasks = ref<GenerationTask[]>([]);
const parseTasks = ref<DramaParseTask[]>([]);
const loading = ref(false);
const saving = ref(false);
const assetBusy = ref(false);
const generating = ref(false);
const sourceSaving = ref(false);
const sourceLoading = ref(false);
const sourceOpen = ref(false);
const projectOpen = ref(false);
const modelsOpen = ref(false);
const settingsSaving = ref(false);
const startingParse = ref(false);
const downloadingVideos = ref(false);
const historyOpen = ref(false);
const historyLoading = ref(false);
const outputs = ref<GenerationOutput[]>([]);
const activatingId = ref("");
const mediaFocus = ref<{ type: "storyboard_image" | "storyboard_video"; storyboardId: string }>();
const assetFocus = ref<{ assetId: string }>();
const editingStoryboardId = ref("");
const taskClock = ref(Date.now());
const { prompts, promptSelections, loadPrompts, selectPrompt, createPrompt, updatePrompt, deletePrompt } =
  useModelPrompts();
let timer: ReturnType<typeof setInterval> | undefined;
let clockTimer: ReturnType<typeof setInterval> | undefined;

const activeTaskStatuses = [
  "pending_submission",
  "submitting",
  "submitted",
  "processing",
  "result_ready",
  "downloading",
  "cancel_requested",
];
const selected = computed(() => data.value?.storyboards.find((item) => item.id === selectedId.value));
const videoModelCapabilities = computed(
  () => data.value?.project.model_configs.find((item) => item.purpose === "video_generation")?.capabilities,
);
const videoDurationRange = computed(() =>
  videoModelCapabilities.value
    ? {
        min: videoModelCapabilities.value.duration_min_seconds,
        max: videoModelCapabilities.value.duration_max_seconds,
      }
    : undefined,
);
const selectedVideoReferenceLimitReason = computed(() =>
  selected.value ? storyboardVideoReferenceLimitReason(selected.value) : "",
);
const activeParse = computed(() =>
  parseTasks.value.find((item) => ["queued", "running", "retry_wait", "cancel_requested"].includes(item.status)),
);
const failedParse = computed(() => {
  const task = parseTasks.value[0];
  return task?.status === "failed" ? task : undefined;
});
const parseLoadingText = computed(() =>
  activeParse.value?.status === "cancel_requested"
    ? "剧本解析取消中"
    : activeParse.value?.status === "queued"
      ? "剧本解析排队中"
      : "剧本解析中",
);
const episodeTitle = computed(() => (data.value ? `第${data.value.episode.episode_no}集` : "剧集工作台"));
const hasDownloadableVideos = computed(() => Boolean(data.value?.storyboards.some((item) => item.result_url)));
const backTarget = computed(() => ({
  path: `/drama-projects/${projectId}`,
  query: { from: String(route.query.from || "drama-projects") },
}));

// downloadAllVideos 下载当前短剧剧集的主视频和备选视频 ZIP，不包含历史记录。
async function downloadAllVideos() {
  if (!data.value || downloadingVideos.value || !hasDownloadableVideos.value) return;
  downloadingVideos.value = true;
  try {
    const blob = await creativeApi.episodeVideosArchive(projectId, episodeId);
    downloadBlob(blob, `${data.value.project.name}${data.value.episode.name}.zip`);
  } catch (error) {
    showError(error, "下载全部视频失败");
  } finally {
    downloadingVideos.value = false;
  }
}

async function load(show = true) {
  if (show) loading.value = true;
  try {
    const [workbench, generationTasks, dramaTasks] = await Promise.all([
      creativeApi.workbench(projectId, episodeId),
      creativeApi.tasks(projectId, episodeId),
      creativeApi.dramaParseTasks(projectId, episodeId),
    ]);
    const editingContent = editingStoryboardId.value
      ? data.value?.storyboards.find((item) => item.id === editingStoryboardId.value)?.script_content
      : undefined;
    data.value = {
      ...workbench,
      storyboards: workbench.storyboards.map((item) => ({
        ...item,
        script_content:
          item.id === editingStoryboardId.value && editingContent !== undefined ? editingContent : item.script_content,
        asset_refs: normalizeAssetRefs(item.asset_refs),
      })),
    };
    const previousParseStatuses = new Map(parseTasks.value.map((item) => [item.id, item.status]));
    tasks.value = generationTasks;
    parseTasks.value = dramaTasks;
    for (const task of dramaTasks.filter(
      (item) =>
        item.status === "failed" &&
        previousParseStatuses.has(item.id) &&
        previousParseStatuses.get(item.id) !== "failed",
    )) {
      showParseFailure(task);
    }
    if (!selectedId.value || !workbench.storyboards.some((item) => item.id === selectedId.value)) {
      selectedId.value = workbench.storyboards[0]?.id || "";
    }
  } catch (error) {
    showError(error, "加载工作台失败");
  } finally {
    loading.value = false;
  }
  syncPolling();
}

async function loadSource() {
  if (sourceLoading.value || source.value) return;
  sourceLoading.value = true;
  try {
    source.value = await creativeApi.dramaEpisodeSource(projectId, episodeId);
    sourceText.value = source.value.raw_content;
  } catch {
    source.value = undefined;
  } finally {
    sourceLoading.value = false;
  }
}

function openSource() {
  sourceOpen.value = true;
  void loadSource();
}

async function loadOptions() {
  if (options.value.styles.length || options.value.models.length) return;
  try {
    options.value = await creativeApi.options();
  } catch (error) {
    showError(error, "加载配置选项失败");
  }
}

async function openProject() {
  projectOpen.value = true;
  await loadOptions();
}
async function openModels() {
  modelsOpen.value = true;
  await Promise.all([loadOptions(), loadPrompts(premiumDramaPromptTypes)]);
}

async function saveProject(payload: ProjectPayload) {
  settingsSaving.value = true;
  try {
    await creativeApi.updateProject(projectId, { ...payload, project_type: "premium_drama", localization: null });
    projectOpen.value = false;
    toast.success("项目信息已保存");
    await load(false);
  } catch (error) {
    showError(error, "保存项目信息失败");
  } finally {
    settingsSaving.value = false;
  }
}

async function saveModels(payload: Record<string, unknown>) {
  settingsSaving.value = true;
  try {
    await creativeApi.saveModels(projectId, payload);
    modelsOpen.value = false;
    toast.success("模型配置已保存");
    await load(false);
  } catch (error) {
    showError(error, "保存模型配置失败");
  } finally {
    settingsSaving.value = false;
  }
}

function syncPolling() {
  const active = Boolean(activeParse.value || tasks.value.some((item) => activeTaskStatuses.includes(item.status)));
  if (active && !timer) timer = setInterval(() => load(false), 3000);
  else if (!active && timer) {
    clearInterval(timer);
    timer = undefined;
  }
}

function selectStoryboard(id: string) {
  selectedId.value = id;
}
function normalizeAssetRefs(value: unknown): Array<{ id: string; name?: string }> {
  if (Array.isArray(value))
    return value.filter((item) => item && typeof item === "object" && typeof item.id === "string");
  if (typeof value !== "string" || !value.trim()) return [];
  const candidates = [value];
  try {
    const bytes = Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
    candidates.push(new TextDecoder().decode(bytes));
  } catch {
    /* value may already be JSON text */
  }
  for (const candidate of candidates) {
    try {
      const parsed: unknown = JSON.parse(candidate);
      if (Array.isArray(parsed))
        return parsed.filter((item) => item && typeof item === "object" && typeof item.id === "string");
    } catch {
      /* try the next representation */
    }
  }
  return [];
}
function assetsFor(storyboard: Storyboard, type: ProjectAsset["asset_type"]) {
  const ids = new Set((storyboard.asset_refs || []).map((item) => item.id));
  return (data.value?.assets || []).filter((asset) => asset.asset_type === type && ids.has(asset.id));
}
function mentionAssetsFor(storyboard: Storyboard): ProjectAsset[] {
  const referencedIds = new Set((storyboard.asset_refs || []).map((item) => item.id));
  const assets = (data.value?.assets || []).filter(
    (asset) => asset.asset_type === "custom" || referencedIds.has(asset.id),
  );
  if (!storyboard.thumbnail_url) return assets;
  return [
    {
      id: `storyboard-image-${storyboard.id}`,
      project_id: projectId,
      source_episode_id: episodeId,
      asset_type: "custom",
      name: `分镜 ${storyboard.sequence_no} 分镜图`,
      image_asset_id: storyboard.thumbnail_asset_id,
      image_url: storyboard.thumbnail_url,
      user_edited: false,
    },
    ...assets,
  ];
}
function assetMentionIndex(content: string, name: string) {
  const needle = `@${name.trim()}`;
  if (needle === "@") return -1;
  let searchFrom = 0;
  while (searchFrom < content.length) {
    const index = content.indexOf(needle, searchFrom);
    if (index < 0) return -1;
    const next = content.slice(index + needle.length)[0];
    if (!next || /[\s\p{P}]/u.test(next)) return index;
    searchFrom = index + 1;
  }
  return -1;
}
function storyboardVideoReferenceAssets(storyboard: Storyboard) {
  const content = storyboard.script_content || "";
  const mentioned = mentionAssetsFor(storyboard)
    .map((asset) => ({ asset, index: assetMentionIndex(content, asset.name) }))
    .filter((item) => item.index >= 0)
    .sort((left, right) => left.index - right.index)
    .map((item) => item.asset);
  if (mentioned.length) return mentioned;
  const referencedIds = new Set((storyboard.asset_refs || []).map((item) => item.id));
  return (data.value?.assets || []).filter((asset) => referencedIds.has(asset.id));
}
function storyboardVideoReferenceLimitReason(storyboard: Storyboard) {
  const capabilities = videoModelCapabilities.value;
  if (!capabilities) return "视频模型缺少参考素材能力配置";
  const assets = storyboardVideoReferenceAssets(storyboard);
  const imageCount = assets.filter((asset) => Boolean(asset.image_url)).length;
  const audioAssets = assets.filter((asset) => Boolean(asset.audio_url));
  const audioDurationMS = audioAssets.reduce((total, asset) => total + Number(asset.audio_duration_ms || 0), 0);
  if (imageCount > capabilities.reference_images.max_count)
    return `参考图数量超过 ${capabilities.reference_images.max_count} 张`;
  if (audioAssets.length > capabilities.reference_audios.max_count)
    return `参考音频数量超过 ${capabilities.reference_audios.max_count} 个`;
  if (
    capabilities.reference_audios.max_total_duration_ms &&
    audioDurationMS > capabilities.reference_audios.max_total_duration_ms
  )
    return `参考音频总时长超过 ${capabilities.reference_audios.max_total_duration_ms / 1000} 秒`;
  if (audioAssets.length > 0 && imageCount === 0) return "不能只使用参考音频";
  return "";
}
function storyboardTaskActive(storyboard: Storyboard, taskType: GenerationTask["task_type"]) {
  return tasks.value.some(
    (task) =>
      task.storyboard_id === storyboard.id &&
      task.task_type === taskType &&
      (taskType !== "image_generation" || !task.asset_id) &&
      activeTaskStatuses.includes(task.status),
  );
}
// storyboardImageError 返回该分镜最近一次失败的分镜图生成任务（不含资产图任务）的上游错误信息，
// 用于在分镜图单元格展示，方便定位上游失败原因。
function storyboardImageError(storyboard: Storyboard) {
  const failed = tasks.value
    .filter(
      (task) =>
        task.storyboard_id === storyboard.id &&
        task.task_type === "image_generation" &&
        !task.asset_id &&
        task.status === "failed" &&
        task.error_message,
    )
    .sort((left, right) => String(right.created_at).localeCompare(String(left.created_at)))[0];
  return failed?.error_message || "";
}

async function saveSource() {
  if (!sourceText.value.trim()) {
    toast.warning("剧集原文不能为空");
    return false;
  }
  sourceSaving.value = true;
  try {
    source.value = await creativeApi.saveDramaEpisodeSource(projectId, episodeId, {
      title: data.value?.episode.name,
      raw_content: sourceText.value,
    });
    toast.success("原文已保存");
    return true;
  } catch (error) {
    showError(error, "保存原文失败");
    return false;
  } finally {
    sourceSaving.value = false;
  }
}

async function startParse() {
  if (activeParse.value) {
    await cancelParse();
    return;
  }
  if (startingParse.value) return;
  startingParse.value = true;
  try {
    if (!source.value) await loadSource();
    if ((!source.value || sourceText.value !== source.value.raw_content) && !(await saveSource())) return;
    const hasStoryboards = Boolean(data.value?.storyboards.length);
    if (hasStoryboards) {
      try {
        await ElMessageBox.confirm(
          "重新解析将永久删除当前剧集的所有分镜、分镜图和生成视频，此操作无法恢复。",
          "确认重新解析剧本",
          { type: "warning", confirmButtonText: "删除并重新解析", cancelButtonText: "取消" },
        );
      } catch (error) {
        if (error === "cancel" || error === "close") return;
        throw error;
      }
    }
    if (hasStoryboards) await creativeApi.reparseDramaEpisode(projectId, episodeId);
    else await creativeApi.parseDramaEpisode(projectId, episodeId);
    toast.success("解析任务已提交");
    await load(false);
  } catch (error) {
    showError(error, "提交解析失败");
  } finally {
    startingParse.value = false;
  }
}

async function cancelParse() {
  if (!activeParse.value) return;
  try {
    await ElMessageBox.confirm("主动取消解析不会退费，是否继续？", "取消解析", {
      type: "warning",
      confirmButtonText: "确认取消",
      cancelButtonText: "返回",
    });
    await creativeApi.cancelDramaParse(projectId, activeParse.value.id);
    await load(false);
  } catch (error) {
    if (error !== "cancel" && error !== "close") showError(error, "取消失败");
  }
}

function updateStoryboardContent(storyboard: Storyboard, content: string) {
  storyboard.script_content = content;
}

async function saveStoryboard(storyboard: Storyboard, notify = false) {
  if (storyboard.locked) {
    toast.warning("分镜已锁定，无法编辑");
    return false;
  }
  saving.value = true;
  try {
    await creativeApi.updateStoryboard(projectId, storyboard.id, { script_content: storyboard.script_content });
    if (notify) toast.success("分镜内容已保存");
    return true;
  } catch (error) {
    showError(error, "保存分镜内容失败");
    return false;
  } finally {
    saving.value = false;
  }
}

async function editStoryboardContent(storyboard: Storyboard) {
  if (storyboard.locked) {
    toast.warning("分镜已锁定，无法编辑");
    return;
  }
  editingStoryboardId.value = storyboard.id;
  selectedId.value = storyboard.id;
  await nextTick();
  document
    .getElementById(`storyboard-editor-${storyboard.id}`)
    ?.querySelector<HTMLElement>("[contenteditable='true']")
    ?.focus();
}

async function finishStoryboardContent(storyboard: Storyboard) {
  if (editingStoryboardId.value !== storyboard.id) return;
  editingStoryboardId.value = "";
  await saveStoryboard(storyboard);
}

function finishStoryboardContentOnBlur(storyboard: Storyboard) {
  window.setTimeout(() => {
    const editor = document.getElementById(`storyboard-editor-${storyboard.id}`);
    if (editor?.contains(document.activeElement)) return;
    void finishStoryboardContent(storyboard);
  }, 150);
}

async function createStoryboard() {
  try {
    const item = await creativeApi.createDramaStoryboard(projectId, episodeId);
    await load(false);
    selectedId.value = item.id;
  } catch (error) {
    showError(error, "创建分镜失败");
  }
}

async function insertStoryboard(position: "before" | "after") {
  if (!selected.value) return;
  try {
    const item = await creativeApi.insertStoryboard(projectId, selected.value.id, position);
    await load(false);
    selectedId.value = item.id;
  } catch (error) {
    showError(error, "插入分镜失败");
  }
}

async function toggleStoryboardLock(storyboard: Storyboard) {
  const locked = !storyboard.locked;
  if (locked && editingStoryboardId.value === storyboard.id) {
    editingStoryboardId.value = "";
    if (!(await saveStoryboard(storyboard))) return;
  }
  try {
    await creativeApi.updateStoryboard(projectId, storyboard.id, { locked });
    storyboard.locked = locked;
    toast.success(locked ? "分镜已锁定" : "分镜已解锁");
  } catch (error) {
    showError(error, locked ? "锁定分镜失败" : "解锁分镜失败");
  }
}

async function deleteStoryboard(storyboard: Storyboard) {
  if (storyboard.locked) {
    toast.warning("分镜已锁定，无法删除");
    return;
  }
  try {
    await ElMessageBox.confirm(
      `确定删除分镜 ${storyboard.sequence_no} 吗？对应的分镜图、视频和历史结果也将永久删除。`,
      "删除分镜",
      { type: "warning", confirmButtonText: "永久删除", cancelButtonText: "取消" },
    );
    await creativeApi.deleteStoryboard(projectId, storyboard.id);
    await load(false);
  } catch (error) {
    if (error !== "cancel" && error !== "close") showError(error, "删除分镜失败");
  }
}

async function generate(
  kind: "image_generation" | "video_generation",
  input: Record<string, unknown> = {},
  storyboard = selected.value,
) {
  if (!storyboard) return;
  if (storyboard.locked) {
    toast.warning("分镜已锁定，无法生成");
    return;
  }
  generating.value = true;
  try {
    if (!(await saveStoryboard(storyboard))) return;
    selectedId.value = storyboard.id;
    await creativeApi.generate(projectId, episodeId, storyboard.id, kind, input);
    toast.success(kind === "image_generation" ? "分镜图任务已提交" : "视频任务已提交");
    await load(false);
  } catch (error) {
    showError(error, "生成失败");
  } finally {
    generating.value = false;
  }
}

async function createAsset(payload: { asset_type: string; name: string }) {
  if (assetBusy.value) return;
  assetBusy.value = true;
  try {
    await creativeApi.createAsset(projectId, payload);
    await load(false);
  } catch (error) {
    showError(error, "创建资产失败");
  } finally {
    assetBusy.value = false;
  }
}
async function saveAsset(id: string, payload: Partial<ProjectAsset>) {
  try {
    await creativeApi.updateAsset(projectId, id, payload);
    await load(false);
  } catch (error) {
    showError(error, "保存资产失败");
    await load(false);
  }
}
async function deleteAsset(asset: ProjectAsset) {
  try {
    await ElMessageBox.confirm(`确定删除资产“${asset.name}”吗？`, "删除资产", {
      type: "warning",
      confirmButtonText: "删除",
      cancelButtonText: "取消",
    });
    await creativeApi.deleteAsset(projectId, asset.id);
    await load(false);
  } catch (error) {
    if (error !== "cancel" && error !== "close") showError(error, "删除资产失败");
  }
}
async function addAssetToStoryboard(asset: ProjectAsset) {
  const storyboard = selected.value;
  if (!storyboard) {
    toast.warning("请先选择一个分镜");
    return;
  }
  if (storyboard.locked) {
    toast.warning("当前分镜已锁定，无法添加资产");
    return;
  }
  const refs = storyboard.asset_refs || [];
  if (refs.some((ref) => ref.id === asset.id)) return;
  await saveStoryboardAssetRefs(storyboard, [...refs, { id: asset.id, name: asset.name }], "添加资产失败");
}
async function removeAssetFromStoryboard(storyboard: Storyboard, asset: ProjectAsset) {
  if (storyboard.locked) {
    toast.warning("当前分镜已锁定，无法移除资产");
    return;
  }
  const refs = (storyboard.asset_refs || []).filter((ref) => ref.id !== asset.id);
  await saveStoryboardAssetRefs(storyboard, refs, "移除资产失败");
}
async function saveStoryboardAssetRefs(
  storyboard: Storyboard,
  refs: NonNullable<Storyboard["asset_refs"]>,
  errorTitle: string,
) {
  if (assetBusy.value) return;
  assetBusy.value = true;
  try {
    await creativeApi.updateStoryboard(projectId, storyboard.id, { asset_refs: refs });
    await load(false);
  } catch (error) {
    showError(error, errorTitle);
  } finally {
    assetBusy.value = false;
  }
}
async function uploadAssetImage(id: string, file: File) {
  assetBusy.value = true;
  try {
    await creativeApi.uploadAssetImage(projectId, id, file);
    await load(false);
  } catch (error) {
    showError(error, "上传失败");
  } finally {
    assetBusy.value = false;
  }
}
async function removeAssetImage(id: string) {
  assetBusy.value = true;
  try {
    await creativeApi.removeAssetImage(projectId, id);
    toast.success("资产图已移入历史记录");
    await load(false);
  } catch (error) {
    showError(error, "移除失败");
  } finally {
    assetBusy.value = false;
  }
}
async function uploadAssetAudio(id: string, file: File) {
  assetBusy.value = true;
  try {
    await creativeApi.uploadAssetAudio(projectId, id, file, await readAudioDurationMs(file));
    await load(false);
  } catch (error) {
    showError(error, "上传失败");
  } finally {
    assetBusy.value = false;
  }
}
async function generateAssetImage(id: string, referenceImageDataUrl?: string) {
  if (!selected.value) {
    toast.warning("请先选择一个分镜");
    return;
  }
  try {
    await creativeApi.generate(projectId, episodeId, selected.value.id, "image_generation", {
      asset_id: id,
      image_urls: referenceImageDataUrl ? [referenceImageDataUrl] : undefined,
    });
    await load(false);
  } catch (error) {
    showError(error, "生成资产图失败");
  }
}
async function cancelGeneration(task: GenerationTask) {
  try {
    await ElMessageBox.confirm("确定取消当前生成任务吗？取消后已扣积分不退。", "取消生成", {
      type: "warning",
      confirmButtonText: "确认取消",
      cancelButtonText: "继续生成",
    });
    await creativeApi.cancelTask(projectId, task.id);
    await load(false);
  } catch (error) {
    if (error === "cancel" || error === "close") return;
    showError(error, "取消失败");
  }
}

function focusStoryboardMedia(storyboard: Storyboard, type: "storyboard_image" | "storyboard_video") {
  selectedId.value = storyboard.id;
  assetFocus.value = undefined;
  mediaFocus.value = { type, storyboardId: storyboard.id };
}

function focusAsset(storyboard: Storyboard, asset: ProjectAsset) {
  selectedId.value = storyboard.id;
  mediaFocus.value = undefined;
  assetFocus.value = { assetId: asset.id };
}

async function generateStoryboardMedia(
  storyboardId: string,
  kind: "image_generation" | "video_generation",
  input: Record<string, unknown> = {},
) {
  const storyboard = data.value?.storyboards.find((item) => item.id === storyboardId);
  if (!storyboard) return;
  await generate(kind, kind === "image_generation" ? { ...input, target_type: "storyboard" } : input, storyboard);
}

// updateStoryboardDuration 保存分镜视频生成时长，并在本地同步，供时长选择器展示。
async function updateStoryboardDuration(storyboardId: string, duration: number) {
  try {
    await creativeApi.updateStoryboard(projectId, storyboardId, { duration_seconds: duration });
    const storyboard = data.value?.storyboards.find((item) => item.id === storyboardId);
    if (storyboard) storyboard.duration_seconds = duration;
  } catch (error) {
    showError(error, "时长保存失败");
  }
}

async function openHistory(storyboardId = selected.value?.id) {
  if (!storyboardId) return;
  selectedId.value = storyboardId;
  historyOpen.value = true;
  historyLoading.value = true;
  try {
    outputs.value = await creativeApi.outputs(projectId, storyboardId);
  } catch (error) {
    showError(error, "加载历史失败");
  } finally {
    historyLoading.value = false;
  }
}
async function activateOutput(output: GenerationOutput) {
  if (!selected.value) return;
  activatingId.value = output.id;
  try {
    await creativeApi.activateOutput(projectId, selected.value.id, output.id);
    await openHistory();
    await load(false);
  } catch (error) {
    showError(error, "切换结果失败");
  } finally {
    activatingId.value = "";
  }
}
async function deleteOutput(output: GenerationOutput) {
  if (!selected.value) return;
  try {
    await creativeApi.deleteOutput(projectId, selected.value.id, output.id);
    await openHistory();
    await load(false);
  } catch (error) {
    showError(error, "删除结果失败");
  }
}
function showError(error: unknown, title: string) {
  const detail = apiError(error);
  toast.error(detail.message, detail.copyText, title);
}
function showParseFailure(task: DramaParseTask) {
  const message = task.error_message?.trim() || "剧本解析失败";
  const detail = [`错误码：${task.error_code || "parse_failed"}`, `错误描述：${message}`, `任务 ID：${task.id}`].join(
    "\n",
  );
  toast.error(message, detail, "剧本解析失败");
}

watch(selectedId, () => {
  outputs.value = [];
});
onMounted(() => {
  void load();
  clockTimer = setInterval(() => {
    taskClock.value = Date.now();
  }, 1000);
});
onBeforeUnmount(() => {
  if (timer) clearInterval(timer);
  if (clockTimer) clearInterval(clockTimer);
});
</script>

<template>
  <CreativeShell class="drama-workbench-shell">
    <section class="workbench-context-bar">
      <div class="workbench-context-main">
        <RouterLink
          class="back-button"
          :to="backTarget"
          title="返回项目"
          ><ArrowLeft :size="18"
        /></RouterLink>
        <strong>{{ episodeTitle }}</strong>
      </div>
      <div class="workbench-context-actions creative-page-actions">
        <el-button
          :icon="Edit"
          @click="openProject"
          >项目信息</el-button
        >
        <el-button
          :icon="Setting"
          @click="openModels"
          >模型配置</el-button
        >
        <el-button
          :icon="FileText"
          @click="openSource"
          >剧集原文</el-button
        >
        <el-button
          :icon="Download"
          :loading="downloadingVideos"
          :disabled="!hasDownloadableVideos"
          :title="hasDownloadableVideos ? '下载当前剧集全部视频' : '当前剧集暂无可下载视频'"
          @click="downloadAllVideos"
          >下载全部视频</el-button
        >
        <el-button
          class="parse-action"
          :class="{ 'is-cancel': activeParse }"
          :type="activeParse ? undefined : 'primary'"
          :icon="activeParse ? undefined : Play"
          :loading="startingParse"
          @click="startParse"
        >
          <LoaderCircle
            v-if="activeParse"
            class="spin"
            :size="15"
          />{{
            activeParse
              ? "取消解析"
              : startingParse
                ? "准备解析"
                : data?.storyboards.length
                  ? "重新解析剧本"
                  : "解析剧本"
          }}
        </el-button>
      </div>
    </section>

    <div
      v-loading="loading"
      class="editor-layout"
    >
      <section class="storyboard-workspace">
        <div class="table-scroll">
          <table class="storyboard-table">
            <thead>
              <tr>
                <th class="sequence-column">序号</th>
                <th class="script-column">分镜内容（双击编辑）</th>
                <th class="entity-column">角色</th>
                <th class="entity-column">场景</th>
                <th class="entity-column">道具</th>
                <th class="media-column">分镜图</th>
                <th class="media-column">视频</th>
              </tr>
            </thead>
            <tbody>
              <template
                v-for="item in data?.storyboards || []"
                :key="item.id"
              >
                <tr class="insert-row">
                  <td colspan="7">
                    <button
                      type="button"
                      @click="
                        selectStoryboard(item.id);
                        insertStoryboard('before');
                      "
                    >
                      <span></span><em><Plus :size="13" />插入分镜</em><span></span>
                    </button>
                  </td>
                </tr>
                <tr
                  class="storyboard-row"
                  :class="{ active: item.id === selectedId, locked: item.locked }"
                  @click="selectStoryboard(item.id)"
                >
                  <td class="sequence-cell">
                    <b>{{ item.sequence_no }}</b>
                    <div class="row-actions">
                      <button
                        class="lock-action"
                        type="button"
                        :class="{ active: item.locked }"
                        :title="item.locked ? '解锁分镜' : '锁定分镜'"
                        @click.stop="toggleStoryboardLock(item)"
                      >
                        <Lock
                          v-if="item.locked"
                          :size="14"
                        /><Unlock
                          v-else
                          :size="14"
                        />
                      </button>
                      <button
                        class="danger"
                        type="button"
                        :disabled="item.locked"
                        :title="item.locked ? '分镜已锁定，无法删除' : '删除分镜'"
                        @click.stop="deleteStoryboard(item)"
                      >
                        <Trash2 :size="14" />
                      </button>
                    </div>
                  </td>
                  <td class="script-cell">
                    <AssetPromptEditor
                      v-if="editingStoryboardId === item.id && !item.locked"
                      :id="`storyboard-editor-${item.id}`"
                      :model-value="item.script_content || ''"
                      class="storyboard-content-editor"
                      :assets="mentionAssetsFor(item)"
                      variant="drama-storyboard"
                      placeholder="输入分镜内容，输入 @ 插入当前分镜资产"
                      @update:model-value="updateStoryboardContent(item, $event)"
                      @blur="finishStoryboardContentOnBlur(item)"
                    />
                    <AssetPromptEditor
                      v-else
                      class="script-content-display"
                      :class="{ empty: !item.script_content, locked: item.locked }"
                      :model-value="item.script_content || ''"
                      :assets="mentionAssetsFor(item)"
                      :placeholder="item.locked ? '暂无分镜内容' : '双击编辑分镜内容'"
                      :title="item.locked ? '分镜已锁定' : '双击编辑分镜内容'"
                      variant="drama-storyboard"
                      readonly
                      @dblclick.stop="editStoryboardContent(item)"
                    />
                  </td>
                  <td
                    v-for="type in ['character', 'scene', 'prop'] as const"
                    :key="type"
                    class="entity-cell"
                  >
                    <div
                      v-for="asset in assetsFor(item, type)"
                      :key="asset.id"
                      class="entity-chip"
                    >
                      <button
                        class="entity-chip-main"
                        type="button"
                        :title="`打开资产详情：${asset.name}`"
                        @click.stop="focusAsset(item, asset)"
                      >
                        <LoadingMedia
                          v-if="asset.image_url"
                          type="image"
                          compact
                          :src="asset.image_url"
                          :alt="asset.name"
                        /><span v-else>{{ asset.name.slice(0, 1) }}</span
                        ><small>{{ asset.name }}</small
                        ><Volume2
                          v-if="asset.audio_url"
                          class="entity-audio-icon"
                          :size="13"
                          title="已上传参考音频"
                        />
                      </button>
                      <button
                        class="entity-remove"
                        type="button"
                        :disabled="item.locked || assetBusy"
                        :title="item.locked ? '分镜已锁定，无法移除资产' : `从当前分镜移除：${asset.name}`"
                        @click.stop="removeAssetFromStoryboard(item, asset)"
                      >
                        <X :size="13" />
                      </button>
                    </div>
                    <span
                      v-if="!assetsFor(item, type).length"
                      class="cell-empty"
                      >未关联</span
                    >
                  </td>
                  <td class="media-cell">
                    <div
                      class="media-focus-target"
                      title="打开分镜图处理"
                      @click.stop="focusStoryboardMedia(item, 'storyboard_image')"
                    >
                      <LoadingMedia
                        v-if="item.thumbnail_url"
                        type="image"
                        compact
                        :src="item.thumbnail_url"
                        :alt="`分镜 ${item.sequence_no}`"
                        data-no-visual-search
                      />
                      <span
                        v-else-if="storyboardTaskActive(item, 'image_generation')"
                        class="media-empty media-loading"
                        ><LoaderCircle
                          class="spin"
                          :size="24"
                        /><small>生成中</small></span
                      ><span
                        v-else
                        class="media-empty"
                        ><Image :size="24" /><small>暂无图片</small></span
                      >
                    </div>
                    <div
                      v-if="!item.thumbnail_url && storyboardImageError(item)"
                      class="storyboard-image-error"
                      :title="storyboardImageError(item)"
                      @click.stop="focusStoryboardMedia(item, 'storyboard_image')"
                    >
                      <CircleAlert :size="12" /><span>生成失败：{{ storyboardImageError(item) }}</span>
                    </div>
                  </td>
                  <td class="media-cell">
                    <div
                      class="media-focus-target"
                      title="打开视频处理"
                      @click.stop="focusStoryboardMedia(item, 'storyboard_video')"
                    >
                      <LoadingMedia
                        v-if="item.result_url"
                        type="video"
                        compact
                        :src="item.result_url"
                        muted
                        preload="metadata"
                        playsinline
                        disablepictureinpicture
                        disableremoteplayback
                        controlslist="nodownload noplaybackrate noremoteplayback"
                      />
                      <span
                        v-else-if="storyboardTaskActive(item, 'video_generation')"
                        class="media-empty media-loading"
                        ><LoaderCircle
                          class="spin"
                          :size="24"
                        /><small>生成中</small></span
                      ><span
                        v-else
                        class="media-empty"
                        ><Film :size="24" /><small>暂无视频</small></span
                      >
                    </div>
                  </td>
                </tr>
              </template>
              <tr
                v-if="data?.storyboards.length"
                class="insert-row"
              >
                <td colspan="7">
                  <button
                    type="button"
                    @click="
                      selectStoryboard(data.storyboards[data.storyboards.length - 1].id);
                      insertStoryboard('after');
                    "
                  >
                    <span></span><em><Plus :size="13" />插入分镜</em><span></span>
                  </button>
                </td>
              </tr>
            </tbody>
          </table>
          <div
            v-if="!data?.storyboards.length && activeParse"
            class="empty-storyboards parse-loading"
          >
            <LoaderCircle
              class="spin"
              :size="26"
            /><strong>{{ parseLoadingText }}</strong
            ><span>模型正在解析剧本并生成资产与分镜，请稍候。</span>
          </div>
          <div
            v-else-if="!data?.storyboards.length && failedParse"
            class="empty-storyboards parse-failed"
          >
            <CircleAlert :size="26" />
            <strong>剧本解析失败</strong>
            <span>{{ failedParse.error_message || "模型返回结果无法解析，请重新解析。" }}</span>
            <el-button
              type="primary"
              :icon="Play"
              @click="startParse"
              >重新解析</el-button
            >
          </div>
          <button
            v-else-if="!data?.storyboards.length"
            class="empty-storyboards"
            type="button"
            @click="createStoryboard"
          >
            <Plus :size="22" /><strong>创建第一个分镜</strong><span>也可以先解析剧本，由模型自动生成分镜。</span>
          </button>
        </div>
      </section>

      <aside class="asset-rail">
        <AssetPanel
          :project-id="projectId"
          :project-name="data?.project.name"
          :episode-name="data?.episode.name"
          :assets="data?.assets || []"
          :storyboards="data?.storyboards || []"
          :current-storyboard="selected"
          :video-generation-blocked-reason="selectedVideoReferenceLimitReason"
          :video-duration-range="videoDurationRange"
          :media-focus="mediaFocus"
          :asset-focus="assetFocus"
          :tasks="tasks"
          :now="taskClock"
          :busy="assetBusy"
          :can-generate="Boolean(selected)"
          @create="createAsset"
          @save="saveAsset"
          @delete="deleteAsset"
          @upload-image="uploadAssetImage"
          @remove-image="removeAssetImage"
          @upload-audio="uploadAssetAudio"
          @generate-image="generateAssetImage"
          @cancel="cancelGeneration"
          @add-to-storyboard="addAssetToStoryboard"
          @generate-storyboard-media="generateStoryboardMedia"
          @update-storyboard-duration="updateStoryboardDuration"
          @history-storyboard="openHistory"
          @history-changed="load(false)"
        />
      </aside>
    </div>

    <el-dialog
      v-model="sourceOpen"
      title="剧集原文"
      width="min(820px, calc(100vw - 28px))"
      class="creative-dialog source-dialog"
    >
      <div
        v-loading="sourceLoading"
        class="source-dialog-content"
      >
        <div class="source-meta">
          <span>{{ sourceText.length.toLocaleString() }} 字</span><small>修改后保存，再解析剧本。</small>
        </div>
        <el-input
          v-model="sourceText"
          type="textarea"
          :rows="22"
          resize="none"
          placeholder="输入或粘贴剧集原文"
        />
      </div>
      <template #footer
        ><el-button @click="sourceOpen = false">关闭</el-button
        ><el-button
          type="primary"
          :icon="Save"
          :loading="sourceSaving"
          @click="saveSource"
          >保存原文</el-button
        ></template
      >
    </el-dialog>
    <ProjectDialog
      v-if="data?.project"
      v-model="projectOpen"
      :options="options"
      :project="data.project"
      :saving="settingsSaving"
      project-type="premium_drama"
      @save="saveProject"
    />
    <ModelConfigDialog
      v-if="data?.project"
      v-model="modelsOpen"
      :options="options"
      :configs="data.project.model_configs"
      :prompts="prompts"
      :prompt-selections="promptSelections"
      :saving="settingsSaving"
      project-type="premium_drama"
      @save="saveModels"
      @prompt-select="selectPrompt"
      @prompt-create="createPrompt"
      @prompt-update="updatePrompt"
      @prompt-delete="deletePrompt"
    />
  </CreativeShell>
</template>

<style scoped lang="scss" src="./DramaWorkbenchView.styles.scss"></style>
