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

本脚本用于单独更新 PPT 第 3 页：月度招标与近三月政府侧/企业侧趋势。

取数逻辑：
- `/tender/overall?year={year}&month={month}&scope=行政区&scope_value={district}`
  使用 `月度数据` 更新单月总体指标和文案。
- `/tender/industry?year={year}&month={month}&scope=行政区&scope_value={district}`
  使用 `月度数据` 更新月度行业 Top3 表格和分析文案。
- `/tender/class-trend?year={year}&month={month}&scope=行政区&scope_value={district}`
  使用 `月度数据` 更新近三月企业侧、政府侧招标次数/金额图表和趋势占位符。
"""

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 = 3
SLIDE_XML = "ppt/slides/slide3.xml"
DATA_SOURCES = ["/tender/overall", "/tender/industry", "/tender/class-trend"]


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)


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


P3_MONTH_PLACEHOLDER = "{{P3统计月份：如2026年4月}}"
P3_ANALYSIS_PLACEHOLDERS = [
    "{{P3整体月度分析：说明单月次数、金额、次数环比、金额环比和量价特征}}",
    "{{P3行业总览：按月度金额Top3说明金额、合计占比和行业集中度}}",
    "{{P3行业分析1：按月度金额排序第1行业，说明个数环比、金额环比和特征}}",
    "{{P3行业分析2：按月度金额排序第2行业，说明个数环比、金额环比和特征}}",
    "{{P3行业分析3：按月度金额排序第3行业，说明个数环比、金额环比和特征}}",
]
P3_TREND_SUMMARY_PLACEHOLDER = "{{P3一句话扼要说明近三月政府侧招标趋势情况}}"
P3_TREND_DETAIL_PLACEHOLDER = "{{P3简要介绍三个月的变化情况}}"
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析，凸显数据特征"


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


@dataclass(frozen=True)
class IndustryMonthlyRow:
    industry: str
    count: int
    amount_yi: Decimal
    count_growth: Decimal | None
    amount_growth: Decimal | None


@dataclass(frozen=True)
class TrendRow:
    month_label: str
    government_count: int
    government_amount_yi: Decimal
    enterprise_count: int
    enterprise_amount_yi: Decimal


@dataclass(frozen=True)
class ChartModel:
    title: str
    series_name: str
    categories: list[str]
    values: list[int | float]


@dataclass(frozen=True)
class P3UpdateModel:
    replacements: dict[str, str]
    sequential_replacements: dict[str, list[str]]
    industry_table_rows: list[list[str]]
    overall_table_rows: list[list[str]]
    charts: dict[str, ChartModel]


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 P3UpdateError(f"P3 更新失败：{field_path} 为空，无法更新第三页。")
    raw = str(value).strip()
    if not raw or raw == "/":
        raise P3UpdateError(f"P3 更新失败：{field_path} 不是可计算数字，无法更新第三页。")
    raw = raw.replace(",", "").removesuffix("%")
    try:
        return Decimal(raw)
    except InvalidOperation as exc:
        raise P3UpdateError(f"P3 更新失败：{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(value, field_path)


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


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


def _format_growth(value: Decimal | None) -> str:
    if value is None:
        return "/"
    sign = "+" if value > 0 else ""
    return f"{sign}{_format_decimal(value)}%"


def _wrap_followup_prompt(fact_text: str) -> str:
    return f"{{{{{fact_text}；{FOLLOWUP_PROMPT_SUFFIX}}}}}"


def _month_label(response: dict[str, Any]) -> str:
    year = response.get("统计年份")
    month = str(response.get("统计月份", "")).strip()
    month_number = month.removesuffix("月")
    if not year or not month_number:
        raise P3UpdateError("P3 更新失败：/tender/overall 缺少统计年份或统计月份。")
    return f"{year}年{month_number}月"


def _monthly_overall(response: dict[str, Any]) -> dict[str, Any]:
    value = response.get("月度数据")
    if not isinstance(value, dict):
        raise P3UpdateError("P3 更新失败：/tender/overall 响应缺少 月度数据。")
    return value


def _industry_rows(response: dict[str, Any]) -> list[IndustryMonthlyRow]:
    raw_rows = response.get("月度数据")
    if not isinstance(raw_rows, list) or not raw_rows:
        raise P3UpdateError("P3 更新失败：/tender/industry 响应缺少 月度数据。")

    sortable_rows: list[tuple[Decimal, dict[str, Any], str, int]] = []
    for index, raw_row in enumerate(raw_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P3UpdateError(f"P3 更新失败：/tender/industry.月度数据[{index}] 不是对象。")
        industry = _required_text(raw_row, "行业", f"/tender/industry.月度数据[{index}].行业")
        amount = _parse_decimal(
            raw_row.get("金额（亿元）"),
            f"/tender/industry.月度数据.{industry}.金额（亿元）",
        )
        count = int(_parse_decimal(raw_row.get("个数"), f"/tender/industry.月度数据.{industry}.个数"))
        sortable_rows.append((amount, raw_row, industry, count))

    if len(sortable_rows) < 3:
        raise P3UpdateError("P3 更新失败：/tender/industry.月度数据 少于 3 行。")

    rows: list[IndustryMonthlyRow] = []
    for amount, raw_row, industry, count in sorted(sortable_rows, key=lambda item: item[0], reverse=True)[:3]:
        rows.append(
            IndustryMonthlyRow(
                industry=industry,
                count=count,
                amount_yi=amount,
                count_growth=_parse_optional_decimal(
                    raw_row.get("个数环比增幅"),
                    f"/tender/industry.月度数据.{industry}.个数环比增幅",
                ),
                amount_growth=_parse_optional_decimal(
                    raw_row.get("金额环比增幅"),
                    f"/tender/industry.月度数据.{industry}.金额环比增幅",
                ),
            )
        )
    return rows


def _overall_analysis(month_label: str, monthly: dict[str, Any]) -> str:
    count = int(_parse_decimal(monthly.get("招标次数"), "/tender/overall.月度数据.招标次数"))
    amount = _parse_decimal(monthly.get("招标预算金额（亿元）"), "/tender/overall.月度数据.招标预算金额（亿元）")
    count_growth = _parse_optional_decimal(monthly.get("招标次数环比增幅"), "/tender/overall.月度数据.招标次数环比增幅")
    amount_growth = _parse_optional_decimal(monthly.get("招标金额环比增幅"), "/tender/overall.月度数据.招标金额环比增幅")
    return _wrap_followup_prompt(
        f"{month_label}：公开市场招标{count}次/{_format_amount(amount)}亿，"
        f"次数环比{_format_growth(count_growth)}，金额环比{_format_growth(amount_growth)}"
    )


def _industry_summary(rows: list[IndustryMonthlyRow]) -> str:
    top3 = rows[:3]
    total_amount = sum((row.amount_yi for row in rows), Decimal("0"))
    top3_amount = sum((row.amount_yi for row in top3), Decimal("0"))
    share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
    top_text = "、".join(f"{row.industry}{_format_amount(row.amount_yi)}亿" for row in top3)
    return _wrap_followup_prompt(f"月度金额Top3行业为{top_text}，合计占比{share.quantize(Decimal('0.1'))}%")


def _industry_analysis(row: IndustryMonthlyRow) -> str:
    return _wrap_followup_prompt(
        f"{row.industry}行业：{row.count}个/{_format_amount(row.amount_yi)}亿，"
        f"个数环比{_format_growth(row.count_growth)}，金额环比{_format_growth(row.amount_growth)}"
    )


def _month_sort_key(label: str) -> tuple[int, int]:
    match = re.fullmatch(r"\s*(\d{4})年(\d{1,2})月\s*", label)
    if not match:
        raise P3UpdateError(f"P3 更新失败：/tender/class-trend.月度数据.月份={label!r} 格式不正确。")
    return int(match.group(1)), int(match.group(2))


def _trend_rows(response: dict[str, Any]) -> list[TrendRow]:
    raw_rows = response.get("月度数据")
    if not isinstance(raw_rows, list) or len(raw_rows) < 3:
        raise P3UpdateError("P3 更新失败：/tender/class-trend.月度数据 少于 3 行。")

    rows: list[TrendRow] = []
    for index, raw_row in enumerate(raw_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P3UpdateError(f"P3 更新失败：/tender/class-trend.月度数据[{index}] 不是对象。")
        month_label = _required_text(raw_row, "月份", f"/tender/class-trend.月度数据[{index}].月份")
        rows.append(
            TrendRow(
                month_label=month_label,
                government_count=int(
                    _parse_decimal(raw_row.get("政府侧招标次数"), f"/tender/class-trend.月度数据.{month_label}.政府侧招标次数")
                ),
                government_amount_yi=_parse_decimal(
                    raw_row.get("政府侧招标预算金额（亿元）"),
                    f"/tender/class-trend.月度数据.{month_label}.政府侧招标预算金额（亿元）",
                ),
                enterprise_count=int(
                    _parse_decimal(raw_row.get("企业侧招标次数"), f"/tender/class-trend.月度数据.{month_label}.企业侧招标次数")
                ),
                enterprise_amount_yi=_parse_decimal(
                    raw_row.get("企业侧招标预算金额（亿元）"),
                    f"/tender/class-trend.月度数据.{month_label}.企业侧招标预算金额（亿元）",
                ),
            )
        )
    return sorted(rows, key=lambda row: _month_sort_key(row.month_label))[-3:]


def _trend_values(rows: list[TrendRow], side: str) -> tuple[list[int], list[Decimal]]:
    if side == "政府侧":
        return [row.government_count for row in rows], [row.government_amount_yi for row in rows]
    if side == "企业侧":
        return [row.enterprise_count for row in rows], [row.enterprise_amount_yi for row in rows]
    raise P3UpdateError(f"P3 更新失败：未知趋势侧别 {side!r}。")


def _trend_summary(rows: list[TrendRow], side: str) -> str:
    counts, amounts = _trend_values(rows, side)
    return _wrap_followup_prompt(
        f"近三月{side}：{counts[0]}->{counts[-1]}次，"
        f"{_format_amount(amounts[0])}->{_format_amount(amounts[-1])}亿"
    )


def _trend_detail(rows: list[TrendRow], side: str) -> str:
    counts, amounts = _trend_values(rows, side)
    parts = [
        f"{row.month_label}{count}次/{_format_amount(amount)}亿"
        for row, count, amount in zip(rows, counts, amounts, strict=True)
    ]
    return _wrap_followup_prompt("；".join(parts))


def _build_charts(rows: list[TrendRow]) -> dict[str, ChartModel]:
    months = [row.month_label for row in rows]
    return {
        "chart2": ChartModel(
            title="近三月企业侧招标次数",
            series_name="个数",
            categories=months,
            values=[row.enterprise_count for row in rows],
        ),
        "chart3": ChartModel(
            title="近三月企业侧招标金额（万元）",
            series_name="金额（万元）",
            categories=months,
            values=[float(row.enterprise_amount_yi * Decimal("10000")) for row in rows],
        ),
        "chart4": ChartModel(
            title="近三月政府侧招标次数",
            series_name="个数",
            categories=months,
            values=[row.government_count for row in rows],
        ),
        "chart5": ChartModel(
            title="近三月政府侧招标金额（万元）",
            series_name="金额（万元）",
            categories=months,
            values=[float(row.government_amount_yi * Decimal("10000")) for row in rows],
        ),
    }


def build_p3_update_model(
    overall_response: dict[str, Any],
    industry_response: dict[str, Any],
    class_trend_response: dict[str, Any],
) -> P3UpdateModel:
    month_label = _month_label(overall_response)
    monthly = _monthly_overall(overall_response)
    industry_rows = _industry_rows(industry_response)
    trend_rows = _trend_rows(class_trend_response)

    overall_count = int(_parse_decimal(monthly.get("招标次数"), "/tender/overall.月度数据.招标次数"))
    overall_amount = _parse_decimal(monthly.get("招标预算金额（亿元）"), "/tender/overall.月度数据.招标预算金额（亿元）")
    overall_count_growth = _parse_optional_decimal(monthly.get("招标次数环比增幅"), "/tender/overall.月度数据.招标次数环比增幅")
    overall_amount_growth = _parse_optional_decimal(monthly.get("招标金额环比增幅"), "/tender/overall.月度数据.招标金额环比增幅")

    replacements = {
        P3_MONTH_PLACEHOLDER: month_label,
        P3_ANALYSIS_PLACEHOLDERS[0]: _overall_analysis(month_label, monthly),
        P3_ANALYSIS_PLACEHOLDERS[1]: _industry_summary(industry_rows),
    }
    for index, row in enumerate(industry_rows, start=1):
        replacements[P3_ANALYSIS_PLACEHOLDERS[index + 1]] = _industry_analysis(row)

    return P3UpdateModel(
        replacements=replacements,
        sequential_replacements={
            P3_TREND_SUMMARY_PLACEHOLDER: [
                _trend_summary(trend_rows, "政府侧"),
                _trend_summary(trend_rows, "企业侧"),
            ],
            P3_TREND_DETAIL_PLACEHOLDER: [
                _trend_detail(trend_rows, "政府侧"),
                _trend_detail(trend_rows, "企业侧"),
            ],
        },
        industry_table_rows=[
            ["行业", "个数", "金额（亿元）", "个数环比", "金额环比"],
            *[
                [
                    row.industry,
                    str(row.count),
                    _format_amount(row.amount_yi),
                    _format_growth(row.count_growth),
                    _format_growth(row.amount_growth),
                ]
                for row in industry_rows
            ],
        ],
        overall_table_rows=[
            ["招标次数", "招标预算金额（亿元）", "招标次数环比（%）", "招标金额环比（%）"],
            [
                str(overall_count),
                _format_amount(overall_amount),
                _format_growth(overall_count_growth),
                _format_growth(overall_amount_growth),
            ],
        ],
        charts=_build_charts(trend_rows),
    )


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=120) as response:
            payload = response.read().decode("utf-8")
            status = response.status
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise P3UpdateError(f"P3 更新失败：{endpoint} HTTP {exc.code}: {detail}") from exc
    except URLError as exc:
        raise P3UpdateError(f"P3 更新失败：无法连接 FastAPI {url}: {exc.reason}") from exc
    if status != 200:
        raise P3UpdateError(f"P3 更新失败：{endpoint} HTTP {status}。")
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise P3UpdateError(f"P3 更新失败：{endpoint} 返回值不是 JSON。") from exc
    if not isinstance(data, dict):
        raise P3UpdateError(f"P3 更新失败：{endpoint} 返回 JSON 根节点不是对象。")
    return data


def fetch_p3_responses(
    api_base: str,
    year: int,
    month: str,
    district: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
    district = district.strip()
    if not district:
        raise P3UpdateError("P3 更新失败：行政区参数 --district 不能为空。")
    params = {"year": year, "month": month, "scope": "行政区", "scope_value": district}
    return (
        fetch_json(api_base, "/tender/overall", params),
        fetch_json(api_base, "/tender/industry", params),
        fetch_json(api_base, "/tender/class-trend", params),
    )


def _replace_slide_text(
    root: ET.Element,
    replacements: dict[str, str],
    sequential_replacements: dict[str, list[str]],
) -> None:
    replaced_counts = {placeholder: 0 for placeholder in replacements}
    sequential_counts = {placeholder: 0 for placeholder in sequential_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)
        for placeholder, values in sequential_replacements.items():
            while placeholder in updated:
                index = sequential_counts[placeholder]
                if index >= len(values):
                    raise P3UpdateError(f"P3 更新失败：模板第 3 页占位符 {placeholder} 出现次数多于设计值。")
                updated = updated.replace(placeholder, values[index], 1)
                sequential_counts[placeholder] += 1
        if updated != original:
            text_nodes[0].text = updated
            for node in text_nodes[1:]:
                node.text = ""

    required_counts = {placeholder: 1 for placeholder in replacements}
    required_counts[P3_MONTH_PLACEHOLDER] = 2
    missing = sorted(
        placeholder
        for placeholder, required_count in required_counts.items()
        if replaced_counts[placeholder] < required_count
    )
    sequential_missing = sorted(
        placeholder
        for placeholder, values in sequential_replacements.items()
        if sequential_counts[placeholder] < len(values)
    )
    if missing or sequential_missing:
        raise P3UpdateError(f"P3 更新失败：模板第 3 页缺少占位符：{', '.join(missing + sequential_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, rows: list[list[str]], label: str) -> None:
    xml_rows = table.findall("./a:tr", OOXML_NS)
    if len(xml_rows) < len(rows):
        raise P3UpdateError(f"P3 更新失败：第 3 页{label}表格行数不足。")
    for extra_row in xml_rows[len(rows):]:
        table.remove(extra_row)
    xml_rows = table.findall("./a:tr", OOXML_NS)
    for row_index, row_values in enumerate(rows):
        cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
        if len(cells) < len(row_values):
            raise P3UpdateError(f"P3 更新失败：第 3 页{label}表格列数不足。")
        for cell, value in zip(cells, row_values, strict=True):
            _set_text_nodes(cell, value)


def _table_frames_by_y(root: ET.Element) -> tuple[ET.Element, ET.Element]:
    frames: list[tuple[int, ET.Element]] = []
    for graphic_frame in root.findall(".//p:graphicFrame", OOXML_NS):
        table = graphic_frame.find(".//a:tbl", OOXML_NS)
        if table is None:
            continue
        off = graphic_frame.find("./p:xfrm/a:off", OOXML_NS)
        y = int(off.attrib.get("y", "0")) if off is not None else 0
        frames.append((y, table))
    if len(frames) != 2:
        raise P3UpdateError("P3 更新失败：第 3 页应包含 2 张表。")
    overall_table, industry_table = [table for _, table in sorted(frames, key=lambda item: item[0])]
    return overall_table, industry_table


def _update_slide_tables(root: ET.Element, model: P3UpdateModel) -> None:
    overall_table, industry_table = _table_frames_by_y(root)
    _update_table(overall_table, model.overall_table_rows, "月度整体")
    _update_table(industry_table, model.industry_table_rows, "月度行业")


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 _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
    text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
    if not text_nodes:
        raise P3UpdateError(f"P3 更新失败：{chart_name}.xml 缺少标题文本节点。")
    text_nodes[0].text = title
    for node in text_nodes[1:]:
        node.text = ""


def _chart_value_text(value: int | float) -> str:
    return _format_decimal(Decimal(str(value)))


def _update_chart_xml(chart_xml: bytes, chart_name: str, model: ChartModel) -> bytes:
    root = ET.fromstring(chart_xml)
    _set_chart_title(root, model.title, chart_name)
    series_nodes = root.findall(".//c:ser", OOXML_NS)
    if not series_nodes:
        raise P3UpdateError(f"P3 更新失败：{chart_name}.xml 缺少图表系列。")
    series = series_nodes[0]
    name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
    if name_nodes:
        name_nodes[0].text = model.series_name
    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 P3UpdateError(f"P3 更新失败：{chart_name}.xml 缺少分类或数值缓存。")
    _set_cache_points(category_cache, model.categories)
    _set_cache_points(value_cache, [_chart_value_text(value) for value in model.values])
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _update_workbook(workbook_bytes: bytes, chart_model: ChartModel) -> bytes:
    workbook = load_workbook(BytesIO(workbook_bytes))
    sheet = workbook.active
    clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
    clear_columns = max(sheet.max_column, 2)
    for row_index in range(1, clear_rows + 1):
        for column_index in range(1, clear_columns + 1):
            sheet.cell(row=row_index, column=column_index).value = None

    sheet["A1"] = "月份"
    sheet["B1"] = chart_model.series_name
    for row_index, (category, value) in enumerate(zip(chart_model.categories, chart_model.values, strict=True), start=2):
        sheet.cell(row=row_index, column=1).value = category
        sheet.cell(row=row_index, column=2).value = value
    output = BytesIO()
    workbook.save(output)
    return output.getvalue()


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

    chart_parts = {f"ppt/charts/chart{index}.xml" for index in range(2, 6)}
    workbook_parts = {f"ppt/embeddings/Workbook{index}.xlsx" for index in range(2, 6)}
    required_parts = {"ppt/slides/slide3.xml", *chart_parts, *workbook_parts}
    with ZipFile(pptx_path, "r") as source:
        missing = sorted(required_parts - set(source.namelist()))
        if missing:
            raise P3UpdateError(f"P3 更新失败：PPTX 缺少必要部件：{', '.join(missing)}。")

        slide_root = ET.fromstring(source.read("ppt/slides/slide3.xml"))
        _replace_slide_text(slide_root, model.replacements, model.sequential_replacements)
        _update_slide_tables(slide_root, model)
        updates: dict[str, bytes] = {
            "ppt/slides/slide3.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
        }
        for index in range(2, 6):
            chart_key = f"chart{index}"
            chart_part = f"ppt/charts/{chart_key}.xml"
            workbook_part = f"ppt/embeddings/Workbook{index}.xlsx"
            updates[chart_part] = _update_chart_xml(source.read(chart_part), chart_key, model.charts[chart_key])
            updates[workbook_part] = _update_workbook(source.read(workbook_part), model.charts[chart_key])

        if pptx_path.resolve() == output_path.resolve():
            with tempfile.NamedTemporaryFile(
                prefix="p3-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 3 of the administrative district 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.")
    parser.add_argument("--district", required=True, help="行政区名称，例如 浦东新区。")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    responses = fetch_p3_responses(args.api_base, args.year, args.month, args.district)
    model = build_p3_update_model(*responses)
    update_p3_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
