#!/usr/bin/env python3
"""Build a full HTML data browser from every sheet in the tender entity workbook."""

from __future__ import annotations

import json
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any

from openpyxl import load_workbook


ROOT = Path(__file__).resolve().parents[1]
SOURCE_XLSX = ROOT / "docs" / "deliverables" / "20260617-raw-excel-entity-tables" / "原始Excel招投标实体抽取表.xlsx"
OUT_DIR = ROOT / "docs" / "deliverables" / "20260617-full-excel-data-browser"
OUT_HTML = OUT_DIR / "招投标实体全量数据浏览.html"


def clean(value: Any) -> str | int | float:
    if value is None:
        return ""
    if isinstance(value, datetime):
        return value.strftime("%Y-%m-%d")
    if isinstance(value, (int, float)):
        return value
    text = str(value).strip()
    return "" if text in {"--", "None", "nan"} else text


def read_workbook() -> dict[str, Any]:
    wb = load_workbook(SOURCE_XLSX, read_only=False, data_only=True)
    sheets: dict[str, Any] = {}
    for ws in wb.worksheets:
        headers = [str(clean(cell.value)) for cell in ws[2]]
        headers = [header for header in headers if header]
        rows: list[dict[str, Any]] = []
        hyperlinks: dict[str, str] = {}
        for excel_row in range(3, ws.max_row + 1):
            item: dict[str, Any] = {}
            for col_index, header in enumerate(headers, 1):
                cell = ws.cell(excel_row, col_index)
                value = clean(cell.value)
                item[header] = value
                if cell.hyperlink and cell.hyperlink.target:
                    hyperlinks[f"{len(rows)}::{header}"] = cell.hyperlink.target
            if any(str(value).strip() for value in item.values()):
                rows.append(item)
        sheets[ws.title] = {
            "headers": headers,
            "rows": rows,
            "rowCount": len(rows),
            "hyperlinks": hyperlinks,
        }
    wb.close()
    return sheets


def as_float(value: Any) -> float:
    if isinstance(value, (int, float)):
        return float(value)
    try:
        return float(str(value).replace(",", ""))
    except ValueError:
        return 0.0


def first(row: dict[str, Any], *keys: str) -> str:
    for key in keys:
        value = row.get(key, "")
        if value != "":
            return str(value)
    return ""


def build_indexes(sheets: dict[str, Any]) -> dict[str, Any]:
    projects = sheets.get("招投标项目表", {}).get("rows", [])
    buyers = sheets.get("招标公司表", {}).get("rows", [])
    bidders = sheets.get("投标公司表", {}).get("rows", [])
    products = sheets.get("投标产品表", {}).get("rows", [])
    qualifications = sheets.get("投标公司资质表", {}).get("rows", [])
    relations = sheets.get("招标-投标关系表", {}).get("rows", [])
    contacts = sheets.get("联系人表", {}).get("rows", [])
    sources = sheets.get("来源链接表", {}).get("rows", [])
    regions = sheets.get("地区统计表", {}).get("rows", [])

    projects_by_id = {first(row, "项目ID"): row for row in projects if first(row, "项目ID")}
    buyers_by_id = {first(row, "招标公司ID"): row for row in buyers if first(row, "招标公司ID")}
    bidders_by_id = {first(row, "投标公司ID"): row for row in bidders if first(row, "投标公司ID")}

    def group_by(rows: list[dict[str, Any]], key: str) -> dict[str, list[dict[str, Any]]]:
        groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
        for row in rows:
            value = first(row, key)
            if value:
                groups[value].append(row)
        return groups

    province_counter = Counter(first(row, "发布省份") or "未识别" for row in projects)
    demand_counter = Counter(first(row, "需求类型") or "未识别" for row in projects)
    client_counter = Counter(first(row, "客户类型") or "未识别" for row in projects)
    amount_total = sum(as_float(row.get("中标金额_万元")) for row in projects)
    source_url_count = sum(1 for row in sources if first(row, "来源链接"))
    todo_qualification_count = sum(1 for row in qualifications if first(row, "状态") == "待补抓")

    top_projects = sorted(
        [
            {
                "projectId": first(row, "项目ID"),
                "name": first(row, "项目名称"),
                "amountWan": as_float(row.get("中标金额_万元")),
                "buyer": first(row, "招标公司"),
                "bidder": first(row, "投标公司/中标单位"),
            }
            for row in projects
        ],
        key=lambda row: row["amountWan"],
        reverse=True,
    )[:30]

    return {
        "projectsById": projects_by_id,
        "buyersById": buyers_by_id,
        "biddersById": bidders_by_id,
        "productsByProject": group_by(products, "项目ID"),
        "relationsByProject": group_by(relations, "项目ID"),
        "sourcesByProject": group_by(sources, "项目ID"),
        "contactsByProject": group_by(contacts, "来源项目ID"),
        "qualificationsByBidder": group_by(qualifications, "投标公司ID"),
        "relationsByBuyer": group_by(relations, "招标公司ID"),
        "relationsByBidder": group_by(relations, "投标公司ID"),
        "projectsByBuyer": group_by(projects, "招标公司ID"),
        "projectsByBidder": group_by(projects, "投标公司ID"),
        "regions": regions,
        "summary": {
            "sheetCount": len(sheets),
            "rowCount": sum(sheet["rowCount"] for sheet in sheets.values()),
            "projectCount": len(projects),
            "buyerCount": len(buyers),
            "bidderCount": len(bidders),
            "productCount": len(products),
            "relationCount": len(relations),
            "contactCount": len(contacts),
            "sourceCount": len(sources),
            "sourceUrlCount": source_url_count,
            "todoQualificationCount": todo_qualification_count,
            "totalAmountWan": round(amount_total, 4),
        },
        "charts": {
            "province": province_counter.most_common(),
            "demand": demand_counter.most_common(),
            "client": client_counter.most_common(),
            "topProjects": top_projects,
        },
    }


def build_payload() -> dict[str, Any]:
    sheets = read_workbook()
    return {
        "meta": {
            "source": str(SOURCE_XLSX.relative_to(ROOT)),
            "generatedAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        },
        "sheets": sheets,
        "indexes": build_indexes(sheets),
    }


def html_escape_json(payload: dict[str, Any]) -> str:
    return json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")


HTML_TEMPLATE = """<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>招投标实体全量数据浏览</title>
  <style>
    :root {
      color-scheme: light;
      --page: #f5f7fa;
      --panel: #ffffff;
      --ink: #17212b;
      --muted: #617084;
      --line: #d8e1ea;
      --accent: #0f766e;
      --accent-ink: #ffffff;
      --soft: #e8f4f2;
      --warn: #9a5b00;
      --warn-soft: #fff3d8;
      --radius: 8px;
    }
    * { box-sizing: border-box; }
    html { scroll-behavior: smooth; }
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
      color: var(--ink);
      background: var(--page);
      line-height: 1.55;
    }
    a { color: var(--accent); text-decoration: none; }
    a:hover { text-decoration: underline; }
    .shell {
      width: min(1480px, calc(100% - 36px));
      margin: 0 auto;
      padding: 28px 0 56px;
    }
    header {
      display: grid;
      grid-template-columns: minmax(0, 1.2fr) minmax(340px, .8fr);
      gap: 22px;
      align-items: stretch;
      margin-bottom: 18px;
    }
    .hero, .panel {
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: var(--radius);
    }
    .hero { padding: 24px; }
    .hero h1 {
      margin: 0;
      font-size: clamp(28px, 4vw, 48px);
      line-height: 1.12;
      letter-spacing: 0;
    }
    .hero p {
      max-width: 780px;
      margin: 14px 0 0;
      color: var(--muted);
      font-size: 16px;
    }
    .summary {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 10px;
      padding: 14px;
    }
    .metric {
      min-height: 86px;
      padding: 14px;
      border: 1px solid var(--line);
      border-radius: var(--radius);
      background: #fbfcfd;
    }
    .metric strong {
      display: block;
      font-size: 24px;
      line-height: 1.1;
    }
    .metric span {
      display: block;
      margin-top: 8px;
      color: var(--muted);
      font-size: 13px;
    }
    .toolbar {
      position: sticky;
      top: 0;
      z-index: 20;
      display: grid;
      grid-template-columns: minmax(260px, 1fr) auto;
      gap: 12px;
      padding: 12px;
      margin-bottom: 18px;
      background: rgb(245 247 250 / .94);
      border: 1px solid var(--line);
      border-radius: var(--radius);
      backdrop-filter: blur(10px);
    }
    input, select, button {
      font: inherit;
      border-radius: 6px;
    }
    input, select {
      width: 100%;
      min-height: 40px;
      padding: 9px 11px;
      color: var(--ink);
      background: #fff;
      border: 1px solid var(--line);
      outline: none;
    }
    input:focus, select:focus, button:focus-visible {
      border-color: var(--accent);
      outline: 2px solid rgb(15 118 110 / .18);
      outline-offset: 1px;
    }
    button {
      min-height: 40px;
      padding: 8px 12px;
      color: var(--accent-ink);
      background: var(--accent);
      border: 1px solid var(--accent);
      cursor: pointer;
    }
    button.secondary {
      color: var(--accent);
      background: #fff;
      border-color: var(--line);
    }
    .nav {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      margin-bottom: 18px;
    }
    .nav a, .chip {
      display: inline-flex;
      align-items: center;
      min-height: 32px;
      padding: 6px 10px;
      color: var(--ink);
      background: #fff;
      border: 1px solid var(--line);
      border-radius: 999px;
      font-size: 13px;
      text-decoration: none;
    }
    .chip.emph, .nav a:hover {
      color: var(--accent);
      background: var(--soft);
      border-color: rgb(15 118 110 / .35);
    }
    .layout {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 420px;
      gap: 18px;
      align-items: start;
    }
    .stack { display: grid; gap: 18px; }
    .panel { overflow: hidden; }
    .panel-head {
      display: flex;
      justify-content: space-between;
      gap: 12px;
      align-items: center;
      padding: 14px 16px;
      border-bottom: 1px solid var(--line);
      background: #fbfcfd;
    }
    .panel-head h2, .panel-head h3 {
      margin: 0;
      font-size: 18px;
      letter-spacing: 0;
    }
    .panel-body { padding: 16px; }
    .detail {
      position: sticky;
      top: 86px;
      max-height: calc(100dvh - 104px);
      overflow: auto;
    }
    .empty {
      color: var(--muted);
      padding: 18px;
      background: #fbfcfd;
      border: 1px dashed var(--line);
      border-radius: var(--radius);
    }
    .kv {
      display: grid;
      grid-template-columns: 118px minmax(0, 1fr);
      gap: 7px 10px;
      font-size: 13px;
    }
    .kv b { color: #344154; }
    .kv span { color: var(--muted); word-break: break-word; }
    .subsection {
      margin-top: 16px;
      padding-top: 14px;
      border-top: 1px solid var(--line);
    }
    .subsection h4 {
      margin: 0 0 10px;
      font-size: 15px;
    }
    .mini-list {
      display: grid;
      gap: 8px;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .mini-list li {
      padding: 9px 10px;
      background: #fbfcfd;
      border: 1px solid var(--line);
      border-radius: 6px;
      font-size: 13px;
    }
    .tabs {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      padding: 12px 16px;
      border-bottom: 1px solid var(--line);
      background: #fbfcfd;
    }
    .tab {
      min-height: 34px;
      padding: 7px 11px;
      color: var(--ink);
      background: #fff;
      border: 1px solid var(--line);
      border-radius: 999px;
      cursor: pointer;
      font-size: 13px;
    }
    .tab.active {
      color: var(--accent-ink);
      background: var(--accent);
      border-color: var(--accent);
    }
    .table-wrap {
      overflow: auto;
      max-height: 680px;
    }
    table {
      width: 100%;
      border-collapse: separate;
      border-spacing: 0;
      min-width: 980px;
    }
    th, td {
      padding: 9px 10px;
      border-bottom: 1px solid #e7edf3;
      border-right: 1px solid #edf2f6;
      vertical-align: top;
      text-align: left;
      font-size: 13px;
    }
    th {
      position: sticky;
      top: 0;
      z-index: 2;
      color: #24364a;
      background: #eaf2f5;
      font-weight: 700;
      white-space: nowrap;
    }
    td {
      max-width: 360px;
      word-break: break-word;
      background: #fff;
    }
    tr:hover td { background: #f8fbfc; }
    .row-action {
      display: inline-flex;
      align-items: center;
      padding: 3px 7px;
      color: var(--accent);
      background: var(--soft);
      border: 1px solid rgb(15 118 110 / .22);
      border-radius: 999px;
      font-size: 12px;
      cursor: pointer;
    }
    .bar-list {
      display: grid;
      gap: 9px;
    }
    .bar-row {
      display: grid;
      grid-template-columns: minmax(120px, 210px) minmax(120px, 1fr) auto;
      gap: 10px;
      align-items: center;
      font-size: 13px;
    }
    .bar {
      height: 10px;
      background: #e7edf3;
      border-radius: 999px;
      overflow: hidden;
    }
    .bar i {
      display: block;
      height: 100%;
      background: var(--accent);
      border-radius: inherit;
    }
    .warning {
      color: var(--warn);
      background: var(--warn-soft);
      border: 1px solid rgb(154 91 0 / .25);
      border-radius: var(--radius);
      padding: 10px 12px;
      font-size: 13px;
    }
    footer {
      margin-top: 24px;
      padding-top: 16px;
      color: var(--muted);
      border-top: 1px solid var(--line);
      font-size: 13px;
    }
    @media (max-width: 1120px) {
      header, .layout { grid-template-columns: 1fr; }
      .detail { position: static; max-height: none; }
    }
    @media (max-width: 720px) {
      .shell { width: min(100% - 24px, 720px); padding-top: 18px; }
      .toolbar { grid-template-columns: 1fr; }
      .summary { grid-template-columns: 1fr; }
      .hero { padding: 18px; }
      .bar-row { grid-template-columns: 1fr; }
      table { min-width: 760px; }
    }
  </style>
</head>
<body>
  <div class="shell">
    <header>
      <section class="hero">
        <h1>招投标实体全量数据浏览</h1>
        <p>这个页面把实体 Excel 的所有 sheet 全部转成可搜索、可点击、可互跳的 HTML。项目、招标公司、投标公司、产品、资质、关系、联系人、来源链接和地区统计都在同一个页面里。</p>
      </section>
      <section class="panel summary" id="summary"></section>
    </header>

    <section class="toolbar">
      <input id="globalSearch" type="search" placeholder="全局搜索：项目、公司、地区、金额、链接、联系人">
      <div style="display:flex;gap:8px;flex-wrap:wrap;">
        <select id="projectSelect" aria-label="选择项目"></select>
        <button id="resetButton" class="secondary" type="button">清空筛选</button>
      </div>
    </section>

    <nav class="nav" id="quickNav"></nav>

    <section class="layout">
      <main class="stack">
        <section class="panel" id="chartsPanel">
          <div class="panel-head">
            <h2>关键分布</h2>
            <span class="chip emph" id="sourceInfo"></span>
          </div>
          <div class="panel-body" style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;" id="charts"></div>
        </section>

        <section class="panel" id="topProjectsPanel">
          <div class="panel-head">
            <h2>高金额项目索引</h2>
            <span class="chip">点击项目可查看完整关联</span>
          </div>
          <div class="panel-body" id="topProjects"></div>
        </section>

        <section class="panel" id="sheetPanel">
          <div class="panel-head">
            <h2>Excel 全量 Sheet</h2>
            <span class="chip" id="sheetCount"></span>
          </div>
          <div class="tabs" id="sheetTabs"></div>
          <div class="table-wrap" id="sheetTable"></div>
        </section>
      </main>

      <aside class="panel detail" id="detailPanel">
        <div class="panel-head">
          <h3>项目详情与关联</h3>
          <span class="chip" id="detailStatus">待选择</span>
        </div>
        <div class="panel-body" id="detailBody"></div>
      </aside>
    </section>

    <footer>
      数据源：<a href="../20260617-raw-excel-entity-tables/原始Excel招投标实体抽取表.xlsx">原始Excel招投标实体抽取表.xlsx</a>。页面为静态 HTML，数据已内嵌，适合离线复核。外部公告链接会跳转到原来源网站。
    </footer>
  </div>

  <script id="excel-data" type="application/json">__DATA__</script>
  <script>
    const DATA = JSON.parse(document.getElementById('excel-data').textContent);
    const sheets = DATA.sheets;
    const idx = DATA.indexes;
    const state = { activeSheet: '招投标项目表', query: '', selectedProject: '' };

    const byId = (id) => document.getElementById(id);
    const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
    const compact = (value) => Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 4 });
    const isUrl = (value) => /^https?:\\/\\//i.test(String(value || ''));

    function linkForCell(header, value) {
      const text = String(value ?? '').trim();
      if (!text) return '';
      if (isUrl(text)) return `<a href="${escapeHtml(text)}" target="_blank" rel="noreferrer">打开来源</a>`;
      if (header === '项目ID' || header === '来源项目ID') {
        return idx.projectsById[text] ? `<a href="#detailPanel" data-project="${escapeHtml(text)}">${escapeHtml(text)}</a>` : escapeHtml(text);
      }
      if (header === '项目名称' || header === '来源项目') {
        const found = Object.values(idx.projectsById).find(row => row['项目名称'] === text);
        return found ? `<a href="#detailPanel" data-project="${escapeHtml(found['项目ID'])}">${escapeHtml(text)}</a>` : escapeHtml(text);
      }
      if (header === '招标公司ID') return `<a href="#sheetPanel" data-sheet="招标公司表" data-query="${escapeHtml(text)}">${escapeHtml(text)}</a>`;
      if (header === '投标公司ID') return `<a href="#sheetPanel" data-sheet="投标公司表" data-query="${escapeHtml(text)}">${escapeHtml(text)}</a>`;
      if (header.includes('链接') || header.includes('地址')) {
        return isUrl(text) ? `<a href="${escapeHtml(text)}" target="_blank" rel="noreferrer">打开链接</a>` : escapeHtml(text);
      }
      return escapeHtml(text);
    }

    function rowText(row) {
      return Object.values(row).map(value => String(value ?? '')).join(' ').toLowerCase();
    }

    function renderSummary() {
      const s = idx.summary;
      const metrics = [
        ['项目数', s.projectCount],
        ['全表行数', s.rowCount],
        ['总金额 万元', compact(s.totalAmountWan)],
        ['来源链接数', s.sourceUrlCount],
        ['招标公司', s.buyerCount],
        ['投标公司', s.bidderCount],
        ['关系记录', s.relationCount],
        ['待补资质', s.todoQualificationCount],
      ];
      byId('summary').innerHTML = metrics.map(([label, value]) => `<div class="metric"><strong>${value}</strong><span>${label}</span></div>`).join('');
      byId('sourceInfo').textContent = DATA.meta.source;
      byId('sheetCount').textContent = `${Object.keys(sheets).length} 个 sheet`;
    }

    function renderQuickNav() {
      byId('quickNav').innerHTML = [
        ['总览', '#chartsPanel'],
        ['高金额项目', '#topProjectsPanel'],
        ['全量 Sheet', '#sheetPanel'],
        ['项目详情', '#detailPanel'],
        ['地图看板', '../20260617-entity-map-dashboard/招投标实体地图看板.html'],
        ['首页', '../../../index.html'],
      ].map(([label, href]) => `<a href="${href}">${label}</a>`).join('');
    }

    function renderBars(container, title, rows) {
      const max = Math.max(...rows.map(row => row[1]), 1);
      container.innerHTML = `<h3 style="margin:0 0 12px;font-size:15px;">${escapeHtml(title)}</h3>` +
        `<div class="bar-list">${rows.slice(0, 10).map(([name, value]) => `
          <div class="bar-row">
            <span>${escapeHtml(name)}</span>
            <span class="bar"><i style="width:${Math.max(4, value / max * 100)}%"></i></span>
            <b>${compact(value)}</b>
          </div>`).join('')}</div>`;
    }

    function renderCharts() {
      const container = byId('charts');
      container.innerHTML = '<div></div><div></div><div></div>';
      renderBars(container.children[0], '省份项目数', idx.charts.province);
      renderBars(container.children[1], '需求类型', idx.charts.demand);
      renderBars(container.children[2], '客户类型', idx.charts.client);
    }

    function renderTopProjects() {
      byId('topProjects').innerHTML = `<div class="table-wrap" style="max-height:420px;"><table><thead><tr>
        <th>项目ID</th><th>项目名称</th><th>金额 万元</th><th>招标公司</th><th>投标公司</th><th>操作</th>
      </tr></thead><tbody>${idx.charts.topProjects.map(row => `<tr>
        <td>${linkForCell('项目ID', row.projectId)}</td>
        <td>${escapeHtml(row.name)}</td>
        <td>${compact(row.amountWan)}</td>
        <td>${escapeHtml(row.buyer)}</td>
        <td>${escapeHtml(row.bidder)}</td>
        <td><button class="row-action" data-project="${escapeHtml(row.projectId)}" type="button">看详情</button></td>
      </tr>`).join('')}</tbody></table></div>`;
    }

    function renderProjectSelect() {
      const projects = Object.values(idx.projectsById);
      byId('projectSelect').innerHTML = '<option value="">选择项目详情</option>' + projects.map(row => {
        const id = row['项目ID'];
        const name = row['项目名称'];
        return `<option value="${escapeHtml(id)}">${escapeHtml(id)} ${escapeHtml(name).slice(0, 46)}</option>`;
      }).join('');
    }

    function renderTabs() {
      byId('sheetTabs').innerHTML = Object.entries(sheets).map(([name, sheet]) => {
        const active = name === state.activeSheet ? ' active' : '';
        return `<button class="tab${active}" type="button" data-sheet="${escapeHtml(name)}">${escapeHtml(name)} (${sheet.rowCount})</button>`;
      }).join('');
    }

    function renderSheet() {
      renderTabs();
      const sheet = sheets[state.activeSheet];
      const query = state.query.trim().toLowerCase();
      let rows = sheet.rows;
      if (query) rows = rows.filter(row => rowText(row).includes(query));
      const headers = sheet.headers;
      if (!rows.length) {
        byId('sheetTable').innerHTML = `<div class="empty">当前筛选下没有记录。</div>`;
        return;
      }
      byId('sheetTable').innerHTML = `<table><thead><tr>${headers.map(header => `<th>${escapeHtml(header)}</th>`).join('')}<th>操作</th></tr></thead>
        <tbody>${rows.map(row => {
          const projectId = row['项目ID'] || row['来源项目ID'] || '';
          const cells = headers.map(header => `<td>${linkForCell(header, row[header])}</td>`).join('');
          const action = projectId && idx.projectsById[projectId] ? `<button class="row-action" data-project="${escapeHtml(projectId)}" type="button">项目详情</button>` : '';
          return `<tr>${cells}<td>${action}</td></tr>`;
        }).join('')}</tbody></table>`;
    }

    function renderKv(row, keys) {
      return `<div class="kv">${keys.map(key => `<b>${escapeHtml(key)}</b><span>${linkForCell(key, row?.[key] ?? '') || '-'}</span>`).join('')}</div>`;
    }

    function renderMini(title, rows, fields, emptyText) {
      if (!rows || !rows.length) return `<div class="subsection"><h4>${escapeHtml(title)}</h4><div class="empty">${escapeHtml(emptyText)}</div></div>`;
      return `<div class="subsection"><h4>${escapeHtml(title)} (${rows.length})</h4><ul class="mini-list">${rows.map(row => `<li>${fields.map(field => {
        const value = row[field] ?? '';
        return `<b>${escapeHtml(field)}：</b>${linkForCell(field, value) || '-'}`;
      }).join('<br>')}</li>`).join('')}</ul></div>`;
    }

    function renderProjectDetail(projectId) {
      const project = idx.projectsById[projectId];
      if (!project) {
        byId('detailStatus').textContent = '待选择';
        byId('detailBody').innerHTML = '<div class="empty">请选择左侧项目，或点击任意项目 ID。</div>';
        return;
      }
      state.selectedProject = projectId;
      byId('projectSelect').value = projectId;
      byId('detailStatus').textContent = projectId;
      const buyer = idx.buyersById[project['招标公司ID']] || {};
      const bidder = idx.biddersById[project['投标公司ID']] || {};
      const products = idx.productsByProject[projectId] || [];
      const relations = idx.relationsByProject[projectId] || [];
      const sources = idx.sourcesByProject[projectId] || [];
      const contacts = idx.contactsByProject[projectId] || [];
      const quals = idx.qualificationsByBidder[project['投标公司ID']] || [];
      byId('detailBody').innerHTML = `
        <div class="warning">当前页面展示 Excel 已有数据和可追溯链接。评分细项、完整三家投标公司、资质证书、设备参数如果源表未披露，会以待补抓或备注说明呈现。</div>
        <div class="subsection" style="margin-top:12px;padding-top:0;border-top:0;">
          <h4>项目主数据</h4>
          ${renderKv(project, ['项目ID','项目名称','项目编号','信息发布时间','发布省份','发布市级','发布区级','中标阶段','中标金额_万元','招标公司','投标公司/中标单位','需求类型','客户类型','合同工期天数','官网查看地址'])}
        </div>
        <div class="subsection"><h4>招标公司画像</h4>${renderKv(buyer, ['招标公司ID','招标公司名称','客户类型','项目数','金额合计_万元','覆盖省份','覆盖城市','联系人','联系电话','来源链接示例'])}</div>
        <div class="subsection"><h4>投标公司画像</h4>${renderKv(bidder, ['投标公司ID','投标公司名称','中标项目数','金额合计_万元','服务客户类型','覆盖省份','需求类型','最早披露日期','最近披露日期','来源链接示例'])}</div>
        ${renderMini('产品/需求记录', products, ['产品记录ID','产品/需求类型','抽取关键词','软硬件/服务判断','中标金额_万元','抽取置信度','备注'], '该项目没有产品记录。')}
        ${renderMini('招标-投标关系', relations, ['关系ID','招标公司','投标公司','关系类型','披露阶段','中标金额_万元','产品/需求类型','来源链接','说明'], '该项目没有关系记录。')}
        ${renderMini('资质记录', quals, ['投标公司名称','资质名称','资质类型','状态','抽取状态','来源链接示例','备注'], '该投标公司没有已披露资质，或仍待补抓。')}
        ${renderMini('联系人', contacts, ['公司名称','公司角色','联系人','联系电话','来源链接'], '该项目没有联系人记录。')}
        ${renderMini('来源证据', sources, ['来源ID','来源类型','来源链接','采集字段','证据等级','备注'], '该项目没有来源记录。')}
      `;
    }

    function attachEvents() {
      document.body.addEventListener('click', event => {
        const projectTarget = event.target.closest('[data-project]');
        if (projectTarget) {
          renderProjectDetail(projectTarget.dataset.project);
          return;
        }
        const sheetTarget = event.target.closest('[data-sheet]');
        if (sheetTarget) {
          state.activeSheet = sheetTarget.dataset.sheet;
          if (sheetTarget.dataset.query) {
            state.query = sheetTarget.dataset.query;
            byId('globalSearch').value = state.query;
          }
          renderSheet();
        }
      });
      byId('globalSearch').addEventListener('input', event => {
        state.query = event.target.value;
        renderSheet();
      });
      byId('projectSelect').addEventListener('change', event => {
        if (event.target.value) renderProjectDetail(event.target.value);
      });
      byId('resetButton').addEventListener('click', () => {
        state.query = '';
        byId('globalSearch').value = '';
        renderSheet();
      });
    }

    function init() {
      renderSummary();
      renderQuickNav();
      renderCharts();
      renderTopProjects();
      renderProjectSelect();
      renderSheet();
      renderProjectDetail('');
      attachEvents();
    }

    init();
  </script>
</body>
</html>
"""


def build_html(payload: dict[str, Any]) -> str:
    return HTML_TEMPLATE.replace("__DATA__", html_escape_json(payload))


def main() -> None:
    if not SOURCE_XLSX.exists():
        raise FileNotFoundError(SOURCE_XLSX)
    payload = build_payload()
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    OUT_HTML.write_text(build_html(payload), encoding="utf-8")
    print(OUT_HTML)
    print(json.dumps(payload["indexes"]["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
