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

// ---------- Admin gating ----------
// Many UI surfaces show internal info (USD cost, model name, billing detail)
// that regular users shouldn't see. We expose a `useIsAdmin()` hook + a
// shared cached promise so multiple components can hit /api/admin/whoami
// without a stampede. The endpoint returns 200 for admins (ADMIN_USERNAMES
// env or the seeded 'pro' user) and 403 for everyone else.
let _ADMIN_CHECK_PROMISE = null;
function _adminCheck() {
  if (!_ADMIN_CHECK_PROMISE) {
    _ADMIN_CHECK_PROMISE = (async () => {
      try {
        const r = await window.MFAPI.admin.whoami();
        return !!(r && r.is_admin);
      } catch {
        return false;
      }
    })();
  }
  return _ADMIN_CHECK_PROMISE;
}
function _resetAdminCheck() { _ADMIN_CHECK_PROMISE = null; }
function useIsAdmin() {
  const [isAdmin, setIsAdmin] = useState(false);
  useEffect(() => {
    let alive = true;
    _adminCheck().then((v) => { if (alive) setIsAdmin(v); });
    return () => { alive = false; };
  }, []);
  return isAdmin;
}
// Expose globally so app.jsx (which loads after this file) can call them.
window.useIsAdmin = useIsAdmin;
window._resetAdminCheck = _resetAdminCheck;

// ---------- 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: "3D资产" },
    { id: "ui", icon: I.Image, label: "游戏 UI" },
    { id: "effects", icon: I.Effects, label: "2D特效" },
  ];
  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>

      <button
        type="button"
        className="new-btn"
        data-track-id="sidebar_new_session"
        onClick={() => onNew && onNew()}
        title="新建对话"
      >
        <I.Plus className="ico" size={16} />
        <span>新建对话</span>
      </button>

      <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" : "")}
              data-track-id={`sidebar_nav_${it.id}`}
              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;
          const selected = (view === "home" || view === "workspace") && workflow === it.id;
          return (
            <button
              key={it.id}
              type="button"
              className={"nav-item workflow-item" + (selected ? " active" : "")}
              data-track-id={`sidebar_workflow_${it.id}`}
              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" : "")}
              data-track-id={`sidebar_recent_${s.id}`}
              role="button"
              tabIndex={0}
              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"
                  data-track-id={`sidebar_recent_delete_${s.id}`}
                  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" data-track-id="sidebar_settings" 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 isAdmin = useIsAdmin();
  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.MFAPI.auth.me();
        if (me && me.authenticated) setUser(me);
        else setUser(null);
      } catch {}
      try {
        const u = await window.MFAPI.billing.usage();
        setUsage(u);
      } catch {}
      try {
        const c = await window.MFAPI.credits.balance();
        setCredits(c);
      } catch {}
    })();
  }, [open]);

  const onLogout = async () => {
    try {
      window.MFAnalytics?.track?.("user logged out", {
        plan: user?.plan || credits?.plan || "free",
        credits_balance: credits?.credits_balance ?? user?.credits_balance ?? 0,
      });
    } catch {}
    try { await window.MFAPI.auth.logout(); } catch {}
    try { window.MFAnalytics?.reset?.(); } catch {}
    setOpen(false);
    setUser(null);
    _resetAdminCheck();
    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" data-track-id="account_menu" 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"
                    data-track-id="account_inline_upgrade"
                    style={{ marginLeft: 8, padding: "1px 6px", fontSize: 11 }}
                    onClick={() => { setOpen(false); window.location.hash = "#/upgrade"; }}
                  >
                    升级 Pro →
                  </button>
                )}
              </div>
            </div>
            {isAdmin && quotaUsd > 0 && (
              <div className="account-stat" style={{ marginTop: 8 }}>
                <div className="account-stat-label">内部美元额度（admin）</div>
                <div className="account-stat-val" style={{ fontFamily: "var(--font-mono)" }}>
                  本会话 ${Number(liveCost || 0).toFixed(4)} · 已用 ${Number(usedUsd).toFixed(4)} / ${Number(quotaUsd).toFixed(2)} · 剩余 ${Number(remainingUsd).toFixed(4)}
                  {totalTokens ? ` · ${Number(totalTokens).toLocaleString()} tokens` : ""}
                </div>
              </div>
            )}
          </div>
          <div className="account-pop-divider" />
          {!user && (
            <button className="account-pop-item" data-track-id="account_login" onClick={() => {
              setOpen(false); window.location.hash = "#/login";
            }}>
              <I.Settings size={14} /> 登录 / 注册
            </button>
          )}
          <button
            className="account-pop-item"
            data-track-id={isPro ? "account_manage_subscription" : "account_upgrade_pro"}
            onClick={() => { setOpen(false); window.location.hash = "#/upgrade"; }}
          >
            <I.Settings size={14} /> {isPro ? "管理订阅" : "升级 Pro · $19.99/月"}
          </button>
          {isPro && (
            <button
              className="account-pop-item"
              data-track-id="account_open_portal"
              onClick={async () => {
                setOpen(false);
                try {
                  const r = await window.MFAPI.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" data-track-id="account_settings" onClick={() => { setOpen(false); window.location.hash = "#/settings/account"; }}>
            <I.Settings size={14} /> 账户设置
          </button>
          {isAdmin && (
            <button className="account-pop-item" data-track-id="account_billing" onClick={() => { setOpen(false); window.location.hash = "#/billing"; }}>
              <I.Refresh size={14} /> 用量与账单（内部成本）
            </button>
          )}
          {user && (
            <>
              <div className="account-pop-divider" />
              <button className="account-pop-item danger" data-track-id="account_logout" 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 [dragOver, setDragOver] = useState(false);
  const canSubmit = !!String(value || "").trim();
  // Paste / drop handlers are only wired when the caller accepts images
  // (onPickImage present) — pure-text composers keep default behavior.
  const acceptsImage = typeof onPickImage === "function";
  const onPrimary = () => {
    if (busy) {
      onStop?.();
      return;
    }
    onSubmit?.();
  };
  const readImageFile = (f) => {
    if (!f || !/^image\//i.test(f.type || "")) return false;
    const reader = new FileReader();
    reader.onload = () => onPickImage?.(reader.result, f.name || "粘贴图片.png");
    reader.readAsDataURL(f);
    return true;
  };
  // Root-level paste catches the textarea's paste via bubbling. Plain-text
  // pastes fall through untouched (no preventDefault unless an image file
  // is actually consumed).
  const onPaste = (e) => {
    if (!acceptsImage) return;
    const files = Array.from(e.clipboardData?.files || []);
    const img = files.find((f) => /^image\//i.test(f.type || ""));
    if (img && readImageFile(img)) e.preventDefault();
  };
  const onDragOver = (e) => {
    if (!acceptsImage) return;
    e.preventDefault();
    setDragOver(true);
  };
  const onDragLeave = () => setDragOver(false);
  const onDrop = (e) => {
    if (!acceptsImage) return;
    e.preventDefault();
    setDragOver(false);
    const files = Array.from(e.dataTransfer?.files || []);
    const img = files.find((f) => /^image\//i.test(f.type || ""));
    if (img) readImageFile(img);
  };
  return (
    <div
      className={"composer" + (dragOver ? " drag-over" : "")}
      onPaste={onPaste}
      onDragOver={onDragOver}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
    >
      {imageData && (
        <div className="composer-attachments">
          <div className="attachment-chip">
            <img src={imageData} alt="ref" />
            <span>{imageName || "参考图"}</span>
            <button className="x" data-track-id="composer_clear_image" 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"
          data-track-id="composer_add_image"
          title="添加参考图"
          onClick={() => fileRef.current?.click()}
        >
          <I.Image size={17} />
        </button>
        <input
          ref={fileRef} type="file" accept="image/*" hidden
          onChange={(e) => {
            readImageFile(e.target.files?.[0]);
            e.target.value = ""; // re-picking the same file must re-fire
          }}
        />

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

        <ModelPicker />

        <button
          className={"send-btn" + (busy ? " stop" : "")}
          data-track-id={busy ? "composer_stop" : "composer_submit"}
          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) ----------
// Hidden if the model endpoint is unavailable; otherwise it stays as the
// lightweight in-composer switcher for picking the runtime LLM.
function ModelPicker() {
  const [open, setOpen] = useState(false);
  const [available, setAvailable] = useState([]);
  const [selected, setSelected] = useState(null);
  const [enabled, setEnabled] = useState(false);
  const ref = useRef(null);

  // Load real list from /api/admin/llm_model on mount. The route is
  // login-gated; if the user cannot reach it, keep the easter egg hidden.
  useEffect(() => {
    let alive = true;
    (async () => {
      try {
        const r = await window.MFAPI.admin.getLlmModel();
        if (!alive) return;
        setAvailable(r.available || []);
        setSelected(r.model);
        setEnabled(true);
      } catch {
        if (!alive) return;
        setSelected("gemini-3.1-pro-preview");
        setEnabled(false);
      }
    })();
    return () => { alive = false; };
  }, []);

  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("MESHFORGE_LLM_MODEL_v1", id);
      await window.MFAPI.admin.setLlmModel(id);
    } catch (e) {
      console.warn("model switch failed:", e);
    }
  };

  if (!enabled) return null;

  // 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" data-track-id="model_picker_open" 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" : "")}
              data-track-id={`model_picker_select_${m.id}`}
              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" data-track-id="model_picker_add_model" 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}
          data-track-id={`dir_toggle_${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" data-track-id={`animation_remove_${a.id}`} 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" data-track-id="animation_add_custom" onClick={() => { add(extra); setExtra(""); }}>
          <I.Plus size={14} /> 添加
        </button>
        <button className="primary-btn" data-track-id="animation_confirm_generate" 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" data-track-id={`animation_preset_${p}`} 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.MFAPI?.library?.save?.(ctx); } catch {}
  };

  const downloadOne = async (f) => {
    if (!ctx) {
      alert("还没有可导出的资产 (ctx 为空)");
      return;
    }
    const format = (f.ext || (f.name || "").split(".").pop() || "file").toLowerCase();
    try {
      window.MFAnalytics?.track?.("export clicked", {
        format,
        source: "export_card",
        action: format === "zip" ? "export_zip" : `export_${format}`,
      });
      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" }
        );
        window.MFAnalytics?.track?.("export completed", { format: "bbmodel", source: "export_card" });
        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.MFAPI.exportGlb(ctx);
        window.MFAnalytics?.track?.("export completed", { format: "glb", source: "export_card" });
        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", ...(window.MFAPI?.analyticsHeaders?.() || {}) },
          body: JSON.stringify(ctx),
        });
        if (!resp.ok) {
          const text = await resp.text().catch(() => "");
          let data = null;
          try { data = text ? JSON.parse(text) : null; } catch {}
          const detail = data?.detail || data || {};
          const err = new Error(detail.message || data?.message || ("HTTP " + resp.status));
          err.status = resp.status;
          err.code = detail.code || data?.code || "";
          err.data = data;
          throw err;
        }
        window.MFAnalytics?.track?.("export completed", { format: "zip", source: "export_card" });
        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" });
        window.MFAnalytics?.track?.("export completed", { format: "png", source: "export_card" });
        return triggerDownload(blob, f.name || `${safeName}_texture.png`);
      }
    } catch (e) {
      if (window.MFAPI?.isPaywallError?.(e)) {
        const payload = window.MFAPI.paywallPayload?.(e, {
          source: "export_card",
          action: format === "zip" ? "export_zip" : `export_${format}`,
          feature: "export",
        });
        window.MFAPI.showPaywall?.(payload);
        return;
      }
      window.MFAnalytics?.track?.("export failed", {
        format,
        source: "export_card",
        error_code: e?.code || e?.status || e?.name || "error",
      });
      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" data-track-id={`export_download_${String(f.ext || "file").toLowerCase()}`} title="下载" onClick={() => downloadOne(f)}>
              <I.Download size={13} />
            </button>
          </div>
        ))}
      </div>
      <div className="row" style={{ marginTop: 12, gap: 8 }}>
        <button className="primary-btn" data-track-id="export_download_zip" style={{ flex: 1 }} onClick={downloadAll}>
          <I.Download size={14} /> 下载全部 (zip)
        </button>
        <button
          className="ghost-btn"
          data-track-id="export_download_bbmodel"
          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, onPersist }) {
  const [tab, setTab] = useState("3d");
  const [playing, setPlaying] = useState(true);
  const [time, setTime] = useState(0);
  const [refreshing, setRefreshing] = useState(false);
  const [refreshErr, setRefreshErr] = useState("");
  // Paint mode: paint directly ON the 3D model (face clicks → atlas pixels)
  // rather than in a separate window. Toggled from the tabs row.
  const [paintMode, setPaintMode] = useState(false);
  const rafRef = useRef();
  // Bumped after each paint stroke so VoxelPreview re-reads
  // session.ctx.texture_png_base64 (mutated in place) and re-paints the
  // cube faces live. PreviewPane owns no setter for ctx, so this tick is
  // the cheap way to force the leaf preview components to re-render.
  const [, forceTick] = useState(0);
  const bumpPreview = useCallback(() => forceTick((n) => n + 1), []);
  // Atlas-paint engine (offscreen canvas mirroring the texture). Drives the
  // on-model painting + the paint toolbar.
  const paint = usePaintEngine(session.ctx, session.spec?.name || "texture", bumpPreview, onPersist);
  const paintEndStroke = paint.endStroke;
  const paintUndo = paint.onUndo;
  const paintRedo = paint.onRedo;
  // End a stroke even if the mouse is released off a face / outside the stage.
  useEffect(() => {
    if (!paintMode) return;
    const up = () => paintEndStroke();
    window.addEventListener("mouseup", up);
    return () => window.removeEventListener("mouseup", up);
  }, [paintMode, paintEndStroke]);
  // Undo/redo keyboard shortcuts while painting: Ctrl/⌘+Z, Ctrl/⌘+Shift+Z, Ctrl+Y.
  useEffect(() => {
    if (!paintMode) return;
    const onKey = (e) => {
      const mod = e.ctrlKey || e.metaKey;
      if (!mod) return;
      const k = (e.key || "").toLowerCase();
      if (k === "z") { e.preventDefault(); e.shiftKey ? paintRedo() : paintUndo(); }
      else if (k === "y") { e.preventDefault(); paintRedo(); }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [paintMode, paintUndo, paintRedo]);

  // 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.MFAPI?.exportGlb || !session.ctx) {
      return () => {};
    }
    (async () => {
      try {
        const blob = await window.MFAPI.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.MFAPI || !session.ctx) {
      setRefreshErr("没有可刷新的 ctx——先生成基础模型");
      setTimeout(() => setRefreshErr(""), 3000);
      return;
    }
    setRefreshing(true); setRefreshErr("");
    try {
      const newCtx = await window.MFAPI.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.MFAPI || !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.MFAPI.library?.save?.(session.ctx); } catch {}
      const blob = await window.MFAPI.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" : "")} data-track-id="preview_tab_3d" onClick={() => setTab("3d")}>
          3D 预览
        </button>
        <button className={"preview-tab" + (tab === "tex" ? " active" : "")} data-track-id="preview_tab_texture" onClick={() => setTab("tex")}>
          贴图
        </button>
        <button
          className={"preview-tab" + (paintMode ? " active" : "")}
          data-track-id="preview_toggle_paint"
          disabled={previewSourceGlb && !previewCubeCount}
          title={previewSourceGlb && !previewCubeCount ? "上传的 GLB 暂不支持直接绘制" : "在模型上直接绘制像素"}
          onClick={() => { setTab("3d"); setPaintMode((v) => !v); }}>
          画笔{paintMode ? " ●" : ""}
        </button>
        <div style={{ flex: 1 }} />
        <button className="icon-btn" title="重新生成贴图"
                data-track-id="preview_refresh_texture"
                onClick={onRefreshTexture} disabled={refreshing}>
          <I.Refresh size={15} />
        </button>
        <button className="icon-btn" data-track-id="preview_close" 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 && !paintMode) ? (
              // 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 — also the canvas for paint mode (forced
              // static so faces stay put while you draw on them).
              <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}
                paint={paintMode ? { active: true, ...paint.controller } : null}
              />
            )
          )}
          {tab === "tex" && <TexturePreview textureB64={session.ctx && session.ctx.texture_png_base64} />}
        </div>

        {tab === "3d" && paintMode && <PaintToolbar engine={paint} onClose={() => setPaintMode(false)} />}

        <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"
                      data-track-id={playing ? "preview_pause_animation" : "preview_play_animation"}
                      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" data-track-id="preview_export_glb" onClick={onExportGlb}>
              <I.Download size={13} /> 导出
            </button>
          </div>
        )}
      </div>
    </section>
  );
}

// ---------- Atlas paint engine ----------
// Hand-edit the texture by painting DIRECTLY on the 3D model. This hook owns
// an OFFSCREEN canvas mirroring the model's atlas; VoxelPreview maps a face
// click to an atlas pixel and calls controller.onPaintAtlas, then we write the
// atlas back into ctx.texture_png_base64 IN PLACE and bump the preview so the
// cube faces repaint live. The PaintToolbar renders the controls.
const PAINT_SWATCHES = [
  "#000000", "#ffffff", "#7f7f7f", "#e23b3b", "#e2913b",
  "#e2d23b", "#4caf50", "#3b7fe2", "#7a4ce2", "#c24ca0",
];
const PAINT_UNDO_CAP = 30;

function usePaintEngine(ctx, name, onChange, onPersist) {
  const res = (ctx && ctx.bbmodel && ctx.bbmodel.resolution) || {};
  const atlasW = Math.max(1, +res.width || 64);
  const atlasH = Math.max(1, +res.height || 64);

  const canvasRef = useRef(null);   // offscreen atlas canvas
  const gRef = useRef(null);
  const undoRef = useRef([]);
  const redoRef = useRef([]);
  const paintingRef = useRef(false);
  const strokeDirtyRef = useRef(false);  // did this stroke actually paint?
  const applyTimerRef = useRef(null);
  const persistTimerRef = useRef(null);  // debounced persist (server/session)
  const lastWrittenRef = useRef(null);  // data URI WE wrote (don't reload it)
  const loadedSrcRef = useRef(undefined);
  const originalSrcRef = useRef(null);  // pristine atlas — what reset reverts to

  const [color, setColor] = useState("#e23b3b");
  // User-defined swatches, picked from the color wheel and persisted so the
  // custom palette sticks across sessions.
  const [customSwatches, setCustomSwatches] = useState(() => {
    try {
      const raw = localStorage.getItem("MESHFORGE_PAINT_SWATCHES_v1");
      const arr = raw ? JSON.parse(raw) : [];
      return Array.isArray(arr) ? arr.filter((s) => typeof s === "string").slice(0, 16) : [];
    } catch { return []; }
  });
  const [brush, setBrush] = useState(1);
  const [eraser, setEraser] = useState(false);
  const [eyedropper, setEyedropper] = useState(false);
  const eyedropperRef = useRef(false);
  useEffect(() => { eyedropperRef.current = eyedropper; }, [eyedropper]);
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);

  const rawTex = ctx && ctx.texture_png_base64;
  const origSrc = useMemo(() => {
    if (!rawTex || typeof rawTex !== "string") return null;
    return rawTex.startsWith("data:") ? rawTex : `data:image/png;base64,${rawTex}`;
  }, [rawTex]);

  const ensure = useCallback(() => {
    if (!canvasRef.current) canvasRef.current = document.createElement("canvas");
    const c = canvasRef.current;
    if (c.width !== atlasW || c.height !== atlasH) { c.width = atlasW; c.height = atlasH; }
    if (!gRef.current) {
      const g = c.getContext("2d");
      g.imageSmoothingEnabled = false;
      gRef.current = g;
    }
    return gRef.current;
  }, [atlasW, atlasH]);

  const loadAtlas = useCallback((src) => {
    const g = ensure();
    undoRef.current = [];
    redoRef.current = [];
    setCanUndo(false);
    setCanRedo(false);
    loadedSrcRef.current = src;
    g.clearRect(0, 0, atlasW, atlasH);
    if (src) {
      const img = new Image();
      img.onload = () => { try { g.drawImage(img, 0, 0, atlasW, atlasH); } catch {} };
      img.src = src;
    }
  }, [ensure, atlasW, atlasH]);

  // Reload the offscreen canvas only when the atlas changes EXTERNALLY (new
  // session / Meshy refresh / reset). Our own paint write-backs are skipped
  // so they don't wipe the undo stack or the in-progress pixels.
  useEffect(() => {
    if (origSrc && origSrc === lastWrittenRef.current) return;
    if (origSrc === loadedSrcRef.current) return;
    originalSrcRef.current = origSrc;
    loadAtlas(origSrc);
  }, [origSrc, loadAtlas]);

  // Persist the painted atlas so it survives a page refresh. Debounced
  // (~900ms) so a drawing burst saves once, not per pixel. The parent wires
  // this to library.save (server) and/or the session store (localStorage).
  const schedulePersist = useCallback(() => {
    if (!onPersist) return;
    if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
    persistTimerRef.current = setTimeout(() => {
      persistTimerRef.current = null;
      try { onPersist(ctx); } catch {}
    }, 900);
  }, [onPersist, ctx]);

  const applyToCtx = useCallback(() => {
    const c = canvasRef.current;
    if (!c || !ctx) return;
    try {
      const url = c.toDataURL("image/png");
      lastWrittenRef.current = url;
      loadedSrcRef.current = url;
      ctx.texture_png_base64 = url;
      if (onChange) onChange();
      schedulePersist();
    } catch {}
  }, [ctx, onChange, schedulePersist]);

  const scheduleApply = useCallback(() => {
    if (applyTimerRef.current) clearTimeout(applyTimerRef.current);
    applyTimerRef.current = setTimeout(() => {
      applyTimerRef.current = null;
      applyToCtx();
    }, 120);
  }, [applyToCtx]);

  const _snapshot = useCallback(() => {
    const g = gRef.current;
    if (!g) return null;
    try { return g.getImageData(0, 0, atlasW, atlasH); } catch { return null; }
  }, [atlasW, atlasH]);

  // Snapshot BEFORE a stroke and clear the redo branch (a new edit forks
  // history). Cap the undo stack.
  const pushUndo = useCallback(() => {
    const snap = _snapshot();
    if (!snap) return;
    undoRef.current.push(snap);
    if (undoRef.current.length > PAINT_UNDO_CAP) undoRef.current.shift();
    redoRef.current = [];
    setCanUndo(true);
    setCanRedo(false);
  }, [_snapshot]);

  const paintAtlasPixel = useCallback((ax, ay) => {
    const g = gRef.current;
    if (!g) return;
    const half = Math.floor(brush / 2);
    const x0 = ax - half, y0 = ay - half;
    if (eraser) g.clearRect(x0, y0, brush, brush);
    else { g.fillStyle = color; g.fillRect(x0, y0, brush, brush); }
    strokeDirtyRef.current = true;
  }, [brush, eraser, color]);

  // 吸色: sample the atlas pixel under the cursor into the current color and
  // drop out of eyedropper mode (one-shot). Transparent pixels are ignored.
  const sampleAtlasPixel = useCallback((ax, ay) => {
    const g = gRef.current;
    if (!g) return;
    try {
      const d = g.getImageData(ax, ay, 1, 1).data;
      if (d[3] === 0) { setEyedropper(false); return; }  // empty texel — nothing to pick
      const hex = "#" + [d[0], d[1], d[2]]
        .map((v) => v.toString(16).padStart(2, "0")).join("");
      setColor(hex);
      setEraser(false);
      setEyedropper(false);
    } catch {}
  }, []);

  // Persist + add a wheel-picked color to the custom palette (newest first,
  // deduped against the fixed swatches and existing customs, capped). Also
  // selects it as the active brush color.
  const addCustomSwatch = useCallback((hex) => {
    const c = String(hex || "").trim().toLowerCase();
    if (!/^#[0-9a-f]{6}$/.test(c)) return;
    setColor(hex);
    setEraser(false);
    setEyedropper(false);
    if (PAINT_SWATCHES.some((s) => s.toLowerCase() === c)) return;  // already a fixed swatch
    setCustomSwatches((prev) => {
      const next = [c, ...prev.filter((s) => s.toLowerCase() !== c)].slice(0, 16);
      try { localStorage.setItem("MESHFORGE_PAINT_SWATCHES_v1", JSON.stringify(next)); } catch {}
      return next;
    });
  }, []);

  const removeCustomSwatch = useCallback((hex) => {
    const c = String(hex || "").toLowerCase();
    setCustomSwatches((prev) => {
      const next = prev.filter((s) => s.toLowerCase() !== c);
      try { localStorage.setItem("MESHFORGE_PAINT_SWATCHES_v1", JSON.stringify(next)); } catch {}
      return next;
    });
  }, []);

  const begin = useCallback(() => {
    paintingRef.current = true;
    strokeDirtyRef.current = false;
    if (!eyedropperRef.current) pushUndo();   // sampling isn't an edit
  }, [pushUndo]);

  const endStroke = useCallback(() => {
    if (!paintingRef.current) return;
    paintingRef.current = false;
    if (applyTimerRef.current) { clearTimeout(applyTimerRef.current); applyTimerRef.current = null; }
    if (strokeDirtyRef.current) applyToCtx();   // only persist if we drew
    strokeDirtyRef.current = false;
  }, [applyToCtx]);

  const onUndo = useCallback(() => {
    const stack = undoRef.current;
    if (!stack.length) return;
    const cur = _snapshot();
    const prev = stack.pop();
    if (cur) redoRef.current.push(cur);
    try { gRef.current.putImageData(prev, 0, 0); } catch {}
    setCanUndo(stack.length > 0);
    setCanRedo(redoRef.current.length > 0);
    applyToCtx();
  }, [_snapshot, applyToCtx]);

  const onRedo = useCallback(() => {
    const stack = redoRef.current;
    if (!stack.length) return;
    const cur = _snapshot();
    const next = stack.pop();
    if (cur) undoRef.current.push(cur);
    try { gRef.current.putImageData(next, 0, 0); } catch {}
    setCanRedo(stack.length > 0);
    setCanUndo(undoRef.current.length > 0);
    applyToCtx();
  }, [_snapshot, applyToCtx]);

  const onReset = useCallback(() => {
    loadAtlas(originalSrcRef.current);
    setTimeout(applyToCtx, 60);   // re-export after the async image draw
  }, [loadAtlas, applyToCtx]);

  const onDownloadPng = useCallback(() => {
    const c = canvasRef.current;
    if (!c) return;
    try {
      const a = document.createElement("a");
      a.href = c.toDataURL("image/png");
      a.download = `${name || "texture"}.png`;
      document.body.appendChild(a); a.click(); a.remove();
    } catch {}
  }, [name]);

  useEffect(() => () => {
    if (applyTimerRef.current) clearTimeout(applyTimerRef.current);
    if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
  }, []);

  return {
    atlasW, atlasH,
    color, setColor, brush, setBrush, eraser, setEraser,
    eyedropper, setEyedropper, canUndo, canRedo,
    customSwatches, addCustomSwatch, removeCustomSwatch,
    onUndo, onRedo, onReset, onDownloadPng, endStroke,
    // Passed to VoxelPreview; methods are stable useCallbacks. A face hit
    // either samples (eyedropper) or paints (and persists).
    controller: {
      eyedropper,
      isPainting: () => paintingRef.current,
      begin,
      onPaintAtlas: (x, y) => {
        if (eyedropperRef.current) { sampleAtlasPixel(x, y); return; }
        paintAtlasPixel(x, y);
        scheduleApply();
      },
    },
  };
}

// Floating toolbar shown over the 3D preview while paint mode is on.
function PaintToolbar({ engine, onClose }) {
  const { color, setColor, brush, setBrush, eraser, setEraser,
          eyedropper, setEyedropper, canUndo, canRedo, onUndo, onRedo,
          onReset, onDownloadPng, customSwatches = [], addCustomSwatch,
          removeCustomSwatch } = engine;
  return (
    <div className="paint-toolbar paint-toolbar-overlay">
      <label className="paint-color-field" title="选择颜色">
        <input type="color" value={color} data-track-id="paint_color_picker"
               onChange={(e) => { setColor(e.target.value); setEraser(false); }} />
        <span className="paint-color-chip" style={{ background: color }} />
      </label>
      <div className="paint-swatches">
        {PAINT_SWATCHES.map((sw) => (
          <button key={sw} type="button"
                  className={"paint-swatch" + (!eraser && sw.toLowerCase() === color.toLowerCase() ? " active" : "")}
                  style={{ background: sw }} title={sw}
                  data-track-id="paint_swatch"
                  onClick={() => { setColor(sw); setEraser(false); }} />
        ))}
        {customSwatches.map((sw) => (
          <button key={sw} type="button"
                  className={"paint-swatch paint-swatch-custom" + (!eraser && sw.toLowerCase() === color.toLowerCase() ? " active" : "")}
                  style={{ background: sw }} title={`${sw} · 右键删除`}
                  data-track-id="paint_swatch_custom"
                  onClick={() => { setColor(sw); setEraser(false); }}
                  onContextMenu={(e) => { e.preventDefault(); removeCustomSwatch && removeCustomSwatch(sw); }} />
        ))}
        {/* + : pick a color from the wheel and add it to the palette */}
        <label className="paint-swatch paint-swatch-add" title="从色盘取色，加入色卡"
               data-track-id="paint_add_swatch">
          <input type="color" defaultValue={color}
                 onChange={(e) => addCustomSwatch && addCustomSwatch(e.target.value)} />
          <span aria-hidden="true">+</span>
        </label>
      </div>
      <span className="paint-sep" />
      <div className="paint-brushes">
        {[1, 2, 3].map((b) => (
          <button key={b} type="button"
                  className={"paint-btn" + (brush === b ? " active" : "")}
                  data-track-id="paint_brush_size"
                  onClick={() => setBrush(b)}>{b}px</button>
        ))}
      </div>
      <button type="button" className={"paint-btn" + (eraser ? " active" : "")}
              data-track-id="paint_eraser"
              onClick={() => { setEraser((v) => !v); setEyedropper(false); }} title="橡皮擦（涂成透明）">橡皮</button>
      <button type="button" className={"paint-btn" + (eyedropper ? " active" : "")}
              data-track-id="paint_eyedropper"
              onClick={() => { setEyedropper((v) => !v); setEraser(false); }}
              title="吸色：点模型上的某处取色">吸色</button>
      <span className="paint-sep" />
      <button type="button" className="paint-btn"
              data-track-id="paint_undo" onClick={onUndo} disabled={!canUndo} title="撤回 (Ctrl/⌘+Z)">撤回</button>
      <button type="button" className="paint-btn"
              data-track-id="paint_redo" onClick={onRedo} disabled={!canRedo} title="重做 (Ctrl/⌘+Shift+Z)">重做</button>
      <button type="button" className="paint-btn"
              data-track-id="paint_reset" onClick={onReset} title="重新载入原始贴图">重置</button>
      <button type="button" className="paint-btn"
              data-track-id="paint_download_png" onClick={onDownloadPng} title="另存为 PNG">导出</button>
      <span className="paint-sep" />
      <button type="button" className="paint-btn"
              data-track-id="paint_exit" onClick={onClose} title="退出绘制">完成</button>
    </div>
  );
}

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 }}>
          贴图未生成。3D资产管线会在建模后自动跑一次 Meshy 贴图，
          也可以点右上角 <I.Refresh size={11} /> 重新生成。
        </div>
      </div>
    </div>
  );
}

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