/** @jsxImportSource preact */
/**
 * Skills view — browse + archive + download crystallized skills.
 *
 * Backed by `/api/v1/skills`. Clicking a row opens a drawer with the
 * full invocation guide, η/gain/support stats, and actions:
 *
 *   - Download as .zip   (backend writes the skill package)
 *   - Toggle visibility  (public ↔ private, for Hub sharing)
 *   - Archive
 */
import { useEffect, useState } from "preact/hooks";
import { api } from "../api/client";
import { openSse } from "../api/sse";
import { t } from "../stores/i18n";
import { Icon } from "../components/Icon";
import { Pager } from "../components/Pager";
import { ShareScopePill } from "../components/ShareScopePill";
import { LightweightModeEmpty } from "../components/LightweightModeEmpty";
import { NamespaceSelect, appendNamespaceParams } from "../components/NamespaceSelect";
import { Markdown } from "../components/Markdown";
import { route } from "../stores/router";
import { clearEntryId, linkTo } from "../stores/cross-link";
import type { CoreEvent, SkillDTO } from "../api/types";
import { areAllIdsSelected, toggleIdsInSelection } from "../utils/selection";
import {
  loadHubSharingEnabled,
  normalizeShareScope,
  SHARE_SCOPE_OPTIONS,
  type ShareScope,
} from "../utils/share";
import { useLightweightMemoryMode } from "../hooks/useLightweightMemoryMode";

interface SkillUsage {
  sourcePolicies: Array<{
    id: string;
    title: string | null;
    status: string | null;
    gain: number | null;
  }>;
  sourceWorldModels: Array<{ id: string; title: string | null }>;
}

type StatusFilter = "" | "active" | "candidate" | "archived";

const DEFAULT_PAGE_SIZE = 20;

interface SkillModelRefusalPayload {
  kind?: string;
  policyId?: string;
  modelRefusal?: {
    provider?: string;
    model?: string;
    servedBy?: string;
    content?: string;
  };
}

interface SkillRefusalNotice {
  id: number;
  policyId: string;
  model: string;
  content: string;
}

export function SkillsView() {
  const [query, setQuery] = useState("");
  const [status, setStatus] = useState<StatusFilter>("");
  const [namespaceFilter, setNamespaceFilter] = useState("");
  const [skills, setSkills] = useState<SkillDTO[] | null>(null);
  const [detail, setDetail] = useState<SkillDTO | null>(null);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(0);
  const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
  const [hasMore, setHasMore] = useState(false);
  const [total, setTotal] = useState(0);
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [refusalNotices, setRefusalNotices] = useState<SkillRefusalNotice[]>([]);
  const [showRefusalNotices, setShowRefusalNotices] = useState(false);
  const lightweight = useLightweightMemoryMode();
  const toggleSel = (id: string) => {
    setSelected((prev) => {
      const n = new Set(prev);
      if (n.has(id)) n.delete(id);
      else n.add(id);
      return n;
    });
  };

  useEffect(() => {
    const ctrl = new AbortController();
    void loadHubSharingEnabled({ force: true, signal: ctrl.signal });
    return () => ctrl.abort();
  }, []);

  const load = async (nextPage: number = 0) => {
    setLoading(true);
    try {
      const qs = new URLSearchParams();
      qs.set("limit", String(pageSize));
      qs.set("offset", String(nextPage * pageSize));
      if (status) qs.set("status", status);
      appendNamespaceParams(qs, namespaceFilter);
      const r = await api.get<{ skills: SkillDTO[]; nextOffset?: number; total?: number }>(
        `/api/v1/skills?${qs.toString()}`,
      );
      setSkills(r.skills ?? []);
      setHasMore(r.nextOffset != null);
      setTotal(r.total ?? 0);
      setPage(nextPage);
    } catch {
      setSkills([]);
      setHasMore(false);
      setTotal(0);
    } finally {
      setLoading(false);
    }
  };
  useEffect(() => {
    if (lightweight.loading || lightweight.enabled) return;
    void load(0);
  }, [status, pageSize, namespaceFilter, lightweight.loading, lightweight.enabled]);

  useEffect(() => {
    if (!lightweight.enabled) return;
    setSkills([]);
    setDetail(null);
    setSelected(new Set());
    setHasMore(false);
    setTotal(0);
    setPage(0);
  }, [lightweight.enabled]);

  useEffect(() => {
    const handle = openSse("/api/v1/events", (_, data) => {
      try {
        const evt = JSON.parse(data) as CoreEvent<SkillModelRefusalPayload>;
        if (evt.type !== "system.error") return;
        const payload = evt.payload;
        if (payload?.kind !== "skill.model_refusal") return;
        const refusal = payload.modelRefusal ?? {};
        setRefusalNotices((prev) => [
          {
            id: evt.seq,
            policyId: payload.policyId ?? "unknown",
            model: [refusal.provider, refusal.model].filter(Boolean).join("/") || "unknown model",
            content: refusal.content ?? "",
          },
          ...prev,
        ].slice(0, 20));
      } catch {
        /* Ignore malformed SSE payloads. */
      }
    });
    return () => handle.close();
  }, []);

  // Deep-link: `#/skills?id=sk_xxx` auto-opens the drawer.
  useEffect(() => {
    if (lightweight.loading || lightweight.enabled) return;
    const id = route.value.params.id;
    if (!id) return;
    const ctrl = new AbortController();
    api
      .get<{ skills: SkillDTO[] }>(
        `/api/v1/skills?limit=500`,
        { signal: ctrl.signal },
      )
      .then((r) => {
        const match = (r.skills ?? []).find((s) => s.id === id);
        if (match) setDetail(match);
      })
      .catch(() => void 0);
    return () => ctrl.abort();
  }, [route.value.params.id, lightweight.loading, lightweight.enabled]);

  const filtered = (skills ?? []).filter((s) => {
    if (!query) return true;
    const q = query.toLowerCase();
    return (
      s.name.toLowerCase().includes(q) ||
      s.invocationGuide.toLowerCase().includes(q)
    );
  });
  const pageIds = filtered.map((s) => s.id);
  const isPageSelected = areAllIdsSelected(selected, pageIds);

  return (
    <>
      <div class="view-header">
        <div class="view-header__title">
          <h1>{t("skills.title")}</h1>
          <p>{t("skills.subtitle")}</p>
        </div>
        {!lightweight.enabled && (
          <div class="view-header__actions">
            <SkillRefusalDropdown
              notices={refusalNotices}
              open={showRefusalNotices}
              onToggle={() => setShowRefusalNotices((v) => !v)}
              onClear={() => {
                setRefusalNotices([]);
                setShowRefusalNotices(false);
              }}
            />
            {/*
             * Refresh — matches MemoriesView / TasksView / PoliciesView /
             * WorldModelsView. Clears search + status filter, drops
             * selection, and re-fetches page 0 so the list visibly
             * snaps back to "fresh top state". The old implementation
             * only re-queried the CURRENT page with the CURRENT filters
             * still applied, which looked like a no-op whenever the
             * filtered slice hadn't actually changed.
             */}
            <button
              class="btn btn--ghost btn--sm"
              onClick={() => {
                setQuery("");
                setStatus("");
                setNamespaceFilter("");
                setSelected(new Set());
                void load(0);
              }}
            >
              <Icon name="refresh-cw" size={14} />
              {t("common.refresh")}
            </button>
          </div>
        )}
      </div>

      {lightweight.loading && (
        <div class="list">
          {[0, 1, 2].map((i) => (
            <div key={i} class="skeleton" style="height:64px" />
          ))}
        </div>
      )}

      {!lightweight.loading && lightweight.enabled && (
        <LightweightModeEmpty
          icon="wand-sparkles"
          message={t("skills.lightweight.empty")}
        />
      )}

      {!lightweight.loading && !lightweight.enabled && (
        <>
          <div class="toolbar">
            <label class="input-search">
              <Icon name="search" size={16} />
              <input
                class="input input--search"
                type="search"
                placeholder={t("skills.search.placeholder")}
                value={query}
                onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
              />
            </label>
          </div>

          <div class="toolbar" style="margin-top:calc(-1 * var(--sp-2))">
            <div class="toolbar__group" role="group" aria-label={t("common.filter")}>
              {[
                { v: "" as StatusFilter, k: "common.all" as const },
                { v: "active" as StatusFilter, k: "status.active" as const },
                { v: "candidate" as StatusFilter, k: "status.candidate" as const },
                { v: "archived" as StatusFilter, k: "status.archived" as const },
              ].map((opt) => (
                <button
                  key={opt.v}
                  class="chip"
                  aria-pressed={status === opt.v}
                  onClick={() => setStatus(opt.v)}
                >
                  {t(opt.k)}
                </button>
              ))}
            </div>
            <NamespaceSelect value={namespaceFilter} onChange={setNamespaceFilter} />
          </div>

          {loading && (
            <div class="list">
              {[0, 1, 2].map((i) => (
                <div key={i} class="skeleton" style="height:64px" />
              ))}
            </div>
          )}

          {!loading && filtered.length === 0 && (
            <div class="empty">
              <div class="empty__icon">
                <Icon name="wand-sparkles" size={22} />
              </div>
              <div class="empty__title">{t("skills.empty")}</div>
              <div class="empty__hint">{t("skills.empty.hint")}</div>
            </div>
          )}

          {filtered.length > 0 && (
            <div class="list">
              {filtered.map((s) => {
                const isSel = selected.has(s.id);
                return (
                  <div
                    key={s.id}
                    class={`mem-card${isSel ? " mem-card--selected" : ""}`}
                    onClick={() => setDetail(s)}
                  >
                    <label
                      class="mem-card__check-wrap"
                      onClick={(e) => e.stopPropagation()}
                    >
                      <input
                        type="checkbox"
                        class="mem-card__check"
                        checked={isSel}
                        onChange={() => toggleSel(s.id)}
                        aria-label="select"
                      />
                    </label>
                    <div class="mem-card__body">
                      <div class="mem-card__title">{s.name}</div>
                      <div class="mem-card__meta">
                        <ShareScopePill scope={s.share?.scope} />
                        <span class={`pill pill--${s.status}`}>
                          {t(`status.${s.status}` as "status.active")}
                        </span>
                        <span class="pill pill--info" title={t("skills.version.title")}>
                          v{s.version ?? 1}
                        </span>
                        <span>η {(s.eta ?? 0).toFixed(2)}</span>
                        <span>gain {(s.gain ?? 0).toFixed(2)}</span>
                        <span>support {s.support ?? 0}</span>
                        <span>
                          {t("skills.trials.pass", {
                            count: String(s.trialsPassed ?? 0),
                          })}
                        </span>
                        <span>
                          {t("skills.usage.count", {
                            count: String(s.usageCount ?? 0),
                          })}
                        </span>
                        {s.lastUsedAt && (
                          <span>
                            {t("skills.usage.lastUsed", {
                              at: formatWhen(s.lastUsedAt),
                            })}
                          </span>
                        )}
                        <span>
                          {t("skills.updated.ago", {
                            at: formatWhen(s.updatedAt),
                          })}
                        </span>
                      </div>
                    </div>
                    <div class="mem-card__tail">
                      <Icon name="chevron-right" size={16} />
                    </div>
                  </div>
                );
              })}
            </div>
          )}

          {(page > 0 || hasMore) && (
            <Pager
              page={page}
              totalItems={total}
              pageSize={pageSize}
              hasMore={hasMore}
              loading={loading}
              onPageSizeChange={setPageSize}
              onPageChange={(nextPage) => {
                void load(nextPage);
              }}
            />
          )}

          {detail && (
            <SkillDrawer
              skill={detail}
              onClose={() => {
                setDetail(null);
                clearEntryId();
              }}
              onChanged={() => {
                void load(page);
                setDetail(null);
                clearEntryId();
              }}
            />
          )}

          {selected.size > 0 && (
            <div class="batch-bar" role="region" aria-label="bulk actions">
              <span class="batch-bar__count">
                {t("common.selected", { n: selected.size })}
              </span>
              <button
                class="btn btn--sm"
                onClick={() => setSelected((prev) => toggleIdsInSelection(prev, pageIds))}
              >
                <Icon name="check-square" size={14} />
                {isPageSelected ? t("common.deselectPage") : t("common.selectPage")}
              </button>
              <button
                class="btn btn--danger btn--sm"
                onClick={async () => {
                  if (selected.size === 0) return;
                  if (!confirm(t("common.bulkDelete.confirm", { n: selected.size }))) return;
                  const ids = [...selected];
                  await Promise.all(
                    ids.map((id) =>
                      api.post("/api/v1/skills/archive", { skillId: id }).catch(() => null),
                    ),
                  );
                  setSelected(new Set());
                  void load(page);
                }}
              >
                <Icon name="archive" size={14} />
                {t("skills.detail.archive")}
              </button>
              <div class="batch-bar__spacer" />
              <button class="btn btn--ghost btn--sm" onClick={() => setSelected(new Set())}>
                {t("common.deselect")}
              </button>
            </div>
          )}
        </>
      )}
    </>
  );
}

function SkillRefusalDropdown({
  notices,
  open,
  onToggle,
  onClear,
}: {
  notices: SkillRefusalNotice[];
  open: boolean;
  onToggle: () => void;
  onClear: () => void;
}) {
  return (
    <div class="skill-refusal-menu">
      <button
        class="btn btn--ghost btn--sm skill-refusal-menu__trigger"
        onClick={onToggle}
        aria-expanded={open}
        title="Skill 沉淀模型拒答提醒"
      >
        <Icon name="bell" size={14} />
        <span>提醒</span>
        {notices.length > 0 && (
          <span class="skill-refusal-menu__badge">{notices.length}</span>
        )}
        <Icon name={open ? "chevron-up" : "chevron-down"} size={12} />
      </button>
      {open && (
        <div class="skill-refusal-menu__panel">
          <div class="skill-refusal-menu__head">
            <strong>Skill 沉淀提醒</strong>
            <button
              class="btn btn--ghost btn--icon"
              onClick={onClear}
              aria-label="清空提醒"
              title="清空提醒"
            >
              <Icon name="x" size={14} />
            </button>
          </div>
          {notices.length === 0 ? (
            <div class="skill-refusal-menu__empty">暂无模型拒答提醒</div>
          ) : (
            <div class="skill-refusal-menu__list">
              {notices.map((item) => (
                <div class="skill-refusal-menu__item" key={item.id}>
                  <div class="skill-refusal-menu__meta">
                    <span>Policy: {item.policyId}</span>
                    <span>Model: {item.model}</span>
                  </div>
                  <div class="skill-refusal-menu__content">{item.content}</div>
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

interface TimelineEntry {
  ts: number;
  kind: string;
  phase?: string;
  durationMs: number;
  success: boolean;
  summary?: string;
}

function SkillDrawer({
  skill,
  onClose,
  onChanged,
}: {
  skill: SkillDTO;
  onClose: () => void;
  onChanged: () => void;
}) {
  const [mode, setMode] = useState<"view" | "edit" | "share">("view");
  const [name, setName] = useState(skill.name);
  const [guide, setGuide] = useState(skill.invocationGuide ?? "");
  const [scope, setScope] = useState<ShareScope>(normalizeShareScope(skill.share?.scope ?? "public"));
  const [busy, setBusy] = useState(false);
  const [timeline, setTimeline] = useState<TimelineEntry[] | null>(null);
  const [usage, setUsage] = useState<SkillUsage | null>(null);
  const decisionGuidance = normalizeDecisionGuidance(
    (skill as { decisionGuidance?: unknown }).decisionGuidance,
  );
  const evidenceAnchors = normalizeEvidenceAnchors(
    (skill as { evidenceAnchors?: unknown }).evidenceAnchors,
  );

  useEffect(() => {
    setName(skill.name);
    setGuide(skill.invocationGuide ?? "");
    setScope(normalizeShareScope(skill.share?.scope ?? "public"));
  }, [skill]);

  useEffect(() => {
    const ctrl = new AbortController();
    api
      .get<{ entries: TimelineEntry[] }>(
        `/api/v1/skills/${encodeURIComponent(skill.id)}/timeline`,
        { signal: ctrl.signal },
      )
      .then((r) => setTimeline(r.entries ?? []))
      .catch(() => setTimeline([]));
    return () => ctrl.abort();
  }, [skill.id]);

  // Separate fetch: resolve source-policy / source-world-model ids to
  // their titles so the drawer renders click-through chips instead of
  // opaque `po_xxx` strings. The server does the joins.
  useEffect(() => {
    const ctrl = new AbortController();
    api
      .get<SkillUsage>(
        `/api/v1/skills/${encodeURIComponent(skill.id)}/usage`,
        { signal: ctrl.signal },
      )
      .then(setUsage)
      .catch(() => setUsage(null));
    return () => ctrl.abort();
  }, [skill.id]);

  const archive = async () => {
    setBusy(true);
    try {
      await api.post("/api/v1/skills/archive", { skillId: skill.id });
      onChanged();
    } finally {
      setBusy(false);
    }
  };

  const reactivate = async () => {
    setBusy(true);
    try {
      await api.post("/api/v1/skills/reactivate", { skillId: skill.id });
      onChanged();
    } finally {
      setBusy(false);
    }
  };

  const hardDelete = async () => {
    if (!confirm(t("skills.act.delete.confirm", { name: skill.name }))) return;
    setBusy(true);
    try {
      await api.del(`/api/v1/skills/${encodeURIComponent(skill.id)}`);
      onChanged();
    } finally {
      setBusy(false);
    }
  };

  const submitEdit = async () => {
    setBusy(true);
    try {
      await api.patch(`/api/v1/skills/${encodeURIComponent(skill.id)}`, {
        name: name.trim() || skill.name,
        invocationGuide: guide,
      });
      onChanged();
    } finally {
      setBusy(false);
    }
  };

  const submitShare = async (s: ShareScope | null) => {
    setBusy(true);
    try {
      await api.post(`/api/v1/skills/${encodeURIComponent(skill.id)}/share`, {
        scope: s,
      });
      onChanged();
    } finally {
      setBusy(false);
    }
  };

  const downloadZip = async () => {
    setBusy(true);
    try {
      const blob = await api.blob(
        `/api/v1/skills/${encodeURIComponent(skill.id)}/download`,
      );
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `${skill.name.replace(/[^\w.-]+/g, "_") || "skill"}.zip`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
    } finally {
      setBusy(false);
    }
  };

  return (
    <div class="drawer-backdrop" onClick={onClose}>
      <aside class="drawer" role="dialog" onClick={(e) => e.stopPropagation()}>
        <header class="drawer__header">
          <div>
            <div class="muted mono" style="font-size:var(--fs-xs);margin-bottom:2px">
              skill {skill.id.slice(0, 16)}
            </div>
            <h2 class="drawer__title">{skill.name}</h2>
          </div>
          <button
            class="btn btn--ghost btn--icon"
            onClick={onClose}
            aria-label={t("common.close")}
          >
            <Icon name="x" size={16} />
          </button>
        </header>

        <div class="drawer__body">
          {mode === "view" && (<>
          {/*
           * Metadata section — styled as a text-based <dl> grid to
           * match the other drawers (Memories / Tasks / Policies /
           * WorldModels).
           */}
          <section class="card card--flat">
            <h3 class="card__title" style="font-size:var(--fs-md)">
              {t("tasks.detail.meta")}
            </h3>
            <dl style="display:grid;grid-template-columns:120px 1fr;gap:6px 16px;margin:0;font-size:var(--fs-sm)">
              <dt class="muted">{t("memories.field.status")}</dt>
              <dd>
                <span class={`pill pill--${skill.status}`}>
                  {t(`status.${skill.status}` as "status.active")}
                </span>
              </dd>
              <dt class="muted">{t("memories.field.share")}</dt>
              <dd><ShareScopePill scope={skill.share?.scope} /></dd>
              <dt class="muted">{t("skills.detail.version")}</dt>
              <dd>v{skill.version ?? 1}</dd>
              <dt class="muted">{t("memories.field.eta")}</dt>
              <dd>{(skill.eta ?? 0).toFixed(3)}</dd>
              <dt class="muted">{t("memories.field.gain")}</dt>
              <dd>{(skill.gain ?? 0).toFixed(3)}</dd>
              <dt class="muted">{t("memories.field.support")}</dt>
              <dd>{skill.support ?? 0}</dd>
              <dt class="muted">{t("skills.trials.pass.label")}</dt>
              <dd>
                {t("skills.trials.pass.detail", {
                  passed: String(skill.trialsPassed ?? 0),
                  attempted: String(skill.trialsAttempted ?? 0),
                })}
              </dd>
              <dt class="muted">{t("skills.usage.count.label")}</dt>
              <dd>{skill.usageCount ?? 0}</dd>
              <dt class="muted">{t("skills.usage.lastUsed.label")}</dt>
              <dd>{skill.lastUsedAt ? formatWhen(skill.lastUsedAt) : t("common.never")}</dd>
              <dt class="muted">{t("memories.field.updatedAt")}</dt>
              <dd>{formatWhen(skill.updatedAt)}</dd>
            </dl>
          </section>

          <section class="card card--flat">
            <h3 class="card__title" style="font-size:var(--fs-md)">
              {t("skills.detail.desc")}
            </h3>
            {skill.invocationGuide ? (
              <Markdown text={skill.invocationGuide} />
            ) : (
              <div class="muted" style="font-size:var(--fs-sm)">
                (empty)
              </div>
            )}
          </section>

          {(usage?.sourcePolicies.length ?? 0) > 0 && (
            <section class="card card--flat">
              <h3 class="card__title" style="font-size:var(--fs-md);margin-bottom:var(--sp-3)">
                {t("skills.xlink.sourcePolicies")}
              </h3>
              <div class="hstack" style="flex-wrap:wrap;gap:var(--sp-2)">
                {usage!.sourcePolicies.map((p) => (
                  <button
                    key={p.id}
                    class="pill pill--link"
                    style="cursor:pointer;border:0;font-family:inherit;font-size:var(--fs-sm)"
                    onClick={() => linkTo("policy", p.id)}
                    title={p.id}
                  >
                    {p.title ?? p.id.slice(0, 10)}
                    {p.gain != null && (
                      <span class="muted" style="margin-left:6px;font-size:var(--fs-xs)">
                        gain {p.gain.toFixed(2)}
                      </span>
                    )}
                  </button>
                ))}
              </div>
            </section>
          )}
          {(usage?.sourceWorldModels.length ?? 0) > 0 && (
            <section class="card card--flat">
              <h3 class="card__title" style="font-size:var(--fs-md);margin-bottom:var(--sp-3)">
                {t("skills.xlink.sourceWorldModels")}
              </h3>
              <div class="hstack" style="flex-wrap:wrap;gap:var(--sp-2)">
                {usage!.sourceWorldModels.map((w) => (
                  <button
                    key={w.id}
                    class="pill pill--link"
                    style="cursor:pointer;border:0;font-family:inherit;font-size:var(--fs-sm)"
                    onClick={() => linkTo("world-model", w.id)}
                    title={w.id}
                  >
                    {w.title ?? w.id.slice(0, 10)}
                  </button>
                ))}
              </div>
            </section>
          )}

          {/*
           * V7 §2.4.6 — decision guidance distilled by the crystallizer
           * from past failures + user feedback. Empty arrays mean
           * "nothing was learned yet"; we hide the section in that case
           * so the drawer stays uncluttered.
           */}
          {(decisionGuidance.preference.length > 0 ||
            decisionGuidance.antiPattern.length > 0) && (
            <section class="card card--flat">
              <h3 class="card__title" style="font-size:var(--fs-md);margin-bottom:var(--sp-3)">
                {t("skills.detail.decisionGuidance")}
              </h3>
              {decisionGuidance.preference.length > 0 && (
                <div style="margin-bottom:var(--sp-3)">
                  <div class="muted" style="font-size:var(--fs-xs);margin-bottom:4px">
                    {t("skills.detail.decisionGuidance.prefer")}
                  </div>
                  <ul style="margin:0;padding-left:18px;font-size:var(--fs-sm);line-height:1.55">
                    {decisionGuidance.preference.map((p, i) => (
                      <li key={`p-${i}`}>{p}</li>
                    ))}
                  </ul>
                </div>
              )}
              {decisionGuidance.antiPattern.length > 0 && (
                <div>
                  <div class="muted" style="font-size:var(--fs-xs);margin-bottom:4px">
                    {t("skills.detail.decisionGuidance.avoid")}
                  </div>
                  <ul style="margin:0;padding-left:18px;font-size:var(--fs-sm);line-height:1.55">
                    {decisionGuidance.antiPattern.map((a, i) => (
                      <li key={`a-${i}`}>{a}</li>
                    ))}
                  </ul>
                </div>
              )}
            </section>
          )}

          {/*
           * V7 §2.1 evidence_anchors — direct trace-level provenance.
           * Click-through chips deep-link into MemoriesView so the user
           * can audit "which memories justified this skill?".
           */}
          {evidenceAnchors.length > 0 && (
            <section class="card card--flat">
              <h3 class="card__title" style="font-size:var(--fs-md);margin-bottom:var(--sp-3)">
                {t("skills.detail.evidenceAnchors", {
                  n: evidenceAnchors.length,
                })}
              </h3>
              <div class="hstack" style="flex-wrap:wrap;gap:var(--sp-2)">
                {evidenceAnchors.map((anchor) => (
                  <button
                    key={anchor.key}
                    class="pill pill--link mono"
                    style={`cursor:${anchor.memoryId ? "pointer" : "default"};border:0;font-family:var(--font-mono);font-size:var(--fs-xs)`}
                    // Trace ids open the Memories tab — traces surface
                    // there one-row-per-step (collapsed by turnId into
                    // memory cards). The cross-link store doesn't have
                    // a "trace" entity kind because the user-facing
                    // unit is "memory", not "trace".
                    onClick={() => {
                      if (anchor.memoryId) linkTo("memory", anchor.memoryId);
                    }}
                    title={anchor.title}
                  >
                    {anchor.label}
                  </button>
                ))}
              </div>
            </section>
          )}

          {/* Evolution timeline — sourced from api_logs skill_generate
              / skill_evolve events. Empty until the first crystallisation
              event is recorded; every rebuild produces one more row. */}
          <section class="card card--flat">
            <h3 class="card__title" style="font-size:var(--fs-md);margin-bottom:var(--sp-3)">
              {t("skills.detail.evolution")}
            </h3>
            {timeline === null ? (
              <div class="skeleton" style="height:60px" />
            ) : timeline.length === 0 ? (
              <div class="muted" style="font-size:var(--fs-sm)">
                {t("skills.detail.evolution.empty")}
              </div>
            ) : (
              <div class="vstack" style="gap:6px">
                {timeline.map((e, i) => (
                  <div
                    key={i}
                    class="hstack"
                    style="gap:var(--sp-3);padding:8px 10px;background:var(--bg-canvas);border-radius:var(--radius-sm);align-items:flex-start;font-size:var(--fs-sm)"
                  >
                    <span class="muted mono" style="font-size:var(--fs-xs);min-width:80px">
                      {formatWhen(e.ts)}
                    </span>
                    <span
                      class={`pill ${e.success ? "pill--active" : "pill--failed"}`}
                      style="font-size:var(--fs-2xs)"
                    >
                      {skillTimelineLabel(e.kind, e.phase)}
                    </span>
                    {/*
                     * The raw `phase` ("started" / "done" / "failed")
                     * duplicates the kind label ("结晶完成" already
                     * implies done) and renders as opaque English on
                     * the Chinese viewer. We fold it into the kind
                     * label above instead of showing a second pill.
                     */}
                    {e.summary && (
                      <span class="truncate" style="flex:1;min-width:0">
                        {e.summary}
                      </span>
                    )}
                  </div>
                ))}
              </div>
            )}
          </section>
          </>)}

          {mode === "edit" && (
            <>
              <section class="card card--flat">
                <div class="modal__field">
                  <label>{t("skills.edit.name")}</label>
                  <input
                    class="input"
                    value={name}
                    onInput={(e) => setName((e.target as HTMLInputElement).value)}
                  />
                </div>
              </section>
              <section class="card card--flat">
                <div class="modal__field">
                  <label>{t("skills.edit.invocationGuide")}</label>
                  <textarea
                    class="textarea"
                    rows={14}
                    value={guide}
                    onInput={(e) => setGuide((e.target as HTMLTextAreaElement).value)}
                  />
                </div>
              </section>
            </>
          )}

          {mode === "share" && (
            <section class="card card--flat">
              <div class="modal__field">
                <label>{t("memories.share.scope")}</label>
                <div class="vstack" style="gap:var(--sp-2)">
                  {SHARE_SCOPE_OPTIONS.map((v) => (
                    <label
                      key={v}
                      class="hstack"
                      style="gap:var(--sp-2);cursor:pointer;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-canvas)"
                    >
                      <input
                        type="radio"
                        name="skill-share-scope"
                        checked={scope === v}
                        onChange={() => setScope(v)}
                      />
                      <span>{t(`memories.share.scope.${v}` as never)}</span>
                    </label>
                  ))}
                </div>
              </div>
            </section>
          )}
        </div>

        <footer class="drawer__footer">
          {mode === "view" && (
            <>
              <button
                class="btn btn--danger btn--sm"
                disabled={busy}
                onClick={hardDelete}
              >
                <Icon name="trash-2" size={14} />
                {t("memories.act.delete")}
              </button>
              <div class="batch-bar__spacer" />
              {skill.status === "archived" ? (
                <button
                  class="btn btn--sm"
                  disabled={busy}
                  onClick={reactivate}
                >
                  <Icon name="check-circle-2" size={14} />
                  {t("policies.act.activate")}
                </button>
              ) : (
                <button class="btn btn--sm" disabled={busy} onClick={archive}>
                  <Icon name="archive" size={14} />
                  {t("skills.detail.archive")}
                </button>
              )}
              <button class="btn btn--sm" disabled={busy} onClick={downloadZip}>
                <Icon name="download" size={14} />
                {t("skills.detail.download")}
              </button>
              <button class="btn btn--sm" disabled={busy} onClick={() => setMode("share")}>
                <Icon name="share" size={14} />
                {skill.share?.scope
                  ? t("memories.act.unshare")
                  : t("memories.act.share")}
              </button>
              <button
                class="btn btn--primary btn--sm"
                disabled={busy}
                onClick={() => setMode("edit")}
              >
                <Icon name="pencil" size={14} />
                {t("memories.act.edit")}
              </button>
            </>
          )}
          {mode === "edit" && (
            <>
              <button class="btn btn--ghost btn--sm" onClick={() => setMode("view")}>
                {t("common.cancel")}
              </button>
              <div class="batch-bar__spacer" />
              <button
                class="btn btn--primary btn--sm"
                disabled={busy}
                onClick={submitEdit}
              >
                <Icon name="check" size={14} />
                {t("common.save")}
              </button>
            </>
          )}
          {mode === "share" && (
            <>
              {skill.share?.scope && (
                <button
                  class="btn btn--danger btn--sm"
                  disabled={busy}
                  onClick={() => submitShare(null)}
                >
                  <Icon name="trash-2" size={14} />
                  {t("memories.act.unshare")}
                </button>
              )}
              <div class="batch-bar__spacer" />
              <button class="btn btn--ghost btn--sm" onClick={() => setMode("view")}>
                {t("common.cancel")}
              </button>
              <button
                class="btn btn--primary btn--sm"
                disabled={busy}
                onClick={() => submitShare(scope)}
              >
                <Icon name="share" size={14} />
                {t("memories.act.share")}
              </button>
            </>
          )}
        </footer>
      </aside>
    </div>
  );
}

function formatWhen(ts: number | undefined): string {
  if (!ts) return "—";
  try {
    return new Date(ts).toLocaleString();
  } catch {
    return "—";
  }
}

interface EvidenceAnchorView {
  key: string;
  label: string;
  title: string;
  memoryId?: string;
}

function normalizeDecisionGuidance(raw: unknown): {
  preference: string[];
  antiPattern: string[];
} {
  const source = parseJsonString(raw);
  if (!source || typeof source !== "object" || Array.isArray(source)) {
    return { preference: [], antiPattern: [] };
  }
  const obj = source as { preference?: unknown; antiPattern?: unknown };
  return {
    preference: stringArray(obj.preference),
    antiPattern: stringArray(obj.antiPattern),
  };
}

function normalizeEvidenceAnchors(raw: unknown): EvidenceAnchorView[] {
  const source = parseJsonString(raw);
  const list = Array.isArray(source) ? source : source == null ? [] : [source];
  return list
    .map((item, index) => normalizeEvidenceAnchor(item, index))
    .filter((item): item is EvidenceAnchorView => item !== null);
}

function normalizeEvidenceAnchor(
  raw: unknown,
  index: number,
): EvidenceAnchorView | null {
  const value = parseJsonString(raw);
  if (typeof value === "string") {
    const id = value.trim();
    if (!id) return null;
    return {
      key: `${index}:${id}`,
      label: compactAnchorLabel(id),
      title: id,
      memoryId: id,
    };
  }

  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return null;
  }

  const obj = value as Record<string, unknown>;
  const directId =
    firstString(obj.traceId, obj.trace_id, obj.memoryId, obj.memory_id, obj.id, obj.refId) ??
    null;
  const label = anchorObjectLabel(obj, directId);
  if (!label) return null;

  return {
    key: `${index}:${directId ?? label}`,
    label: compactAnchorLabel(label),
    title: safeJsonTitle(obj) ?? label,
    memoryId: directId ?? undefined,
  };
}

function anchorObjectLabel(
  obj: Record<string, unknown>,
  directId: string | null,
): string | null {
  if (directId) return directId;

  const kind = firstString(obj.kind, obj.type, obj.source);
  const task = firstString(obj.task, obj.taskId, obj.task_id);
  const skill = firstString(obj.skill, obj.skillId, obj.skill_id, obj.name);
  if (kind && task && skill) return `${kind}:${task}/${skill}`;
  if (kind && task) return `${kind}:${task}`;
  if (task && skill) return `${task}/${skill}`;

  return firstString(obj.label, obj.title, obj.path) ?? safeJsonTitle(obj);
}

function parseJsonString(value: unknown): unknown {
  if (typeof value !== "string") return value;
  const trimmed = value.trim();
  if (!trimmed) return "";
  if (!/^[\[{"]/.test(trimmed)) return value;
  try {
    return JSON.parse(trimmed) as unknown;
  } catch {
    return value;
  }
}

function stringArray(value: unknown): string[] {
  const parsed = parseJsonString(value);
  if (!Array.isArray(parsed)) return [];
  return parsed
    .map((item) => {
      const v = parseJsonString(item);
      return typeof v === "string" ? v.trim() : "";
    })
    .filter(Boolean);
}

function firstString(...values: unknown[]): string | null {
  for (const value of values) {
    const parsed = parseJsonString(value);
    if (typeof parsed === "string" && parsed.trim()) return parsed.trim();
  }
  return null;
}

function compactAnchorLabel(value: string): string {
  return value.length > 32 ? `${value.slice(0, 29)}...` : value;
}

function safeJsonTitle(value: unknown): string | null {
  try {
    return JSON.stringify(value);
  } catch {
    return null;
  }
}

/**
 * Friendly label for timeline `kind`. Falls back to the raw kind when
 * the event name isn't in the lookup — new event types should still
 * render rather than silently disappear.
 *
 * For rows where the recorded kind is just the raw tool name
 * (`skill_generate` / `skill_evolve`), we use the row's `phase` field
 * to pick the most specific label: e.g. `skill_generate` + phase
 * `done` reads as "结晶完成" rather than the opaque "skill_generate".
 */
function skillTimelineLabel(kind: string, phase?: string): string {
  switch (kind) {
    case "skill.crystallized":
      return t("skills.timeline.kind.crystallized");
    case "skill.crystallization.started":
      return t("skills.timeline.kind.started");
    case "skill.rebuilt":
      return t("skills.timeline.kind.rebuilt");
    case "skill.eta.updated":
      return t("skills.timeline.kind.etaUpdated");
    case "skill.status.changed":
      return t("skills.timeline.kind.statusChanged");
    case "skill.archived":
      return t("skills.timeline.kind.archived");
    case "skill.verification.failed":
      return t("skills.timeline.kind.verifyFailed");
    case "skill.failed":
      return t("skills.timeline.kind.failed");
    case "skill_generate":
      if (phase === "started") return t("skills.timeline.kind.started");
      if (phase === "done") return t("skills.timeline.kind.crystallized");
      if (phase === "failed") return t("skills.timeline.kind.failed");
      return t("skills.timeline.kind.crystallized");
    case "skill_evolve":
      return t("skills.timeline.kind.rebuilt");
    default:
      return kind;
  }
}
