/* global React, CR_UI, CR_API */
const { useState, useEffect, useCallback } = React;
const { Icons, Btn, Tag, EmptyState, formatRelative } = CR_UI;

// Tiny markdown→HTML renderer for Article body.
// Security note: research bodies come from Claude AND from the user's manual
// edit. Both must be treated as untrusted — even Claude's output can echo
// adversarial content from external sources it browsed. We:
//   1. HTML-escape every input character before any markdown processing, so
//      a body containing `<script>` or `<img onerror=>` becomes literal text.
//   2. Validate URLs in `[text](url)` — only http://, https://, and `#fn-N`
//      anchors are allowed. `javascript:`, `data:`, `vbscript:` etc. are
//      replaced with `#` so a malicious link can't fire on click.
function renderMarkdown(md) {
  const escapeHtml = (s) => String(s)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");

  const safeUrl = (url) => {
    const trimmed = String(url).trim();
    // Allow internal anchor links to footnote targets only.
    if (/^#fn-\d+$/.test(trimmed)) return trimmed;
    // Strip ASCII control chars (0x00-0x1F and 0x7F DEL) that could be used
    // to obfuscate the scheme check (e.g. "java\x00script:" smuggles past
    // a naive prefix match).
    const sanitized = trimmed.replace(/[\u0000-\u001f\u007f]/g, "");
    if (/^https?:\/\//i.test(sanitized)) return sanitized;
    // javascript:, data:, vbscript:, file:, mailto: ... -> "#"
    return "#";
  };

  // STEP 1: escape the raw body so no later regex emits unescaped HTML.
  const escapedLines = md.split("\n").map(escapeHtml);

  let html = "";
  let inList = false;
  const footnoteIdx = {};

  // Footnote DEFINITION lines look like `[^N]: text`. Strip them out and
  // index the body for the trailing source list. The body part is already
  // HTML-escaped from STEP 1.
  const footnoteDefs = {};
  const cleanLines = [];
  escapedLines.forEach((line) => {
    const m = line.match(/^\[\^(\d+)\]:\s*(.*)$/);
    if (m) { footnoteDefs[m[1]] = m[2]; return; }
    cleanLines.push(line);
  });

  const inline = (s) => {
    // Footnote reference [^N] → numbered superscript link to #fn-N
    s = s.replace(/\[\^(\d+)\]/g, (_, n) => {
      if (!footnoteIdx[n]) footnoteIdx[n] = Object.keys(footnoteIdx).length + 1;
      return `<sup><a href="#fn-${n}">[${footnoteIdx[n]}]</a></sup>`;
    });
    // Bold / italic — operate on already-escaped text.
    s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
    s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
    // Markdown links — URL goes through safeUrl, label stays escaped.
    s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
      `<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`,
    );
    return s;
  };

  // Note: the markers we test for (##, ###, >, -, *, ---) survive HTML
  // escaping intact because escapeHtml only touches & < > " '.
  cleanLines.forEach((line) => {
    if (/^###\s/.test(line)) {
      if (inList) { html += "</ul>"; inList = false; }
      html += `<h3>${inline(line.replace(/^###\s/, ""))}</h3>`;
    } else if (/^##\s/.test(line)) {
      if (inList) { html += "</ul>"; inList = false; }
      html += `<h2>${inline(line.replace(/^##\s/, ""))}</h2>`;
    } else if (/^#\s/.test(line)) {
      if (inList) { html += "</ul>"; inList = false; }
      html += `<h1>${inline(line.replace(/^#\s/, ""))}</h1>`;
    } else if (/^&gt;\s/.test(line)) {
      // After escapeHtml, leading "> " becomes "&gt; "
      if (inList) { html += "</ul>"; inList = false; }
      html += `<blockquote>${inline(line.replace(/^&gt;\s/, ""))}</blockquote>`;
    } else if (/^[-*]\s/.test(line)) {
      if (!inList) { html += "<ul>"; inList = true; }
      html += `<li>${inline(line.replace(/^[-*]\s/, ""))}</li>`;
    } else if (/^---/.test(line)) {
      if (inList) { html += "</ul>"; inList = false; }
      html += "<hr/>";
    } else if (line.trim() === "") {
      if (inList) { html += "</ul>"; inList = false; }
    } else {
      if (inList) { html += "</ul>"; inList = false; }
      html += `<p>${inline(line)}</p>`;
    }
  });
  if (inList) html += "</ul>";

  // Auto-link a bare URL at the start of a footnote definition and decode
  // percent-encoded Cyrillic so it displays as readable text.
  const linkFootnoteDef = (escapedText) => {
    return escapedText.replace(/^(https?:\/\/\S+)/, (escapedUrl) => {
      let display = escapedUrl;
      try {
        const raw = escapedUrl.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
        const decoded = decodeURIComponent(raw);
        display = decoded.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
      } catch (_) {}
      return `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" class="footnote-url">${display}</a>`;
    });
  };

  const idxList = Object.entries(footnoteIdx);
  if (idxList.length) {
    html += `<div class="footnotes"><h3 style="font-size:13px;font-family:'TT Lakes',sans-serif;letter-spacing:0.14em;text-transform:uppercase;color:var(--fg-muted);margin-top:32px;font-weight:600;">Источники</h3><ol>`;
    idxList.sort((a, b) => a[1] - b[1]).forEach(([n]) => {
      html += `<li id="fn-${n}">${linkFootnoteDef(inline(footnoteDefs[n] || ""))}</li>`;
    });
    html += "</ol></div>";
  }
  return html;
}

const ArticlesScreen = ({ onGeneratePost, onRefine, onRunResearch, onOpenJobLog, brand, navTarget, onNavConsumed, activeJobs }) => {
  const [topics, setTopics]         = useState([]);   // approved topics list
  const [topicsLoading, setTopicsLoading] = useState(false);
  const [topicsError, setTopicsError] = useState(null);
  const [activeId, setActiveId]     = useState(null);
  // research cache: topicId → { status, activeVersion, versions, sources_used, last_refined, cost_tokens }
  const [researchCache, setResearchCache] = useState({});
  const [researchLoading, setResearchLoading] = useState(false);
  const [versionV, setVersionV]     = useState(null);
  const [editing, setEditing]       = useState(false);
  const [editBuffer, setEditBuffer] = useState("");
  const [savingEdit, setSavingEdit] = useState(false);
  // Bumping refreshToken forces the fetch effect to re-run even when
  // brand/activeId haven't changed — used by polling and the post-job
  // transition effect to pick up freshly written research files.
  const [refreshToken, setRefreshToken] = useState(0);
  const bumpRefresh = useCallback(() => setRefreshToken((n) => n + 1), []);
  // Topic id of the refine job that actually started — set when refineJobRunning
  // transitions false→true (canonical job-started signal, not the click handler).
  const [refiningTopicId, setRefiningTopicId] = useState(null);
  // Set of topic_ids that have at least one draft or published post.
  const [postsTopicIds, setPostsTopicIds] = useState(new Set());

  const reloadTopics = useCallback(() => {
    if (!brand) return Promise.resolve();
    setTopicsLoading(true);
    setTopicsError(null);
    return CR_API.listTopics(brand, "approved")
      .then((list) => {
        setTopics(list);
        setTopicsLoading(false);
        return list;
      })
      .catch((err) => {
        setTopicsError(err.message);
        setTopicsLoading(false);
        return [];
      });
  }, [brand]);

  // Always reflects the brand that was current at the moment the component
  // rendered — used to guard stale async post-lookup responses.
  const activeBrandRef = React.useRef(brand);
  useEffect(() => { activeBrandRef.current = brand; }, [brand]);

  // Refresh which topics have posts (drafts or published).
  const reloadPostsTopicIds = useCallback(() => {
    if (!brand) return;
    const requestBrand = brand; // capture brand at call time
    Promise.all([
      CR_API.listPostDrafts(requestBrand).catch(() => []),
      CR_API.listPostsPublished(requestBrand).catch(() => []),
    ]).then(([drafts, published]) => {
      if (activeBrandRef.current !== requestBrand) return; // brand changed, discard
      const ids = new Set([...drafts, ...published].map((p) => p.topic_id));
      setPostsTopicIds(ids);
    });
  }, [brand]);

  // Preload version lists for all topics in parallel (sidebar needs status w/o body).
  // Skips topics that already have a fully-loaded body in the cache.
  const preloadResearchStatuses = useCallback((topicList) => {
    if (!brand || !topicList.length) return;
    const brandSnap = brand;
    Promise.all(
      topicList.map((t) =>
        CR_API.researchVersions(brandSnap, t.id)
          .then((versions) => ({ id: t.id, versions }))
          .catch(() => ({ id: t.id, versions: [] }))
      )
    ).then((results) => {
      if (activeBrandRef.current !== brandSnap) return;
      setResearchCache((prev) => {
        const next = { ...prev };
        results.forEach(({ id, versions }) => {
          // Don't overwrite an entry that already has a loaded body.
          if (next[id]?.versions?.some((v) => v.body != null)) return;
          if (!versions.length) {
            next[id] = { status: "missing", activeVersion: null, versions: [], sources_used: 0, last_refined: null, cost_tokens: 0 };
          } else {
            const latest = versions[versions.length - 1];
            next[id] = {
              status: "ready",
              activeVersion: latest,
              versions: versions.map((v) => ({ v, label: `v${v}`, body: null, refined_at: null, tokens: 0 })),
              sources_used: 0,
              last_refined: null,
              cost_tokens: 0,
            };
          }
        });
        return next;
      });
    });
  }, [brand]);

  // Load approved topics when brand changes
  useEffect(() => {
    if (!brand) return;
    const ctrl = new AbortController();
    setTopics([]);
    setTopicsError(null);
    setActiveId(null);
    setPostsTopicIds(new Set());
    reloadPostsTopicIds();
    reloadTopics().then((list) => {
      if (ctrl.signal.aborted) return;
      if (list && list.length > 0) setActiveId(list[0].id);
      preloadResearchStatuses(list);
    });
    return () => ctrl.abort();
  }, [brand, reloadTopics, preloadResearchStatuses]);

  // Apply navTarget once topics are loaded
  useEffect(() => {
    if (!navTarget || navTarget.kind !== "topic" || topics.length === 0) return;
    if (topics.find((t) => t.id === navTarget.id)) {
      setActiveId(navTarget.id);
    }
    onNavConsumed && onNavConsumed();
  }, [navTarget, topics, onNavConsumed]);

  // refine_article affects only one topic; deep_research is batch (all topics).
  const isRunning = (j) => j.status === "running" || j.status === "queued";
  const refineJobRunning   = (activeJobs || []).some((j) => j.kind === "refine_article" && isRunning(j));
  const deepResearchRunning = (activeJobs || []).some((j) => j.kind === "deep_research"  && isRunning(j));
  const researchJobRunning = refineJobRunning || deepResearchRunning;

  // deep_research: batch job, poll the currently viewed topic.
  useEffect(() => {
    if (!brand || !activeId || !deepResearchRunning) return;
    const t = setInterval(() => {
      setResearchCache((c) => { const next = { ...c }; delete next[activeId]; return next; });
      bumpRefresh();
    }, 8000);
    return () => clearInterval(t);
  }, [brand, activeId, deepResearchRunning, bumpRefresh]);

  // refine_article: single-topic job, poll only the frozen refiningTopicId —
  // never touches activeId so navigating away doesn't corrupt other topics.
  useEffect(() => {
    if (!brand || !refineJobRunning || !refiningTopicId) return;
    const tid = refiningTopicId; // captured at effect-start, stable for this run
    const t = setInterval(() => {
      setResearchCache((c) => { const next = { ...c }; delete next[tid]; return next; });
      bumpRefresh();
    }, 8000);
    return () => clearInterval(t);
  }, [brand, refineJobRunning, refiningTopicId, bumpRefresh]);

  const generatePostJobRunning = (activeJobs || []).some(
    (j) => j.kind === "generate_post" && isRunning(j),
  );

  // Reload posts set when a generate_post job finishes.
  const wasGeneratePostRunning = React.useRef(false);
  useEffect(() => {
    if (wasGeneratePostRunning.current && !generatePostJobRunning && brand) {
      reloadPostsTopicIds();
    }
    wasGeneratePostRunning.current = generatePostJobRunning;
  }, [generatePostJobRunning, brand, reloadPostsTopicIds]);

  const wasResearchJobRunning = React.useRef(false);
  const wasRefineRunning = React.useRef(false);
  useEffect(() => {
    // refineJobRunning just became true → job confirmed started, freeze topic now.
    if (!wasRefineRunning.current && refineJobRunning && activeId) {
      setRefiningTopicId(activeId);
    }
    if (wasResearchJobRunning.current && !researchJobRunning && brand) {
      if (wasRefineRunning.current && refiningTopicId) {
        setResearchCache((c) => { const n = { ...c }; delete n[refiningTopicId]; return n; });
        setRefiningTopicId(null);
      } else {
        setResearchCache({});
      }
      bumpRefresh();
    }
    wasResearchJobRunning.current = researchJobRunning;
    wasRefineRunning.current = refineJobRunning;
  }, [researchJobRunning, refineJobRunning, brand, activeId, refiningTopicId, bumpRefresh]);

  // Fetch research when active topic changes
  useEffect(() => {
    if (!brand || !activeId) return;
    if (researchCache[activeId]) {
      const cached = researchCache[activeId];
      // Skip full fetch only when we have real data loaded.
      // Stub entries from preloadResearchStatuses have body: null — fall through.
      if (cached.status === "missing") {
        setVersionV(cached.activeVersion);
        return;
      }
      const activeVer = cached.versions.find((v) => v.v === cached.activeVersion);
      if (activeVer?.body != null) {
        setVersionV(cached.activeVersion);
        return;
      }
      // Body not yet loaded — fall through to full fetch.
    }
    setResearchLoading(true);
    setVersionV(null);

    Promise.all([
      CR_API.getResearch(brand, activeId).catch(() => null),
      CR_API.researchVersions(brand, activeId).catch(() => []),
    ]).then(([doc, versions]) => {
      if (!doc) {
        // Research file not yet on disk. We DON'T know here whether a job is
        // running — that's brand-level state in `researchJobRunning` and may
        // toggle independently. We just mark the entry "missing" and let
        // renderMain / the sidebar branch on `researchJobRunning` to decide
        // between "Запустить ресерч" (no job) and "Claude собирает…" (job is
        // running).
        const entry = { status: "missing", activeVersion: null, versions: [], sources_used: 0, last_refined: null, cost_tokens: 0 };
        setResearchCache((c) => ({ ...c, [activeId]: entry }));
        setResearchLoading(false);
        return;
      }
      const versionObjs = (versions.length ? versions : [doc.version]).map((v) => ({
        v,
        label: `v${v}`,
        body: v === doc.version ? doc.body : null, // lazy: only latest pre-loaded
        refined_at: v === doc.version ? doc.generated_at : null,
        tokens: v === doc.version ? (doc.cost_tokens || 0) : 0,
      }));
      const entry = {
        status: "ready",
        activeVersion: doc.version,
        versions: versionObjs,
        sources_used: (doc.sources_used || []).length,
        last_refined: doc.generated_at,
        cost_tokens: doc.cost_tokens || 0,
      };
      setResearchCache((c) => ({ ...c, [activeId]: entry }));
      setVersionV(doc.version);
      setResearchLoading(false);
    });
  }, [brand, activeId, refreshToken]); // eslint-disable-line react-hooks/exhaustive-deps

  // When a different version is clicked, load it if not yet cached
  const handleVersionClick = async (v) => {
    setVersionV(v);
    const cached = researchCache[activeId];
    if (!cached) return;
    const already = cached.versions.find((x) => x.v === v);
    if (already?.body != null) return; // already loaded
    try {
      const doc = await CR_API.getResearch(brand, activeId, v);
      setResearchCache((c) => ({
        ...c,
        [activeId]: {
          ...c[activeId],
          versions: c[activeId].versions.map((x) =>
            x.v === v ? { ...x, body: doc.body, refined_at: doc.generated_at, tokens: doc.cost_tokens || 0 } : x
          ),
        },
      }));
    } catch (_) {}
  };

  const topic = topics.find((t) => t.id === activeId) || topics[0];
  const research = activeId ? researchCache[activeId] : null;
  const version = research?.versions.find((v) => v.v === versionV);
  const isEmpty = !topicsLoading && topics.length === 0 && !topicsError;

  const renderMain = () => {
    if (topicsLoading) {
      return (
        <div className="article-view" style={{ alignItems: "flex-start" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "56px 32px", color: "var(--fg-muted)" }}>
            <Icons.Spinner size={18} /> Загрузка статей…
          </div>
        </div>
      );
    }
    if (topicsError) {
      return (
        <div className="article-view">
          <div className="article-view__inner" style={{ gridTemplateColumns: "1fr" }}>
            <div className="article-view__main">
              <div className="error-banner" style={{ maxWidth: "none", margin: 0 }}>
                <Icons.AlertTriangle size={18} />
                <div style={{ flex: 1 }}>
                  <div className="error-banner__title">Не удалось загрузить статьи</div>
                  <div className="error-banner__body">{topicsError}</div>
                </div>
                <Btn kind="primary" size="sm" onClick={() => { setTopicsError(null); setTopics([]); setActiveId(null); }}>Повторить</Btn>
              </div>
            </div>
          </div>
        </div>
      );
    }

    if (isEmpty) {
      return (
        <div className="article-view" style={{ alignItems: "flex-start" }}>
          <EmptyState
            icon={<Icons.Doc size={26} />}
            title="Нет одобренных тем"
            body="Перейди в раздел Очередь → Новые и одобри хотя бы одну тему. Ресерч запустится автоматически."
            actions={<Btn kind="primary">Открыть Очередь</Btn>}
          />
        </div>
      );
    }

    if (researchLoading || !research) {
      return (
        <div className="article-view">
          <div className="article-view__inner" style={{ gridTemplateColumns: "1fr" }}>
            <div className="article-view__main">
              <div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "56px 0", textAlign: "center", gap: 14 }}>
                <div style={{ color: "var(--status-info-fg)" }}><Icons.Spinner size={28} /></div>
                <div className="screen__title" style={{ fontSize: 22 }}>Загрузка ресерча…</div>
              </div>
            </div>
          </div>
        </div>
      );
    }

    if (research.status === "missing") {
      const isRunningNow = researchJobRunning;
      return (
        <div className="article-view">
          <div className="article-view__inner" style={{ gridTemplateColumns: "1fr" }}>
            <div className="article-view__main">
              <div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "56px 0", textAlign: "center", gap: 14 }}>
                {isRunningNow ? (
                  <>
                    <div style={{ color: "var(--status-info-fg)" }}><Icons.Spinner size={28} /></div>
                    <div className="screen__title" style={{ fontSize: 22 }}>Claude собирает информацию…</div>
                    <div style={{ color: "var(--fg-muted)", maxWidth: 460 }}>
                      Идёт <code style={{ fontFamily: "JetBrains Mono, monospace", color: "var(--brand-red)" }}>deep_research</code>. Обычно 4–6 минут — смотрите прогресс в журнале.
                    </div>
                    <Btn kind="ghost" size="sm" icon={<Icons.ChevronRight size={13} />} onClick={onOpenJobLog}>Открыть журнал</Btn>
                  </>
                ) : (
                  <>
                    <div className="screen__title" style={{ fontSize: 22 }}>Нет статьи</div>
                    <div style={{ color: "var(--fg-muted)", maxWidth: 460 }}>
                      Тема «{topic?.title}» одобрена, но ресерч ещё не запускали. Нажмите «Запустить ресерч» — Claude напишет первую версию по темам бренда.
                    </div>
                    <Btn kind="primary" size="sm" icon={<Icons.Sparkles size={13} />} onClick={() => onRunResearch && onRunResearch(topic)}>
                      Запустить ресерч
                    </Btn>
                  </>
                )}
              </div>
            </div>
          </div>
        </div>
      );
    }

    const bodyHtml = version?.body != null ? renderMarkdown(version.body) : "<p style='color:var(--fg-muted)'>Загрузка версии…</p>";

    return (
      <div className="article-view">
        <div className="article-view__inner">
          <div className="article-view__main">
            {editing ? (
              <textarea className="editor-area" value={editBuffer} onChange={(e) => setEditBuffer(e.target.value)} spellCheck={false} />
            ) : (
              <div className="md" dangerouslySetInnerHTML={{ __html: bodyHtml }}
                onClick={(e) => {
                  const a = e.target.closest('a[href^="#fn-"]');
                  if (!a) return;
                  e.preventDefault();
                  const el = document.getElementById(a.getAttribute("href").slice(1));
                  if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
                }}
              />
            )}
          </div>
        </div>
      </div>
    );
  };

  return (
    <div className="screen">
      <div className="split">
        <aside className="split__aside">
          <div className="split__aside-header">
            <div className="section-label" style={{ color: "var(--fg-muted)" }}>Одобренные · Статьи</div>
            <div style={{ flex: 1 }} />
          </div>
          <div className="split__aside-list">
            {topics.map((t) => {
              const r = researchCache[t.id];
              // Three states a sidebar row can be in:
              //   ready   — research file exists for this topic (icon ✓, "Готово · v…")
              //   missing — no research yet AND no job running (icon –, "Нет статьи")
              //   running — no research yet but a brand-level deep_research /
              //             refine_article job is in flight (spinner, "Идёт ресерч")
              // Without this distinction the spinner used to crank forever for
              // approved topics that just sat idle without ever being researched.
              let rowStatus;
              if (r?.status === "ready") rowStatus = "ready";
              else if (deepResearchRunning) rowStatus = "running";
              else if (refineJobRunning && t.id === refiningTopicId) rowStatus = "running";
              else rowStatus = "missing";
              const hasPost = postsTopicIds.has(t.id);
              const statusIcon = rowStatus === "running"
                ? <span style={{ color: "var(--status-info-fg)" }}><Icons.Spinner size={12} /></span>
                : hasPost
                  ? <span style={{ color: "var(--status-approved-fg)" }}><Icons.Check size={12} /></span>
                  : <span style={{ color: "var(--fg-muted)" }}>–</span>;
              const tag = rowStatus === "running"
                ? "Идёт ресерч"
                : rowStatus === "ready"
                  ? `Готово · v${r?.activeVersion ?? "?"}`
                  : "Нет статьи";
              return (
                <div key={t.id}
                  className={"article-list-item " + (activeId === t.id ? "is-active" : "")}
                  onClick={() => { setActiveId(t.id); setEditing(false); }}>
                  <div className="article-list-item__icon">{statusIcon}</div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div className="article-list-item__title">{t.title}</div>
                    <div className="article-list-item__meta">{tag}</div>
                  </div>
                </div>
              );
            })}
          </div>
        </aside>

        <main className="split__main">
          <div className="article-toolbar">
            <h2 className="article-toolbar__title">{topic?.title || "—"}</h2>
            {research && research.status === "ready" && research.versions.length > 0 && (
              <select className="version-select" value={versionV ?? ""} onChange={(e) => handleVersionClick(Number(e.target.value))}>
                {research.versions.map((v) => (
                  <option key={v.v} value={v.v}>{v.label}</option>
                ))}
              </select>
            )}
            <div style={{ width: 1, height: 22, background: "var(--border)" }} />
            {editing ? (
              <>
                <Btn kind="ghost" size="sm" disabled={savingEdit} onClick={() => setEditing(false)}>Отмена</Btn>
                <Btn kind="primary" size="sm" icon={<Icons.Check size={13} />} disabled={savingEdit || !editBuffer.trim()}
                  onClick={async () => {
                    if (!brand || !topic?.id) return;
                    setSavingEdit(true);
                    try {
                      const sources = research?.versions?.find((v) => v.v === versionV)?.sources_used || [];
                      const doc = await CR_API.manualEditResearch(brand, topic.id, editBuffer, sources);
                      // Refresh the cache for this topic from scratch
                      const versions = await CR_API.researchVersions(brand, topic.id).catch(() => [doc.version]);
                      const versionObjs = versions.map((v) => ({
                        v,
                        label: `v${v}`,
                        body: v === doc.version ? doc.body : null,
                        refined_at: v === doc.version ? doc.generated_at : null,
                        tokens: 0,
                      }));
                      setResearchCache((c) => ({
                        ...c,
                        [topic.id]: {
                          status: "ready",
                          activeVersion: doc.version,
                          versions: versionObjs,
                          sources_used: doc.sources_used.length,
                          last_refined: doc.generated_at,
                          cost_tokens: 0,
                        },
                      }));
                      setVersionV(doc.version);
                      setEditing(false);
                    } catch (e) {
                      alert("Не удалось сохранить: " + e.message);
                    } finally {
                      setSavingEdit(false);
                    }
                  }}>
                  {savingEdit ? "Сохранение…" : `Сохранить как v${(research?.versions.length || 0) + 1}`}
                </Btn>
              </>
            ) : (
              <>
                <Btn kind="secondary" size="sm" icon={<Icons.Sparkles size={13} />} onClick={() => onRefine && onRefine(topic)}
                  disabled={!research || research.status === "missing"}>Уточнить</Btn>
                <Btn kind="ghost" size="sm" icon={<Icons.Edit size={13} />}
                  disabled={!research || research.status === "missing"}
                  onClick={() => { setEditBuffer(version?.body || ""); setEditing(true); }}>Править вручную</Btn>
                <Btn kind="primary" size="sm" icon={<Icons.Send size={13} />}
                  onClick={() => onGeneratePost && onGeneratePost(topic)}
                  disabled={!research || research.status === "missing"}>Сгенерировать пост</Btn>
              </>
            )}
          </div>
          {renderMain()}
        </main>
      </div>
    </div>
  );
};

window.CR_ARTICLES = { ArticlesScreen };
