"""
根据 bid_fill_draft.json（正文真源）+ sq_bid_outline.json（结构真源）
生成投标文档 docx。

API 用法:
    from scripts.generate_docx import generate_bid_docx

    # 方式 1: 传文件路径，自动保存
    generate_bid_docx(
        bid_fill_draft="data/bid_fill_draft.json",
        sq_bid_outline="data/sq_bid_outline.json",
        project_name="包件一 松江区办公中心物业管理服务",
        header_label="商务标",
        output_path="output/标书.docx",
    )

    # 方式 2: 传已解析的 dict，返回 Document 对象
    doc = generate_bid_docx(
        bid_fill_draft=json.load(open("data/bid_fill_draft.json")),
        sq_bid_outline=json.load(open("data/sq_bid_outline.json")),
    )

CLI 用法:
    python scripts/generate_docx.py [--help]

参考: data/真源解析说明.md
样式规范: 阶段5 - 生成标书
"""

import json
import re
from pathlib import Path
from typing import Union

import docx
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement


# ── 字体常量 ──────────────────────────────────────────

FONT_SIZE = {"二号": 22, "小三": 15, "四号": 14, "小四": 12, "五号": 10.5}

FONT_SONG = "宋体"        # 正文中文字体
FONT_HEI = "黑体"         # 标题中文字体
FONT_TNR = "Times New Roman"  # 数字/英文字体


# ════════════════════════════════════════════════════════
# 字体工具 — 核心规则：西文 = TNR，东亚 = 宋体/黑体
# ════════════════════════════════════════════════════════

def _set_font(run, ea_font=FONT_SONG, size_pt=12, bold=False, color_rgb=(0, 0, 0)):
    """
    统一设置 run 字体：
    - 西文（数字/英文）→ Times New Roman
    - 东亚（中文）→ ea_font 参数（宋体/黑体）
    - 文字颜色始终黑色
    - 禁用倾斜
    """
    run.font.name = FONT_TNR
    run.font.element.rPr.rFonts.set(qn('w:eastAsia'), ea_font)
    run.font.size = Pt(size_pt)
    run.font.bold = bold
    if color_rgb:
        run.font.color.rgb = RGBColor(*color_rgb)
    run.font.italic = False
    # 注意: underline 不由这里控制，由调用处按需设置


def _apply_style_fonts(rPr, ea_font):
    """在 rPr 上设置西文=TNR + 东亚=ea_font，并清除 theme 引用"""
    rFonts = rPr.find(qn('w:rFonts'))
    if rFonts is None:
        rFonts = OxmlElement('w:rFonts')
        rPr.insert(0, rFonts)
    rFonts.set(qn('w:ascii'), FONT_TNR)
    rFonts.set(qn('w:hAnsi'), FONT_TNR)
    rFonts.set(qn('w:eastAsia'), ea_font)
    # 清除 theme 引用（否则 theme 会覆盖显式字体名）
    for attr in ['asciiTheme', 'hAnsiTheme', 'eastAsiaTheme', 'cstheme']:
        key = qn(f'w:{attr}')
        if key in rFonts.attrib:
            del rFonts.attrib[key]


def _apply_normal_style(style):
    """设置 Normal 样式基准"""
    rPr = style.element.get_or_add_rPr()
    _apply_style_fonts(rPr, FONT_SONG)
    style.font.size = Pt(FONT_SIZE["小四"])
    style.font.color.rgb = RGBColor(0, 0, 0)
    pf = style.paragraph_format
    pf.space_before = Pt(0)
    pf.space_after = Pt(0)
    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
    pf.line_spacing = 1.5


def _apply_heading_style(style, size_pt, ea_font, bold):
    """统一设置标题样式"""
    rPr = style.element.get_or_add_rPr()
    _apply_style_fonts(rPr, ea_font)
    # 字号
    sz = rPr.find(qn('w:sz'))
    if sz is None:
        sz = OxmlElement('w:sz')
        rPr.append(sz)
    sz.set(qn('w:val'), str(int(size_pt * 2)))
    szCs = rPr.find(qn('w:szCs'))
    if szCs is None:
        szCs = OxmlElement('w:szCs')
        rPr.append(szCs)
    szCs.set(qn('w:val'), str(int(size_pt * 2)))
    # 加粗
    b = rPr.find(qn('w:b'))
    if b is None:
        b = OxmlElement('w:b')
        rPr.append(b)
    bCs = rPr.find(qn('w:bCs'))
    if bCs is None:
        bCs = OxmlElement('w:bCs')
        rPr.append(bCs)
    # 颜色黑色
    color = rPr.find(qn('w:color'))
    if color is None:
        color = OxmlElement('w:color')
        rPr.append(color)
    color.set(qn('w:val'), '000000')
    # 清除颜色上的 themeColor 引用
    for attr in ['themeColor', 'themeShade', 'themeTint']:
        key = qn(f'w:{attr}')
        if key in color.attrib:
            del color.attrib[key]
    # 禁止倾斜
    i = rPr.find(qn('w:i'))
    if i is None:
        i = OxmlElement('w:i')
        rPr.append(i)
    i.set(qn('w:val'), '0')
    # 段落格式
    pf = style.paragraph_format
    pf.space_before = Pt(0)
    pf.space_after = Pt(0)
    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
    pf.line_spacing = 1.5
    pf.first_line_indent = Pt(0)


def _create_toc_style(styles_element, style_id, name, based_on, ea_font, size_pt, bold):
    """在文档 styles.xml 中创建 TOC 样式"""
    # 查找是否已存在（通过迭代避免 XPath 命名空间问题）
    existing = None
    for child in styles_element:
        if child.tag == qn('w:style') and child.get(qn('w:styleId')) == style_id:
            existing = child
            break
    if existing is not None:
        styles_element.remove(existing)

    style_el = OxmlElement('w:style')
    style_el.set(qn('w:type'), 'paragraph')
    style_el.set(qn('w:styleId'), style_id)

    name_el = OxmlElement('w:name')
    name_el.set(qn('w:val'), name)
    style_el.append(name_el)
    if based_on:
        based_el = OxmlElement('w:basedOn')
        based_el.set(qn('w:val'), based_on)
        style_el.append(based_el)

    rPr = OxmlElement('w:rPr')
    _apply_style_fonts(rPr, ea_font)
    sz = OxmlElement('w:sz'); sz.set(qn('w:val'), str(int(size_pt * 2))); rPr.append(sz)
    szCs = OxmlElement('w:szCs'); szCs.set(qn('w:val'), str(int(size_pt * 2))); rPr.append(szCs)
    if bold:
        rPr.append(OxmlElement('w:b'))
        rPr.append(OxmlElement('w:bCs'))
    color = OxmlElement('w:color'); color.set(qn('w:val'), '000000'); rPr.append(color)
    style_el.append(rPr)
    styles_element.append(style_el)


# ── 段落布局工具 ──────────────────────────────────────

def _add_empty_para(doc, pt_height=6):
    p = doc.add_paragraph()
    pf = p.paragraph_format
    pf.space_before = Pt(0)
    pf.space_after = Pt(0)
    pf.line_spacing = Pt(pt_height)
    return p


def _set_para_spacing(pf, before=0, after=0, line_spacing=1.5):
    pf.space_before = Pt(before)
    pf.space_after = Pt(after)
    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
    pf.line_spacing = line_spacing


def _set_first_line_indent(pf, font_size_pt=12, chars=2):
    pf.first_line_indent = Pt(font_size_pt * chars)


# ── 表格边框工具 ──────────────────────────────────────

def _add_table_borders(table):
    """给表格加细实线边框"""
    tbl = table._tbl
    tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
    borders = OxmlElement('w:tblBorders')
    for edge in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
        el = OxmlElement(f'w:{edge}')
        el.set(qn('w:val'), 'single')
        el.set(qn('w:sz'), '4')
        el.set(qn('w:space'), '0')
        el.set(qn('w:color'), '000000')
        borders.append(el)
    tblPr.append(borders)


def _remove_table_borders(table):
    """移除表格边框（用于页眉表格）"""
    tbl = table._tbl
    tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
    borders = OxmlElement('w:tblBorders')
    for edge in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
        el = OxmlElement(f'w:{edge}')
        el.set(qn('w:val'), 'none')
        el.set(qn('w:sz'), '0')
        el.set(qn('w:space'), '0')
        el.set(qn('w:color'), 'auto')
        borders.append(el)
    tblPr.append(borders)


# ════════════════════════════════════════════════════════
# 正文渲染器 — 解析 export_markdown 写入 docx
# ════════════════════════════════════════════════════════

class _BodyRenderer:
    """渲染 export_markdown 正文到 docx

    规则：
    - 正文：小四宋体（西文 TNR），1.5 倍行距，首行缩进 2 字符
    - 表格：五号宋体
    - **加粗** → 加粗
    - #### 内部标题 → 黑体加粗段落
    - && → 下划线空格
    - ![](path) → 图片
    """

    def __init__(self, doc: Document):
        self.doc = doc

    def render(self, markdown_text: str):
        if not markdown_text or not markdown_text.strip():
            return
        lines = markdown_text.split("\n")
        i = 0
        while i < len(lines):
            line = lines[i]
            stripped = line.strip()
            if not stripped:
                i += 1
                continue
            # 分隔线跳过
            if re.match(r'^-{3,}$', stripped) or re.match(r'^\*{3,}$', stripped):
                i += 1
                continue
            # Markdown 表格
            if stripped.startswith("|") and stripped.endswith("|"):
                tbl_lines = []
                while i < len(lines) and lines[i].strip().startswith("|"):
                    tbl_lines.append(lines[i].strip())
                    i += 1
                self._render_table(tbl_lines)
                continue
            # 行内图片
            if "![" in stripped:
                rest = stripped
                while rest:
                    m = re.search(r'!\[.*?\]\((.*?)\)', rest)
                    if m:
                        before = rest[:m.start()].strip()
                        if before:
                            self._render_line(before)
                        self._render_image(m.group(1))
                        rest = rest[m.end():].strip()
                    else:
                        if rest:
                            self._render_line(rest)
                        rest = ""
                i += 1
                continue
            # 引用
            if stripped.startswith(">"):
                p = self.doc.add_paragraph()
                _set_font(p.add_run(stripped.lstrip(">").strip()), size_pt=FONT_SIZE["小四"])
                _set_para_spacing(p.paragraph_format)
                p.paragraph_format.left_indent = Cm(1)
                i += 1
                continue
            # 正文段落
            self._render_line(stripped)
            i += 1

    def _render_line(self, text: str):
        """一行正文：小四宋体，首行缩进2字符"""
        p = self.doc.add_paragraph()
        pf = p.paragraph_format
        _set_para_spacing(pf)
        _set_first_line_indent(pf, font_size_pt=FONT_SIZE["小四"])
        for seg_type, seg_text in self._parse_inline(text):
            if seg_type == "bold":
                r = p.add_run(seg_text)
                _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"], bold=True)
            elif seg_type == "placeholder":
                r = p.add_run(seg_text.replace("&&", "  "))
                _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
                r.underline = True
            elif seg_type == "heading":
                r = p.add_run(seg_text)
                _set_font(r, ea_font=FONT_HEI, size_pt=FONT_SIZE["小四"], bold=True)
                pf.first_line_indent = Pt(0)
            else:
                r = p.add_run(seg_text)
                _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])

    @staticmethod
    def _parse_inline(text: str):
        """解析内联格式 → [(type, text), ...]"""
        segments = []
        m = re.match(r'^(#{1,6})\s+(.*)', text)
        if m:
            segments.append(("heading", m.group(2)))
            return segments
        for part in re.split(r'(\*\*.*?\*\*)', text):
            if part.startswith("**") and part.endswith("**"):
                inner = part[2:-2]
                if inner:
                    segments.append(("bold", inner))
            else:
                for sub in re.split(r'(&&+)', part):
                    if sub.startswith("&&"):
                        segments.append(("placeholder", sub))
                    elif sub:
                        segments.append(("text", sub))
        return segments

    def _render_table(self, table_lines: list):
        """Markdown 表格 → docx 表格，五号宋体"""
        rows = []
        for line in table_lines:
            if not re.match(r'^\|[-:\s|]+\|$', line):
                cells = [c.strip() for c in line.split("|")[1:-1]]
                rows.append(cells)

        if not rows:
            return
        header = rows[0]
        body = rows[1:] if len(rows) > 1 else []
        cols = max(len(header), max((len(r) for r in body), default=0))
        if cols == 0:
            return
        for r in body:
            while len(r) < cols:
                r.append("")

        table = self.doc.add_table(rows=1 + len(body), cols=cols)
        table.alignment = WD_TABLE_ALIGNMENT.CENTER
        _add_table_borders(table)

        for j, h in enumerate(header):
            cell = table.rows[0].cells[j]
            cell.text = ""
            p = cell.paragraphs[0]
            p.alignment = WD_ALIGN_PARAGRAPH.CENTER
            _set_font(p.add_run(h), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"], bold=True)

        for i, row in enumerate(body):
            for j, text in enumerate(row):
                if j >= len(table.rows[i + 1].cells):
                    continue
                cell = table.rows[i + 1].cells[j]
                cell.text = ""
                p = cell.paragraphs[0]
                is_num = bool(re.match(r'^[\d,.\s\-+%()]*$', text))
                p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if is_num else WD_ALIGN_PARAGRAPH.LEFT
                _set_font(p.add_run(text), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])

    def _render_image(self, img_path: str):
        p = Path(img_path)
        resolved = None
        if p.exists():
            resolved = p
        else:
            for drive in ["D:", "E:", "C:"]:
                alt = Path(str(p).replace("E:", drive).replace("D:", drive))
                if alt.exists():
                    resolved = alt
                    break
        if resolved:
            try:
                para = self.doc.add_paragraph()
                para.alignment = WD_ALIGN_PARAGRAPH.CENTER
                para.paragraph_format.first_line_indent = Pt(0)
                para.add_run().add_picture(str(resolved), width=Cm(14))
            except Exception:
                self._placeholder_image(img_path)
        else:
            self._placeholder_image(img_path)

    def _placeholder_image(self, img_path):
        p = self.doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        _set_font(p.add_run(f"[图片: {Path(img_path).name}]"),
                  ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])


# ════════════════════════════════════════════════════════
# 大纲遍历器（核心引擎）
# ════════════════════════════════════════════════════════

class _OutlineDocxBuilder:

    def __init__(self, outline: dict, payload: dict, *,
                 project_name: Union[str, None] = None,
                 header_label: Union[str, None] = None):
        self.outline = outline
        self.payload = payload
        self._project_name_override = project_name
        self._header_label_override = header_label
        self.doc = Document()

        # path → section 索引
        self.by_path = {sec["path"]: sec for sec in payload.get("sections", [])}

        # 卷册角色 → 商务标/技术标
        self.volume_roles: dict = {}
        for node in outline.get("tree", []):
            role = node.get("outline_role", "commercial")
            self.volume_roles[node.get("path", "")] = "商务标" if role == "commercial" else "技术标"

        self._header_right_cell = None

        self._setup_page()
        self._setup_styles()
        self._setup_header_footer()

    @property
    def _project_name(self):
        return self._project_name_override or self.outline.get("alias", "")

    @property
    def _task_id(self):
        return self.outline.get("task_id", "")

    @property
    def _package_id(self):
        return self.outline.get("package_id", "")

    # ── 页面设置 ──
    def _setup_page(self):
        for sec in self.doc.sections:
            sec.top_margin = Cm(2.54)
            sec.bottom_margin = Cm(2.54)
            sec.left_margin = Cm(2.5)
            sec.right_margin = Cm(2.5)

    # ── 样式 ──
    def _setup_styles(self):
        _apply_normal_style(self.doc.styles['Normal'])

        # 标题层级映射（供目录使用 + 符合标书规范）:
        #   L1 卷册         → Heading 1 → 二号黑体加粗      （不在"一、二、三"编号体系中）
        #   L2 章（第一章）  → Heading 2 → 小三黑体加粗      （一级标题 一、二、三...）
        #   L3 节            → Heading 3 → 四号黑体加粗      （二级标题 (一)(二)...）
        #   L4+ 细项         → 加粗段落   → 小四宋体加粗     （三级标题 1. 2. 3...，不入目录）
        _apply_heading_style(self.doc.styles['Heading 1'],
                             FONT_SIZE["二号"], FONT_HEI, bold=True)
        _apply_heading_style(self.doc.styles['Heading 2'],
                             FONT_SIZE["小三"], FONT_HEI, bold=True)
        _apply_heading_style(self.doc.styles['Heading 3'],
                             FONT_SIZE["四号"], FONT_HEI, bold=True)

        # 目录样式: 一级目录四号黑体，二级目录小四宋体
        styles_el = self.doc.styles.element
        _create_toc_style(styles_el, 'TOC1', 'toc 1', 'Normal',
                          FONT_HEI, FONT_SIZE["四号"], bold=False)
        _create_toc_style(styles_el, 'TOC2', 'toc 2', 'Normal',
                          FONT_SONG, FONT_SIZE["小四"], bold=False)

    # ── 页眉（封面/目录节：无页码）──
    def _setup_header_footer(self):
        section = self.doc.sections[0]
        header = section.header
        header.is_linked_to_previous = False
        for p in header.paragraphs:
            p.clear()
        self._build_header_table(header)

        # 封面/目录节：页脚留空（无页码）

    def _build_header_table(self, header):
        """构造页眉表格：左项目名 + 右标签"""
        htable = header.add_table(rows=1, cols=2, width=Cm(16))
        htable.alignment = WD_TABLE_ALIGNMENT.CENTER
        _set_font(htable.rows[0].cells[0].paragraphs[0].add_run(self._project_name),
                  ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])

        right_cell = htable.rows[0].cells[1]
        right_cell.text = ""
        self._header_right_cell = right_cell
        _remove_table_borders(htable)

    def _update_header_role(self, role_text):
        if self._header_right_cell is not None:
            p = self._header_right_cell.paragraphs[0]
            p.clear()
            _set_font(p.add_run(role_text), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])

    # ── 正文节（页码从 1 起始）──
    def _add_body_section(self):
        new_sec = self.doc.add_section()
        new_sec.top_margin = Cm(2.54)
        new_sec.bottom_margin = Cm(2.54)
        new_sec.left_margin = Cm(2.5)
        new_sec.right_margin = Cm(2.5)
        new_sec.header.is_linked_to_previous = False
        new_sec.footer.is_linked_to_previous = False

        # 页眉
        for p in new_sec.header.paragraphs:
            p.clear()
        self._build_header_table(new_sec.header)

        # 页脚：居中页码
        fp = new_sec.footer.paragraphs[0]
        fp.alignment = WD_ALIGN_PARAGRAPH.CENTER

        # 在段落默认属性上设置五号宋体，确保 PAGE 域渲染时继承正确字体
        pPr = fp._p.get_or_add_pPr()
        rPr_def = pPr.find(qn('w:rPr'))
        if rPr_def is None:
            rPr_def = OxmlElement('w:rPr')
            pPr.insert(0, rPr_def)
        _apply_style_fonts(rPr_def, FONT_SONG)
        sz_def = rPr_def.find(qn('w:sz'))
        if sz_def is None:
            sz_def = OxmlElement('w:sz')
            rPr_def.append(sz_def)
        sz_def.set(qn('w:val'), str(int(FONT_SIZE["五号"] * 2)))
        szCs_def = rPr_def.find(qn('w:szCs'))
        if szCs_def is None:
            szCs_def = OxmlElement('w:szCs')
            rPr_def.append(szCs_def)
        szCs_def.set(qn('w:val'), str(int(FONT_SIZE["五号"] * 2)))
        color_def = rPr_def.find(qn('w:color'))
        if color_def is None:
            color_def = OxmlElement('w:color')
            rPr_def.append(color_def)
        color_def.set(qn('w:val'), '000000')

        r = fp.add_run()
        fc = OxmlElement('w:fldChar')
        fc.set(qn('w:fldCharType'), 'begin')
        r._r.append(fc)

        r2 = fp.add_run()
        it = OxmlElement('w:instrText')
        it.text = ' PAGE '
        r2._r.append(it)

        r3 = fp.add_run()
        fc2 = OxmlElement('w:fldChar')
        fc2.set(qn('w:fldCharType'), 'end')
        r3._r.append(fc2)

        for rn in fp.runs:
            _set_font(rn, ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])

        # 从 1 开始
        pnt = OxmlElement('w:pgNumType')
        pnt.set(qn('w:start'), '1')
        new_sec._sectPr.append(pnt)

    # ── 封面 ──
    def _add_cover(self):
        for _ in range(8):
            _add_empty_para(self.doc, 18)

        # "投 标 文 件" —— 二号黑体加粗居中
        p = self.doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        _set_font(p.add_run("投 标 文 件"), ea_font=FONT_HEI,
                  size_pt=FONT_SIZE["二号"], bold=True)
        _add_empty_para(self.doc, 24)

        # 项目名称（副标题）
        p = self.doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        _set_font(p.add_run(self._project_name), ea_font=FONT_HEI, size_pt=18)
        _add_empty_para(self.doc, 12)

        # 标识
        p = self.doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        _set_font(p.add_run(f"{self._task_id} / {self._package_id}"),
                  ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
        self.doc.add_page_break()

    # ── 目录 ──
    def _add_toc(self):
        p = self.doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        _set_font(p.add_run("目  录"), ea_font=FONT_HEI,
                  size_pt=FONT_SIZE["二号"], bold=False)
        p.paragraph_format.space_after = Pt(12)

        ptoc = self.doc.add_paragraph()
        r = ptoc.add_run()
        c = OxmlElement('w:fldChar')
        c.set(qn('w:fldCharType'), 'begin')
        r._r.append(c)

        r2 = ptoc.add_run()
        it = OxmlElement('w:instrText')
        it.text = ' TOC \\o "1-3" \\h \\z \\u '
        r2._r.append(it)

        r3 = ptoc.add_run()
        c2 = OxmlElement('w:fldChar')
        c2.set(qn('w:fldCharType'), 'separate')
        r3._r.append(c2)

        r4 = ptoc.add_run()
        r4.text = "（请在 Word 中右键此处 → 更新域 以生成目录）"
        _set_font(r4, ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])

        r5 = ptoc.add_run()
        c3 = OxmlElement('w:fldChar')
        c3.set(qn('w:fldCharType'), 'end')
        r5._r.append(c3)

        self.doc.add_page_break()

    # ── 构建主流程 ──
    def build(self) -> Document:
        self._add_cover()
        self._add_toc()
        self._add_body_section()

        for l1_node in self.outline.get("tree", []):
            role = self.volume_roles.get(l1_node.get("path", ""), "")
            self._update_header_role(self._header_label_override or role)
            self._traverse_node(l1_node)
        return self.doc

    # ── 大纲递归遍历 ──
    @staticmethod
    def _strip_title_punct(title: str) -> str:
        """去除标题末尾标点符号，确保标题独占一行、末尾不带标点"""
        return title.rstrip("。，、；：？！.,;:!?·—…")

    def _traverse_node(self, node, parent_role=""):
        level = node["level"]
        title = self._strip_title_punct(node["title"])
        path = node["path"]
        children = node.get("children", [])

        em = ""
        if path in self.by_path:
            raw = (self.by_path[path].get("export_markdown", "") or "")
            em = self._clean_export(raw, title)

        if level == 1:
            # L1 卷册 → Heading 1（二号黑体加粗，出现在目录）
            self.doc.add_page_break()
            self.doc.add_heading(title, level=1)
            if em.strip():
                _BodyRenderer(self.doc).render(em)
            for child in children:
                self._traverse_node(child, parent_role=path)

        elif level == 2:
            # L2 章（第一章...）→ Heading 2 → 一级标题，小三黑体加粗
            self.doc.add_heading(title, level=2)
            if em.strip():
                _BodyRenderer(self.doc).render(em)
            for child in children:
                self._traverse_node(child, parent_role)

        elif level == 3:
            # L3 节 → Heading 3 → 二级标题，四号黑体加粗
            self.doc.add_heading(title, level=3)
            if em.strip():
                _BodyRenderer(self.doc).render(em)
            for child in children:
                self._traverse_node(child, parent_role)

        elif level >= 4:
            # L4+ 细项 → 加粗段落（小四宋体加粗），不入目录
            if em.strip():
                p = self.doc.add_paragraph()
                pf = p.paragraph_format
                _set_para_spacing(pf, before=3, after=0)
                pf.first_line_indent = Pt(0)
                _set_font(p.add_run(title), ea_font=FONT_SONG,
                          size_pt=FONT_SIZE["小四"], bold=True)
                _BodyRenderer(self.doc).render(em)
            for child in children:
                self._traverse_node(child, parent_role)

    @staticmethod
    def _clean_export(markdown: str, title: str) -> str:
        """去除 export_markdown 开头冗余内容"""
        lines = markdown.split("\n")
        cleaned = []
        skipping = True
        for line in lines:
            s = line.strip()
            if skipping:
                if not s:
                    continue
                if s.startswith("#") and title in s:
                    continue
                if any(kw in s for kw in ["为您撰写", "根据您", "好的，"]):
                    continue
                skipping = False
            cleaned.append(line)
        return "\n".join(cleaned)


# ════════════════════════════════════════════════════════
# 公开 API
# ════════════════════════════════════════════════════════

def generate_bid_docx(
    bid_fill_draft: Union[dict, str, Path],
    sq_bid_outline: Union[dict, str, Path],
    *,
    project_name: Union[str, None] = None,
    header_label: Union[str, None] = None,
    output_path: Union[str, Path, None] = None,
) -> Union[Document, Path]:
    """生成投标文件 docx。

    参数
    ----
    bid_fill_draft : dict | str | Path
        正文真源。dict 或 JSON 文件路径。
    sq_bid_outline : dict | str | Path
        结构真源。dict 或 JSON 文件路径。
    project_name : str | None
        页眉左侧项目全称。不传则从 outline.alias 读取。
    header_label : str | None
        页眉右侧标签（"商务标"/"技术标"）。不传则按大纲角色判断。
    output_path : str | Path | None
        docx 保存路径。None 则返回 Document 对象。

    返回
    ----
    Document | Path
    """
    outline = _load_json(sq_bid_outline)
    payload = _load_json(bid_fill_draft)
    builder = _OutlineDocxBuilder(outline, payload,
                                  project_name=project_name,
                                  header_label=header_label)
    doc = builder.build()
    if output_path:
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        doc.save(str(output_path))
        return output_path
    return doc


def _load_json(data) -> dict:
    if isinstance(data, dict):
        return data
    with open(Path(data), "r", encoding="utf-8") as f:
        return json.load(f)


# ════════════════════════════════════════════════════════
# CLI 入口
# ════════════════════════════════════════════════════════

def main():
    import argparse
    parser = argparse.ArgumentParser(description="投标文件 DOCX 生成器")
    parser.add_argument("--fill", default="data/bid_fill_draft.json")
    parser.add_argument("--outline", default="data/sq_bid_outline.json")
    parser.add_argument("--project-name", default=None)
    parser.add_argument("--header-label", default=None)
    parser.add_argument("--output", "-o", default=None)
    args = parser.parse_args()

    if args.output is None:
        outline = _load_json(args.outline)
        alias = outline.get("alias", "投标文件")
        safe = re.sub(r'[\\/:*?"<>|]', '_', alias)
        out_dir = Path(__file__).resolve().parent.parent / "output"
        out_dir.mkdir(parents=True, exist_ok=True)
        args.output = str(out_dir / f"{safe}.docx")

    result = generate_bid_docx(
        bid_fill_draft=args.fill,
        sq_bid_outline=args.outline,
        project_name=args.project_name,
        header_label=args.header_label,
        output_path=args.output,
    )

    payload = _load_json(args.fill)
    total_chars = sum(
        len(sec.get("export_markdown", "") or "")
        for sec in payload.get("sections", [])
    )
    result_doc = docx.Document(str(result))
    print(f"文档已生成: {result}")
    print(f"  正文总字符数: {total_chars:,}")
    print(f"  段 落 数: {len(result_doc.paragraphs)}")
    print(f"  表 格 数: {len(result_doc.tables)}")


if __name__ == "__main__":
    main()
