// Reusable building blocks for the MeshForge UI.
const { useState, useRef, useEffect, useMemo, useCallback } = React;

// ---------- Brand mark ----------
function BrandLogo({ className = "", busy = false, size = 34 }) {
  return (
    <div className={["brand-logo", className, busy ? "busy" : ""].filter(Boolean).join(" ")}>
      <svg width={size} height={size} viewBox="0 0 1024 1024" fill="none" aria-hidden="true">
        <polygon points="512,236 751.023,374 751.023,650 512,788 272.977,650 272.977,374" stroke="currentColor" strokeWidth="40" strokeLinecap="round" strokeLinejoin="round" />
        <path d="M512 236 512 788" stroke="currentColor" strokeWidth="40" strokeLinecap="round" strokeLinejoin="round" />
        <path d="M272.977 374 512 512 751.023 374" stroke="currentColor" strokeWidth="40" strokeLinecap="round" strokeLinejoin="round" />
        <path d="M272.977 650 512 512 751.023 650" stroke="currentColor" strokeWidth="40" strokeLinecap="round" strokeLinejoin="round" />
        <circle cx="512" cy="236" r="38" fill="currentColor" />
        <circle cx="751.023" cy="374" r="38" fill="currentColor" />
        <circle cx="751.023" cy="650" r="38" fill="currentColor" />
        <circle cx="512" cy="788" r="38" fill="currentColor" />
        <circle cx="272.977" cy="650" r="38" fill="currentColor" />
        <circle cx="272.977" cy="374" r="38" fill="currentColor" />
      </svg>
    </div>
  );
}

// ---------- Sidebar ----------
function Sidebar({ recent, activeId, onSelect, onDelete, onNew, onSettings, workflow = "full", onWorkflow, view = "home", onView, authUser = null, authChecked = true, brandBusy = false }) {
  const items = [
    { id: "full", icon: I.Cube, label: "完整资产" },
    { id: "animation", icon: I.Footprints, label: "仅动画" },
    { id: "texture", icon: I.Texture, label: "仅贴图" },
    { id: "ui", icon: I.Image, label: "游戏 UI" },
    { id: "effects", icon: I.Effects, label: "特效" },
  ];
  const libNav = [
    { id: "library", icon: I.Folder, label: "我的资产" },
    { id: "community", icon: I.Search, label: "社区资产" },
  ];
  return (
    <aside className="sidebar">
      <div className="brand">
        <div className="brand-mark">
          <BrandLogo busy={brandBusy} />
        </div>
        MeshForge
      </div>

      <div className="nav-group" style={{ marginTop: 0 }}>
        {libNav.map((it) => {
          const Icon = it.icon;
          return (
            <button
              key={it.id}
              type="button"
              className={"nav-item" + (view === it.id ? " active" : "")}
              onClick={() => onView && onView(it.id)}
            >
              <Icon className="ico" size={16} />
              <span>{it.label}</span>
            </button>
          );
        })}
      </div>

      <div className="nav-group">
        <div className="nav-group-title">工作流</div>
        {items.map((it) => {
          const Icon = it.icon;
          return (
            <button
              key={it.id}
              type="button"
              className={"nav-item" + (view === "home" && workflow === it.id ? " active" : "")}
              onClick={() => onWorkflow && onWorkflow(it.id)}
            >
              <Icon className="ico" size={16} />
              <span>{it.label}</span>
              {it.badge && <span className="badge">{it.badge}</span>}
            </button>
          );
        })}
      </div>

      <div className="nav-group" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
        <div className="nav-group-title">最近会话</div>
        <div className="recent-list">
          {recent.map((s) => (
            <div
              key={s.id}
              className={"recent-item" + (s.id === activeId ? " active" : "")}
              onClick={() => onSelect(s.id)}
              title={s.prompt}
            >
              <span className={"dot" + (s.live ? " live" : "")} />
              <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>{s.title}</span>
              {/* Delete button — appears on hover (CSS .recent-del-btn).
                  Stops propagation so the click doesn't also select the row. */}
              {onDelete && (
                <button
                  type="button"
                  className="recent-del-btn"
                  title="删除会话"
                  onClick={(e) => { e.stopPropagation(); onDelete(s.id); }}
                >
                  <I.X size={11} />
                </button>
              )}
            </div>
          ))}
        </div>
      </div>

      <div className="sidebar-foot">
        <AccountAvatarMenu user={authUser} authChecked={authChecked} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 13, color: "var(--ink)", fontWeight: 500 }}>
            {authUser ? authUser.username : (authChecked ? "未登录" : "检查登录中")}
          </div>
          <div style={{ fontSize: 11, color: "var(--ink-4)" }}>
            {authUser ? "已登录" : "登录后可对话"}
          </div>
        </div>
        <button className="icon-btn" title="设置" onClick={onSettings}><I.Settings size={16} /></button>
      </div>
    </aside>
  );
}

// ---------- Account avatar popover ----------
function AccountAvatarMenu({ user: initialUser = null, authChecked = true }) {
  const [open, setOpen] = useState(false);
  const [user, setUser] = useState(initialUser);
  const [usage, setUsage] = useState(null);
  const [credits, setCredits] = useState(null);
  const ref = useRef(null);

  useEffect(() => { setUser(initialUser); }, [initialUser?.id, initialUser?.username]);

  useEffect(() => {
    if (!open) return;
    const close = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [open]);

  // Lazy-load user info + usage when the menu opens. Credits balance is
  // the user-facing number now; legacy USD usage is kept for advanced
  // viewers but no longer drives any paywall — see docs/billing-system.md.
  useEffect(() => {
    if (!open) return;
    (async () => {
      try {
        const me = await window.MCAPI.auth.me();
        if (me && me.authenticated) setUser(me);
        else setUser(null);
      } catch {}
      try {
        const u = await window.MCAPI.billing.usage();
        setUsage(u);
      } catch {}
      try {
        const c = await window.MCAPI.credits.balance();
        setCredits(c);
      } catch {}
    })();
  }, [open]);

  const onLogout = async () => {
    try { await window.MCAPI.auth.logout(); } catch {}
    setOpen(false);
    setUser(null);
    window.location.hash = "#/login";
  };

  const initial = user ? (user.username || "?")[0].toUpperCase() : "?";
  const lifetime = usage?.lifetime || {};
  const quota = usage?.quota || {};
  const usedUsd = quota.used_usd ?? lifetime.total_cost_usd ?? lifetime.est_cost_usd ?? 0;
  const quotaUsd = quota.quota_usd ?? user?.quota_usd ?? 0;
  const remainingUsd = quota.remaining_usd ?? Math.max(0, quotaUsd - usedUsd);
  const totalTokens = lifetime.total_tokens || lifetime.llm_total_tokens || 0;
  const liveCost = usage?.live?.total_cost_usd || 0;
  const livePct = quotaUsd ? Math.min(100, Math.round((usedUsd / quotaUsd) * 100)) : 0;

  // Paywall-driven badge state. The cookie carries credits too (see
  // /api/auth/me) but we re-fetch on open so the badge stays accurate
  // right after a Stripe checkout / refund event.
  const planName = (credits?.plan || user?.plan || "free").toLowerCase();
  const isPro = planName === "pro";
  const subStatus = credits?.subscription_status || user?.subscription_status || null;
  const creditsBalance = Number(
    credits?.credits_balance ?? user?.credits_balance ?? 0
  );
  const creditsTotal = Number(
    credits?.credits_total_this_cycle ?? user?.credits_total_this_cycle ?? 0
  );
  const creditsPct = creditsTotal
    ? Math.max(0, Math.min(100, Math.round((creditsBalance / creditsTotal) * 100)))
    : 0;
  // Color thresholds for the badge bar — red < 10% balance, green for Pro
  // when balance is healthy, amber otherwise.
  const badgeTone = creditsBalance <= 0
    ? "danger"
    : (creditsBalance < Math.max(5, creditsTotal * 0.1) ? "warn" : (isPro ? "ok" : "neutral"));

  return (
    <div className="account-pop-wrap" ref={ref}>
      <button className="avatar avatar-btn" onClick={() => setOpen((v) => !v)} title="账户">
        {initial}
      </button>
      {open && (
        <div className="account-pop">
          <div className="account-pop-head">
            <div className="avatar lg">{initial}</div>
            <div>
              <div className="account-pop-name">{user ? user.username : "未登录"}</div>
              <div className="account-pop-email">
                {user ? `id: ${user.id || ""}` : "去登录解锁所有功能"}
              </div>
            </div>
          </div>
          <div className="account-pop-row">
            <span className={`plan-pill plan-pill-${isPro ? "pro" : "free"}`}>
              {isPro ? "Pro" : "Free"}
            </span>
            <span className="account-pop-meta">
              {subStatus === "past_due"
                ? "⚠️ 续费失败，请更新支付方式"
                : (isPro ? "已订阅 · $19.99/月" : "未订阅")}
            </span>
          </div>
          <div className="account-pop-stats">
            <div className="account-stat">
              <div className="account-stat-label">credits 余额</div>
              <div
                className={`account-stat-bar tone-${badgeTone}`}
                style={{ position: "relative" }}
              >
                <div
                  className="fill"
                  style={{
                    width: creditsPct + "%",
                    background:
                      badgeTone === "danger" ? "#dc2626"
                      : badgeTone === "warn" ? "#d97706"
                      : badgeTone === "ok" ? "#16a34a"
                      : "var(--ink-4)",
                  }}
                />
              </div>
              <div className="account-stat-val">
                {creditsBalance} {creditsTotal ? `/ ${creditsTotal}` : ""} credits
                {!isPro && (
                  <button
                    className="ghost-btn"
                    style={{ marginLeft: 8, padding: "1px 6px", fontSize: 11 }}
                    onClick={() => { setOpen(false); window.location.hash = "#/upgrade"; }}
                  >
                    升级 Pro →
                  </button>
                )}
              </div>
            </div>
          </div>
          <div className="account-pop-divider" />
          {!user && (
            <button className="account-pop-item" onClick={() => {
              setOpen(false); window.location.hash = "#/login";
            }}>
              <I.Settings size={14} /> 登录 / 注册
            </button>
          )}
          <button
            className="account-pop-item"
            onClick={() => { setOpen(false); window.location.hash = "#/upgrade"; }}
          >
            <I.Settings size={14} /> {isPro ? "管理订阅" : "升级 Pro · $19.99/月"}
          </button>
          {isPro && (
            <button
              className="account-pop-item"
              onClick={async () => {
                setOpen(false);
                try {
                  const r = await window.MCAPI.stripe.portal();
                  if (r && r.url) window.location.href = r.url;
                } catch (e) {
                  alert("无法打开订阅管理：" + (e?.message || e));
                }
              }}
            >
              <I.Settings size={14} /> 取消 / 更换支付方式
            </button>
          )}
          <button className="account-pop-item" onClick={() => { setOpen(false); window.location.hash = "#/settings/account"; }}>
            <I.Settings size={14} /> 账户设置
          </button>
          <button className="account-pop-item" onClick={() => { setOpen(false); window.location.hash = "#/billing"; }}>
            <I.Refresh size={14} /> 用量与账单（内部成本）
          </button>
          {user && (
            <>
              <div className="account-pop-divider" />
              <button className="account-pop-item danger" onClick={onLogout}>
                退出登录
              </button>
            </>
          )}
        </div>
      )}
    </div>
  );
}

// ---------- Composer (used both in home and bottom of workspace) ----------
function Composer({
  value, onChange,
  imageData, imageName,
  onPickImage, onClearImage,
  style, onStyleChange,
  onSubmit,
  busy = false,
  onStop,
  showStyle = true,
  large = false,
  placeholder = "描述你想要的角色、物件或场景…",
}) {
  const fileRef = useRef(null);
  const canSubmit = !!String(value || "").trim();
  const onPrimary = () => {
    if (busy) {
      onStop?.();
      return;
    }
    onSubmit?.();
  };
  const onFile = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const reader = new FileReader();
    reader.onload = () => onPickImage?.(reader.result, f.name);
    reader.readAsDataURL(f);
  };
  return (
    <div className="composer">
      {imageData && (
        <div className="composer-attachments">
          <div className="attachment-chip">
            <img src={imageData} alt="ref" />
            <span>{imageName || "参考图"}</span>
            <button className="x" onClick={onClearImage} title="移除">
              <I.X size={12} />
            </button>
          </div>
        </div>
      )}
      <textarea
        rows={large ? 3 : 2}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        onKeyDown={(e) => {
          if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
            e.preventDefault();
            onPrimary();
          }
        }}
      />
      <div className="composer-bar">
        <button
          className="icon-btn"
          title="添加参考图"
          onClick={() => fileRef.current?.click()}
        >
          <I.Image size={17} />
        </button>
        <input ref={fileRef} type="file" accept="image/*" hidden onChange={onFile} />

        {showStyle && (
          <div className="style-group">
            <span className="style-label">纹理风格：</span>
            <div className="style-pills">
              <button
                className={"style-pill" + (style === "pixel" ? " active" : "")}
                onClick={() => onStyleChange("pixel")}
              >
                像素
              </button>
              <button
                className={"style-pill" + (style === "pbr" ? " active" : "")}
                onClick={() => onStyleChange("pbr")}
              >
                PBR
              </button>
            </div>
          </div>
        )}

        <ModelPicker />

        <button
          className={"send-btn" + (busy ? " stop" : "")}
          onClick={onPrimary}
          disabled={!busy && !canSubmit}
          title={busy ? "中止进程" : "生成 (Ctrl/⌘ + Enter)"}
          aria-label={busy ? "中止进程" : "发送"}
        >
          {busy ? <span className="send-stop-square" aria-hidden="true" /> : <I.Send size={16} />}
        </button>
      </div>
    </div>
  );
}

// ---------- Model picker (LLM provider dropdown) ----------
function ModelPicker() {
  const [open, setOpen] = useState(false);
  const [available, setAvailable] = useState([]);
  const [selected, setSelected] = useState(null);
  const ref = useRef(null);

  // Load real list from /api/admin/llm_model on mount.
  useEffect(() => {
    (async () => {
      try {
        const r = await window.MCAPI.admin.getLlmModel();
        setAvailable(r.available || []);
        setSelected(r.model);
      } catch {
        setSelected("gemini-3.1-pro-preview");
      }
    })();
  }, []);

  useEffect(() => {
    if (!open) return;
    const close = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [open]);

  const onPick = async (id) => {
    setOpen(false);
    setSelected(id);
    // Persist user's preference + flip the backend's runtime LLM_MODEL.
    try {
      localStorage.setItem("MCSKIN_LLM_MODEL_v1", id);
      await window.MCAPI.admin.setLlmModel(id);
    } catch (e) {
      console.warn("model switch failed:", e);
    }
  };

  // Build model rows from the server's known list with vendor + tag tags.
  const items = available.map((id) => ({
    id,
    label: id,
    vendor: id.startsWith("claude") ? "Anthropic" : id.startsWith("gemini") ? "Google" : "Other",
    tag: id.includes("flash") || id.includes("haiku") ? "FAST"
       : id.includes("opus") ? "OPUS"
       : id.includes("3.1") ? "PRO"
       : "STD",
  }));
  const cur = items.find((m) => m.id === selected) || items[0] || { id: selected, label: selected || "—", tag: "" };
  return (
    <div className="model-picker" ref={ref}>
      <button className="model-trigger" onClick={() => setOpen((v) => !v)} title="选择推理模型">
        <span className="model-trigger-name">{cur.label}</span>
        <I.ChevronDown size={12} style={{ transform: open ? "rotate(180deg)" : "none", transition: "transform .15s" }} />
      </button>
      {open && (
        <div className="model-menu">
          <div className="model-menu-head">推理模型</div>
          {items.map((m) => (
            <button
              key={m.id}
              className={"model-item" + (m.id === selected ? " active" : "")}
              onClick={() => onPick(m.id)}
            >
              <span className="model-item-tag">{m.tag}</span>
              <span className="model-item-main">
                <span className="model-item-name">{m.label}</span>
                <span className="model-item-vendor">{m.vendor}</span>
              </span>
              {m.id === selected && <I.Check size={14} />}
            </button>
          ))}
          <div className="model-menu-divider" />
          <button className="model-item add" onClick={() => { setOpen(false); window.location.hash = "#/settings/models"; }}>
            <span className="model-item-tag plus">+</span>
            <span className="model-item-main">
              <span className="model-item-name">添加模型…</span>
              <span className="model-item-vendor">支持 DeepSeek · OpenAI · Google · Claude</span>
            </span>
          </button>
        </div>
      )}
    </div>
  );
}
function DirToggle({ value, onChange }) {
  return (
    <div className="dir-toggle">
      {[1, 2, 4, 8].map((n) => (
        <button
          key={n}
          className={value === n ? "active" : ""}
          onClick={() => onChange(n)}
          title={n === 1 ? "单向" : n === 2 ? "左右镜像" : n === 4 ? "前后左右" : "八方向"}
        >
          @{n}
        </button>
      ))}
    </div>
  );
}

// ---------- Stage progress card ----------
function StageRow({ name, detail, time, status }) {
  return (
    <div className="stage-row">
      <div className={"stage-status " + status}>
        {status === "done" && <I.Check size={11} />}
        {status === "active" && <I.Loader size={11} />}
        {status === "error" && <I.X size={11} />}
      </div>
      <div>
        <span className="stage-name">{name}</span>
        {detail && <span className="stage-detail">{detail}</span>}
      </div>
      <div className="stage-time">{time}</div>
    </div>
  );
}

function StageCard({ stages }) {
  return (
    <div className="stage-card">
      <div className="stage-list">
        {stages.map((s) => <StageRow key={s.name} {...s} />)}
      </div>
    </div>
  );
}

// ---------- Spec card ----------
function SpecCard({ spec }) {
  return (
    <div className="info-card">
      <div className="info-card-head">
        <I.Cpu className="ico" />
        <span>Analyst — 模型规格</span>
      </div>
      <dl className="spec-grid">
        <dt>名称</dt><dd>{spec.name}</dd>
        <dt>分辨率</dt><dd>{spec.resolution}</dd>
        <dt>对称</dt><dd>{spec.symmetric ? "true" : "false"}</dd>
        <dt>骨骼组</dt><dd>{spec.groups}</dd>
      </dl>
    </div>
  );
}

// ---------- Animation editor ----------
const ANIM_PRESETS = ["待机", "走路", "奔跑", "攻击", "受击", "死亡", "跳跃", "飞行"];

function AnimationEditor({ anims, setAnims, onConfirm }) {
  const [extra, setExtra] = useState("");
  const update = (id, patch) =>
    setAnims((p) => p.map((a) => (a.id === id ? { ...a, ...patch } : a)));
  const remove = (id) => setAnims((p) => p.filter((a) => a.id !== id));
  const add = (name) => {
    const trimmed = name.trim();
    if (!trimmed) return;
    setAnims((p) => [...p, { id: "a-" + Date.now().toString(36), name: trimmed, dirs: 1 }]);
  };
  return (
    <div className="info-card">
      <div className="info-card-head">
        <I.Footprints className="ico" />
        <span>动画 — 自然语言驱动</span>
      </div>
      <div className="anim-list">
        {anims.map((a) => (
          <div key={a.id} className="anim-row">
            <input
              className="anim-name"
              value={a.name}
              onChange={(e) => update(a.id, { name: e.target.value })}
            />
            <DirToggle value={a.dirs} onChange={(v) => update(a.id, { dirs: v })} />
            <button className="x" onClick={() => remove(a.id)} title="移除">
              <I.X size={13} />
            </button>
          </div>
        ))}
      </div>
      <div className="add-anim">
        <input
          placeholder="加一个动作,如 攻击@2"
          value={extra}
          onChange={(e) => setExtra(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              add(extra);
              setExtra("");
            }
          }}
        />
        <button className="ghost-btn" onClick={() => { add(extra); setExtra(""); }}>
          <I.Plus size={14} /> 添加
        </button>
        <button className="primary-btn" onClick={onConfirm}>
          生成动画
        </button>
      </div>
      <div className="preset-row">
        {ANIM_PRESETS.filter((p) => !anims.find((a) => a.name === p)).map((p) => (
          <button key={p} className="preset-chip" onClick={() => add(p)}>
            + {p}
          </button>
        ))}
      </div>
    </div>
  );
}

// ---------- Export card ----------
function ExportCard({ files, ctx, name }) {
  // Real download glue. Each row's [download] button calls a per-extension
  // handler hitting the FastAPI backend (or, for bbmodel, just serializing
  // ctx.bbmodel client-side — bbmodel is just JSON). The "download all" bar
  // grabs the standard /api/export zip + saves it as one blob.
  const safeName = name || "model";

  const triggerDownload = (blob, filename) => {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = filename; document.body.appendChild(a);
    a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
  };

  // Fire-and-forget persistence: every download path also saves the
  // session into My Assets so the asset appears there even when the
  // user only ever clicks .bbmodel or the texture PNG (those paths
  // don't go through /api/export and would otherwise leave nothing on
  // disk). The backend write is idempotent on session_id, so GLB/ZIP
  // paths that already persist via /api/export are harmless to also
  // call here (server-side save_library_record overwrites the same
  // record).
  const persistToLibrary = () => {
    try { window.MCAPI?.library?.save?.(ctx); } catch {}
  };

  const downloadOne = async (f) => {
    if (!ctx) {
      alert("还没有可导出的资产 (ctx 为空)");
      return;
    }
    try {
      if (f.ext === "BB" || /\.bbmodel$/i.test(f.name)) {
        // bbmodel is plain JSON sitting on ctx.bbmodel — no backend
        // call for the download itself, so save to library explicitly.
        persistToLibrary();
        const blob = new Blob(
          [JSON.stringify(ctx.bbmodel || {}, null, 2)],
          { type: "application/json" }
        );
        return triggerDownload(blob, f.name || `${safeName}.bbmodel`);
      }
      if (f.ext === "GLB" || /\.glb$/i.test(f.name)) {
        // /api/export/glb already persists, but call save() too for the
        // belt-and-suspenders consistency of "every download = saved".
        persistToLibrary();
        const blob = await window.MCAPI.exportGlb(ctx);
        return triggerDownload(blob, f.name || `${safeName}.glb`);
      }
      if (f.ext === "ZIP" || /\.zip$/i.test(f.name)) {
        // /api/export already persists.
        persistToLibrary();
        const resp = await fetch("/api/export", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(ctx),
        });
        if (!resp.ok) throw new Error("HTTP " + resp.status);
        return triggerDownload(await resp.blob(), f.name || `${safeName}_pack.zip`);
      }
      if (f.ext === "PNG" || /\.png$/i.test(f.name)) {
        // Texture PNG comes from ctx.texture_png_base64 — pure client
        // decode, no backend round-trip otherwise.
        persistToLibrary();
        const b64 = ctx.texture_png_base64;
        if (!b64) {
          alert("当前会话没有贴图,无法导出 PNG");
          return;
        }
        const binary = atob(b64);
        const bytes = new Uint8Array(binary.length);
        for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
        const blob = new Blob([bytes], { type: "image/png" });
        return triggerDownload(blob, f.name || `${safeName}_texture.png`);
      }
    } catch (e) {
      alert(`下载失败: ${e && e.message || e}`);
    }
  };

  const downloadAll = () => downloadOne({ ext: "ZIP" });

  return (
    <div className="export-card">
      <div className="info-card-head">
        <I.Download className="ico" />
        <span>导出 — 已生成 {files.length} 个文件</span>
      </div>
      <div className="file-grid">
        {files.map((f) => (
          <div key={f.name} className="file-row">
            <div className="file-icon">{f.ext}</div>
            <div className="file-meta">
              <div className="file-name">{f.name}</div>
              <div className="file-size">{f.size}</div>
            </div>
            <button className="dl" title="下载" onClick={() => downloadOne(f)}>
              <I.Download size={13} />
            </button>
          </div>
        ))}
      </div>
      <div className="row" style={{ marginTop: 12, gap: 8 }}>
        <button className="primary-btn" style={{ flex: 1 }} onClick={downloadAll}>
          <I.Download size={14} /> 下载全部 (zip)
        </button>
        <button
          className="ghost-btn"
          onClick={() => downloadOne({ ext: "BB", name: `${safeName}.bbmodel` })}
          title="下载 .bbmodel 后用 Blockbench 打开"
        >
          <I.Folder size={14} /> 下载 .bbmodel
        </button>
      </div>
    </div>
  );
}

function countOutlinerBones(nodes) {
  let count = 0;
  for (const node of (nodes || [])) {
    if (!node || typeof node !== "object") continue;
    count += 1 + countOutlinerBones(node.children || []);
  }
  return count;
}

function GlbModelPreview({ src, bbmodel, animationName = "", playing = true }) {
  const viewerRef = useRef(null);
  const frameRef = useRef(null);
  const [loaded, setLoaded] = useState(false);
  const [ready, setReady] = useState(
    () => typeof customElements !== "undefined" && !!customElements.get("model-viewer"),
  );
  // Trigger model-viewer's built-in auto-framing when the viewer or its
  // src changes. With `camera-orbit="… auto"` and `bounds="tight"` on the
  // element (and the CSS using position:absolute;inset:0 so the host can
  // actually fill the panel), this is all that's needed to keep the
  // model centered. The earlier manual camera math overshot; do not
  // restore it without re-validating.
  const centerViewer = useCallback(() => {
    const viewer = viewerRef.current;
    if (!viewer || !ready) return;
    try { viewer.updateFraming?.(); } catch {}
  }, [ready]);

  useEffect(() => {
    if (ready || typeof customElements === "undefined") return;
    let live = true;
    customElements.whenDefined("model-viewer")
      .then(() => { if (live) setReady(true); })
      .catch(() => {});
    return () => { live = false; };
  }, [ready]);

  useEffect(() => {
    const viewer = viewerRef.current;
    if (!viewer || !ready) return;
    setLoaded(false);
    const handleLoad = () => {
      setLoaded(true);
      window.requestAnimationFrame?.(() => centerViewer());
      window.setTimeout(centerViewer, 120);
      window.setTimeout(centerViewer, 500);
    };
    viewer.addEventListener?.("load", handleLoad);
    const firstFrame = window.setTimeout(centerViewer, 180);
    const settledFrame = window.setTimeout(centerViewer, 700);
    return () => {
      viewer.removeEventListener?.("load", handleLoad);
      window.clearTimeout(firstFrame);
      window.clearTimeout(settledFrame);
    };
  }, [ready, src, centerViewer]);

  useEffect(() => {
    if (!ready) return;
    const frame = frameRef.current;
    if (!frame) return;
    let frameId = 0;
    const scheduleCenter = () => {
      if (frameId) window.cancelAnimationFrame(frameId);
      frameId = window.requestAnimationFrame?.(centerViewer) || window.setTimeout(centerViewer, 0);
    };
    const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleCenter) : null;
    observer?.observe(frame);
    window.addEventListener("resize", scheduleCenter);
    scheduleCenter();
    return () => {
      observer?.disconnect();
      window.removeEventListener("resize", scheduleCenter);
      if (frameId) {
        window.cancelAnimationFrame?.(frameId);
        window.clearTimeout?.(frameId);
      }
    };
  }, [ready, centerViewer]);

  useEffect(() => {
    const viewer = viewerRef.current;
    if (!viewer || !ready) return;
    try {
      if (animationName) viewer.animationName = animationName;
      if (playing) viewer.play?.();
      else viewer.pause?.();
      centerViewer();
      window.setTimeout(centerViewer, 120);
    } catch {}
  }, [ready, src, animationName, playing, centerViewer]);

  if (!ready) {
    const hasCubes = !!(bbmodel && (bbmodel.elements || []).length);
    if (hasCubes) return <window.VoxelPreview bbmodel={bbmodel} cubes={null} />;
    return (
      <div className="glb-viewer-wrap">
        <div className="glb-viewer-fallback">
          GLB preview engine loading
        </div>
      </div>
    );
  }

  return (
    <div className="glb-viewer-wrap">
      <div className="glb-viewer-frame" ref={frameRef}>
        {/* No className= here: Babel-standalone renders that as the literal
            `classname` attribute on custom elements, not `class`. Styling
            lives on `.glb-viewer-frame > model-viewer` instead. */}
        <model-viewer
          ref={viewerRef}
          src={src}
          camera-controls="true"
          auto-rotate="true"
          autoplay={playing ? true : undefined}
          animation-name={animationName || undefined}
          interaction-prompt="none"
          shadow-intensity="0.65"
          exposure="0.95"
          camera-orbit="35deg 70deg auto"
          bounds="tight"
          onLoad={centerViewer}
        />
        {!loaded && <div className="glb-viewer-fallback">Loading GLB preview</div>}
      </div>
    </div>
  );
}

// ---------- Preview pane ----------
function PreviewPane({ session, onClose }) {
  const [tab, setTab] = useState("3d");
  const [playing, setPlaying] = useState(true);
  const [time, setTime] = useState(0);
  const [refreshing, setRefreshing] = useState(false);
  const [refreshErr, setRefreshErr] = useState("");
  const rafRef = useRef();

  // Real animation list pulled from ctx.bbmodel.animations.
  const realAnims = useMemo(() => {
    const list = ((session.ctx?.bbmodel?.animations) || []).filter((a) => a && a.name);
    return list;
  }, [session.ctx]);
  const [anim, setAnim] = useState(() => realAnims[0]?.name || "");
  // Reset selection when ctx swaps to a different session.
  useEffect(() => {
    if (realAnims.length && !realAnims.find((a) => a.name === anim)) {
      setAnim(realAnims[0].name);
    }
  }, [realAnims]);
  const activeAnim = realAnims.find((a) => a.name === anim) || null;
  const animLength = Number(activeAnim?.length) || 1.6;
  const previewElements = session.ctx?.bbmodel?.elements || [];
  const previewFormat = session.ctx?.bbmodel?.meta?.model_format
    || session.ctx?.model_spec?.format
    || session.spec?.format
    || "bedrock_entity";
  const previewCubeCount = previewElements.length;
  const previewTris = previewCubeCount * 12;
  const previewBoneCount = countOutlinerBones(session.ctx?.bbmodel?.outliner || []);
  const previewOriginalGlb = session.ctx?.scratch?.source_glb_url || "";
  const previewTexturedGlb = session.ctx?.scratch?.textured_glb_url || "";
  const [animatedGlbSrc, setAnimatedGlbSrc] = useState("");
  const animationExportKey = activeAnim
    ? [
        session.ctx?.session_id || "",
        activeAnim.name || "",
        realAnims.map((a) => `${a.name}:${a.length || ""}:${Object.keys(a.animators || {}).length}`).join("|"),
      ].join("::")
    : "";
  useEffect(() => {
    let live = true;
    let objectUrl = "";
    setAnimatedGlbSrc("");
    if (!animationExportKey || previewCubeCount || !previewOriginalGlb || !window.MCAPI?.exportGlb || !session.ctx) {
      return () => {};
    }
    (async () => {
      try {
        const blob = await window.MCAPI.exportGlb(session.ctx);
        objectUrl = URL.createObjectURL(blob);
        if (live) setAnimatedGlbSrc(objectUrl);
        else URL.revokeObjectURL(objectUrl);
      } catch {
        if (live) setAnimatedGlbSrc("");
      }
    })();
    return () => {
      live = false;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
  }, [animationExportKey, previewOriginalGlb, previewCubeCount, session.ctx]);
  const previewSourceGlb = animatedGlbSrc || previewTexturedGlb || previewOriginalGlb;
  const previewStats = previewCubeCount
    ? `${previewCubeCount} cubes · ${previewTris} tris`
    : (previewSourceGlb
      ? `${previewBoneCount || 0} bones · GLB preview`
      : (previewBoneCount ? `${previewBoneCount} bones · rig preview` : "0 cubes · 0 tris"));

  const onRefreshTexture = async () => {
    if (!window.MCAPI || !session.ctx) {
      setRefreshErr("没有可刷新的 ctx——先生成基础模型");
      setTimeout(() => setRefreshErr(""), 3000);
      return;
    }
    setRefreshing(true); setRefreshErr("");
    try {
      const newCtx = await window.MCAPI.streamTextureBuild(
        { ctx: session.ctx, user_prompt: "", texture_style: "pixel" },
      );
      // Mutate in place — parent owns ctx, but PreviewPane is a leaf so
      // we can't lift this cleanly without a callback prop. The texture
      // tab will pick up the new texture on the parent's next render
      // (state_sync writes through onUpdate up there).
      if (!newCtx || !newCtx.texture_png_base64) {
        throw new Error("贴图刷新失败 — 后端没有返回 texture_png_base64。");
      }
      Object.assign(session.ctx, newCtx);
      setTab("tex");
    } catch (e) {
      setRefreshErr(e && e.message || "刷新失败");
      setTimeout(() => setRefreshErr(""), 4000);
    } finally {
      setRefreshing(false);
    }
  };

  const onExportGlb = async () => {
    if (!window.MCAPI || !session.ctx) {
      setRefreshErr("没有可导出的 ctx——先生成基础模型");
      setTimeout(() => setRefreshErr(""), 3000);
      return;
    }
    try {
      // Belt-and-suspenders: /api/export/glb already persists, but call
      // save() too so the user's mental model "every download saves to
      // My Assets" holds even on a partial backend failure.
      try { window.MCAPI.library?.save?.(session.ctx); } catch {}
      const blob = await window.MCAPI.exportGlb(session.ctx);
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `${session.spec?.name || "model"}.glb`;
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
    } catch (e) {
      setRefreshErr(e && e.message || "导出失败");
      setTimeout(() => setRefreshErr(""), 4000);
    }
  };

  useEffect(() => {
    let last = performance.now();
    const tick = (t) => {
      const dt = (t - last) / 1000;
      last = t;
      // Loop on the active animation's length (default 1.6s when none).
      if (playing) setTime((p) => (p + dt) % Math.max(0.1, animLength));
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, [playing, animLength]);

  const fmt = (t) => `${t.toFixed(2)}s / ${animLength.toFixed(2)}s`;

  return (
    <section className="preview">
      <div className="preview-tabs">
        <button className={"preview-tab" + (tab === "3d" ? " active" : "")} onClick={() => setTab("3d")}>
          3D 预览
        </button>
        <button className={"preview-tab" + (tab === "tex" ? " active" : "")} onClick={() => setTab("tex")}>
          贴图
        </button>
        <div style={{ flex: 1 }} />
        <button className="icon-btn" title="重新生成贴图"
                onClick={onRefreshTexture} disabled={refreshing}>
          <I.Refresh size={15} />
        </button>
        <button className="icon-btn" title="关闭预览" onClick={onClose}><I.X size={16} /></button>
      </div>
      {refreshErr && (
        <div style={{ position: "absolute", top: 50, left: 12, right: 12, zIndex: 9,
                       padding: "6px 12px", background: "var(--danger)", color: "white",
                       fontSize: 12, borderRadius: 6 }}>
          {refreshErr}
        </div>
      )}

      <div className="preview-stage">
        <div className="preview-grid" />
        <div className="preview-model-area">
          {tab === "3d" && (
            (previewSourceGlb && !previewCubeCount) ? (
              <GlbModelPreview
                src={previewSourceGlb}
                bbmodel={session.ctx?.bbmodel}
                animationName={activeAnim?.name || ""}
                playing={playing}
              />
            ) : activeAnim ? (
              // Real bone-driven preview when the user picked an animation.
              <window.AnimatedVoxelPreview
                bbmodel={session.ctx?.bbmodel}
                animation={activeAnim}
                time={time}
                textureB64={session.ctx && session.ctx.texture_png_base64}
              />
            ) : (
              // Static cube view (no anim selected, or no anim authored yet).
              <VoxelPreview
                model={session.model}
                bbmodel={session.ctx && session.ctx.bbmodel}
                cubes={session.ctx && session.ctx.bbmodel
                  ? window.bbmodelToVoxelCubes(session.ctx.bbmodel)
                  : null}
                textureB64={session.ctx && session.ctx.texture_png_base64}
              />
            )
          )}
          {tab === "tex" && <TexturePreview textureB64={session.ctx && session.ctx.texture_png_base64} />}
        </div>

        <div className="preview-overlay-top">
          <span className="preview-pill">
            <I.Cube size={12} />
            {previewFormat}
          </span>
          <span className="preview-pill" style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>
            {previewStats}
          </span>
          <div style={{ flex: 1 }} />
          <span className="preview-pill">
            <span style={{ width: 6, height: 6, borderRadius: 999, background: "var(--good)" }} />
            就绪
          </span>
        </div>

        {tab === "3d" && (
          <div className="preview-overlay-bottom">
            <div className="anim-control-pill">
              <button className="play-circle"
                      onClick={() => setPlaying(!playing)}
                      disabled={!realAnims.length}
                      title={!realAnims.length ? "没有动画 — 先生成一个" : (playing ? "暂停" : "播放")}>
                {playing ? <I.Pause size={12} /> : <I.Play size={12} />}
              </button>
              <select value={anim} onChange={(e) => { setAnim(e.target.value); setTime(0); }}
                      disabled={!realAnims.length}>
                {realAnims.length === 0 && <option value="">(无动画)</option>}
                {realAnims.map((a) => (
                  <option key={a.name} value={a.name}>{a.name}</option>
                ))}
              </select>
              <span className="anim-time">{realAnims.length ? fmt(time) : "—"}</span>
            </div>
            <div style={{ flex: 1 }} />
            <button className="ghost-btn" onClick={onExportGlb}>
              <I.Download size={13} /> 导出
            </button>
          </div>
        )}
      </div>
    </section>
  );
}

function TexturePreview({ textureB64 }) {
  // Backend state_sync ships the atlas PNG as `texture_png_base64`
  // (already a base64 string). Build a data: URI directly when present;
  // otherwise fall through to the placeholder atlas below.
  const realSrc = useMemo(() => {
    if (!textureB64) return null;
    if (typeof textureB64 !== "string") return null;
    return textureB64.startsWith("data:") ? textureB64
      : `data:image/png;base64,${textureB64}`;
  }, [textureB64]);

  if (realSrc) {
    return (
      <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
        <div style={{
          padding: 12, background: "var(--bg-elev)",
          border: "1px solid var(--line)", borderRadius: 12,
          boxShadow: "var(--shadow-md)",
        }}>
          <img src={realSrc} alt="texture atlas" style={{
            width: 320, height: 320, imageRendering: "pixelated",
            background: "var(--bg-soft)",
          }} />
        </div>
      </div>
    );
  }
  return _TexturePreviewPlaceholder();
}

function _TexturePreviewPlaceholder() {
  // Empty state — no fake atlas pixels, just a hint that the user
  // needs to run build_texture (or wait for the auto-chained one
  // after generate completes).
  return (
    <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
      <div style={{
        padding: "24px 32px", color: "var(--ink-4)",
        textAlign: "center", maxWidth: 320,
      }}>
        <div style={{ fontSize: 36, opacity: 0.3, marginBottom: 8 }}>▦</div>
        <div style={{ fontSize: 13 }}>
          贴图未生成。完整资产管线会在建模后自动跑一次 Meshy 贴图，
          也可以点右上角 <I.Refresh size={11} /> 重新生成。
        </div>
      </div>
    </div>
  );
}

window.MFC = {
  Sidebar, Composer, DirToggle, StageCard, SpecCard,
  AnimationEditor, ExportCard, PreviewPane,
  BrandLogo,
};
