#!/usr/bin/env python3
"""P1 update script draft.

本脚本后续用于单独更新 PPT 第 1 页：上海信息化公开市场招标情况总览。

取数逻辑草案：
- `/tender/overall?year={year}&month={month}&scope=上海`
  用于累计招标金额、累计招标次数、同比增幅等总览 KPI。
- `/tender/industry?year={year}&month={month}&scope=上海`
  用于增长最强行业、规模最大行业、行业机会卡片等。
- `/tender/region?year={year}&month={month}&scope=上海`
  用于重点区域金额 Top 图表和区域经营主线。
- `/tender/major-buyers?year={year}&month={month}&scope=上海`
  用于政府侧、企业侧 Top 客户列表。

模板注意事项：
- 当前第 1 页使用短占位符，目的是避免模板阶段破坏版面。
- 一些占位符在 PPT XML 中会被拆成多个 text run，不能只做单个 `a:t` 节点替换。
- 招采节奏表不需要占位符更新。
- 政府侧/企业侧客户区只更新 Top5 客户名称，不再更新规模、个数、占比。
- 经营主线只更新重点区域、核心行业、核心客群；由智能体基于数据生成短判断，双轮驱动不更新。
- “政策洞察”“客户关键人”“存量项目经营”等内容当前不在 P1 自动更新范围内。

第一页占位符和取数说明：

1. 市场总体趋势
   - `{{累计金额}}` <= `/tender/overall.累计数据.招标预算金额（亿元）`
   - `{{金额同比}}` <= `/tender/overall.累计数据.招标金额同比增幅`
   - `{{累计次数}}` <= `/tender/overall.累计数据.招标次数`
   - `{{次数同比}}` <= `/tender/overall.累计数据.招标次数同比增幅`

2. 增长最强行业列表 Top5
   - `{{行业1增幅}}` 到 `{{行业5增幅}}`
     从 `/tender/industry.累计数据` 中按 `金额同比增幅` 排序取 Top5。
   - 输出格式：`行业名 + 增幅`，例如 `融合创新 +670%`。

3. 规模最大行业列表 Top5
   - `{{行业1金额}}` 到 `{{行业5金额}}`
     从 `/tender/industry.累计数据` 中按 `金额（亿元）` 排序取 Top5。
   - 输出格式：`行业名 + 金额`，例如 `党政 52.67亿元`。

4. 重点区域图
   - 第 1 页图表 `ppt/charts/chart1.xml`
     使用 `/tender/region.累计数据`，按 `金额（亿元）` 取 Top10。

5. 政府侧/企业侧客户
   - `{{政府侧客户 1}}` 到 `{{政府侧客户 5}}`
     取 `/tender/major-buyers.政府侧招标单位` 前 5 名的 `招标单位`。
   - `{{企业侧客户 1}}` 到 `{{企业侧客户 5}}`
     取 `/tender/major-buyers.企业侧招标单位` 前 5 名的 `招标单位`。

6. 经营主线
   - `{{区域1及分析结果}}`、`{{区域2及分析结果}}`
     由智能体基于 `/tender/region.累计数据` 生成短判断。
   - `{{行业1及分析结果}}`、`{{行业2及分析结果}}`
     由智能体基于 `/tender/industry.累计数据` 生成短判断。
   - `{{客群1及分析结果}}`、`{{客群2及分析结果}}`
     由智能体基于 `/tender/major-buyers` 生成短判断。
"""

from __future__ import annotations

import argparse
import json
import re
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
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

from openpyxl import load_workbook


PAGE_NUMBER = 1
SLIDE_XML = "ppt/slides/slide1.xml"

DATA_SOURCES = [
    "/tender/overall",
    "/tender/industry",
    "/tender/region",
    "/tender/major-buyers",
    "/tender/class",
    "/tender/time",
    "/tender/customer-139",
]

UPDATE_TARGETS = [
    "累计招标金额、招标次数及同比指标",
    "增长最强行业、规模最大行业等行业卡片",
    "重点区域金额 Top 图表",
    "政府侧与企业侧 Top 客户列表",
    "经营主线中的重点区域、核心行业、核心客群",
]


OOXML_NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}

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


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


@dataclass(frozen=True)
class RegionChartData:
    categories: list[str]
    values: list[float]


@dataclass(frozen=True)
class P1UpdateModel:
    replacements: dict[str, str]
    region_chart: RegionChartData
    quarter_table_rows: list[list[str]]


REQUIRED_ENDPOINTS = (
    "/tender/overall",
    "/tender/industry",
    "/tender/region",
    "/tender/major-buyers",
    "/tender/class",
    "/tender/time",
    "/tender/customer-139",
)


def _format_decimal(value: Decimal) -> str:
    return format(value.normalize(), "f")


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


def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
    raw = "" if value is None else str(value).strip()
    if not raw or raw == "/":
        return None
    return _parse_decimal(raw, field_path)


def _format_growth(value: Any, field_path: str) -> str:
    number = _parse_decimal(value, field_path)
    sign = "+" if number > 0 else ""
    return f"{sign}{_format_decimal(number)}%"


def _format_growth_or_fact(value: Any, field_path: str, fact_text: str) -> str:
    number = _parse_optional_decimal(value, field_path)
    if number is None:
        return fact_text
    sign = "+" if number > 0 else ""
    return f"{sign}{_format_decimal(number)}%"


def _format_amount(value: Any, field_path: str) -> str:
    return _format_decimal(_parse_decimal(value, field_path))


def _format_amount_yi_with_unit(value: Any, field_path: str) -> str:
    amount_yi = _parse_decimal(value, field_path).quantize(Decimal("0.01"))
    return f"{amount_yi:.2f}亿元"


def _endpoint_response(responses: dict[str, Any], endpoint: str) -> dict[str, Any]:
    for key in (endpoint, endpoint.lstrip("/")):
        value = responses.get(key)
        if value is None:
            continue
        if not isinstance(value, dict):
            raise P1UpdateError(f"P1 更新失败：{endpoint} 响应必须是 JSON 对象。")
        return value
    raise P1UpdateError(f"P1 更新失败：缺少必需接口 {endpoint} 的响应。")


def _required_dict(response: dict[str, Any], endpoint: str, key: str) -> dict[str, Any]:
    value = response.get(key)
    if not isinstance(value, dict):
        raise P1UpdateError(f"P1 更新失败：{endpoint} 响应缺少 {key}，无法更新第一页。")
    return value


def _required_rows(response: dict[str, Any], endpoint: str, key: str) -> list[dict[str, Any]]:
    value = response.get(key)
    if not isinstance(value, list) or not value:
        raise P1UpdateError(f"P1 更新失败：{endpoint} 响应缺少 {key}，无法更新第一页。")
    rows = [row for row in value if isinstance(row, dict)]
    if len(rows) != len(value):
        raise P1UpdateError(f"P1 更新失败：{endpoint}.{key} 中存在非对象行。")
    return rows


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 P1UpdateError(f"P1 更新失败：{field_path} 为空，无法更新第一页。")
    return text


def _top_rows_by_amount(
    rows: list[dict[str, Any]],
    amount_key: str,
    field_prefix: str,
    count: int,
) -> list[dict[str, Any]]:
    if len(rows) < count:
        raise P1UpdateError(f"P1 更新失败：{field_prefix} 少于 {count} 行，无法更新第一页。")
    return sorted(
        rows,
        key=lambda row: _parse_decimal(row.get(amount_key), f"{field_prefix}.{amount_key}"),
        reverse=True,
    )[:count]


def _top_industry_growth_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    candidates: list[tuple[int, Decimal, Decimal, dict[str, Any]]] = []
    for row in rows:
        amount = _parse_decimal(row.get("金额（亿元）"), "/tender/industry.累计数据.金额（亿元）")
        growth = _parse_optional_decimal(
            row.get("金额同比增幅"),
            "/tender/industry.累计数据.金额同比增幅",
        )
        candidates.append(
            (
                1 if growth is not None else 0,
                growth if growth is not None else Decimal("-999999999"),
                amount,
                row,
            )
        )
    if len(candidates) < 5:
        raise P1UpdateError("P1 更新失败：/tender/industry.累计数据 少于 5 行。")
    return [
        row
        for _has_growth, _growth, _amount, row in sorted(
            candidates,
            key=lambda item: (item[0], item[1], item[2]),
            reverse=True,
        )[:5]
    ]


GENERIC_CUSTOMER_MARKERS = (
    "某",
    "未知",
    "未披露",
    "匿名",
    "不详",
    "保密",
    "无名",
)


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(marker in name for marker in GENERIC_CUSTOMER_MARKERS)


def _customer_rows(response: dict[str, Any], section: str) -> list[dict[str, Any]]:
    rows = _required_rows(response, "/tender/major-buyers", section)
    meaningful_rows = [
        row for row in rows if _is_meaningful_customer_name(row.get("招标单位"))
    ]
    if len(meaningful_rows) < 5:
        raise P1UpdateError(
            f"P1 更新失败：/tender/major-buyers.{section} 过滤无意义客户后少于 5 行。"
        )
    return meaningful_rows


def _analysis_growth_part(value: Any, field_path: str) -> str | None:
    number = _parse_optional_decimal(value, field_path)
    if number is None:
        return None
    sign = "+" if number > 0 else ""
    return f"金额同比{sign}{_format_decimal(number)}%"


def _wrap_followup_prompt(data_text: str) -> str:
    return f"{{{{{data_text}；请基于数据进行概括总结分析，凸显数据特征}}}}"


def _line_for_region(row: dict[str, Any]) -> str:
    name = _required_text(row, "属地", "/tender/region.累计数据.属地")
    amount = _format_amount(row.get("金额（亿元）"), f"/tender/region.累计数据.{name}.金额（亿元）")
    count = str(
        int(_parse_decimal(row.get("个数"), f"/tender/region.累计数据.{name}.个数"))
    )
    parts = [f"{name}属地累计招标金额{amount}亿元", f"招标次数{count}次"]
    growth = _analysis_growth_part(
        row.get("金额同比增幅"),
        f"/tender/region.累计数据.{name}.金额同比增幅",
    )
    if growth is not None:
        parts.append(growth)
    return _wrap_followup_prompt("、".join(parts))


def _line_for_industry(row: dict[str, Any]) -> str:
    name = _required_text(row, "行业", "/tender/industry.累计数据.行业")
    amount = _format_amount(row.get("金额（亿元）"), f"/tender/industry.累计数据.{name}.金额（亿元）")
    count = str(
        int(_parse_decimal(row.get("个数"), f"/tender/industry.累计数据.{name}.个数"))
    )
    parts = [f"{name}行业累计招标金额{amount}亿元", f"招标次数{count}次"]
    growth = _analysis_growth_part(
        row.get("金额同比增幅"),
        f"/tender/industry.累计数据.{name}.金额同比增幅",
    )
    if growth is not None:
        parts.append(growth)
    return _wrap_followup_prompt("、".join(parts))


def _line_for_customer(row: dict[str, Any]) -> str:
    name = _required_text(row, "招标单位", "/tender/major-buyers.招标单位")
    amount = _format_amount(
        row.get("招标预算金额（万元）"),
        f"/tender/major-buyers.{name}.招标预算金额（万元）",
    )
    count = row.get("招标次数")
    count_text = "" if count is None else str(count).strip()
    if not count_text:
        raise P1UpdateError(f"P1 更新失败：/tender/major-buyers.{name}.招标次数 为空。")
    return _wrap_followup_prompt(f"{name}累计招标预算{amount}万元、招标次数{count_text}次")


def _quarter_parts(label: str) -> tuple[str, str]:
    match = re.fullmatch(r"\s*(\d{4})年([1-4])季度\s*", label)
    if not match:
        raise P1UpdateError(f"P1 更新失败：/tender/time.季度数据.季度={label!r} 格式不正确。")
    return f"{match.group(1)}年", f"Q{match.group(2)}"


def _quarter_table_rows(response: dict[str, Any]) -> list[list[str]]:
    rows = _required_rows(response, "/tender/time", "季度数据")
    if len(rows) != 5:
        raise P1UpdateError("P1 更新失败：/tender/time.季度数据 必须包含 5 个季度。")

    year_headers = ["时间类型"]
    quarter_headers = [""]
    government_amounts = ["政府"]
    enterprise_amounts = ["企业"]
    previous_year = ""
    for index, row in enumerate(rows, start=1):
        label = _required_text(row, "季度", f"/tender/time.季度数据[{index}].季度")
        year_label, quarter_label = _quarter_parts(label)
        year_headers.append(year_label if year_label != previous_year else "")
        quarter_headers.append(quarter_label)
        previous_year = year_label
        government_amounts.append(
            f"{_format_amount(row.get('政府侧招采金额（亿元）'), f'/tender/time.季度数据[{index}].政府侧招采金额（亿元）')}亿"
        )
        enterprise_amounts.append(
            f"{_format_amount(row.get('企业侧招采金额（亿元）'), f'/tender/time.季度数据[{index}].企业侧招采金额（亿元）')}亿"
        )

    return [year_headers, quarter_headers, government_amounts, enterprise_amounts]


def build_p1_update_model(responses: dict[str, Any]) -> P1UpdateModel:
    """Build all P1 replacements and chart data from FastAPI responses."""
    overall = _endpoint_response(responses, "/tender/overall")
    industry = _endpoint_response(responses, "/tender/industry")
    region = _endpoint_response(responses, "/tender/region")
    major_buyers = _endpoint_response(responses, "/tender/major-buyers")
    tender_class = _endpoint_response(responses, "/tender/class")
    tender_time = _endpoint_response(responses, "/tender/time")
    customer_139 = _endpoint_response(responses, "/tender/customer-139")

    cumulative_overall = _required_dict(overall, "/tender/overall", "累计数据")
    industry_rows = _required_rows(industry, "/tender/industry", "累计数据")
    region_rows = _required_rows(region, "/tender/region", "累计数据")
    government_rows = _customer_rows(major_buyers, "政府侧招标单位")
    enterprise_rows = _customer_rows(major_buyers, "企业侧招标单位")
    government_side = _required_dict(tender_class, "/tender/class", "政府侧累计数据")
    enterprise_side = _required_dict(tender_class, "/tender/class", "企业侧累计数据")
    customer_139_monthly = _required_dict(customer_139, "/tender/customer-139", "月度数据")

    growth_rows = _top_industry_growth_rows(industry_rows)
    amount_rows = _top_rows_by_amount(industry_rows, "金额（亿元）", "/tender/industry.累计数据", 5)
    region_top_rows = _top_rows_by_amount(region_rows, "金额（亿元）", "/tender/region.累计数据", 10)
    customer_top_rows = _top_rows_by_amount(
        [*government_rows, *enterprise_rows],
        "招标预算金额（万元）",
        "/tender/major-buyers.合并客户",
        2,
    )

    cumulative_amount = _format_amount(
        cumulative_overall.get("招标预算金额（亿元）"),
        "/tender/overall.累计数据.招标预算金额（亿元）",
    )
    cumulative_count = str(cumulative_overall.get("招标次数", "")).strip()
    if not cumulative_count:
        raise P1UpdateError("P1 更新失败：/tender/overall.累计数据.招标次数 为空。")

    replacements = {
        "{{累计金额}}": _format_amount(
            cumulative_overall.get("招标预算金额（亿元）"),
            "/tender/overall.累计数据.招标预算金额（亿元）",
        ),
        "{{金额同比}}": _format_growth_or_fact(
            cumulative_overall.get("招标金额同比增幅"),
            "/tender/overall.累计数据.招标金额同比增幅",
            f"金额{cumulative_amount}亿元",
        ),
        "{{累计次数}}": cumulative_count,
        "{{次数同比}}": _format_growth_or_fact(
            cumulative_overall.get("招标次数同比增幅"),
            "/tender/overall.累计数据.招标次数同比增幅",
            f"次数{cumulative_count}次",
        ),
        "{{政府侧招标规模万元}} 万": _format_amount_yi_with_unit(
            government_side.get("招标预算金额（亿元）"),
            "/tender/class.政府侧累计数据.招标预算金额（亿元）",
        ),
        "{{政府侧招标金额占比}}": _required_text(
            government_side,
            "招标金额占比",
            "/tender/class.政府侧累计数据.招标金额占比",
        ),
        "{{政府侧招标个数}}": str(
            int(
                _parse_decimal(
                    government_side.get("招标次数"),
                    "/tender/class.政府侧累计数据.招标次数",
                )
            )
        ),
        "{{政府侧招标次数占比}}": _required_text(
            government_side,
            "招标次数占比",
            "/tender/class.政府侧累计数据.招标次数占比",
        ),
        "{{企业侧招标规模万元}} 万": _format_amount_yi_with_unit(
            enterprise_side.get("招标预算金额（亿元）"),
            "/tender/class.企业侧累计数据.招标预算金额（亿元）",
        ),
        "{{企业侧招标金额占比}}": _required_text(
            enterprise_side,
            "招标金额占比",
            "/tender/class.企业侧累计数据.招标金额占比",
        ),
        "{{企业侧招标个数}}": str(
            int(
                _parse_decimal(
                    enterprise_side.get("招标次数"),
                    "/tender/class.企业侧累计数据.招标次数",
                )
            )
        ),
        "{{企业侧招标次数占比}}": _required_text(
            enterprise_side,
            "招标次数占比",
            "/tender/class.企业侧累计数据.招标次数占比",
        ),
        "{{规模万元}} 万": _format_amount_yi_with_unit(
            customer_139_monthly.get("招标预算金额（亿元）"),
            "/tender/customer-139.月度数据.招标预算金额（亿元）",
        ),
        "{{招标个数}}": str(
            int(
                _parse_decimal(
                    customer_139_monthly.get("招标次数"),
                    "/tender/customer-139.月度数据.招标次数",
                )
            )
        ),
    }

    for index, row in enumerate(growth_rows, start=1):
        name = _required_text(row, "行业", f"/tender/industry.累计数据[{index}].行业")
        amount = _format_amount(row.get("金额（亿元）"), f"/tender/industry.累计数据.{name}.金额（亿元）")
        count = str(int(_parse_decimal(row.get("个数"), f"/tender/industry.累计数据.{name}.个数")))
        growth = _format_growth_or_fact(
            row.get("金额同比增幅"),
            f"/tender/industry.累计数据.{name}.金额同比增幅",
            f"金额{amount}亿元/次数{count}次",
        )
        replacements[f"{{{{行业{index}增幅}}}}"] = f"{name} {growth}"

    for index, row in enumerate(amount_rows, start=1):
        name = _required_text(row, "行业", f"/tender/industry.累计数据[{index}].行业")
        amount = _format_amount(row.get("金额（亿元）"), f"/tender/industry.累计数据.{name}.金额（亿元）")
        replacements[f"{{{{行业{index}金额}}}}"] = f"{name} {amount}亿元"

    for index, row in enumerate(government_rows[:5], start=1):
        replacements[f"{{{{政府侧客户 {index}}}}}"] = _required_text(
            row,
            "招标单位",
            f"/tender/major-buyers.政府侧招标单位[{index}].招标单位",
        )

    for index, row in enumerate(enterprise_rows[:5], start=1):
        replacements[f"{{{{企业侧客户 {index}}}}}"] = _required_text(
            row,
            "招标单位",
            f"/tender/major-buyers.企业侧招标单位[{index}].招标单位",
        )

    for index, row in enumerate(region_top_rows[:2], start=1):
        replacements[f"{{{{区域{index}及分析结果}}}}"] = _line_for_region(row)

    for index, row in enumerate(amount_rows[:2], start=1):
        replacements[f"{{{{行业{index}及分析结果}}}}"] = _line_for_industry(row)

    for index, row in enumerate(customer_top_rows, start=1):
        replacements[f"{{{{客群{index}及分析结果}}}}"] = _line_for_customer(row)

    return P1UpdateModel(
        replacements=replacements,
        region_chart=RegionChartData(
            categories=[
                _required_text(row, "属地", "/tender/region.累计数据.属地")
                for row in region_top_rows
            ],
            values=[
                float(_parse_decimal(row.get("金额（亿元）"), "/tender/region.累计数据.金额（亿元）"))
                for row in region_top_rows
            ],
        ),
        quarter_table_rows=_quarter_table_rows(tender_time),
    )


def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
    base = api_base.rstrip("/")
    url = f"{base}{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 P1UpdateError(f"P1 更新失败：{endpoint} HTTP {exc.code}: {detail}") from exc
    except URLError as exc:
        raise P1UpdateError(f"P1 更新失败：无法连接 FastAPI {url}: {exc.reason}") from exc

    if status != 200:
        raise P1UpdateError(f"P1 更新失败：{endpoint} HTTP {status}。")
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise P1UpdateError(f"P1 更新失败：{endpoint} 返回值不是 JSON。") from exc
    if not isinstance(data, dict):
        raise P1UpdateError(f"P1 更新失败：{endpoint} 返回 JSON 根节点不是对象。")
    return data


def fetch_p1_responses(api_base: str, year: int, month: str) -> dict[str, Any]:
    params = {"year": year, "month": month, "scope": "上海"}
    return {
        endpoint: fetch_json(api_base, endpoint, params)
        for endpoint in REQUIRED_ENDPOINTS
    }


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


def _replace_slide_text(slide_xml: bytes, replacements: dict[str, str]) -> bytes:
    root = ET.fromstring(slide_xml)
    _replace_slide_text_root(root, replacements)
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _replace_slide_text_root(root: ET.Element, replacements: dict[str, str]) -> None:
    replaced: set[str] = set()
    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:
                updated = updated.replace(placeholder, value)
                replaced.add(placeholder)
        if updated != original:
            text_nodes[0].text = updated
            for node in text_nodes[1:]:
                node.text = ""

    missing = sorted(set(replacements) - replaced)
    if missing:
        raise P1UpdateError(f"P1 更新失败：模板第 1 页缺少占位符：{', '.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_quarter_table(root: ET.Element, table_rows: list[list[str]]) -> None:
    tables = root.findall(".//a:tbl", OOXML_NS)
    if len(tables) != 1:
        raise P1UpdateError("P1 更新失败：第 1 页应包含 1 张招采节奏表。")
    xml_rows = tables[0].findall("./a:tr", OOXML_NS)
    if len(xml_rows) < len(table_rows):
        raise P1UpdateError("P1 更新失败：第 1 页招采节奏表行数不足。")
    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 P1UpdateError("P1 更新失败：第 1 页招采节奏表列数不足。")
        for cell, value in zip(cells, row_values, strict=True):
            _set_text_nodes(cell, value)


def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
    for child in list(cache):
        if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
            cache.remove(child)

    pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
    pt_count.set("val", str(len(values)))
    for index, value in enumerate(values):
        point = ET.SubElement(cache, _qn("c", "pt"))
        point.set("idx", str(index))
        value_node = ET.SubElement(point, _qn("c", "v"))
        value_node.text = value


def _update_chart1_xml(chart_xml: bytes, chart_data: RegionChartData) -> bytes:
    root = ET.fromstring(chart_xml)
    series = root.find(".//c:ser", OOXML_NS)
    if series is None:
        raise P1UpdateError("P1 更新失败：chart1.xml 中没有找到图表系列。")

    category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
    value_cache = series.find("./c:val//c:numCache", OOXML_NS)
    if category_cache is None or value_cache is None:
        raise P1UpdateError("P1 更新失败：chart1.xml 缺少分类或数值缓存。")

    _set_cache_points(category_cache, chart_data.categories)
    _set_cache_points(
        value_cache,
        [_format_decimal(Decimal(str(value))) for value in chart_data.values],
    )
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _update_workbook1(workbook_bytes: bytes, chart_data: RegionChartData) -> bytes:
    workbook = load_workbook(BytesIO(workbook_bytes))
    sheet = workbook.active
    sheet["A1"] = "属地"
    sheet["B1"] = "金额（亿元）"
    for row_index, (category, value) in enumerate(
        zip(chart_data.categories, chart_data.values, strict=True),
        start=2,
    ):
        sheet.cell(row=row_index, column=1).value = category
        sheet.cell(row=row_index, column=2).value = value

    for row_index in range(len(chart_data.categories) + 2, sheet.max_row + 1):
        sheet.cell(row=row_index, column=1).value = None
        sheet.cell(row=row_index, column=2).value = None

    output = BytesIO()
    workbook.save(output)
    return output.getvalue()


def update_p1_pptx(pptx_path: Path, output_path: Path, model: P1UpdateModel) -> None:
    if not pptx_path.exists():
        raise P1UpdateError(f"P1 更新失败：输入 PPTX 不存在：{pptx_path}")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    required_package_parts = {
        "ppt/slides/slide1.xml",
        "ppt/charts/chart1.xml",
        "ppt/embeddings/Workbook1.xlsx",
    }

    with ZipFile(pptx_path, "r") as source:
        names = set(source.namelist())
        missing = sorted(required_package_parts - names)
        if missing:
            raise P1UpdateError(f"P1 更新失败：PPTX 缺少必要部件：{', '.join(missing)}。")

        slide_root = ET.fromstring(source.read("ppt/slides/slide1.xml"))
        _replace_slide_text_root(slide_root, model.replacements)
        _update_quarter_table(slide_root, model.quarter_table_rows)

        updates = {
            "ppt/slides/slide1.xml": ET.tostring(
                slide_root,
                encoding="utf-8",
                xml_declaration=True,
            ),
            "ppt/charts/chart1.xml": _update_chart1_xml(
                source.read("ppt/charts/chart1.xml"),
                model.region_chart,
            ),
            "ppt/embeddings/Workbook1.xlsx": _update_workbook1(
                source.read("ppt/embeddings/Workbook1.xlsx"),
                model.region_chart,
            ),
        }

        if pptx_path.resolve() == output_path.resolve():
            with tempfile.NamedTemporaryFile(
                prefix="p1-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:
                for info in source.infolist():
                    target.writestr(info, updates.get(info.filename, source.read(info.filename)))
            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 1 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()
    responses = fetch_p1_responses(args.api_base, args.year, args.month)
    model = build_p1_update_model(responses)
    update_p1_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
