// App shell — navigation between Hub, Creator, Viewer

const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = React;

function randomSceneSeed() {
  return Math.floor(Math.random() * 1000);
}

function normalizeScene(scene) {
  const prompt = typeof scene?.prompt === "string" ? scene.prompt : "";
  const promptEn = typeof scene?.promptEn === "string" ? scene.promptEn : "";
  const rawImageRef = scene?.imageFilename || scene?.imageUrl || "";
  const imageUrl = typeof rawImageRef === "string" && rawImageRef && !rawImageRef.startsWith("blob:")
    ? (window.SongfilmApiConfig?.getMediaFilename?.(rawImageRef) || rawImageRef)
    : "";

  return {
    ...scene,
    prompt,
    legacyPrompt: typeof scene?.legacyPrompt === "string" ? scene.legacyPrompt : prompt,
    promptEn,
    promptSource: scene?.promptSource || (promptEn ? "ai" : prompt ? "legacy" : "pending"),
    analysisStatus: promptEn ? "done" : (scene?.analysisStatus === "error" ? "error" : "idle"),
    analysisError: typeof scene?.analysisError === "string" ? scene.analysisError : "",
    imageStatus: imageUrl ? "done" : (scene?.imageStatus === "error" ? "error" : "idle"),
    imageError: typeof scene?.imageError === "string" ? scene.imageError : "",
    imageUrl,
    imageFilename: window.SongfilmApiConfig?.getMediaFilename?.(scene?.imageFilename || imageUrl) || (typeof scene?.imageFilename === "string" ? scene.imageFilename : ""),
    imageSavedAt: typeof scene?.imageSavedAt === "string" ? scene.imageSavedAt : "",
    imageSeed: Number.isFinite(scene?.imageSeed) ? scene.imageSeed : randomSceneSeed(),
    imageSource: typeof scene?.imageSource === "string" ? scene.imageSource : (imageUrl ? "ai" : ""),
    imageProvider: scene?.imageProvider === "google" ? "google" : "",
    googleImagePrompt: typeof scene?.googleImagePrompt === "string" ? scene.googleImagePrompt : "",
    sentenceId: typeof scene?.sentenceId === "string" ? scene.sentenceId : "",
    chapterNumber: typeof scene?.chapterNumber === "string" ? scene.chapterNumber : (typeof scene?.sentenceId === "string" ? scene.sentenceId : ""),
  };
}

function normalizeSceneGlobal(sceneGlobal) {
  const rawImageRef = sceneGlobal?.imageFilename || sceneGlobal?.imageUrl || "";
  const imageUrl = typeof rawImageRef === "string" && rawImageRef && !rawImageRef.startsWith("blob:")
    ? (window.SongfilmApiConfig?.getMediaFilename?.(rawImageRef) || rawImageRef)
    : "";
  const imageMode = sceneGlobal?.imageMode === "global" ? "global" : "per-scene";
  const imageSource = ["ai", "upload"].includes(sceneGlobal?.imageSource)
    ? sceneGlobal.imageSource
    : "ai";

  return {
    promptKo: typeof sceneGlobal?.promptKo === "string" ? sceneGlobal.promptKo : "",
    promptEn: typeof sceneGlobal?.promptEn === "string" ? sceneGlobal.promptEn : "",
    referenceImages: Array.isArray(sceneGlobal?.referenceImages)
      ? sceneGlobal.referenceImages.map((image) => ({
          ...image,
          url: window.SongfilmApiConfig?.getMediaFilename?.(image?.filename || image?.url) || image?.url || "",
          filename: window.SongfilmApiConfig?.getMediaFilename?.(image?.filename || image?.url) || image?.filename || "",
        }))
      : [],
    imageMode,
    imageSource,
    imageUrl,
    imageFilename: window.SongfilmApiConfig?.getMediaFilename?.(sceneGlobal?.imageFilename || imageUrl) || (typeof sceneGlobal?.imageFilename === "string" ? sceneGlobal.imageFilename : ""),
    imageSavedAt: typeof sceneGlobal?.imageSavedAt === "string" ? sceneGlobal.imageSavedAt : "",
    imageStatus: imageUrl ? "done" : (["loading", "error"].includes(sceneGlobal?.imageStatus) ? sceneGlobal.imageStatus : "idle"),
    imageError: typeof sceneGlobal?.imageError === "string" ? sceneGlobal.imageError : "",
    imagePromptEn: typeof sceneGlobal?.imagePromptEn === "string" ? sceneGlobal.imagePromptEn : "",
    imageSeed: Number.isFinite(sceneGlobal?.imageSeed) ? sceneGlobal.imageSeed : randomSceneSeed(),
    updatedAt: typeof sceneGlobal?.updatedAt === "string" ? sceneGlobal.updatedAt : "",
  };
}

function normalizeSong(song) {
  const normalized = {
    referenceLyricsText: "",
    referenceLyricsName: "",
    transcribeMode: "",
    transcribeWorkflow: null,
    paletteName: "midnight-violet",
    lyricTimeline: null,
    cues: [],
    workflowStep: "",
    storyId: "",
    storageVersion: "",
    savedAt: "",
    audioStorageKey: "",
    audioMimeType: "audio/mpeg",
    audioPersistedAt: "",
    audioByteLength: 0,
    previewSettings: {
      fontSize: 36,
      subPos: "bottom",
      transition: "fade",
    },
    exportState: {
      status: "idle",
      progress: 0,
      done: false,
    },
    ...song,
    scenes: (song?.scenes || []).map(normalizeScene),
    sceneGlobal: normalizeSceneGlobal(song?.sceneGlobal),
  };

  return {
    ...normalized,
    audioServerUrl: window.SongfilmApiConfig?.getMediaFilename?.(
      normalized.audio?.filename || normalized.audioServerUrl || normalized.audio?.url || ""
    ) || normalized.audioServerUrl || "",
    previewSettings: {
      fontSize: 36,
      subPos: "bottom",
      transition: "fade",
      ...(song?.previewSettings || {}),
    },
    exportState: {
      status: "idle",
      progress: 0,
      done: false,
      ...(song?.exportState || {}),
    },
  };
}

function normalizeAlbum(album) {
  const coverImageRef = album?.coverImageFilename || album?.coverImageUrl || "";
  const coverImageFilename = window.SongfilmApiConfig?.getMediaFilename?.(coverImageRef) || coverImageRef;
  return {
    ...album,
    coverImageUrl: coverImageFilename || "",
    coverImageFilename: coverImageFilename || "",
    songs: (album?.songs || []).map(normalizeSong),
  };
}

const ALBUM_STORAGE_BASE = window.SongfilmApiConfig.getBase("storage");
const ALBUM_RENDERER_BASE = window.SongfilmApiConfig.getBase("remotion");

function sanitizeAlbumEntryName(value, fallback = "item") {
  const text = String(value || fallback).trim().replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_");
  return text || fallback;
}

function triggerAlbumBlobDownload(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 2000);
}

function albumBlobToDataUrl(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result || ""));
    reader.onerror = () => reject(reader.error || new Error("이미지를 data URL로 변환하지 못했습니다."));
    reader.readAsDataURL(blob);
  });
}

function albumDataUrlToBlob(dataUrl) {
  const text = String(dataUrl || "").trim();
  const match = text.match(/^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,(.*)$/i);
  if (!match) {
    throw new Error("앨범 커버 data URL 형식이 올바르지 않습니다.");
  }
  const mimeType = match[1] || "image/png";
  const isBase64 = !!match[2];
  const payload = match[3] || "";
  if (isBase64) {
    const binary = atob(payload);
    const bytes = new Uint8Array(binary.length);
    for (let index = 0; index < binary.length; index += 1) {
      bytes[index] = binary.charCodeAt(index);
    }
    return new Blob([bytes], { type: mimeType });
  }
  return new Blob([decodeURIComponent(payload)], { type: mimeType });
}

async function fetchAlbumCoverImageDataUrl(album) {
  const coverImageUrl = String(album?.coverImageUrl || "").trim();
  if (!coverImageUrl) return "";
  if (coverImageUrl.startsWith("data:")) return coverImageUrl;
  try {
    const imageUrl = window.SongfilmApiConfig?.buildMediaUrl
      ? window.SongfilmApiConfig.buildMediaUrl("image", coverImageUrl)
      : coverImageUrl;
    const response = await fetch(imageUrl);
    if (!response.ok) return "";
    const blob = await response.blob();
    if (!blob.type.startsWith("image/")) return "";
    return albumBlobToDataUrl(blob);
  } catch (error) {
    console.warn("[Album Song ZIP] 커버 이미지 포함 실패:", error);
    return "";
  }
}

function loadAlbumJSZip() {
  if (window.JSZip) return Promise.resolve(window.JSZip);
  return new Promise((resolve, reject) => {
    const s = document.createElement("script");
    s.src = "https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js";
    s.onload = () => resolve(window.JSZip);
    s.onerror = reject;
    document.head.appendChild(s);
  });
}

function getAlbumRenderedVideoUrl(song) {
  const videoFilename = String(song?.exportState?.videoFilename || "").trim();
  if (videoFilename) {
    return window.SongfilmApiConfig?.buildMediaUrl
      ? window.SongfilmApiConfig.buildMediaUrl("video", videoFilename)
      : `${ALBUM_STORAGE_BASE}/video/${encodeURIComponent(videoFilename)}`;
  }
  const jobId = String(song?.exportState?.jobId || "").trim();
  if (jobId) {
    return `${ALBUM_RENDERER_BASE}/render/${jobId}/file`;
  }
  return "";
}

function waitAlbumMs(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const SONG_BACKUP_SCHEMA = "songfilm-song-backup/v1";

function cloneAlbumJson(value) {
  return JSON.parse(JSON.stringify(value || {}));
}

function makeSongBackupZipUri(entryPath) {
  return `zip://${entryPath}`;
}

function isSongBackupZipUri(value) {
  return String(value || "").startsWith("zip://");
}

function songBackupEntryFromUri(value) {
  return String(value || "").replace(/^zip:\/\//, "");
}

function getUrlFilename(value, fallback = "asset") {
  const raw = String(value || "").trim();
  if (!raw) return fallback;
  try {
    const parsed = new URL(raw, window.location.href);
    const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
    return lastSegment ? decodeURIComponent(lastSegment) : fallback;
  } catch {
    return raw.split(/[\\/]/).filter(Boolean).pop() || fallback;
  }
}

function getBlobExtension(blob, fallback = ".bin") {
  const mimeType = String(blob?.type || "").toLowerCase();
  if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return ".mp3";
  if (mimeType.includes("mp4")) return ".mp4";
  if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return ".jpg";
  if (mimeType.includes("png")) return ".png";
  if (mimeType.includes("webp")) return ".webp";
  if (mimeType.includes("gif")) return ".gif";
  return fallback;
}

function reserveSongBackupEntryName(usedEntries, folder, preferredName, fallbackName) {
  const safePreferred = sanitizeAlbumEntryName(preferredName || fallbackName, fallbackName);
  const dotIndex = safePreferred.lastIndexOf(".");
  const stem = dotIndex > 0 ? safePreferred.slice(0, dotIndex) : safePreferred;
  const ext = dotIndex > 0 ? safePreferred.slice(dotIndex) : "";
  let candidate = `${folder}/${safePreferred}`;
  let counter = 2;
  while (usedEntries.has(candidate)) {
    candidate = `${folder}/${stem}-${counter}${ext}`;
    counter += 1;
  }
  usedEntries.add(candidate);
  return candidate;
}

async function fetchSongBackupBlob(url, label) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`${label} 다운로드 실패 (${response.status})`);
  }
  return await response.blob();
}

async function addSongBackupAsset({ zip, manifest, usedEntries, folder, preferredName, fallbackName, blob, sourceUrl, kind }) {
  const ext = preferredName && /\.[^.]+$/.test(preferredName) ? "" : getBlobExtension(blob);
  const entryPath = reserveSongBackupEntryName(usedEntries, folder, preferredName || `${fallbackName}${ext}`, `${fallbackName}${ext}`);
  zip.file(entryPath, blob);
  const asset = {
    kind,
    entryPath,
    uri: makeSongBackupZipUri(entryPath),
    sourceUrl: sourceUrl || "",
    originalName: preferredName || "",
    mimeType: blob?.type || "",
    byteLength: Number(blob?.size || 0),
  };
  manifest.assets.push(asset);
  return asset;
}

async function addSongBackupImageAsset({ zip, manifest, usedEntries, imageUrl, preferredName, fallbackName }) {
  const sourceUrl = String(imageUrl || "").trim();
  if (!sourceUrl || isSongBackupZipUri(sourceUrl)) return null;
  try {
    const blob = sourceUrl.startsWith("data:")
      ? albumDataUrlToBlob(sourceUrl)
      : await fetchSongBackupBlob(window.SongfilmApiConfig?.buildMediaUrl ? window.SongfilmApiConfig.buildMediaUrl("image", sourceUrl) : sourceUrl, "이미지");
    if (blob?.type && !blob.type.startsWith("image/")) {
      throw new Error(`이미지 MIME 타입이 아닙니다: ${blob.type}`);
    }
    return await addSongBackupAsset({
      zip,
      manifest,
      usedEntries,
      folder: "Images",
      preferredName: preferredName || getUrlFilename(sourceUrl, fallbackName),
      fallbackName,
      blob,
      sourceUrl,
      kind: "image",
    });
  } catch (error) {
    manifest.missing.push({ kind: "image", sourceUrl, reason: error?.message || String(error) });
    console.warn("[Song Backup] 이미지 포함 실패:", sourceUrl, error);
    return null;
  }
}

async function downloadSongBackupZip({ song, album }) {
  if (!song) throw new Error("백업할 Song을 찾지 못했습니다.");
  if (!window.SongfilmSongStorage?.exportSongSnapshot) {
    throw new Error("Song 스냅샷 저장소를 찾을 수 없습니다.");
  }

  const JSZip = await loadAlbumJSZip();
  const zip = new JSZip();
  const usedEntries = new Set();
  const songBase = sanitizeAlbumEntryName((song.filename || song.title || song.id || "song").replace(/\.[^.]+$/, ""), "song");
  const manifest = {
    schema: SONG_BACKUP_SCHEMA,
    exportedAt: new Date().toISOString(),
    songId: song.id || "",
    songTitle: song.title || "",
    album: {
      id: album?.id || "",
      title: album?.title || "",
      artist: album?.artist || "",
      year: album?.year || "",
    },
    assets: [],
    missing: [],
  };

  const payload = cloneAlbumJson(await window.SongfilmSongStorage.exportSongSnapshot(song));
  const snapshotSong = payload.song || {};
  snapshotSong.audio = snapshotSong.audio || {};

  try {
    const audioBlob = await loadAlbumSongAudioBlob(song);
    const audioName = song.filename || snapshotSong.audio.filename || `${songBase}${getBlobExtension(audioBlob, ".mp3")}`;
    const audioAsset = await addSongBackupAsset({
      zip,
      manifest,
      usedEntries,
      folder: "Audio",
      preferredName: audioName,
      fallbackName: `${songBase}.mp3`,
      blob: audioBlob,
      sourceUrl: song.audioServerUrl || "",
      kind: "audio",
    });
    snapshotSong.audio.url = audioAsset.uri;
    snapshotSong.audio.dataUrl = "";
    snapshotSong.audioServerUrl = audioAsset.uri;
    snapshotSong.audioStorageKey = "";
  } catch (error) {
    manifest.missing.push({ kind: "audio", reason: error?.message || String(error) });
    console.warn("[Song Backup] 오디오 포함 실패:", song.title, error);
  }

  try {
    const videoUrl = getAlbumRenderedVideoUrl(song);
    if (!videoUrl) {
      manifest.missing.push({ kind: "video", reason: "렌더링된 비디오가 없습니다." });
    } else {
      const videoBlob = await loadAlbumSongVideoBlob(song);
      const videoAsset = await addSongBackupAsset({
        zip,
        manifest,
        usedEntries,
        folder: "Video",
        preferredName: `${songBase}${getBlobExtension(videoBlob, ".mp4")}`,
        fallbackName: `${songBase}.mp4`,
        blob: videoBlob,
        sourceUrl: videoUrl,
        kind: "video",
      });
      snapshotSong.exportState = {
        ...(snapshotSong.exportState || {}),
        status: "done",
        done: true,
        videoFilename: videoAsset.uri,
      };
    }
  } catch (error) {
    manifest.missing.push({ kind: "video", reason: error?.message || String(error) });
    console.warn("[Song Backup] 비디오 포함 실패:", song.title, error);
  }

  const imageUrlMap = new Map();
  const addImageOnce = async ({ imageUrl, preferredName, fallbackName }) => {
    const key = String(imageUrl || "").trim();
    if (!key) return null;
    if (imageUrlMap.has(key)) return imageUrlMap.get(key);
    const asset = await addSongBackupImageAsset({ zip, manifest, usedEntries, imageUrl: key, preferredName, fallbackName });
    imageUrlMap.set(key, asset);
    return asset;
  };

  for (let index = 0; index < (snapshotSong.scenes || []).length; index += 1) {
    const scene = snapshotSong.scenes[index];
    const asset = await addImageOnce({
      imageUrl: scene?.imageUrl,
      preferredName: scene?.imageFilename || getUrlFilename(scene?.imageUrl, `scene-${index + 1}.png`),
      fallbackName: `${songBase}-scene-${index + 1}.png`,
    });
    if (asset) {
      scene.imageUrl = asset.uri;
      scene.imageFilename = asset.entryPath.split("/").pop();
    }
  }

  if (snapshotSong.sceneGlobal?.imageUrl) {
    const asset = await addImageOnce({
      imageUrl: snapshotSong.sceneGlobal.imageUrl,
      preferredName: snapshotSong.sceneGlobal.imageFilename || getUrlFilename(snapshotSong.sceneGlobal.imageUrl, `${songBase}-global.png`),
      fallbackName: `${songBase}-global.png`,
    });
    if (asset) {
      snapshotSong.sceneGlobal.imageUrl = asset.uri;
      snapshotSong.sceneGlobal.imageFilename = asset.entryPath.split("/").pop();
    }
  }

  const referenceImages = snapshotSong.sceneGlobal?.referenceImages || [];
  for (let index = 0; index < referenceImages.length; index += 1) {
    const reference = referenceImages[index];
    const asset = await addImageOnce({
      imageUrl: reference?.url,
      preferredName: reference?.filename || reference?.originalName || getUrlFilename(reference?.url, `reference-${index + 1}.png`),
      fallbackName: `${songBase}-reference-${index + 1}.png`,
    });
    if (asset) {
      reference.url = asset.uri;
      reference.filename = asset.entryPath.split("/").pop();
    }
  }

  if (album?.coverImageUrl) {
    const asset = await addImageOnce({
      imageUrl: album.coverImageUrl,
      preferredName: album.coverImageFilename || getUrlFilename(album.coverImageUrl, `${songBase}-album-cover.png`),
      fallbackName: `${songBase}-album-cover.png`,
    });
    if (asset) {
      manifest.album.coverImage = {
        uri: asset.uri,
        filename: asset.entryPath.split("/").pop(),
      };
    }
  }

  zip.file("manifest.json", JSON.stringify(manifest, null, 2));
  zip.file("Songs/song.songfilm.json", JSON.stringify(payload, null, 2));
  const blob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: { level: 6 },
  });
  const filename = `${songBase}_full.songfilm.zip`;
  triggerAlbumBlobDownload(blob, filename);
  return { filename, manifest };
}

function findSongBackupAsset(manifest, uri) {
  const target = String(uri || "").trim();
  return (manifest?.assets || []).find((asset) => asset?.uri === target || makeSongBackupZipUri(asset?.entryPath) === target) || null;
}

function getSongBackupZipEntry(zip, entryPath) {
  const cleanPath = String(entryPath || "").replace(/^\/+/, "");
  return cleanPath ? zip.file(cleanPath) : null;
}

async function readSongBackupZipBlob(zip, entryPath, mimeType = "") {
  const entry = getSongBackupZipEntry(zip, entryPath);
  if (!entry) throw new Error(`ZIP 내부 파일을 찾지 못했습니다: ${entryPath}`);
  const buffer = await entry.async("arraybuffer");
  return new Blob([buffer], { type: mimeType || "" });
}

async function uploadSongBackupStorageAsset({ zip, manifest, uri, type, fallbackName }) {
  const asset = findSongBackupAsset(manifest, uri);
  const entryPath = asset?.entryPath || songBackupEntryFromUri(uri);
  const blob = await readSongBackupZipBlob(zip, entryPath, asset?.mimeType || (type === "video" ? "video/mp4" : "audio/mpeg"));
  const form = new FormData();
  form.append("file", blob, asset?.originalName || entryPath.split("/").pop() || fallbackName);
  const response = await fetch(`${ALBUM_STORAGE_BASE}/${type}/save`, { method: "POST", body: form });
  if (!response.ok) {
    throw new Error(`${type === "video" ? "비디오" : "오디오"} 업로드 실패 (${response.status})`);
  }
  const payload = await response.json();
  const filename = String(payload?.filename || "").trim();
  return {
    filename,
    url: filename,
    size: Number(payload?.size || blob.size || 0),
  };
}

async function uploadSongBackupImageAsset({ zip, manifest, uri, storyId, chapterNumber }) {
  if (!window.SongfilmAIBroker?.saveGeneratedImage) {
    throw new Error("이미지 저장 기능이 로드되지 않았습니다.");
  }
  const asset = findSongBackupAsset(manifest, uri);
  const entryPath = asset?.entryPath || songBackupEntryFromUri(uri);
  const blob = await readSongBackupZipBlob(zip, entryPath, asset?.mimeType || "image/png");
  const imageBlob = blob.type?.startsWith("image/") ? blob : new Blob([await blob.arrayBuffer()], { type: "image/png" });
  return await window.SongfilmAIBroker.saveGeneratedImage({
    imageBlob,
    storyId,
    chapterNumber,
  });
}

async function restoreSongBackupZip(file, makeId) {
  if (!file) throw new Error("복원할 ZIP 파일이 없습니다.");
  if (!window.SongfilmSongStorage?.importSongSnapshotPayload) {
    throw new Error("Song 스냅샷 복구 도구를 찾을 수 없습니다.");
  }

  const JSZip = await loadAlbumJSZip();
  const zip = await JSZip.loadAsync(file);
  const manifestEntry = zip.file("manifest.json");
  const manifest = manifestEntry ? JSON.parse(await manifestEntry.async("string")) : { assets: [] };
  const songEntry = zip.file("Songs/song.songfilm.json") || Object.values(zip.files)
    .filter((entry) => !entry.dir && /\.songfilm\.json$/i.test(entry.name))
    .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0];

  if (!songEntry) {
    throw new Error("ZIP 안에서 Song JSON을 찾지 못했습니다.");
  }

  const payload = JSON.parse(await songEntry.async("string"));
  const snapshotSong = payload.song || {};
  const newSongId = "song-" + makeId();
  const restoreStoryId = window.SongfilmSongStorage?.buildStoryId?.({ ...snapshotSong, id: newSongId })
    || snapshotSong.storyId
    || newSongId;

  const audioUri = String(snapshotSong?.audio?.url || snapshotSong?.audioServerUrl || "").trim();
  if (isSongBackupZipUri(audioUri)) {
    const uploadedAudio = await uploadSongBackupStorageAsset({
      zip,
      manifest,
      uri: audioUri,
      type: "audio",
      fallbackName: snapshotSong?.audio?.filename || snapshotSong.filename || "audio.mp3",
    });
    snapshotSong.audio = {
      ...(snapshotSong.audio || {}),
      url: uploadedAudio.url,
      dataUrl: "",
      filename: uploadedAudio.filename || snapshotSong?.audio?.filename || snapshotSong.filename || "",
      byteLength: uploadedAudio.size,
    };
    snapshotSong.audioServerUrl = uploadedAudio.url;
    snapshotSong.audioStorageKey = "";
    snapshotSong.audioPersistedAt = "";
    snapshotSong.audioByteLength = uploadedAudio.size;
  }

  const videoUri = String(snapshotSong?.exportState?.videoFilename || "").trim();
  if (isSongBackupZipUri(videoUri)) {
    const uploadedVideo = await uploadSongBackupStorageAsset({
      zip,
      manifest,
      uri: videoUri,
      type: "video",
      fallbackName: `${sanitizeAlbumEntryName(snapshotSong.title || "song", "song")}.mp4`,
    });
    snapshotSong.exportState = {
      ...(snapshotSong.exportState || {}),
      status: "done",
      progress: 100,
      done: true,
      jobId: "",
      videoFilename: uploadedVideo.filename,
      renderedAt: new Date().toISOString(),
      error: "",
    };
  }

  for (let index = 0; index < (snapshotSong.scenes || []).length; index += 1) {
    const scene = snapshotSong.scenes[index];
    const imageUri = String(scene?.imageUrl || "").trim();
    if (!isSongBackupZipUri(imageUri)) continue;
    const savedImage = await uploadSongBackupImageAsset({
      zip,
      manifest,
      uri: imageUri,
      storyId: restoreStoryId,
      chapterNumber: window.SongfilmSongStorage?.buildChapterNumber?.(scene, index) || index + 1,
    });
    scene.imageUrl = savedImage.filename || window.SongfilmApiConfig?.getMediaFilename?.(savedImage.url) || savedImage.url || "";
    scene.imageFilename = savedImage.filename || scene.imageFilename || "";
    scene.imageSavedAt = new Date().toISOString();
    scene.imageStatus = "done";
    scene.imageError = "";
  }

  if (snapshotSong.sceneGlobal?.imageUrl && isSongBackupZipUri(snapshotSong.sceneGlobal.imageUrl)) {
    const savedGlobalImage = await uploadSongBackupImageAsset({
      zip,
      manifest,
      uri: snapshotSong.sceneGlobal.imageUrl,
      storyId: restoreStoryId,
      chapterNumber: 0,
    });
    snapshotSong.sceneGlobal.imageUrl = savedGlobalImage.filename || window.SongfilmApiConfig?.getMediaFilename?.(savedGlobalImage.url) || savedGlobalImage.url || "";
    snapshotSong.sceneGlobal.imageFilename = savedGlobalImage.filename || snapshotSong.sceneGlobal.imageFilename || "";
    snapshotSong.sceneGlobal.imageSavedAt = new Date().toISOString();
    snapshotSong.sceneGlobal.imageStatus = "done";
    snapshotSong.sceneGlobal.imageError = "";
  }

  const referenceImages = snapshotSong.sceneGlobal?.referenceImages || [];
  for (let index = 0; index < referenceImages.length; index += 1) {
    const reference = referenceImages[index];
    const imageUri = String(reference?.url || "").trim();
    if (!isSongBackupZipUri(imageUri)) continue;
    const savedReference = await uploadSongBackupImageAsset({
      zip,
      manifest,
      uri: imageUri,
      storyId: restoreStoryId,
      chapterNumber: 0,
    });
    reference.url = savedReference.filename || window.SongfilmApiConfig?.getMediaFilename?.(savedReference.url) || savedReference.url || "";
    reference.filename = savedReference.filename || reference.filename || "";
    reference.createdAt = new Date().toISOString();
  }

  snapshotSong.id = newSongId;
  snapshotSong.storyId = restoreStoryId;
  snapshotSong.savedAt = new Date().toISOString();
  payload.song = snapshotSong;
  payload.savedAt = snapshotSong.savedAt;

  const restoredSong = await window.SongfilmSongStorage.importSongSnapshotPayload(payload, newSongId);
  return normalizeSong(restoredSong);
}

async function downloadAlbumSongSnapshotsZip(album) {
  const songs = Array.isArray(album?.songs) ? album.songs : [];
  if (!songs.length) {
    throw new Error("다운로드할 Song이 없습니다.");
  }

  if (!window.SongfilmSongStorage?.exportSongSnapshot) {
    throw new Error("Song 스냅샷 저장소를 찾을 수 없습니다.");
  }

  const JSZip = await loadAlbumJSZip();
  const zip = new JSZip();
  const albumBase = sanitizeAlbumEntryName(album?.title || "album", "album");
  const songsFolder = zip.folder("Songs");
  const coverImageDataUrl = "";

  for (let index = 0; index < songs.length; index += 1) {
    const song = songs[index];
    const snapshot = await window.SongfilmSongStorage.exportSongSnapshot(song);
    const songBase = sanitizeAlbumEntryName(
      (song?.filename || song?.title || `song-${index + 1}`).replace(/\.[^.]+$/, ""),
      `song-${index + 1}`
    );
    const order = String(index + 1).padStart(2, "0");
    songsFolder.file(
      `${order}_${songBase}.songfilm.json`,
      JSON.stringify(snapshot, null, 2)
    );
  }

  zip.file("album.json", JSON.stringify({
    id: album?.id || "",
    title: album?.title || "",
    artist: album?.artist || "",
    year: album?.year || "",
    cover: album?.cover || "auto",
    coverImageUrl: album?.coverImageUrl || "",
    coverImageFilename: album?.coverImageFilename || "",
    coverImagePromptEn: album?.coverImagePromptEn || "",
    coverImageProvider: album?.coverImageProvider || "",
    coverGoogleImagePrompt: album?.coverGoogleImagePrompt || "",
    coverImageSeed: album?.coverImageSeed || "",
    coverImageUpdatedAt: album?.coverImageUpdatedAt || "",
    coverImageDataUrl,
    songCount: songs.length,
    exportedAt: new Date().toISOString(),
  }, null, 2));

  const blob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: { level: 6 },
  });

  const filename = `${albumBase}_songs.zip`;
  triggerAlbumBlobDownload(blob, filename);
  return filename;
}

async function importAlbumSongSnapshotsZip(file, makeId) {
  if (!window.SongfilmSongStorage?.importSongSnapshotPayload) {
    throw new Error("Song 스냅샷 복구 도구를 찾을 수 없습니다.");
  }

  const JSZip = await loadAlbumJSZip();
  const zip = await JSZip.loadAsync(file);
  const albumEntry = zip.file("album.json");
  let albumMeta = {};

  if (albumEntry) {
    try {
      albumMeta = JSON.parse(await albumEntry.async("string"));
    } catch (error) {
      console.warn("[Album Upload] album.json 파싱 실패:", error);
    }
  }

  const songEntries = Object.values(zip.files)
    .filter((entry) => !entry.dir && /(^|\/)Songs\/.+\.songfilm\.json$/i.test(entry.name))
    .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));

  if (!songEntries.length) {
    throw new Error("Zip 안에서 Songs/*.songfilm.json 파일을 찾지 못했습니다.");
  }

  const songs = [];
  for (const entry of songEntries) {
    const songId = "song-" + makeId();
    const payload = JSON.parse(await entry.async("string"));
    const restoredSong = await window.SongfilmSongStorage.importSongSnapshotPayload(payload, songId);
    songs.push(normalizeSong(restoredSong));
  }

  const titleFromFile = file.name.replace(/(?:_songs)?\.zip$/i, "").replace(/[_-]+/g, " ").trim();
  const restoredAlbumId = "album-" + makeId();
  let coverImageUrl = albumMeta.coverImageUrl || "";
  let coverImageFilename = albumMeta.coverImageFilename || "";
  const coverImageDataUrl = String(albumMeta.coverImageDataUrl || "").trim();
  if (coverImageDataUrl && window.SongfilmAIBroker?.saveGeneratedImage) {
    try {
      const savedCover = await window.SongfilmAIBroker.saveGeneratedImage({
        imageBlob: albumDataUrlToBlob(coverImageDataUrl),
        storyId: `Album-${restoredAlbumId}`,
        chapterNumber: 0,
      });
      coverImageUrl = savedCover.filename || window.SongfilmApiConfig?.getMediaFilename?.(savedCover.url) || savedCover.url || coverImageUrl;
      coverImageFilename = savedCover.filename || coverImageFilename;
    } catch (error) {
      console.warn("[Album Upload] 커버 이미지 복원 실패:", error);
    }
  }
  return normalizeAlbum({
    id: restoredAlbumId,
    title: albumMeta.title || titleFromFile || "업로드한 앨범",
    artist: albumMeta.artist || "Artist",
    year: albumMeta.year || new Date().getFullYear(),
    cover: albumMeta.cover || "auto",
    coverImageUrl,
    coverImageFilename,
    coverImagePromptEn: albumMeta.coverImagePromptEn || "",
    coverImageProvider: albumMeta.coverImageProvider === "google" ? "google" : "",
    coverGoogleImagePrompt: albumMeta.coverGoogleImagePrompt || "",
    coverImageSeed: albumMeta.coverImageSeed || "",
    coverImageUpdatedAt: albumMeta.coverImageUpdatedAt || "",
    songs,
    importedAt: new Date().toISOString(),
  });
}

function getAlbumStorageAudioFilename(song) {
  const explicit = String(song?.exportState?.audioFilename || "").trim();
  if (explicit) return explicit;
  const serverAudioUrl = String(song?.audioServerUrl || "").trim();
  if (!serverAudioUrl) return "";
  const filename = window.SongfilmApiConfig?.getMediaFilename?.(serverAudioUrl) || "";
  if (filename) return filename;
  try {
    const url = new URL(serverAudioUrl);
    return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() || "");
  } catch {
    return "";
  }
}

async function loadAlbumSongAudioBlob(song) {
  const storedFilename = String(song?.exportState?.audioFilename || "").trim();
  if (storedFilename) {
    const resp = await fetch(`${ALBUM_STORAGE_BASE}/audio/${encodeURIComponent(storedFilename)}`);
    if (!resp.ok) throw new Error(`오디오 다운로드 실패 (${resp.status})`);
    return await resp.blob();
  }

  const serverAudioUrl = String(song?.audioServerUrl || "").trim();
  if (serverAudioUrl) {
    const audioUrl = window.SongfilmApiConfig?.buildMediaUrl
      ? window.SongfilmApiConfig.buildMediaUrl("audio", serverAudioUrl)
      : serverAudioUrl;
    const resp = await fetch(audioUrl);
    if (!resp.ok) throw new Error(`오디오 다운로드 실패 (${resp.status})`);
    return await resp.blob();
  }

  const restoredAudioUrl = await window.SongfilmSongStorage.restoreAudioUrl(song);
  if (restoredAudioUrl) {
    const resp = await fetch(restoredAudioUrl);
    if (!resp.ok) throw new Error(`오디오 다운로드 실패 (${resp.status})`);
    return await resp.blob();
  }

  const snap = await window.SongfilmSongStorage.exportSongSnapshot(song);
  const dataUrl = snap?.song?.audio?.dataUrl;
  if (!dataUrl) throw new Error("오디오 데이터 없음");
  return window.SongfilmSongStorage.dataUrlToBlob(dataUrl);
}

async function loadAlbumSongVideoBlob(song) {
  const videoUrl = getAlbumRenderedVideoUrl(song);
  if (!videoUrl) throw new Error("렌더링된 비디오가 없습니다.");
  const resp = await fetch(videoUrl);
  if (!resp.ok) throw new Error(`비디오 다운로드 실패 (${resp.status})`);
  return await resp.blob();
}

const BATCH_RENDER_ACTIVE_STATUSES = new Set(["pending", "bundling", "rendering", "uploading"]);
const BATCH_RENDER_INITIAL_STATE = {
  open: false,
  running: false,
  stopping: false,
  status: "idle",
  currentSongId: null,
  currentJobId: null,
  currentIndex: 0,
  total: 0,
  progress: 0,
  stage: "",
  error: "",
  selectedSongIds: [],
  forceRerender: true,
  items: [],
};

function createBatchRenderItems(songs) {
  return songs.map((song, index) => ({
    songId: song.id,
    title: song.title || `track-${index + 1}`,
    selected: true,
    status: "queued",
    progress: 0,
    message: "대기 중",
  }));
}

function shouldInlineAlbumRenderAsset(url) {
  if (!url) return false;
  if (window.SongfilmSongStorage?.isBlobUrl?.(url)) return true;
  if (!/^(https?:|data:|blob:)/i.test(String(url))) return true;
  try {
    const parsed = new URL(url, window.location.href);
    return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
  } catch {
    return false;
  }
}

async function fetchAlbumRenderAssetDataUrl(url) {
  const sourceUrl = window.SongfilmApiConfig?.buildMediaUrl
    ? window.SongfilmApiConfig.buildMediaUrl("image", url)
    : url;
  const resp = await fetch(sourceUrl);
  if (!resp.ok) {
    throw new Error(`이미지 로드 실패 (${resp.status})`);
  }
  const blob = await resp.blob();
  return await new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

async function inlineAlbumRenderImages(song) {
  const scenes = await Promise.all((song.scenes || []).map(async (scene) => {
    if (!shouldInlineAlbumRenderAsset(scene?.imageUrl)) return scene;
    try {
      return {
        ...scene,
        imageUrl: await fetchAlbumRenderAssetDataUrl(scene.imageUrl),
      };
    } catch (error) {
      console.warn("[Batch Render] 씬 이미지 변환 실패, 빈 이미지로 대체:", scene.imageUrl, error);
      return { ...scene, imageUrl: "" };
    }
  }));

  let sceneGlobal = song.sceneGlobal || {};
  if (shouldInlineAlbumRenderAsset(sceneGlobal.imageUrl)) {
    try {
      sceneGlobal = {
        ...sceneGlobal,
        imageUrl: await fetchAlbumRenderAssetDataUrl(sceneGlobal.imageUrl),
      };
    } catch (error) {
      console.warn("[Batch Render] 전체 장면 이미지 변환 실패, 빈 이미지로 대체:", sceneGlobal.imageUrl, error);
      sceneGlobal = { ...sceneGlobal, imageUrl: "" };
    }
  }

  return { scenes, sceneGlobal };
}

async function makeAlbumRenderSongData(song) {
  const { scenes, sceneGlobal } = await inlineAlbumRenderImages(song);
  return {
    title: song.title,
    filename: song.filename || "audio.mp3",
    duration: song.duration,
    paletteName: song.paletteName || "midnight-violet",
    previewSettings: song.previewSettings || {},
    scenes,
    sceneGlobal,
    lyricTimeline: song.lyricTimeline || null,
  };
}

function createBatchStoppedError() {
  const error = new Error("배치 렌더링이 중지되었습니다.");
  error.name = "BatchRenderStopped";
  return error;
}

function sanitizeSceneForStorage(scene) {
  const imageUrl = typeof scene?.imageUrl === "string" && scene.imageUrl && !scene.imageUrl.startsWith("blob:")
    ? scene.imageUrl
    : "";

  return {
    ...scene,
    imageUrl,
    imageFilename: typeof scene?.imageFilename === "string" ? scene.imageFilename : "",
    imageSavedAt: typeof scene?.imageSavedAt === "string" ? scene.imageSavedAt : "",
    imageSource: typeof scene?.imageSource === "string" ? scene.imageSource : "",
    imageProvider: scene?.imageProvider === "google" ? "google" : "",
    googleImagePrompt: typeof scene?.googleImagePrompt === "string" ? scene.googleImagePrompt : "",
    analysisStatus: scene?.promptEn ? "done" : (scene?.analysisStatus === "error" ? "error" : "idle"),
    imageStatus: imageUrl ? "done" : (scene?.imageStatus === "error" ? "error" : "idle"),
  };
}

function sanitizeSceneGlobalForStorage(sceneGlobal) {
  const normalized = normalizeSceneGlobal(sceneGlobal);
  return {
    ...normalized,
    imageUrl: normalized.imageUrl && !normalized.imageUrl.startsWith("blob:") ? normalized.imageUrl : "",
    imageStatus: normalized.imageUrl ? "done" : (["loading", "error"].includes(normalized.imageStatus) ? normalized.imageStatus : "idle"),
  };
}

function sanitizeAlbumsForStorage(albums) {
  return albums.map((album) => ({
    ...album,
    songs: (album.songs || []).map((song) => ({
      ...song,
      scenes: (song.scenes || []).map(sanitizeSceneForStorage),
      sceneGlobal: sanitizeSceneGlobalForStorage(song.sceneGlobal),
    })),
  }));
}

function App() {
  const { Icon, AlbumHub, Creator, Viewer, ToastHost, TWEAK_DEFAULTS, TweaksPanel, INITIAL_ALBUM, makeId } = window;

  const [albums, setAlbums] = useStateApp(() => {
    try {
      const savedAlbums = localStorage.getItem("songfilm-albums");
      if (savedAlbums) {
        const parsed = JSON.parse(savedAlbums);
        if (Array.isArray(parsed) && parsed.length) return parsed.map(normalizeAlbum);
      }
      // migrate legacy single-album storage
      const legacy = localStorage.getItem("songfilm-album");
      if (legacy) {
        const a = JSON.parse(legacy);
        return [normalizeAlbum({ ...a, id: a.id || "album-" + makeId() })];
      }
    } catch(e) {}
    return [normalizeAlbum({ ...INITIAL_ALBUM, id: "album-default" })];
  });

  const [currentAlbumId, setCurrentAlbumId] = useStateApp(() => {
    try {
      const saved = localStorage.getItem("songfilm-currentAlbumId");
      if (saved) return saved;
    } catch(e) {}
    return null;
  });

  const [view, setView] = useStateApp(() => {
    try { return localStorage.getItem("songfilm-view") || "hub"; } catch(e) { return "hub"; }
  });
  const [currentSongId, setCurrentSongId] = useStateApp(() => {
    try { return localStorage.getItem("songfilm-songId") || null; } catch(e) { return null; }
  });
  const [viewerSongId, setViewerSongId] = useStateApp(null);
  const [tweaks, setTweaks] = useStateApp(() => {
    try { return { ...TWEAK_DEFAULTS, ...JSON.parse(localStorage.getItem("songfilm-tweaks") || "{}") }; }
    catch(e) { return TWEAK_DEFAULTS; }
  });
  const [tweaksOn, setTweaksOn] = useStateApp(false);
  const [sidebarCollapsed, setSidebarCollapsed] = useStateApp(() => {
    try { return localStorage.getItem("songfilm-sidebarCollapsed") === "true"; }
    catch(e) { return false; }
  });
  const [albumDownloadBusy, setAlbumDownloadBusy] = useStateApp(false);
  const [albumDownloadProgress, setAlbumDownloadProgress] = useStateApp(0);
  const [albumDownloadStage, setAlbumDownloadStage] = useStateApp("");
  const [batchRenderState, setBatchRenderState] = useStateApp(BATCH_RENDER_INITIAL_STATE);
  const [newAlbumModal, setNewAlbumModal] = useStateApp(false);
  const [albumUploadBusy, setAlbumUploadBusy] = useStateApp(false);
  const [songBackupBusyId, setSongBackupBusyId] = useStateApp(null);
  const [songBackupUploadBusy, setSongBackupUploadBusy] = useStateApp(false);
  const batchStopRequestedRef = useRefApp(false);
  const albumUploadInputRef = useRefApp(null);

  const album = albums.find(a => a.id === currentAlbumId) || albums[0];
  useEffectApp(() => {
    if (!albums.find(a => a.id === currentAlbumId)) {
      setCurrentAlbumId(albums[0]?.id || null);
    }
  }, [albums, currentAlbumId]);

  const setAlbum = (updater) => {
    setAlbums(prev => prev.map(a => {
      if (a.id !== album.id) return a;
      return typeof updater === "function" ? updater(a) : updater;
    }));
  };

  // persist
  useEffectApp(() => {
    try {
      localStorage.setItem("songfilm-albums", JSON.stringify(sanitizeAlbumsForStorage(albums)));
    } catch(e){}
  }, [albums]);
  useEffectApp(() => { try { if (currentAlbumId) localStorage.setItem("songfilm-currentAlbumId", currentAlbumId); } catch(e){} }, [currentAlbumId]);
  useEffectApp(() => { try { localStorage.setItem("songfilm-view", view); } catch(e){} }, [view]);
  useEffectApp(() => { try { if (currentSongId) localStorage.setItem("songfilm-songId", currentSongId); } catch(e){} }, [currentSongId]);
  useEffectApp(() => { try { localStorage.setItem("songfilm-tweaks", JSON.stringify(tweaks)); } catch(e){} }, [tweaks]);
  useEffectApp(() => { try { localStorage.setItem("songfilm-sidebarCollapsed", String(sidebarCollapsed)); } catch(e){} }, [sidebarCollapsed]);

  // Edit-mode (Tweaks) handshake
  useEffectApp(() => {
    const onMsg = (e) => {
      if (e.data?.type === "__activate_edit_mode") setTweaksOn(true);
      if (e.data?.type === "__deactivate_edit_mode") setTweaksOn(false);
    };
    window.addEventListener("message", onMsg);
    try { window.parent.postMessage({ type: "__edit_mode_available" }, "*"); } catch(e) {}
    return () => window.removeEventListener("message", onMsg);
  }, []);

  const openSong = (id) => { setCurrentSongId(id); setView("creator"); };
  const openLyricEditor = (id) => { setCurrentSongId(id); setView("lyric-editor"); };
  const openViewer = (songId = null) => {
    setViewerSongId(songId);
    setView("viewer");
  };
  const openSongFromAlbum = (song) => {
    if (song?.status === "ready") {
      openViewer(song.id);
      return;
    }
    openSong(song.id);
  };
  const newSong = () => {
    const id = "song-" + makeId();
    const song = {
      id,
      title: "새 노래",
      filename: "",
      duration: 0,
      size: "",
      status: "empty",
      cues: [],
      scenes: [],
      lyricTimeline: null,
      referenceLyricsText: "",
      referenceLyricsName: "",
      transcribeMode: "",
      transcribeWorkflow: null,
      paletteName: "midnight-violet",
      workflowStep: "upload",
      storyId: "",
      storageVersion: "",
      savedAt: "",
      audioStorageKey: "",
      audioMimeType: "audio/mpeg",
      audioPersistedAt: "",
      audioByteLength: 0,
      previewSettings: {
        fontSize: 36,
        subPos: "bottom",
        transition: "fade",
      },
      exportState: {
        status: "idle",
        progress: 0,
        done: false,
      },
    };
    setAlbum(prev => ({ ...prev, songs: [...prev.songs, song] }));
    setCurrentSongId(id);
    setView("creator");
  };

  const createAlbum = ({ title, artist, year }) => {
    const id = "album-" + makeId();
    const newAlbum = {
      id,
      title: title?.trim() || "새 앨범",
      artist: artist?.trim() || "Artist",
      year: year || new Date().getFullYear(),
      cover: "auto",
      songs: [],
    };
    setAlbums(prev => [...prev, newAlbum]);
    setCurrentAlbumId(id);
    setView("hub");
    setNewAlbumModal(false);
    window.toast(`앨범 "${newAlbum.title}" 생성됨`);
  };

  const deleteAlbum = (id) => {
    if (albums.length <= 1) { window.toast("최소 1개의 앨범은 필요합니다"); return; }
    if (!confirm("이 앨범을 삭제할까요? 포함된 모든 노래도 함께 제거됩니다.")) return;
    setAlbums(prev => prev.filter(a => a.id !== id));
    window.toast("앨범이 삭제되었습니다");
  };

  const switchAlbum = (id) => {
    setCurrentAlbumId(id);
    setView("hub");
    setCurrentSongId(null);
  };

  const patchBatchRenderItem = (songId, patch) => {
    setBatchRenderState((prev) => ({
      ...prev,
      items: prev.items.map((item) => (
        item.songId === songId ? { ...item, ...patch } : item
      )),
    }));
  };

  const openBatchRenderDialog = () => {
    if (batchRenderState.running) return;
    const ready = album.songs.filter((song) => song.status === "ready");
    if (!ready.length) {
      window.toast("배치 렌더링할 완성곡이 없습니다.");
      return;
    }

    setBatchRenderState({
      ...BATCH_RENDER_INITIAL_STATE,
      open: true,
      status: "config",
      total: ready.length,
      selectedSongIds: ready.map((song) => song.id),
      forceRerender: true,
      stage: "렌더링할 곡을 선택하세요.",
      items: createBatchRenderItems(ready),
    });
  };

  const setBatchRenderForceRerender = (forceRerender) => {
    if (batchRenderState.running) return;
    setBatchRenderState((prev) => ({
      ...prev,
      forceRerender,
    }));
  };

  const toggleBatchRenderSong = (songId) => {
    if (batchRenderState.running) return;
    setBatchRenderState((prev) => {
      const selected = new Set(prev.selectedSongIds || []);
      if (selected.has(songId)) selected.delete(songId);
      else selected.add(songId);

      return {
        ...prev,
        selectedSongIds: Array.from(selected),
        items: prev.items.map((item) => (
          item.songId === songId
            ? {
                ...item,
                selected: selected.has(songId),
                status: selected.has(songId) ? "queued" : "excluded",
                progress: 0,
                message: selected.has(songId) ? "대기 중" : "렌더링 제외",
              }
            : item
        )),
      };
    });
  };

  const selectAllBatchRenderSongs = () => {
    if (batchRenderState.running) return;
    setBatchRenderState((prev) => ({
      ...prev,
      selectedSongIds: prev.items.map((item) => item.songId),
      items: prev.items.map((item) => ({
        ...item,
        selected: true,
        status: "queued",
        progress: 0,
        message: "대기 중",
      })),
    }));
  };

  const clearBatchRenderSongs = () => {
    if (batchRenderState.running) return;
    setBatchRenderState((prev) => ({
      ...prev,
      selectedSongIds: [],
      items: prev.items.map((item) => ({
        ...item,
        selected: false,
        status: "excluded",
        progress: 0,
        message: "렌더링 제외",
      })),
    }));
  };

  const patchBatchSongExportState = (songId, patch) => {
    setAlbum((prevAlbum) => ({
      ...prevAlbum,
      songs: prevAlbum.songs.map((song) => (
        song.id !== songId
          ? song
          : {
              ...song,
              exportState: {
                ...(song.exportState || {}),
                ...patch,
              },
            }
      )),
    }));
  };

  const ensureBatchNotStopped = () => {
    if (batchStopRequestedRef.current) {
      throw createBatchStoppedError();
    }
  };

  const pollBatchRenderJob = async ({ song, jobId, audioFilename, index, total }) => {
    for (;;) {
      ensureBatchNotStopped();
      await waitAlbumMs(1200);
      ensureBatchNotStopped();

      const statusResp = await fetch(`${ALBUM_RENDERER_BASE}/render/${jobId}/status`);
      if (!statusResp.ok) {
        throw new Error(`${song.title} 렌더 상태 조회 실패 (${statusResp.status})`);
      }

      const statusData = await statusResp.json();
      const status = String(statusData?.status || "pending");
      const renderProgress = Math.round(Number(statusData?.progress || 0) * 100);
      const statusLabel = status === "bundling" ? "번들링" : status === "uploading" ? "업로드" : "렌더링";
      const itemStatus = status === "uploading" ? "uploading-video" : status;
      const overallProgress = Math.round(((index + Math.min(1, renderProgress / 100)) / Math.max(total, 1)) * 100);

      setBatchRenderState((prev) => ({
        ...prev,
        progress: Math.min(99, overallProgress),
        stage: `${song.title} ${statusLabel} 중… ${Math.min(100, renderProgress)}%`,
      }));
      patchBatchRenderItem(song.id, {
        status: itemStatus,
        progress: renderProgress,
        message: `${statusLabel} 중… ${Math.min(100, renderProgress)}%`,
      });
      patchBatchSongExportState(song.id, {
        status,
        done: status === "done",
        progress: renderProgress,
        error: statusData?.error || "",
        audioFilename,
        jobId,
        videoFilename: statusData?.videoFilename || "",
      });

      if (status === "canceled") {
        throw createBatchStoppedError();
      }

      if (status === "done") {
        const renderedAt = new Date().toISOString();
        patchBatchRenderItem(song.id, {
          status: "done",
          progress: 100,
          message: "완료",
        });
        patchBatchSongExportState(song.id, {
          status: "done",
          done: true,
          progress: 100,
          audioFilename,
          jobId,
          videoFilename: statusData?.videoFilename || "",
          renderedAt,
          error: "",
        });
        return;
      }

      if (status === "error") {
        throw new Error(statusData?.error || `${song.title} 렌더링 실패`);
      }
    }
  };

  const renderBatchSong = async (song, index, total, { forceRerender = true } = {}) => {
    const existingVideoFilename = String(song?.exportState?.videoFilename || "").trim();
    const existingJobId = String(song?.exportState?.jobId || "").trim();
    const existingStatus = String(song?.exportState?.status || "");
    const existingDone = !forceRerender && existingStatus === "done" && (existingVideoFilename || existingJobId);
    const activeJobId = !forceRerender && BATCH_RENDER_ACTIVE_STATUSES.has(existingStatus) ? existingJobId : "";

    setBatchRenderState((prev) => ({
      ...prev,
      currentSongId: song.id,
      currentJobId: activeJobId || null,
      currentIndex: index + 1,
      stage: `${song.title} 준비 중…`,
    }));

    if (existingDone) {
      const progress = Math.round(((index + 1) / Math.max(total, 1)) * 100);
      patchBatchRenderItem(song.id, {
        status: "skipped",
        progress: 100,
        message: "기존 렌더 사용",
      });
      setBatchRenderState((prev) => ({
        ...prev,
        progress,
        stage: `${song.title} 기존 비디오 사용`,
      }));
      return;
    }

    if (forceRerender) {
      patchBatchSongExportState(song.id, {
        status: "pending",
        done: false,
        progress: 0,
        error: "",
      });
    }

    let audioFilename = getAlbumStorageAudioFilename(song);
    if (!audioFilename) {
      ensureBatchNotStopped();
      patchBatchRenderItem(song.id, {
        status: "uploading",
        progress: 0,
        message: "오디오 업로드 중…",
      });
      setBatchRenderState((prev) => ({
        ...prev,
        stage: `${song.title} 오디오 업로드 중…`,
      }));
      const audioBlob = await loadAlbumSongAudioBlob(song);
      ensureBatchNotStopped();
      const audioForm = new FormData();
      audioForm.append("file", audioBlob, song.filename || `${song.title}.mp3`);
      const audioResp = await fetch(`${ALBUM_STORAGE_BASE}/audio/save`, { method: "POST", body: audioForm });
      if (!audioResp.ok) {
        throw new Error(`${song.title} 오디오 업로드 실패 (${audioResp.status})`);
      }
      const audioData = await audioResp.json();
      audioFilename = String(audioData?.filename || "").trim();
      patchBatchSongExportState(song.id, { audioFilename });
    }

    let jobId = activeJobId;
    if (!jobId) {
      ensureBatchNotStopped();
      patchBatchRenderItem(song.id, {
        status: "checking",
        progress: 0,
        message: "렌더링 서버 확인 중…",
      });
      setBatchRenderState((prev) => ({
        ...prev,
        stage: "렌더링 서버 확인 중…",
      }));
      const healthResp = await fetch(`${ALBUM_RENDERER_BASE}/health`);
      if (!healthResp.ok) {
        throw new Error(`렌더링 서버 연결 실패 (${healthResp.status})`);
      }

      ensureBatchNotStopped();
      patchBatchRenderItem(song.id, {
        status: "pending",
        progress: 0,
        message: "이미지 준비 및 렌더링 시작 중…",
      });
      setBatchRenderState((prev) => ({
        ...prev,
        stage: `${song.title} 이미지 준비 및 비디오 렌더링 시작…`,
      }));
      const songData = await makeAlbumRenderSongData(song);
      ensureBatchNotStopped();
      const startResp = await fetch(`${ALBUM_RENDERER_BASE}/render`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ songData, audioFilename }),
      });
      if (!startResp.ok) {
        let errMsg = `${song.title} 렌더 시작 실패 (${startResp.status})`;
        try {
          const errBody = await startResp.json();
          errMsg = errBody?.error || errBody?.message || errMsg;
        } catch {}
        throw new Error(errMsg);
      }

      const body = await startResp.json();
      jobId = body?.jobId;
      if (!jobId) throw new Error(`${song.title} 렌더 jobId가 없습니다.`);
      setBatchRenderState((prev) => ({
        ...prev,
        currentJobId: jobId,
      }));
      patchBatchSongExportState(song.id, {
        status: "bundling",
        done: false,
        progress: 0,
        error: "",
        audioFilename,
        jobId,
        videoFilename: "",
        renderedAt: "",
      });
    } else {
      patchBatchRenderItem(song.id, {
        status: existingStatus,
        progress: Number(song.exportState?.progress || 0),
        message: "진행 중인 렌더에 다시 연결",
      });
    }

    await pollBatchRenderJob({ song, jobId, audioFilename, index, total });
  };

  const startBatchRenderVideos = async () => {
    if (batchRenderState.running) return;
    const selected = new Set(batchRenderState.selectedSongIds || []);
    const ready = album.songs.filter((song) => song.status === "ready" && selected.has(song.id));
    if (!ready.length) {
      window.toast("렌더링할 곡을 하나 이상 선택해 주세요.");
      return;
    }

    batchStopRequestedRef.current = false;
    setBatchRenderState((prev) => ({
      ...prev,
      open: true,
      running: true,
      status: "running",
      total: ready.length,
      progress: 0,
      currentSongId: null,
      currentIndex: 0,
      error: "",
      stage: prev.forceRerender ? "선택한 곡을 처음부터 다시 렌더링합니다…" : "선택한 곡의 비디오를 준비합니다…",
      items: prev.items.map((item) => (
        selected.has(item.songId)
          ? { ...item, selected: true, status: "queued", progress: 0, message: "대기 중" }
          : { ...item, selected: false, status: "excluded", progress: 0, message: "렌더링 제외" }
      )),
    }));

    try {
      window.toast(batchRenderState.forceRerender ? "선택한 곡 재렌더링 시작" : "배치 비디오 렌더링 시작");
      for (let index = 0; index < ready.length; index += 1) {
        ensureBatchNotStopped();
        await renderBatchSong(ready[index], index, ready.length, {
          forceRerender: batchRenderState.forceRerender,
        });
      }

      setBatchRenderState((prev) => ({
        ...prev,
        running: false,
        stopping: false,
        status: "done",
        currentSongId: null,
        currentJobId: null,
        progress: 100,
        stage: prev.forceRerender ? "선택한 곡 재렌더링 완료" : "배치 비디오 렌더링 완료",
      }));
      window.toast(batchRenderState.forceRerender ? "선택한 곡 재렌더링 완료" : "배치 비디오 렌더링 완료");
    } catch (error) {
      if (error?.name === "BatchRenderStopped") {
        setBatchRenderState((prev) => ({
          ...prev,
          running: false,
          stopping: false,
          status: "stopped",
          currentJobId: null,
          progress: prev.progress,
          stage: "배치 렌더링이 중지되었습니다.",
          items: prev.items.map((item) => (
            item.songId === prev.currentSongId && !["done", "skipped", "error"].includes(item.status)
              ? { ...item, status: "stopped", message: "중지됨" }
              : item
          )),
        }));
        window.toast("배치 렌더링 중지됨");
        return;
      }

      console.error("[Batch Render] 실패:", error);
      setBatchRenderState((prev) => ({
        ...prev,
        running: false,
        stopping: false,
        status: "error",
        currentJobId: null,
        error: error?.message || String(error),
        stage: "배치 렌더링 실패",
        items: prev.items.map((item) => (
          item.songId === prev.currentSongId
            ? { ...item, status: "error", message: error?.message || String(error) }
            : item
        )),
      }));
      window.toast("배치 렌더링 실패: " + (error?.message || error));
    }
  };

  const requestStopBatchRender = async () => {
    if (!batchRenderState.running) {
      setBatchRenderState((prev) => ({ ...prev, open: false }));
      return;
    }
    batchStopRequestedRef.current = true;
    const cancelJobId = batchRenderState.currentJobId;
    const cancelSongId = batchRenderState.currentSongId;
    setBatchRenderState((prev) => ({
      ...prev,
      stopping: true,
      stage: cancelJobId ? "현재 서버 렌더를 취소하는 중…" : "현재 준비 작업 후 배치 큐를 중지합니다…",
    }));
    if (!cancelJobId) return;

    try {
      await fetch(`${ALBUM_RENDERER_BASE}/render/${cancelJobId}/cancel`, { method: "POST" });
      if (cancelSongId) {
        patchBatchRenderItem(cancelSongId, {
          status: "stopped",
          message: "서버 렌더 취소 요청됨",
        });
        patchBatchSongExportState(cancelSongId, {
          status: "canceled",
          done: false,
          error: "렌더링이 취소되었습니다.",
        });
      }
    } catch (error) {
      console.warn("[Batch Render] 렌더 취소 요청 실패:", error);
      window.toast("렌더 취소 요청 실패: " + (error?.message || error));
    }
  };

  const closeBatchRenderDialog = () => {
    if (batchRenderState.running) {
      requestStopBatchRender();
      return;
    }
    setBatchRenderState((prev) => ({ ...prev, open: false }));
  };

  const downloadAlbum = async () => {
    if (albumDownloadBusy) return;
    const ready = album.songs.filter(s => s.status === "ready");
    if (!ready.length) {
      window.toast("다운로드할 완성곡이 없습니다.");
      return;
    }

    const patchSongExportState = (songId, patch) => {
      setAlbum((prevAlbum) => ({
        ...prevAlbum,
        songs: prevAlbum.songs.map((song) => (
          song.id !== songId
            ? song
            : {
                ...song,
                exportState: {
                  ...(song.exportState || {}),
                  ...patch,
                },
              }
        )),
      }));
    };

    const ensureRenderedVideo = async (song, songIndex) => {
      const existingVideoFilename = String(song?.exportState?.videoFilename || "").trim();
      const existingJobId = String(song?.exportState?.jobId || "").trim();
      const existingDone = song?.exportState?.status === "done" || !!existingVideoFilename;
      const segmentStart = 8 + Math.floor((songIndex / Math.max(ready.length, 1)) * 52);
      const segmentSize = Math.max(8, Math.floor(52 / Math.max(ready.length, 1)));

      if (existingDone && (existingVideoFilename || existingJobId)) {
        setAlbumDownloadProgress(Math.min(60, segmentStart + segmentSize));
        setAlbumDownloadStage(`${song.title} 기존 비디오 사용`);
        return {
          ...song,
          exportState: {
            ...(song.exportState || {}),
            status: "done",
            done: true,
            progress: 100,
          },
        };
      }

      let audioFilename = getAlbumStorageAudioFilename(song);
      if (!audioFilename) {
        setAlbumDownloadProgress(segmentStart);
        setAlbumDownloadStage(`${song.title} 오디오 업로드 중…`);
        const audioBlob = await loadAlbumSongAudioBlob(song);
        const audioForm = new FormData();
        audioForm.append("file", audioBlob, song.filename || `${song.title}.mp3`);
        const audioResp = await fetch(`${ALBUM_STORAGE_BASE}/audio/save`, { method: "POST", body: audioForm });
        if (!audioResp.ok) {
          throw new Error(`${song.title} 오디오 업로드 실패 (${audioResp.status})`);
        }
        const audioData = await audioResp.json();
        audioFilename = String(audioData?.filename || "").trim();
        patchSongExportState(song.id, { audioFilename });
      }

      setAlbumDownloadProgress(Math.min(60, segmentStart + 2));
      setAlbumDownloadStage("렌더링 서버 확인 중…");
      const healthResp = await fetch(`${ALBUM_RENDERER_BASE}/health`);
      if (!healthResp.ok) {
        throw new Error(`렌더링 서버 연결 실패 (${healthResp.status})`);
      }

      const songData = {
        title: song.title,
        filename: song.filename || "audio.mp3",
        duration: song.duration,
        paletteName: song.paletteName || "midnight-violet",
        previewSettings: song.previewSettings || {},
        scenes: song.scenes || [],
        sceneGlobal: song.sceneGlobal || {},
        lyricTimeline: song.lyricTimeline || null,
      };

      setAlbumDownloadProgress(Math.min(60, segmentStart + 4));
      setAlbumDownloadStage(`${song.title} 비디오 렌더링 시작…`);
      const startResp = await fetch(`${ALBUM_RENDERER_BASE}/render`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ songData, audioFilename }),
      });
      if (!startResp.ok) {
        let errMsg = `${song.title} 렌더 시작 실패 (${startResp.status})`;
        try {
          const errBody = await startResp.json();
          errMsg = errBody?.error || errBody?.message || errMsg;
        } catch {}
        throw new Error(errMsg);
      }

      const { jobId } = await startResp.json();
      patchSongExportState(song.id, {
        status: "bundling",
        done: false,
        progress: 0,
        error: "",
        audioFilename,
        jobId,
      });

      for (;;) {
        await waitAlbumMs(1200);
        const statusResp = await fetch(`${ALBUM_RENDERER_BASE}/render/${jobId}/status`);
        if (!statusResp.ok) {
          throw new Error(`${song.title} 렌더 상태 조회 실패 (${statusResp.status})`);
        }
        const statusData = await statusResp.json();
        const status = String(statusData?.status || "pending");
        const renderProgress = Math.round(Number(statusData?.progress || 0) * 100);
        const overallProgress = segmentStart + Math.round((Math.min(100, renderProgress) / 100) * segmentSize);
        setAlbumDownloadProgress(Math.min(60, overallProgress));
        setAlbumDownloadStage(`${song.title} ${status === "bundling" ? "번들링" : status === "uploading" ? "업로드" : "렌더링"} 중… ${Math.min(100, renderProgress)}%`);
        patchSongExportState(song.id, {
          status,
          done: status === "done",
          progress: renderProgress,
          error: statusData?.error || "",
          audioFilename,
          jobId,
          videoFilename: statusData?.videoFilename || "",
        });

        if (status === "done") {
          return {
            ...song,
            exportState: {
              ...(song.exportState || {}),
              status: "done",
              done: true,
              progress: 100,
              audioFilename,
              jobId,
              videoFilename: statusData?.videoFilename || "",
            },
          };
        }
        if (status === "error") {
          throw new Error(statusData?.error || `${song.title} 렌더링 실패`);
        }
      }
    };

    setAlbumDownloadBusy(true);
    setAlbumDownloadProgress(0);
    setAlbumDownloadStage("JSZip 로드 중…");
    try {
      window.toast("앨범 zip 준비 중…");
      const JSZip = await loadAlbumJSZip();
      const zip = new JSZip();
      const albumBase = sanitizeAlbumEntryName(album.title || "album", "album");
      const audioFolder = zip.folder("Audio");
      const videoFolder = zip.folder("Video");
      const renderedSongs = [];

      setAlbumDownloadProgress(8);
      setAlbumDownloadStage("곡 비디오 준비 중…");
      for (let index = 0; index < ready.length; index += 1) {
        renderedSongs.push(await ensureRenderedVideo(ready[index], index));
      }

      for (let index = 0; index < renderedSongs.length; index += 1) {
        const song = renderedSongs[index];
        const songBase = sanitizeAlbumEntryName(
          (song.filename || song.title || `track-${index + 1}`).replace(/\.[^.]+$/, ""),
          `track-${index + 1}`
        );
        const order = String(index + 1).padStart(2, "0");
        const audioName = `${order}_${song.filename || `${songBase}.mp3`}`;
        const videoName = `${order}_${songBase}.mp4`;
        const collectionProgress = 60 + Math.floor((index / Math.max(renderedSongs.length, 1)) * 14);
        setAlbumDownloadProgress(collectionProgress);
        setAlbumDownloadStage(`${song.title || songBase} 파일 수집 중…`);

        try {
          audioFolder.file(audioName, await loadAlbumSongAudioBlob(song));
        } catch (e) {
          console.warn("[Album ZIP] 오디오 수집 실패", song.title, e);
        }

        try {
          videoFolder.file(videoName, await loadAlbumSongVideoBlob(song));
        } catch (e) {
          console.warn("[Album ZIP] 비디오 수집 실패", song.title, e);
        }
      }

      setAlbumDownloadProgress(74);
      setAlbumDownloadStage("zip 압축 중…");
      const blob = await zip.generateAsync(
        {
          type: "blob",
          compression: "DEFLATE",
          compressionOptions: { level: 6 },
        },
        (metadata) => {
          const current = 74 + Math.round((metadata.percent || 0) * 0.25);
          setAlbumDownloadProgress(Math.min(99, current));
        }
      );

      const filename = `${albumBase}.zip`;
      triggerAlbumBlobDownload(blob, filename);
      setAlbumDownloadProgress(100);
      setAlbumDownloadStage("다운로드 시작…");
      window.toast(`${filename} 다운로드 시작`);
    } catch (e) {
      console.error("[Album ZIP] 생성 실패:", e);
      window.toast("앨범 zip 생성 실패: " + (e.message || e));
    } finally {
      setAlbumDownloadBusy(false);
      setTimeout(() => {
        setAlbumDownloadProgress(0);
        setAlbumDownloadStage("");
      }, 800);
    }
  };

  const downloadSongSnapshots = async () => {
    try {
      window.toast("개발 중 Song ZIP 준비 중…");
      const filename = await downloadAlbumSongSnapshotsZip(album);
      window.toast(`${filename} 다운로드 시작`);
    } catch (error) {
      console.error("[Album Song ZIP] 생성 실패:", error);
      window.toast("Song ZIP 생성 실패: " + (error?.message || error));
      throw error;
    }
  };

  const downloadSongBackup = async (songId) => {
    const song = album.songs.find((item) => item.id === songId);
    if (!song || songBackupBusyId) return;
    setSongBackupBusyId(songId);
    try {
      window.toast(`"${song.title || song.filename || "Song"}" 백업 ZIP 준비 중…`);
      const { filename, manifest } = await downloadSongBackupZip({ song, album });
      const missingCount = manifest?.missing?.length || 0;
      window.toast(`${filename} 다운로드 시작${missingCount ? ` · 누락 ${missingCount}개 기록` : ""}`);
    } catch (error) {
      console.error("[Song Backup] 생성 실패:", error);
      window.toast("Song 백업 ZIP 생성 실패: " + (error?.message || error));
      throw error;
    } finally {
      setSongBackupBusyId(null);
    }
  };

  const uploadSongBackup = async (file) => {
    if (!file || songBackupUploadBusy) return;
    setSongBackupUploadBusy(true);
    try {
      window.toast("Song 백업 ZIP 복원 중…");
      const restoredSong = await restoreSongBackupZip(file, makeId);
      setAlbum((prev) => ({
        ...prev,
        songs: [...prev.songs, restoredSong],
      }));
      setCurrentSongId(restoredSong.id);
      setView("hub");
      window.toast(`"${restoredSong.title || "복원한 Song"}" 복원 완료`);
    } catch (error) {
      console.error("[Song Backup] 복원 실패:", error);
      window.toast("Song 백업 ZIP 복원 실패: " + (error?.message || error));
    } finally {
      setSongBackupUploadBusy(false);
    }
  };

  const uploadAlbumZip = async (file) => {
    if (!file || albumUploadBusy) return;
    setAlbumUploadBusy(true);
    try {
      window.toast("앨범 ZIP 업로드 중…");
      const uploadedAlbum = await importAlbumSongSnapshotsZip(file, makeId);
      setAlbums(prev => [...prev, uploadedAlbum]);
      setCurrentAlbumId(uploadedAlbum.id);
      setCurrentSongId(null);
      setView("hub");
      window.toast(`"${uploadedAlbum.title}" 앨범 업로드 완료 · ${uploadedAlbum.songs.length}곡`);
    } catch (error) {
      console.error("[Album Upload] 실패:", error);
      window.toast("앨범 ZIP 업로드 실패: " + (error?.message || error));
    } finally {
      setAlbumUploadBusy(false);
    }
  };

  const breadcrumb = () => {
    if (view === "hub") return [{label: album.title, current: true}];
    if (view === "viewer") return [
      { label: album.title, onClick: () => setView("hub") },
      { label: "플레이어", current: true },
    ];
    const s = album.songs.find(x => x.id === currentSongId);
    return [
      { label: album.title, onClick: () => setView("hub") },
      { label: s?.title || "노래", current: true },
    ];
  };

  if (!album) return null;

  return (
    <div className={`app-root ${sidebarCollapsed ? "sidebar-collapsed" : ""}`}>
      {/* Sidebar */}
      <div className="sidebar">
        <div className="sidebar-brand">
          <div className="logo"/>
          <div className="sidebar-brand-copy">
            <div className="sidebar-brand-name">Songfilm</div>
            <div className="sidebar-brand-sub">studio · v0.3</div>
          </div>
          <button
            className="sidebar-toggle"
            onClick={() => setSidebarCollapsed(prev => !prev)}
            title={sidebarCollapsed ? "사이드바 펼치기" : "사이드바 접기"}
            aria-label={sidebarCollapsed ? "사이드바 펼치기" : "사이드바 접기"}
            aria-expanded={!sidebarCollapsed}
          >
            {sidebarCollapsed ? <Icon.ArrowRight size={14}/> : <Icon.ArrowLeft size={14}/>}
          </button>
        </div>

        <div className="nav-section">뷰</div>
        <div
          className={`nav-item ${view === "hub" ? "active" : ""}`}
          onClick={() => setView("hub")}
          data-tooltip="앨범 홈"
        >
          <Icon.Home size={14}/> <span>앨범 홈</span>
        </div>
        <div
          className={`nav-item ${view === "viewer" ? "active" : ""}`}
          onClick={() => openViewer()}
          data-tooltip="앨범 플레이어"
        >
          <Icon.Player size={14}/> <span>앨범 플레이어</span>
        </div>

        <div className="nav-section">라이브러리 ({albums.length})</div>
        {albums.map(a => {
          const isCurrent = a.id === album.id;
          return (
            <React.Fragment key={a.id}>
              <div
                className={`nav-item library-album-item ${albums.length > 1 ? "can-delete" : ""} ${isCurrent && view === "hub" ? "active" : ""}`}
                onClick={() => switchAlbum(a.id)}
                style={{fontSize: 13, fontWeight: isCurrent ? 600 : 500}}
                data-tooltip={a.title}
              >
                <Icon.Album size={13}/>
                <span style={{overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", flex: 1, color: isCurrent ? "var(--ink)" : "var(--ink-2)"}}>{a.title}</span>
                <span className="library-album-action-slot">
                  <span className="library-album-count">{a.songs.length}</span>
                  {albums.length > 1 && (
                    <button
                      className="library-album-delete"
                      onClick={(e) => { e.stopPropagation(); deleteAlbum(a.id); }}
                      title="앨범 삭제"
                      aria-label={`${a.title} 앨범 삭제`}
                    >
                      <Icon.Trash size={12}/>
                    </button>
                  )}
                </span>
              </div>

              {/* Songs nested under the currently active album */}
              {isCurrent && (
                <div style={{marginLeft: 14, paddingLeft: 10, borderLeft: "1px solid var(--line)"}}>
                  {a.songs.length === 0 && (
                    <div style={{padding:"6px 10px", fontSize: 11.5, color:"var(--ink-4)", fontStyle:"italic"}}>
                      노래 없음
                    </div>
                  )}
                  {a.songs.map(s => (
                    <div key={s.id}
                      className={`nav-item ${view === "creator" && currentSongId === s.id ? "active" : ""}`}
                      onClick={() => openSongFromAlbum(s)}
                      style={{fontSize: 12}}
                    >
                      <span className="dot" style={{background: s.status === "ready" ? "oklch(72% 0.18 145)" : s.status === "draft" ? "var(--accent-3)" : "var(--ink-4)"}}/>
                      <span style={{overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>{s.title}</span>
                    </div>
                  ))}
                  <div className="nav-item" onClick={newSong} style={{color:"var(--ink-3)", fontSize: 12}}>
                    <Icon.Plus size={11}/> 새 노래
                  </div>
                </div>
              )}
            </React.Fragment>
          );
        })}
        <div className="nav-item" onClick={() => setNewAlbumModal(true)} style={{color:"var(--ink-3)", marginTop: 6}}>
          <Icon.Plus size={12}/> <span>새 앨범</span>
        </div>
        <div
          className={`nav-item ${albumUploadBusy ? "disabled" : ""}`}
          onClick={() => !albumUploadBusy && albumUploadInputRef.current?.click()}
          style={{color:"var(--ink-3)"}}
        >
          <Icon.Upload size={12}/> <span>{albumUploadBusy ? "앨범 업로드 중…" : "앨범 ZIP 업로드"}</span>
        </div>
        <input
          ref={albumUploadInputRef}
          type="file"
          accept=".zip,application/zip,application/x-zip-compressed"
          hidden
          onChange={(event) => {
            const file = event.target.files?.[0];
            event.target.value = "";
            if (file) uploadAlbumZip(file);
          }}
        />

        <div className="sidebar-spacer"/>
        <div style={{padding:"10px 10px 6px", fontSize: 11, color:"var(--ink-4)", lineHeight:1.5, fontFamily:"var(--font-mono)"}}>
          prototype · mock ASR,<br/>images, render
        </div>
      </div>

      {/* Main */}
      <div className="main">
        <div className="topbar">
          <div className="breadcrumb">
            {breadcrumb().map((b, i, arr) => (
              <React.Fragment key={i}>
                <span className={b.current ? "current" : ""} onClick={b.onClick} style={{cursor: b.onClick ? "pointer":"default"}}>
                  {b.label}
                </span>
                {i < arr.length - 1 && <span className="sep">/</span>}
              </React.Fragment>
            ))}
          </div>
          <div className="topbar-right">
            <div style={{fontSize: 11.5, color:"var(--ink-4)", fontFamily:"var(--font-mono)"}}>
              {album.songs.length} 곡 · {album.songs.filter(s=>s.status==="ready").length} 완성
            </div>
            <button className="pill-btn" onClick={() => setNewAlbumModal(true)}>
              <Icon.Plus size={11}/> 새 앨범
            </button>
            {view !== "viewer" && album.songs.filter(s=>s.status==="ready").length > 0 && (
              <button className="pill-btn accent" onClick={() => openViewer()}>
                <Icon.Play size={11}/> 앨범 재생
              </button>
            )}
          </div>
        </div>

        <div className="view">
          {view === "hub" && (
            <AlbumHub
              album={album} setAlbum={setAlbum}
              onOpenSong={openSong}
              onNewSong={newSong}
              onOpenViewer={openViewer}
              onBatchRenderVideos={openBatchRenderDialog}
              onDownloadAlbum={downloadAlbum}
              onDownloadSongSnapshots={downloadSongSnapshots}
              onDownloadSongBackup={downloadSongBackup}
              onUploadSongBackup={uploadSongBackup}
              songBackupBusyId={songBackupBusyId}
              songBackupUploadBusy={songBackupUploadBusy}
              batchRenderRunning={batchRenderState.running}
              albumDownloadBusy={albumDownloadBusy}
              albumDownloadProgress={albumDownloadProgress}
              albumDownloadStage={albumDownloadStage}
            />
          )}
          {(view === "creator" || view === "lyric-editor") && currentSongId && (
            <Creator
              album={album} setAlbum={setAlbum}
              songId={currentSongId}
              onBack={() => setView("hub")}
              onExit={() => setView("hub")}
              onOpenLyricEditor={() => openLyricEditor(currentSongId)}
            />
          )}
          {view === "viewer" && (
            <Viewer
              album={album}
              initialSongId={viewerSongId}
              onExit={() => setView("hub")}
              onDownloadAlbum={downloadAlbum}
            />
          )}
        </div>
      </div>

      {tweaksOn && <TweaksPanel tweaks={tweaks} setTweaks={setTweaks}/>}
      {newAlbumModal && <NewAlbumModal onCreate={createAlbum} onClose={() => setNewAlbumModal(false)}/>}
      {batchRenderState.open && (
        <BatchRenderModal
          state={batchRenderState}
          onStart={startBatchRenderVideos}
          onStop={requestStopBatchRender}
          onClose={closeBatchRenderDialog}
          onToggleSong={toggleBatchRenderSong}
          onSelectAll={selectAllBatchRenderSongs}
          onClearSelection={clearBatchRenderSongs}
          onForceRerenderChange={setBatchRenderForceRerender}
        />
      )}
      <ToastHost/>
      {view === "lyric-editor" && currentSongId && (() => {
        const { LyricEditor } = window;
        const song = album.songs.find(s => s.id === currentSongId);
        if (!song || !LyricEditor) return null;
        return (
          <LyricEditor
            song={song}
            onSave={(updatedSong) => {
              setAlbum(prev => ({
                ...prev,
                songs: prev.songs.map(s => s.id === updatedSong.id ? updatedSong : s),
              }));
              setView("creator");
            }}
            onExit={() => setView("creator")}
          />
        );
      })()}
    </div>
  );
}

function BatchRenderModal({
  state,
  onStart,
  onStop,
  onClose,
  onToggleSong,
  onSelectAll,
  onClearSelection,
  onForceRerenderChange,
}) {
  const { Icon } = window;
  const statusLabel = {
    queued: "대기",
    excluded: "제외",
    skipped: "건너뜀",
    checking: "확인",
    pending: "대기",
    uploading: "오디오",
    bundling: "번들링",
    rendering: "렌더링",
    "uploading-video": "업로드",
    done: "완료",
    error: "실패",
    stopped: "중지",
  };
  const doneCount = state.items.filter((item) => item.status === "done" || item.status === "skipped").length;
  const errorCount = state.items.filter((item) => item.status === "error").length;
  const isFinished = !state.running && ["done", "stopped", "error"].includes(state.status);
  const isConfig = !state.running && state.status === "config";
  const selectedCount = (state.selectedSongIds || []).length;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal batch-render-modal" onClick={e => e.stopPropagation()}>
        <div className="batch-render-head">
          <div>
            <h3>배치 비디오 렌더링</h3>
            <p>
              {isConfig
                ? `완성곡 ${state.total}곡 중 다시 렌더링할 곡을 선택하세요.`
                : `선택한 ${state.total}곡을 앨범 순서대로 렌더링합니다. 중지해도 이미 시작된 서버 렌더는 계속될 수 있습니다.`}
            </p>
          </div>
          <div className={`batch-render-status ${state.status}`}>
            {state.running ? "실행 중" : state.status === "config" ? "설정" : state.status === "done" ? "완료" : state.status === "stopped" ? "중지됨" : state.status === "error" ? "오류" : "대기"}
          </div>
        </div>

        {isConfig && (
          <div className="batch-render-options">
            <label className="batch-render-option">
              <input
                type="checkbox"
                checked={state.forceRerender}
                onChange={(event) => onForceRerenderChange(event.target.checked)}
              />
              <span>
                <strong>모두 처음부터 다시 렌더링</strong>
                <em>기존 MP4가 있어도 선택한 곡은 새 `/render` 작업을 시작합니다.</em>
              </span>
            </label>
            <div className="batch-render-select-actions">
              <button className="pill-btn sm" onClick={onSelectAll}>전체 선택</button>
              <button className="pill-btn sm" onClick={onClearSelection}>전체 제외</button>
              <span className="mono">{selectedCount}/{state.items.length}곡 선택</span>
            </div>
          </div>
        )}

        <div className="batch-render-summary">
          <div>
            <strong>{Math.round(state.progress || 0)}%</strong>
            <span>전체 진행률</span>
          </div>
          <div>
            <strong>{isConfig ? selectedCount : `${doneCount}/${state.total}`}</strong>
            <span>{isConfig ? "선택됨" : "완료/건너뜀"}</span>
          </div>
          <div>
            <strong>{errorCount}</strong>
            <span>실패</span>
          </div>
        </div>

        <div className="progress-track batch-render-progress">
          <div className="bar" style={{width:`${Math.min(100, Math.max(0, state.progress || 0))}%`}}/>
        </div>
        <div className="batch-render-stage mono">
          {state.stopping ? "중지 요청됨 · 다음 곡은 시작하지 않습니다." : state.stage || "작업 준비 중…"}
        </div>

        <div className="batch-render-list">
          {state.items.map((item, index) => (
            <div key={item.songId} className={`batch-render-row ${item.status} ${state.currentSongId === item.songId ? "active" : ""}`}>
              <div className="batch-render-index mono">
                {isConfig ? (
                  <input
                    className="batch-render-check"
                    type="checkbox"
                    checked={!!item.selected}
                    onChange={() => onToggleSong(item.songId)}
                    onClick={(event) => event.stopPropagation()}
                  />
                ) : (
                  String(index + 1).padStart(2, "0")
                )}
              </div>
              <div className="batch-render-title">
                <span>{item.title}</span>
                <em>{item.message || statusLabel[item.status] || item.status}</em>
              </div>
              <div className="batch-render-mini">
                <div style={{width: `${Math.min(100, Math.max(0, item.progress || 0))}%`}}/>
              </div>
              <span className={`batch-render-badge ${item.status}`}>
                {statusLabel[item.status] || item.status}
              </span>
            </div>
          ))}
        </div>

        {state.error && (
          <div className="batch-render-error">
            {state.error}
          </div>
        )}

        <div className="modal-actions">
          {isConfig ? (
            <>
              <button className="pill-btn" onClick={onClose}>취소</button>
              <button className="pill-btn primary" onClick={onStart} disabled={selectedCount === 0}>
                <Icon.Film size={13}/> {state.forceRerender ? "선택한 곡 처음부터 다시 렌더링" : "선택한 곡 렌더링"}
              </button>
            </>
          ) : state.running ? (
            <button className="pill-btn danger" onClick={onStop} disabled={state.stopping}>
              {state.stopping ? <><Icon.Loader size={13}/> 중지 중…</> : <><Icon.X size={12}/> 중지</>}
            </button>
          ) : (
            <button className="pill-btn primary" onClick={onClose}>
              {isFinished ? "닫기" : "확인"}
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

function NewAlbumModal({ onCreate, onClose }) {
  const { Icon } = window;
  const [title, setTitle] = useStateApp("");
  const [artist, setArtist] = useStateApp("");
  const [year, setYear] = useStateApp(new Date().getFullYear());

  useEffectApp(() => {
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
      if (e.key === "Enter" && title.trim()) onCreate({ title, artist, year: +year });
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [title, artist, year]);

  const submit = () => {
    if (!title.trim()) { window.toast("앨범 제목을 입력해주세요"); return; }
    onCreate({ title, artist, year: +year });
  };

  const inputStyle = {
    width:"100%", background:"var(--bg-2)", border:"1px solid var(--line)",
    borderRadius: 8, padding:"10px 12px", fontSize: 14, outline:"none", marginBottom: 10,
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{width: 440}}>
        <h3>새 앨범 만들기</h3>
        <p>앨범 정보를 입력하세요. 나중에 앨범 홈에서 제목을 수정할 수 있습니다.</p>

        <label style={{fontSize: 11, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:"0.06em", fontWeight:600, display:"block", marginBottom: 4}}>
          앨범 제목
        </label>
        <input
          autoFocus
          placeholder="예: 밤의 편지"
          value={title}
          onChange={e => setTitle(e.target.value)}
          style={inputStyle}
        />

        <label style={{fontSize: 11, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:"0.06em", fontWeight:600, display:"block", marginBottom: 4}}>
          아티스트
        </label>
        <input
          placeholder="예: 윤하린"
          value={artist}
          onChange={e => setArtist(e.target.value)}
          style={inputStyle}
        />

        <label style={{fontSize: 11, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:"0.06em", fontWeight:600, display:"block", marginBottom: 4}}>
          연도
        </label>
        <input
          type="number"
          value={year}
          onChange={e => setYear(e.target.value)}
          style={{...inputStyle, fontFamily:"var(--font-mono)"}}
        />

        <div className="modal-actions">
          <button className="pill-btn" onClick={onClose}>취소</button>
          <button className="pill-btn primary" onClick={submit}>
            <Icon.Plus size={12}/> 앨범 생성
          </button>
        </div>
      </div>
    </div>
  );
}

function DownloadModal({ album, setAlbum, onClose }) {
  const { Icon } = window;
  const ready = album.songs.filter(s => s.status === "ready");
  const [bundling, setBundling] = useStateApp(false);
  const [progress, setProgress] = useStateApp(0);
  const [stage, setStage] = useStateApp("");
  const [errorMsg, setErrorMsg] = useStateApp("");

  const patchSongExportState = (songId, patch) => {
    setAlbum((prevAlbum) => ({
      ...prevAlbum,
      songs: prevAlbum.songs.map((song) => (
        song.id !== songId
          ? song
          : {
              ...song,
              exportState: {
                ...(song.exportState || {}),
                ...patch,
              },
            }
      )),
    }));
  };

  const ensureRenderedVideo = async (song, songIndex) => {
    const existingVideoFilename = String(song?.exportState?.videoFilename || "").trim();
    const existingJobId = String(song?.exportState?.jobId || "").trim();
    const existingDone = song?.exportState?.status === "done" || !!existingVideoFilename;
    const segmentStart = 8 + Math.floor((songIndex / Math.max(ready.length, 1)) * 52);
    const segmentSize = Math.max(8, Math.floor(52 / Math.max(ready.length, 1)));

    if (existingDone && (existingVideoFilename || existingJobId)) {
      return {
        ...song,
        exportState: {
          ...(song.exportState || {}),
          status: "done",
          done: true,
          progress: 100,
        },
      };
    }

    let audioFilename = getAlbumStorageAudioFilename(song);
    if (!audioFilename) {
      setStage(`${song.title} 오디오 업로드 중…`);
      const audioBlob = await loadAlbumSongAudioBlob(song);
      const audioForm = new FormData();
      audioForm.append("file", audioBlob, song.filename || `${song.title}.mp3`);
      const audioResp = await fetch(`${ALBUM_STORAGE_BASE}/audio/save`, { method: "POST", body: audioForm });
      if (!audioResp.ok) {
        throw new Error(`${song.title} 오디오 업로드 실패 (${audioResp.status})`);
      }
      const audioData = await audioResp.json();
      audioFilename = String(audioData?.filename || "").trim();
      patchSongExportState(song.id, { audioFilename });
    }

    setStage("렌더링 서버 확인 중…");
    const healthResp = await fetch(`${ALBUM_RENDERER_BASE}/health`);
    if (!healthResp.ok) {
      throw new Error(`렌더링 서버 연결 실패 (${healthResp.status})`);
    }

    const songData = {
      title: song.title,
      filename: song.filename || "audio.mp3",
      duration: song.duration,
      paletteName: song.paletteName || "midnight-violet",
      previewSettings: song.previewSettings || {},
      scenes: song.scenes || [],
        sceneGlobal: song.sceneGlobal || {},
      lyricTimeline: song.lyricTimeline || null,
    };

    setStage(`${song.title} 비디오 렌더링 시작…`);
    const startResp = await fetch(`${ALBUM_RENDERER_BASE}/render`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ songData, audioFilename }),
    });
    if (!startResp.ok) {
      let errMsg = `${song.title} 렌더 시작 실패 (${startResp.status})`;
      try {
        const errBody = await startResp.json();
        errMsg = errBody?.error || errBody?.message || errMsg;
      } catch {}
      throw new Error(errMsg);
    }

    const { jobId } = await startResp.json();
    patchSongExportState(song.id, {
      status: "bundling",
      done: false,
      progress: 0,
      error: "",
      audioFilename,
      jobId,
    });

    for (;;) {
      await waitAlbumMs(1200);
      const statusResp = await fetch(`${ALBUM_RENDERER_BASE}/render/${jobId}/status`);
      if (!statusResp.ok) {
        throw new Error(`${song.title} 렌더 상태 조회 실패 (${statusResp.status})`);
      }
      const statusData = await statusResp.json();
      const status = String(statusData?.status || "pending");
      const renderProgress = Math.round(Number(statusData?.progress || 0) * 100);
      const overallProgress = segmentStart + Math.round((Math.min(100, renderProgress) / 100) * segmentSize);
      setProgress(Math.min(66, overallProgress));
      setStage(`${song.title} ${status === "bundling" ? "번들링" : status === "uploading" ? "업로드" : "렌더링"} 중… ${Math.min(100, renderProgress)}%`);
      patchSongExportState(song.id, {
        status,
        done: status === "done",
        progress: renderProgress,
        error: statusData?.error || "",
        audioFilename,
        jobId,
        videoFilename: statusData?.videoFilename || "",
      });

      if (status === "done") {
        return {
          ...song,
          exportState: {
            ...(song.exportState || {}),
            status: "done",
            done: true,
            progress: 100,
            audioFilename,
            jobId,
            videoFilename: statusData?.videoFilename || "",
          },
        };
      }
      if (status === "error") {
        throw new Error(statusData?.error || `${song.title} 렌더링 실패`);
      }
    }
  };

  const start = async () => {
    if (!ready.length) return;
    setBundling(true);
    setProgress(0);
    setStage("JSZip 로드 중…");
    setErrorMsg("");

    try {
      const JSZip = await loadAlbumJSZip();
      const zip = new JSZip();
      const albumBase = sanitizeAlbumEntryName(album.title || "album", "album");
      const audioFolder = zip.folder("Audio");
      const videoFolder = zip.folder("Video");
      const renderedSongs = [];

      setProgress(8);
      setStage("곡 비디오 준비 중…");

      for (let index = 0; index < ready.length; index += 1) {
        renderedSongs.push(await ensureRenderedVideo(ready[index], index));
      }

      setProgress(60);
      setStage("곡 파일 수집 중…");

      for (let index = 0; index < renderedSongs.length; index += 1) {
        const song = renderedSongs[index];
        const songBase = sanitizeAlbumEntryName(
          (song.filename || song.title || `track-${index + 1}`).replace(/\.[^.]+$/, ""),
          `track-${index + 1}`
        );
        const order = String(index + 1).padStart(2, "0");
        const audioName = `${order}_${song.filename || `${songBase}.mp3`}`;
        const videoName = `${order}_${songBase}.mp4`;
        const segmentStart = 60 + Math.floor((index / Math.max(renderedSongs.length, 1)) * 6);
        setProgress(segmentStart);
        setStage(`${song.title || songBase} 파일 수집 중…`);

        try {
          audioFolder.file(audioName, await loadAlbumSongAudioBlob(song));
        } catch (e) {
          console.warn("[Album ZIP] 오디오 수집 실패", song.title, e);
        }

        try {
          videoFolder.file(videoName, await loadAlbumSongVideoBlob(song));
        } catch (e) {
          console.warn("[Album ZIP] 비디오 수집 실패", song.title, e);
        }
      }

      setProgress(74);
      setStage("zip 압축 중…");
      const blob = await zip.generateAsync(
        {
          type: "blob",
          compression: "DEFLATE",
          compressionOptions: { level: 6 },
        },
        (metadata) => {
          const current = 74 + Math.round((metadata.percent || 0) * 0.25);
          setProgress(Math.min(99, current));
        }
      );

      const filename = `${albumBase}.zip`;
      triggerAlbumBlobDownload(blob, filename);
      setProgress(100);
      setStage("다운로드 시작…");
      window.toast("앨범 zip 다운로드 시작");
      onClose();
    } catch (e) {
      console.error("[Album ZIP] 생성 실패:", e);
      setErrorMsg(e.message || String(e));
      window.toast("앨범 zip 생성 실패: " + (e.message || e));
    } finally {
      setBundling(false);
    }
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{width: 520}}>
        <h3>앨범 다운로드</h3>
        <p>{album.title} · {ready.length}곡의 mp3와 Remotion 비디오를 바로 zip으로 묶어 다운로드합니다.</p>

        <div className="file-list">
          <div className="file-row"><Icon.Album size={14}/><span>{album.title}.zip — 루트</span><span className="sz">~{(ready.length*14).toFixed(0)} MB</span></div>
          <div className="file-row" style={{paddingLeft: 24}}>
            <Icon.Music size={12}/><span>Audio/</span><span className="sz">{ready.length} files</span>
          </div>
          <div className="file-row" style={{paddingLeft: 24}}>
            <Icon.Film size={12}/><span>Video/</span><span className="sz">{ready.length} files</span>
          </div>
          {ready.map((s, i) => (
            <React.Fragment key={s.id}>
              <div className="file-row" style={{paddingLeft: 40}}>
                <Icon.Music size={12}/><span>Audio/{(i+1).toString().padStart(2,"0")}_{s.filename || `${s.title}.mp3`}</span><span className="sz">{s.size}</span>
              </div>
              <div className="file-row" style={{paddingLeft: 40}}>
                <Icon.Film size={12}/><span>Video/{(i+1).toString().padStart(2,"0")}_{(s.filename || s.title).replace(/\.[^.]+$/, "")}.mp4</span><span className="sz">~12 MB</span>
              </div>
            </React.Fragment>
          ))}
        </div>

        {bundling && (
          <div style={{marginTop: 16}}>
            <div className="progress-track"><div className="bar" style={{width:`${progress}%`}}/></div>
            <div style={{fontSize: 11, color:"var(--ink-3)", fontFamily:"var(--font-mono)", marginTop: 6}}>
              {stage || "작업 준비 중…"}
            </div>
          </div>
        )}

        {errorMsg && (
          <div style={{marginTop: 16, color:"oklch(70% 0.17 24)", fontSize: 12}}>
            실패: {errorMsg}
          </div>
        )}

        <div className="modal-actions">
          <button className="pill-btn" onClick={onClose}>닫기</button>
          <button className="pill-btn primary" onClick={start} disabled={bundling}>
            {bundling ? <><Icon.Loader size={13}/> ZIP 준비 중…</> : <><Icon.Download/> 다운로드</>}
          </button>
        </div>
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
