// ---------- 4. Articles ----------
const ARTICLES_STATE = {
  page: 1, pageSize: 20, rows: [], total: 0, sites: [], depts: [],
  // Set of article ids checked. Cleared on every search() (the row set
  // changes) and after a successful bulk-delete.
  selected: new Set(),
  // Cross-page hand-off: callers (e.g. 统计分析 page's "看文章" button)
  // can stash a desired site/target/dept filter here BEFORE go("articles").
  // PAGES.articles consumes it inside the same render cycle so the
  // initial fetch runs with the right query and we don't race two
  // searchArticles() calls — the empty-filter one losing late and
  // overwriting the filtered results.
  pendingSite: null, pendingTarget: null, pendingDept: null, pendingQ: null,
};

PAGES.articles = async () => {
  const ws = document.getElementById("workspace");
  // Load sites for the dropdown filter (cache on first visit)
  if (!ARTICLES_STATE.sites.length) {
    try {
      const r = await api("/api/sites");
      ARTICLES_STATE.sites = r.sites || [];
    } catch { /* fall through; filter still works as text input fallback */ }
  }
  if (!ARTICLES_STATE.depts.length) {
    try {
      const r = await api("/api/local-departments?limit=2000");
      ARTICLES_STATE.depts = r.items || [];
    } catch { /* dept filter fall back to no-options */ }
  }
  const siteOpts = `<option value="">所有站点</option>` +
    (ARTICLES_STATE.sites || []).map(s =>
      `<option value="${esc(s.site_code)}">${esc(s.site_code)} · ${esc(s.site_name || "")}</option>`
    ).join("");
  // Dept input: text + datalist for typeahead. With ~hundreds of departments
  // a plain <select> made the operator scroll forever. The datalist option
  // value embeds both id and name so typing either narrows the list; we
  // parse the dept_id back out at submit time. Empty input = all depts.
  const deptListOpts = (ARTICLES_STATE.depts || []).map(d => {
    const label = `${d.dept_id} · ${d.short_name || d.dept_name || d.full_name || ""}`;
    return `<option value="${esc(label)}"></option>`;
  }).join("");
  ws.innerHTML = `
    <div class="card p-4">
      <div class="flex gap-2 flex-wrap items-center">
        <input id="a-q" placeholder="标题/正文关键词…" class="border border-slate-200 rounded px-3 py-1.5 text-sm w-64"/>
        <select id="a-site" class="border border-slate-200 rounded px-3 py-1.5 text-sm" style="min-width:200px">${siteOpts}</select>
        <select id="a-target" class="border border-slate-200 rounded px-3 py-1.5 text-sm mono" style="min-width:240px"></select>
        <input id="a-dept" list="a-dept-list" placeholder="所有部门（输入编号或名称）"
               class="border border-slate-200 rounded px-3 py-1.5 text-sm" style="min-width:240px"
               autocomplete="off"/>
        <datalist id="a-dept-list">${deptListOpts}</datalist>
        <select id="a-status" class="border border-slate-200 rounded px-3 py-1.5 text-sm">
          <option value="">全部状态</option>
          <option value="ready">ready</option>
          <option value="failed">failed</option>
          <option value="raw">raw</option>
        </select>
        <button type="button" onclick="_toggleArticleAdvancedFilters()" class="btn-outline">业务条件</button>
        <button onclick="searchArticles()" class="bg-[#1D4ED8] hover:bg-blue-700 text-white text-sm rounded px-4 py-1.5">搜索</button>
        <button onclick="_importAllRagArticles()" class="btn-outline">全部入库</button>
      </div>
      <div id="a-advanced" class="hidden mt-3 border-t border-slate-100 pt-3">
        <div class="grid grid-cols-3 gap-2">
          <input id="a-article-id" placeholder="article_id" type="number" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-native-post-id" placeholder="native_post_id" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-url" placeholder="URL 包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-url-hash" placeholder="url_hash 精确匹配" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-title" placeholder="标题包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-doc-no" placeholder="文号包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-index-no" placeholder="索引号包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-publisher" placeholder="发布机构包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-source-raw" placeholder="来源包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-channel-name" placeholder="栏目名包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-channel-path" placeholder="栏目路径包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-content-category" placeholder="一级分类包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-content-subcategory" placeholder="二级分类包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-open-category" placeholder="公开分类包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <input id="a-topic-words" placeholder="主题词包含…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <select id="a-has-attachment" class="border border-slate-200 rounded px-3 py-1.5 text-sm">
            <option value="">是否有附件：全部</option>
            <option value="true">有附件</option>
            <option value="false">无附件</option>
          </select>
          <select id="a-is-effective" class="border border-slate-200 rounded px-3 py-1.5 text-sm">
            <option value="">是否有效：全部</option>
            <option value="true">有效</option>
            <option value="false">非当前有效</option>
          </select>
          <select id="a-fetch-strategy" class="border border-slate-200 rounded px-3 py-1.5 text-sm">
            <option value="">抓取策略：全部</option>
            <option value="httpx">httpx</option>
            <option value="playwright">playwright</option>
            <option value="patchright">patchright</option>
          </select>
          <input id="a-rag-status" placeholder="RAG 状态…" class="border border-slate-200 rounded px-3 py-1.5 text-sm"/>
          <label class="text-xs text-slate-500">发布日期从 <input id="a-publish-date-from" type="date" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">发布日期至 <input id="a-publish-date-to" type="date" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">生效日期从 <input id="a-effective-date-from" type="date" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">生效日期至 <input id="a-effective-date-to" type="date" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">失效日期从 <input id="a-expiry-date-from" type="date" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">失效日期至 <input id="a-expiry-date-to" type="date" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">抓取时间从 <input id="a-fetched-from" type="datetime-local" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">抓取时间至 <input id="a-fetched-to" type="datetime-local" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">入库时间从 <input id="a-created-from" type="datetime-local" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
          <label class="text-xs text-slate-500">入库时间至 <input id="a-created-to" type="datetime-local" class="border border-slate-200 rounded px-2 py-1 text-sm w-full"/></label>
        </div>
      </div>
    </div>
    <div id="a-results"></div>
  `;
  // Pre-fill site/target if a cross-page jumper stashed a request before
  // calling go("articles"). Apply BEFORE _populateArticleTargetSelect so
  // the target dropdown is filtered by the desired site, and BEFORE the
  // initial searchArticles() so we don't race two fetches.
  if (ARTICLES_STATE.pendingQ != null) {
    document.getElementById("a-q").value = ARTICLES_STATE.pendingQ || "";
    ARTICLES_STATE.pendingQ = null;
  }
  if (ARTICLES_STATE.pendingSite != null) {
    document.getElementById("a-site").value = ARTICLES_STATE.pendingSite || "";
    ARTICLES_STATE.pendingSite = null;
  }
  // target dropdown is populated based on site selection — repopulate now
  // and on every site change so it always matches.
  _populateArticleTargetSelect();
  if (ARTICLES_STATE.pendingTarget != null) {
    document.getElementById("a-target").value = ARTICLES_STATE.pendingTarget || "";
    ARTICLES_STATE.pendingTarget = null;
  }
  if (ARTICLES_STATE.pendingDept != null) {
    // pendingDept is a bare dept_id from cross-page handoff; resolve to the
    // labelled form ("100 · 财政局") so the input shows something readable.
    const wantId = String(ARTICLES_STATE.pendingDept || "");
    if (wantId) {
      const match = (ARTICLES_STATE.depts || []).find(d => String(d.dept_id) === wantId);
      document.getElementById("a-dept").value = match
        ? `${match.dept_id} · ${match.short_name || match.dept_name || match.full_name || ""}`
        : wantId;
    } else {
      document.getElementById("a-dept").value = "";
    }
    ARTICLES_STATE.pendingDept = null;
  }
  document.getElementById("a-site").onchange = () => {
    _populateArticleTargetSelect();
    searchArticles();
  };
  document.getElementById("a-target").onchange = searchArticles;
  // Trigger search when the user picks from the datalist (browsers fire
  // both 'input' and 'change' for that) or hits Enter. We don't auto-search
  // on every keystroke — that hammers the API while the user is still
  // typing.
  const deptInput = document.getElementById("a-dept");
  deptInput.onchange = searchArticles;
  deptInput.onkeydown = (e) => { if (e.key === "Enter") searchArticles(); };
  document.getElementById("a-status").onchange = searchArticles;
  searchArticles();   // single initial fetch — uses the pre-filled filters
};

function _toggleArticleAdvancedFilters() {
  document.getElementById("a-advanced")?.classList.toggle("hidden");
}

// Build the target_code <select> options from the cached sites payload.
// Filters by the currently selected site_code; when site is "" we list every
// target with its site_code prefix so the operator can still filter
// without first narrowing the site.
function _populateArticleTargetSelect() {
  const sel = document.getElementById("a-target");
  if (!sel) return;
  const prevValue = sel.value;
  const siteCode = (document.getElementById("a-site")?.value || "").trim();
  const sites = ARTICLES_STATE.sites || [];

  const opts = [`<option value="">所有 target</option>`];
  if (siteCode) {
    const site = sites.find(s => s.site_code === siteCode);
    const targets = (site && site.targets) || [];
    // Newest-first so just-created targets show up at the top
    targets.slice().sort((a, b) => {
      const ta = a.created_at ? Date.parse(a.created_at) : 0;
      const tb = b.created_at ? Date.parse(b.created_at) : 0;
      return tb - ta;
    });
    for (const t of targets) {
      const label = t.target_name ? `${t.target_code} · ${t.target_name}` : t.target_code;
      opts.push(`<option value="${esc(t.target_code)}">${esc(label)}</option>`);
    }
  } else {
    // All sites — show every target, grouped via <optgroup> for scanability
    for (const s of sites) {
      const targets = s.targets || [];
      if (!targets.length) continue;
      opts.push(`<optgroup label="${esc(s.site_code)} · ${esc(s.site_name || "")}">`);
      for (const t of targets) {
        const label = t.target_name ? `${t.target_code} · ${t.target_name}` : t.target_code;
        opts.push(`<option value="${esc(t.target_code)}">${esc(label)}</option>`);
      }
      opts.push(`</optgroup>`);
    }
  }
  sel.innerHTML = opts.join("");
  // Preserve prior selection if still valid under the new site filter
  if (prevValue && [...sel.options].some(o => o.value === prevValue)) {
    sel.value = prevValue;
  } else {
    sel.value = "";
  }
}
// Build the filter querystring once. Used by both the page-fetch and the
// "select all matching" path that hits /search/ids.
function _articleFilterQS() {
  const q = document.getElementById("a-q").value.trim();
  const site = document.getElementById("a-site").value.trim();
  const target = document.getElementById("a-target").value.trim();
  let deptRaw = (document.getElementById("a-dept")?.value || "").trim();
  let dept = "";
  if (deptRaw) {
    const m = deptRaw.match(/^(\d+)/);
    dept = m ? m[1] : deptRaw;
  }
  const status = document.getElementById("a-status").value;
  const qs = new URLSearchParams();
  if (q) qs.set("q", q);
  if (site) qs.set("site", site);
  if (target) qs.set("target", target);
  if (dept) qs.set("dept", dept);
  if (status) qs.set("status", status);
  const fields = [
    ["a-article-id", "article_id"],
    ["a-native-post-id", "native_post_id"],
    ["a-url", "url"],
    ["a-url-hash", "url_hash"],
    ["a-title", "title"],
    ["a-doc-no", "doc_no"],
    ["a-index-no", "index_no"],
    ["a-publisher", "publisher"],
    ["a-source-raw", "source_raw"],
    ["a-channel-name", "channel_name"],
    ["a-channel-path", "channel_path"],
    ["a-content-category", "content_category"],
    ["a-content-subcategory", "content_subcategory"],
    ["a-open-category", "open_category"],
    ["a-topic-words", "topic_words"],
    ["a-has-attachment", "has_attachment"],
    ["a-is-effective", "is_effective"],
    ["a-fetch-strategy", "fetch_strategy"],
    ["a-rag-status", "rag_export_status"],
    ["a-publish-date-from", "publish_date_from"],
    ["a-publish-date-to", "publish_date_to"],
    ["a-effective-date-from", "effective_date_from"],
    ["a-effective-date-to", "effective_date_to"],
    ["a-expiry-date-from", "expiry_date_from"],
    ["a-expiry-date-to", "expiry_date_to"],
    ["a-fetched-from", "fetched_from"],
    ["a-fetched-to", "fetched_to"],
    ["a-created-from", "created_from"],
    ["a-created-to", "created_to"],
  ];
  for (const [id, key] of fields) {
    const v = (document.getElementById(id)?.value || "").trim();
    if (v) qs.set(key, v);
  }
  return qs;
}

// Fetch a single page from the backend. With ~5K+ articles in the table
// the old "limit=5000 + client-side slice" approach silently dropped
// records past the cap and shipped a 5MB JSON every search. Now we ask
// for exactly one page and re-fetch on pager clicks.
async function searchArticles({ resetPage = true } = {}) {
  if (resetPage) {
    ARTICLES_STATE.page = 1;
    ARTICLES_STATE.selected.clear();   // filter changed → drop selection
  }
  const { page, pageSize } = ARTICLES_STATE;
  const qs = _articleFilterQS();
  qs.set("limit", String(pageSize));
  qs.set("offset", String((page - 1) * pageSize));
  const box = document.getElementById("a-results");
  box.innerHTML = `<div class="text-slate-400 text-sm">搜索中…</div>`;
  try {
    const r = await api("/api/articles/search?" + qs);
    ARTICLES_STATE.rows = r.items || [];
    ARTICLES_STATE.total = r.total || 0;
    _renderArticlesTable();
  } catch (e) {
    box.innerHTML = `<div class="card p-6 text-sm text-red-600">搜索失败：${esc(e.message)}</div>`;
  }
}

function _renderArticlesTable() {
  const { rows, page, pageSize, total } = ARTICLES_STATE;
  // rows IS the current page (server already paginated). No client slicing.
  const slice = rows;
  const box = document.getElementById("a-results");

  const visibleIds = slice.map(a => a.id);
  const allVisibleChecked = visibleIds.length > 0
    && visibleIds.every(id => ARTICLES_STATE.selected.has(id));
  const selectedCount = ARTICLES_STATE.selected.size;
  const selectedVisibleImportableCount = slice.filter(a =>
    ARTICLES_STATE.selected.has(a.id) && _isRagRetryable(a)
  ).length;

  const actionBar = selectedCount > 0 ? `
    <div class="flex items-center gap-2 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded flex-wrap">
      <span class="text-sm text-slate-700">已选 <span class="font-semibold text-red-700">${selectedCount}</span> 篇</span>
      ${selectedVisibleImportableCount ? `<span class="text-xs text-red-700">当前页可 RAG 入库 ${selectedVisibleImportableCount} 篇</span>` : ""}
      <button class="btn-outline" onclick="_selectAllArticles()">勾选全部搜索结果（${total} 篇）</button>
      <button class="btn-outline" onclick="_clearArticleSelection()">清除选择</button>
      <div style="flex:1"></div>
      <button onclick="_retrySelectedRagFailed()" class="text-sm rounded px-3 py-1.5 bg-[#1D4ED8] text-white hover:bg-blue-700">RAG 入库</button>
      <button onclick="_bulkDeleteSelected()" class="text-sm rounded px-3 py-1.5 bg-red-600 text-white hover:bg-red-700">🗑 批量删除</button>
    </div>` : "";

  box.innerHTML = card({
    title: "文章列表",
    body: `
      ${actionBar}
      <div class="overflow-x-auto">
      <table class="w-full text-sm">
        <thead class="bg-slate-50 text-slate-500 text-xs uppercase">
          <tr>
            <th class="text-center px-3 py-2" style="width:36px">
              <input type="checkbox" id="a-check-all" ${allVisibleChecked ? "checked" : ""} title="勾选当前页全部" />
            </th>
            <th class="text-left px-4 py-2">标题</th>
            <th class="px-4 py-2">site</th>
            <th class="px-4 py-2">target</th>
            <th class="px-4 py-2">部门</th>
            <th class="px-4 py-2">文号/机构</th>
            <th class="px-4 py-2">分类</th>
            <th class="px-4 py-2">状态</th>
            <th class="px-4 py-2">附件</th>
            <th class="px-4 py-2">发布时间</th>
            <th class="px-4 py-2">抓取时间</th>
            <th class="px-4 py-2">RAG</th>
            <th class="px-4 py-2 text-right">操作</th>
          </tr>
        </thead>
        <tbody>
          ${slice.map(a => `
            <tr class="border-t border-slate-100 hover:bg-slate-50/60">
              <td class="px-3 py-2 text-center">
                <input type="checkbox" class="a-check"
                       data-id="${a.id}"
                       ${ARTICLES_STATE.selected.has(a.id) ? "checked" : ""} />
              </td>
              <td class="px-4 py-2 max-w-[420px] truncate">
                <button onclick="openArticleModal(${a.id})" class="text-slate-900 hover:underline text-left" title="查看采集到的正文">${esc(a.title || "(无标题)")}</button>
              </td>
              <td class="px-4 py-2 text-xs mono text-slate-500 text-center">${esc(a.site_code || "")}</td>
              <td class="px-4 py-2 text-xs mono text-slate-500 text-center">${esc(a.target_code || "")}</td>
              <td class="px-4 py-2 text-xs text-center" title="${a.dept_id ? esc((a.dept_name || "") + " (" + a.dept_id + ")") : ""}">
                ${a.dept_id
                  ? `<span class="text-slate-700">${esc(a.dept_short_name || a.dept_name || "—")}</span><div class="text-[10px] text-slate-400 mono">${a.dept_id}</div>`
                  : `<span class="text-slate-300">—</span>`}
              </td>
              <td class="px-4 py-2 text-xs text-center max-w-[220px] truncate" title="${esc([a.doc_no, a.publisher].filter(Boolean).join(' · '))}">
                <div class="text-slate-700 truncate">${esc(a.doc_no || "—")}</div>
                <div class="text-[10px] text-slate-400 truncate">${esc(a.publisher || "")}</div>
              </td>
              <td class="px-4 py-2 text-xs text-center max-w-[220px] truncate" title="${esc([a.content_category, a.content_subcategory, a.open_category].filter(Boolean).join(' / '))}">
                ${esc([a.content_category, a.content_subcategory].filter(Boolean).join(" / ") || a.open_category || "—")}
              </td>
              <td class="px-4 py-2 text-center">${a.status === "ready" ? pill("ready", "green") : pill(a.status || "?", "slate")}</td>
              <td class="px-4 py-2 text-center">${a.has_attachment ? "📎" : ""}</td>
              <td class="px-4 py-2 text-center mono text-xs text-slate-500">${fmt(a.publish_time)}</td>
              <td class="px-4 py-2 text-center mono text-xs text-slate-500">${fmt(a.fetched_at)}</td>
              <td class="px-4 py-2 text-center">${_ragStatusCell(a)}</td>
              <td class="px-4 py-2 text-right">
                <button onclick="openArticleAttributesModal(${a.id})" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-700 hover:bg-slate-50">属性</button>
                <button onclick="deleteArticle(${a.id}, '${esc((a.title || '').slice(0, 40))}', ${!!a.has_attachment})" class="text-xs px-2 py-1 rounded border border-red-200 text-red-600 hover:bg-red-50">删除</button>
              </td>
            </tr>`).join("") || `<tr><td colspan="13" class="p-8 text-center text-slate-400 text-sm">无匹配</td></tr>`}
        </tbody>
      </table>
      </div>
      <div id="a-pager"></div>`
  });

  document.querySelectorAll(".a-check").forEach(el => {
    el.onchange = (e) => {
      const id = Number(e.target.dataset.id);
      if (e.target.checked) ARTICLES_STATE.selected.add(id);
      else ARTICLES_STATE.selected.delete(id);
      _renderArticlesTable();
    };
  });
  const allEl = document.getElementById("a-check-all");
  if (allEl) {
    allEl.onchange = (e) => {
      for (const id of visibleIds) {
        if (e.target.checked) ARTICLES_STATE.selected.add(id);
        else ARTICLES_STATE.selected.delete(id);
      }
      _renderArticlesTable();
    };
  }

  renderPager({
    container: document.getElementById("a-pager"),
    total, page, pageSize,
    summary: `共 ${total} 篇`,
    // Page change re-fetches the new offset from server (resetPage=false
    // keeps the existing selection set across pages).
    onPage: p => { ARTICLES_STATE.page = p; searchArticles({ resetPage: false }); },
  });
}

function _isRagRetryable(a) {
  return a && !a.exported_to_rag_at && ["", "failed", "pending"].includes(a.rag_export_status || "");
}

function _ragStatusCell(a) {
  if (a.exported_to_rag_at) return pill("完成", "green");
  const status = a.rag_export_status || "";
  if (status === "failed") {
    const title = a.rag_export_error ? ` title="${esc(a.rag_export_error)}"` : "";
    return `<span${title}>${pill("失败", "red")}</span>`;
  }
  if (status === "running") return pill("入库中", "blue");
  if (status === "pending") return pill("待重试", "amber");
  return pill("待", "amber");
}

// Select every article matching the current filter (NOT just the current
// page). Hits the lightweight ids-only endpoint so we don't pull row data.
async function _selectAllArticles() {
  try {
    const r = await api("/api/articles/search/ids?" + _articleFilterQS());
    for (const id of (r.ids || [])) ARTICLES_STATE.selected.add(id);
    toast(`已勾选 ${r.total} 篇（含其他页）`, "ok");
    _renderArticlesTable();
  } catch (e) {
    toast("勾选全部失败：" + e.message, "err");
  }
}
function _clearArticleSelection() {
  ARTICLES_STATE.selected.clear();
  _renderArticlesTable();
}

async function _importAllRagArticles() {
  if (!confirm("确认对全部文章中待入库、入库失败的 ready 文章执行 RAG 入库？\n\n这个操作不依赖当前勾选项，也不受当前分页影响。")) return;
  try {
    const r = await api("/api/articles/rag-import-all", { method: "POST" });
    if (r.queued) {
      toast(`已提交全部入库任务，预计处理 ${r.eligible_count} 篇`, "ok");
    } else {
      toast("没有待入库或入库失败的文章", "ok");
    }
    searchArticles({ resetPage: false });
  } catch (e) {
    toast(e.message || "全部入库提交失败", "err");
  }
}

async function _retrySelectedRagFailed() {
  const ids = Array.from(ARTICLES_STATE.selected);
  if (!ids.length) { toast("请先勾选文章", "err"); return; }
  if (!confirm(`确认对选中的 ${ids.length} 篇文章执行 RAG 入库？\n\n未入库、入库失败的 ready 文章会提交；已完成、入库中或文章未 ready 的条目会自动跳过。`)) return;
  try {
    const r = await api("/api/articles/rag-retry", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ article_ids: ids }),
    });
    toast(`已提交 ${r.queued_count} 篇 RAG 入库，跳过 ${r.skipped_count} 篇`, r.queued_count ? "ok" : "err");
    ARTICLES_STATE.selected.clear();
    searchArticles({ resetPage: false });
  } catch (e) {
    toast(e.message || "RAG 入库提交失败", "err");
  }
}

async function _retryArticleRagFailed(id) {
  try {
    const r = await api("/api/articles/rag-retry", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ article_ids: [id] }),
    });
    toast(`已提交 ${r.queued_count} 篇 RAG 入库`, r.queued_count ? "ok" : "err");
    closeModal();
    searchArticles({ resetPage: false });
  } catch (e) {
    toast(e.message || "RAG 入库提交失败", "err");
  }
}

async function _refetchArticleNow(id) {
  if (!confirm(`确认立即重新抓取 a_${id}？\n旧数据会先保留，只有新抓取成功后才替换并清理旧缓存文件。`)) return;
  try {
    toast(`正在重新抓取 a_${id}...`, "ok");
    const r = await api(`/api/articles/${id}/refetch`, { method: "POST" });
    const result = r.result || {};
    const status = result.status || "unknown";
    const newId = result.article_id || id;
    if (status === "ready") {
      toast(`重新抓取完成：a_${newId}`, "ok");
    } else {
      toast(`重新抓取完成，状态：${status}${result.reason ? " · " + result.reason : ""}`, status === "failed" ? "err" : "ok");
    }
    closeModal();
    searchArticles({ resetPage: false });
  } catch (e) {
    toast(e.message || "重新抓取失败", "err");
  }
}

async function _bulkDeleteSelected() {
  const ids = Array.from(ARTICLES_STATE.selected);
  if (!ids.length) { toast("请先勾选文章", "err"); return; }
  if (!confirm(`确认删除 ${ids.length} 篇文章？\n会一并清掉它们的附件和 raw_html / text 文件，不可恢复。`)) return;
  try {
    const r = await api("/api/articles/bulk-delete", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ article_ids: ids }),
    });
    toast(`已删除 ${r.deleted_count} 篇 · 清理文件 ${r.files_removed} 个`, "ok");
    ARTICLES_STATE.selected.clear();
    searchArticles();
  } catch (e) { toast(e.message || "批量删除失败", "err"); }
}

// ---------- Article detail modal (pencil DMPvp) ----------
async function openArticleModal(id) {
  const loadingBody = `<div class="p-10 text-center text-slate-400 text-sm">加载文章中…</div>`;
  openModal({
    title: "文章详情",
    subtitle: `article_id = a_${id}`,
    body: loadingBody,
    footer: `<button onclick="closeModal()" class="btn-ghost">关闭</button>`,
    wide: true,
  });
  try {
    // Use admin namespace so the request inherits the dashboard's
    // Basic Auth realm (/admin/*). Hitting the public /api/articles/<id>
    // directly would fall under _RagTokenGate's Bearer realm, which the
    // browser-cached admin Basic creds don't auto-flow into.
    const a = await api(`/api/articles/${id}`);
    _renderArticleModal(a);
  } catch (e) {
    document.querySelector(".modal-body").innerHTML =
      `<div class="p-6 text-sm text-red-600">加载失败：${esc(e.message)}</div>`;
  }
}

// Render one label/value row inside the 政府信息公开要素 grid
function _gokLine(label, value) {
  const v = value == null || value === "" ? "—" : value;
  return `<div class="flex gap-2"><span class="text-slate-500 w-20 shrink-0">${esc(label)}</span><span class="text-slate-800 truncate" title="${esc(String(v))}">${esc(String(v))}</span></div>`;
}

function _articleEffectiveLabel(a) {
  if (a.is_effective == null) return null;
  const publicMeta = (a.metadata_json && typeof a.metadata_json === "object")
    ? (a.metadata_json.public_meta || {})
    : {};
  const validityLabel = String(publicMeta.validity_state_label || a.metadata_json?.validity_state_label || "").trim();
  if (validityLabel) return validityLabel;
  const status = String(a.open_category || "").trim();
  if (!a.is_effective && status) {
    if (status.includes("即将实施")) return "即将实施";
    if (status.includes("尚未生效")) return "尚未生效";
    if (status.includes("废止")) return "已废止";
    if (status.includes("被代替")) return "已被代替";
  }
  return a.is_effective ? "有效" : "失效";
}

function _renderArticleModal(a) {
  const metaCard = (label, value) => `
    <div class="flex-1 rounded-lg border border-[#E5EBF1] bg-[#F8FAFC] p-3 min-w-0">
      <div class="text-[11px] text-slate-500">${esc(label)}</div>
      <div class="text-[13px] text-slate-900 font-medium mt-0.5 truncate">${value}</div>
    </div>`;
  const statusPill = a.status === "ready" ? pill("ready", "green") : pill(a.status || "—", "slate");
  const ragPill = _ragStatusCell(a);
  const content = (a.content_text || "").trim();
  const paragraphs = content ? content.split(/\n+/) : [];
  const contentBlock = !content
    ? `<div class="text-slate-400 text-sm">正文为空（未入库）</div>`
    : paragraphs.map(p =>
        `<p class="text-[13px] text-slate-800 leading-relaxed">${esc(p)}</p>`
      ).join("")
      + `<div class="text-[11px] text-slate-400 mt-2">（共 ${paragraphs.length} 段 · ${content.length} 字）</div>`;
  const atts = (a.attachments || []).map(att => `
    <div class="flex items-center gap-3 border border-[#E5EBF1] rounded-md bg-white px-3 py-2">
      <span class="lu text-slate-500" style="font-size:16px">&#xe0d0;</span>
      <div class="flex-1 min-w-0">
        <div class="text-[13px] text-slate-800 truncate">${esc(att.file_name || "(未命名)")}</div>
        <div class="text-[11px] text-slate-400 mono">${esc(att.file_ext || "—")}${att.size_bytes ? " · " + _humanSize(att.size_bytes) : ""}</div>
      </div>
      <a href="/admin/api/articles/${a.id}/attachments/${att.id}" target="_blank" class="btn-outline text-xs">下载</a>
    </div>`).join("");
  const standardMeta = a.standard_meta ? `
        <div class="rounded-lg border border-[#DCE8F6] bg-[#F8FBFF] p-4 mb-4">
          <div class="text-[11px] text-slate-500 font-semibold mb-3">标准结构化元数据</div>
          <div class="grid grid-cols-2 gap-x-6 gap-y-2 text-[13px]">
            ${_gokLine("标准号", a.standard_meta.standard_no)}
            ${_gokLine("标准状态", a.standard_meta.standard_status)}
            ${_gokLine("中文名称", a.standard_meta.chinese_title)}
            ${_gokLine("英文名称", a.standard_meta.english_title)}
            ${_gokLine("CCS", (a.standard_meta.ccs_codes || []).join("; "))}
            ${_gokLine("ICS", (a.standard_meta.ics_codes || []).join("; "))}
            ${_gokLine("发布日期", a.standard_meta.publish_date)}
            ${_gokLine("实施日期", a.standard_meta.implementation_date)}
            ${_gokLine("主管部门", a.standard_meta.competent_department)}
            ${_gokLine("归口部门", a.standard_meta.technical_committee)}
            ${_gokLine("发布单位", a.standard_meta.issuing_body)}
            ${_gokLine("标准类型", a.standard_meta.standard_type)}
          </div>
        </div>` : "";
  const header = `
    <div>
      <div class="text-[16px] font-semibold text-slate-900 leading-snug">${esc(a.title || "(无标题)")}</div>
      <div class="text-[12px] text-slate-500 mt-1">article_id = a_${a.id}${a.exported_to_rag_at ? " · 已入库 RAG " + fmt(a.exported_to_rag_at) : ""}</div>
    </div>`;
  const openstdDownloadButton = a.site_code === "openstd_samr"
    ? `<button class="btn-outline" onclick="_startOpenstdAttachmentDownload(${a.id})">下载正文附件</button>`
    : "";
  // Replace entire modal dialog (title area was generic)
  const backdrop = document.getElementById("modal-backdrop");
  if (!backdrop) return;
  backdrop.innerHTML = `
    <div class="modal-dialog wide" onclick="event.stopPropagation()">
      <div class="modal-header">
        ${header}
        <div class="flex gap-2 items-center">
          ${_isRagRetryable(a) ? `<button class="btn-outline" onclick="_retryArticleRagFailed(${a.id})">RAG 入库</button>` : ""}
          ${openstdDownloadButton}
          <button class="btn-outline" onclick="_refetchArticleNow(${a.id})">重新抓取</button>
          <div class="modal-close" onclick="closeModal()">✕</div>
        </div>
      </div>
      <div class="modal-body">
        <div class="flex gap-3 mb-4">
          ${metaCard("来源", esc((a.site_code || "—") + (a.target_code ? " · " + a.target_code : "")))}
          ${metaCard("发布日期", esc(fmt(a.publish_time) || a.publish_date || "—"))}
          ${metaCard("栏目路径", esc(a.channel_path || a.channel_name || "—"))}
          ${metaCard("状态", `${statusPill} ${ragPill}`)}
        </div>
        ${a.rag_export_error ? `<div class="rounded-lg border border-red-200 bg-red-50 p-3 mb-4 text-[12px] text-red-700">RAG 入库失败：${esc(a.rag_export_error)}</div>` : ""}
        ${standardMeta}
        <div class="rounded-lg border border-[#E5EBF1] bg-[#F8FAFC] p-4 mb-4">
          <div class="text-[11px] text-slate-500 font-semibold mb-3">政府信息公开要素</div>
          <div class="grid grid-cols-2 gap-x-6 gap-y-2 text-[13px]">
            ${_gokLine("索引号", a.index_no)}
            ${_gokLine("发布机构", a.publisher)}
            ${_gokLine("文号", a.doc_no)}
            ${_gokLine("主题词", a.topic_words)}
            ${_gokLine(a.site_code === "openstd_samr" ? "标准状态" : "主题分类", a.open_category || [a.content_category, a.content_subcategory].filter(Boolean).join("\\\\"))}
            ${_gokLine("成文日期", a.publish_date)}
            ${_gokLine("生效日期", a.effective_date)}
            ${_gokLine("是否有效", _articleEffectiveLabel(a))}
            ${_gokLine("失效日期", a.expiry_date)}
            ${_gokLine("抓取时间", fmt(a.fetched_at))}
            ${_gokLine("article_id", "a_" + a.id)}
          </div>
        </div>
        <div class="rounded-lg border border-[#E5EBF1] bg-white p-5">
          <div class="flex items-center justify-between mb-2">
            <div class="text-[11px] text-slate-500 font-semibold">正文预览</div>
            <div class="flex gap-2">
              ${a.raw_html_path ? `<a class="btn-outline" target="_blank" href="/admin/api/articles/${a.id}/raw-html">查看原始 HTML</a>` : ""}
              ${a.text_path ? `<a class="btn-outline" target="_blank" href="/admin/api/articles/${a.id}/text">下载文本缓存</a>` : ""}
            </div>
          </div>
          <div class="space-y-2">${contentBlock}</div>
        </div>
        ${atts ? `
          <div class="rounded-lg border border-[#E5EBF1] bg-[#F8FAFC] p-4 mt-4">
            <div class="text-[11px] text-slate-500 font-semibold mb-2">附件（${a.attachments.length}）</div>
            <div class="space-y-2">${atts}</div>
          </div>` : ""}
      </div>
      <div class="modal-footer" style="justify-content:space-between">
        <div class="text-[11px] text-slate-400 truncate max-w-[60%]" title="${esc(a.url || "")}">原文 URL：${esc(a.url || "—")}</div>
        <div class="flex gap-2">
          <a class="btn-outline" target="_blank" href="${esc(a.url || "#")}">打开原文</a>
          <button class="btn-ghost" onclick="closeModal()">关闭</button>
        </div>
      </div>
    </div>`;
  backdrop.firstElementChild?.addEventListener("click", e => e.stopPropagation());
}

async function _startOpenstdAttachmentDownload(articleId) {
  try {
    const r = await api(`/api/articles/${articleId}/openstd-download/start`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "{}",
    });
    _showOpenstdCaptchaModal(articleId, r.session_id, r.captcha_url);
  } catch (e) {
    toast(e.message || "标准正文附件下载会话创建失败", "err");
  }
}

function _showOpenstdCaptchaModal(articleId, sessionId, captchaUrl) {
  const src = `${captchaUrl}?_=${Date.now()}`;
  openModal({
    title: "下载标准正文附件",
    subtitle: "输入验证码后，服务器会自动下载文件并写入附件表",
    body: `
      <div class="p-5 space-y-4">
        <div class="text-sm text-slate-600">请识别下方验证码。提交成功后会自动回到文章详情。</div>
        <div class="flex items-center gap-3">
          <input id="openstd-captcha-input" maxlength="4" autocomplete="off"
                 class="border border-slate-300 rounded px-3 py-2 text-lg tracking-[0.25em] w-32 text-center"
                 onkeydown="if(event.key==='Enter'){_submitOpenstdCaptcha(${articleId}, '${esc(sessionId)}', '${esc(captchaUrl)}')}"/>
          <img id="openstd-captcha-img" src="${esc(src)}" class="border border-slate-200 rounded h-12 bg-white cursor-pointer"
               onclick="this.src='${esc(captchaUrl)}?_='+Date.now()" title="点击换一张"/>
          <button class="btn-outline" onclick="document.getElementById('openstd-captcha-img').src='${esc(captchaUrl)}?_='+Date.now()">换一张</button>
        </div>
        <div id="openstd-captcha-error" class="text-xs text-red-600"></div>
      </div>`,
    footer: `
      <button class="btn-ghost" onclick="closeModal()">取消</button>
      <button class="btn-primary" onclick="_submitOpenstdCaptcha(${articleId}, '${esc(sessionId)}', '${esc(captchaUrl)}')">提交并下载</button>`,
  });
  setTimeout(() => document.getElementById("openstd-captcha-input")?.focus(), 50);
}

async function _submitOpenstdCaptcha(articleId, sessionId, captchaUrl) {
  const input = document.getElementById("openstd-captcha-input");
  const err = document.getElementById("openstd-captcha-error");
  const img = document.getElementById("openstd-captcha-img");
  const captcha = (input?.value || "").trim();
  if (!captcha) {
    if (err) err.textContent = "请输入验证码";
    return;
  }
  try {
    const resp = await fetch(`/admin/api/openstd-download-sessions/${sessionId}/submit`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ captcha }),
    });
    if (resp.status === 409) {
      if (err) err.textContent = "验证码不正确，请重新输入";
      if (input) input.value = "";
      if (img) img.src = `${captchaUrl}?_=${Date.now()}`;
      input?.focus();
      return;
    }
    if (!resp.ok) {
      const body = await resp.text().catch(() => "");
      throw new Error(`${resp.status} ${resp.statusText} — ${body.slice(0, 160)}`);
    }
    const r = await resp.json();
    toast(`已下载附件：${r.attachment?.file_name || ""}`, "ok");
    await openArticleModal(articleId);
  } catch (e) {
    if (err) err.textContent = e.message || "下载失败";
    toast(e.message || "下载失败", "err");
  }
}

async function openArticleAttributesModal(id) {
  openModal({
    title: "文章业务属性",
    subtitle: `article_id = a_${id}`,
    wide: true,
    body: `<div class="p-8 text-center text-slate-400 text-sm">加载属性中…</div>`,
  });
  try {
    const a = await api(`/api/articles/${id}`);
    const value = v => {
      if (v == null || v === "") return "—";
      if (typeof v === "object") return JSON.stringify(v, null, 2);
      return String(v);
    };
    const row = (label, key, val) => `
      <tr class="border-t border-slate-100">
        <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">${esc(label)}</td>
        <td class="px-3 py-2 text-xs mono text-slate-400 whitespace-nowrap">${esc(key)}</td>
        <td class="px-3 py-2 text-xs text-slate-800 break-all whitespace-pre-wrap">${esc(value(val))}</td>
      </tr>`;
    const rows = [
      ["article_id", "id", a.id],
      ["site_id", "site_id", a.site_id],
      ["site_code", "site_code", a.site_code],
      ["target_id", "target_id", a.target_id],
      ["target_code", "target_code", a.target_code],
      ["dept_id", "dept_id", a.dept_id],
      ["native_post_id", "native_post_id", a.native_post_id],
      ["url", "url", a.url],
      ["url_hash", "url_hash", a.url_hash],
      ["标题", "title", a.title],
      ["发布时间", "publish_time", a.publish_time],
      ["来源", "source_raw", a.source_raw],
      ["栏目名", "channel_name", a.channel_name],
      ["栏目路径", "channel_path", a.channel_path],
      ["一级分类", "content_category", a.content_category],
      ["二级分类", "content_subcategory", a.content_subcategory],
      ["索引号", "index_no", a.index_no],
      ["文号", "doc_no", a.doc_no],
      ["发布机构", "publisher", a.publisher],
      ["成文日期", "publish_date", a.publish_date],
      ["生效日期", "effective_date", a.effective_date],
      ["是否有效", "is_effective", _articleEffectiveLabel(a)],
      ["失效日期", "expiry_date", a.expiry_date],
      ["主题词", "topic_words", a.topic_words],
      ["公开分类", "open_category", a.open_category],
      ["标准结构化元数据", "standard_meta", a.standard_meta],
      ["公开元数据", "metadata_json", a.metadata_json],
      ["正文长度", "content_text_len", a.content_text_len],
      ["正文预览", "content_text", (a.content_text || "").slice(0, 1200)],
      ["是否有附件", "has_attachment", a.has_attachment ? "是" : "否"],
      ["状态", "status", a.status],
      ["抓取策略", "fetch_strategy", a.fetch_strategy],
      ["抓取时间", "fetched_at", a.fetched_at],
      ["RAG 入库时间", "exported_to_rag_at", a.exported_to_rag_at],
      ["RAG 状态", "rag_export_status", a.rag_export_status],
      ["RAG 开始时间", "rag_export_started_at", a.rag_export_started_at],
      ["RAG 完成时间", "rag_export_finished_at", a.rag_export_finished_at],
      ["RAG 错误", "rag_export_error", a.rag_export_error],
      ["RAG task ids", "rag_export_task_ids", a.rag_export_task_ids],
      ["创建时间", "created_at", a.created_at],
      ["更新时间", "updated_at", a.updated_at],
    ].map(([label, key, val]) => row(label, key, val)).join("");
    openModal({
      title: "文章业务属性",
      subtitle: `article_id = a_${id} · 可按这些字段在业务条件中查询`,
      wide: true,
      body: `
        <table class="w-full text-sm">
          <thead class="bg-slate-50 text-slate-500 text-xs">
            <tr>
              <th class="text-left px-3 py-2" style="width:130px">属性</th>
              <th class="text-left px-3 py-2" style="width:170px">字段</th>
              <th class="text-left px-3 py-2">值</th>
            </tr>
          </thead>
          <tbody>${rows}</tbody>
        </table>`,
      footer: `<button onclick="closeModal()" class="btn-ghost">关闭</button>`,
    });
  } catch (e) {
    document.querySelector(".modal-body").innerHTML =
      `<div class="p-6 text-sm text-red-600">加载失败：${esc(e.message)}</div>`;
  }
}

async function deleteArticle(id, titleSnippet, hasAttachment) {
  const msg = `确认删除文章？\n\n  ${titleSnippet || "(无标题)"}` +
    (hasAttachment ? "\n\n⚠ 该文章包含附件 — 附件 DB 行 + 磁盘文件会一并删除。" : "") +
    "\n\n原始 HTML 快照与文本缓存也会被清理。article 删除后再次抓取同一 URL 可正常入库。";
  if (!confirm(msg)) return;
  try {
    const r = await api(`/api/articles/${id}`, { method: "DELETE" });
    toast(`已删除 article=${id}${r.attachments_removed ? ` · 含 ${r.attachments_removed} 个附件` : ""}`);
    // Remove from local state without refetch
    ARTICLES_STATE.rows = ARTICLES_STATE.rows.filter(x => x.id !== id);
    _renderArticlesTable();
  } catch (e) { toast(e.message, "err"); }
}
