// Marketplace app —— PR-S.1c rewrite per Claude Design bundle 8Uv-tmaD1W9VPBLDMeqJhg.
//
// Layout(完全照设计稿):
//   topbar:brand · tabs(Playground/Manage/Evals/Traces/[Marketplace active])· Publish · My installs
//   market-tabs:Skills | MCP Servers(各自 count badge)
//   market-toolbar:search + (cats — 仅当后端给了 category 才渲染) + installed counter + sort
//   market-scroll → market-grid(4 / row,48 / page)→ Pager
//
// Card(简化版,跟设计稿最后一轮一致):
//   icon(soft-tinted initials,左)+ body(name + 2-line desc + minimal stats)+ add-btn(右)
//   stats 我们用真后端字段而不是 fake installs / rating:
//     - skill:`v{latest_version}` + `verified ✓`(visibility=public)
//     - mcp:`{transport}` + `available ✓` / `unavailable ⚠`
//
// 数据(并发拉 4 路,跟 PR-S.1b 一致):
//   GET /v1/marketplace/skills   — 公共 catalog,不含 per-user 字段
//   GET /v1/marketplace/mcps     — 同上
//   GET /v1/skills               — per-user 视角(看 `enabled` 判断 installed)
//   GET /v1/user/bindings        — 真相源:skill_ids ∪ mcp_ids
//
// Install / Uninstall(行为不变,跟 PR-S.1b 同):
//   skill install  → POST /v1/user/skills/{id}/install
//   skill uninstall:
//     · owner_user_id == 我 → DELETE /v1/personal-skills/{id}(物理删 clone)
//     · 其它 → PATCH /v1/user/bindings 把 id 从 skill_ids 摘掉
//   mcp install    → PATCH /v1/user/bindings 把 server_id append 到 mcp_ids
//   mcp uninstall  → PATCH /v1/user/bindings 把 server_id 从 mcp_ids 摘掉
//
// 任何 mutation 后 refreshUserState() 重拉 listSkills + getUserBindings 再算 installed 集合。

const { useState: mkUseState, useEffect: mkUseEffect, useMemo: mkUseMemo, useCallback: mkUseCallback } = React;

const PAGE_SIZE = 48;  // 4 per row × 12 rows

// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------

// Map a hex like "#2563eb" to a soft pastel bg by adding alpha. CSS file's
// `.app-card .icon` paints text in `item.color`; we paint bg as `color + "1f"`
// (12% alpha) here, matching the design's pastel tint.
function tintBg(color) {
  if (!color || typeof color !== "string" || !color.startsWith("#")) {
    return "rgba(13, 13, 13, 0.06)";
  }
  return color + "1f";
}

// Distinct categories present in this list. Backend doesn't yet ship a
// `category` field; for V1 (no categories) we return ["All"] and the chips
// bar is skipped entirely by the caller.
function deriveCategories(items) {
  const set = new Set();
  for (const it of items) {
    if (it && typeof it.category === "string" && it.category) set.add(it.category);
  }
  return ["All", ...Array.from(set).sort()];
}

// ----------------------------------------------------------------------------
// AppCard — icon + body (name / desc / minimal stats) + + button
// ----------------------------------------------------------------------------

function AppCard({ item, kind, installed, busy, onInstall, onUninstall, disabledReason }) {
  const handle = () => {
    if (busy) return;
    if (disabledReason && !installed) return;
    if (installed) onUninstall();
    else onInstall();
  };

  // Stats line — real backend fields, not fake installs/rating.
  let statsBits;
  if (kind === "skill") {
    statsBits = (
      <>
        {item.latest_version && <span className="s">v{item.latest_version}</span>}
        {item.visibility === "public" && (
          <span className="s verified" title="public skill"><Icon name="check" size={12} /> public</span>
        )}
      </>
    );
  } else {
    statsBits = (
      <>
        {item.transport && <span className="s">{item.transport}</span>}
        {item.available === false
          ? <span className="s warn" title="MCP server is currently unreachable"><Icon name="x" size={12} /> unavailable</span>
          : <span className="s verified"><Icon name="check" size={12} /> available</span>}
      </>
    );
  }

  return (
    <div className="app-card">
      <div className="icon" style={{ background: tintBg(item.color), color: item.color }}>
        {item.initials || "?"}
      </div>
      <div className="body">
        <div className="nm" title={item.name}>{item.name}</div>
        <div className="desc">{item.description || item.desc || "No description."}</div>
        <div className="stats">{statsBits}</div>
      </div>
      <button
        className={"add-btn" + (installed ? " installed" : "") + (busy ? " installing" : "")}
        onClick={handle}
        disabled={!!disabledReason && !installed}
        title={
          busy ? "Working…"
          : installed ? "Uninstall"
          : disabledReason ? disabledReason
          : "Install"
        }
      >
        {busy ? (
          <span className="spinner"></span>
        ) : installed ? (
          <>
            <span className="ic-check"><Icon name="check" size={15} /></span>
            <span className="ic-remove"><Icon name="x" size={15} /></span>
          </>
        ) : (
          <Icon name="plus" size={16} />
        )}
      </button>
    </div>
  );
}

// ----------------------------------------------------------------------------
// InstallConfirmModal — PR-S.6c collision popup (D3)
// ----------------------------------------------------------------------------
//
// 渲染场景:
//   1. preflight 返 already_installed=true → "Already installed" 提示(idempotent
//      install,理论 UI 卡住了已 grey out 的 button,但 API 直调可能到这条)
//   2. preflight collisions 非空 → 列每条 shadow:
//      "This will shadow `<name>` from agent <id>" 或 "from your existing skill"
//      [Continue] 继续 install · [Cancel] 取消
//
// 设计稿统一性:`fixed` 全屏覆盖,中间卡片(white bg + border + 280px min-width),
// 跟 manage / marketplace 的 quiet 调一致;只用 styles.css 已有的 tokens。

function InstallConfirmModal({ skill, preflight, onConfirm, onCancel }) {
  const collisions = Array.isArray(preflight?.collisions) ? preflight.collisions : [];
  const alreadyInstalled = !!preflight?.already_installed;

  return (
    <div
      onClick={onCancel}
      style={{
        position: "fixed", inset: 0, zIndex: 9000,
        background: "rgba(13, 13, 13, 0.42)",
        display: "flex", alignItems: "center", justifyContent: "center",
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "var(--bg)", borderRadius: 12,
          border: "1px solid var(--border)",
          boxShadow: "0 24px 64px rgba(0,0,0,0.18)",
          minWidth: 380, maxWidth: 520, padding: 24,
        }}
      >
        <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
          <Icon name="plus" size={18} />
          <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
            Install <span style={{ fontFamily: "var(--font-mono)" }}>{skill.name}</span>?
          </h2>
        </div>

        {alreadyInstalled && (
          <div
            style={{
              marginTop: 14, padding: "10px 12px", borderRadius: 8,
              background: "var(--accent-bg)", color: "var(--accent)",
              fontSize: 12.5, lineHeight: 1.5,
            }}
          >
            <strong>You already have a fork of this skill.</strong> Re-installing
            won't create a second copy — the existing instance stays as-is.
          </div>
        )}

        {collisions.length > 0 && (
          <>
            <p style={{
              margin: "16px 0 6px", fontSize: 13.5, color: "var(--text-muted)",
              lineHeight: 1.55,
            }}>
              Installing will <strong>shadow</strong> a same-name skill that already
              belongs to:
            </p>
            <ul style={{
              listStyle: "none", padding: 0, margin: 0,
              border: "1px solid var(--border)", borderRadius: 8,
              background: "var(--bg-subtle)",
            }}>
              {collisions.map((col, i) => (
                <li
                  key={i}
                  style={{
                    padding: "8px 12px",
                    borderBottom: i + 1 < collisions.length ? "1px solid var(--border)" : "none",
                    fontSize: 12.5, color: "var(--text)",
                    fontFamily: "var(--font-mono)",
                  }}
                >
                  <span style={{ color: "var(--text-muted)" }}>shadow </span>
                  <strong>{col.name}</strong>
                  <span style={{ color: "var(--text-muted)" }}> in </span>
                  <span>{col.source}</span>
                </li>
              ))}
            </ul>
            <p style={{
              margin: "10px 0 0", fontSize: 12, color: "var(--text-faint)",
              lineHeight: 1.5,
            }}>
              When you run that agent, your installed version takes priority
              (user &gt; agent on same-name).
            </p>
          </>
        )}

        <div
          style={{
            marginTop: 22, display: "flex", justifyContent: "flex-end", gap: 8,
          }}
        >
          <button className="btn ghost sm" onClick={onCancel}>Cancel</button>
          <button
            className="btn sm"
            onClick={onConfirm}
            style={{
              background: "var(--text)", color: "#fff", borderColor: "var(--text)",
            }}
          >
            {alreadyInstalled && !collisions.length ? "OK" : "Continue"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ----------------------------------------------------------------------------
// McpInstallModal — PR-S.7c: MCP install 表单(env + secrets 输入)
// ----------------------------------------------------------------------------
//
// 跟 InstallConfirmModal 不同:**带表单**,因为 MCP install 需要 user 填
// catalog 声明的 env / secret 字段。modal:
//   - 如果有 collisions → 顶部列出 "this will shadow X" 提示
//   - 如果 already_installed → 顶部 "Re-install will overwrite env+secrets" 提示
//   - 对每个 config_schema 字段渲染 input:
//       type=secret → password input(masked)
//       type=string → 普通 text input,带 placeholder=default
//       required → label 加 "*"
//   - footer:Cancel / Install
// onConfirm 收集 form 数据传回 (env_dict, secrets_dict)。

function McpInstallModal({ mcp, preflight, onConfirm, onCancel }) {
  const schema = Array.isArray(preflight?.config_schema) ? preflight.config_schema : [];
  const collisions = Array.isArray(preflight?.collisions) ? preflight.collisions : [];
  const alreadyInstalled = !!preflight?.already_installed;

  // Initialize form state from schema defaults
  const initial = {};
  for (const f of schema) {
    initial[f.name] = f.default || "";
  }
  const [values, setValues] = React.useState(initial);
  const [submitting, setSubmitting] = React.useState(false);

  function update(name, val) {
    setValues((prev) => ({ ...prev, [name]: val }));
  }

  function handleSubmit(e) {
    if (e) e.preventDefault();
    if (submitting) return;
    setSubmitting(true);
    const env = {};
    const secrets = {};
    for (const f of schema) {
      const v = values[f.name];
      if (v == null || v === "") {
        if (f.required) continue;     // skip empty optional fields
        continue;
      }
      if (f.type === "secret") secrets[f.name] = v;
      else env[f.name] = v;
    }
    onConfirm(env, secrets);
  }

  return (
    <div
      onClick={onCancel}
      style={{
        position: "fixed", inset: 0, zIndex: 9000,
        background: "rgba(13, 13, 13, 0.42)",
        display: "flex", alignItems: "center", justifyContent: "center",
      }}
    >
      <form
        onClick={(e) => e.stopPropagation()}
        onSubmit={handleSubmit}
        style={{
          background: "var(--bg)", borderRadius: 12,
          border: "1px solid var(--border)",
          boxShadow: "0 24px 64px rgba(0,0,0,0.18)",
          minWidth: 420, maxWidth: 560, padding: 24,
        }}
      >
        <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
          <Icon name="plus" size={18} />
          <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
            Install <span style={{ fontFamily: "var(--font-mono)" }}>{mcp.name}</span>?
          </h2>
        </div>

        {alreadyInstalled && (
          <div style={{
            marginTop: 14, padding: "10px 12px", borderRadius: 8,
            background: "var(--accent-bg)", color: "var(--accent)",
            fontSize: 12.5, lineHeight: 1.5,
          }}>
            <strong>You've installed this MCP already.</strong> Re-install
            will <strong>overwrite</strong> your existing env + secrets with
            the values below.
          </div>
        )}

        {collisions.length > 0 && (
          <>
            <p style={{
              margin: "16px 0 6px", fontSize: 13.5, color: "var(--text-muted)",
              lineHeight: 1.55,
            }}>
              Installing will <strong>shadow</strong> a same-name MCP already
              belonging to:
            </p>
            <ul style={{
              listStyle: "none", padding: 0, margin: 0,
              border: "1px solid var(--border)", borderRadius: 8,
              background: "var(--bg-subtle)",
            }}>
              {collisions.map((col, i) => (
                <li key={i} style={{
                  padding: "8px 12px",
                  borderBottom: i + 1 < collisions.length ? "1px solid var(--border)" : "none",
                  fontSize: 12.5, color: "var(--text)",
                  fontFamily: "var(--font-mono)",
                }}>
                  <span style={{ color: "var(--text-muted)" }}>shadow </span>
                  <strong>{col.name}</strong>
                  <span style={{ color: "var(--text-muted)" }}> in </span>
                  <span>{col.source}</span>
                </li>
              ))}
            </ul>
          </>
        )}

        {schema.length > 0 && (
          <>
            <p style={{
              margin: "16px 0 10px", fontSize: 13, color: "var(--text-muted)",
            }}>
              Configure this MCP for your account:
            </p>
            <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
              {schema.map((f) => (
                <div key={f.name}>
                  <label style={{
                    display: "block", fontSize: 12, fontWeight: 500,
                    color: "var(--text)", marginBottom: 4,
                    fontFamily: "var(--font-mono)",
                  }}>
                    {f.name}{f.required ? " *" : ""}
                    {f.type === "secret" && (
                      <span style={{
                        marginLeft: 6, fontSize: 10,
                        background: "var(--bg-subtle)", padding: "1px 5px",
                        borderRadius: 3, color: "var(--text-muted)",
                        fontFamily: "var(--font-sans)", letterSpacing: 0.4,
                      }}>SECRET</span>
                    )}
                  </label>
                  <input
                    type={f.type === "secret" ? "password" : "text"}
                    value={values[f.name] || ""}
                    onChange={(e) => update(f.name, e.target.value)}
                    placeholder={f.default || (f.type === "secret" ? "•••••" : "")}
                    autoComplete={f.type === "secret" ? "new-password" : "off"}
                    style={{
                      width: "100%", padding: "7px 10px", fontSize: 12.5,
                      border: "1px solid var(--border)", borderRadius: 6,
                      background: "white", outline: "none",
                      fontFamily: f.type === "secret"
                        ? "var(--font-mono)" : "inherit",
                    }}
                  />
                  {f.description && (
                    <div style={{
                      marginTop: 3, fontSize: 11.5, color: "var(--text-faint)",
                    }}>
                      {f.description}
                    </div>
                  )}
                </div>
              ))}
            </div>
          </>
        )}

        {schema.length === 0 && !alreadyInstalled && collisions.length === 0 && (
          <p style={{
            margin: "16px 0 0", fontSize: 13, color: "var(--text-muted)",
            lineHeight: 1.55,
          }}>
            This MCP doesn't require any per-user configuration. Click
            Install to bind it to your account.
          </p>
        )}

        <div style={{
          marginTop: 22, display: "flex", justifyContent: "flex-end", gap: 8,
        }}>
          <button type="button" className="btn ghost sm" onClick={onCancel}>
            Cancel
          </button>
          <button
            type="submit"
            className="btn sm"
            disabled={submitting}
            style={{
              background: "var(--text)", color: "#fff", borderColor: "var(--text)",
            }}
          >
            {submitting ? "…" : "Install"}
          </button>
        </div>
      </form>
    </div>
  );
}

// ----------------------------------------------------------------------------
// Pager
// ----------------------------------------------------------------------------

function Pager({ page, pageCount, onChange, total, start, end }) {
  if (pageCount <= 1) {
    return (
      <div className="market-pager">
        <span className="info">{total} item{total === 1 ? "" : "s"}</span>
      </div>
    );
  }
  const nums = [];
  const push = (n) => nums.push(n);
  push(1);
  const lo = Math.max(2, page - 1);
  const hi = Math.min(pageCount - 1, page + 1);
  if (lo > 2) nums.push("…");
  for (let i = lo; i <= hi; i++) push(i);
  if (hi < pageCount - 1) nums.push("…");
  if (pageCount > 1) push(pageCount);
  return (
    <div className="market-pager">
      <span className="info">Showing {start + 1}–{end} of {total}</span>
      <button className="pager-btn" disabled={page === 1} onClick={() => onChange(page - 1)}>
        <Icon name="arrow-left" size={13} />
      </button>
      {nums.map((n, i) => n === "…"
        ? <span key={"e" + i} className="pager-ellipsis">…</span>
        : <button key={n} className={"pager-btn" + (n === page ? " active" : "")} onClick={() => onChange(n)}>{n}</button>
      )}
      <button className="pager-btn" disabled={page === pageCount} onClick={() => onChange(page + 1)}>
        <Icon name="chev" size={13} />
      </button>
    </div>
  );
}

// ----------------------------------------------------------------------------
// MarketplaceApp
// ----------------------------------------------------------------------------

function MarketplaceApp() {
  const [tab, setTab] = mkUseState("skill");           // "skill" | "mcp"
  const [marketSkills, setMarketSkills] = mkUseState([]);
  const [marketSkillTotal, setMarketSkillTotal] = mkUseState(0);
  const [marketMcps, setMarketMcps] = mkUseState([]);
  const [mySkills, setMySkills] = mkUseState([]);
  const [bindings, setBindings] = mkUseState({ skill_ids: [], mcp_ids: [] });
  const [query, setQuery] = mkUseState("");
  const [cat, setCat] = mkUseState("All");
  const [sort, setSort] = mkUseState("name");           // "name" | "newest"
  const [page, setPage] = mkUseState(1);
  const [loading, setLoading] = mkUseState(true);
  const [skillPageLoading, setSkillPageLoading] = mkUseState(false);
  const [error, setError] = mkUseState(null);
  const [busyIds, setBusyIds] = mkUseState({});
  // PR-S.1d:My installs 过滤 — 只显示当前 user 已绑定的 catalog 条目。
  // Manage 把 Tenant 编辑入口砍了,所以"我装了哪些 skills / mcps"现在只在
  // 这里看 ——My installs 按钮(topbar 右上)切这个 boolean。
  const [installedOnly, setInstalledOnly] = mkUseState(false);
  // PR-S.6c:install confirm modal。null = 不显;否则 { skill, preflight }。
  const [pendingInstall, setPendingInstall] = mkUseState(null);
  // PR-S.7c MCP 同 pattern,但有 env/secrets 表单 → 自己 state。
  const [pendingMcpInstall, setPendingMcpInstall] = mkUseState(null);
  // PR-S.17d · topbar 上 Users 入口 admin-gate
  const [isAdmin, setIsAdmin] = mkUseState(false);
  mkUseEffect(() => {
    window.api.authMe()
      .then((m) => setIsAdmin(!!m.is_admin))
      .catch(() => {});
  }, []);

  // Reset paging + category + query when switching tab.
  mkUseEffect(() => { setPage(1); setCat("All"); setQuery(""); }, [tab]);

  const refreshUserState = mkUseCallback(async () => {
    const cursor = String((Math.max(1, page) - 1) * PAGE_SIZE);
    const [market, mcpMarket, mine, b] = await Promise.all([
      window.api.listMarketplaceSkillsPage({
        limit: PAGE_SIZE,
        cursor,
        q: query,
        sort,
        installedOnly,
      }),
      window.api.listMarketplaceMcps(),
      window.api.listSkills({ scope: "mine" }),
      window.api.getUserBindings(),
    ]);
    setMarketSkills(Array.isArray(market?.items) ? market.items : []);
    setMarketSkillTotal(Number.isFinite(Number(market?.total_count)) ? Number(market.total_count) : 0);
    setMarketMcps(Array.isArray(mcpMarket) ? mcpMarket : []);
    setMySkills(Array.isArray(mine) ? mine : []);
    setBindings({
      skill_ids: Array.isArray(b?.skill_ids) ? b.skill_ids : [],
      mcp_ids: Array.isArray(b?.mcp_ids) ? b.mcp_ids : [],
    });
  }, [page, query, sort, installedOnly]);

  mkUseEffect(() => {
    (async () => {
      try {
        const [m, mine, b] = await Promise.all([
          window.api.listMarketplaceMcps(),
          window.api.listSkills({ scope: "mine" }),
          window.api.getUserBindings(),
        ]);
        setMarketMcps(m);
        setMySkills(Array.isArray(mine) ? mine : []);
        setBindings({
          skill_ids: Array.isArray(b?.skill_ids) ? b.skill_ids : [],
          mcp_ids: Array.isArray(b?.mcp_ids) ? b.mcp_ids : [],
        });
      } catch (e) {
        setError(e?.message || String(e));
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  mkUseEffect(() => {
    if (tab !== "skill") return undefined;
    let active = true;
    (async () => {
      setSkillPageLoading(true);
      setError(null);
      try {
        const cursor = String((Math.max(1, page) - 1) * PAGE_SIZE);
        const resp = await window.api.listMarketplaceSkillsPage({
          limit: PAGE_SIZE,
          cursor,
          q: query,
          sort,
          installedOnly,
        });
        if (!active) return;
        setMarketSkills(Array.isArray(resp.items) ? resp.items : []);
        setMarketSkillTotal(Number.isFinite(Number(resp.total_count)) ? Number(resp.total_count) : 0);
      } catch (e) {
        if (active) setError(e?.message || String(e));
      } finally {
        if (active) setSkillPageLoading(false);
      }
    })();
    return () => { active = false; };
  }, [tab, page, query, sort, installedOnly]);

  // installed = (enabled in mySkills) ∪ (skill_id in bindings).
  const installedSkillIds = mkUseMemo(() => {
    const s = new Set(bindings.skill_ids || []);
    for (const sk of mySkills) {
      if (sk?.enabled) s.add(sk.skill_id);
    }
    return s;
  }, [bindings.skill_ids, mySkills]);

  const ownedSkillIds = mkUseMemo(() => {
    const s = new Set();
    for (const sk of mySkills) {
      if (sk?.owner_type === "user" && sk?.owner_user_id === window.api.userId) {
        s.add(sk.skill_id);
      }
    }
    return s;
  }, [mySkills]);

  const setBusy = (id, on) => {
    setBusyIds((prev) => {
      const next = { ...prev };
      if (on) next[id] = true;
      else delete next[id];
      return next;
    });
  };

  // ---- mutations ---------------------------------------------------------

  async function installSkill(skill) {
    // PR-S.6c install flow:
    //   1. preflight 检测 shadow / already-installed
    //   2. 有警告 → 弹 modal,等 user 点 Continue/Cancel
    //   3. 干净 → 直接 install
    // preflight 挂(老后端没这 endpoint)→ skip modal,直接 install。
    setBusy(skill.skill_id, true);
    setError(null);
    let preflight = null;
    try {
      preflight = await window.api.preflightInstallSkill(skill.skill_id);
    } catch {
      preflight = null;
    }
    const hasCollisions = preflight && Array.isArray(preflight.collisions) && preflight.collisions.length > 0;
    const alreadyInstalled = preflight && preflight.already_installed;
    if (preflight && (hasCollisions || alreadyInstalled)) {
      // 留 busy=true,modal 的 Continue/Cancel handler 才 clear。
      setPendingInstall({ skill, preflight });
      return;
    }
    try {
      await window.api.installSkill(skill.skill_id, skill.latest_version);
      await refreshUserState();
    } catch (e) {
      setError(`Install ${skill.name}: ${e?.message || e}`);
    } finally {
      setBusy(skill.skill_id, false);
    }
  }

  async function confirmPendingInstall() {
    if (!pendingInstall) return;
    const { skill } = pendingInstall;
    setError(null);
    setPendingInstall(null);
    try {
      await window.api.installSkill(skill.skill_id, skill.latest_version);
      await refreshUserState();
    } catch (e) {
      setError(`Install ${skill.name}: ${e?.message || e}`);
    } finally {
      setBusy(skill.skill_id, false);
    }
  }

  function cancelPendingInstall() {
    if (!pendingInstall) return;
    const { skill } = pendingInstall;
    setPendingInstall(null);
    setBusy(skill.skill_id, false);
  }

  async function uninstallSkill(skill) {
    setBusy(skill.skill_id, true);
    setError(null);
    try {
      const instanceId = skill.installed_instance_id || skill.skill_id;
      if (ownedSkillIds.has(instanceId)) {
        await window.api.deletePersonalSkill(instanceId);
      } else {
        const cur = bindings.skill_ids || [];
        if (cur.includes(instanceId)) {
          const next = cur.filter((id) => id !== instanceId);
          await window.api.updateUserBindings({ skill_ids: next, mcp_ids: bindings.mcp_ids });
        }
      }
      await refreshUserState();
    } catch (e) {
      setError(`Uninstall ${skill.name}: ${e?.message || e}`);
    } finally {
      setBusy(skill.skill_id, false);
    }
  }

  async function installMcp(mcp) {
    // PR-S.7c install flow:
    //   1. preflight 拿 collisions / already_installed / config_schema
    //   2. 任何一项非空 → 弹 modal 收 env+secrets,Confirm 才真 install
    //   3. 干净 + 无 config_schema → 直 install(只 wire bindings,无 env / secret)
    //   preflight 挂(老后端无 endpoint)→ 走 legacy bindings PATCH 兜底
    setBusy(mcp.server_id, true);
    setError(null);
    let preflight = null;
    try {
      preflight = await window.api.preflightInstallMcp(mcp.server_id);
    } catch {
      preflight = null;
    }
    if (preflight) {
      const hasCollisions = Array.isArray(preflight.collisions) && preflight.collisions.length > 0;
      const alreadyInstalled = !!preflight.already_installed;
      const needsForm = Array.isArray(preflight.config_schema) && preflight.config_schema.length > 0;
      if (hasCollisions || alreadyInstalled || needsForm) {
        setPendingMcpInstall({ mcp, preflight });
        return;
      }
      // Clean + no form → install via new fork endpoint with empty env/secrets.
      try {
        await window.api.installMcp(mcp.server_id, { env: {}, secrets: {} });
        await refreshUserState();
      } catch (e) {
        setError(`Install ${mcp.name}: ${e?.message || e}`);
      } finally {
        setBusy(mcp.server_id, false);
      }
      return;
    }
    // Legacy fallback:老 backend 没 preflight endpoint → bindings PATCH 路径
    try {
      const cur = bindings.mcp_ids || [];
      if (!cur.includes(mcp.server_id)) {
        const next = [...cur, mcp.server_id];
        await window.api.updateUserBindings({ skill_ids: bindings.skill_ids, mcp_ids: next });
      }
      await refreshUserState();
    } catch (e) {
      setError(`Install ${mcp.name}: ${e?.message || e}`);
    } finally {
      setBusy(mcp.server_id, false);
    }
  }

  async function confirmPendingMcpInstall(env, secrets) {
    if (!pendingMcpInstall) return;
    const { mcp } = pendingMcpInstall;
    setError(null);
    setPendingMcpInstall(null);
    try {
      await window.api.installMcp(mcp.server_id, { env, secrets });
      await refreshUserState();
    } catch (e) {
      setError(`Install ${mcp.name}: ${e?.message || e}`);
    } finally {
      setBusy(mcp.server_id, false);
    }
  }

  function cancelPendingMcpInstall() {
    if (!pendingMcpInstall) return;
    const { mcp } = pendingMcpInstall;
    setPendingMcpInstall(null);
    setBusy(mcp.server_id, false);
  }

  async function uninstallMcp(mcp) {
    setBusy(mcp.server_id, true);
    setError(null);
    try {
      const instanceId = mcp.installed_instance_id;
      if (instanceId) {
        await window.api.uninstallMcp(instanceId);
      } else {
        const mids = bindings.mcp_ids || [];
        if (!mids.includes(mcp.server_id)) {
          throw new Error("Could not find a matching MCP installation.");
        }
        await window.api.updateUserBindings({
          skill_ids: bindings.skill_ids,
          mcp_ids: mids.filter((id) => id !== mcp.server_id),
        });
      }
      await refreshUserState();
    } catch (e) {
      setError(`Uninstall ${mcp.name}: ${e?.message || e}`);
    } finally {
      setBusy(mcp.server_id, false);
    }
  }

  // ---- derived filtered + paged list ------------------------------------

  const catalog = tab === "skill" ? marketSkills : marketMcps;
  const cats = mkUseMemo(() => deriveCategories(catalog), [catalog]);

  const filtered = mkUseMemo(() => {
    if (tab === "skill") return marketSkills;
    let xs = catalog.filter((it) => {
      if (cat !== "All" && it.category !== cat) return false;
      if (query.trim()) {
        const q = query.toLowerCase();
        const name = (it.name || "").toLowerCase();
        const desc = (it.description || it.desc || "").toLowerCase();
        if (!name.includes(q) && !desc.includes(q)) return false;
      }
      if (installedOnly) {
        if (!it.installed) return false;
      }
      return true;
    });
    if (sort === "newest") {
      xs = [...xs].sort((a, b) => {
        const av = a.latest_version || "";
        const bv = b.latest_version || "";
        if (av === bv) return (a.name || "").localeCompare(b.name || "");
        return bv.localeCompare(av);
      });
    } else {
      xs = [...xs].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
    }
    return xs;
  }, [catalog, marketSkills, cat, query, sort, installedOnly, tab]);

  const total = tab === "skill" ? marketSkillTotal : filtered.length;
  const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
  const safePage = Math.min(page, pageCount);
  const start = (safePage - 1) * PAGE_SIZE;
  const pageItems = tab === "skill" ? marketSkills : filtered.slice(start, Math.min(start + PAGE_SIZE, filtered.length));
  const end = Math.min(start + pageItems.length, total);

  const installedCount = tab === "skill"
    ? installedSkillIds.size
    : marketMcps.filter((mcp) => mcp.installed).length;

  // ---- topbar nav --------------------------------------------------------

  const navLink = (label, href) => (
    <a href={href} style={{
      background: "transparent", border: 0, padding: "6px 10px", borderRadius: 6,
      color: "var(--text-muted)", fontSize: 13, textDecoration: "none", cursor: "pointer",
    }}>{label}</a>
  );

  return (
    <div className="app market-app">
      <div className="topbar">
        <div className="brand">
          baizhi
          <span style={{ color: "var(--text-faint)" }}>/</span>
          <span style={{ fontWeight: 500, color: "var(--text-muted)" }}>Marketplace</span>
        </div>
        <div className="tabs">
          {navLink("Playground", "/playground")}
          {navLink("Manage", "/manage")}
          {navLink("Evals", "/evals")}
          {navLink("Traces", "/traces")}
          <button className="active">Marketplace</button>
          {navLink("Apps", "/apps")}
          {isAdmin && navLink("Users", "/users")}
          {navLink("API Docs", "/api-docs")}
        </div>
        {/* PR-S.2-nav-order: Marketplace 是统一顺序里的第 5 位,active button
            天然落在正确位置(它自己),无需再 reorder。*/}
        <div className="spacer" />
        <div className="actions">
          <a href="/manage" className="btn ghost sm" title="Publish a new skill via Manage page" style={{ textDecoration: "none" }}>
            <Icon name="plus" size={12} /> Publish
          </a>
          <button
            className={"btn sm" + (installedOnly ? " primary" : "")}
            onClick={() => setInstalledOnly((v) => !v)}
            title={installedOnly
              ? "Showing only items you've installed — click to browse all"
              : `${installedCount} ${tab === "skill" ? "skills" : "MCP servers"} installed — click to show only those`}
          >
            <Icon name="grid" size={12} /> My&nbsp;installs
            {installedCount > 0 && (
              <span style={{
                marginLeft: 6, fontFamily: "var(--font-mono)", fontSize: 11,
                background: installedOnly ? "rgba(255,255,255,0.18)" : "var(--bg-subtle)",
                padding: "1px 6px", borderRadius: 3,
              }}>{installedCount}</span>
            )}
          </button>
        </div>
      </div>

      <div className="market-tabs">
        <button className={tab === "skill" ? "active" : ""} onClick={() => setTab("skill")}>
          <Icon name="wand" size={13} /> Skills
          <span className="count">{marketSkillTotal}</span>
        </button>
        <button className={tab === "mcp" ? "active" : ""} onClick={() => setTab("mcp")}>
          <Icon name="tool" size={13} /> MCP Servers
          <span className="count">{marketMcps.length}</span>
        </button>
      </div>

      <div className="market-toolbar">
        <div className="search-input">
          <span className="ic"><Icon name="search" size={13} /></span>
          <input
            placeholder={`Search ${tab === "skill" ? "skills" : "MCP servers"}…`}
            value={query}
            onChange={(e) => { setQuery(e.target.value); setPage(1); }}
          />
        </div>
        {cats.length > 1 ? (
          <div className="market-cats">
            {cats.map((c) => (
              <button key={c} className={"cat-chip" + (c === cat ? " active" : "")} onClick={() => { setCat(c); setPage(1); }}>{c}</button>
            ))}
          </div>
        ) : (
          <div className="toolbar-spacer" />
        )}
        <button
          className="installed-chip"
          onClick={() => { setPage(1); setInstalledOnly((v) => !v); }}
          title={installedOnly ? "Click to show all items" : "Click to filter to only your installs"}
          style={{
            border: 0, cursor: "pointer",
            outline: installedOnly ? "2px solid var(--accent)" : "none",
            outlineOffset: 1,
          }}
        >
          <Icon name="check" size={12} /> {installedCount} installed
        </button>
        <div className="market-sort">
          <select value={sort} onChange={(e) => { setSort(e.target.value); setPage(1); }}>
            <option value="name">Name A–Z</option>
            <option value="newest">Recently published</option>
          </select>
        </div>
      </div>

      {error && <div className="market-error">{error}</div>}

      {pendingInstall && (
        <InstallConfirmModal
          skill={pendingInstall.skill}
          preflight={pendingInstall.preflight}
          onConfirm={confirmPendingInstall}
          onCancel={cancelPendingInstall}
        />
      )}

      {pendingMcpInstall && (
        <McpInstallModal
          mcp={pendingMcpInstall.mcp}
          preflight={pendingMcpInstall.preflight}
          onConfirm={confirmPendingMcpInstall}
          onCancel={cancelPendingMcpInstall}
        />
      )}

      <div className="market-scroll">
        {loading || (tab === "skill" && skillPageLoading) ? (
          <div className="market-empty">
            <div className="icon"><Icon name="search" size={20} /></div>
            <h3>Loading…</h3>
            <p>Fetching marketplace catalog and your installs.</p>
          </div>
        ) : pageItems.length === 0 ? (
          <div className="market-empty">
            <div className="icon"><Icon name="search" size={20} /></div>
            <h3>No {tab === "skill" ? "skills" : "MCP servers"} found</h3>
            <p>{installedOnly
              ? `You haven't installed any ${tab === "skill" ? "skills" : "MCP servers"} yet. Browse the catalog and click + to install.`
              : (query || cat !== "All")
                ? "Try a different search term or category."
                : "Admins can add catalog items from the Manage page."}</p>
            {installedOnly && (
              <p style={{ marginTop: 12 }}>
                <button className="btn sm" onClick={() => { setPage(1); setInstalledOnly(false); }}>Show all</button>
              </p>
            )}
          </div>
        ) : (
          <>
            <div className="market-grid">
              {pageItems.map((it) => {
                if (tab === "skill") {
                  const id = it.skill_id;
                  const installed = !!it.installed;
                  return (
                    <AppCard
                      key={id}
                      item={it}
                      kind="skill"
                      installed={installed}
                      busy={!!busyIds[id]}
                      onInstall={() => installSkill(it)}
                      onUninstall={() => uninstallSkill(it)}
                    />
                  );
                } else {
                  const id = it.server_id;
                  const installed = !!it.installed;
                  const disabledReason = !installed && it.available === false
                    ? "MCP server is currently unavailable"
                    : null;
                  return (
                    <AppCard
                      key={id}
                      item={it}
                      kind="mcp"
                      installed={installed}
                      busy={!!busyIds[id]}
                      disabledReason={disabledReason}
                      onInstall={() => installMcp(it)}
                      onUninstall={() => uninstallMcp(it)}
                    />
                  );
                }
              })}
            </div>
            <Pager
              page={safePage}
              pageCount={pageCount}
              onChange={setPage}
              total={total}
              start={start}
              end={end}
            />
          </>
        )}
      </div>
    </div>
  );
}

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