#!/usr/bin/env python3
"""Update slide 5 of the service monthly PPTX.

本脚本用于单独更新 PPT 第 5 页：政府侧、企业侧 Top5 客户与 139 客户重点项目。

取数逻辑：
- `/tender/major-buyers?year={year}&month={month}&scope=上海`
  使用 `政府侧招标单位` 和 `企业侧招标单位` 两组数据。
- `/tender/customer-139?year={year}&month={month}&scope=上海`
  使用 `月度数据` 更新 139 客户当月招标总览提示。
- `/tender/customer-139/key-projects?year={year}&month={month}&scope=上海`
  使用 `重点项目` 更新月度 139 客户招标重点项目表。

更新内容：
- 标题中的统计周期，并将模板标题 Top10 改为 Top5。
- 两张客户表：政府侧有效 Top5 客户、企业侧有效 Top5 客户。
- 139 客户月度总览与项目共性后置分析占位符。
- 一张重点项目表：月度 139 客户招标重点项目 Top5。
"""

from __future__ import annotations

import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile


PAGE_NUMBER = 5
SLIDE_XML = "ppt/slides/slide5.xml"
SLIDE_RELS_XML = "ppt/slides/_rels/slide5.xml.rels"
NOTES_SLIDE_XML = "ppt/notesSlides/notesSlide5.xml"
NOTES_SLIDE_RELS_XML = "ppt/notesSlides/_rels/notesSlide5.xml.rels"
CONTENT_TYPES_XML = "[Content_Types].xml"
DATA_SOURCES = [
    "/tender/major-buyers",
    "/tender/customer-139",
    "/tender/customer-139/key-projects",
]


OOXML_NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
NOTES_SLIDE_REL_TYPE = (
    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
)
NOTES_MASTER_REL_TYPE = (
    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
)
SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
NOTES_SLIDE_CONTENT_TYPE = (
    "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"
)

for _prefix, _uri in OOXML_NS.items():
    ET.register_namespace(_prefix, _uri)


def _qn(prefix: str, tag: str) -> str:
    return f"{{{OOXML_NS[prefix]}}}{tag}"


P5_PERIOD_PLACEHOLDER = "{{P5统计周期：如2026年1-4月}}"
P5_MONTHLY_PERIOD_PLACEHOLDER = "{{P5统计周期：如2026年4月}}"
P5_139_SUMMARY_PLACEHOLDER = "{{P5 139客户月度招标总览：简要描述139客户招标次数、招标预算金额}}"
P5_PROJECT_SUMMARY_PLACEHOLDER = "{{根据项目名称简要概括重点项目共性内容}}"
TOP10_TITLE_TEXT = "Top10客户"
TOP5_TITLE_TEXT = "Top5客户"
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析，凸显数据特征"
MEANINGLESS_CUSTOMER_KEYWORDS = ("某", "未知", "未披露", "匿名", "不详", "保密", "无名")
TOP_CUSTOMER_COUNT = 5
TOP_PROJECT_COUNT = 5
TOP_PROJECT_NOTE_COUNT = 10


class P5UpdateError(ValueError):
    """Raised when P5 cannot be updated from complete, trusted data."""


@dataclass(frozen=True)
class BuyerRow:
    name: str
    count: int
    amount_wan: Decimal


@dataclass(frozen=True)
class KeyProjectRow:
    buyer: str
    project_name: str
    amount_wan: Decimal


@dataclass(frozen=True)
class P5UpdateModel:
    cumulative_period_label: str
    monthly_period_label: str
    government_rows: list[BuyerRow]
    enterprise_rows: list[BuyerRow]
    key_project_rows: list[KeyProjectRow]
    government_table_rows: list[list[str]]
    enterprise_table_rows: list[list[str]]
    key_project_table_rows: list[list[str]]
    notes_project_rows: list[KeyProjectRow]
    notes_text: str
    replacements: dict[str, str]


def _parse_decimal(value: Any, field_path: str) -> Decimal:
    if value is None:
        raise P5UpdateError(f"P5 更新失败：{field_path} 为空，无法更新第五页。")
    raw = str(value).strip()
    if not raw or raw == "/":
        raise P5UpdateError(f"P5 更新失败：{field_path} 不是可计算数字，无法更新第五页。")
    raw = raw.replace(",", "").removesuffix("%")
    try:
        return Decimal(raw)
    except InvalidOperation as exc:
        raise P5UpdateError(f"P5 更新失败：{field_path}={value!r} 不是数字。") from exc


def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
    value = row.get(key)
    text = "" if value is None else str(value).strip()
    if not text:
        raise P5UpdateError(f"P5 更新失败：{field_path} 为空，无法更新第五页。")
    return text


def _format_amount_wan(value: Decimal) -> str:
    return f"{value.quantize(Decimal('0.01'))}"


def _format_amount_yi(value: Any, field_path: str) -> str:
    return f"{_parse_decimal(value, field_path).quantize(Decimal('0.01'))}"


def _period_labels(response: dict[str, Any], endpoint: str) -> tuple[str, str]:
    year = response.get("统计年份")
    month = str(response.get("统计月份", "")).strip()
    month_number = month.removesuffix("月")
    if not year or not month_number:
        raise P5UpdateError(f"P5 更新失败：{endpoint} 缺少统计年份或统计月份。")
    return f"{year}年1-{month_number}月", f"{year}年{month_number}月"


def _validate_same_period(
    major_buyers_response: dict[str, Any],
    customer_139_response: dict[str, Any],
    key_projects_response: dict[str, Any],
) -> tuple[str, str]:
    periods = {
        "/tender/major-buyers": _period_labels(major_buyers_response, "/tender/major-buyers"),
        "/tender/customer-139": _period_labels(customer_139_response, "/tender/customer-139"),
        "/tender/customer-139/key-projects": _period_labels(
            key_projects_response,
            "/tender/customer-139/key-projects",
        ),
    }
    unique_periods = set(periods.values())
    if len(unique_periods) != 1:
        detail = "，".join(
            f"{endpoint}={cumulative}/{monthly}"
            for endpoint, (cumulative, monthly) in periods.items()
        )
        raise P5UpdateError(f"P5 更新失败：三个接口统计周期不一致：{detail}。")
    return periods["/tender/major-buyers"]


def _is_meaningful_customer_name(value: Any) -> bool:
    name = "" if value is None else str(value).strip()
    if not name:
        return False
    return not any(keyword in name for keyword in MEANINGLESS_CUSTOMER_KEYWORDS)


def _buyer_rows(response: dict[str, Any], section: str) -> list[BuyerRow]:
    raw_rows = response.get(section)
    if not isinstance(raw_rows, list) or not raw_rows:
        raise P5UpdateError(f"P5 更新失败：/tender/major-buyers.{section} 缺少数据。")

    rows: list[BuyerRow] = []
    for index, raw_row in enumerate(raw_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P5UpdateError(f"P5 更新失败：/tender/major-buyers.{section}[{index}] 不是对象。")
        name = _required_text(
            raw_row,
            "招标单位",
            f"/tender/major-buyers.{section}[{index}].招标单位",
        )
        if not _is_meaningful_customer_name(name):
            continue
        rows.append(
            BuyerRow(
                name=name,
                count=int(_parse_decimal(raw_row.get("招标次数"), f"/tender/major-buyers.{section}.{name}.招标次数")),
                amount_wan=_parse_decimal(
                    raw_row.get("招标预算金额（万元）"),
                    f"/tender/major-buyers.{section}.{name}.招标预算金额（万元）",
                ),
            )
        )

    if len(rows) < TOP_CUSTOMER_COUNT:
        raise P5UpdateError(
            f"P5 更新失败：/tender/major-buyers.{section} 过滤无意义客户后少于 {TOP_CUSTOMER_COUNT} 行。"
        )
    return sorted(rows, key=lambda row: (-row.amount_wan, -row.count, row.name))[
        :TOP_CUSTOMER_COUNT
    ]


def _table_rows(rows: list[BuyerRow]) -> list[list[str]]:
    return [
        ["招标单位", "招标次数", "招标预算金额（万元）"],
        *[[row.name, str(row.count), _format_amount_wan(row.amount_wan)] for row in rows],
    ]


def _key_project_rows(response: dict[str, Any]) -> list[KeyProjectRow]:
    raw_rows = response.get("重点项目")
    if not isinstance(raw_rows, list) or not raw_rows:
        raise P5UpdateError("P5 更新失败：/tender/customer-139/key-projects.重点项目 缺少数据。")

    rows: list[KeyProjectRow] = []
    for index, raw_row in enumerate(raw_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P5UpdateError(
                f"P5 更新失败：/tender/customer-139/key-projects.重点项目[{index}] 不是对象。"
            )
        project_name = _required_text(
            raw_row,
            "项目名称",
            f"/tender/customer-139/key-projects.重点项目[{index}].项目名称",
        )
        rows.append(
            KeyProjectRow(
                buyer=_required_text(
                    raw_row,
                    "招标单位",
                    f"/tender/customer-139/key-projects.重点项目[{index}].招标单位",
                ),
                project_name=project_name,
                amount_wan=_parse_decimal(
                    raw_row.get("招标预算金额（万元）"),
                    f"/tender/customer-139/key-projects.重点项目.{project_name}.招标预算金额（万元）",
                ),
            )
        )
    return sorted(rows, key=lambda row: (-row.amount_wan, row.project_name, row.buyer))


def _key_project_table_rows(rows: list[KeyProjectRow]) -> list[list[str]]:
    return [
        ["月度139客户招标重点项目", "", ""],
        ["招标单位", "项目名称", "招标预算金额（万元）"],
        *[[row.buyer, row.project_name, _format_amount_wan(row.amount_wan)] for row in rows],
    ]


def _wrap_followup_prompt(data_text: str, suffix: str = FOLLOWUP_PROMPT_SUFFIX) -> str:
    return f"{{{{{data_text}；{suffix}}}}}"


def _customer_139_summary_text(response: dict[str, Any], monthly_period_label: str) -> str:
    monthly = response.get("月度数据")
    if not isinstance(monthly, dict):
        raise P5UpdateError("P5 更新失败：/tender/customer-139 缺少 月度数据。")
    count = int(_parse_decimal(monthly.get("招标次数"), "/tender/customer-139.月度数据.招标次数"))
    amount_yi = _format_amount_yi(
        monthly.get("招标预算金额（亿元）"),
        "/tender/customer-139.月度数据.招标预算金额（亿元）",
    )
    return _wrap_followup_prompt(f"{monthly_period_label}：139客户{count}次/{amount_yi}亿")


def _project_summary_text(rows: list[KeyProjectRow]) -> str:
    highest_amount = max((row.amount_wan for row in rows), default=Decimal("0"))
    return _wrap_followup_prompt(
        f"139重点项目Top{len(rows)}见右表，最高{_format_amount_wan(highest_amount)}万元",
        "请基于项目名称概括重点项目共性内容",
    )


def _key_project_notes_text(rows: list[KeyProjectRow], monthly_period_label: str) -> str:
    lines = [f"139客户招标及重点项目 Top10（{monthly_period_label}）"]
    for index, row in enumerate(rows[:TOP_PROJECT_NOTE_COUNT], start=1):
        lines.append(
            f"{index}. {row.project_name}｜{row.buyer}｜{_format_amount_wan(row.amount_wan)}万元"
        )
    return "\n".join(lines)


def build_p5_update_model(
    major_buyers_response: dict[str, Any],
    customer_139_response: dict[str, Any],
    key_projects_response: dict[str, Any],
) -> P5UpdateModel:
    cumulative_period_label, monthly_period_label = _validate_same_period(
        major_buyers_response,
        customer_139_response,
        key_projects_response,
    )
    government_rows = _buyer_rows(major_buyers_response, "政府侧招标单位")
    enterprise_rows = _buyer_rows(major_buyers_response, "企业侧招标单位")
    all_key_project_rows = _key_project_rows(key_projects_response)
    key_project_rows = all_key_project_rows[:TOP_PROJECT_COUNT]
    notes_project_rows = all_key_project_rows[:TOP_PROJECT_NOTE_COUNT]
    return P5UpdateModel(
        cumulative_period_label=cumulative_period_label,
        monthly_period_label=monthly_period_label,
        government_rows=government_rows,
        enterprise_rows=enterprise_rows,
        key_project_rows=key_project_rows,
        government_table_rows=_table_rows(government_rows),
        enterprise_table_rows=_table_rows(enterprise_rows),
        key_project_table_rows=_key_project_table_rows(key_project_rows),
        notes_project_rows=notes_project_rows,
        notes_text=_key_project_notes_text(notes_project_rows, monthly_period_label),
        replacements={
            P5_PERIOD_PLACEHOLDER: cumulative_period_label,
            P5_MONTHLY_PERIOD_PLACEHOLDER: monthly_period_label,
            P5_139_SUMMARY_PLACEHOLDER: _customer_139_summary_text(
                customer_139_response,
                monthly_period_label,
            ),
            P5_PROJECT_SUMMARY_PLACEHOLDER: _project_summary_text(key_project_rows),
            TOP10_TITLE_TEXT: TOP5_TITLE_TEXT,
        },
    )


def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
    url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
    request = Request(url)
    try:
        with urlopen(request, timeout=20) as response:
            payload = response.read().decode("utf-8")
            status = response.status
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise P5UpdateError(f"P5 更新失败：{endpoint} HTTP {exc.code}: {detail}") from exc
    except URLError as exc:
        raise P5UpdateError(f"P5 更新失败：无法连接 FastAPI {url}: {exc.reason}") from exc
    if status != 200:
        raise P5UpdateError(f"P5 更新失败：{endpoint} HTTP {status}。")
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise P5UpdateError(f"P5 更新失败：{endpoint} 返回值不是 JSON。") from exc
    if not isinstance(data, dict):
        raise P5UpdateError(f"P5 更新失败：{endpoint} 返回 JSON 根节点不是对象。")
    return data


def fetch_p5_responses(
    api_base: str,
    year: int,
    month: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
    params = {"year": year, "month": month, "scope": "上海"}
    return (
        fetch_json(api_base, "/tender/major-buyers", params),
        fetch_json(api_base, "/tender/customer-139", params),
        fetch_json(api_base, "/tender/customer-139/key-projects", params),
    )


def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
    replaced_counts = {placeholder: 0 for placeholder in replacements}
    for paragraph in root.findall(".//a:p", OOXML_NS):
        text_nodes = paragraph.findall(".//a:t", OOXML_NS)
        if not text_nodes:
            continue
        original = "".join(node.text or "" for node in text_nodes)
        updated = original
        for placeholder, value in replacements.items():
            if placeholder in updated:
                replaced_counts[placeholder] += updated.count(placeholder)
                updated = updated.replace(placeholder, value)
        if updated != original:
            text_nodes[0].text = updated
            for node in text_nodes[1:]:
                node.text = ""
    required_counts = {
        P5_PERIOD_PLACEHOLDER: 2,
        P5_MONTHLY_PERIOD_PLACEHOLDER: 1,
        P5_139_SUMMARY_PLACEHOLDER: 1,
        P5_PROJECT_SUMMARY_PLACEHOLDER: 1,
        TOP10_TITLE_TEXT: 2,
    }
    missing = sorted(
        placeholder
        for placeholder, required_count in required_counts.items()
        if replaced_counts.get(placeholder, 0) < required_count
    )
    if missing:
        raise P5UpdateError(f"P5 更新失败：模板第 5 页缺少占位符：{', '.join(missing)}。")


def _set_text_nodes(parent: ET.Element, value: str) -> None:
    text_nodes = parent.findall(".//a:t", OOXML_NS)
    if not text_nodes:
        tx_body = parent.find("./a:txBody", OOXML_NS)
        if tx_body is None:
            tx_body = ET.SubElement(parent, _qn("a", "txBody"))
            ET.SubElement(tx_body, _qn("a", "bodyPr"))
            ET.SubElement(tx_body, _qn("a", "lstStyle"))
        paragraph = tx_body.find("./a:p", OOXML_NS)
        if paragraph is None:
            paragraph = ET.SubElement(tx_body, _qn("a", "p"))
        run = ET.SubElement(paragraph, _qn("a", "r"))
        text_node = ET.SubElement(run, _qn("a", "t"))
        text_nodes = [text_node]
    text_nodes[0].text = value
    for node in text_nodes[1:]:
        node.text = ""


def _update_table(table: ET.Element, table_rows: list[list[str]]) -> None:
    xml_rows = table.findall("./a:tr", OOXML_NS)
    if len(xml_rows) < len(table_rows):
        raise P5UpdateError("P5 更新失败：第 5 页表格行数不足。")
    for extra_row in xml_rows[len(table_rows):]:
        table.remove(extra_row)
    xml_rows = table.findall("./a:tr", OOXML_NS)
    for row_index, row_values in enumerate(table_rows):
        cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
        if len(cells) < len(row_values):
            raise P5UpdateError("P5 更新失败：第 5 页表格列数不足。")
        for cell, value in zip(cells, row_values, strict=True):
            _set_text_nodes(cell, value)


def _update_slide_tables(root: ET.Element, model: P5UpdateModel) -> None:
    tables = root.findall(".//a:tbl", OOXML_NS)
    if len(tables) < 3:
        raise P5UpdateError("P5 更新失败：第 5 页未找到 3 张表。")
    _update_table(tables[0], model.government_table_rows)
    _update_table(tables[1], model.enterprise_table_rows)
    _update_table(tables[2], model.key_project_table_rows)


def _rel_qn(tag: str) -> str:
    return f"{{{REL_NS}}}{tag}"


def _ct_qn(tag: str) -> str:
    return f"{{{CONTENT_TYPES_NS}}}{tag}"


def _next_relationship_id(root: ET.Element) -> str:
    max_id = 0
    for relationship in root.findall("./rel:Relationship", {"rel": REL_NS}):
        raw_id = relationship.attrib.get("Id", "")
        if raw_id.startswith("rId") and raw_id[3:].isdigit():
            max_id = max(max_id, int(raw_id[3:]))
    return f"rId{max_id + 1}"


def _ensure_slide_notes_relationship(xml_bytes: bytes) -> bytes:
    ET.register_namespace("", REL_NS)
    root = ET.fromstring(xml_bytes)
    for relationship in root.findall("./rel:Relationship", {"rel": REL_NS}):
        if relationship.attrib.get("Type") == NOTES_SLIDE_REL_TYPE:
            relationship.set("Target", "../notesSlides/notesSlide5.xml")
            return ET.tostring(root, encoding="utf-8", xml_declaration=True)

    relationship = ET.SubElement(root, _rel_qn("Relationship"))
    relationship.set("Id", _next_relationship_id(root))
    relationship.set("Type", NOTES_SLIDE_REL_TYPE)
    relationship.set("Target", "../notesSlides/notesSlide5.xml")
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _ensure_notes_slide_content_type(xml_bytes: bytes) -> bytes:
    ET.register_namespace("", CONTENT_TYPES_NS)
    root = ET.fromstring(xml_bytes)
    for override in root.findall("./ct:Override", {"ct": CONTENT_TYPES_NS}):
        if override.attrib.get("PartName") == "/ppt/notesSlides/notesSlide5.xml":
            override.set("ContentType", NOTES_SLIDE_CONTENT_TYPE)
            return ET.tostring(root, encoding="utf-8", xml_declaration=True)

    override = ET.SubElement(root, _ct_qn("Override"))
    override.set("PartName", "/ppt/notesSlides/notesSlide5.xml")
    override.set("ContentType", NOTES_SLIDE_CONTENT_TYPE)
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _notes_slide_relationships_xml() -> bytes:
    ET.register_namespace("", REL_NS)
    root = ET.Element(_rel_qn("Relationships"))
    slide_rel = ET.SubElement(root, _rel_qn("Relationship"))
    slide_rel.set("Id", "rId1")
    slide_rel.set("Type", SLIDE_REL_TYPE)
    slide_rel.set("Target", "../slides/slide5.xml")
    master_rel = ET.SubElement(root, _rel_qn("Relationship"))
    master_rel.set("Id", "rId2")
    master_rel.set("Type", NOTES_MASTER_REL_TYPE)
    master_rel.set("Target", "../notesMasters/notesMaster1.xml")
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _add_notes_paragraph(parent: ET.Element, text: str, bold: bool = False) -> None:
    paragraph = ET.SubElement(parent, _qn("a", "p"))
    paragraph_properties = ET.SubElement(paragraph, _qn("a", "pPr"))
    paragraph_properties.set("lvl", "0")
    run = ET.SubElement(paragraph, _qn("a", "r"))
    run_properties = ET.SubElement(run, _qn("a", "rPr"))
    run_properties.set("lang", "zh-CN")
    run_properties.set("altLang", "en-US")
    run_properties.set("sz", "1200")
    if bold:
        run_properties.set("b", "1")
    latin = ET.SubElement(run_properties, _qn("a", "latin"))
    latin.set("typeface", "仿宋_GB2312")
    east_asian = ET.SubElement(run_properties, _qn("a", "ea"))
    east_asian.set("typeface", "仿宋_GB2312")
    text_node = ET.SubElement(run, _qn("a", "t"))
    text_node.text = text
    end_run_properties = ET.SubElement(paragraph, _qn("a", "endParaRPr"))
    end_run_properties.set("lang", "zh-CN")
    end_run_properties.set("altLang", "en-US")


def _notes_slide_xml(notes_text: str) -> bytes:
    root = ET.Element(_qn("p", "notes"))
    c_sld = ET.SubElement(root, _qn("p", "cSld"))
    sp_tree = ET.SubElement(c_sld, _qn("p", "spTree"))
    nv_grp = ET.SubElement(sp_tree, _qn("p", "nvGrpSpPr"))
    ET.SubElement(nv_grp, _qn("p", "cNvPr"), {"id": "1", "name": ""})
    ET.SubElement(nv_grp, _qn("p", "cNvGrpSpPr"))
    ET.SubElement(nv_grp, _qn("p", "nvPr"))
    grp_sp_pr = ET.SubElement(sp_tree, _qn("p", "grpSpPr"))
    xfrm = ET.SubElement(grp_sp_pr, _qn("a", "xfrm"))
    ET.SubElement(xfrm, _qn("a", "off"), {"x": "0", "y": "0"})
    ET.SubElement(xfrm, _qn("a", "ext"), {"cx": "0", "cy": "0"})
    ET.SubElement(xfrm, _qn("a", "chOff"), {"x": "0", "y": "0"})
    ET.SubElement(xfrm, _qn("a", "chExt"), {"cx": "0", "cy": "0"})

    shape = ET.SubElement(sp_tree, _qn("p", "sp"))
    nv_sp_pr = ET.SubElement(shape, _qn("p", "nvSpPr"))
    ET.SubElement(nv_sp_pr, _qn("p", "cNvPr"), {"id": "2", "name": "备注占位符 1"})
    ET.SubElement(nv_sp_pr, _qn("p", "cNvSpPr"))
    nv_pr = ET.SubElement(nv_sp_pr, _qn("p", "nvPr"))
    ET.SubElement(nv_pr, _qn("p", "ph"), {"type": "body", "idx": "1"})
    sp_pr = ET.SubElement(shape, _qn("p", "spPr"))
    shape_xfrm = ET.SubElement(sp_pr, _qn("a", "xfrm"))
    ET.SubElement(shape_xfrm, _qn("a", "off"), {"x": "685800", "y": "4400550"})
    ET.SubElement(shape_xfrm, _qn("a", "ext"), {"cx": "5486400", "cy": "3600450"})
    ET.SubElement(sp_pr, _qn("a", "prstGeom"), {"prst": "rect"}).append(
        ET.Element(_qn("a", "avLst"))
    )
    tx_body = ET.SubElement(shape, _qn("p", "txBody"))
    ET.SubElement(
        tx_body,
        _qn("a", "bodyPr"),
        {"lIns": "91440", "tIns": "45720", "rIns": "91440", "bIns": "45720"},
    )
    ET.SubElement(tx_body, _qn("a", "lstStyle"))
    for index, line in enumerate(notes_text.splitlines()):
        _add_notes_paragraph(tx_body, line, bold=index == 0)

    clr_map_ovr = ET.SubElement(root, _qn("p", "clrMapOvr"))
    ET.SubElement(clr_map_ovr, _qn("a", "masterClrMapping"))
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def update_p5_pptx(pptx_path: Path, output_path: Path, model: P5UpdateModel) -> None:
    if not pptx_path.exists():
        raise P5UpdateError(f"P5 更新失败：输入 PPTX 不存在：{pptx_path}")
    output_path.parent.mkdir(parents=True, exist_ok=True)

    required_parts = {CONTENT_TYPES_XML, SLIDE_XML, SLIDE_RELS_XML}
    with ZipFile(pptx_path, "r") as source:
        missing = sorted(required_parts - set(source.namelist()))
        if missing:
            raise P5UpdateError(f"P5 更新失败：PPTX 缺少必要部件：{', '.join(missing)}。")

        slide_root = ET.fromstring(source.read(SLIDE_XML))
        _replace_slide_text(slide_root, model.replacements)
        _update_slide_tables(slide_root, model)
        updates = {
            CONTENT_TYPES_XML: _ensure_notes_slide_content_type(source.read(CONTENT_TYPES_XML)),
            SLIDE_XML: ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
            SLIDE_RELS_XML: _ensure_slide_notes_relationship(source.read(SLIDE_RELS_XML)),
            NOTES_SLIDE_XML: _notes_slide_xml(model.notes_text),
            NOTES_SLIDE_RELS_XML: _notes_slide_relationships_xml(),
        }

        if pptx_path.resolve() == output_path.resolve():
            with tempfile.NamedTemporaryFile(
                prefix="p5-update-",
                suffix=".pptx",
                dir=output_path.parent,
                delete=False,
            ) as tmp:
                actual_output = Path(tmp.name)
        else:
            actual_output = output_path
        try:
            with ZipFile(actual_output, "w") as target:
                existing_names = set(source.namelist())
                for info in source.infolist():
                    target.writestr(info, updates.get(info.filename, source.read(info.filename)))
                for filename, payload in updates.items():
                    if filename not in existing_names:
                        target.writestr(filename, payload)
            if actual_output != output_path:
                actual_output.replace(output_path)
        except Exception:
            if actual_output != output_path and actual_output.exists():
                actual_output.unlink()
            raise


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Update page 5 of the service monthly PPTX.")
    parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
    parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
    parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
    parser.add_argument("--month", required=True, help="Report month, for example 4月.")
    parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    major_buyers_response, customer_139_response, key_projects_response = fetch_p5_responses(
        args.api_base,
        args.year,
        args.month,
    )
    model = build_p5_update_model(
        major_buyers_response,
        customer_139_response,
        key_projects_response,
    )
    update_p5_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
