// ====== Manager app (wired to live backend) ======
//
// Loads skills first, then agents / mcps from the API at mount; agent
// decoration needs window.SKILLS to translate backend skill_ids.
// Surfaces them through the three editors which call back into api.* for persistence.
//
// What the backend supports today (so the UI is honest about it):
//   • Agents:  list, create, update (name + system_prompt + skill_ids).
//              No delete endpoint — Delete button is hidden.
//   • Skills:  list, create personal-skill draft, update draft, publish.
//              File tree only roundtrips SKILL.md content — backend doesn't
//              store other files yet. Other files are visible/editable but
//              warned as local-only.
//   • MCP:     list, create, update, delete, check health, and bind/unbind
//              the catalog item for the current user's runs.

const { useState: mUseState, useEffect: mUseEffect, useRef: mUseRef, useMemo: mUseMemo } = React;
const MANAGE_MARKET_SKILLS_LIMIT = 200;
const MODEL_CONFIG_UI_KEYS = [
  "modelProvider", "model", "temperature", "top_p", "max_tokens",
  "reasoning_enabled", "reasoning_visible",
];

function loadSkillFiles(skill) {
  // The list-skills endpoint doesn't return the SKILL.md body — only the
  // editor needs it, and only for personal/editable skills (drafts). For
  // public skills, we show a synthesized SKILL.md placeholder.
  if (!skill) return [];
  // If we already populated files (e.g. for a freshly-created personal
  // skill), keep them.
  if (skill.files && skill.files.some((f) => f.content)) return skill.files;
  return [{
    path: "SKILL.md",
    required: true,
    content: skill.editable
      ? "# SKILL.md content not yet fetched. Click in the editor to start writing."
      : `# ${skill.name}\n\n${skill.description || ""}\n\n(read-only: this is a public skill installed from the catalog)`,
  }];
}

function changedLineRange(before = "", after = "") {
  const oldLines = before.split("\n");
  const newLines = after.split("\n");
  let prefix = 0;
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
  let suffix = 0;
  while (
    suffix < oldLines.length - prefix && suffix < newLines.length - prefix
    && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
  ) suffix += 1;
  return {
    start: prefix + 1,
    end: Math.max(prefix + 1, newLines.length - suffix),
  };
}

function skillTestBadge(state) {
  if (state?.status !== "passed") return null;
  const completedAt = Number(state.completed_at || 0);
  const detail = completedAt
    ? `Skill Test passed on ${new Date(completedAt).toLocaleString()}`
    : "Skill Test passed";
  return <span className="pill ok" title={detail}>tested</span>;
}

function ManagerApp() {
  const [section, setSection] = mUseState("agents");

  const [agents, setAgents] = mUseState([]);
  const [skills, setSkills] = mUseState([]);
  const [mcps, setMcps] = mUseState([]);
  const [apps, setApps] = mUseState([]);
  const [localTools, setLocalTools] = mUseState([]);
  // PR-S.1: marketplace = 全平台 public skills/mcps,跟 Mine 视图(`skills`/`mcps`)
  // 分开 fetch。subTab 决定 list-col 展示哪一份。
  const [marketSkills, setMarketSkills] = mUseState([]);
  const [marketSkillsTotal, setMarketSkillsTotal] = mUseState(0);
  const [marketSkillsCursor, setMarketSkillsCursor] = mUseState(null);
  const [marketSkillsHasMore, setMarketSkillsHasMore] = mUseState(false);
  const [marketSkillsLoading, setMarketSkillsLoading] = mUseState(false);
  const [marketSkillsLoadingMore, setMarketSkillsLoadingMore] = mUseState(false);
  const [marketSkillsPrefetch, setMarketSkillsPrefetch] = mUseState(null);
  const [marketSkillsQueryKey, setMarketSkillsQueryKey] = mUseState(null);
  const [marketSkillsPreloaded, setMarketSkillsPreloaded] = mUseState(false);
  const [marketMcps, setMarketMcps] = mUseState([]);
  const [skillsSubTab, setSkillsSubTab] = mUseState("mine");   // "mine" | "marketplace"
  const [mcpsSubTab, setMcpsSubTab] = mUseState("mine");
  // PR-S.2: caller's admin flag — drives whether admin actions (Add MCP
  // catalog item, etc.) render. Default false until /v1/auth/me responds.
  const [isAdmin, setIsAdmin] = mUseState(false);
  // Bindings still come from Marketplace as the primary install surface, but
  // MCP detail needs to show whether a checked server is actually advertised
  // to future runs for this user.
  const [bindings, setBindings] = mUseState({ skill_ids: [], mcp_ids: [] });
  const [loaded, setLoaded] = mUseState(false);
  const [loadError, setLoadError] = mUseState(null);
  const [toast, setToast] = mUseState(null);

  const [selectedAgent, setSelectedAgent] = mUseState(null);
  const [selectedSkill, setSelectedSkill] = mUseState(null);
  const [selectedMCP, setSelectedMCP] = mUseState(null);
  const [query, setQuery] = mUseState("");
  const marketSkillsRequestSeq = mUseRef(0);
  const marketSkillsLoadingMoreRef = mUseRef(false);
  // PR-S.16:Add-MCP modal 状态(原 window.prompt 简陋路径升级)
  const [mcpAddModalOpen, setMcpAddModalOpen] = mUseState(false);

  // Initial load
  mUseEffect(() => {
    (async () => {
      try {
        const s = await api.listSkills({ scope: "mine" });
        window.SKILLS = s;
        const [a, m, lt, modelsResp, mm, me, tb, appItems] = await Promise.all([
          api.listAgents(),
          api.listMcpServers(),
          api.listLocalTools().catch(() => []),
          api.listModels().catch(() => null),  // 后端不可达时退回 bootstrap MODELS
          // PR-S.1: MCP marketplace remains small; skills marketplace is loaded lazily.
          api.listMarketplaceMcps().catch(() => []),
          // PR-S.2: 读 admin 标志(失败不挡)。
          api.authMe().catch(() => ({ is_admin: false })),
          api.getUserBindings().catch(() => ({ skill_ids: [], mcp_ids: [] })),
          api.listApps().catch(() => []),
        ]);
        setIsAdmin(!!me.is_admin);
        setBindings({
          skill_ids: Array.isArray(tb?.skill_ids) ? tb.skill_ids : [],
          mcp_ids: Array.isArray(tb?.mcp_ids) ? tb.mcp_ids : [],
        });
        window.INITIAL_AGENTS = a; window.SKILLS = s; window.MCPS = m; window.LOCAL_TOOLS = lt;
        // LLM 窗口权威来源是后端;覆盖 bootstrap MODELS。
        if (modelsResp && Array.isArray(modelsResp.items) && modelsResp.items.length) {
          window.MODELS = modelsResp.items;
        }
        if (modelsResp && Array.isArray(modelsResp.providers) && modelsResp.providers.length) {
          window.MODEL_PROVIDERS = modelsResp.providers;
        }
        setAgents(a); setSkills(s.map((sk) => ({ ...sk, files: loadSkillFiles(sk) }))); setMcps(m); setLocalTools(lt); setApps(appItems);
        setMarketMcps(mm);
        setSelectedAgent(a[0]?.id || null);
        setSelectedSkill(s[0]?.id || null);
        setSelectedMCP(m[0]?.id || null);
        setLoaded(true);
        preloadMarketplaceSkillsFirstPage();
      } catch (err) {
        setLoadError(err.message || String(err));
        setLoaded(true);
      }
    })();
  }, []);

  mUseEffect(() => { window.SKILLS = skills; }, [skills]);
  mUseEffect(() => { window.MCPS = mcps; }, [mcps]);
  mUseEffect(() => { window.LOCAL_TOOLS = localTools; }, [localTools]);

  // Keep Manage's App Tools view aligned with runtime tool injection. The
  // runtime scopes App tools by the current Agent, so refresh this list when
  // the selected Agent changes instead of relying only on user-owned Apps.
  mUseEffect(() => {
    if (!selectedAgent) return;
    api.listApps({ agentId: selectedAgent }).then((agentApps) => {
      setApps((previous) => {
        const merged = new Map(previous.map((item) => [item.app_id, item]));
        for (const item of agentApps || []) merged.set(item.app_id, item);
        return Array.from(merged.values());
      });
    }).catch(() => {});
  }, [selectedAgent]);
  mUseEffect(() => { window.INITIAL_AGENTS = agents; }, [agents]);

  mUseEffect(() => { if (agents.length && !agents.find((a) => a.id === selectedAgent)) setSelectedAgent(agents[0]?.id); }, [agents]);
  mUseEffect(() => { if (skills.length && !skills.find((s) => s.id === selectedSkill)) setSelectedSkill(skills[0]?.id); }, [skills]);
  mUseEffect(() => { if (mcps.length && !mcps.find((m) => m.id === selectedMCP)) setSelectedMCP(mcps[0]?.id); }, [mcps]);

  async function preloadMarketplaceSkillsFirstPage() {
    try {
      await loadMarketplaceSkillsFirstPage();
      setMarketSkillsPreloaded(true);
    } catch (_) {
      setMarketSkillsPreloaded(false);
    }
  }

  // Hydrate the file tree for a personal skill on first selection. The /v1/skills
  // listing doesn't include draft contents — we fetch them lazily so list rendering
  // stays cheap and editing has real bytes to round-trip.
  mUseEffect(() => {
    const skill = skills.find((x) => x.id === selectedSkill);
    if (!skill || !skill.editable) return;
    if (skill._draftLoaded) return;
    api.getPersonalSkillDraft(skill.skill_id).then((draft) => {
      setSkillTestStates((prev) => ({ ...prev, [skill.id]: draft.test_state || { status: "idle" } }));
      setSkills((prev) => prev.map((s) => s.id !== skill.id ? s : {
        ...s,
        _draftLoaded: true,
        files: [
          { path: "SKILL.md", required: true, content: draft.skill_md || "" },
          ...Object.entries(draft.other_files || {}).map(([path, content]) => ({ path, content })),
        ],
      }));
    }).catch((err) => {
      // Mark loaded anyway to avoid retry loop; surface a toast.
      setSkills((prev) => prev.map((s) => s.id !== skill.id ? s : { ...s, _draftLoaded: true }));
      showToast("Load draft failed: " + (err.message || err));
    });
  }, [selectedSkill, skills]);

  mUseEffect(() => { setQuery(""); }, [section]);
  mUseEffect(() => {
    if (
      section === "skills"
      && skillsSubTab === "marketplace"
      && (!marketSkillsPreloaded || marketSkillsQueryKey !== marketSkillsKey())
    ) {
      loadMarketplaceSkillsFirstPage();
    }
  }, [section, skillsSubTab, query, marketSkillsPreloaded, marketSkillsQueryKey]);

  const showToast = (msg) => {
    setToast(msg);
    setTimeout(() => setToast(null), 2200);
  };

  const marketSkillsKey = () => query.trim();

  async function fetchMarketplaceSkillsPage(cursor, key = marketSkillsKey()) {
    return await api.listMarketplaceSkillsPage({
      limit: MANAGE_MARKET_SKILLS_LIMIT,
      cursor,
      q: key,
      sort: "name",
    });
  }

  async function loadAgentSkillPage({
    cursor = null,
    limit = 50,
    q = "",
    sort = "name",
  } = {}) {
    return await api.listMarketplaceSkillsPage({ cursor, limit, q, sort });
  }

  async function prefetchMarketplaceSkills(cursor, key, seq) {
    if (!cursor) return;
    try {
      const resp = await fetchMarketplaceSkillsPage(cursor, key);
      if (seq !== marketSkillsRequestSeq.current) return;
      setMarketSkillsPrefetch({ key, cursor, resp });
    } catch {
      // Prefetch is opportunistic; the explicit load-more path will retry.
    }
  }

  async function loadMarketplaceSkillsFirstPage() {
    const key = marketSkillsKey();
    const seq = ++marketSkillsRequestSeq.current;
    setMarketSkillsLoading(true);
    setMarketSkillsLoadingMore(false);
    marketSkillsLoadingMoreRef.current = false;
    setMarketSkillsPrefetch(null);
    setMarketSkillsQueryKey(key);
    setMarketSkills([]);
    setMarketSkillsTotal(0);
    setMarketSkillsCursor(null);
    setMarketSkillsHasMore(false);
    try {
      const resp = await fetchMarketplaceSkillsPage(null, key);
      if (seq !== marketSkillsRequestSeq.current) return;
      const items = Array.isArray(resp?.items) ? resp.items : [];
      setMarketSkills(items);
      if (key === "") setMarketSkillsPreloaded(true);
      setMarketSkillsTotal(Number.isFinite(Number(resp?.total_count)) ? Number(resp.total_count) : items.length);
      setMarketSkillsCursor(resp?.next_cursor || null);
      setMarketSkillsHasMore(!!resp?.has_more);
      if (resp?.has_more && resp?.next_cursor) {
        prefetchMarketplaceSkills(resp.next_cursor, key, seq);
      }
    } catch (err) {
      if (seq === marketSkillsRequestSeq.current) {
        setMarketSkills([]);
        if (key === "") setMarketSkillsPreloaded(false);
        setMarketSkillsTotal(0);
        setMarketSkillsCursor(null);
        setMarketSkillsHasMore(false);
        showToast("Load marketplace skills failed: " + (err.message || err));
      }
    } finally {
      if (seq === marketSkillsRequestSeq.current) setMarketSkillsLoading(false);
    }
  }

  async function loadMoreMarketplaceSkills() {
    if (!marketSkillsHasMore || !marketSkillsCursor) return;
    if (marketSkillsLoading || marketSkillsLoadingMoreRef.current) return;
    const key = marketSkillsQueryKey ?? marketSkillsKey();
    const cursor = marketSkillsCursor;
    const seq = ++marketSkillsRequestSeq.current;
    marketSkillsLoadingMoreRef.current = true;
    setMarketSkillsLoadingMore(true);
    try {
      let resp = null;
      if (marketSkillsPrefetch?.key === key && marketSkillsPrefetch?.cursor === cursor) {
        resp = marketSkillsPrefetch.resp;
      } else {
        resp = await fetchMarketplaceSkillsPage(cursor, key);
      }
      if (seq !== marketSkillsRequestSeq.current) return;
      const items = Array.isArray(resp?.items) ? resp.items : [];
      setMarketSkills((prev) => [...prev, ...items]);
      setMarketSkillsTotal(Number.isFinite(Number(resp?.total_count)) ? Number(resp.total_count) : marketSkillsTotal);
      setMarketSkillsCursor(resp?.next_cursor || null);
      setMarketSkillsHasMore(!!resp?.has_more);
      setMarketSkillsPrefetch(null);
      if (resp?.has_more && resp?.next_cursor) {
        prefetchMarketplaceSkills(resp.next_cursor, key, seq);
      }
    } catch (err) {
      showToast("Load more marketplace skills failed: " + (err.message || err));
    } finally {
      if (seq === marketSkillsRequestSeq.current) {
        marketSkillsLoadingMoreRef.current = false;
        setMarketSkillsLoadingMore(false);
      }
    }
  }

  function onListScroll(e) {
    if (section !== "skills" || skillsSubTab !== "marketplace") return;
    const el = e.currentTarget;
    if (el.scrollHeight - el.scrollTop - el.clientHeight < 160) {
      loadMoreMarketplaceSkills();
    }
  }

  const refreshSkillState = async (preferredSkillId = selectedSkill) => {
    const [fresh, tb] = await Promise.all([
      api.listSkills({ scope: "mine" }),
      api.getUserBindings().catch(() => ({ skill_ids: [], mcp_ids: [] })),
    ]);
    const enriched = fresh.map((s) => ({ ...s, files: loadSkillFiles(s) }));
    setSkills(enriched);
    setBindings({
      skill_ids: Array.isArray(tb?.skill_ids) ? tb.skill_ids : [],
      mcp_ids: Array.isArray(tb?.mcp_ids) ? tb.mcp_ids : [],
    });
    const nextSelected = preferredSkillId && enriched.some((s) => s.id === preferredSkillId)
      ? preferredSkillId
      : enriched[0]?.id || null;
    setSelectedSkill(nextSelected);
    if (section === "skills" && skillsSubTab === "marketplace") {
      await loadMarketplaceSkillsFirstPage();
    }
    return enriched;
  };

  // Per-entity save state for the editor's SaveBar (PR 18). Agents still
  // autosave on backend-backed edits. Skills are intentionally local-dirty
  // until the user clicks Save, so editing SKILL.md/name does not PATCH on
  // every keystroke.
  const [agentSaveStates, setAgentSaveStates] = React.useState({}); // {id: {status, lastSavedAt?, error?}}
  const [skillSaveStates, setSkillSaveStates] = React.useState({});
  const [skillTestStates, setSkillTestStates] = React.useState({});
  const [skillChangedLines, setSkillChangedLines] = React.useState({});
  const [mcpCheckStates, setMcpCheckStates] = React.useState({});
  const [mcpBindingStates, setMcpBindingStates] = React.useState({});

  const setAgentSaveState = (id, patch) => setAgentSaveStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } }));
  const setSkillSaveState = (id, patch) => setSkillSaveStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } }));
  const setMcpCheckState = (id, patch) => setMcpCheckStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } }));
  const setMcpBindingState = (id, patch) => setMcpBindingStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } }));

  // ---------- agents ----------
  // Optimistic local + backend PATCH. Model fields persist in Agent.model_config;
  // only presentation fields(color / initials / description) stay local.
  // MCP simplification(2026-05-26):`enabledMCPs` 现在也走 backend
  // (`agent.enabled_mcp_servers`),不再 localStorage-only。
  const updateAgent = async (id, patch) => {
    let optimistic;
    setAgents((prev) => prev.map((a) => {
      if (a.id !== id) return a;
      optimistic = { ...a, ...patch };
      api.persistAgentUi(optimistic);
      return optimistic;
    }));
    const touchesBackend = "name" in patch || "system" in patch || "promptVariables" in patch || "maxAgentRounds" in patch || "enabledSkills" in patch || "enabledMCPs" in patch || "enabledLocalTools" in patch || MODEL_CONFIG_UI_KEYS.some((key) => key in patch);
    if (!touchesBackend || !optimistic) {
      // UI-only fields land in localStorage synchronously — surface as saved.
      if (optimistic) setAgentSaveState(id, { status: "saved", lastSavedAt: Date.now(), error: null });
      return;
    }
    setAgentSaveState(id, { status: "saving" });
    try {
      const updated = await api.updateAgent(id, {
        name: optimistic.name,
        system_prompt: optimistic.system || "",
        prompt_variables: optimistic.promptVariables || [],
        max_agent_rounds: optimistic.maxAgentRounds || 0,
        skill_ids: optimistic.enabledSkills || [],
        enabled_mcp_servers: optimistic.enabledMCPs || [],
        local_tool_names: optimistic.enabledLocalTools || optimistic.localToolNames || [],
        model_config: api.modelConfigFromAgentUi(optimistic),
      });
      setAgents((prev) => prev.map((a) => a.id === id ? { ...a, ...updated } : a));
      setAgentSaveState(id, { status: "saved", lastSavedAt: Date.now(), error: null });
    } catch (err) {
      const msg = err.message || String(err);
      setAgentSaveState(id, { status: "error", error: msg });
      showToast("Save failed: " + msg);
    }
  };

  // Force a re-save of the entity's *current* local state — what the user
  // gets when they click the bottom Save button. Because we already auto-
  // save on each keystroke this is usually a no-op round trip, but the
  // explicit feedback ("Saved just now") closes the bug.md P1 "用户无法
  // 判断修改是否已经保存".
  const forceSaveAgent = async (id) => {
    const a = agents.find((x) => x.id === id);
    if (!a) return;
    await updateAgent(id, {
      name: a.name, system: a.system,
      promptVariables: a.promptVariables || [],
      maxAgentRounds: a.maxAgentRounds || null,
      enabledSkills: a.enabledSkills,
      enabledMCPs: a.enabledMCPs,
      enabledLocalTools: a.enabledLocalTools || a.localToolNames || [],
      modelProvider: a.modelProvider,
      model: a.model,
      temperature: a.temperature,
      top_p: a.top_p,
      max_tokens: a.max_tokens,
      reasoning_enabled: a.reasoning_enabled,
      reasoning_visible: a.reasoning_visible,
    });
  };

  const installSkillTemplateForAgent = async (agentId, templateId, version) => {
    try {
      const installed = await api.installSkillForAgent(agentId, templateId, version);
      const instanceId = installed.skill_id || installed.instance_id;
      if (!instanceId) throw new Error("install response missing skill_id");
      await updateAgent(agentId, {
        enabledSkills: [
          ...new Set([
            ...((agents.find((a) => a.id === agentId)?.enabledSkills) || []),
            instanceId,
          ]),
        ],
      });
      const freshAgents = await api.listAgents();
      setAgents(freshAgents);
      showToast(`Attached ${installed.name || templateId}@${installed.version || version}`);
    } catch (err) {
      showToast("Attach skill failed: " + (err.message || err));
    }
  };

  const addAgent = async () => {
    const n = agents.length + 1;
    try {
      const created = await api.createAgent({
        name: `Untitled agent ${n}`,
        system_prompt: "You are a helpful assistant.",
        prompt_variables: [],
        max_agent_rounds: null,
        skill_ids: [],
        enabled_mcp_servers: [],
        local_tool_names: [],
      });
      api.persistAgentUi(created);
      setAgents((p) => [...p, created]);
      setSelectedAgent(created.id);
    } catch (err) {
      alert("Create failed: " + (err.message || err));
    }
  };

  // PR pg-new-agent-to-manage: when Playground sent us here via "+ New
  // agent" the URL is /manage?new=agent. Once initial data is loaded,
  // auto-trigger addAgent so the user lands on a fresh editable agent
  // (rather than seeing the existing list and having to click again).
  // Clear ?new=agent so a refresh doesn't re-fire.
  mUseEffect(() => {
    if (!loaded) return;
    try {
      const params = new URLSearchParams(window.location.search);
      if (params.get("new") !== "agent") return;
      params.delete("new");
      const newSearch = params.toString();
      window.history.replaceState(
        {}, "",
        window.location.pathname + (newSearch ? "?" + newSearch : ""),
      );
      addAgent();
    } catch (_) { /* URLSearchParams unsupported — skip silently */ }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded]);

  const deleteAgent = async (agent) => {
    if (agent.id === "agent_default") {
      showToast("The default agent can't be deleted.");
      return;
    }
    if (!confirm(`Delete agent "${agent.name}"?`)) return;
    try {
      await api.deleteAgent(agent.id);
      setAgents((prev) => prev.filter((a) => a.id !== agent.id));
      showToast(`Deleted ${agent.name}`);
    } catch (err) {
      showToast("Delete failed: " + (err.message || err));
    }
  };

  // ---------- skills ----------
  // Local-only draft editing. Persisting to the backend happens in
  // saveSkillDraft(), bound to the explicit Save button.
  const updateSkill = async (id, patch) => {
    let nextLocal = null;
    setSkills((prev) => prev.map((s) => {
      if (s.id !== id) return s;
      nextLocal = { ...s, ...patch };
      return nextLocal;
    }));
    if (!nextLocal?.editable) {
      if (nextLocal) setSkillSaveState(id, { status: "readonly" });
      return;
    }
    setSkillSaveState(id, { status: "dirty", error: null });
  };

  const saveSkillDraft = async (skill) => {
    if (!skill?.editable) {
      if (skill) setSkillSaveState(skill.id, { status: "readonly" });
      return false;
    }
    const skillMd = skill.files?.find((f) => f.path === "SKILL.md")?.content;
    if (!skillMd) {
      setSkillSaveState(skill.id, { status: "error", error: "SKILL.md missing — can't save" });
      return false;
    }
    const otherFiles = {};
    for (const f of skill.files || []) {
      if (f.path === "SKILL.md") continue;
      otherFiles[f.path] = f.content || "";
    }
    setSkillSaveState(skill.id, { status: "saving" });
    try {
      await api.updatePersonalSkillDraft(skill.skill_id, skillMd, otherFiles);
      setSkillSaveState(skill.id, { status: "saved", lastSavedAt: Date.now(), error: null });
      return true;
    } catch (err) {
      const msg = err.message || String(err);
      setSkillSaveState(skill.id, { status: "error", error: msg });
      showToast("Draft save failed: " + msg);
      return false;
    }
  };

  const forceSaveSkill = async (id) => {
    const s = skills.find((x) => x.id === id);
    if (!s) return;
    await saveSkillDraft(s);
  };

  const runSkillTest = async (skill) => {
    if (!skill?.editable) return;
    if (skillSaveStates[skill.id]?.status === "dirty") {
      showToast("Save the draft before testing.");
      return;
    }
    try {
      const started = await api.startPersonalSkillTest(skill.skill_id);
      setSkillTestStates((prev) => ({ ...prev, [skill.id]: started }));
      const poll = async () => {
        const current = await api.getPersonalSkillTest(skill.skill_id);
        setSkillTestStates((prev) => ({ ...prev, [skill.id]: current }));
        if (current.status === "running") window.setTimeout(poll, 1200);
        else showToast(current.status === "passed" ? "Skill Test passed. You can publish this draft." : "Skill Test finished with findings.");
      };
      window.setTimeout(poll, 600);
    } catch (err) {
      showToast("Skill Test failed to start: " + (err.message || err));
    }
  };

  const applySkillTestProposal = async (skill, proposalId) => {
    setSkillSaveState(skill.id, { status: "saving", error: null });
    setSkillTestStates((prev) => ({
      ...prev,
      [skill.id]: { ...(prev[skill.id] || {}), applying_proposal_id: proposalId },
    }));
    try {
      await api.applyPersonalSkillTestProposal(skill.skill_id, proposalId);
      const draft = await api.getPersonalSkillDraft(skill.skill_id);
      const nextFiles = [{ path: "SKILL.md", required: true, content: draft.skill_md || "" }, ...Object.entries(draft.other_files || {}).map(([path, content]) => ({ path, content }))];
      const beforeByPath = new Map((skill.files || []).map((file) => [file.path, file.content || ""]));
      const changed = {};
      for (const file of nextFiles) {
        const before = beforeByPath.get(file.path) || "";
        if (before !== (file.content || "")) changed[file.path] = changedLineRange(before, file.content || "");
      }
      setSkills((prev) => prev.map((s) => s.id !== skill.id ? s : {
        ...s, _draftLoaded: true,
        files: nextFiles,
      }));
      setSkillChangedLines((prev) => ({ ...prev, [skill.id]: changed }));
      setSkillTestStates((prev) => {
        const prior = prev[skill.id] || {};
        const applied = (prior.quality?.proposals || []).find((item) => item.id === proposalId);
        return {
          ...prev,
          // Saving invalidates the published Test pass, but the completed
          // report remains visible so the user can confirm what changed.
          [skill.id]: {
            ...prior,
            ...(draft.test_state || { status: "stale" }),
            status: "stale",
            applying_proposal_id: null,
            applied_proposal: applied ? { path: applied.path, reason: applied.reason } : { path: "Skill draft" },
            quality: { ...(prior.quality || {}), status: "stale", proposals: [] },
          },
        };
      });
      setSkillSaveState(skill.id, { status: "saved", lastSavedAt: Date.now(), error: null });
      showToast("Applied suggestion. Run Test again before publishing.");
    } catch (err) {
      setSkillTestStates((prev) => ({
        ...prev,
        [skill.id]: { ...(prev[skill.id] || {}), applying_proposal_id: null },
      }));
      setSkillSaveState(skill.id, { status: "error", error: err.message || String(err) });
      showToast("Apply suggestion failed: " + (err.message || err));
    }
  };

  const deleteSkill = async (skill) => {
    if (!skill?.editable) {
      showToast("Public skills can't be deleted.");
      return;
    }
    if (!confirm(`Delete personal skill "${skill.name}"? This removes it for all your agents.`)) return;
    try {
      await api.deletePersonalSkill(skill.skill_id);
      setSkills((prev) => prev.filter((s) => s.id !== skill.id));
      // Also strip from any agent that referenced it.
      setAgents((prev) => prev.map((a) => ({
        ...a,
        enabledSkills: (a.enabledSkills || []).filter((id) => id !== skill.skill_id),
      })));
      showToast(`Deleted ${skill.name}`);
    } catch (err) {
      showToast("Delete failed: " + (err.message || err));
    }
  };

  const addSkill = async () => {
    const n = skills.length + 1;
    const name = `new_skill_${n}`;
    const desc = "A new skill.";
    const skill_md = SKILL_TEMPLATE_MD.replace(/{name}/g, name).replace(/{desc}/g, desc);
    try {
      const created = await api.createPersonalSkill(name, skill_md);
      const enriched = await refreshSkillState(created.skill_id);
      setSkills(enriched.map((s) => (
        s.skill_id === created.skill_id
          ? { ...s, files: [{ path: "SKILL.md", required: true, content: skill_md }] }
          : s
      )));
      showToast(`Created draft "${name}". Publish a version to install it.`);
    } catch (err) {
      alert("Create skill failed: " + (err.message || err));
    }
  };

  // PR-F.1: import-from-URL skill creation. State drives the inline modal
  // (urlImportOpen). Submit calls the same backend pipeline as upload but
  // the bytes come from a URL we fetch server-side. Two URL shapes accepted:
  //   - direct .zip URL (any https endpoint serving a ZIP)
  //   - GitHub /tree/ URL (we extract the subdir + repackage server-side)
  const [urlImportOpen, setUrlImportOpen] = mUseState(false);
  const [urlImportValue, setUrlImportValue] = mUseState("");
  const [urlImportBusy, setUrlImportBusy] = mUseState(false);
  const [urlImportError, setUrlImportError] = mUseState(null);
  const submitUrlImport = async () => {
    const url = (urlImportValue || "").trim();
    if (!url) {
      setUrlImportError("URL is required");
      return;
    }
    setUrlImportBusy(true);
    setUrlImportError(null);
    try {
      const created = await api.importPersonalSkillFromUrl(url);
      await refreshSkillState(created.skill_id);
      // Close + clear on success.
      setUrlImportOpen(false);
      setUrlImportValue("");
      // PR-F.1c: if the server skipped binary README assets, surface the
      // count in the toast so the user isn't surprised when their
      // SKILL.md preview is "missing" the PNGs they saw on GitHub.
      const skipped = created.skipped_binary_files;
      const skippedSuffix = skipped && skipped.count > 0
        ? ` (skipped ${skipped.count} binary file${skipped.count > 1 ? "s" : ""})`
        : "";
      showToast(`Imported "${created.name}"${skippedSuffix}. Publish to install.`);
    } catch (err) {
      setUrlImportError(err.message || String(err));
    } finally {
      setUrlImportBusy(false);
    }
  };

  // Upload-ZIP variant of skill creation. Hidden <input type="file"> is
  // anchored in the topbar render below; clicking the "Upload ZIP" button
  // triggers it via uploadZipInputRef. Backend (POST /v1/personal-skills/upload)
  // does the ZIP validation; we just refresh the list on success.
  const uploadZipInputRef = mUseRef(null);
  const onUploadZipPicked = async (e) => {
    const file = e.target.files?.[0];
    e.target.value = "";  // allow re-uploading the same filename
    if (!file) return;
    if (!file.name.toLowerCase().endsWith(".zip")) {
      alert("Please pick a .zip file. Got: " + file.name);
      return;
    }
    try {
      const created = await api.uploadPersonalSkillZip(file);
      await refreshSkillState(created.skill_id);
      const skipped = created.skipped_binary_files;
      const skippedSuffix = skipped && skipped.count > 0
        ? ` (skipped ${skipped.count} binary file${skipped.count > 1 ? "s" : ""})`
        : "";
      showToast(`Imported "${created.name}" from ${file.name}${skippedSuffix}. Publish a version to make it runnable.`);
    } catch (err) {
      alert("Upload failed: " + (err.message || err));
    }
  };

  const publishSkillVersion = async (skill) => {
    if (!skill?.editable) return;
    if (skillSaveStates[skill.id]?.status === "dirty") {
      showToast("Save the draft before publishing.");
      return;
    }
    const versionStr = (skill.version === "draft" ? "0.1.0" : (skill.version || "")).trim();
    if (!versionStr) {
      showToast("Enter a version before publishing.");
      return;
    }
    try {
      await api.publishPersonalSkill(skill.skill_id, versionStr, true);
      await refreshSkillState(skill.skill_id);
      showToast(`Published private version ${skill.name}@${versionStr}`);
    } catch (err) {
      alert("Publish failed: " + (err.message || err));
    }
  };

  const publishSkillToMarketplace = async (skill) => {
    if (!skill?.editable || !skill.latest_version) return;
    if (skillSaveStates[skill.id]?.status === "dirty") {
      showToast("Save the draft before publishing to Marketplace.");
      return;
    }
    if (!confirm(`Publish ${skill.name}@${skill.latest_version} to Marketplace?`)) return;
    try {
      await api.publishPersonalSkillToMarketplace(skill.skill_id);
      await refreshSkillState(skill.skill_id);
      showToast(`${skill.name} is now available in Marketplace`);
    } catch (err) {
      alert("Marketplace publish failed: " + (err.message || err));
    }
  };

  const withdrawSkillFromMarketplace = async (skill) => {
    if (!skill?.editable || skill.marketplace_visibility !== "public") return;
    if (!confirm(`Remove ${skill.name} from Marketplace? Existing installations keep their pinned version.`)) return;
    try {
      await api.withdrawPersonalSkillFromMarketplace(skill.skill_id);
      await refreshSkillState(skill.skill_id);
      showToast(`${skill.name} was removed from Marketplace`);
    } catch (err) {
      alert("Marketplace withdrawal failed: " + (err.message || err));
    }
  };

  // ---------- mcps ----------
  // MCP catalog CRUD (admin) — MCP store PR (2026-05-26):.baizhi-data SQLite
  // 持久化。debounced auto-save 不在本 PR;现在 patch 立即 PATCH 后端。
  const updateMCP = async (id, patch) => {
    setMcps((prev) => prev.map((m) => (m.id !== id ? m : { ...m, ...patch })));
    // 真改字段(name / desc / url / headers / args 等)才触发 backend PATCH;
    // 纯 UI shape(filter 之类的)走前面那行就停。
    const backendKeys = [
      "name", "description", "transport",
      "url", "http_url", "headers_template",
      "auth_env_key", "command", "args",
    ];
    const touchesBackend = backendKeys.some((k) => k in patch);
    if (!touchesBackend) return;
    try {
      const updated = await api.updateMcpServer(id, patch);
      setMcps((prev) => prev.map((m) => (m.id !== id ? m : { ...m, ...updated })));
      checkMCP(id, { quiet: true });
    } catch (err) {
      showToast("MCP save failed: " + (err.message || err));
    }
  };

  const checkMCP = async (id, { quiet = false } = {}) => {
    setMcpCheckState(id, { status: "checking", error: null });
    try {
      const result = await api.checkMcpServer(id);
      setMcpCheckState(id, { status: "checked", result, error: null });
      setMcps((prev) => prev.map((m) => (
        m.id !== id ? m : {
          ...m,
          available: !!result.available,
          status: result.available ? "available" : "unavailable",
          configStatus: result.config,
          checkStatus: result,
        }
      )));
      if (!quiet) {
        const n = result.tools?.count || 0;
        showToast(result.mcp?.ok ? `MCP connected · ${n} tool${n === 1 ? "" : "s"}` : `MCP check failed: ${result.mcp?.error || "not available"}`);
      }
    } catch (err) {
      const msg = err.message || String(err);
      setMcpCheckState(id, { status: "error", error: msg });
      if (!quiet) showToast("MCP check failed: " + msg);
    }
  };

  const setMcpEnabledForUser = async (id, enabled) => {
    setMcpBindingState(id, { status: "saving", error: null });
    const curSkills = Array.isArray(bindings.skill_ids) ? bindings.skill_ids : [];
    const curMcps = Array.isArray(bindings.mcp_ids) ? bindings.mcp_ids : [];
    const nextMcps = enabled
      ? [...new Set([...curMcps, id])]
      : curMcps.filter((mid) => mid !== id);
    try {
      const updated = await api.updateUserBindings({ skill_ids: curSkills, mcp_ids: nextMcps });
      setBindings({
        skill_ids: Array.isArray(updated?.skill_ids) ? updated.skill_ids : curSkills,
        mcp_ids: Array.isArray(updated?.mcp_ids) ? updated.mcp_ids : nextMcps,
      });
      setMcpBindingState(id, { status: "saved", error: null });
      showToast(enabled
        ? `Enabled ${id} for future runs`
        : `Disabled ${id} for future runs`);
    } catch (err) {
      const msg = err.message || String(err);
      setMcpBindingState(id, { status: "error", error: msg });
      showToast("MCP binding failed: " + msg);
    }
  };

  // PR-S.16:opens McpAddModal(单击就开),admin 一次填全字段,
  // confirmAddMCP 真发 POST。失败 modal 内部接住,不 close 让 admin 重试。
  const addMCP = () => setMcpAddModalOpen(true);

  const confirmAddMCP = async (payload) => {
    // 让 modal 等 await(失败时它 catch 自处理 err display)
    const created = await api.createMcpServer(payload);
    setMcps((p) => [...p, created]);
    setSelectedMCP(created.id);
    setMcpAddModalOpen(false);
  };

  const removeMCP = async (id) => {
    if (!window.confirm(`Delete MCP server "${id}"? Agents that referenced it will silently drop the tool.`)) return;
    try {
      await api.deleteMcpServer(id);
      setMcps((prev) => prev.filter((m) => m.id !== id));
      if (selectedMCP === id) {
        const remain = mcps.filter((m) => m.id !== id);
        setSelectedMCP(remain[0]?.id || null);
      }
    } catch (err) {
      showToast("Delete MCP failed: " + (err.message || err));
    }
  };

  const notSupported = (action) => () => {
    showToast(`${action} not yet supported on the backend.`);
  };

  // ---------- filtered lists ----------
  const filt = (xs, fields) => xs.filter((x) => {
    if (!query.trim()) return true;
    const q = query.toLowerCase();
    return fields.some((k) => (x[k] || "").toString().toLowerCase().includes(q));
  });
  const fAgents = filt(agents, ["name", "desc", "id"]);
  const fSkills = filt(skills, ["name", "description", "id"]);
  const fMCPs   = filt(mcps,   ["name", "description", "id"]);
  // PR-S.1: marketplace 视图过滤(同样 query 搜索)
  const fMarketSkills = marketSkills;
  const fMarketMcps   = filt(marketMcps,   ["name", "description", "server_id"]);
  const enabledUserMcpIds = new Set(bindings.mcp_ids || []);

  let listTitle = "Agents", listCount = agents.length;
  let listItems = null, addLabel = "New agent", onAdd = addAgent;
  if (section === "agents") {
    listTitle = "Agents"; listCount = agents.length;
    listItems = fAgents.map((a) => (
      <div key={a.id}
        className={"list-item" + (a.id === selectedAgent ? " active" : "")}
        onClick={() => setSelectedAgent(a.id)}
      >
        <div className="av" style={{ background: a.color }}>{a.initials}</div>
        <div className="meta">
          <div className="ttl">{a.name}</div>
          <div className="sub">
            {a.desc || <span className="dim">No description</span>}
          </div>
        </div>
      </div>
    ));
  } else if (section === "skills") {
    listTitle = "Skills"; listCount = skillsSubTab === "marketplace"
      ? (marketSkillsQueryKey === null ? marketSkills.length : marketSkillsTotal)
      : skills.length;
    addLabel = "New skill"; onAdd = addSkill;
    if (skillsSubTab === "marketplace") {
      const marketRows = fMarketSkills.map((s) => {
        const already = !!s.installed;
        return (
          <div key={s.skill_id} className="list-item" style={{ cursor: "default" }}>
            <div className="av mono" style={{ background: s.color }}>{s.initials}</div>
            <div className="meta">
              <div className="ttl" style={{ fontFamily: "var(--font-mono)", fontSize: 12.5 }}>
                {s.name}
                {s.latest_version && <span className="pill">v{s.latest_version}</span>}
                <span className="pill" style={{ background: "var(--accent-bg)", color: "var(--accent)", marginLeft: 4 }}>public</span>
              </div>
              <div className="sub">{s.description}</div>
            </div>
            <button
              className={"btn sm" + (already ? "" : " primary")}
              disabled={already || !s.latest_version}
              style={{ marginLeft: "auto" }}
              onClick={async (e) => {
                e.stopPropagation();
                try {
                  const installed = await api.installSkill(s.skill_id, s.latest_version);
                  await refreshSkillState(installed.skill_id);
                  showToast(`Installed ${s.name}@${s.latest_version}`);
                } catch (err) {
                  showToast("Install failed: " + (err.message || err));
                }
              }}
            >
              {already ? "Installed" : "Install"}
            </button>
          </div>
        );
      });
      const footer = (marketSkillsHasMore || marketSkillsLoadingMore) ? (
        <div key="__market_skill_load_more" style={{ padding: "12px 10px 18px", display: "flex", justifyContent: "center" }}>
          <button
            className="btn sm"
            disabled={marketSkillsLoadingMore}
            onClick={loadMoreMarketplaceSkills}
          >
            {marketSkillsLoadingMore ? "Loading more..." : "Load more"}
          </button>
        </div>
      ) : null;
      listItems = footer ? [...marketRows, footer] : marketRows;
    } else {
      listItems = fSkills.map((s) => (
        <div key={s.id}
          className={"list-item" + (s.id === selectedSkill ? " active" : "")}
          onClick={() => setSelectedSkill(s.id)}
        >
          <div className="av mono" style={{ background: s.color }}>{s.initials}</div>
          <div className="meta">
            <div className="ttl" style={{ fontFamily: "var(--font-mono)", fontSize: 12.5 }}>
              {s.name}
              <span className="pill">v{s.version}</span>
              {skillTestBadge(skillTestStates[s.id] || s.test_state)}
              {s.visibility === "public" && <span className="pill" style={{ background: "var(--accent-bg)", color: "var(--accent)", marginLeft: 4 }}>public</span>}
            </div>
            <div className="sub">{s.description}</div>
          </div>
          {s.editable && (
            <button
              className="row-delete"
              onClick={(e) => { e.stopPropagation(); deleteSkill(s); }}
              title={`Delete ${s.name}`}
              style={{
                opacity: 0, marginLeft: "auto",
                border: "none", background: "transparent",
                color: "var(--text-muted)", cursor: "pointer",
                padding: "4px 6px", borderRadius: 4,
                transition: "opacity 120ms ease",
              }}
            >
              <Icon name="trash" size={12} />
            </button>
          )}
        </div>
      ));
    }
  } else if (section === "mcps") {
    listTitle = "MCP Servers"; listCount = mcpsSubTab === "marketplace" ? marketMcps.length : mcps.length;
    // PR-S.2: 加 MCP 到 catalog 是 admin 操作。普通用户隐藏 Add 钮。
    // 自己加 personal MCP(PR-S.4)走完全不同的路径,不再这里挡。
    addLabel = "Add MCP"; onAdd = isAdmin ? addMCP : null;
    const renderMcps = mcpsSubTab === "marketplace" ? fMarketMcps : fMCPs;
    listItems = renderMcps.map((m) => (
      <div key={m.id}
        className={"list-item" + (m.id === selectedMCP ? " active" : "")}
        onClick={() => setSelectedMCP(m.id)}
      >
        <div className="av" style={{ background: m.color }}>{m.initials}</div>
        <div className="meta">
          <div className="ttl">
            <span style={{ fontFamily: "var(--font-mono)" }}>{m.name}</span>
            {/* MCP simplification(2026-05-26):没有 tenant-enabled 概念了。
                绿点 = `available`(env / config 就绪);红点 = unavailable。
                per-agent 开关在 Agents 页 MCP Servers 子区。 */}
            <span
              className={"status-dot " + (m.available ? "ok" : "warn")}
              title={m.available ? "Available — env / config OK" : "Unavailable — auth env missing"}
            />
          </div>
          <div className="sub">{m.description || <span className="dim">No description</span>}</div>
        </div>
        {m.configStatus && (
          <span className="pill" title={(m.configStatus.issues || []).join("; ") || "Configured"}>
            {m.configStatus.configured ? "configured" : "config"}
          </span>
        )}
        {m.checkStatus?.tools && (
          <span className="pill" title={m.checkStatus.tools.error || "Tools available"}>
            tools {m.checkStatus.tools.count || 0}
          </span>
        )}
        {enabledUserMcpIds.has(m.id) && (
          <span className="pill" title="Enabled for this user's future runs">
            enabled
          </span>
        )}
        <span className="pill">{m.transport}</span>
      </div>
    ));
  }
  // PR-S.1d (manage-drop-tenant):bindings section 整段移除(list + detail)。
  // Tenant rail entry 也去掉;user bindings 只在 Marketplace 里管。

  let detail = null;
  if (!loaded) {
    detail = <div className="detail"><div className="detail-empty"><div><p className="muted">Loading…</p></div></div></div>;
  } else if (loadError) {
    detail = <div className="detail"><div className="detail-empty">
      <div>
        <h3 style={{ color: "#b91c1c" }}>Failed to load manager</h3>
        <pre style={{ background: "#f7f7f8", padding: 12, borderRadius: 6, overflow: "auto", textAlign: "left" }}>{loadError}</pre>
      </div>
    </div></div>;
  } else if (section === "agents") {
    const a = agents.find((x) => x.id === selectedAgent);
    detail = a ? (
      <AgentEditor
        agent={a}
        skills={skills}
        mcps={mcps}
        localTools={localTools}
        apps={apps}
        loadSkillPage={loadAgentSkillPage}
        onUpdate={(p) => updateAgent(a.id, p)}
        onInstallSkillTemplate={(templateId, version) => installSkillTemplateForAgent(a.id, templateId, version)}
        onDelete={() => deleteAgent(a)}
        onDuplicate={notSupported("Duplicate agent")}
        saveState={agentSaveStates[a.id]}
        onSave={() => forceSaveAgent(a.id)}
      />
    ) : <EmptyDetail label="agents" onAdd={addAgent} />;
  } else if (section === "skills") {
    const s = skills.find((x) => x.id === selectedSkill);
    detail = s ? (
      <SkillEditor
        skill={s}
        onUpdate={(p) => updateSkill(s.id, p)}
        onDelete={() => deleteSkill(s)}
        onDuplicate={notSupported("Duplicate skill")}
        onPublishVersion={() => publishSkillVersion(s)}
        onPublishToMarketplace={() => publishSkillToMarketplace(s)}
        onWithdrawFromMarketplace={() => withdrawSkillFromMarketplace(s)}
        saveState={s.editable ? skillSaveStates[s.id] : { status: "readonly" }}
        onSave={() => forceSaveSkill(s.id)}
        testState={skillTestStates[s.id] || s.test_state || { status: "idle" }}
        onTest={() => runSkillTest(s)}
        onApplyTestProposal={(proposalId) => applySkillTestProposal(s, proposalId)}
        changedLines={skillChangedLines[s.id] || {}}
      />
    ) : <EmptyDetail label="skills" onAdd={addSkill} />;
  } else if (section === "mcps") {
    const m = mcps.find((x) => x.id === selectedMCP);
    detail = m ? (
      <MCPEditor
        mcp={m}
        onUpdate={(p) => updateMCP(m.id, p)}
        onDelete={() => removeMCP(m.id)}
        onDuplicate={notSupported("Duplicate MCP")}
        onCheck={() => checkMCP(m.id)}
        checkState={mcpCheckStates[m.id]}
        userEnabled={enabledUserMcpIds.has(m.id)}
        bindingState={mcpBindingStates[m.id]}
        onSetUserEnabled={(enabled) => setMcpEnabledForUser(m.id, enabled)}
      />
    ) : <EmptyDetail label="MCP servers" onAdd={notSupported("Add MCP")} />;
  }
  // PR-S.1d:bindings detail branch 也去掉。TenantBindingsEditor 整个文件已删,
  // 这里没有 fallback 可走 —— rail 入口也清掉确保走不进来。

  return (
    <div className="app">
      <div className="topbar">
        <div className="brand">
          <div className="dot">百</div>
          baizhi
          <span style={{ color: "var(--text-faint)" }}>/</span>
          <span style={{ fontWeight: 500, color: "var(--text-muted)" }}>Manage</span>
        </div>
        <div className="tabs">
          <a href="/playground" style={mTabLink}>Playground</a>
          <button className="active">Manage</button>
          <a href="/evals" style={mTabLink}>Evals</a>
          <a href="/traces" style={mTabLink}>Traces</a>
          <a href="/marketplace" style={mTabLink}>Marketplace</a>
          <a href="/apps" style={mTabLink}>Apps</a>
          {isAdmin && <a href="/users" style={mTabLink}>Users</a>}
          <a href="/api-docs" style={mTabLink}>API&nbsp;Docs</a>
        </div>
        <div className="spacer" />
      </div>

      <div className="manage-shell">
        <div className="rail">
          <RailBtn icon="user"     label="Agents"  active={section === "agents"}  onClick={() => setSection("agents")} />
          <RailBtn icon="wand"     label="Skills"  active={section === "skills"}  onClick={() => setSection("skills")} />
          <RailBtn icon="tool"     label="MCPs"    active={section === "mcps"}    onClick={() => setSection("mcps")} />
          <div className="sp" />
        </div>

        <div className="list-col">
          <div className="list-head">
            <div className="list-title-row">
              <h2>{listTitle}</h2>
              <span className="count">{listCount}</span>
            </div>
            {(section === "skills" || onAdd) && (
              <div className="list-actions-row">
                {section === "skills" && (
                  <button
                    className="btn sm"
                    onClick={() => { setUrlImportOpen(true); setUrlImportError(null); }}
                    title="Import a skill by URL — direct .zip URL or GitHub /tree/{ref}/{path}"
                  >
                    <Icon name="link" size={12} /> Import URL
                  </button>
                )}
                {section === "skills" && (
                  <button
                    className="btn sm"
                    onClick={() => uploadZipInputRef.current && uploadZipInputRef.current.click()}
                    title="Import a skill from a ZIP that contains SKILL.md (+ optional scripts/references/assets)"
                  >
                    <Icon name="upload" size={12} /> Upload ZIP
                  </button>
                )}
                {onAdd && (
                  <button className="btn primary sm" onClick={onAdd}>
                    <Icon name="plus" size={12} /> {addLabel}
                  </button>
                )}
                {section === "skills" && (
                  <input
                    type="file"
                    accept=".zip,application/zip"
                    ref={uploadZipInputRef}
                    style={{ display: "none" }}
                    onChange={onUploadZipPicked}
                  />
                )}
              </div>
            )}
            {/* PR-S.1: Mine / Marketplace sub-tab(skills + mcps 视图)。
                Mine = user 自装/自建;Marketplace = 全平台 public catalog + Install 钮。 */}
            {(section === "skills" || section === "mcps") && (
              <div className="row2" style={{ display: "flex", gap: 6 }}>
                {(() => {
                  const sub = section === "skills" ? skillsSubTab : mcpsSubTab;
                  const setSub = section === "skills" ? setSkillsSubTab : setMcpsSubTab;
                  const mineN = section === "skills" ? skills.length : mcps.length;
                  const marketN = section === "skills"
                    ? (marketSkillsQueryKey === null ? marketSkills.length : marketSkillsTotal)
                    : marketMcps.length;
                  const Tab = ({ value, label, count }) => (
                    <button
                      className={"btn sm" + (sub === value ? " primary" : "")}
                      onClick={() => setSub(value)}
                      style={{ fontWeight: sub === value ? 600 : 400 }}
                    >
                      {label} <span style={{ opacity: 0.7, marginLeft: 4 }}>{count}</span>
                    </button>
                  );
                  return (
                    <>
                      <Tab value="mine" label="Mine" count={mineN} />
                      <Tab value="marketplace" label="Marketplace" count={marketN} />
                    </>
                  );
                })()}
              </div>
            )}
            <div className="row2">
              <div className="search-input">
                <span className="ic"><Icon name="search" size={12} /></span>
                <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={`Search ${listTitle.toLowerCase()}…`} />
              </div>
            </div>
          </div>
          <div className="list-scroll" onScroll={onListScroll}>
            {(listItems || []).length === 0 ? (
              <div className="attach-empty" style={{ padding: "32px 16px" }}>
                <div className="icon"><Icon name="search" size={16} /></div>
                <div>{marketSkillsLoading && section === "skills" && skillsSubTab === "marketplace" ? "Loading…" : (loaded ? "No matches" : "Loading…")}</div>
              </div>
            ) : listItems}
          </div>
        </div>

        {detail}
      </div>

      {toast && (
        <div style={{
          position: "fixed", bottom: 20, left: "50%", transform: "translateX(-50%)",
          background: "#0d0d0d", color: "#fff", padding: "8px 14px",
          borderRadius: 999, fontSize: 12.5, zIndex: 9999,
          boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
        }}>{toast}</div>
      )}

      {/* PR-F.1: Import-skill-from-URL modal. Two URL shapes are accepted
          by the backend; the placeholder + helper text spell them out.
          Submitting calls POST /v1/personal-skills/import-url which fetches
          server-side, runs the same unpack+validate pipeline as upload. */}
      {urlImportOpen && (
        <div
          style={{
            position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)",
            display: "flex", alignItems: "center", justifyContent: "center",
            zIndex: 10000,
          }}
          onClick={(e) => { if (e.target === e.currentTarget && !urlImportBusy) setUrlImportOpen(false); }}
        >
          <div style={{
            background: "#fff", borderRadius: 8, padding: 20,
            width: 520, maxWidth: "90vw",
            boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
          }}>
            <h3 style={{ margin: "0 0 4px", fontSize: 16 }}>Import skill from URL</h3>
            <div style={{ fontSize: 12, color: "#666", marginBottom: 14, lineHeight: 1.5 }}>
              Paste a URL. We fetch it server-side, validate frontmatter,
              and create a draft personal skill (publish to install). Supported:
              <ul style={{ margin: "6px 0 0 18px", padding: 0, fontSize: 11.5 }}>
                <li>Direct <code>.zip</code> URL</li>
                <li>GitHub repo: <code>https://github.com/owner/repo</code></li>
                <li>GitHub subdir: <code>https://github.com/owner/repo/tree/main/skills/pdf</code></li>
              </ul>
            </div>
            <input
              type="url"
              value={urlImportValue}
              onChange={(e) => setUrlImportValue(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && !urlImportBusy) submitUrlImport();
                if (e.key === "Escape" && !urlImportBusy) setUrlImportOpen(false);
              }}
              placeholder="https://… (.zip or github.com/…/tree/…/…)"
              autoFocus
              disabled={urlImportBusy}
              style={{
                width: "100%", padding: "8px 10px", fontSize: 13,
                border: "1px solid #ccc", borderRadius: 4, boxSizing: "border-box",
              }}
            />
            {urlImportError && (
              <div style={{
                marginTop: 10, padding: "8px 10px", fontSize: 12,
                background: "#fff5f5", color: "#a00", borderRadius: 4,
                border: "1px solid #f5c2c7",
              }}>{urlImportError}</div>
            )}
            <div style={{ marginTop: 14, display: "flex", gap: 8, justifyContent: "flex-end" }}>
              <button
                className="btn sm"
                onClick={() => setUrlImportOpen(false)}
                disabled={urlImportBusy}
              >Cancel</button>
              <button
                className="btn primary sm"
                onClick={submitUrlImport}
                disabled={urlImportBusy || !urlImportValue.trim()}
              >{urlImportBusy ? "Importing…" : "Import"}</button>
            </div>
          </div>
        </div>
      )}

      {/* PR-S.16:Admin add-MCP 全字段表单 modal,取代原 window.prompt */}
      {mcpAddModalOpen && (
        <McpAddModal
          onConfirm={confirmAddMCP}
          onCancel={() => setMcpAddModalOpen(false)}
        />
      )}
    </div>
  );
}

const mTabLink = {
  background: "transparent", border: 0, padding: "6px 10px", borderRadius: 6,
  color: "var(--text-muted)", fontSize: 13, textDecoration: "none",
};

function AppsEditor({ app, agents, onCreated, onUpdated, onToast }) {
  const [tab, setTab] = mUseState(() => new URLSearchParams(window.location.search).get("tab") || "overview");
  const [form, setForm] = mUseState({ app_id: "", name: "", owner_type: "user", owner_id: api.userId || "", executor_agent_id: agents[0]?.id || "" });
  const [manifestText, setManifestText] = mUseState("");
  const [schema, setSchema] = mUseState({});
  const [audit, setAudit] = mUseState([]);
  const [resource, setResource] = mUseState("");
  const [records, setRecords] = mUseState([]);
  const [recordText, setRecordText] = mUseState("{\n  \n}");
  const [permission, setPermission] = mUseState({ agent_id: agents[0]?.id || "", scopes: "read,write" });
  const [busy, setBusy] = mUseState(false);

  mUseEffect(() => {
    if (!app) {
      setTab(new URLSearchParams(window.location.search).get("tab") || "overview");
      setManifestText("");
      setForm((prev) => ({
        ...prev,
        owner_id: prev.owner_id || api.userId || "",
      }));
      return;
    }
    setManifestText(JSON.stringify(app.manifest || { app_id: app.app_id, tables: {} }, null, 2));
    const first = Object.keys(app.manifest?.tables || {})[0] || "";
    setResource(first);
  }, [app?.app_id]);

  mUseEffect(() => {
    if (!agents.length) return;
    setForm((prev) => prev.executor_agent_id ? prev : ({ ...prev, executor_agent_id: agents[0].id }));
    setPermission((prev) => prev.agent_id ? prev : ({ ...prev, agent_id: agents[0].id }));
  }, [agents]);

  mUseEffect(() => {
    if (!app) return;
    if (tab === "schema") api.inspectAppSchema(app.app_id).then((r) => setSchema(r.tables || {})).catch((e) => onToast("Schema load failed: " + e.message));
    if (tab === "audit") api.listAppAudit(app.app_id).then(setAudit).catch((e) => onToast("Audit load failed: " + e.message));
    if (tab === "data" && resource) api.listAppRecords(app.app_id, resource).then(setRecords).catch((e) => onToast("Data load failed: " + e.message));
  }, [app?.app_id, tab, resource]);

  const setField = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
  const create = async () => {
    setBusy(true);
    try { const created = await api.createApp(form); await onCreated(created); }
    catch (e) { onToast("Create App failed: " + e.message); }
    finally { setBusy(false); }
  };
  const publish = async () => {
    setBusy(true);
    try { const manifest = JSON.parse(manifestText); await api.publishAppManifest(app.app_id, manifest); await onUpdated(); onToast("Manifest published"); }
    catch (e) { onToast("Manifest publish failed: " + e.message); }
    finally { setBusy(false); }
  };
  const replaceExecutor = async (executor_agent_id) => {
    setBusy(true);
    try { await api.replaceAppExecutor(app.app_id, { executor_agent_id, expected_version: app.version }); await onUpdated(); onToast("Executor updated"); }
    catch (e) { onToast("Executor update failed: " + e.message); }
    finally { setBusy(false); }
  };
  const addPermission = async () => {
    try { await api.grantAppPermission(app.app_id, { agent_id: permission.agent_id, scopes: permission.scopes.split(",").map((x) => x.trim()).filter(Boolean) }); onToast("Permission saved"); }
    catch (e) { onToast("Permission failed: " + e.message); }
  };
  const createRecord = async () => {
    try { await api.createAppRecord(app.app_id, resource, JSON.parse(recordText)); setRecordText("{\n  \n}"); const rows = await api.listAppRecords(app.app_id, resource); setRecords(rows); onToast("Record created"); }
    catch (e) { onToast("Create record failed: " + e.message); }
  };

  if (!app) return (
    <div className="detail apps-detail">
      <div className="detail-head"><div className="av" style={{ background: "#0d0d0d" }}>A</div><div className="titles"><h1>New App</h1><div className="sub">Create a Supabase-backed App space</div></div></div>
      <div className="detail-body"><div className="form-grid">
        <div className="apps-section-title">App identity <span>Control-plane metadata and executor binding</span></div>
        <label className="field-lg">App ID<input className="input-lg" placeholder="app001" value={form.app_id} onChange={(e) => setField("app_id", e.target.value)} /></label>
        <label className="field-lg">Name<input className="input-lg" placeholder="Tasks" value={form.name} onChange={(e) => setField("name", e.target.value)} /></label>
        <label className="field-lg">Owner type<select className="input-lg" value={form.owner_type} onChange={(e) => setField("owner_type", e.target.value)}><option value="user">User</option><option value="agent">Agent</option></select></label>
        <label className="field-lg">Owner ID<input className="input-lg" value={form.owner_id} onChange={(e) => setField("owner_id", e.target.value)} /></label>
        <label className="field-lg">Executor Agent<select className="input-lg" value={form.executor_agent_id} onChange={(e) => setField("executor_agent_id", e.target.value)}>{agents.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.id}</option>)}</select></label>
        <div><button className="btn primary" disabled={busy || !form.app_id || !form.name || !form.owner_id || !form.executor_agent_id} onClick={create}>Create App</button></div>
      </div></div>
    </div>
  );

  const tables = Object.keys(app.manifest?.tables || {});
  return (
    <div className="detail apps-detail">
      <div className="detail-head"><div className="av" style={{ background: "#0d0d0d" }}>A</div><div className="titles"><h1>{app.name}<span className="pill">{app.status}</span></h1><div className="sub">{app.app_id} · {app.owner_type}:{app.owner_id} · v{app.version}</div></div><div className="actions"><span className="status-dot ok" title="Supabase-backed" /><span className="pill">Supabase</span></div></div>
      <div className="apps-tabs">{["overview", "schema", "data", "permissions", "audit"].map((item) => <button key={item} className={tab === item ? "active" : ""} onClick={() => setTab(item)}>{item}</button>)}</div>
      <div className="detail-body">
        {tab === "overview" && <div className="form-grid">
          <div className="apps-section-title">Overview <span>App ownership and runtime binding</span></div>
          <div className="apps-kv"><span>Owner</span><strong>{app.owner_type}:{app.owner_id}</strong><span>Executor</span><strong>{app.executor_agent_id}</strong><span>Status</span><strong>{app.status}</strong><span>Version</span><strong>{app.version}</strong></div>
          <label className="field-lg">Replace executor<select className="input-lg" value={app.executor_agent_id} disabled={busy} onChange={(e) => replaceExecutor(e.target.value)}>{agents.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.id}</option>)}</select><span className="field-help">Changing the executor updates the App binding immediately.</span></label>
          <label className="field-lg">Manifest JSON<textarea className="textarea-lg mono" value={manifestText} onChange={(e) => setManifestText(e.target.value)} /></label>
          <div><button className="btn primary" disabled={busy} onClick={publish}>Publish manifest</button></div>
        </div>}
        {tab === "schema" && <div><div className="apps-section-title">Schema <span>Tables currently visible in the App schema</span></div>{Object.entries(schema).length === 0 ? <div className="attach-empty">No tables yet. Publish a Manifest to create one.</div> : Object.entries(schema).map(([name, columns]) => <div className="apps-table-card" key={name}><strong>{name}</strong><div>{columns.map((column) => <span className="pill" key={column}>{column}</span>)}</div></div>)}</div>}
        {tab === "data" && <div><div className="apps-section-title">Data <span>Manifest-approved CRUD through the App API</span></div><div className="apps-data-toolbar"><select className="input-lg" value={resource} onChange={(e) => setResource(e.target.value)}>{tables.map((name) => <option key={name} value={name}>{name}</option>)}</select><button className="btn sm" onClick={() => resource && api.listAppRecords(app.app_id, resource).then(setRecords)}>Refresh</button></div><textarea className="textarea-lg mono apps-record-input" value={recordText} onChange={(e) => setRecordText(e.target.value)} /><button className="btn primary sm" onClick={createRecord} disabled={!resource}>Create record</button><pre className="apps-json-output">{JSON.stringify(records, null, 2)}</pre></div>}
        {tab === "permissions" && <div><div className="apps-section-title">Permissions <span>Grant an Agent access to this App</span></div><div className="apps-permission-row"><select className="input-lg" value={permission.agent_id} onChange={(e) => setPermission({ ...permission, agent_id: e.target.value })}>{agents.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.id}</option>)}</select><input className="input-lg" value={permission.scopes} onChange={(e) => setPermission({ ...permission, scopes: e.target.value })} /><button className="btn primary" onClick={addPermission}>Grant</button></div><p className="field-help">Scopes are comma-separated, for example <code>read,write</code>.</p></div>}
        {tab === "audit" && <div><div className="apps-section-title">Audit <span>Recent App lifecycle and tool activity</span></div>{audit.length === 0 ? <div className="attach-empty">No audit events.</div> : <div className="apps-audit-list">{audit.map((event) => <div className="apps-audit-row" key={event.event_id}><span className="pill">{event.result}</span><strong>{event.operation}</strong><span className="field-help">{event.tool_name || "control plane"}</span></div>)}</div>}</div>}
      </div>
    </div>
  );
}

function RailBtn({ icon, label, active, onClick }) {
  return (
    <button className={"rail-btn" + (active ? " active" : "")} onClick={onClick}>
      <Icon name={icon} size={18} />
      <span>{label}</span>
    </button>
  );
}

function EmptyDetail({ label, onAdd }) {
  return (
    <div className="detail">
      <div className="detail-empty">
        <div>
          <div className="ic-wrap"><Icon name="wand" size={22} /></div>
          <h3>No {label} selected</h3>
          <p className="muted" style={{ marginBottom: 12 }}>Pick one from the list or create a new one.</p>
          <button className="btn primary" onClick={onAdd}>
            <Icon name="plus" size={12} /> Create new
          </button>
        </div>
      </div>
    </div>
  );
}

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