#!/usr/bin/env python3
"""
Interactive HTML Report Generator (v2)
Renders pipeline results as a self-contained interactive HTML page.
Features: editable JSON, kpId dropdown, push buttons, progress tracking.
"""

import json
import html
import os
import logging
from datetime import datetime

logger = logging.getLogger("html_report")

# Per-cType push body field mapping
PUSH_FIELD_MAP = {
    "core_reading_order": ["taskInfo", "materialInfo", "flowInfo", "studyInfo"],
    "core_reading_imageDrag": ["taskInfo", "questionGroup", "studyInfo"],
    "core_speaking_reply": ["taskInfo", "dialogSetting", "dialogConfig", "studyInfo"],
    "core_speaking_inquiry": ["taskInfo", "dialogSetting", "dialogConfig", "studyInfo"],
    "core_speaking_image": ["taskInfo", "dialogConfig", "studyInfo", "imageInfo", "questionList", "optionList"],
    "core_listening_order": ["taskInfo", "materialInfo", "flowInfo", "studyInfo"],
    "core_listening_drag": ["taskInfo", "preDialog", "dialogList", "questionList", "studyInfo"],
    "core_listening_choose": ["taskInfo", "questionGroup", "studyInfo"],
    "core_writing_imgMakeSentence": ["taskInfo", "textInfo", "studyInfo", "evalInfo"],
    "core_writing_imgWrite": ["taskInfo", "textInfo", "studyInfo", "evalInfo"],
    "core_writing_questionMakeSentence": ["taskInfo", "textInfo", "studyInfo", "evalInfo"],
    "core_writing_questionWrite": ["taskInfo", "textInfo", "studyInfo", "evalInfo"],
    "core_writing_imgMakeWord": ["taskInfo", "preDialog", "imageInfo", "questionList", "studyInfo"],
}

ALL_CORE_FIELDS = [
    "taskInfo", "materialInfo", "flowInfo", "studyInfo",
    "questionGroup", "dialogSetting", "dialogConfig",
    "imageInfo", "optionList", "questionList",
    "preDialog", "dialogList",
    "textInfo", "evalInfo",
]


def _build_component_data(result):
    """Extract component data dict for JS embedding."""
    cId = result.get("cId", "")
    cType = result.get("cType", "")
    category = result.get("category", "mid")
    status = result.get("status", "")

    comp = {
        "cId": str(cId),
        "cType": cType,
        "category": category,
        "type_name": result.get("type_name", ""),
        "status": status,
        "raw_config": result.get("raw_config", ""),
        "config_info": result.get("config_info", ""),
        "knowledge_text": result.get("knowledge_text", ""),
    }

    if status not in ("generated", "saved"):
        comp["error"] = result.get("error", "")
        comp["reason"] = result.get("reason", "")
        return comp

    if category == "core":
        for key in ALL_CORE_FIELDS:
            data = result.get(key)
            if data:
                comp[key] = data
    else:
        jd = result.get("jsonData")
        if jd and isinstance(jd, dict):
            comp["jsonData"] = jd

    kp = result.get("kpInfo")
    if kp:
        kp_clean = json.loads(json.dumps(kp))
        for item in kp_clean.get("kpInfo", []):
            item.pop("candidates", None)
        if kp_clean.get("kpInfo"):
            comp["kpInfo"] = kp_clean

    return comp


def _render_json_section_editable(label, key, data, idx):
    """Render a dual-view JSON section: visual key-value editor + raw JSON textarea."""
    if not data:
        return ""
    if isinstance(data, str):
        try:
            data = json.loads(data)
        except (json.JSONDecodeError, TypeError):
            pass
    if not isinstance(data, dict):
        # Non-dict (e.g. list): use json.dumps to ensure double quotes
        raw = json.dumps(data, ensure_ascii=False, indent=2)
        escaped = html.escape(raw)
        ta_id = f"ta-{idx}-{key}"
        num_lines = raw.count('\n') + 1
        rows = min(max(num_lines, 3), 20)
        line_nums_html = ''.join(f'<span>{i}</span>' for i in range(1, num_lines + 1))
        return f'''
    <div class="json-section">
      <div class="json-section-label">{html.escape(label)}</div>
      <div class="json-lined-wrap">
        <div class="json-line-nums" id="ln-{ta_id}">{line_nums_html}</div>
        <textarea id="{ta_id}" data-idx="{idx}" data-key="{key}" class="json-textarea" rows="{rows}" oninput="syncLineNums(this)" onscroll="this.previousElementSibling.scrollTop=this.scrollTop">{escaped}</textarea>
      </div>
    </div>'''

    raw = json.dumps(data, ensure_ascii=False, indent=2)
    escaped = html.escape(raw)
    ta_id = f"ta-{idx}-{key}"
    ve_id = f"ve-{idx}-{key}"
    sec_id = f"sec-{idx}-{key}"
    num_lines = raw.count('\n') + 1
    rows = min(max(num_lines, 5), 30)
    line_nums_html = ''.join(f'<span>{i}</span>' for i in range(1, num_lines + 1))

    # Build visual editor rows for top-level keys
    ve_rows = []
    for field_key, field_val in data.items():
        fk_escaped = html.escape(field_key)
        input_id = f"vf-{idx}-{key}-{field_key}"
        if isinstance(field_val, (dict, list)):
            # Complex value: mini JSON textarea
            val_json = json.dumps(field_val, ensure_ascii=False, indent=2)
            val_escaped = html.escape(val_json)
            val_rows = min(max(val_json.count('\n') + 1, 2), 8)
            ve_rows.append(f'''<tr class="ve-row">
              <td class="ve-key" title="{fk_escaped}">{fk_escaped}</td>
              <td class="ve-val"><textarea id="{input_id}" class="ve-input ve-complex" rows="{val_rows}" data-vtype="json">{val_escaped}</textarea></td>
            </tr>''')
        elif isinstance(field_val, bool):
            val_str = "true" if field_val else "false"
            ve_rows.append(f'''<tr class="ve-row">
              <td class="ve-key" title="{fk_escaped}">{fk_escaped}</td>
              <td class="ve-val"><input type="text" id="{input_id}" class="ve-input ve-bool" value="{val_str}" data-vtype="bool" /></td>
            </tr>''')
        elif isinstance(field_val, (int, float)):
            ve_rows.append(f'''<tr class="ve-row">
              <td class="ve-key" title="{fk_escaped}">{fk_escaped}</td>
              <td class="ve-val"><input type="text" id="{input_id}" class="ve-input ve-num" value="{field_val}" data-vtype="number" /></td>
            </tr>''')
        elif field_val is None:
            ve_rows.append(f'''<tr class="ve-row">
              <td class="ve-key" title="{fk_escaped}">{fk_escaped}</td>
              <td class="ve-val"><input type="text" id="{input_id}" class="ve-input ve-null" value="null" data-vtype="null" /></td>
            </tr>''')
        else:
            # String
            val_escaped = html.escape(str(field_val))
            is_long = len(str(field_val)) > 80 or '\n' in str(field_val)
            if is_long:
                val_rows2 = min(max(str(field_val).count('\n') + 1, 2), 5)
                ve_rows.append(f'''<tr class="ve-row">
              <td class="ve-key" title="{fk_escaped}">{fk_escaped}</td>
              <td class="ve-val"><textarea id="{input_id}" class="ve-input ve-str-long" rows="{val_rows2}" data-vtype="string">{val_escaped}</textarea></td>
            </tr>''')
            else:
                ve_rows.append(f'''<tr class="ve-row">
              <td class="ve-key" title="{fk_escaped}">{fk_escaped}</td>
              <td class="ve-val"><input type="text" id="{input_id}" class="ve-input ve-str" value="{val_escaped}" data-vtype="string" /></td>
            </tr>''')

    ve_table = '\n'.join(ve_rows)

    return f'''
    <div class="json-section" id="{sec_id}">
      <div class="json-section-label">
        {html.escape(label)}
        <button class="ve-toggle-btn" onclick="toggleJsonView('{sec_id}','{ve_id}','{ta_id}','{idx}','{key}')">
          <span id="btn-{sec_id}">查看原始JSON</span>
        </button>
      </div>
      <div class="ve-editor" id="{ve_id}" data-idx="{idx}" data-key="{key}">
        <table class="ve-table"><tbody>{ve_table}</tbody></table>
      </div>
      <div class="ve-raw" id="raw-{sec_id}" style="display:none;">
        <div class="json-lined-wrap">
          <div class="json-line-nums" id="ln-{ta_id}">{line_nums_html}</div>
          <textarea id="{ta_id}" data-idx="{idx}" data-key="{key}" class="json-textarea" rows="{rows}" oninput="syncLineNums(this)" onscroll="this.previousElementSibling.scrollTop=this.scrollTop">{escaped}</textarea>
        </div>
        <div class="ve-error" id="err-{sec_id}" style="display:none;"></div>
      </div>
    </div>'''


def _render_kp_section(kp_data, idx):
    """Render kpInfo section as a simple editable table (no candidate dropdowns)."""
    if not kp_data:
        return ""

    kp_list = kp_data.get("kpInfo", [])
    if not kp_list:
        return '<div class="json-section"><div class="json-section-label">kpInfo</div><div style="padding:8px 0;color:var(--text2);font-size:13px;">无知识点</div></div>'

    rows_html = []
    for ki, kp in enumerate(kp_list):
        kp_id = kp.get("kpId") or ""
        kp_type = kp.get("kpType", "")
        kp_title = kp.get("kpTitle", "")
        kp_skill = kp.get("kpSkill", "")
        kp_skill_name = kp.get("kpSkillName", "")

        kp_id_style = '' if kp_id else ' style="color:var(--text2);font-style:italic;"'
        kp_id_placeholder = ' placeholder="(未匹配)"' if not kp_id else ''

        rows_html.append(f'''
        <tr>
          <td class="kp-idx">[{ki}]</td>
          <td><input type="text" value="{html.escape(kp_id)}" id="kp-{idx}-{ki}-id" class="kp-input kp-id-input"{kp_id_style}{kp_id_placeholder} /></td>
          <td><input type="text" value="{html.escape(kp_type)}" id="kp-{idx}-{ki}-type" class="kp-input kp-type" /></td>
          <td class="kp-title">{html.escape(kp_title)}</td>
          <td><input type="text" value="{html.escape(kp_skill)}" id="kp-{idx}-{ki}-skill" class="kp-input" /></td>
          <td><input type="text" value="{html.escape(kp_skill_name)}" id="kp-{idx}-{ki}-skillname" class="kp-input" /></td>
        </tr>''')

    table_html = '\n'.join(rows_html)
    matched_count = sum(1 for kp in kp_list if kp.get("kpId"))
    count_label = f"{matched_count}/{len(kp_list)}已匹配" if matched_count < len(kp_list) else f"{len(kp_list)}个知识点"
    return f'''
    <div class="json-section">
      <div class="json-section-label">kpInfo <span class="kp-count">({count_label})</span></div>
      <table class="kp-table">
        <thead><tr><th>#</th><th>kpId</th><th>kpType</th><th>kpTitle</th><th>kpSkill</th><th>kpSkillName</th></tr></thead>
        <tbody>{table_html}</tbody>
      </table>
    </div>'''


def _render_component(idx, result):
    """Render a single interactive component card."""
    cId = result.get("cId", "?")
    type_name = result.get("type_name", "?")
    cType = result.get("cType", "?")
    status = result.get("status", "?")
    category = result.get("category", "mid")

    status_class = "ok" if status in ("generated", "saved") else ("fail" if status == "failed" else "skip")
    status_label = {"generated": "生成成功", "saved": "已保存", "failed": "失败", "skipped": "跳过"}.get(status, status)

    cat_badge = ""
    if category == "core":
        cat_badge = '<span class="cat-badge core">核心</span>'

    # Push buttons (only for successful components)
    push_btns = ""
    if status in ("generated", "saved"):
        push_btns = f'''
        <button class="push-btn config-btn" data-idx="{idx}" onclick="event.stopPropagation();pushComponent({idx})">
          配置推送<span class="push-icon" id="cfg-status-{idx}"></span>
        </button>
        <button class="push-btn kp-btn" data-idx="{idx}" onclick="event.stopPropagation();pushKp({idx})">
          知识推送<span class="push-icon" id="kp-status-{idx}"></span>
        </button>'''

    # Regenerate buttons (split: component + kp)
    regen_btn = f'''<button class="push-btn regen-btn" data-idx="{idx}" onclick="event.stopPropagation();toggleRegenPanel({idx},'component')">
          重新生成组件<span class="push-icon" id="regen-status-{idx}"></span>
        </button>
        <button class="push-btn regen-kp-btn" data-idx="{idx}" onclick="event.stopPropagation();toggleRegenPanel({idx},'kp')">
          重新生成知识点<span class="push-icon" id="regen-kp-status-{idx}"></span>
        </button>'''

    # Build body
    body_html = ""
    if status in ("generated", "saved"):
        sections = []
        if category == "core":
            fields = PUSH_FIELD_MAP.get(cType, ALL_CORE_FIELDS)
            for key in fields:
                data = result.get(key)
                if data:
                    sections.append(_render_json_section_editable(key, key, data, idx))
        else:
            jd = result.get("jsonData")
            if jd and isinstance(jd, dict):
                sections.append(_render_json_section_editable("jsonData", "jsonData", jd, idx))

        # kpInfo — 仅 JSON textarea
        kp = result.get("kpInfo")
        if kp:
            kp_display = json.loads(json.dumps(kp))
            for item in kp_display.get("kpInfo", []):
                item.pop("candidates", None)
            if kp_display.get("kpInfo"):
                sections.append(_render_json_section_editable("kpInfo JSON", "kpInfo", kp_display, idx))

        # Intermediate (read-only)
        intermediate = result.get("intermediate", {})
        if intermediate:
            for label, text in intermediate.items():
                if text:
                    escaped_text = html.escape(str(text))
                    sections.append(f'''
                    <div class="json-section intermediate">
                      <div class="json-section-label">中间结果: {html.escape(label)}</div>
                      <pre class="intermediate-pre">{escaped_text}</pre>
                    </div>''')

        body_html = "\n".join(s for s in sections if s)
    elif status == "failed":
        err = html.escape(result.get("error", "未知错误"))
        body_html = f'<div class="error-msg">错误: {err}</div>'
    elif status == "skipped":
        reason = html.escape(result.get("reason", ""))
        body_html = f'<div class="skip-msg">跳过: {reason}</div>'

    # Regenerate panel — split into component regen + kp regen
    raw_config = result.get("raw_config", "")
    escaped_config = html.escape(raw_config)
    config_rows = min(max(raw_config.count('\n') + 1, 5), 25)
    knowledge_text = result.get("knowledge_text", "")
    escaped_kp_text = html.escape(knowledge_text)
    kp_rows = min(max(knowledge_text.count('\n') + 1, 3), 15)
    # For failed/skipped: show config panel expanded; for success: collapsed
    regen_display = "block" if status in ("failed", "skipped") else "none"
    regen_panel = f'''
    <div class="regen-panel" id="regen-panel-{idx}" style="display:none;">
      <div class="regen-label">原始组件配置 <span class="regen-hint">(可编辑后点击"执行重新生成组件")</span></div>
      <textarea class="regen-textarea" id="regen-config-{idx}" rows="{config_rows}">{escaped_config}</textarea>
      <button class="regen-exec-btn" id="regen-exec-{idx}" onclick="regenerateComponentJson({idx})">执行重新生成组件</button>
    </div>
    <div class="regen-panel regen-kp-panel" id="regen-kp-panel-{idx}" style="display:none;">
      <div class="regen-label regen-kp-label">知识点文本 <span class="regen-hint">(来自sheet知识点列，可编辑后点击"执行重新生成知识点")</span></div>
      <textarea class="regen-textarea regen-kp-textarea" id="regen-kp-text-{idx}" rows="{kp_rows}">{escaped_kp_text}</textarea>
      <button class="regen-exec-btn regen-kp-exec-btn" id="regen-kp-exec-{idx}" onclick="regenerateComponentKp({idx})">执行重新生成知识点</button>
    </div>'''

    return f'''
    <div class="comp-card" id="card-{idx}">
      <div class="comp-header" onclick="toggleCard({idx})">
        <span class="comp-idx">{idx}</span>
        {cat_badge}<span class="comp-type">{html.escape(type_name)}</span>
        <span class="comp-cid">cId: {html.escape(str(cId))}</span>
        <span class="comp-ctype">{html.escape(str(cType))}</span>
        {push_btns}
        {regen_btn}
        <span class="comp-status {status_class}" id="status-label-{idx}">{status_label}</span>
        <span class="comp-arrow" id="arrow-{idx}">&#9654;</span>
      </div>
      <div class="comp-body" id="body-{idx}">
        {body_html}
        <div id="regen-result-{idx}"></div>
        {regen_panel}
      </div>
    </div>'''


def generate_html_report(report, output_path=None, model_name="", wiki_token="", api_url=None):
    """Generate an interactive HTML report from pipeline results."""
    title = report.get("title", "未知剧本")
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    # Build component cards HTML
    cards = []
    components_data = []
    for i, result in enumerate(report.get("results", []), 1):
        cards.append(_render_component(i, result))
        components_data.append(_build_component_data(result))
    components_html = "\n".join(cards)

    # Serialize data for JS (escape </script> to prevent premature tag closure)
    def _js_safe(s):
        return s.replace("</", "<\\/")

    components_json = _js_safe(json.dumps(components_data, ensure_ascii=True))
    push_field_map_json = _js_safe(json.dumps(PUSH_FIELD_MAP, ensure_ascii=True))
    character_map_json = _js_safe(json.dumps(report.get("character_map") or {}, ensure_ascii=True))
    section_char_map_json = _js_safe(json.dumps(report.get("section_char_map") or [], ensure_ascii=True))
    level_json = _js_safe(json.dumps(report.get("level") or None, ensure_ascii=True))

    total = report.get("total_components", 0)
    success = report.get("success", 0)
    failed = report.get("failed", 0)
    skipped = report.get("skipped", 0)

    # Count by category
    core_count = sum(1 for r in report.get("results", []) if r.get("category") == "core")
    mid_count = sum(1 for r in report.get("results", []) if r.get("category") != "core")

    html_content = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{html.escape(title)} — 组件JSON报告</title>
<style>
:root {{
  --bg: #f8f9fa; --card: #fff; --border: #e2e8f0;
  --text: #1a202c; --text2: #64748b; --accent: #3b82f6;
  --green: #10b981; --red: #ef4444; --yellow: #f59e0b;
  --purple: #8b5cf6;
}}
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;
       background:var(--bg); color:var(--text); line-height:1.6; padding:0; }}
.container {{ max-width:1280px; margin:0 auto; padding:20px; padding-top:80px; }}

/* Fixed top bar */
.top-bar {{ position:fixed; top:0; left:0; right:0; z-index:100;
            background:linear-gradient(135deg,#1e293b 0%,#334155 100%);
            color:#fff; padding:12px 24px; display:flex; align-items:center; gap:16px;
            box-shadow:0 2px 8px rgba(0,0,0,0.15); }}
.top-bar .push-all-btn {{ background:#3b82f6; color:#fff; border:none; border-radius:6px;
                          padding:8px 20px; font-size:14px; font-weight:600; cursor:pointer;
                          transition:background 0.2s; white-space:nowrap; }}
.top-bar .push-all-btn:hover {{ background:#2563eb; }}
.top-bar .push-all-btn:disabled {{ background:#64748b; cursor:not-allowed; }}
.progress-wrap {{ flex:1; display:flex; align-items:center; gap:12px; }}
.progress-bar {{ flex:1; height:8px; background:#475569; border-radius:4px; overflow:hidden; }}
.progress-fill {{ height:100%; background:#10b981; border-radius:4px; transition:width 0.3s; width:0%; }}
.progress-text {{ font-size:13px; white-space:nowrap; min-width:120px; }}
.push-summary {{ font-size:12px; display:flex; gap:12px; }}
.push-summary .s-ok {{ color:#6ee7b7; }}
.push-summary .s-fail {{ color:#fca5a5; }}

/* Header */
.header {{ background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
           color:#fff; padding:28px 32px; border-radius:12px; margin-bottom:24px; }}
.header h1 {{ font-size:22px; font-weight:600; margin-bottom:8px; }}
.header .meta {{ font-size:13px; opacity:0.85; display:flex; gap:24px; flex-wrap:wrap; }}

/* Stats */
.stats {{ display:flex; gap:12px; margin-bottom:24px; flex-wrap:wrap; }}
.stat-card {{ background:var(--card); border:1px solid var(--border); border-radius:8px;
              padding:14px 20px; flex:1; min-width:100px; text-align:center; display:flex; flex-direction:column; justify-content:center; }}
.stat-card .num {{ font-size:28px; font-weight:700; }}
.stat-card .label {{ font-size:12px; color:var(--text2); margin-top:2px; }}
.stat-card.ok .num {{ color:var(--green); }}
.stat-card.fail .num {{ color:var(--red); }}
.stat-card.skip .num {{ color:var(--yellow); }}
.stat-split {{ flex:1; min-width:100px; display:flex; flex-direction:column; gap:6px; }}
.stat-split .split-item {{ flex:1; background:var(--card); border:1px solid var(--border); border-radius:8px;
                           padding:6px 16px; display:flex; align-items:center; gap:8px; }}
.stat-split .split-dot {{ width:8px; height:8px; border-radius:50%; flex-shrink:0; }}
.stat-split .split-dot.core {{ background:var(--accent); }}
.stat-split .split-dot.mid {{ background:var(--purple); }}
.stat-split .split-num {{ font-size:16px; font-weight:700; color:var(--text); }}
.stat-split .split-label {{ font-size:12px; color:var(--text2); }}

/* Component card */
.comp-card {{ background:var(--card); border:1px solid var(--border); border-radius:10px;
              margin-bottom:12px; overflow:hidden; transition:box-shadow 0.2s; }}
.comp-card:hover {{ box-shadow:0 2px 12px rgba(0,0,0,0.06); }}
.comp-header {{ display:flex; align-items:center; gap:8px; padding:12px 16px;
                cursor:pointer; user-select:none; border-bottom:1px solid transparent; }}
.comp-card.open .comp-header {{ border-bottom-color:var(--border); }}
.comp-header:hover {{ background:#f1f5f9; }}
.comp-idx {{ background:var(--accent); color:#fff; font-size:11px; font-weight:600;
             padding:2px 8px; border-radius:10px; min-width:28px; text-align:center; }}
.cat-badge {{ font-size:10px; padding:1px 6px; border-radius:3px; font-weight:500; }}
.cat-badge.core {{ background:#f59e0b; color:#fff; }}
.comp-type {{ font-size:12px; color:#fff; padding:2px 8px; border-radius:4px;
              background:var(--purple); font-weight:500; }}
.comp-cid {{ font-size:12px; font-weight:600; }}
.comp-ctype {{ font-size:11px; color:var(--text2); font-family:monospace; }}
.comp-status {{ font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; }}
.comp-status.ok {{ background:#d1fae5; color:#065f46; }}
.comp-status.fail {{ background:#fee2e2; color:#991b1b; }}
.comp-status.skip {{ background:#fef3c7; color:#92400e; }}
.comp-arrow {{ font-size:12px; color:var(--text2); transition:transform 0.2s; margin-left:4px; }}
.comp-card.open .comp-arrow {{ transform:rotate(90deg); }}
.comp-body {{ display:none; padding:12px 16px; }}
.comp-card.open .comp-body {{ display:block; }}

/* Push buttons in header */
.push-btn {{ margin-left:auto; font-size:11px; font-weight:600; padding:3px 10px; border-radius:4px;
             border:1px solid rgba(0,0,0,0.1); cursor:pointer; transition:all 0.2s; display:inline-flex;
             align-items:center; gap:4px; }}
.push-btn:first-of-type {{ margin-left:auto; }}
.push-btn + .push-btn {{ margin-left:4px; }}
.config-btn {{ background:#eff6ff; color:#2563eb; border-color:#bfdbfe; }}
.config-btn:hover {{ background:#dbeafe; }}
.kp-btn {{ background:#f0fdf4; color:#16a34a; border-color:#bbf7d0; }}
.kp-btn:hover {{ background:#dcfce7; }}
.regen-btn {{ background:#fef3c7; color:#d97706; border-color:#fde68a; }}
.regen-btn:hover {{ background:#fde68a; }}
.regen-kp-btn {{ background:#fdf2f8; color:#db2777; border-color:#fbcfe8; }}
.regen-kp-btn:hover {{ background:#fce7f3; }}
.push-btn:disabled {{ opacity:0.5; cursor:not-allowed; }}
.push-icon {{ display:inline-block; width:8px; height:8px; border-radius:50%; background:#94a3b8; }}
.push-icon.ok {{ background:#10b981; }}
.push-icon.fail {{ background:#ef4444; }}
.push-icon.loading {{ background:#f59e0b; animation:pulse 0.8s infinite; }}
@keyframes pulse {{ 0%,100%{{opacity:1}} 50%{{opacity:0.3}} }}

/* Regenerate panel */
.regen-panel {{ margin-top:12px; padding:12px; border:1px dashed var(--border); border-radius:8px; background:#fffbeb; }}
.regen-label {{ font-size:13px; font-weight:600; color:#92400e; margin-bottom:6px; }}
.regen-hint {{ font-weight:normal; font-size:11px; color:var(--text2); }}
.regen-textarea {{ width:100%; font-family:"SF Mono",Monaco,Consolas,monospace; font-size:12px;
                   border:1px solid #fde68a; border-radius:6px; padding:10px; background:#fff;
                   resize:vertical; line-height:1.5; tab-size:2; }}
.regen-textarea:focus {{ border-color:#d97706; outline:none; box-shadow:0 0 0 2px rgba(217,119,6,0.15); }}
.regen-exec-btn {{ margin-top:8px; padding:8px 20px; border:none; border-radius:6px;
                   background:#d97706; color:#fff; font-size:13px; font-weight:600;
                   cursor:pointer; transition:background 0.2s; }}
.regen-exec-btn:hover {{ background:#b45309; }}
.regen-exec-btn:disabled {{ opacity:0.5; cursor:not-allowed; }}
.regen-kp-panel {{ background:#fdf2f8; border-color:#fbcfe8; }}
.regen-kp-label {{ color:#9d174d; }}
.regen-kp-textarea {{ border-color:#fbcfe8; }}
.regen-kp-textarea:focus {{ border-color:#db2777; box-shadow:0 0 0 2px rgba(219,39,119,0.15); }}
.regen-kp-exec-btn {{ background:#db2777; }}
.regen-kp-exec-btn:hover {{ background:#9d174d; }}

/* JSON sections */
.json-section {{ margin-bottom:12px; }}
.json-section-label {{ font-weight:600; font-size:13px; color:var(--accent); margin-bottom:4px;
                       display:flex; align-items:center; gap:8px; }}
.json-textarea {{ width:100%; font-family:"SF Mono",Monaco,Consolas,monospace; font-size:12px;
                  border:1px solid var(--border); border-radius:6px; padding:10px; background:#fafbfc;
                  resize:vertical; line-height:1.5; tab-size:2; }}
.json-textarea:focus {{ border-color:var(--accent); outline:none; box-shadow:0 0 0 2px rgba(59,130,246,0.15); }}

/* Visual Editor */
.ve-toggle-btn {{ font-size:11px; padding:2px 10px; border:1px solid var(--border); border-radius:4px;
                  background:#f8fafc; color:var(--text2); cursor:pointer; transition:all 0.2s; font-weight:normal; }}
.ve-toggle-btn:hover {{ background:#e2e8f0; color:var(--text); }}
.ve-toggle-btn.active {{ background:var(--accent); color:#fff; border-color:var(--accent); }}
.ve-editor {{ border:1px solid var(--border); border-radius:6px; overflow:hidden; }}
.ve-table {{ width:100%; border-collapse:collapse; }}
.ve-row {{ border-bottom:1px solid #f1f5f9; }}
.ve-row:last-child {{ border-bottom:none; }}
.ve-row:hover {{ background:#f8fafc; }}
.ve-key {{ width:160px; min-width:120px; max-width:220px; padding:6px 10px; font-size:12px; font-weight:600;
           color:var(--purple); font-family:"SF Mono",Monaco,Consolas,monospace; vertical-align:top;
           background:#fafbfc; border-right:1px solid #f1f5f9; white-space:nowrap; overflow:hidden;
           text-overflow:ellipsis; user-select:none; }}
.ve-val {{ padding:4px 8px; vertical-align:top; }}
.ve-input {{ width:100%; font-size:12px; padding:4px 8px; border:1px solid transparent; border-radius:4px;
             background:transparent; font-family:"SF Mono",Monaco,Consolas,monospace; line-height:1.5;
             transition:border-color 0.2s, background 0.2s; }}
.ve-input:hover {{ border-color:var(--border); background:#fff; }}
.ve-input:focus {{ border-color:var(--accent); background:#fff; outline:none; box-shadow:0 0 0 2px rgba(59,130,246,0.1); }}
.ve-complex {{ resize:vertical; tab-size:2; min-height:40px; }}
.ve-str {{ color:var(--text); }}
.ve-str-long {{ color:var(--text); resize:vertical; }}
.ve-num {{ color:#0d9488; }}
.ve-bool {{ color:#8b5cf6; }}
.ve-null {{ color:var(--text2); font-style:italic; }}
.ve-raw {{ position:relative; }}
.json-lined-wrap {{ display:flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; background:#fafbfc; }}
.json-line-nums {{ padding:10px 0; background:#f1f5f9; border-right:1px solid var(--border); user-select:none;
                   font-family:"SF Mono",Monaco,Consolas,monospace; font-size:12px; line-height:1.5;
                   color:var(--text2); text-align:right; min-width:36px; overflow:hidden; }}
.json-line-nums span {{ display:block; padding:0 8px; }}
.json-lined-wrap .json-textarea {{ border:none; border-radius:0; flex:1; margin:0; resize:vertical; min-height:60px; }}
.ve-error {{ margin-top:4px; padding:6px 10px; background:#fef2f2; border:1px solid #fecaca; border-radius:4px;
             color:#dc2626; font-size:12px; font-family:"SF Mono",Monaco,Consolas,monospace; }}

/* kpInfo table */
.kp-table {{ width:100%; border-collapse:collapse; font-size:12px; margin-top:4px; }}
.kp-table th {{ text-align:left; padding:6px 8px; background:#f1f5f9; color:var(--text2);
                font-weight:600; border-bottom:2px solid var(--border); }}
.kp-table td {{ padding:5px 8px; border-bottom:1px solid #f1f5f9; vertical-align:top; }}
.kp-idx {{ color:var(--text2); font-size:11px; font-style:italic; width:30px; }}
.kp-title {{ font-family:monospace; font-size:12px; color:var(--text); }}
.kp-input {{ font-size:12px; padding:3px 6px; border:1px solid var(--border); border-radius:4px;
             background:#fff; width:100%; }}
.kp-input:focus {{ border-color:var(--accent); outline:none; }}
.kp-input.kp-type {{ width:80px; }}
.kp-input.kp-id-input {{ min-width:140px; font-family:monospace; }}
.kp-count {{ font-weight:normal; color:var(--text2); font-size:12px; }}

/* Intermediate results */
.intermediate .json-section-label {{ color:var(--text2); }}
.intermediate-pre {{ font-family:monospace; font-size:12px; background:#f8fafc; border:1px solid var(--border);
                     border-radius:6px; padding:10px; white-space:pre-wrap; word-break:break-all;
                     max-height:200px; overflow-y:auto; color:var(--text2); }}

/* Error/skip messages */
.error-msg {{ padding:12px; color:var(--red); font-size:13px; }}
.skip-msg {{ padding:12px; color:var(--yellow); font-size:13px; }}

/* Raw config section for failed/skipped */
.raw-config-section {{ margin-top:8px; padding:0 12px 12px; }}
.raw-config-label {{ font-weight:600; font-size:12px; color:var(--text2); margin-bottom:4px; }}
.raw-config-pre {{ font-family:"SF Mono",Monaco,Consolas,monospace; font-size:12px; background:#f8fafc;
                   border:1px solid var(--border); border-radius:6px; padding:10px;
                   white-space:pre-wrap; word-break:break-all; max-height:300px;
                   overflow-y:auto; color:var(--text); line-height:1.5; }}

/* Toast */
.toast {{ position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); padding:16px 32px; border-radius:8px;
          font-size:14px; font-weight:500; z-index:300; transition:opacity 0.3s;
          box-shadow:0 4px 20px rgba(0,0,0,0.2); }}
.toast.success {{ background:#065f46; color:#fff; }}
.toast.error {{ background:#991b1b; color:#fff; }}
.toast.info {{ background:#1e40af; color:#fff; }}

.modal-overlay {{ position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
                  z-index:400; display:flex; align-items:center; justify-content:center; }}
.modal-box {{ background:#fff; border-radius:12px; padding:24px 32px; max-width:500px; width:90%;
              box-shadow:0 8px 30px rgba(0,0,0,0.25); text-align:center; }}
.modal-msg {{ font-size:14px; color:#991b1b; font-weight:500; margin-bottom:16px; line-height:1.6;
              word-break:break-all; font-family:"SF Mono",Monaco,Consolas,monospace; white-space:pre-wrap;
              text-align:left; max-height:60vh; overflow-y:auto; }}
.modal-ok-btn {{ padding:8px 28px; border:none; border-radius:6px; background:#991b1b; color:#fff;
                 font-size:13px; font-weight:600; cursor:pointer; transition:background 0.2s; }}
.modal-ok-btn:hover {{ background:#7f1d1d; }}

.footer {{ text-align:center; padding:20px; color:var(--text2); font-size:12px; }}
</style>
</head>
<body>

<!-- Fixed top bar -->
<div class="top-bar">
  <button class="push-all-btn" id="pushAllCfgBtn" onclick="pushAllComponents()">推送全部组件</button>
  <button class="push-all-btn" id="pushAllKpBtn" onclick="pushAllKpAll()" style="background:#059669;">推送全部知识点</button>
  <button class="push-all-btn" onclick="exportHtml()" style="background:#7c3aed;" title="将当前所有修改嵌入并下载新HTML">导出修改HTML</button>
  <input type="text" id="apiUrlInput" value="{api_url or 'https://api-test.valavala.com/content/feishu/notify'}"
         style="font-size:11px;padding:4px 8px;border:1px solid #475569;border-radius:4px;background:#1e293b;color:#cbd5e1;width:320px;"
         title="推送API地址（可修改）" />
  <div class="progress-wrap">
    <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
    <div class="progress-text" id="progressText">就绪</div>
  </div>
  <div class="push-summary">
    <span class="s-ok" id="summaryOk">成功: 0</span>
    <span class="s-fail" id="summaryFail">失败: 0</span>
  </div>
</div>

<div class="container">
  <div class="header">
    <h1>{html.escape(title)}</h1>
    <div class="meta">
      <span>生成时间: {timestamp}</span>
      <span>模型: {html.escape(model_name or "doubao-seed-2.0-pro")}</span>
      <span>Wiki: {html.escape(wiki_token or report.get("wiki_url", ""))}</span>
    </div>
  </div>

  <div class="stats">
    <div class="stat-split">
      <div class="split-item"><div class="split-dot core"></div><div class="split-num">{core_count}</div><div class="split-label">核心互动</div></div>
      <div class="split-item"><div class="split-dot mid"></div><div class="split-num">{mid_count}</div><div class="split-label">中互动</div></div>
    </div>
    <div class="stat-card"><div class="num">{total}</div><div class="label">总组件</div></div>
    <div class="stat-card ok"><div class="num">{success}</div><div class="label">成功</div></div>
    <div class="stat-card fail"><div class="num">{failed}</div><div class="label">失败</div></div>
    <div class="stat-card skip"><div class="num">{skipped}</div><div class="label">跳过</div></div>
  </div>

  {components_html}

  <div class="footer">Interactive Component JSON Generator — Report v2</div>
</div>

<div class="toast" id="toast" style="display:none;"></div>
<div class="modal-overlay" id="modalOverlay" style="display:none;" onclick="closeModal()">
  <div class="modal-box" onclick="event.stopPropagation()">
    <div class="modal-msg" id="modalMsg"></div>
    <button class="modal-ok-btn" onclick="closeModal()">确定</button>
  </div>
</div>

<script>
// ============ Embedded Data ============
const COMPONENTS = {components_json};
const PUSH_FIELD_MAP = {push_field_map_json};
const CHARACTER_MAP = {character_map_json};
const SECTION_CHAR_MAP = {section_char_map_json};
const LEVEL = {level_json};

function getApiUrl() {{
  const input = document.getElementById('apiUrlInput');
  return input ? input.value.trim() : "{api_url or 'https://api-test.valavala.com/content/feishu/notify'}";
}}

function getRegenUrl() {{
  const pushUrl = getApiUrl();
  // 从 push URL 推导 regenerate URL（同一服务器）
  const idx = pushUrl.indexOf('/api/');
  if (idx > 0) return pushUrl.substring(0, idx) + '/api/regenerate';
  return pushUrl.replace(/\\/[^\\/]*$/, '/api/regenerate');
}}

// ============ Card Toggle ============
function toggleCard(idx) {{
  const card = document.getElementById('card-' + idx);
  card.classList.toggle('open');
}}

// ============ Visual Editor ============
function getVeKeys(veId) {{
  const ve = document.getElementById(veId);
  if (!ve) return [];
  const keys = [];
  ve.querySelectorAll('.ve-key').forEach(td => keys.push(td.getAttribute('title')));
  return keys;
}}

function syncVisualToRaw(secId, veId, taId, idx, key) {{
  const ve = document.getElementById(veId);
  const ta = document.getElementById(taId);
  if (!ve || !ta) return false;

  const keys = getVeKeys(veId);
  const obj = {{}};
  for (const fk of keys) {{
    const inputId = 'vf-' + idx + '-' + key + '-' + fk;
    const el = document.getElementById(inputId);
    if (!el) continue;
    const vtype = el.getAttribute('data-vtype');
    const raw = (el.tagName === 'TEXTAREA') ? el.value : el.value;
    try {{
      if (vtype === 'json') {{
        obj[fk] = JSON.parse(raw);
      }} else if (vtype === 'number') {{
        const n = Number(raw);
        obj[fk] = isNaN(n) ? raw : (raw.includes('.') ? parseFloat(raw) : parseInt(raw, 10));
      }} else if (vtype === 'bool') {{
        obj[fk] = (raw === 'true');
      }} else if (vtype === 'null') {{
        obj[fk] = (raw === 'null') ? null : raw;
      }} else {{
        obj[fk] = raw;
      }}
    }} catch(e) {{
      showToast('字段 "' + fk + '" 的值不是合法JSON: ' + e.message, 'error', 4000);
      return false;
    }}
  }}
  ta.value = JSON.stringify(obj, null, 2);
  hideVeError(secId);
  return true;
}}

function syncRawToVisual(secId, veId, taId, idx, key) {{
  const ve = document.getElementById(veId);
  const ta = document.getElementById(taId);
  if (!ve || !ta) return false;

  let obj;
  try {{
    obj = JSON.parse(ta.value);
  }} catch(e) {{
    showVeError(secId, '原始JSON解析失败: ' + e.message);
    return false;
  }}
  if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {{
    showVeError(secId, '根节点必须是一个JSON对象');
    return false;
  }}

  // Rebuild visual rows
  const tbody = ve.querySelector('tbody');
  tbody.innerHTML = '';
  for (const [fk, fv] of Object.entries(obj)) {{
    const inputId = 'vf-' + idx + '-' + key + '-' + fk;
    const fkEsc = escHtml(fk);
    let valHtml = '';
    if (fv === null) {{
      valHtml = '<input type="text" id="' + inputId + '" class="ve-input ve-null" value="null" data-vtype="null" />';
    }} else if (typeof fv === 'boolean') {{
      valHtml = '<input type="text" id="' + inputId + '" class="ve-input ve-bool" value="' + fv + '" data-vtype="bool" />';
    }} else if (typeof fv === 'number') {{
      valHtml = '<input type="text" id="' + inputId + '" class="ve-input ve-num" value="' + fv + '" data-vtype="number" />';
    }} else if (typeof fv === 'object') {{
      const jsonStr = JSON.stringify(fv, null, 2);
      const rows = Math.min(Math.max(jsonStr.split('\\n').length, 2), 8);
      valHtml = '<textarea id="' + inputId + '" class="ve-input ve-complex" rows="' + rows + '" data-vtype="json">' + escHtml(jsonStr) + '</textarea>';
    }} else {{
      const sv = String(fv);
      if (sv.length > 80 || sv.includes('\\n')) {{
        const rows = Math.min(Math.max(sv.split('\\n').length, 2), 5);
        valHtml = '<textarea id="' + inputId + '" class="ve-input ve-str-long" rows="' + rows + '" data-vtype="string">' + escHtml(sv) + '</textarea>';
      }} else {{
        valHtml = '<input type="text" id="' + inputId + '" class="ve-input ve-str" value="' + escHtml(sv) + '" data-vtype="string" />';
      }}
    }}
    tbody.innerHTML += '<tr class="ve-row"><td class="ve-key" title="' + fkEsc + '">' + fkEsc + '</td><td class="ve-val">' + valHtml + '</td></tr>';
  }}
  hideVeError(secId);
  return true;
}}

function toggleJsonView(secId, veId, taId, idx, key) {{
  const veDiv = document.getElementById(veId);
  const rawDiv = document.getElementById('raw-' + secId);
  const btn = document.getElementById('btn-' + secId);
  const btnEl = btn.parentElement;

  if (rawDiv.style.display === 'none') {{
    // Switch to raw: sync visual → raw
    if (!syncVisualToRaw(secId, veId, taId, idx, key)) return;
    veDiv.style.display = 'none';
    rawDiv.style.display = 'block';
    btn.textContent = '可视化编辑';
    btnEl.classList.add('active');
    const ta = document.getElementById(taId);
    if (ta) syncLineNums(ta);
  }} else {{
    // Switch to visual: sync raw → visual
    if (!syncRawToVisual(secId, veId, taId, idx, key)) return;
    rawDiv.style.display = 'none';
    veDiv.style.display = 'block';
    btn.textContent = '查看原始JSON';
    btnEl.classList.remove('active');
  }}
}}

function showVeError(secId, msg) {{
  const el = document.getElementById('err-' + secId);
  if (el) {{ el.textContent = '\u26a0 ' + msg; el.style.display = 'block'; }}
  showToast(msg, 'error', 4000);
}}

function syncLineNums(ta) {{
  const numLines = ta.value.split('\\n').length;
  const lnDiv = ta.previousElementSibling;
  if (!lnDiv || !lnDiv.classList.contains('json-line-nums')) return;
  let html = '';
  for (let i = 1; i <= numLines; i++) html += '<span>' + i + '</span>';
  lnDiv.innerHTML = html;
  lnDiv.scrollTop = ta.scrollTop;
}}

function hideVeError(secId) {{
  const el = document.getElementById('err-' + secId);
  if (el) el.style.display = 'none';
}}

function escHtml(s) {{
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}}

// ============ Toast ============
let _batchMode = false;
let _batchErrors = [];
let _batchContext = '';

function showToast(msg, type, duration) {{
  if (type === 'error') {{
    if (_batchMode) {{
      _batchErrors.push(_batchContext ? ('[' + _batchContext + '] ' + msg) : msg);
      return;
    }}
    showModal(msg);
    return;
  }}
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.className = 'toast ' + (type || 'info');
  t.style.display = 'block';
  clearTimeout(t._timer);
  t._timer = setTimeout(() => {{ t.style.display = 'none'; }}, type === 'success' ? 1000 : (duration || 3000));
}}

function showModal(msg) {{
  const overlay = document.getElementById('modalOverlay');
  const msgEl = document.getElementById('modalMsg');
  msgEl.textContent = msg;
  overlay.style.display = 'flex';
  document.addEventListener('keydown', _modalEscHandler);
}}

function closeModal() {{
  document.getElementById('modalOverlay').style.display = 'none';
  document.removeEventListener('keydown', _modalEscHandler);
}}

function _modalEscHandler(e) {{
  if (e.key === 'Escape') closeModal();
}}

// ============ Collect Data from DOM ============
function ensureSynced(idx, key) {{
  // If visual editor is visible, sync it to textarea first
  const secId = 'sec-' + idx + '-' + key;
  const veId = 've-' + idx + '-' + key;
  const taId = 'ta-' + idx + '-' + key;
  const veDiv = document.getElementById(veId);
  const rawDiv = document.getElementById('raw-' + secId);
  if (veDiv && rawDiv && rawDiv.style.display === 'none') {{
    return syncVisualToRaw(secId, veId, taId, idx, key);
  }}
  return true;
}}

function collectComponentData(idx) {{
  const comp = COMPONENTS[idx - 1];
  if (!comp || (comp.status !== 'generated' && comp.status !== 'saved')) return null;

  const data = {{}};
  if (comp.category === 'core') {{
    const fields = PUSH_FIELD_MAP[comp.cType] || [];
    for (const key of fields) {{
      if (!ensureSynced(idx, key)) return null;
      const ta = document.getElementById('ta-' + idx + '-' + key);
      if (ta) {{
        try {{ data[key] = JSON.parse(ta.value); }}
        catch(e) {{ showToast('JSON解析错误: ' + key + ' (' + e.message + ')', 'error'); return null; }}
      }}
    }}
  }} else {{
    if (!ensureSynced(idx, 'jsonData')) return null;
    const ta = document.getElementById('ta-' + idx + '-jsonData');
    if (ta) {{
      try {{ data.jsonData = JSON.parse(ta.value); }}
      catch(e) {{ showToast('JSON解析错误: jsonData (' + e.message + ')', 'error'); return null; }}
    }}
  }}
  data.cType = comp.cType;
  data.category = comp.category;
  return data;
}}

function collectKpInfo(idx) {{
  const comp = COMPONENTS[idx - 1];
  if (!comp || !comp.kpInfo) return null;

  // Sync visual editor to textarea first if in visual mode
  ensureSynced(idx, 'kpInfo');

  // 优先从 textarea 读取（用户可能直接编辑了 JSON）
  const ta = document.getElementById('ta-' + idx + '-kpInfo');
  if (ta) {{
    try {{
      const parsed = JSON.parse(ta.value);
      if (parsed && parsed.kpInfo) {{
        parsed.kpInfo = parsed.kpInfo.filter(k => {{ delete k.candidates; return k.kpId; }});
        if (parsed.kpInfo.length === 0) return null;
        return parsed;
      }}
    }} catch(e) {{
      // textarea JSON 无效，fallback 到表格
    }}
  }}

  const kpData = JSON.parse(JSON.stringify(comp.kpInfo));
  const kpList = kpData.kpInfo || [];

  for (let ki = 0; ki < kpList.length; ki++) {{
    const idInput = document.getElementById('kp-' + idx + '-' + ki + '-id');
    const typeInput = document.getElementById('kp-' + idx + '-' + ki + '-type');
    const skillInput = document.getElementById('kp-' + idx + '-' + ki + '-skill');
    const skillNameInput = document.getElementById('kp-' + idx + '-' + ki + '-skillname');
    if (idInput) kpList[ki].kpId = idInput.value.trim() || null;
    if (typeInput) kpList[ki].kpType = typeInput.value;
    if (skillInput) kpList[ki].kpSkill = skillInput.value;
    if (skillNameInput) kpList[ki].kpSkillName = skillNameInput.value;
    delete kpList[ki].candidates;
  }}
  kpData.kpInfo = kpList.filter(k => k.kpId);
  if (kpData.kpInfo.length === 0) return null;
  return kpData;
}}

// ============ Build Push Bodies ============
function buildPushBody(idx) {{
  const comp = COMPONENTS[idx - 1];
  const data = collectComponentData(idx);
  if (!data) return null;

  if (comp.category === 'mid') {{
    return {{ componentData: JSON.stringify(data.jsonData) }};
  }}

  const fields = PUSH_FIELD_MAP[comp.cType];
  if (!fields) {{
    showToast('未知的核心互动类型: ' + comp.cType, 'error');
    return null;
  }}
  const body = {{ pushType: 'core' }};
  for (const key of fields) {{
    if (data[key]) body[key] = JSON.stringify(data[key]);
  }}
  return body;
}}

function buildKpPushBody(idx) {{
  const kpInfo = collectKpInfo(idx);
  if (!kpInfo) return null;
  return {{ pushRelationKp: JSON.stringify(kpInfo) }};
}}

// ============ Status Update ============
function setStatus(id, state) {{
  const el = document.getElementById(id);
  if (!el) return;
  el.className = 'push-icon ' + state;
}}

// ============ Push API ============
async function doPush(body, label) {{
  try {{
    const url = getApiUrl();
    const resp = await fetch(url, {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify(body),
    }});
    if (resp.ok) {{
      return {{ ok: true }};
    }} else {{
      const text = await resp.text().catch(() => '');
      return {{ ok: false, error: resp.status + ' ' + text.substring(0, 300) }};
    }}
  }} catch(e) {{
    return {{ ok: false, error: '网络错误: ' + e.message }};
  }}
}}

async function pushComponent(idx) {{
  const body = buildPushBody(idx);
  if (!body) {{
    setStatus('cfg-status-' + idx, 'fail');
    return false;
  }}

  setStatus('cfg-status-' + idx, 'loading');
  const r = await doPush(body, '组件配置');
  if (r.ok) {{
    setStatus('cfg-status-' + idx, 'ok');
    showToast('组件 #' + idx + ' 配置推送成功', 'success');
  }} else {{
    setStatus('cfg-status-' + idx, 'fail');
    showToast('组件 #' + idx + ' 配置推送失败: ' + r.error, 'error', 5000);
  }}
  return r.ok;
}}

async function pushKp(idx) {{
  const body = buildKpPushBody(idx);
  if (!body) {{
    setStatus('kp-status-' + idx, 'fail');
    showToast('组件 #' + idx + ' 知识点推送失败: 无知识点或kpId为空', 'error');
    return false;
  }}

  setStatus('kp-status-' + idx, 'loading');
  const r = await doPush(body, '知识点');
  if (r.ok) {{
    setStatus('kp-status-' + idx, 'ok');
    showToast('组件 #' + idx + ' 知识点推送成功', 'success');
  }} else {{
    setStatus('kp-status-' + idx, 'fail');
    showToast('组件 #' + idx + ' 知识点推送失败: ' + r.error, 'error', 5000);
  }}
  return r.ok;
}}

// ============ Push All ============
let pushAllRunning = false;

async function _runPushAll(mode) {{
  if (pushAllRunning) return;
  pushAllRunning = true;
  _batchMode = true;
  _batchErrors = [];

  const cfgBtn = document.getElementById('pushAllCfgBtn');
  const kpBtn = document.getElementById('pushAllKpBtn');
  if (cfgBtn) {{ cfgBtn.disabled = true; cfgBtn.textContent = '推送中...'; }}
  if (kpBtn) {{ kpBtn.disabled = true; kpBtn.textContent = '推送中...'; }}

  const validIdxs = [];
  for (let i = 1; i <= COMPONENTS.length; i++) {{
    const c = COMPONENTS[i - 1];
    if (c.status === 'generated' || c.status === 'saved') validIdxs.push(i);
  }}

  const perComp = (mode === 'both') ? 2 : 1;
  const total = validIdxs.length * perComp;
  let done = 0, okCount = 0, failCount = 0;

  function updateProgress() {{
    const pct = total > 0 ? Math.round(done / total * 100) : 0;
    document.getElementById('progressFill').style.width = pct + '%';
    document.getElementById('progressText').textContent = done + '/' + total;
    document.getElementById('summaryOk').textContent = '成功: ' + okCount;
    document.getElementById('summaryFail').textContent = '失败: ' + failCount;
  }}

  for (const idx of validIdxs) {{
    const comp = COMPONENTS[idx - 1];
    _batchContext = comp.cType + ' / cId:' + comp.cId;

    if (mode === 'config' || mode === 'both') {{
      const body1 = buildPushBody(idx);
      if (body1) {{
        setStatus('cfg-status-' + idx, 'loading');
        const r1 = await doPush(body1);
        if (r1.ok) {{ setStatus('cfg-status-' + idx, 'ok'); okCount++; }}
        else {{ setStatus('cfg-status-' + idx, 'fail'); failCount++; _batchErrors.push('[' + _batchContext + '] 配置推送失败: ' + r1.error); }}
      }} else {{ setStatus('cfg-status-' + idx, 'fail'); failCount++; }}
      done++; updateProgress();
    }}

    if (mode === 'kp' || mode === 'both') {{
      const body2 = buildKpPushBody(idx);
      if (body2) {{
        setStatus('kp-status-' + idx, 'loading');
        const r2 = await doPush(body2);
        if (r2.ok) {{ setStatus('kp-status-' + idx, 'ok'); okCount++; }}
        else {{ setStatus('kp-status-' + idx, 'fail'); failCount++; _batchErrors.push('[' + _batchContext + '] 知识点推送失败: ' + r2.error); }}
      }} else {{
        setStatus('kp-status-' + idx, 'fail');
        failCount++;
        _batchErrors.push('[' + _batchContext + '] 知识点推送失败: 无知识点或kpId为空');
      }}
      done++; updateProgress();
    }}
  }}

  _batchContext = '';
  if (cfgBtn) {{ cfgBtn.disabled = false; cfgBtn.textContent = '推送全部组件'; }}
  if (kpBtn) {{ kpBtn.disabled = false; kpBtn.textContent = '推送全部知识点'; }}
  pushAllRunning = false;
  _batchMode = false;

  if (failCount > 0) {{
    let summary = '推送完成: 成功 ' + okCount + ', 失败 ' + failCount;
    if (_batchErrors.length > 0) summary += '\\n\\n失败详情:\\n' + _batchErrors.join('\\n');
    showModal(summary);
  }} else {{
    showToast('推送完成: 全部成功 (' + okCount + '项)', 'success');
  }}
}}

function pushAllComponents() {{ _runPushAll('config'); }}
function pushAllKpAll() {{ _runPushAll('kp'); }}

// ============ Regenerate ============
function toggleRegenPanel(idx, mode) {{
  const compPanel = document.getElementById('regen-panel-' + idx);
  const kpPanel = document.getElementById('regen-kp-panel-' + idx);

  if (mode === 'kp') {{
    if (compPanel) compPanel.style.display = 'none';
    if (kpPanel) {{
      const showing = kpPanel.style.display !== 'block';
      kpPanel.style.display = showing ? 'block' : 'none';
      if (showing) {{
        const card = document.getElementById('card-' + idx);
        if (card) card.classList.add('open');
      }}
    }}
  }} else {{
    if (kpPanel) kpPanel.style.display = 'none';
    if (compPanel) {{
      const showing = compPanel.style.display !== 'block';
      compPanel.style.display = showing ? 'block' : 'none';
      if (showing) {{
        const card = document.getElementById('card-' + idx);
        if (card) card.classList.add('open');
      }}
    }}
  }}
}}

async function regenerateComponentJson(idx) {{
  const comp = COMPONENTS[idx - 1];
  const ta = document.getElementById('regen-config-' + idx);
  const btn = document.getElementById('regen-exec-' + idx);
  if (!ta || !btn) return;

  const teachingConfig = ta.value.trim();
  if (!teachingConfig) {{
    showToast('原始组件配置不能为空', 'error');
    return;
  }}

  btn.disabled = true;
  btn.textContent = '生成中...';
  setStatus('regen-status-' + idx, 'loading');

  const regenUrl = getRegenUrl();

  try {{
    const resp = await fetch(regenUrl, {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{
        mode: 'component',
        teaching_config: teachingConfig,
        type_name: comp.type_name,
        cId: comp.cId,
        character_map: CHARACTER_MAP,
        section_char_map: SECTION_CHAR_MAP,
        config_info: comp.config_info || '',
        level: LEVEL,
      }}),
    }});

    const data = await resp.json();

    if (!resp.ok) {{
      throw new Error(data.error || ('HTTP ' + resp.status));
    }}

    // Update COMPONENTS data (only jsonData/core fields, keep kpInfo)
    comp.status = 'generated';
    comp.category = data.category || comp.category;
    comp.cType = data.cType || comp.cType;
    comp.raw_config = teachingConfig;

    if (comp.category === 'core') {{
      const fields = PUSH_FIELD_MAP[comp.cType] || [];
      for (const key of fields) {{
        if (data[key]) comp[key] = data[key];
      }}
    }} else {{
      if (data.jsonData) comp.jsonData = data.jsonData;
    }}
    // kpInfo not updated in component-only mode

    // Rebuild body HTML (pass existing kpInfo from comp, not from data)
    const displayData = {{ ...data }};
    if (!displayData.kpInfo && comp.kpInfo) displayData.kpInfo = comp.kpInfo;
    rebuildComponentBody(idx, displayData);

    setStatus('regen-status-' + idx, 'ok');
    btn.textContent = '执行重新生成组件';
    btn.disabled = false;

    // Update status label
    const statusEl = document.getElementById('status-label-' + idx);
    if (statusEl) {{
      statusEl.className = 'comp-status ok';
      statusEl.textContent = '生成成功';
    }}

    showToast('组件 #' + idx + ' 组件配置重新生成成功', 'success');
  }} catch(e) {{
    setStatus('regen-status-' + idx, 'fail');
    btn.textContent = '执行重新生成组件';
    btn.disabled = false;
    showToast('组件 #' + idx + ' 组件重新生成失败: ' + e.message, 'error');
  }}
}}

async function regenerateComponentKp(idx) {{
  const comp = COMPONENTS[idx - 1];
  const ta = document.getElementById('regen-kp-text-' + idx);
  const btn = document.getElementById('regen-kp-exec-' + idx);
  if (!ta || !btn) return;

  const knowledgeText = ta.value.trim();
  if (!knowledgeText) {{
    showToast('知识点文本不能为空', 'error');
    return;
  }}

  btn.disabled = true;
  btn.textContent = '生成中...';
  setStatus('regen-kp-status-' + idx, 'loading');

  const regenUrl = getRegenUrl();

  try {{
    const resp = await fetch(regenUrl, {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{
        mode: 'kp',
        type_name: comp.type_name,
        cId: comp.cId,
        knowledge_text: knowledgeText,
        level: LEVEL,
      }}),
    }});

    const data = await resp.json();

    if (!resp.ok) {{
      throw new Error(data.error || ('HTTP ' + resp.status));
    }}

    // Update kpInfo only
    if (data.kpInfo) {{
      comp.kpInfo = data.kpInfo;
    }} else {{
      comp.kpInfo = null;
    }}
    comp.knowledge_text = knowledgeText;

    // Rebuild body HTML (keep existing jsonData/core fields)
    const displayData = {{}};
    if (comp.category === 'core') {{
      const fields = PUSH_FIELD_MAP[comp.cType] || [];
      for (const key of fields) {{
        if (comp[key]) displayData[key] = comp[key];
      }}
    }} else {{
      if (comp.jsonData) displayData.jsonData = comp.jsonData;
    }}
    displayData.kpInfo = comp.kpInfo;
    rebuildComponentBody(idx, displayData);

    setStatus('regen-kp-status-' + idx, 'ok');
    btn.textContent = '执行重新生成知识点';
    btn.disabled = false;

    showToast('组件 #' + idx + ' 知识点重新生成成功', 'success');
  }} catch(e) {{
    setStatus('regen-kp-status-' + idx, 'fail');
    btn.textContent = '执行重新生成知识点';
    btn.disabled = false;
    showToast('组件 #' + idx + ' 知识点重新生成失败: ' + e.message, 'error');
  }}
}}

function rebuildComponentBody(idx, data) {{
  const comp = COMPONENTS[idx - 1];
  const resultDiv = document.getElementById('regen-result-' + idx);
  if (!resultDiv) return;

  // Clear old content above regen-result (error-msg, json-sections etc)
  const body = document.getElementById('body-' + idx);
  const regenPanel = document.getElementById('regen-panel-' + idx);
  const regenKpPanel = document.getElementById('regen-kp-panel-' + idx);
  // Remove everything except regen-result and regen panels
  while (body.firstChild && body.firstChild !== resultDiv && body.firstChild !== regenPanel && body.firstChild !== regenKpPanel) {{
    body.removeChild(body.firstChild);
  }}

  // Build new JSON sections
  let html = '';
  if (comp.category === 'core') {{
    const fields = PUSH_FIELD_MAP[comp.cType] || [];
    for (const key of fields) {{
      const val = data[key];
      if (val) {{
        html += buildJsonSectionHtml(key, key, val, idx);
      }}
    }}
  }} else {{
    if (data.jsonData) {{
      html += buildJsonSectionHtml('jsonData', 'jsonData', data.jsonData, idx);
    }}
  }}

  const kpToShow = data.kpInfo || comp.kpInfo;
  if (kpToShow) {{
    const kpClean = JSON.parse(JSON.stringify(kpToShow));
    if (kpClean.kpInfo) {{
      kpClean.kpInfo.forEach(k => delete k.candidates);
      if (kpClean.kpInfo.length > 0) {{
        html += buildJsonSectionHtml('kpInfo JSON', 'kpInfo', kpClean, idx);
      }}
    }}
  }}

  // Insert before resultDiv
  const temp = document.createElement('div');
  temp.innerHTML = html;
  while (temp.firstChild) {{
    body.insertBefore(temp.firstChild, resultDiv);
  }}

  // Show push buttons in header if not already there
  ensurePushButtons(idx);
}}

function buildJsonSectionHtml(label, key, data, idx) {{
  const raw = JSON.stringify(data, null, 2);
  const escaped = raw.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  const taId = 'ta-' + idx + '-' + key;
  const secId = 'sec-' + idx + '-' + key;
  const veId = 've-' + idx + '-' + key;
  const numLines = raw.split('\\n').length;
  const rows = Math.min(Math.max(numLines, 5), 30);
  let lineNums = '';
  for (let i = 1; i <= numLines; i++) lineNums += '<span>' + i + '</span>';

  const isObj = data && typeof data === 'object' && !Array.isArray(data);
  const toggleBtn = isObj
    ? '<button class="ve-toggle-btn active" data-sec="' + secId + '" data-ve="' + veId + '" data-ta="' + taId + '" data-idx="' + idx + '" data-key="' + key + '" onclick="toggleJsonView(this.dataset.sec,this.dataset.ve,this.dataset.ta,this.dataset.idx,this.dataset.key)"><span id="btn-' + secId + '">\u53ef\u89c6\u5316\u7f16\u8f91</span></button>'
    : '';
  const veDiv = isObj
    ? '<div class="ve-editor" id="' + veId + '" data-idx="' + idx + '" data-key="' + key + '" style="display:none;"><table class="ve-table"><tbody></tbody></table></div>'
    : '';

  return '<div class="json-section" id="' + secId + '">' +
    '<div class="json-section-label">' + escHtml(label) + toggleBtn + '</div>' +
    veDiv +
    '<div class="ve-raw" id="raw-' + secId + '">' +
      '<div class="json-lined-wrap">' +
        '<div class="json-line-nums" id="ln-' + taId + '">' + lineNums + '</div>' +
        '<textarea id="' + taId + '" data-idx="' + idx + '" data-key="' + key + '" class="json-textarea" rows="' + rows + '" oninput="syncLineNums(this)" onscroll="this.previousElementSibling.scrollTop=this.scrollTop">' + escaped + '</textarea>' +
      '</div>' +
      '<div class="ve-error" id="err-' + secId + '" style="display:none;"></div>' +
    '</div>' +
    '</div>';
}}

function ensurePushButtons(idx) {{
  const header = document.querySelector('#card-' + idx + ' .comp-header');
  if (!header) return;
  // Check if push buttons already exist
  if (header.querySelector('.config-btn')) return;
  // Find regen-btn to insert before it
  const regenBtn = header.querySelector('.regen-btn');
  if (!regenBtn) return;

  const cfgBtn = document.createElement('button');
  cfgBtn.className = 'push-btn config-btn';
  cfgBtn.setAttribute('data-idx', idx);
  cfgBtn.onclick = function(e) {{ e.stopPropagation(); pushComponent(idx); }};
  cfgBtn.innerHTML = '配置推送<span class="push-icon" id="cfg-status-' + idx + '"></span>';

  const kpBtn = document.createElement('button');
  kpBtn.className = 'push-btn kp-btn';
  kpBtn.setAttribute('data-idx', idx);
  kpBtn.onclick = function(e) {{ e.stopPropagation(); pushKp(idx); }};
  kpBtn.innerHTML = '知识推送<span class="push-icon" id="kp-status-' + idx + '"></span>';

  header.insertBefore(kpBtn, regenBtn);
  header.insertBefore(cfgBtn, kpBtn);
}}

// ============ LocalStorage Auto-Save ============
const SAVE_KEY = 'icj_' + document.title + '_{timestamp}';

function saveEdits() {{
  const state = {{}};
  document.querySelectorAll('textarea, input.kp-input, input.ve-input, #apiUrlInput').forEach(el => {{
    if (el.id) state[el.id] = el.value;
  }});
  try {{ localStorage.setItem(SAVE_KEY, JSON.stringify(state)); }} catch(e) {{}}
}}

function restoreEdits() {{
  let state;
  try {{ state = JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); }} catch(e) {{ return; }}
  if (!state) return;
  for (const [id, val] of Object.entries(state)) {{
    const el = document.getElementById(id);
    if (el) el.value = val;
  }}
  document.querySelectorAll('.json-textarea').forEach(ta => syncLineNums(ta));
}}

let _saveTimer = null;
document.addEventListener('input', function(e) {{
  const el = e.target;
  if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {{
    clearTimeout(_saveTimer);
    _saveTimer = setTimeout(saveEdits, 800);
  }}
}});

// ============ Export Updated HTML ============
function exportHtml() {{
  document.querySelectorAll('textarea').forEach(ta => {{ ta.textContent = ta.value; }});
  document.querySelectorAll('input[id]').forEach(inp => {{ inp.setAttribute('value', inp.value); }});
  const blob = new Blob(['<!DOCTYPE html>\\n' + document.documentElement.outerHTML], {{type: 'text/html;charset=utf-8'}});
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = document.title.replace(/ \u2014.*$/, '') + '_modified.html';
  a.click();
  URL.revokeObjectURL(a.href);
  showToast('HTML已导出，当前修改已嵌入', 'success');
}}

restoreEdits();
</script>
</body>
</html>'''

    # Output path
    if output_path is None:
        outputs_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "outputs")
        os.makedirs(outputs_dir, exist_ok=True)
        safe_title = "".join(c if c.isalnum() or c in "-_ " else "_" for c in title).strip()
        output_path = os.path.join(outputs_dir, f"{safe_title}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html")

    with open(output_path, "w", encoding="utf-8") as f:
        f.write(html_content)

    logger.info(f"HTML报告已生成: {output_path}")
    return output_path
