// /Users/mdamba/dev/candrm/hotels_video/scripts/web/app.jsx
// ホテル動画ジェネレータ — 本番 UI (React + Babel inline)
// docs/ver1 の 2 ペイン構成を採用しつつ、すべて実 API に接続している。
//   POST /api/scripts            … LLM で台本を初期生成
//   POST /api/narration          … 1 文ぶんのナレーション合成 (WAV)
//   POST /api/narration/rewrite  … AI による書き直し
//   POST /api/jobs               … 動画レンダリングジョブ作成
//   GET  /api/jobs/{id}          … ジョブ進捗ポーリング
//   GET  /api/jobs/{id}/result   … 完成動画 (mp4)
//
// 共通デザイントークンは ./tokens.css と ./components.css に従う。

const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ============================================================
// ログイン画面 (docs/ver1_login から移植)
// セッションは sessionStorage で保持する。
// ============================================================

// ---------- ログイン: アイコン ----------
const LoginIcon = {
  Mail: () => (
    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
      <rect x="2" y="4" width="12" height="9" rx="1.5" />
      <path d="M2.5 5 L8 9 L13.5 5" />
    </svg>
  ),
  Arrow: () => (
    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
      <path d="M3 8 H13 M9 4 L13 8 L9 12" />
    </svg>
  ),
  Back: () => (
    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
      <path d="M13 8 H3 M7 4 L3 8 L7 12" />
    </svg>
  ),
  Check: () => (
    <svg width="28" height="28" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
      <path d="M3 8 L7 12 L13 4" />
    </svg>
  ),
  Alert: () => (
    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
      <circle cx="8" cy="8" r="6" />
      <path d="M8 5 V9" />
      <circle cx="8" cy="11" r="0.5" fill="currentColor" stroke="none" />
    </svg>
  ),
};

// ---------- ログイン: ロゴ ----------
function LoginBrandLogo() {
  return (
    <div className="auth-head">
      <div className="logo">H</div>
      <div className="brand">ホテル動画ジェネレータ</div>
      <span className="spacer" />
      <div className="help">
        困っていますか? <a href="#" onClick={(e) => e.preventDefault()}>ヘルプ</a>
      </div>
    </div>
  );
}

// ---------- ログイン: メール入力ステップ ----------
function LoginStepEmail({ initialEmail, onSend, isLoading }) {
  const [email, setEmail] = useState(initialEmail || "");
  const [agree, setAgree] = useState(true);
  const [touched, setTouched] = useState(false);

  const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  const showError = touched && !valid && email.length > 0;

  const submit = (e) => {
    e.preventDefault();
    setTouched(true);
    if (!valid || !agree) return;
    onSend(email);
  };

  return (
    <form className="auth-form" onSubmit={submit}>
      <div className="step-row">
        <span>STEP 1 / 2</span>
        <span className="pip current"><span className="fill" /></span>
        <span className="pip"><span className="fill" /></span>
      </div>

      <h1>ログイン</h1>
      <p className="lead">
        メールアドレスに 6 桁の認証コードをお送りします。<br />
        ご登録がない場合は自動でアカウントが作成されます。
      </p>

      <div className={"auth-field" + (showError ? " error" : "")}>
        <label htmlFor="login-email">メールアドレス</label>
        <input
          id="login-email"
          type="email"
          autoComplete="email"
          inputMode="email"
          placeholder="you@example.com"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          onBlur={() => setTouched(true)}
          autoFocus
          disabled={isLoading}
        />
        {showError && (
          <span className="err-msg">
            <LoginIcon.Alert />
            正しいメールアドレスを入力してください
          </span>
        )}
      </div>

      <label className="checkbox-row">
        <input
          type="checkbox"
          checked={agree}
          onChange={(e) => setAgree(e.target.checked)}
        />
        <span>
          <a href="#" onClick={(e) => e.preventDefault()}>利用規約</a> および{" "}
          <a href="#" onClick={(e) => e.preventDefault()}>プライバシーポリシー</a>{" "}
          に同意します
        </span>
      </label>

      <button
        type="submit"
        className="auth-cta"
        disabled={!valid || !agree || isLoading}
      >
        {isLoading ? (
          <>
            <span className="spin" />
            送信中...
          </>
        ) : (
          <>
            認証コードを送信
            <LoginIcon.Arrow />
          </>
        )}
      </button>
    </form>
  );
}

// ---------- ログイン: 認証コードステップ ----------
function LoginStepCode({ email, expectedCode, onVerify, onBack, onResend, isLoading }) {
  const [digits, setDigits] = useState(["", "", "", "", "", ""]);
  const [error, setError] = useState(null);
  const [resendTimer, setResendTimer] = useState(45);
  const inputsRef = useRef([]);

  useEffect(() => {
    if (resendTimer <= 0) return;
    const id = setTimeout(() => setResendTimer((s) => s - 1), 1000);
    return () => clearTimeout(id);
  }, [resendTimer]);

  useEffect(() => { inputsRef.current[0]?.focus(); }, []);

  const setDigit = (i, v) => {
    setError(null);
    setDigits((prev) => { const next = [...prev]; next[i] = v; return next; });
  };

  const handleChange = (i, e) => {
    const v = e.target.value.replace(/\D/g, "").slice(-1);
    setDigit(i, v);
    if (v && i < 5) inputsRef.current[i + 1]?.focus();
    if (i === 5 && v) {
      const all = [...digits.slice(0, 5), v].join("");
      if (all.length === 6) setTimeout(() => verifyCode(all), 120);
    }
  };

  const handleKeyDown = (i, e) => {
    if (e.key === "Backspace") {
      if (digits[i]) { setDigit(i, ""); }
      else if (i > 0) { inputsRef.current[i - 1]?.focus(); setDigit(i - 1, ""); }
    } else if (e.key === "ArrowLeft" && i > 0) {
      inputsRef.current[i - 1]?.focus();
    } else if (e.key === "ArrowRight" && i < 5) {
      inputsRef.current[i + 1]?.focus();
    } else if (e.key === "Enter") {
      const code = digits.join("");
      if (code.length === 6) verifyCode(code);
    }
  };

  const handlePaste = (e) => {
    const text = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
    if (!text) return;
    e.preventDefault();
    const next = [...digits];
    for (let i = 0; i < 6; i++) next[i] = text[i] || "";
    setDigits(next);
    setError(null);
    inputsRef.current[Math.min(text.length, 5)]?.focus();
    if (text.length === 6) setTimeout(() => verifyCode(text), 120);
  };

  // 検証は完全にサーバ側で行う。expectedCode は廃止 (古いモック互換のため引数だけ残す)。
  const verifyCode = async (code) => {
    setError(null);
    let ok = false;
    try {
      const r = onVerify(code);
      ok = r && typeof r.then === "function" ? await r : Boolean(r);
    } catch (_) {
      ok = false;
    }
    if (!ok) {
      setError("認証コードが一致しません。もう一度ご確認ください。");
      setTimeout(() => {
        inputsRef.current[0]?.focus();
        inputsRef.current[0]?.select?.();
      }, 350);
    }
  };

  const code = digits.join("");
  const canSubmit = code.length === 6 && !isLoading;

  return (
    <form
      className="auth-form"
      onSubmit={(e) => { e.preventDefault(); if (canSubmit) verifyCode(code); }}
    >
      <div className="step-row">
        <span>STEP 2 / 2</span>
        <span className="pip done"><span className="fill" /></span>
        <span className="pip current"><span className="fill" /></span>
      </div>

      <h1>認証コードを入力</h1>
      <p className="lead">
        メールに 6 桁のコードをお送りしました。<br />
        受信トレイにない場合は迷惑メールフォルダもご確認ください。
      </p>

      <div className="otp-context">
        <span style={{ color: "var(--muted)", display: "inline-flex", alignItems: "center", gap: 6 }}>
          <LoginIcon.Mail />送信先
        </span>
        <span className="email-text">{email}</span>
        <button type="button" className="change" onClick={onBack}>変更</button>
      </div>

      <div className="otp-row" onPaste={handlePaste}>
        {digits.map((d, i) => (
          <input
            key={i}
            ref={(el) => (inputsRef.current[i] = el)}
            type="text"
            inputMode="numeric"
            autoComplete={i === 0 ? "one-time-code" : "off"}
            maxLength={1}
            value={d}
            onChange={(e) => handleChange(i, e)}
            onKeyDown={(e) => handleKeyDown(i, e)}
            disabled={isLoading}
            className={"otp-cell" + (d ? " filled" : "") + (error ? " error" : "")}
          />
        ))}
      </div>

      {error && (
        <span className="err-msg" style={{ marginTop: -4 }}>
          <LoginIcon.Alert />{error}
        </span>
      )}

      <button type="submit" className="auth-cta" disabled={!canSubmit}>
        {isLoading ? (
          <><span className="spin" />確認中...</>
        ) : (
          <>確認してログイン<LoginIcon.Arrow /></>
        )}
      </button>

      <div className="resend-row">
        <span>コードが届かない?</span>
        {resendTimer > 0 ? (
          <>
            <span className="timer">
              {String(Math.floor(resendTimer / 60)).padStart(2, "0")}:{String(resendTimer % 60).padStart(2, "0")}
            </span>
            <span style={{ color: "var(--muted-2)" }}>後に再送</span>
          </>
        ) : (
          <button type="button" onClick={() => { onResend(); setResendTimer(45); }}>
            認証コードを再送
          </button>
        )}
      </div>

      <button type="button" className="auth-back" onClick={onBack}>
        <LoginIcon.Back />
        メールアドレスを変更
      </button>
    </form>
  );
}

// ---------- ログイン: 成功ステップ ----------
function LoginStepSuccess({ email, onDone }) {
  useEffect(() => {
    const id = setTimeout(onDone, 1400);
    return () => clearTimeout(id);
  }, [onDone]);

  return (
    <div className="auth-success">
      <div className="check-circle">
        <LoginIcon.Check />
      </div>
      <h1>ログイン完了</h1>
      <p className="lead">
        <strong>{email}</strong> でログインしました。
      </p>
      <div className="loading-text">
        <span className="dot" />
        <span className="dot" />
        <span className="dot" />
        <span style={{ marginLeft: 4 }}>ダッシュボードへ移動しています</span>
      </div>
    </div>
  );
}

// ---------- ログイン: 開発用トースト (擬似メール) ----------
function LoginDevToast({ email, code, onDismiss }) {
  if (!code) return null;
  return (
    <div className="dev-toast">
      <span className="badge">DEV</span>
      <span>
        <strong style={{ color: "var(--text)" }}>{email}</strong> 宛の認証コード:
      </span>
      <span className="code">{code}</span>
      <button onClick={onDismiss} title="閉じる">×</button>
    </div>
  );
}

// ---------- ログイン: 画面全体 ----------
function LoginScreen({ onLogin }) {
  const [step, setStep] = useState("email");
  const [email, setEmail] = useState("");
  const [code, setCode] = useState(null);          // dev トースト表示用 (LOGIN_DEV_RETURN_CODE=true 時のみ)
  const [loading, setLoading] = useState(false);
  const [animKey, setAnimKey] = useState(0);
  const [error, setError] = useState("");

  // メールアドレスを受け取り → サーバに OTP 発行を依頼
  const sendCode = async (em) => {
    setEmail(em);
    setLoading(true);
    setError("");
    try {
      const fd = new FormData();
      fd.append("email", em);
      const res = await fetch("/api/login/start", { method: "POST", body: fd });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
      // 開発モード時は code が返るので DEV トーストに表示
      setCode(data.code || null);
      setStep("code");
      setAnimKey((k) => k + 1);
    } catch (e) {
      setError(e.message || "送信に失敗しました");
    } finally {
      setLoading(false);
    }
  };

  // OTP を検証 → 成功なら Cookie がセットされる
  // 戻り値の boolean を StepCode 側のシェイク制御に使う
  const verifyCode = async (entered) => {
    setLoading(true);
    setError("");
    try {
      const fd = new FormData();
      fd.append("email", email);
      fd.append("code", String(entered || ""));
      const res = await fetch("/api/login/verify", {
        method: "POST",
        body: fd,
        credentials: "same-origin",
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.detail || "コードが一致しません");
      setStep("success");
      setAnimKey((k) => k + 1);
      return true;
    } catch (e) {
      setError(e.message || "検証に失敗しました");
      return false;
    } finally {
      setLoading(false);
    }
  };

  const resendCode = async () => {
    if (!email) return;
    await sendCode(email);
  };

  const backToEmail = () => {
    setStep("email"); setCode(null);
    setError(""); setAnimKey((k) => k + 1);
  };

  const handleDone = useCallback(() => {
    onLogin(email);
  }, [email, onLogin]);

  return (
    <div className="auth-stage">
      <LoginBrandLogo />

      <div className="auth-body">
        <div className="auth-card" key={animKey} style={{ animation: "step-in 0.28s var(--ease)" }}>
          {step === "email" && (
            <LoginStepEmail initialEmail={email} onSend={sendCode} isLoading={loading} />
          )}
          {step === "code" && (
            <LoginStepCode
              email={email}
              expectedCode={code}
              onVerify={verifyCode}
              onBack={backToEmail}
              onResend={resendCode}
              isLoading={loading}
            />
          )}
          {step === "success" && <LoginStepSuccess email={email} onDone={handleDone} />}
        </div>
      </div>

      <div className="auth-foot">
        <a href="#" onClick={(e) => e.preventDefault()}>利用規約</a>
        <a href="#" onClick={(e) => e.preventDefault()}>プライバシー</a>
        <a href="#" onClick={(e) => e.preventDefault()}>セキュリティ</a>
        <span style={{ marginLeft: 8 }}>© 2026 Hotel Video Generator</span>
      </div>

      <LoginDevToast
        email={email}
        code={step === "code" ? code : null}
        onDismiss={() => setCode(null)}
      />

      <style>{`
        @keyframes step-in {
          from { opacity: 0; transform: translateY(6px); }
          to   { opacity: 1; transform: translateY(0); }
        }
      `}</style>
    </div>
  );
}

// ============================================================
// 以下、メインアプリ
// ============================================================

// ---------- 定数 ----------
const ANGLES = [
  ["foodie",  "食重視"],
  ["onsen",   "温泉重視"],
  ["couple",  "カップル向け"],
  ["family",  "家族連れ向け"],
  ["solo",    "一人旅向け"],
];
const TELOP_STYLES = [
  ["classic", "クラシック"],
  ["yellow",  "イエロー"],
  ["pink",    "ピンク"],
  ["neon",    "ネオン"],
];
const TELOP_POS = [
  ["top",    "上"],
  ["center", "中央"],
  ["bottom", "下"],
];
const SPEAKERS = [
  ["3",  "ずんだもん"],
  ["2",  "四国めたん"],
  ["8",  "春日部つむぎ"],
  ["14", "冥鳴ひまり"],
];

// 「修正の方向性」プリセット
const REWRITE_PRESETS = [
  "もっと短く",
  "もっと長く",
  "数字を入れて",
  "数字を消す",
  "若者向けに",
  "丁寧に",
  "カジュアルに",
  "感情的に",
  "落ち着いた口調で",
  "一文にまとめて",
];

// ナレーション文字数からの粗い秒数推定 (UI 表示用)
function estDuration(text) {
  const chars = (text || "").replace(/\s/g, "").length;
  return Math.max(1.5, +(chars / 6.2).toFixed(1));
}

function cx(...xs) { return xs.filter(Boolean).join(" "); }

// ---------- 素材プレビュー ----------
// File から img / video を作る。サムネ用に静止表示する。
function MaterialMedia({ material, autoplay = false, controls = false }) {
  if (!material) {
    return <div className="ph">素材なし</div>;
  }
  if (material.kind === "VIDEO") {
    return (
      <video
        src={material.url}
        muted
        playsInline
        preload="metadata"
        controls={controls}
        autoPlay={autoplay}
        onLoadedMetadata={(e) => {
          // 1 フレーム目を静止表示するため少しシークする
          if (!autoplay) {
            try { e.currentTarget.currentTime = Math.min(0.5, (e.currentTarget.duration || 1) * 0.1); }
            catch (_) {}
          }
        }}
      />
    );
  }
  return <img src={material.url} alt={material.file?.name || material.filename || ""} />;
}

// ---------- Toast ----------
function ToastStack({ toasts }) {
  return (
    <div className="toast-stack">
      {toasts.map((t) => (
        <div key={t.id} className={cx("toast", t.kind)}>
          {t.msg}
        </div>
      ))}
    </div>
  );
}

// ---------- 左ペイン: 素材アップロード ----------
function UploadZone({ onAdd, disabled, max = 10, current = 0 }) {
  const inputRef = useRef(null);
  const [dragOver, setDragOver] = useState(false);

  const handleFiles = (list) => {
    const incoming = Array.from(list).filter((f) => {
      const t = f.type || "";
      return t.startsWith("image/") || t.startsWith("video/");
    });
    if (incoming.length === 0) return;
    const remaining = max - current;
    onAdd(incoming.slice(0, remaining));
  };

  return (
    <div
      className={cx("upload-zone", dragOver && "dragover")}
      onClick={() => !disabled && inputRef.current?.click()}
      onDragEnter={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }}
      onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }}
      onDragLeave={() => setDragOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setDragOver(false);
        if (disabled) return;
        handleFiles(e.dataTransfer.files);
      }}
      style={disabled ? { opacity: 0.5, cursor: "not-allowed" } : null}
    >
      <div className="icon">📁</div>
      <div><strong>ドラッグ&ドロップ</strong> または クリックで選択</div>
      <div className="hint" style={{ marginTop: 4 }}>
        画像 (jpg/png/webp) または 動画 (mp4/mov/webm) を 1〜{max} 個
      </div>
      <input
        ref={inputRef}
        type="file"
        accept="image/*,video/*"
        multiple
        hidden
        onChange={(e) => {
          if (e.target.files) handleFiles(e.target.files);
          e.target.value = "";
        }}
      />
    </div>
  );
}

// ---------- 左ペイン: シーン行 ----------
function SceneRow({ scene, index, selected, onClick }) {
  const synthClass =
    scene.synth === "ready" ? "ready" :
    scene.synth === "synth" ? "synth" :
    scene.synth === "error" ? "error" : "idle";
  const synthLabel =
    scene.synth === "ready" ? "合成済" :
    scene.synth === "synth" ? "合成中" :
    scene.synth === "error" ? "失敗" : "未合成";

  return (
    <div
      className={cx("scene-row", selected && "selected")}
      onClick={onClick}
    >
      <div className="idx">{index + 1}</div>
      <div className="thumb">
        {scene.material && <span className="kind-tag">{scene.material.kind}</span>}
        <MaterialMedia material={scene.material} />
        {scene.telop && <div className="telop-mini">{scene.telop}</div>}
      </div>
      <div className="meta">
        <div className="telop-line">
          {scene.telop
            ? scene.telop.replace(/\n/g, " ")
            : <span style={{ color: "var(--muted)" }}>(テロップ未設定)</span>}
        </div>
        <div className="narr-line">
          {scene.narration || "(ナレーション未設定)"}
        </div>
        <div className="status-row">
          <span className={`status-dot ${synthClass}`}></span>
          <span>{synthLabel}</span>
          <span style={{ flex: 1 }}></span>
          {scene.audioDuration != null && (
            <span className="duration">{scene.audioDuration.toFixed(1)}s</span>
          )}
        </div>
      </div>
    </div>
  );
}

// ---------- 右ペイン: シーン編集 ----------
function SceneEditor({
  scene,
  sceneIndex,
  totalScenes,
  settings,
  onPatch,
  onRewrite,
  onSettingChange,
  onPrev,
  onNext,
  onPlay,
  onPause,
  playing,
  playProgress,
  revert,
}) {
  const [customPrompt, setCustomPrompt] = useState("");
  const [showCustom, setShowCustom] = useState(false);

  if (!scene) {
    return (
      <div style={{ padding: 40, color: "var(--muted)", gridColumn: "1 / -1" }}>
        左から編集するシーンを選んでください。
      </div>
    );
  }

  const telopStyle = settings.telopStyle;
  const telopPos = settings.telopPos;

  const synthTag =
    scene.synth === "ready"
      ? <span className="tag-pill ok">✓ 合成済</span>
      : scene.synth === "synth"
      ? <span className="tag-pill wait">🔄 合成中</span>
      : scene.synth === "error"
      ? <span className="tag-pill err">失敗</span>
      : <span className="tag-pill idle">未合成</span>;

  return (
    <>
      <div className="detail-head">
        <h2>シーン {sceneIndex + 1} / {totalScenes}</h2>
        <span className="scene-meta">
          素材: {scene.material?.file?.name || "—"}{" "}
          {scene.material?.kind && <span className="kbd">{scene.material.kind}</span>}
        </span>
        <span className="spacer"></span>
        <div className="nav">
          <button
            className="btn btn-icon"
            onClick={onPrev}
            disabled={sceneIndex === 0}
            title="前のシーン (←)"
          >
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75">
              <path d="M10 12 L5 8 L10 4" />
            </svg>
          </button>
          <button
            className="btn btn-icon"
            onClick={onNext}
            disabled={sceneIndex === totalScenes - 1}
            title="次のシーン (→)"
          >
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75">
              <path d="M6 4 L11 8 L6 12" />
            </svg>
          </button>
        </div>
      </div>

      <div className="preview-col">
        <div className="preview-frame">
          <div className="media-bg">
            <MaterialMedia material={scene.material} />
          </div>
          {scene.telop && (
            <div className={`telop-overlay pos-${telopPos}`}>
              <div className={`telop-text style-${telopStyle}`}>{scene.telop}</div>
            </div>
          )}
        </div>
        <div className="preview-actions">
          <button
            className="btn btn-primary btn-sm"
            onClick={playing ? onPause : onPlay}
            disabled={!scene.narration?.trim() || scene.synth === "synth"}
          >
            {playing
              ? "⏸ 一時停止"
              : scene.synth === "ready" ? "▶ 再生" : "▶ 合成して再生"}
          </button>
          <span className="hint" style={{ fontFamily: "var(--mono)" }}>
            {scene.audioDuration != null
              ? `${(playProgress * scene.audioDuration).toFixed(1)} / ${scene.audioDuration.toFixed(1)}s`
              : "—:— / —:—"}
          </span>
          <span className="spacer"></span>
          <span className="hint">
            <span className="kbd">←→</span> シーン移動
          </span>
        </div>
      </div>

      <div className="editor-col">
        {/* テロップ */}
        <div className="editor-card">
          <h3>
            テロップ
            <span className="spacer"></span>
            <span className="hint">{(scene.telop || "").length} 文字</span>
          </h3>
          <textarea
            className="telop-input"
            value={scene.telop}
            onChange={(e) => onPatch({ telop: e.target.value })}
            placeholder="例: 美肌の湯&#10;絶景の朝風呂"
            rows={2}
          />
          <div className="field-row">
            <span className="label-mini">位置</span>
            {TELOP_POS.map(([v, label]) => (
              <button
                key={v}
                className={cx("chip", telopPos === v && "active")}
                onClick={() => onSettingChange("telopPos", v)}
              >
                {label}
              </button>
            ))}
            <span className="label-mini" style={{ marginLeft: 8 }}>色</span>
            {TELOP_STYLES.map(([v, label]) => (
              <button
                key={v}
                className={cx("chip", telopStyle === v && "active")}
                onClick={() => onSettingChange("telopStyle", v)}
              >
                {label}
              </button>
            ))}
          </div>
        </div>

        {/* ナレーション */}
        <div className="editor-card">
          <h3>
            ナレーション
            {synthTag}
            <span className="spacer"></span>
            <span className="hint">
              {(scene.narration || "").length} 文字 ・ 約 {estDuration(scene.narration)}秒
            </span>
          </h3>
          <textarea
            className="narr-input"
            value={scene.narration}
            onChange={(e) => onPatch({ narration: e.target.value })}
            placeholder="ナレーション本文..."
          />
          <div className="synth-row">
            <button
              className="btn btn-soft btn-sm"
              onClick={onPlay}
              disabled={!scene.narration?.trim() || scene.synth === "synth"}
            >
              {scene.synth === "ready" ? "🔁 再合成" : "🔊 ナレーション合成・試聴"}
            </button>
            <span className="status">
              {scene.synth === "ready"
                ? "テキストを編集すると自動で未合成に戻ります"
                : "テキスト編集 → 再合成ボタンで試聴できます"}
            </span>
          </div>

          {/* AI 修正プリセット */}
          <div className="divider"></div>
          <div className="presets">
            <span className="label-mini">AI 修正:</span>
            {REWRITE_PRESETS.map((p) => (
              <button
                key={p}
                className="chip preset"
                onClick={() => onRewrite(p)}
                disabled={scene.synth === "synth"}
              >
                {p}
              </button>
            ))}
            <button
              className="chip preset"
              onClick={() => setShowCustom((v) => !v)}
            >
              ✏️ 自由入力
            </button>
          </div>
          {showCustom && (
            <div className="custom-input">
              <input
                type="text"
                placeholder="例: 海の音が聞こえる感じで / 体言止めで終わる"
                value={customPrompt}
                onChange={(e) => setCustomPrompt(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && customPrompt.trim()) {
                    onRewrite(customPrompt.trim());
                    setCustomPrompt("");
                    setShowCustom(false);
                  }
                }}
                autoFocus
              />
              <button
                className="btn btn-primary btn-sm"
                onClick={() => {
                  if (customPrompt.trim()) {
                    onRewrite(customPrompt.trim());
                    setCustomPrompt("");
                    setShowCustom(false);
                  }
                }}
              >
                修正
              </button>
            </div>
          )}

          {/* Before/After */}
          {scene.prev && (
            <div className="history">
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none"
                   stroke="currentColor" strokeWidth="1.5"
                   style={{ marginTop: 2, color: "var(--muted)", flexShrink: 0 }}>
                <path d="M3 8 H13 M3 8 L7 4 M3 8 L7 12" />
              </svg>
              <div className="prev-text">
                <span className="from-note">直前 ({scene.prev.from} 修正前):</span>
                {scene.prev.narration}
              </div>
              <button
                className="btn btn-ghost btn-sm"
                onClick={revert}
                title="この内容に戻す"
              >
                ← 戻す
              </button>
            </div>
          )}
        </div>
      </div>
    </>
  );
}

// ---------- 進捗モーダル ----------
function ProgressModal({ status, progress, phase, logs }) {
  const logEnd = useRef(null);
  useEffect(() => {
    logEnd.current?.scrollIntoView({ behavior: "smooth" });
  }, [logs.length]);

  const phaseLabel = {
    queued:             "🕒 ジョブ待機中",
    generating_script:  "🧠 台本生成中",
    synthesizing:       "🎙 ナレーション合成中",
    rendering:          "🎞 シーン描画中",
    finalizing:         "✨ 連結・仕上げ",
    done:               "✓ 完了",
    failed:             "✕ 失敗",
  }[phase] || phase;

  return (
    <div className="modal-backdrop">
      <div className="modal">
        <h3>
          <span>動画を生成中...</span>
          <span className="tag-pill wait">{status}</span>
        </h3>
        <div className="modal-body">
          <div className="job-progress">
            <div className="phase">{phaseLabel}</div>
            <div className="progress-bar thick">
              <div className="fill" style={{ width: `${progress}%` }}></div>
            </div>
            <div className="pct-big">{Math.round(progress)}%</div>
          </div>
          <ul className="logs">
            {logs.map((l, i) => (
              <li key={i}>
                <span className="ts">{l.ts}</span>
                <span>{l.msg}</span>
              </li>
            ))}
            <li ref={logEnd}></li>
          </ul>
        </div>
      </div>
    </div>
  );
}

// ---------- 結果モーダル ----------
// ---------- 投稿コンポーザー定数 ----------
// プラットフォーム別キャプション文字数制限 (hard=投稿不可ライン, warn=警告ライン)
const CHAR_LIMITS = {
  instagram: { hard: 2200, warn: 2000 },
  tiktok:    { hard: 2200, warn: 2000 },
  youtube:   { hard:  100, warn:   80 },
  x:         { hard:  280, warn:  260 },
  threads:   { hard:  500, warn:  450 },
  facebook:  { hard: 5000, warn: 4500 },
  line:      { hard: 1000, warn:  900 },
  note:      { hard: 1000, warn:  900 },
  pinterest: { hard:  500, warn:  450 },
  bluesky:   { hard:  300, warn:  280 },
  linkedin:  { hard: 3000, warn: 2700 },
  mastodon:  { hard:  500, warn:  450 },
};

// 訴求軸別ハッシュタグ提案 (モック — 将来 LLM 化)
const HASHTAG_BANK = {
  foodie:  ["#熱海", "#懐石", "#旅館", "#温泉旅館", "#地魚", "#グルメ旅", "#リゾート"],
  onsen:   ["#熱海", "#温泉", "#露天風呂", "#源泉かけ流し", "#温泉旅館", "#美肌の湯", "#旅館"],
  couple:  ["#熱海", "#カップル旅行", "#記念日旅行", "#温泉デート", "#オーシャンビュー", "#夫婦旅"],
  family:  ["#熱海", "#家族旅行", "#子連れ旅行", "#温泉旅館", "#リゾート", "#週末旅"],
  solo:    ["#熱海", "#一人旅", "#おひとりさま", "#温泉", "#週末旅", "#自分時間"],
};

// 訴求軸ラベル (ANGLES のサブセット)
const ANGLE_LABEL = {
  foodie: "食重視",
  onsen:  "温泉重視",
  couple: "カップル向け",
  family: "家族連れ向け",
  solo:   "一人旅向け",
};

// バイト数を人間可読フォーマット (例: 8.2 MB)
function formatBytes(bytes) {
  if (!bytes || bytes < 0) return "—";
  const units = ["B", "KB", "MB", "GB"];
  let v = bytes, u = 0;
  while (v >= 1024 && u < units.length - 1) { v /= 1024; u++; }
  return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[u]}`;
}

// 文字数メーター行
function CharMeter({ platform, len }) {
  const lim = CHAR_LIMITS[platform.id] || { hard: 2000, warn: 1800 };
  const pct = Math.min(1, len / lim.hard);
  const over = len > lim.hard;
  const warn = !over && len > lim.warn;
  const cls = over ? "over" : warn ? "warn" : "";
  return (
    <div className={"char-meter " + cls}>
      <SnsTile platform={platform} />
      <div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0 }}>
        <span className="name">{platform.name}</span>
        <span className="meter">
          <span className="fill" style={{ width: `${pct * 100}%` }}></span>
        </span>
      </div>
      <span className="count">
        {len.toLocaleString()} / {lim.hard.toLocaleString()}
      </span>
    </div>
  );
}

// 投稿完了オーバーレイ
function PublishResult({ targets, onClose, onViewQueue }) {
  return (
    <div className="publish-result">
      <div className="publish-result-inner">
        <div className="icon-big">
          <svg width="28" height="28" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
            <path d="M3 8 L7 12 L13 4" />
          </svg>
        </div>
        <h3>投稿が完了しました</h3>
        <div className="desc">
          {targets.length} 件の配信先にスケジュールされました。各プラットフォームの応答を確認しています...
        </div>
        <div className="results">
          {targets.map((t) => (
            <div key={t.platform.id} className="result-row">
              <SnsTile platform={t.platform} />
              <span>
                <div className="name">{t.platform.name}</div>
                <div style={{ fontSize: 10.5, color: "var(--muted)", fontFamily: "var(--mono)" }}>{t.handle}</div>
              </span>
              <span style={{ display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-end" }}>
                <span className="state-ok">
                  <span className="status-dot ready"></span>
                  {t.mock ? "送信 (モック)" : "投稿済"}
                </span>
                {t.url && (
                  <a href={t.url.startsWith("http") ? t.url : "#"}
                     target={t.url.startsWith("http") ? "_blank" : undefined}
                     rel="noreferrer"
                     onClick={(e) => { if (!t.url.startsWith("http")) e.preventDefault(); }}>
                    {t.url.replace(/^https?:\/\//, "")}
                  </a>
                )}
              </span>
            </div>
          ))}
        </div>
        <div className="actions">
          <button className="btn btn-ghost" onClick={onClose}>閉じて編集に戻る</button>
          <button className="btn btn-primary" onClick={onViewQueue}>投稿の状況を見る</button>
        </div>
      </div>
    </div>
  );
}

// ---------- メイン: 動画完成モーダル (旧 ResultModal) ----------
function ResultModal({ jobId, scenes, settings, connectedChannels, ads, selectedFbAdId, setSelectedFbAdId, selectedIgAdId, setSelectedIgAdId, onOpenConnectChannels, onOpenDestinations, onClose }) {
  const videoUrl = `/api/jobs/${jobId}/result`;

  // ---------- ステート ----------
  const [selected, setSelected] = useState(() =>
    connectedChannels.filter((c) => c.state === "ok").map((c) => c.id)
  );
  const [caption, setCaption] = useState(
    "🌊 熱海プリンセスリゾート\n\n" +
    "海を望む大人のリゾート。源泉かけ流しの美肌の湯と、地元の魚介を使った創作懐石で、特別な時間をお過ごしください。\n\n" +
    "ご予約はプロフィールのリンクから👇"
  );
  const [hashtags, setHashtags] = useState("#熱海 #温泉旅館 #オーシャンビュー");
  const [showOverrides, setShowOverrides] = useState(false);
  const [overrides, setOverrides] = useState({}); // {platformId: text}
  const [schedule, setSchedule] = useState("now"); // "now" | "queue" | "draft"
  const [scheduledAt, setScheduledAt] = useState(() => {
    const d = new Date(Date.now() + 60 * 60 * 1000);
    return d.toISOString().slice(0, 16);
  });
  const [publishing, setPublishing] = useState(false);
  const [published, setPublished] = useState(null);
  const [publishError, setPublishError] = useState("");
  const [fileSize, setFileSize] = useState(null);

  // 投稿先 (ads) — enabled な FB ページのみセレクタに出す。
  // state (selectedFbAdId / selectedIgAdId) は App 側で管理している。
  const fbAds = (ads || []).filter(
    (a) => a.platform === "meta_facebook" && a.enabled
  );
  const igAds = (ads || []).filter(
    (a) => a.platform === "meta_instagram" && a.enabled
  );
  const adLabel = (ad) => {
    if (ad.label) return ad.label;
    const d = ad.data || {};
    if (ad.platform === "meta_instagram" && d.username) return "@" + d.username;
    return d.page_name || ad.external_id;
  };

  // ---------- 派生値 ----------
  const platformMap = useMemo(() => {
    const m = {};
    SNS_PLATFORMS.forEach((p) => (m[p.id] = p));
    return m;
  }, []);

  const selectedChannels = useMemo(
    () => connectedChannels.filter((c) => selected.includes(c.id)),
    [selected, connectedChannels]
  );
  const selectedPlatforms = useMemo(() => {
    const seen = new Set();
    const out = [];
    selectedChannels.forEach((c) => {
      if (seen.has(c.platform)) return;
      seen.add(c.platform);
      const p = platformMap[c.platform];
      if (p) out.push(p);
    });
    return out;
  }, [selectedChannels, platformMap]);

  const fullText = caption + (hashtags ? "\n\n" + hashtags : "");
  const textFor = (platformId) =>
    overrides[platformId] !== undefined ? overrides[platformId] : fullText;

  const suggested = HASHTAG_BANK[settings.angle] || HASHTAG_BANK.foodie;
  const angleLabel = ANGLE_LABEL[settings.angle] || settings.angle;
  const duration = useMemo(
    () => scenes.reduce((s, sc) => s + estDuration(sc.narration || ""), 0),
    [scenes]
  );

  // Esc で閉じる
  useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape" && !publishing) onClose();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, publishing]);

  // mp4 ファイルサイズ (HEAD で Content-Length)
  // /api/jobs/.../result は HEAD 非対応のため、認証不要な静的 URL を使う
  useEffect(() => {
    let alive = true;
    (async () => {
      try {
        const r = await fetch(`/public/${jobId}.mp4`, { method: "HEAD" });
        const cl = r.headers.get("content-length");
        if (alive && cl) setFileSize(parseInt(cl, 10));
      } catch (_) { /* swallow */ }
    })();
    return () => { alive = false; };
  }, [jobId]);

  // ---------- ハンドラ ----------
  const addHashtag = (h) => {
    setHashtags((prev) => {
      const tokens = prev.split(/\s+/).filter(Boolean);
      if (tokens.includes(h)) return tokens.filter((t) => t !== h).join(" ");
      return [...tokens, h].join(" ");
    });
  };
  const toggleChannel = (c) => {
    if (c.state === "err") return;
    setSelected((prev) =>
      prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]
    );
  };
  const generateAICaption = () => {
    // 訴求軸に応じた簡易プリセット (将来 LLM 化)
    const presets = {
      foodie: "🍱 熱海プリンセスリゾート\n\n地元の海の幸を活かした創作懐石。器も、盛り付けも、ひとつの作品。\n大切な人と、忘れられない食卓を。",
      onsen:  "♨️ 源泉かけ流し・美肌の湯\n\n熱海の高台に湧く、まろやかな塩化物泉。湯けむりの向こうに広がるオーシャンビュー。\n肌でほどける、極上のひととき。",
      couple: "💑 二人だけの熱海ステイ\n\n海を望む露天風呂と、創作懐石のディナー。記念日に、忘れられない一夜を。",
      family: "👨‍👩‍👧 家族でゆっくり熱海旅\n\nお子様も楽しめる露天風呂と、地魚を使ったお食事。世代をこえて笑顔になれる宿。",
      solo:   "🌊 一人時間を熱海で\n\nオーシャンビューと源泉。誰にも気をつかわず、ただ静かに、自分にだけ向き合う休日を。",
    };
    setCaption(presets[settings.angle] || presets.foodie);
  };

  const canPublish = selected.length > 0 && fullText.trim().length > 0 && !publishing;

  // 投稿: FB/IG は実 API、他チャンネルは UI 上のモック表示のみ
  const handlePublish = async () => {
    setPublishing(true);
    setPublishError("");
    try {
      // 選択中チャンネルから実 API 対応プラットフォームを抽出
      const realTargets = [];
      const realChannels = [];
      selectedChannels.forEach((c) => {
        if (c.platform === "facebook" || c.platform === "instagram") {
          if (!realTargets.includes(c.platform)) realTargets.push(c.platform);
          realChannels.push(c);
        }
      });

      // 実 API 呼び出し (FB/IG のいずれかが選択されていれば)
      let apiResult = null;
      if (realTargets.length > 0 && schedule === "now") {
        const fd = new FormData();
        fd.append("caption",
          overrides.facebook ?? overrides.instagram ?? fullText);
        fd.append("targets", realTargets.join(","));
        // ads テーブルの id を渡す。未指定なら is_default → 先頭の順でサーバが解決。
        if (selectedFbAdId != null && realTargets.includes("facebook")) {
          fd.append("fb_ad_id", String(selectedFbAdId));
        }
        if (selectedIgAdId != null && realTargets.includes("instagram")) {
          fd.append("ig_ad_id", String(selectedIgAdId));
        }
        const res = await fetch(`/api/jobs/${jobId}/publish`, { method: "POST", body: fd });
        const data = await res.json().catch(() => ({}));
        if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
        apiResult = data;
      }

      // PublishResult 用 targets を組み立て
      const targets = selectedChannels.map((c) => {
        const p = platformMap[c.platform];
        let url = null;
        let mock = true;
        if (apiResult) {
          if (c.platform === "facebook" && apiResult.facebook?.post_id) {
            url = `https://www.facebook.com/${apiResult.facebook.post_id}`;
            mock = false;
          } else if (c.platform === "instagram" && apiResult.instagram?.media_id) {
            url = `https://www.instagram.com/p/${apiResult.instagram.media_id}`;
            mock = false;
          }
        }
        if (schedule !== "now") mock = true;
        return { platform: p, handle: c.handle, url, mock };
      });
      setPublished({ targets });
    } catch (e) {
      setPublishError(e.message || String(e));
    } finally {
      setPublishing(false);
    }
  };

  return (
    <div className="complete-backdrop" onClick={(e) => { if (!publishing) onClose(); }}>
      <div className="complete-modal" onClick={(e) => e.stopPropagation()}>
        {/* ヘッダ */}
        <div className="complete-head">
          <span className="badge-done">
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
              <path d="M3 8 L7 12 L13 4" />
            </svg>
          </span>
          <div>
            <h2>動画が完成しました</h2>
            <div className="meta">
              <code>JOB {jobId}</code>
              <span className="dot"></span>
              <span>{scenes.length} シーン</span>
              <span className="dot"></span>
              <span>{duration.toFixed(1)} 秒</span>
              <span className="dot"></span>
              <span>{angleLabel}</span>
            </div>
          </div>
          <span className="spacer"></span>
          <button className="btn btn-ghost btn-sm" onClick={onClose} disabled={publishing}>
            閉じて編集に戻る <span className="kbd" style={{ marginLeft: 6 }}>Esc</span>
          </button>
        </div>

        {/* ボディ */}
        <div className="complete-body">
          {/* 左: プレビュー */}
          <div className="complete-preview">
            <div className="frame">
              <video
                src={videoUrl}
                controls
                playsInline
                autoPlay
                style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", background: "black" }}
              />
            </div>
            <div className="stats">
              <div className="cell">
                <div className="lbl">尺</div>
                <div className="val">{duration.toFixed(1)}s</div>
              </div>
              <div className="cell">
                <div className="lbl">サイズ</div>
                <div className="val">9:16</div>
              </div>
              <div className="cell">
                <div className="lbl">ファイル</div>
                <div className="val">{formatBytes(fileSize)}</div>
              </div>
              <div className="cell">
                <div className="lbl">解像度</div>
                <div className="val">1080p</div>
              </div>
            </div>
            <div className="quick-actions">
              <a className="btn btn-soft btn-sm" href={videoUrl} download={`${jobId}.mp4`} title="MP4 をダウンロード">
                <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
                  <path d="M8 3 V11 M4.5 8 L8 11.5 L11.5 8 M3 13 H13" />
                </svg>
                ダウンロード
              </a>
              <button
                className="btn btn-soft btn-sm"
                title="共有リンクをコピー"
                onClick={() => {
                  const url = new URL(videoUrl, window.location.origin).toString();
                  navigator.clipboard?.writeText(url).catch(() => {});
                }}
              >
                <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
                  <path d="M6.5 9.5 L9.5 6.5" />
                  <path d="M9 4.5 L10.5 3 a2 2 0 0 1 2.8 2.8 L11.8 7.3" />
                  <path d="M7 8.7 L5.5 10.2 a2 2 0 0 1 -2.8 -2.8 L4.2 5.9" />
                </svg>
                リンク
              </button>
            </div>
          </div>

          {/* 右: 投稿フォーム */}
          <div className="complete-form">
            {/* (1) 配信先 */}
            <div className="compose-card">
              <div className="head">
                <span>配信先</span>
                <span className="count">{selected.length} / {connectedChannels.length} 選択</span>
                <span className="spacer"></span>
                <button className="tiny" onClick={() => setSelected(
                  selected.length === connectedChannels.filter((c) => c.state !== "err").length
                    ? []
                    : connectedChannels.filter((c) => c.state !== "err").map((c) => c.id)
                )}>
                  {selected.length > 0 ? "すべて解除" : "すべて選択"}
                </button>
              </div>

              {/* 有効な投稿先が複数ある場合のセレクタ。1 件なら自動でそれが使われる。
                  「投稿先の管理」リンクから DestinationsModal を開けるようにしている。 */}
              {(fbAds.length > 1 || igAds.length > 1) && (
                <div
                  className="fb-page-picker"
                  style={{
                    padding: "10px 14px",
                    display: "flex",
                    flexDirection: "column",
                    gap: 6,
                    borderBottom: "1px solid var(--border-soft)",
                    fontSize: 12,
                  }}
                >
                  {fbAds.length > 1 && (
                    <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                      <span style={{ color: "var(--muted)", minWidth: 96 }}>
                        Facebook 宛先
                      </span>
                      <select
                        value={selectedFbAdId ?? ""}
                        onChange={(e) => setSelectedFbAdId(Number(e.target.value))}
                        disabled={publishing}
                        style={{
                          flex: 1, padding: "4px 8px", borderRadius: 4,
                          border: "1px solid var(--border)",
                          background: "var(--panel-2)",
                          color: "var(--text)", fontSize: 12,
                        }}
                      >
                        {fbAds.map((a) => (
                          <option key={a.id} value={a.id}>
                            {adLabel(a)}{a.is_default ? " ★既定" : ""} — {a.external_id}
                          </option>
                        ))}
                      </select>
                    </div>
                  )}
                  {igAds.length > 1 && (
                    <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                      <span style={{ color: "var(--muted)", minWidth: 96 }}>
                        Instagram 宛先
                      </span>
                      <select
                        value={selectedIgAdId ?? ""}
                        onChange={(e) => setSelectedIgAdId(Number(e.target.value))}
                        disabled={publishing}
                        style={{
                          flex: 1, padding: "4px 8px", borderRadius: 4,
                          border: "1px solid var(--border)",
                          background: "var(--panel-2)",
                          color: "var(--text)", fontSize: 12,
                        }}
                      >
                        {igAds.map((a) => (
                          <option key={a.id} value={a.id}>
                            {adLabel(a)}{a.is_default ? " ★既定" : ""} — {a.external_id}
                          </option>
                        ))}
                      </select>
                    </div>
                  )}
                  <a
                    onClick={onOpenDestinations}
                    style={{
                      fontSize: 11, color: "var(--muted)",
                      textDecoration: "underline", cursor: "pointer",
                      alignSelf: "flex-end",
                    }}
                  >
                    投稿先を管理する →
                  </a>
                </div>
              )}
              {connectedChannels.length === 0 ? (
                <div className="empty" style={{ padding: 20 }}>
                  <div style={{ fontSize: 12, marginBottom: 8 }}>配信先がまだ接続されていません</div>
                  <button className="btn btn-primary btn-sm" onClick={onOpenConnectChannels}>
                    + 配信先を接続
                  </button>
                </div>
              ) : (
                <>
                  <div className="channel-pick-list">
                    {connectedChannels.map((c) => {
                      const p = platformMap[c.platform];
                      if (!p) return null;
                      const isSelected = selected.includes(c.id);
                      const isErr = c.state === "err";
                      const isWarn = c.state === "warn";
                      return (
                        <label
                          key={c.id}
                          className={
                            "channel-pick" +
                            (isSelected ? " selected" : "") +
                            (isWarn ? " warn" : "") +
                            (isErr ? " disabled" : "")
                          }
                          onClick={(e) => { e.preventDefault(); toggleChannel(c); }}
                        >
                          <span className="checkbox">
                            {isSelected && (
                              <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                                <path d="M3 8 L7 12 L13 4" />
                              </svg>
                            )}
                          </span>
                          <SnsTile platform={p} />
                          <span className="label">
                            <span className="name">{p.name}</span>
                            <span className="handle">{c.handle}</span>
                          </span>
                          {isWarn && (
                            <span className="badge-warn">
                              <span className="status-dot synth"></span>要再認証
                            </span>
                          )}
                        </label>
                      );
                    })}
                  </div>
                  <a className="channel-add-link" onClick={onOpenConnectChannels}>
                    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                      <path d="M8 3 V13 M3 8 H13" />
                    </svg>
                    他の配信先を接続する
                  </a>
                </>
              )}
            </div>

            {/* (2) キャプション */}
            <div className="compose-card">
              <div className="head">
                <span>キャプション</span>
                <span className="count">{caption.length} 文字</span>
                <span className="spacer"></span>
                <button className="ai-suggest" onClick={generateAICaption}>
                  ✨ AIで生成
                </button>
              </div>
              <div className="caption-area">
                <textarea
                  value={caption}
                  onChange={(e) => setCaption(e.target.value)}
                  placeholder="動画の内容を伝えるキャプションを入力..."
                />
                <div className="insert-row">
                  <span className="label-mini">ハッシュタグ提案 ({angleLabel}):</span>
                  {suggested.map((h) => {
                    const used = hashtags.split(/\s+/).includes(h);
                    return (
                      <button
                        key={h}
                        className={"chip" + (used ? " active" : "")}
                        onClick={() => addHashtag(h)}
                      >
                        {h}
                      </button>
                    );
                  })}
                </div>
                <input
                  type="text"
                  value={hashtags}
                  onChange={(e) => setHashtags(e.target.value)}
                  style={{ marginTop: 8, fontSize: 12 }}
                  placeholder="#熱海 #温泉 ..."
                />
              </div>

              {selectedPlatforms.length > 0 && (
                <div style={{ marginTop: 12 }}>
                  {selectedPlatforms.map((p) => (
                    <CharMeter key={p.id} platform={p} len={textFor(p.id).length} />
                  ))}
                </div>
              )}

              {selectedPlatforms.length > 1 && (
                <>
                  <div className="platform-overrides" style={{ display: showOverrides ? "block" : "none" }}>
                    {selectedPlatforms.map((p) => (
                      <div key={p.id} className="row">
                        <SnsTile platform={p} />
                        <span className="pname">{p.name}</span>
                        <input
                          type="text"
                          value={overrides[p.id] ?? ""}
                          placeholder={`${p.name} 専用のキャプション (空欄で共通)`}
                          onChange={(e) => setOverrides({ ...overrides, [p.id]: e.target.value })}
                        />
                        {overrides[p.id] !== undefined ? (
                          <button
                            className="reset"
                            onClick={() => {
                              const o = { ...overrides };
                              delete o[p.id];
                              setOverrides(o);
                            }}
                          >
                            リセット
                          </button>
                        ) : (
                          <span style={{ width: 36 }}></span>
                        )}
                      </div>
                    ))}
                  </div>
                  <a
                    className="channel-add-link"
                    onClick={() => setShowOverrides(!showOverrides)}
                    style={{ marginTop: 8 }}
                  >
                    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: showOverrides ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}>
                      <path d="M6 4 L11 8 L6 12" />
                    </svg>
                    {showOverrides ? "プラットフォーム別の編集を閉じる" : "プラットフォーム別に個別編集"}
                  </a>
                </>
              )}
            </div>

            {/* (3) 投稿タイミング */}
            <div className="compose-card">
              <div className="head">
                <span>投稿タイミング</span>
              </div>
              <div className="schedule-pick">
                <div
                  className={"schedule-opt" + (schedule === "now" ? " active" : "")}
                  onClick={() => setSchedule("now")}
                >
                  <div className="label">
                    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
                      <circle cx="8" cy="8" r="5.5" />
                      <path d="M8 5 V8 L10 9.5" />
                    </svg>
                    今すぐ投稿
                  </div>
                  <div className="desc">承認後すぐに各SNSへ送信</div>
                </div>
                <div
                  className={"schedule-opt" + (schedule === "queue" ? " active" : "")}
                  onClick={() => setSchedule("queue")}
                >
                  <div className="label">
                    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
                      <rect x="3" y="4" width="10" height="9" rx="1.5" />
                      <path d="M3 7 H13 M6 3 V5 M10 3 V5" />
                    </svg>
                    予約投稿
                  </div>
                  <div className="desc">日時を指定して自動配信</div>
                </div>
                <div
                  className={"schedule-opt" + (schedule === "draft" ? " active" : "")}
                  onClick={() => setSchedule("draft")}
                >
                  <div className="label">
                    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
                      <path d="M4 3 H10 L12.5 5.5 V13 H4 Z M10 3 V5.5 H12.5" />
                    </svg>
                    下書き保存
                  </div>
                  <div className="desc">あとで投稿。各SNSには送られません</div>
                </div>
              </div>
              {schedule === "queue" && (
                <div className="schedule-when">
                  <span className="label-mini">日時</span>
                  <input
                    type="datetime-local"
                    value={scheduledAt}
                    onChange={(e) => setScheduledAt(e.target.value)}
                  />
                  <button className="btn btn-soft btn-sm" title="次の最適時間枠 (各SNSの推定 reach 最大時刻)">
                    ⏰ 最適枠
                  </button>
                </div>
              )}
            </div>

            {publishError && (
              <div className="hint err" style={{ padding: "6px 12px" }}>
                投稿失敗: {publishError}
              </div>
            )}
          </div>
        </div>

        {/* フッタ */}
        <div className="complete-foot">
          <div className="summary">
            <span>配信先</span>
            <div className="targets">
              {selectedChannels.length === 0 ? (
                <span style={{ color: "var(--muted)" }}>未選択</span>
              ) : (
                selectedChannels.map((c) => {
                  const p = platformMap[c.platform];
                  return <SnsTile key={c.id} platform={p} />;
                })
              )}
            </div>
            <span>·</span>
            <span>
              {schedule === "now" && <>すぐに <strong>{selected.length}</strong> 件投稿</>}
              {schedule === "queue" && <><strong>{new Date(scheduledAt).toLocaleString("ja-JP", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</strong> に投稿予約</>}
              {schedule === "draft" && <>下書きとして保存</>}
            </span>
          </div>
          <span className="spacer"></span>
          <button className="btn btn-ghost btn-sm" onClick={onClose} disabled={publishing}>
            ✏️ 編集に戻る
          </button>
          <button
            className="btn btn-publish btn-lg"
            onClick={handlePublish}
            disabled={!canPublish}
            title={selected.length === 0 ? "配信先を1つ以上選択" : ""}
          >
            {publishing
              ? <>🔄 投稿中...</>
              : schedule === "draft"
                ? <>💾 下書きを保存</>
                : schedule === "queue"
                  ? <>📅 {selected.length} 件を予約</>
                  : <>🚀 {selected.length} 件に投稿する</>
            }
          </button>
        </div>

        {/* 投稿完了オーバーレイ */}
        {published && (
          <PublishResult
            targets={published.targets}
            onClose={onClose}
            onViewQueue={onClose}
          />
        )}
      </div>
    </div>
  );
}

// ---------- 起動時のサンプル文 (任意で入れておく) ----------
const HOTEL_TEXT_PLACEHOLDER =
  "施設名・所在地・客室・温泉・料理・アクセス・推し要素などを公式サイトからコピペしてください...";

// ---------- App ----------
// ============================================================
//  SNS連携 (配信先) — 左ペイン下部 + 追加モーダル + OAuth 風モーダル
//  アイコンは商標を避けたオリジナル線画グリフ。タイル色のみブランド寄り。
// ============================================================
const SNS_PLATFORMS = [
  {
    id: "instagram",
    name: "Instagram",
    kind: "ビジネス・クリエイター",
    group: "popular",
    info: "投稿・リール・ストーリーズに対応",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5">
        <rect x="3.25" y="3.25" width="9.5" height="9.5" rx="2.5" />
        <circle cx="8" cy="8" r="2.25" />
        <circle cx="11.25" cy="4.75" r="0.5" fill="currentColor" stroke="none" />
      </g>
    ),
  },
  // TikTok / YouTube Shorts は Coming soon のため一覧から除外。
  {
    id: "x",
    name: "X (旧Twitter)",
    kind: "プロフィール",
    group: "popular",
    info: "動画ツイートとして投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
        <path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5" />
      </g>
    ),
  },
  {
    id: "threads",
    name: "Threads",
    kind: "プロフィール",
    group: "popular",
    info: "Meta アカウントから連携",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
        <path d="M8 3.5 C5 3.5 3.5 5.5 3.5 8 C3.5 10.8 5.5 12.5 8 12.5 C10.2 12.5 11.5 11.2 11.5 9.8 C11.5 8.4 10.5 7.5 9 7.5 C7.5 7.5 6.5 8.2 6.5 9.2 C6.5 10 7.2 10.5 8.2 10.3" />
      </g>
    ),
  },
  {
    id: "facebook",
    name: "Facebook",
    kind: "ページ・グループ",
    group: "popular",
    info: "ビジネスページへの投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
        <path d="M9.5 4.5 H8.5 a1.5 1.5 0 0 0 -1.5 1.5 V8 H5.5 M7 8 H9 M7 8 V12.5" />
      </g>
    ),
  },
  {
    id: "line",
    name: "LINE公式",
    kind: "公式アカウント",
    group: "japan",
    info: "ホテルの友だちにメッセージ配信",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round">
        <path d="M3.5 7 C3.5 4.5 5.5 3.25 8 3.25 C10.5 3.25 12.5 4.75 12.5 7 C12.5 9 10.5 10.5 8 10.5 L6.5 10.5 L4.5 12.25 L5 10.25 C4 9.5 3.5 8.3 3.5 7 Z" />
      </g>
    ),
  },
  {
    id: "note",
    name: "note",
    kind: "クリエイター",
    group: "japan",
    info: "テキスト記事 + 動画埋め込みで投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
        <path d="M4.5 4.5 V11.5 M4.5 4.5 L11.5 11.5 M11.5 4.5 V11.5" />
      </g>
    ),
  },
  {
    id: "pinterest",
    name: "Pinterest",
    kind: "ビジネス",
    group: "more",
    info: "アイデアピンとして投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
        <circle cx="8" cy="7.5" r="3" />
        <path d="M7.25 9 L6 12.5" />
      </g>
    ),
  },
  {
    id: "bluesky",
    name: "Bluesky",
    kind: "プロフィール",
    group: "more",
    info: "AT Protocol 経由で投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round">
        <path d="M3.5 4.5 C5 5 6.5 6.5 8 8.5 C9.5 6.5 11 5 12.5 4.5 C12 7.5 10.5 9 8 11 C5.5 9 4 7.5 3.5 4.5 Z" />
      </g>
    ),
  },
  {
    id: "linkedin",
    name: "LinkedIn",
    kind: "ページ・プロフィール",
    group: "more",
    info: "ビジネス向け動画として投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
        <circle cx="5" cy="5" r="0.5" fill="currentColor" stroke="none" />
        <path d="M5 7 V11.5" />
        <path d="M8 11.5 V8.5 a1.5 1.5 0 0 1 3 0 V11.5 M8 7 V11.5" />
      </g>
    ),
  },
  {
    id: "mastodon",
    name: "Mastodon",
    kind: "インスタンス",
    group: "more",
    info: "Fediverse へ動画投稿",
    glyph: (
      <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
        <path d="M4 9.5 C4 5 5.5 4 8 4 C10.5 4 12 5 12 9.5" />
        <path d="M6 7.5 V10 M8 7.5 V10 M10 7.5 V10" />
      </g>
    ),
  },
];

const SNS_GROUP_TITLE = {
  popular: "人気のチャンネル",
  japan: "日本向け",
  more: "その他",
};

const SNS_MOCK_HANDLES = {
  instagram: "@atami_princess",
  tiktok:    "@atami_princess",
  youtube:   "@AtamiPrincessResort",
  x:         "@atami_princess",
  threads:   "@atami_princess",
  facebook:  "AtamiPrincessResort",
  line:      "@atami-princess",
  note:      "atami_princess",
  pinterest: "atami_princess",
  bluesky:   "atami.bsky.social",
  linkedin:  "atami-princess",
  mastodon:  "@atami@mstdn.jp",
};
function mockSnsHandle(platformId) {
  return SNS_MOCK_HANDLES[platformId] || "@atami_princess";
}

function SnsTile({ platform, size = "md", dim = false }) {
  const p = typeof platform === "string"
    ? SNS_PLATFORMS.find((x) => x.id === platform)
    : platform;
  if (!p) return null;
  const sz = size === "lg" ? 26 : 14;
  return (
    <span className={"sns-tile" + (size === "lg" ? " lg" : "") + (dim ? " dim" : "")} data-p={p.id}>
      <svg width={sz} height={sz} viewBox="0 0 16 16" aria-hidden="true">
        {p.glyph}
      </svg>
    </span>
  );
}

function SnsSection({ connected, onOpen, onDisconnect }) {
  return (
    <div className="section">
      <div className="section-head" style={{ paddingBottom: 6 }}>
        <span>配信先</span>
        <span className="count">{connected.length}</span>
        <span className="spacer"></span>
        <button className="tiny" onClick={onOpen}>管理</button>
      </div>
      <div className="sns-section">
        {connected.length > 0 && (
          <div className="sns-channel-list">
            {connected.map((c) => {
              const p = SNS_PLATFORMS.find((x) => x.id === c.platform);
              if (!p) return null;
              return (
                <div key={c.id} className="sns-channel-row" title={`${p.name} — ${c.handle}`}>
                  <SnsTile platform={p} />
                  <div className="label">
                    <span className="name">{p.name}</span>
                    <span className="handle">{c.handle}</span>
                  </div>
                  <span className={"state " + c.state}>
                    {c.state === "ok" && (<><span className="status-dot ready" />接続中</>)}
                    {c.state === "warn" && (<><span className="status-dot synth" />要再認証</>)}
                    {c.state === "err" && (<><span className="status-dot error" />エラー</>)}
                  </span>
                  <button
                    className="remove"
                    onClick={(e) => { e.stopPropagation(); onDisconnect(c.platform); }}
                    title="連携を解除"
                    aria-label="連携を解除"
                  >×</button>
                </div>
              );
            })}
          </div>
        )}
        <button className="sns-add-btn" onClick={onOpen}>
          <span className="plus">+</span>
          <span>{connected.length === 0 ? "配信先を接続" : "他のチャンネルを追加"}</span>
        </button>
      </div>
    </div>
  );
}

function SnsConnectModal({ connectedIds, connecting, onPick, onDisconnect, onClose }) {
  const [q, setQ] = useState("");
  const [filter, setFilter] = useState("all");

  const filtered = useMemo(() => {
    const ql = q.trim().toLowerCase();
    return SNS_PLATFORMS.filter((p) => {
      if (filter !== "all" && p.group !== filter) return false;
      if (!ql) return true;
      return (
        p.name.toLowerCase().includes(ql) ||
        p.kind.toLowerCase().includes(ql) ||
        p.id.includes(ql)
      );
    });
  }, [q, filter]);

  const grouped = useMemo(() => {
    const g = { popular: [], japan: [], more: [] };
    filtered.forEach((p) => g[p.group].push(p));
    return g;
  }, [filtered]);

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="sns-modal" onClick={(e) => e.stopPropagation()}>
        <div className="sns-modal-head">
          <div>
            <h2>配信先を追加</h2>
            <div className="sub">生成した動画を自動でこのチャンネルに投稿します</div>
          </div>
          <span className="spacer" />
          <button className="btn-icon" onClick={onClose} title="閉じる (Esc)">
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
              <path d="M4 4 L12 12 M12 4 L4 12" />
            </svg>
          </button>
        </div>

        <div className="sns-modal-search">
          <div className="search-wrap">
            <svg className="search-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
              <circle cx="7" cy="7" r="3.5" />
              <path d="M9.7 9.7 L12 12" />
            </svg>
            <input
              type="search"
              placeholder="プラットフォームを検索..."
              value={q}
              onChange={(e) => setQ(e.target.value)}
              autoFocus
            />
          </div>
          <div className="filter-chips">
            {[
              ["all", "すべて"],
              ["popular", "人気"],
              ["japan", "日本向け"],
              ["more", "その他"],
            ].map(([v, l]) => (
              <button
                key={v}
                className={"chip" + (filter === v ? " active" : "")}
                onClick={() => setFilter(v)}
              >
                {l}
              </button>
            ))}
          </div>
        </div>

        <div className="sns-modal-body">
          {filtered.length === 0 && (
            <div className="empty">
              <div className="icon">🔍</div>
              該当するプラットフォームがありません
            </div>
          )}
          {["popular", "japan", "more"].map((gk) => {
            if (!grouped[gk].length) return null;
            return (
              <div key={gk} className="group">
                <div className="group-label">{SNS_GROUP_TITLE[gk]}</div>
                <div className="sns-grid">
                  {grouped[gk].map((p) => {
                    const isConnected = connectedIds.includes(p.id);
                    const isConnecting = connecting === p.id;
                    return (
                      <button
                        key={p.id}
                        className={
                          "sns-card" +
                          (isConnected ? " connected" : "") +
                          (isConnecting ? " connecting" : "")
                        }
                        onClick={() => {
                          if (isConnecting) return;
                          if (isConnected) onDisconnect(p.id);
                          else onPick(p);
                        }}
                        disabled={isConnecting}
                        title={isConnected ? `${p.name} — クリックで連携解除` : p.info}
                      >
                        <span className="corner" title={p.info}>
                          <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
                            <circle cx="8" cy="8" r="6" />
                            <path d="M8 7 V11" strokeLinecap="round" />
                            <circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none" />
                          </svg>
                        </span>
                        {isConnected && (
                          <span className="check" aria-label="接続済み">
                            <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                              <path d="M3 8 L7 12 L13 4" />
                            </svg>
                          </span>
                        )}
                        {isConnecting && <span className="spinner" aria-label="接続中" />}
                        <SnsTile platform={p} size="lg" />
                        <div className="name">{p.name}</div>
                        <div className="kind">{p.kind}</div>
                        {isConnected && (
                          <span className="disconnect-hint">クリックで連携解除</span>
                        )}
                      </button>
                    );
                  })}
                </div>
              </div>
            );
          })}
        </div>

        <div className="sns-modal-foot">
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
            <rect x="3" y="6" width="10" height="7.5" rx="1.5" />
            <path d="M5.5 6 V4.5 a2.5 2.5 0 0 1 5 0 V6" />
          </svg>
          <span>OAuth で安全に接続。動画投稿の権限のみリクエストします。</span>
          <span className="spacer" />
          <a href="#" onClick={(e) => e.preventDefault()}>権限の詳細</a>
        </div>
      </div>
    </div>
  );
}

function SnsOAuthModal({ platform, onCancel, onComplete }) {
  if (!platform) return null;
  return (
    <div className="modal-backdrop" onClick={onCancel}>
      <div className="sns-oauth" onClick={(e) => e.stopPropagation()}>
        <SnsTile platform={platform} size="lg" />
        <h3>{platform.name} に接続</h3>
        <div className="desc">
          ホテル動画ジェネレータが {platform.name} アカウントへの動画投稿権限をリクエストしています。
        </div>
        <div className="perms">
          <div className="perms-title">許可される操作</div>
          <ul>
            <li>
              <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M3 8 L7 12 L13 4" />
              </svg>
              <span>動画コンテンツの投稿</span>
            </li>
            <li>
              <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M3 8 L7 12 L13 4" />
              </svg>
              <span>プロフィール情報の読み取り (アカウント名のみ)</span>
            </li>
            <li>
              <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M3 8 L7 12 L13 4" />
              </svg>
              <span>投稿のインサイト取得 (再生回数等)</span>
            </li>
          </ul>
        </div>
        <div className="progress-line" key={platform.id}>
          <div className="fill" onAnimationEnd={onComplete}></div>
        </div>
        <div className="actions">
          <button className="btn btn-ghost" onClick={onCancel}>キャンセル</button>
          <button className="btn btn-primary" onClick={onComplete}>許可して接続</button>
        </div>
      </div>
    </div>
  );
}

// ---------- 過去ジョブ一覧 (setup 画面) ----------
// 同じユーザ (email) で作成した、または legacy ("") の過去ジョブを
// サムネカードで並べ、クリックで再編集モードに復帰させる。
function PastJobsSection({ jobs, loading, onOpen, onDelete, opening }) {
  if (!jobs || jobs.length === 0) {
    return (
      <div className="past-jobs">
        <div className="section-head"><span>過去のジョブ</span></div>
        <div className="empty">
          {loading ? "読み込み中…" : "まだ作成済みのジョブはありません"}
        </div>
      </div>
    );
  }
  return (
    <div className="past-jobs">
      <div className="section-head">
        <span>過去のジョブ</span>
        <span className="count">{jobs.length}</span>
      </div>
      <div className="card-grid">
        {jobs.map((j) => (
          <PastJobCard
            key={j.id}
            job={j}
            opening={opening === j.id}
            onOpen={() => onOpen(j)}
            onDelete={() => onDelete(j)}
          />
        ))}
      </div>
    </div>
  );
}

function PastJobCard({ job, opening, onOpen, onDelete }) {
  const isVideoThumb = (job.thumb_url || "").endsWith(".mp4");
  const statusClass =
    job.status === "done" ? "ok" :
    job.status === "failed" ? "err" : "warn";
  const statusLabel =
    job.status === "done" ? "完成" :
    job.status === "failed" ? "失敗" :
    job.status === "queued" ? "待機" :
    job.status === "generating_script" ? "台本生成中" :
    job.status === "synthesizing" ? "音声合成中" :
    job.status === "rendering" ? "描画中" :
    job.status === "finalizing" ? "仕上げ中" :
    job.status;
  return (
    <div className={cx("past-card", opening && "opening")}>
      <button
        className="thumb-btn"
        onClick={onOpen}
        disabled={opening}
        title="クリックで再編集"
      >
        <div className="thumb">
          {job.thumb_url ? (
            isVideoThumb ? (
              <video
                src={job.thumb_url}
                muted
                playsInline
                preload="metadata"
                onLoadedMetadata={(e) => {
                  try { e.currentTarget.currentTime = 0.5; } catch (_) {}
                }}
              />
            ) : (
              <img src={job.thumb_url} alt="" />
            )
          ) : (
            <div className="ph">素材なし</div>
          )}
          <span className={`status-pill ${statusClass}`}>{statusLabel}</span>
          {opening && <div className="opening-overlay">読み込み中…</div>}
        </div>
      </button>
      <div className="meta">
        <div className="title" title={job.hotel_text_excerpt}>
          {job.hotel_text_excerpt || <span className="muted">(本文なし)</span>}
        </div>
        <div className="sub">
          <span>{job.n_materials} 素材</span>
          <span className="dot">·</span>
          <span title={job.created_at}>
            {job.created_at ? job.created_at.replace("T", " ") : ""}
          </span>
        </div>
        <div className="actions-row">
          <span className="job-id">{job.id}</span>
          <span className="spacer" />
          <button
            className="tiny danger"
            onClick={(e) => { e.stopPropagation(); onDelete(); }}
            title="削除"
          >🗑</button>
        </div>
      </div>
    </div>
  );
}

// ---------- 投稿先 (ads) 管理モーダル ----------
// /api/ads の CRUD を呼び出して、ユーザごとの投稿/広告先を有効/無効・既定切替・
// ラベル編集できる。Meta OAuth で取れる FB ページ / IG ビジネスアカウントが
// platform 別に並ぶ。再同期で OAuth レスポンスから ads 行を再投影する。
// TikTok / YouTube は Coming soon のため一覧から除外。実装着手時に再追加する。
const DESTINATION_PLATFORM_META = {
  meta_facebook:  { label: "Facebook ページ",   tag: "FB", color: "#1877F2" },
  meta_instagram: { label: "Instagram",         tag: "IG", color: "#E4405F" },
};

function DestinationsModal({ onClose, onChange }) {
  const [ads, setAds] = useState([]);
  const [loading, setLoading] = useState(false);
  const [syncing, setSyncing] = useState(false);
  const [error, setError] = useState("");
  const [editing, setEditing] = useState(null); // { id, label }

  const reload = useCallback(async () => {
    setLoading(true);
    setError("");
    try {
      const r = await fetch("/api/ads", { credentials: "same-origin" });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const d = await r.json();
      setAds(d.ads || []);
    } catch (e) {
      setError(e.message || String(e));
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { reload(); }, [reload]);

  // Esc で閉じる
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  const handleSync = async () => {
    setSyncing(true);
    setError("");
    try {
      const r = await fetch("/api/ads/sync", {
        method: "POST", credentials: "same-origin",
      });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      await reload();
      onChange?.();
    } catch (e) {
      setError(e.message || String(e));
    } finally {
      setSyncing(false);
    }
  };

  const patch = async (id, fields) => {
    const fd = new FormData();
    Object.entries(fields).forEach(([k, v]) => {
      if (v === null || v === undefined) return;
      fd.append(k, typeof v === "boolean" ? (v ? "true" : "false") : String(v));
    });
    try {
      const r = await fetch(`/api/ads/${id}`, {
        method: "PATCH", credentials: "same-origin", body: fd,
      });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      await reload();
      onChange?.();
    } catch (e) {
      setError(e.message || String(e));
    }
  };

  const remove = async (id) => {
    if (!window.confirm(
      "この投稿先を一覧から削除しますか？\n" +
      "(Meta から再同期すれば復活します。無効化したいだけなら「有効」を OFF にしてください)"
    )) return;
    try {
      const r = await fetch(`/api/ads/${id}`, {
        method: "DELETE", credentials: "same-origin",
      });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      await reload();
      onChange?.();
    } catch (e) {
      setError(e.message || String(e));
    }
  };

  // platform ごとにグルーピング (定義順を維持)
  const grouped = useMemo(() => {
    const order = Object.keys(DESTINATION_PLATFORM_META);
    const map = {};
    order.forEach((k) => (map[k] = []));
    ads.forEach((a) => {
      if (!map[a.platform]) map[a.platform] = [];
      map[a.platform].push(a);
    });
    return order.map((k) => [k, map[k] || []]);
  }, [ads]);

  const adDisplayName = (ad) => {
    if (ad.label) return ad.label;
    const d = ad.data || {};
    if (ad.platform === "meta_instagram" && d.username) return "@" + d.username;
    if (d.page_name) return d.page_name;
    return ad.external_id;
  };

  return (
    <div
      className="complete-backdrop"
      onClick={onClose}
      style={{ zIndex: 200 }}
    >
      <div
        className="complete-modal"
        onClick={(e) => e.stopPropagation()}
        style={{ maxWidth: 720, width: "94vw" }}
      >
        <div className="complete-head">
          <h2>投稿先の管理</h2>
          <span className="spacer"></span>
          <button
            className="btn btn-soft btn-sm"
            onClick={handleSync}
            disabled={syncing}
            title="Meta OAuth レスポンスから FB / IG の投稿先を再投影する"
          >
            {syncing ? "同期中…" : "🔄 Meta から再同期"}
          </button>
          <button
            className="btn btn-ghost btn-sm"
            onClick={onClose}
            style={{ marginLeft: 8 }}
          >
            閉じる
          </button>
        </div>

        <div style={{ padding: "12px 18px", fontSize: 12, color: "var(--muted)" }}>
          ここで「有効」にした投稿先だけが、動画完成時の投稿候補に並びます。
          各プラットフォームで「デフォルト」に指定したものが、投稿時の既定の宛先になります。
        </div>

        {error && (
          <div style={{
            margin: "0 18px", padding: "8px 12px", borderRadius: 6,
            background: "rgba(244,67,54,0.1)", color: "#ff7878", fontSize: 12,
          }}>
            {error}
          </div>
        )}

        <div className="complete-body" style={{
          display: "block", padding: "12px 18px", maxHeight: "60vh", overflowY: "auto",
        }}>
          {loading && <div style={{ color: "var(--muted)" }}>読み込み中…</div>}

          {!loading && grouped.map(([platform, rows]) => {
            const meta = DESTINATION_PLATFORM_META[platform];
            return (
              <section key={platform} style={{ marginBottom: 18 }}>
                <div style={{
                  display: "flex", alignItems: "center", gap: 8,
                  marginBottom: 6, fontSize: 13, fontWeight: 600,
                }}>
                  <span style={{
                    display: "inline-block", width: 22, height: 22,
                    borderRadius: 4, background: meta.color, color: "#fff",
                    fontSize: 10, fontWeight: 700, lineHeight: "22px",
                    textAlign: "center",
                  }}>{meta.tag}</span>
                  <span>{meta.label}</span>
                  <span className="count" style={{ color: "var(--muted)" }}>
                    {rows.length}
                  </span>
                </div>
                {rows.length === 0 ? (
                  <div style={{
                    padding: "10px 12px", border: "1px dashed var(--border)",
                    borderRadius: 6, color: "var(--muted)", fontSize: 12,
                  }}>
                    未連携です。Meta 連携を完了するか「Meta から再同期」を押してください
                  </div>
                ) : (
                  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                    {rows.map((ad) => {
                      const isEditing = editing && editing.id === ad.id;
                      return (
                        <div
                          key={ad.id}
                          style={{
                            display: "grid",
                            gridTemplateColumns: "1fr auto auto auto",
                            alignItems: "center",
                            gap: 10,
                            padding: "8px 12px",
                            border: "1px solid var(--border)",
                            borderRadius: 6,
                            background: ad.enabled ? "var(--panel-2)" : "transparent",
                            opacity: ad.enabled ? 1 : 0.55,
                          }}
                        >
                          <div style={{ minWidth: 0 }}>
                            {isEditing ? (
                              <div style={{ display: "flex", gap: 6 }}>
                                <input
                                  autoFocus
                                  type="text"
                                  value={editing.label}
                                  onChange={(e) => setEditing({
                                    ...editing, label: e.target.value
                                  })}
                                  onKeyDown={(e) => {
                                    if (e.key === "Enter") {
                                      patch(ad.id, { label: editing.label });
                                      setEditing(null);
                                    } else if (e.key === "Escape") {
                                      setEditing(null);
                                    }
                                  }}
                                  style={{
                                    flex: 1, padding: "4px 8px", borderRadius: 4,
                                    border: "1px solid var(--border)",
                                    background: "var(--panel)", color: "var(--text)",
                                    fontSize: 13,
                                  }}
                                />
                                <button
                                  className="btn btn-soft btn-sm"
                                  onClick={() => {
                                    patch(ad.id, { label: editing.label });
                                    setEditing(null);
                                  }}
                                >保存</button>
                                <button
                                  className="btn btn-ghost btn-sm"
                                  onClick={() => setEditing(null)}
                                >取消</button>
                              </div>
                            ) : (
                              <div
                                onClick={() => setEditing({ id: ad.id, label: ad.label || "" })}
                                style={{ cursor: "pointer", overflow: "hidden" }}
                                title="クリックでラベルを編集"
                              >
                                <div style={{
                                  fontSize: 13, fontWeight: 500,
                                  whiteSpace: "nowrap",
                                  overflow: "hidden",
                                  textOverflow: "ellipsis",
                                }}>
                                  {adDisplayName(ad)}
                                </div>
                                <div style={{
                                  fontSize: 11, color: "var(--muted)",
                                  fontFamily: "var(--mono)",
                                  whiteSpace: "nowrap",
                                  overflow: "hidden",
                                  textOverflow: "ellipsis",
                                }}>
                                  {ad.external_id}
                                  {ad.platform === "meta_instagram" && ad.data?.source && (
                                    <span style={{
                                      marginLeft: 6, padding: "1px 6px",
                                      borderRadius: 8, fontSize: 10,
                                      background: ad.data.source === "ig_login"
                                        ? "rgba(228,64,95,0.18)"
                                        : "rgba(24,119,242,0.18)",
                                      color: ad.data.source === "ig_login"
                                        ? "#E4405F" : "#1877F2",
                                      fontFamily: "var(--font-sans)",
                                    }}>
                                      {ad.data.source === "ig_login"
                                        ? "IG ログイン"
                                        : "FB ページ経由"}
                                    </span>
                                  )}
                                </div>
                              </div>
                            )}
                          </div>

                          {/* デフォルト */}
                          <label
                            title="このプラットフォームの既定の投稿先"
                            style={{
                              display: "flex", alignItems: "center", gap: 4,
                              fontSize: 11, color: "var(--muted)",
                              opacity: ad.enabled ? 1 : 0.5,
                              cursor: ad.enabled ? "pointer" : "not-allowed",
                            }}
                          >
                            <input
                              type="radio"
                              name={`default-${platform}`}
                              checked={ad.is_default}
                              disabled={!ad.enabled}
                              onChange={() => patch(ad.id, { is_default: true })}
                            />
                            既定
                          </label>

                          {/* 有効/無効 */}
                          <label
                            title="投稿候補に出すかどうか"
                            style={{
                              display: "flex", alignItems: "center", gap: 4,
                              fontSize: 11, color: "var(--muted)",
                              cursor: "pointer",
                            }}
                          >
                            <input
                              type="checkbox"
                              checked={ad.enabled}
                              onChange={() => patch(ad.id, { enabled: !ad.enabled })}
                            />
                            有効
                          </label>

                          {/* 削除 */}
                          <button
                            className="tiny danger"
                            onClick={() => remove(ad.id)}
                            title="一覧から削除 (再同期で復活)"
                          >
                            削除
                          </button>
                        </div>
                      );
                    })}
                  </div>
                )}
              </section>
            );
          })}
        </div>
      </div>
    </div>
  );
}


function App() {
  // ログイン状態は GET /api/session で取得 (HttpOnly Cookie なので JS から見えない)。
  // null = 確認中, false = 未ログイン (LoginScreen 表示), true = ログイン済み。
  const [loggedIn, setLoggedIn] = useState(null);
  const [authEmail, setAuthEmail] = useState("");

  const reloadSession = useCallback(async () => {
    try {
      const r = await fetch("/api/session", { credentials: "same-origin" });
      if (r.ok) {
        const d = await r.json();
        setLoggedIn(!!d.authenticated);
        setAuthEmail(d.email || "");
      } else {
        setLoggedIn(false);
        setAuthEmail("");
      }
    } catch (_) {
      setLoggedIn(false);
      setAuthEmail("");
    }
  }, []);

  useEffect(() => { reloadSession(); }, [reloadSession]);

  const handleLogin = useCallback((email) => {
    setLoggedIn(true);
    setAuthEmail(email || "");
  }, []);

  const handleLogout = useCallback(async () => {
    try {
      await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
    } finally {
      setLoggedIn(false);
      setAuthEmail("");
    }
  }, []);

  const [hotelText, setHotelText] = useState("");
  const [materials, setMaterials] = useState([]); // { id, file, url, kind }
  const [settings, setSettings] = useState({
    angle: "foodie",
    telopStyle: "classic",
    telopPos: "center",
    speaker: "3",
  });

  // phase: "setup" -> "editing" -> "rendering" -> "done"
  const [phase, setPhase] = useState("setup");
  const [scenes, setScenes] = useState([]);
  const [selectedId, setSelectedId] = useState(null);
  const [generatingScript, setGeneratingScript] = useState(false);

  // 再生 (現在選択シーンのみ)
  const audioRef = useRef(null);
  const [playing, setPlaying] = useState(false);
  const [playProgress, setPlayProgress] = useState(0);

  // レンダージョブ
  const [jobId, setJobId] = useState(null);
  const [jobStatus, setJobStatus] = useState("queued");
  const [jobProgress, setJobProgress] = useState(0);
  const [jobLogs, setJobLogs] = useState([]);

  // SNS 配信先 (mock — FB/IG 以外。FB/IG は metaAuth から派生)
  // TikTok / YouTube は Coming soon のためデフォルト未接続にする。
  const [snsConnected, setSnsConnected] = useState([]);
  const [snsConnectOpen, setSnsConnectOpen] = useState(false);
  const [snsOAuthPlatform, setSnsOAuthPlatform] = useState(null);
  const [snsConnecting, setSnsConnecting] = useState(null);

  // Meta (FB + IG) 実 OAuth ステート
  // FB ログイン 1 回で FB ページ + 連携済み IG ビジネスを同時取得する仕様
  // (scripts/meta_oauth.py 参照)。よって disconnect は両方をまとめて解除する。
  const [metaAuth, setMetaAuth] = useState(null);
  // 投稿先一覧 (ads テーブル)。/api/ads から取得。OAuth 連携後 + 管理画面で
  // 変更があったときに reloadAds で再取得する。publish 時の宛先解決にも使う。
  const [ads, setAds] = useState([]);
  // 投稿先管理モーダルの開閉
  const [destinationsOpen, setDestinationsOpen] = useState(false);
  // 投稿先 ad_id (ads テーブルの id, number)。未選択なら is_default が使われる。
  const [selectedFbAdId, setSelectedFbAdId] = useState(null);
  const [selectedIgAdId, setSelectedIgAdId] = useState(null);
  const reloadMetaAuth = useCallback(async () => {
    try {
      const r = await fetch("/api/auth/status");
      setMetaAuth(await r.json());
    } catch (_) {
      setMetaAuth({ connected: false });
    }
  }, []);
  const reloadAds = useCallback(async () => {
    try {
      const r = await fetch("/api/ads", { credentials: "same-origin" });
      if (!r.ok) throw new Error(`status=${r.status}`);
      const d = await r.json();
      setAds(d.ads || []);
    } catch (_) {
      setAds([]);
    }
  }, []);
  useEffect(() => {
    reloadMetaAuth();
    reloadAds();
    const url = new URL(window.location.href);
    // ?connected=1 (FB OAuth 完了) または ?connected=ig (IG Login 完了)
    if (url.searchParams.get("connected")) {
      url.searchParams.delete("connected");
      window.history.replaceState({}, "", url.toString());
      reloadMetaAuth();
      reloadAds();
    }
    const onFocus = () => { reloadMetaAuth(); reloadAds(); };
    window.addEventListener("focus", onFocus);
    return () => window.removeEventListener("focus", onFocus);
  }, [reloadMetaAuth, reloadAds]);

  // FB/IG の既定 ad_id を自動選択 (有効な行のうち is_default、なければ先頭)。
  // ユーザが既に選択している場合 (= 一覧から消えた等で無効でない限り) は維持する。
  useEffect(() => {
    const fbCandidates = ads.filter(
      (a) => a.platform === "meta_facebook" && a.enabled
    );
    if (fbCandidates.length > 0 &&
        !fbCandidates.some((a) => a.id === selectedFbAdId)) {
      const def = fbCandidates.find((a) => a.is_default) || fbCandidates[0];
      setSelectedFbAdId(def.id);
    }
    const igCandidates = ads.filter(
      (a) => a.platform === "meta_instagram" && a.enabled
    );
    if (igCandidates.length > 0 &&
        !igCandidates.some((a) => a.id === selectedIgAdId)) {
      const def = igCandidates.find((a) => a.is_default) || igCandidates[0];
      setSelectedIgAdId(def.id);
    }
  }, [ads, selectedFbAdId, selectedIgAdId]);

  // 表示用の配信先一覧 — ads (FB/IG 行) + mock の他プラットフォーム
  // enabled な投稿先のみを「使える宛先」として並べる。
  const displayedSnsConnected = useMemo(() => {
    const real = [];
    const fbAds = ads.filter(
      (a) => a.platform === "meta_facebook" && a.enabled
    );
    const fbAd = fbAds.find((a) => a.id === selectedFbAdId) || fbAds[0];
    if (fbAd) {
      real.push({
        id: "meta-fb",
        platform: "facebook",
        handle: fbAd.label || fbAd.data?.page_name || "連携済み",
        state: "ok",
      });
    }
    const igAds = ads.filter(
      (a) => a.platform === "meta_instagram" && a.enabled
    );
    const igAd = igAds.find((a) => a.id === selectedIgAdId) || igAds[0];
    if (igAd) {
      const username = igAd.data?.username;
      real.push({
        id: "meta-ig",
        platform: "instagram",
        handle: igAd.label || (username ? "@" + username : "連携済み"),
        state: "ok",
      });
    }
    const mock = snsConnected.filter(
      (c) => c.platform !== "facebook" && c.platform !== "instagram"
    );
    return [...real, ...mock];
  }, [ads, snsConnected, selectedFbAdId, selectedIgAdId]);

  const handleSnsPick = useCallback((platform) => {
    // FB は Facebook ログイン経由 (FB ページ + 連携 IG ビジネスを一括取得)。
    // IG は Instagram Login API 経由で FB ページ非依存で単体連携する。
    if (platform.id === "facebook") {
      window.location.href = "/api/auth/facebook/start";
      return;
    }
    if (platform.id === "instagram") {
      window.location.href = "/api/auth/instagram/start";
      return;
    }
    setSnsOAuthPlatform(platform);
  }, []);
  const handleSnsDisconnect = useCallback(async (platformId) => {
    if (platformId === "facebook") {
      if (!window.confirm("Facebook の連携を解除しますか？ (Instagram Login は残ります)")) return;
      try {
        await fetch("/api/auth/facebook/disconnect", { method: "POST" });
      } finally {
        reloadMetaAuth();
      }
      return;
    }
    if (platformId === "instagram") {
      if (!window.confirm("Instagram の連携を解除しますか？ (Facebook 連携は残ります)")) return;
      try {
        await fetch("/api/auth/instagram/disconnect", { method: "POST" });
      } finally {
        reloadMetaAuth();
      }
      return;
    }
    const p = SNS_PLATFORMS.find((x) => x.id === platformId);
    const label = p ? p.name : platformId;
    if (!window.confirm(`${label} の連携を解除しますか？`)) return;
    setSnsConnected((prev) => prev.filter((c) => c.platform !== platformId));
  }, [reloadMetaAuth]);
  const handleSnsOAuthComplete = useCallback(() => {
    const p = snsOAuthPlatform;
    if (!p) return;
    setSnsOAuthPlatform(null);
    setSnsConnecting(p.id);
    setTimeout(() => {
      setSnsConnected((prev) => {
        if (prev.some((c) => c.platform === p.id)) return prev;
        return [
          ...prev,
          {
            id: "c" + (prev.length + 1) + "_" + Date.now(),
            platform: p.id,
            handle: mockSnsHandle(p.id),
            state: "ok",
          },
        ];
      });
      setSnsConnecting(null);
    }, 900);
  }, [snsOAuthPlatform]);

  // 過去ジョブ一覧 (setup 画面で表示)
  const [pastJobs, setPastJobs] = useState([]);
  const [pastJobsLoading, setPastJobsLoading] = useState(false);
  const [openingPastJob, setOpeningPastJob] = useState(null); // job_id

  const reloadPastJobs = useCallback(async () => {
    setPastJobsLoading(true);
    try {
      const r = await fetch("/api/jobs", { credentials: "same-origin" });
      if (!r.ok) throw new Error(`status=${r.status}`);
      const d = await r.json();
      setPastJobs(d.jobs || []);
    } catch (_) {
      // 一覧取得は静かに失敗させる (UI 上はリストが空になるだけ)
      setPastJobs([]);
    } finally {
      setPastJobsLoading(false);
    }
  }, []);

  // setup 画面に居て、ログイン済みのときに過去ジョブ一覧を取得する
  useEffect(() => {
    if (loggedIn !== true) return;
    if (phase !== "setup") return;
    reloadPastJobs();
  }, [loggedIn, phase, reloadPastJobs]);

  // トースト (openPastJob / deletePastJob などが showToast に依存するため、
  // それらの useCallback よりも前に宣言しておく必要がある — TDZ 対策)
  const [toasts, setToasts] = useState([]);
  const showToast = useCallback((msg, kind = "ok") => {
    const id = Date.now() + Math.random();
    setToasts((prev) => [...prev, { id, msg, kind }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 4500);
  }, []);

  // 過去ジョブを再編集モードで開く。
  // サーバ側の素材ファイル (`/public/<id>/media/...`) を fetch して Blob → File に
  // 変換し、新規アップロードと同じ shape (`{id, file, url, kind}`) で materials に
  // 詰め直す。これで以降の保存 (POST /api/jobs) 経路がそのまま流用できる。
  const openPastJob = useCallback(async (job) => {
    if (openingPastJob) return;
    setOpeningPastJob(job.id);
    try {
      const r = await fetch(`/api/jobs/${job.id}/payload`, {
        credentials: "same-origin",
      });
      if (!r.ok) throw new Error(`status=${r.status}`);
      const payload = await r.json();

      // 素材をブラウザに取り込む (1 ファイルずつ blob 化)
      const loadedMats = await Promise.all(
        (payload.materials || []).map(async (m, i) => {
          const resp = await fetch(m.url, { credentials: "same-origin" });
          if (!resp.ok) throw new Error(`素材取得失敗: ${m.filename}`);
          const blob = await resp.blob();
          const file = new File([blob], m.filename, {
            type: blob.type || (m.kind === "VIDEO" ? "video/mp4" : "image/jpeg"),
          });
          return {
            id: `m-${job.id}-${i}-${Math.random().toString(36).slice(2, 7)}`,
            file,
            url: URL.createObjectURL(file),
            kind: m.kind,
          };
        })
      );

      // 既存 materials の ObjectURL は破棄しておく
      setMaterials((prev) => {
        prev.forEach((p) => {
          if (p.url && p.url.startsWith("blob:")) URL.revokeObjectURL(p.url);
        });
        return loadedMats;
      });

      // ホテル本文
      setHotelText(payload.hotel_text || "");

      // 設定 (旧データは空でも UI 既定値にフォールバック)
      const s = payload.settings || {};
      setSettings((prev) => ({
        angle: s.angle || prev.angle,
        telopStyle: s.telop_style || prev.telopStyle,
        telopPos: s.telop_pos || prev.telopPos,
        speaker: s.speaker != null ? String(s.speaker) : prev.speaker,
      }));

      // 台本 → シーンを復元できれば editing フェーズへ。
      // 無ければ setup のまま (素材だけ並んだ状態) で残す。
      const sc = payload.script && payload.script.scenes;
      if (sc && sc.length > 0) {
        const newScenes = sc.map((sn, i) => ({
          id: `s${i + 1}`,
          telop: sn.telop || "",
          narration: sn.narration || "",
          material: loadedMats[i] || null,
          synth: "idle",
          audioUrl: null,
          audioDuration: null,
          prev: null,
        }));
        setScenes(newScenes);
        setSelectedId(newScenes[0]?.id || null);
        setPhase("editing");
        showToast(`過去ジョブを読み込みました (${newScenes.length} シーン)`, "ok");
      } else {
        // 台本が無い (失敗ジョブ等) — 素材だけ復元してホーム入力に戻す
        setScenes([]);
        setSelectedId(null);
        setPhase("setup");
        showToast("素材を読み込みました (台本未生成)", "ok");
      }
    } catch (err) {
      showToast("過去ジョブの読み込みに失敗: " + err.message, "err");
    } finally {
      setOpeningPastJob(null);
    }
  }, [openingPastJob, showToast]);

  const deletePastJob = useCallback(async (job) => {
    const label = job.hotel_text_excerpt
      ? `「${job.hotel_text_excerpt.slice(0, 30)}…」`
      : `(${job.id})`;
    if (!window.confirm(`${label} を削除しますか？\n素材・動画ファイルも一緒に削除されます。`)) {
      return;
    }
    try {
      const r = await fetch(`/api/jobs/${job.id}`, {
        method: "DELETE",
        credentials: "same-origin",
      });
      if (!r.ok) throw new Error(`status=${r.status}`);
      setPastJobs((prev) => prev.filter((j) => j.id !== job.id));
      showToast("削除しました", "ok");
    } catch (err) {
      showToast("削除に失敗: " + err.message, "err");
    }
  }, [showToast]);

  const selectedIndex = scenes.findIndex((s) => s.id === selectedId);
  const selectedScene = scenes[selectedIndex];

  // ===== 素材 =====
  const addFiles = (files) => {
    const additions = files.map((f) => ({
      id: `m-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
      file: f,
      url: URL.createObjectURL(f),
      kind: (f.type || "").startsWith("video/") ? "VIDEO" : "IMG",
    }));
    setMaterials((prev) => [...prev, ...additions]);
  };
  const removeMaterial = (idx) => {
    setMaterials((prev) => {
      const m = prev[idx];
      if (m && m.url && m.url.startsWith("blob:")) URL.revokeObjectURL(m.url);
      return prev.filter((_, i) => i !== idx);
    });
  };

  const moveMaterial = (idx, dir) => {
    setMaterials((prev) => {
      const j = idx + dir;
      if (j < 0 || j >= prev.length) return prev;
      const next = prev.slice();
      [next[idx], next[j]] = [next[j], next[idx]];
      return next;
    });
  };

  // ===== 台本生成 =====
  const handleGenerateScript = async () => {
    if (!hotelText.trim()) {
      showToast("ホテル情報を入力してください", "err");
      return;
    }
    if (materials.length === 0) {
      showToast("素材を 1 つ以上アップロードしてください", "err");
      return;
    }
    if (materials.length > 10) {
      showToast("素材は最大 10 個までです", "err");
      return;
    }
    setGeneratingScript(true);
    try {
      const fd = new FormData();
      fd.append("hotel_text", hotelText);
      fd.append("angle", settings.angle);
      fd.append("speaker", settings.speaker);
      fd.append("n_scenes", String(materials.length));
      const res = await fetch("/api/scripts", { method: "POST", body: fd });
      if (!res.ok) {
        const err = await res.text();
        throw new Error(`サーバエラー (${res.status}): ${err}`);
      }
      const { script } = await res.json();
      const newScenes = (script.scenes || []).map((s, i) => ({
        id: `s${i + 1}`,
        telop: s.telop || "",
        narration: s.narration || "",
        material: materials[i] || null,
        synth: "idle",
        audioUrl: null,
        audioDuration: null,
        prev: null,
      }));
      setScenes(newScenes);
      setSelectedId(newScenes[0]?.id || null);
      setPhase("editing");
      showToast(`台本を生成しました (${newScenes.length} シーン)`, "ok");
    } catch (err) {
      showToast("台本生成に失敗: " + err.message, "err");
    } finally {
      setGeneratingScript(false);
    }
  };

  // ===== シーン編集 =====
  const patchScene = useCallback((id, patch) => {
    setScenes((prev) =>
      prev.map((s) => {
        if (s.id !== id) return s;
        const next = { ...s, ...patch };
        // ナレーションが変わった場合は既存音声を破棄して未合成に戻す
        if (patch.narration !== undefined && patch.narration !== s.narration) {
          if (s.audioUrl) URL.revokeObjectURL(s.audioUrl);
          next.audioUrl = null;
          next.audioDuration = null;
          next.synth = "idle";
        }
        return next;
      })
    );
  }, []);

  const revertNarration = useCallback((id) => {
    setScenes((prev) =>
      prev.map((s) => {
        if (s.id !== id || !s.prev) return s;
        if (s.audioUrl) URL.revokeObjectURL(s.audioUrl);
        return {
          ...s,
          narration: s.prev.narration,
          audioUrl: null,
          audioDuration: null,
          synth: "idle",
          prev: null,
        };
      })
    );
  }, []);

  // ===== AI 修正 =====
  const rewriteNarration = async (sceneId, direction) => {
    const scene = scenes.find((s) => s.id === sceneId);
    if (!scene || !scene.narration.trim()) {
      showToast("ナレーションが空です", "err");
      return;
    }
    setScenes((prev) =>
      prev.map((s) => (s.id === sceneId ? { ...s, synth: "synth" } : s))
    );
    try {
      const fd = new FormData();
      fd.append("narration", scene.narration);
      fd.append("direction", direction);
      const res = await fetch("/api/narration/rewrite", { method: "POST", body: fd });
      if (!res.ok) {
        const err = await res.text();
        throw new Error(`サーバエラー (${res.status}): ${err}`);
      }
      const { narration: newNarr } = await res.json();
      setScenes((prev) =>
        prev.map((s) => {
          if (s.id !== sceneId) return s;
          if (s.audioUrl) URL.revokeObjectURL(s.audioUrl);
          return {
            ...s,
            narration: newNarr,
            audioUrl: null,
            audioDuration: null,
            synth: "idle",
            prev: { narration: scene.narration, from: direction },
          };
        })
      );
      showToast(`「${direction}」で修正しました`, "ok");
    } catch (err) {
      setScenes((prev) =>
        prev.map((s) => (s.id === sceneId ? { ...s, synth: "idle" } : s))
      );
      showToast("AI 修正に失敗: " + err.message, "err");
    }
  };

  // ===== ナレーション合成 + 再生 =====
  const playNarration = async () => {
    if (!selectedScene) return;
    if (!selectedScene.narration.trim()) {
      showToast("ナレーションが空です", "err");
      return;
    }
    const audio = audioRef.current;
    if (!audio) return;

    // 既に合成済みかつ最新ならそのまま再生
    if (selectedScene.synth === "ready" && selectedScene.audioUrl) {
      audio.src = selectedScene.audioUrl;
      try { audio.currentTime = 0; } catch (_) {}
      audio.play().catch(() => {});
      setPlaying(true);
      return;
    }

    // 合成 → 再生
    setScenes((prev) =>
      prev.map((s) => (s.id === selectedScene.id ? { ...s, synth: "synth" } : s))
    );
    try {
      const fd = new FormData();
      fd.append("text", selectedScene.narration);
      fd.append("speaker", settings.speaker);
      fd.append("speed_scale", "1.05");
      const res = await fetch("/api/narration", { method: "POST", body: fd });
      if (!res.ok) {
        const err = await res.text();
        throw new Error(`サーバエラー (${res.status}): ${err}`);
      }
      const blob = await res.blob();
      const url = URL.createObjectURL(blob);

      // duration を取るために一旦 metadata だけロード
      const tmp = new Audio();
      tmp.src = url;
      await new Promise((resolve) => {
        tmp.addEventListener("loadedmetadata", resolve, { once: true });
        tmp.addEventListener("error", resolve, { once: true });
      });
      const dur = isFinite(tmp.duration) ? tmp.duration : null;

      setScenes((prev) =>
        prev.map((s) => {
          if (s.id !== selectedScene.id) return s;
          if (s.audioUrl) URL.revokeObjectURL(s.audioUrl);
          return { ...s, synth: "ready", audioUrl: url, audioDuration: dur };
        })
      );

      audio.src = url;
      try { audio.currentTime = 0; } catch (_) {}
      audio.play().catch(() => {});
      setPlaying(true);
    } catch (err) {
      setScenes((prev) =>
        prev.map((s) => (s.id === selectedScene.id ? { ...s, synth: "error" } : s))
      );
      showToast("ナレーション合成に失敗: " + err.message, "err");
    }
  };

  const pauseNarration = () => {
    audioRef.current?.pause();
    setPlaying(false);
  };

  // ===== レンダリング =====
  const allReadyOptional = scenes.length > 0; // 合成は任意 (サーバ側で再合成される)

  const handleRender = async () => {
    if (scenes.length === 0) return;
    // 一時停止
    pauseNarration();

    const script = {
      scenes: scenes.map((s) => ({
        telop: s.telop || "",
        narration: s.narration || "",
        broll: s.material?.file?.name || "",
      })),
      speaker_id: parseInt(settings.speaker, 10),
      speed_scale: 1.05,
    };

    const fd = new FormData();
    fd.append("hotel_text", hotelText);
    fd.append("angle", settings.angle);
    fd.append("telop_style", settings.telopStyle);
    fd.append("telop_pos", settings.telopPos);
    fd.append("speaker", settings.speaker);
    fd.append("script_json", JSON.stringify(script));
    materials.forEach((m) => fd.append("images", m.file, m.file.name));

    setPhase("rendering");
    // 前回のジョブ ID が残っているとポーリングが古い "done" を拾ってしまうのでクリア
    setJobId(null);
    setJobStatus("queued");
    setJobProgress(0);
    setJobLogs([]);

    try {
      const res = await fetch("/api/jobs", { method: "POST", body: fd });
      if (!res.ok) {
        const err = await res.text();
        throw new Error(`サーバエラー (${res.status}): ${err}`);
      }
      const { job_id } = await res.json();
      setJobId(job_id);
    } catch (err) {
      setPhase("editing");
      showToast("ジョブ作成に失敗: " + err.message, "err");
    }
  };

  // ジョブのポーリング
  useEffect(() => {
    if (!jobId || phase !== "rendering") return;
    let stopped = false;
    let lastLogIdx = 0;
    const tick = async () => {
      try {
        const r = await fetch(`/api/jobs/${jobId}`);
        if (!r.ok) throw new Error(`status=${r.status}`);
        const job = await r.json();
        if (stopped) return;
        setJobStatus(job.status);
        setJobProgress(job.progress || 0);
        const all = job.logs || [];
        if (all.length > lastLogIdx) {
          setJobLogs((prev) => [...prev, ...all.slice(lastLogIdx)]);
          lastLogIdx = all.length;
        }
        if (job.status === "done") {
          setPhase("done");
          showToast("動画が完成しました 🎉", "ok");
          return;
        }
        if (job.status === "failed") {
          setPhase("editing");
          showToast("生成失敗: " + (job.error || "(unknown)"), "err");
          return;
        }
      } catch (err) {
        if (stopped) return;
        showToast("ジョブ取得失敗: " + err.message, "err");
      }
      if (!stopped) setTimeout(tick, 1500);
    };
    tick();
    return () => { stopped = true; };
  }, [jobId, phase, showToast]);

  // 再生位置の更新 (audio の timeupdate に紐づけ)
  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) return;
    const onTime = () => {
      if (audio.duration > 0) {
        setPlayProgress(audio.currentTime / audio.duration);
      }
    };
    const onEnded = () => {
      setPlayProgress(1);
      setPlaying(false);
    };
    const onPause = () => setPlaying(false);
    audio.addEventListener("timeupdate", onTime);
    audio.addEventListener("ended", onEnded);
    audio.addEventListener("pause", onPause);
    return () => {
      audio.removeEventListener("timeupdate", onTime);
      audio.removeEventListener("ended", onEnded);
      audio.removeEventListener("pause", onPause);
    };
  }, []);

  // シーン切替時に再生をリセット
  useEffect(() => {
    pauseNarration();
    setPlayProgress(0);
  }, [selectedId]);

  // キーボードショートカット (← → でシーン移動 / Esc で結果モーダルを閉じる)
  useEffect(() => {
    const onKey = (e) => {
      const tag = (e.target?.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea" || tag === "select") return;
      if (e.key === "ArrowLeft" && selectedIndex > 0) {
        setSelectedId(scenes[selectedIndex - 1].id);
      } else if (e.key === "ArrowRight" && selectedIndex < scenes.length - 1) {
        setSelectedId(scenes[selectedIndex + 1].id);
      } else if (e.key === "Escape") {
        if (phase === "done") setPhase("editing");
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [scenes, selectedIndex, phase]);

  const backToSetup = () => {
    // 素材 (materials) はそのまま残し、シーン (台本 + 合成済み音声) のみ破棄する。
    // setup 画面で素材を追加/削除/並び替えしてから再度「台本を生成」できるように。
    scenes.forEach((s) => s.audioUrl && URL.revokeObjectURL(s.audioUrl));
    setScenes([]);
    setSelectedId(null);
    setPhase("setup");
  };

  // ===== 統計 =====
  const totalDuration = scenes.reduce(
    (a, s) => a + (s.audioDuration || estDuration(s.narration)),
    0
  );
  const readyCount = scenes.filter((s) => s.synth === "ready").length;

  // ===== レンダー =====
  // セッション確認中はチラつき防止に空表示
  if (loggedIn === null) return null;
  if (loggedIn === false) {
    return <LoginScreen onLogin={handleLogin} />;
  }

  return (
    <div className="app">
      {/* Top bar */}
      <div className="topbar">
        <div className="brand">
          <div className="logo">H</div>
          ホテル動画ジェネレータ
        </div>
        <div className="crumbs">
          {phase === "setup"   && "入力"}
          {phase === "editing" && (<>入力 <span className="sep">/</span> 台本確認・編集</>)}
          {phase === "rendering" && (<>入力 <span className="sep">/</span> 台本 <span className="sep">/</span> 生成中</>)}
          {phase === "done"    && (<>入力 <span className="sep">/</span> 台本 <span className="sep">/</span> 完成</>)}
        </div>
        <div className="spacer"></div>
        <div className="save-state">
          <span className="dot"></span>
          {phase === "rendering" ? "生成中…" : phase === "done" ? "完成" : "編集中"}
        </div>
        {authEmail && (
          <div
            className="auth-info"
            style={{
              display: "flex",
              alignItems: "center",
              gap: 8,
              marginLeft: 12,
              fontSize: 12,
              color: "var(--muted)",
            }}
          >
            <button
              className="btn btn-ghost btn-sm"
              onClick={() => setDestinationsOpen(true)}
              title="投稿先 (FB ページ / IG / ...) の有効化や既定を管理"
            >
              📡 投稿先
            </button>
            <span style={{ fontFamily: "var(--mono)" }}>{authEmail}</span>
            <button
              className="btn btn-ghost btn-sm"
              onClick={handleLogout}
              title="ログアウト (Session Cookie を削除)"
            >
              ログアウト
            </button>
          </div>
        )}
      </div>

      <div className="workspace">
        {/* Left pane */}
        <aside className="left">
          {/* Hotel info */}
          <div className="section">
            <div className="section-head"><span>ホテル情報</span></div>
            <div className="hotel-summary">
              <textarea
                value={hotelText}
                onChange={(e) => setHotelText(e.target.value)}
                placeholder={HOTEL_TEXT_PLACEHOLDER}
                disabled={phase === "rendering"}
              />
            </div>
          </div>

          {/* Settings */}
          <div className="section">
            <div className="section-head"><span>動画設定</span></div>
            <div className="settings-grid">
              <label className="setting">
                <span>訴求軸</span>
                <select
                  value={settings.angle}
                  onChange={(e) => setSettings({ ...settings, angle: e.target.value })}
                  disabled={phase === "editing" || phase === "rendering"}
                >
                  {ANGLES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
                </select>
              </label>
              <label className="setting">
                <span>話者</span>
                <select
                  value={settings.speaker}
                  onChange={(e) => setSettings({ ...settings, speaker: e.target.value })}
                  disabled={phase === "rendering"}
                >
                  {SPEAKERS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
                </select>
              </label>
              <label className="setting">
                <span>テロップ色</span>
                <select
                  value={settings.telopStyle}
                  onChange={(e) => setSettings({ ...settings, telopStyle: e.target.value })}
                  disabled={phase === "rendering"}
                >
                  {TELOP_STYLES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
                </select>
              </label>
              <label className="setting">
                <span>テロップ位置</span>
                <select
                  value={settings.telopPos}
                  onChange={(e) => setSettings({ ...settings, telopPos: e.target.value })}
                  disabled={phase === "rendering"}
                >
                  {TELOP_POS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
                </select>
              </label>
            </div>
          </div>

          {/* Materials (setup 時のみ) */}
          {phase === "setup" && (
            <>
              <div className="section-head" style={{ padding: "12px 14px 6px" }}>
                <span>素材</span>
                <span className="count">{materials.length}/10</span>
                <span className="spacer"></span>
              </div>
              <UploadZone
                onAdd={addFiles}
                current={materials.length}
                max={10}
                disabled={phase === "rendering"}
              />
              {materials.length > 0 && (
                <div className="material-grid">
                  {materials.map((m, i) => (
                    <div className="material-tile" key={m.id}>
                      <div className="thumb">
                        <MaterialMedia material={m} />
                        <span className="idx-badge">#{i + 1}</span>
                        <span className="kind-tag">{m.kind}</span>
                      </div>
                      <div className="row">
                        <button
                          className="tiny"
                          onClick={() => moveMaterial(i, -1)}
                          disabled={i === 0}
                          title="左へ"
                        >◀</button>
                        <span
                          className="name"
                          title={m.file?.name || m.filename || ""}
                        >
                          {m.file?.name || m.filename || ""}
                        </span>
                        <button
                          className="tiny"
                          onClick={() => moveMaterial(i, 1)}
                          disabled={i === materials.length - 1}
                          title="右へ"
                        >▶</button>
                        <button
                          className="remove"
                          onClick={() => removeMaterial(i)}
                          title="削除"
                        >×</button>
                      </div>
                    </div>
                  ))}
                </div>
              )}
            </>
          )}

          {/* Scene list (editing 以降) */}
          {phase !== "setup" && (
            <>
              <div className="section-head" style={{ padding: "12px 14px 6px" }}>
                <span>シーン</span>
                <span className="count">{scenes.length}</span>
                <span className="spacer"></span>
                <button
                  className="tiny"
                  onClick={backToSetup}
                  disabled={phase === "rendering"}
                  title="入力に戻る (シーン破棄)"
                >
                  入力に戻る
                </button>
              </div>
              <div className="scene-list">
                {scenes.map((s, i) => (
                  <SceneRow
                    key={s.id}
                    scene={s}
                    index={i}
                    selected={s.id === selectedId}
                    onClick={() => setSelectedId(s.id)}
                  />
                ))}
              </div>
            </>
          )}

          {/* SNS 配信先 */}
          <SnsSection
            connected={displayedSnsConnected}
            onOpen={() => setSnsConnectOpen(true)}
            onDisconnect={handleSnsDisconnect}
          />
        </aside>

        {/* Right pane */}
        <main className="right">
          <div className="detail">
            {phase === "setup" ? (
              <div className="setup-pane">
                <PastJobsSection
                  jobs={pastJobs}
                  loading={pastJobsLoading}
                  opening={openingPastJob}
                  onOpen={openPastJob}
                  onDelete={deletePastJob}
                />
              <div className="onboarding">
                <div className="icon">🎬</div>
                <h2>ホテルのショート動画を自動で作る</h2>
                <p>
                  ホテルの紹介テキストと素材 (画像 / 動画 1〜10 個) を入れると、
                  AI が台本を組み立てて、9:16 縦型ショート動画 (1080×1920) を生成します。
                </p>
                <div className="steps">
                  <div className={cx("step", hotelText.trim() ? "done" : "active")}>
                    <span className="num">1</span>
                    <span className="label">左ペインに <strong style={{ color: "var(--text)" }}>ホテル情報</strong> を入力</span>
                    {hotelText.trim() && <span className="tag-pill ok">✓</span>}
                  </div>
                  <div className={cx("step",
                    materials.length > 0 ? "done"
                    : hotelText.trim() ? "active" : "")}>
                    <span className="num">2</span>
                    <span className="label">
                      <strong style={{ color: "var(--text)" }}>素材</strong> を 1〜10 個アップロード
                    </span>
                    {materials.length > 0 && (
                      <span className="tag-pill ok">{materials.length}</span>
                    )}
                  </div>
                  <div className={cx("step",
                    hotelText.trim() && materials.length > 0 ? "active" : "")}>
                    <span className="num">3</span>
                    <span className="label">下の「🧠 台本を生成」を押す</span>
                  </div>
                </div>
              </div>
              </div>
            ) : (
              <SceneEditor
                scene={selectedScene}
                sceneIndex={selectedIndex}
                totalScenes={scenes.length}
                settings={settings}
                onPatch={(patch) => patchScene(selectedId, patch)}
                onSettingChange={(key, val) =>
                  setSettings((prev) => ({ ...prev, [key]: val }))
                }
                onRewrite={(direction) => rewriteNarration(selectedId, direction)}
                onPrev={() =>
                  selectedIndex > 0 && setSelectedId(scenes[selectedIndex - 1].id)
                }
                onNext={() =>
                  selectedIndex < scenes.length - 1 &&
                  setSelectedId(scenes[selectedIndex + 1].id)
                }
                onPlay={playNarration}
                onPause={pauseNarration}
                playing={playing}
                playProgress={playProgress}
                revert={() => revertNarration(selectedId)}
              />
            )}
          </div>

          {/* Action bar */}
          <div className="actionbar">
            {phase === "setup" ? (
              <>
                <div className="stat">
                  <span className="tag-pill info">{materials.length}</span>
                  <span>素材 <strong>{materials.length}</strong> / 10 個</span>
                  <span style={{ marginLeft: 12, color: "var(--muted)" }}>
                    {hotelText.trim()
                      ? `ホテル情報 ${hotelText.length} 文字`
                      : "ホテル情報 未入力"}
                  </span>
                </div>
                <div style={{ flex: 1 }}></div>
                <button
                  className="btn btn-primary btn-lg"
                  onClick={handleGenerateScript}
                  disabled={
                    generatingScript ||
                    !hotelText.trim() ||
                    materials.length === 0
                  }
                >
                  {generatingScript ? "🔄 台本を生成中..." : "🧠 台本を生成"}
                </button>
              </>
            ) : (
              <>
                <div className="stat">
                  <span className="tag-pill ok">{readyCount}</span>
                  <span className="tag-pill idle">{scenes.length - readyCount}</span>
                  <span style={{ marginLeft: 4 }}>
                    合成済 <strong>{readyCount}</strong> / {scenes.length} シーン
                  </span>
                  <span style={{ marginLeft: 12, color: "var(--muted)" }}>
                    推定尺 <strong>{totalDuration.toFixed(1)}s</strong>
                  </span>
                </div>
                <div style={{ flex: 1 }}></div>
                <button
                  className="btn btn-ghost btn-lg"
                  onClick={backToSetup}
                  disabled={phase === "rendering"}
                >
                  ← 入力に戻る
                </button>
                <button
                  className="btn btn-primary btn-lg"
                  onClick={handleRender}
                  disabled={!allReadyOptional || phase === "rendering"}
                  title={
                    allReadyOptional
                      ? "サーバ側で改めてナレーションが合成されます"
                      : "シーンがありません"
                  }
                >
                  🎬 動画を生成
                </button>
              </>
            )}
          </div>
        </main>
      </div>

      {/* 隠し audio (シーン編集ペインの再生用) */}
      <audio ref={audioRef} hidden></audio>

      {/* 進捗モーダル */}
      {phase === "rendering" && (
        <ProgressModal
          status={jobStatus}
          progress={jobProgress}
          phase={jobStatus}
          logs={jobLogs}
        />
      )}

      {/* 結果モーダル */}
      {phase === "done" && jobId && (
        <ResultModal
          jobId={jobId}
          scenes={scenes}
          settings={settings}
          connectedChannels={displayedSnsConnected}
          ads={ads}
          selectedFbAdId={selectedFbAdId}
          setSelectedFbAdId={setSelectedFbAdId}
          selectedIgAdId={selectedIgAdId}
          setSelectedIgAdId={setSelectedIgAdId}
          onOpenConnectChannels={() => setSnsConnectOpen(true)}
          onOpenDestinations={() => setDestinationsOpen(true)}
          onClose={() => setPhase("editing")}
        />
      )}

      {/* 投稿先管理モーダル */}
      {destinationsOpen && (
        <DestinationsModal
          onClose={() => setDestinationsOpen(false)}
          onChange={() => { reloadAds(); reloadMetaAuth(); }}
        />
      )}

      {/* SNS 配信先: 接続モーダル */}
      {snsConnectOpen && (
        <SnsConnectModal
          connectedIds={displayedSnsConnected.map((c) => c.platform)}
          connecting={snsConnecting}
          onPick={handleSnsPick}
          onDisconnect={handleSnsDisconnect}
          onClose={() => setSnsConnectOpen(false)}
        />
      )}

      {/* SNS 配信先: OAuth 風シミュレーション */}
      {snsOAuthPlatform && (
        <SnsOAuthModal
          platform={snsOAuthPlatform}
          onCancel={() => setSnsOAuthPlatform(null)}
          onComplete={handleSnsOAuthComplete}
        />
      )}

      {/* トースト */}
      <ToastStack toasts={toasts} />
    </div>
  );
}

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