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

本脚本用于单独更新 PPT 第 5 页：运营商市场中标总览。

取数逻辑：
- `/award/overall?year={year}&month={month}&ct=是&scope=行政区&scope_value={district}`
  使用 `累计数据` 和 `月度数据` 更新运营商 KPI、正文和 4 张运营商图表。
- `/award/industry?year={year}&month={month}&ct=是&scope=行政区&scope_value={district}`
  使用 `累计数据.金额数据` 更新行业规模图表与文案。
- `/award/overall?year={year - 1}&month={month}&ct=是&scope=行政区&scope_value={district}`
  作为累计同比基准。
- `/award/overall?year={previous_year}&month={previous_month}&ct=是&scope=行政区&scope_value={district}`
  作为单月环比基准。
"""

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 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 = 5
SLIDE_XML = "ppt/slides/slide5.xml"
DATA_SOURCES = ["/award/overall", "/award/industry"]
OPERATORS = ("移动", "电信", "联通")


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}"


P5_TEXT_PLACEHOLDERS = [
    "{{P4累计统计周期：如2026年1-4月}}",
    "{{P4月度统计周期：如2026年4月}}",
    "{{P4累计中标金额亿}}",
    "{{P4累计金额同比}}",
    "{{P4累计中标项目数}}",
    "{{P4累计项目数同比}}",
    "{{P4单月中标金额亿}}",
    "{{P4单月金额环比}}",
    "{{P4单月中标项目数}}",
    "{{P4单月项目数环比}}",
    "{{P4累计总览：说明累计金额、项目数同比和整体特征}}",
    "{{P4累计移动分析：简要说明总结移动特征}}",
    "{{P4累计电信分析：简要说明总结电信特征}}",
    "{{P4累计联通分析：简要说明总结联通特征}}",
    "{{P4月度总览：说明单月金额、项目数环比和整体特征}}",
    "{{P4月度移动分析：简要说明总结移动特征}}",
    "{{P4月度电信分析：简要说明总结电信特征}}",
    "{{P4月度联通分析：简要说明总结联通特征}}",
    "{{P4行业总览：按累计中标金额Top3说明行业规模和集中度}}",
    "{{P4行业分析1：按累计中标金额排序第1行业说明运营商竞争格局}}",
    "{{P4行业分析2：按累计中标金额排序第2行业说明运营商竞争格局}}",
    "{{P4行业分析3：按累计中标金额排序第3行业说明运营商竞争格局}}",
]

FOLLOWUP_PROMPT_SUFFIX = "请概括，凸显特征"
COMPACT_FOLLOWUP_PROMPT_SUFFIX = ""


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


@dataclass(frozen=True)
class OperatorMetric:
    operator: str
    count: int
    amount_wan: Decimal
    count_share: str
    amount_share: str
    count_change_pp: str
    amount_change_pp: str


@dataclass(frozen=True)
class SegmentAmountRow:
    name: str
    total_amount_wan: Decimal
    operator_amounts: dict[str, Decimal]


@dataclass(frozen=True)
class SegmentCountRow:
    name: str
    total_count: int
    operator_counts: dict[str, int]


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


@dataclass(frozen=True)
class P5UpdateModel:
    cumulative_period_label: str
    monthly_period_label: str
    cumulative_total_count: int
    cumulative_total_amount_wan: Decimal
    monthly_total_count: int
    monthly_total_amount_wan: Decimal
    replacements: dict[str, 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 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 _month_number(response: dict[str, Any], endpoint: str) -> int:
    raw_month = str(response.get("统计月份", "")).strip().removesuffix("月")
    if not raw_month:
        raise P5UpdateError(f"P5 更新失败：{endpoint} 缺少统计月份。")
    try:
        return int(raw_month)
    except ValueError as exc:
        raise P5UpdateError(f"P5 更新失败：{endpoint}.统计月份={raw_month!r} 不是月份。") from exc


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


def _growth_percent(current: Decimal, baseline: Decimal, field_path: str) -> Decimal | None:
    if baseline == 0:
        return None
    return (current - baseline) / baseline * Decimal("100")


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


def _format_pp_change(value: str) -> str:
    raw = "" if value is None else str(value).strip()
    if not raw or raw == "/":
        return "/"
    decimal_value = _parse_decimal(value, "运营商份额变化")
    sign = "+" if decimal_value > 0 else ""
    return f"{sign}{_format_decimal(decimal_value)}pp"


def _format_yi_from_wan(value: Decimal) -> str:
    return f"{_format_yi_number_from_wan(value)}亿"


def _format_yi_number_from_wan(value: Decimal) -> str:
    return str((value / Decimal("10000")).quantize(Decimal("0.01")))


def _wrap_followup_prompt(fact_text: str, *, compact: bool = False) -> str:
    suffix = COMPACT_FOLLOWUP_PROMPT_SUFFIX if compact else FOLLOWUP_PROMPT_SUFFIX
    if not suffix:
        return f"{{{{{fact_text}}}}}"
    return f"{{{{{fact_text}；{suffix}}}}}"


def _ensure_operator_rows(rows: Any, section_path: str) -> list[dict[str, Any]]:
    if not isinstance(rows, list) or not rows:
        raise P5UpdateError(f"P5 更新失败：{section_path} 缺少数据。")
    by_operator: dict[str, dict[str, Any]] = {}
    for index, row in enumerate(rows, start=1):
        if not isinstance(row, dict):
            raise P5UpdateError(f"P5 更新失败：{section_path}[{index}] 不是对象。")
        operator = _required_text(row, "运营商", f"{section_path}[{index}].运营商")
        by_operator[operator] = row
    missing = [operator for operator in OPERATORS if operator not in by_operator]
    if missing:
        raise P5UpdateError(f"P5 更新失败：{section_path} 缺少运营商：{', '.join(missing)}。")
    return [by_operator[operator] for operator in OPERATORS]


def _operator_metrics(
    response: dict[str, Any],
    section_key: str,
    count_key: str,
    amount_key: str,
    count_change_key: str,
    amount_change_key: str,
) -> list[OperatorMetric]:
    raw_rows = _ensure_operator_rows(response.get(section_key), f"/award/overall.{section_key}")
    metrics: list[OperatorMetric] = []
    for row in raw_rows:
        operator = _required_text(row, "运营商", "/award/overall.运营商")
        metrics.append(
            OperatorMetric(
                operator=operator,
                count=int(_parse_decimal(row.get(count_key), f"/award/overall.{section_key}.{operator}.{count_key}")),
                amount_wan=_parse_decimal(row.get(amount_key), f"/award/overall.{section_key}.{operator}.{amount_key}"),
                count_share=_required_text(row, "个数份额", f"/award/overall.{section_key}.{operator}.个数份额"),
                amount_share=_required_text(row, "金额份额", f"/award/overall.{section_key}.{operator}.金额份额"),
                count_change_pp=_format_pp_change(
                    _required_text(row, count_change_key, f"/award/overall.{section_key}.{operator}.{count_change_key}")
                ),
                amount_change_pp=_format_pp_change(
                    _required_text(row, amount_change_key, f"/award/overall.{section_key}.{operator}.{amount_change_key}")
                ),
            )
        )
    return metrics


def _operator_totals(
    response: dict[str, Any],
    section_key: str,
    count_key: str,
    amount_key: str,
    source_label: str,
) -> tuple[int, Decimal]:
    raw_rows = _ensure_operator_rows(response.get(section_key), f"{source_label}.{section_key}")
    total_count = 0
    total_amount = Decimal("0")
    for row in raw_rows:
        operator = _required_text(row, "运营商", f"{source_label}.{section_key}.运营商")
        total_count += int(_parse_decimal(row.get(count_key), f"{source_label}.{section_key}.{operator}.{count_key}"))
        total_amount += _parse_decimal(row.get(amount_key), f"{source_label}.{section_key}.{operator}.{amount_key}")
    return total_count, total_amount


def _operator_phrase(metrics: list[OperatorMetric], operator: str, period_type: str, comparison_label: str) -> str:
    row = next(metric for metric in metrics if metric.operator == operator)
    return _wrap_followup_prompt(
        f"{operator}{row.count}个/{_format_yi_from_wan(row.amount_wan)}，额{row.amount_share}，{comparison_label}{row.amount_change_pp}",
        compact=True,
    )


def _relationship_phrase(count_growth: Decimal | None, amount_growth: Decimal | None) -> str:
    if count_growth is None or amount_growth is None:
        return "对比基准为0，增幅暂不可比，需以本期实际规模判断经营机会"
    if count_growth >= 0 and amount_growth >= 0:
        if amount_growth > count_growth * Decimal("2"):
            return "金额增速高于项目数增速，大单驱动特征明显"
        return "项目数与金额同步增长，市场保持稳健扩张"
    if count_growth < 0 <= amount_growth:
        return "项目数承压但金额增长，项目单体规模抬升"
    if count_growth >= 0 > amount_growth:
        return "项目数增长但金额回落，单体规模有所收缩"
    return "项目数与金额同步回落，市场阶段性承压"


def _overall_summary(
    period_label: str,
    total_count: int,
    total_amount_wan: Decimal,
    count_growth: Decimal | None,
    amount_growth: Decimal | None,
    comparison_label: str,
) -> str:
    period_type = "单月" if comparison_label == "环比" else "累计"
    return _wrap_followup_prompt(
        f"{period_type}{total_count}个/{_format_yi_from_wan(total_amount_wan)}，"
        f"{comparison_label}项{_format_growth(count_growth)}/额{_format_growth(amount_growth)}",
        compact=True,
    )


def _segment_rows(response: dict[str, Any], dimension_key: str, endpoint: str) -> list[SegmentAmountRow]:
    raw_amount_rows = response.get("累计数据", {}).get("金额数据") if isinstance(response.get("累计数据"), dict) else None
    if not isinstance(raw_amount_rows, list) or not raw_amount_rows:
        raise P5UpdateError(f"P5 更新失败：{endpoint}.累计数据.金额数据 缺少数据。")

    rows: list[SegmentAmountRow] = []
    for index, raw_row in enumerate(raw_amount_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P5UpdateError(f"P5 更新失败：{endpoint}.累计数据.金额数据[{index}] 不是对象。")
        name = _required_text(raw_row, dimension_key, f"{endpoint}.累计数据.金额数据[{index}].{dimension_key}")
        operator_amounts = {
            operator: _parse_decimal(
                raw_row.get(f"{operator}金额（万元）"),
                f"{endpoint}.累计数据.金额数据.{name}.{operator}金额（万元）",
            )
            for operator in OPERATORS
        }
        rows.append(
            SegmentAmountRow(
                name=name,
                total_amount_wan=sum(operator_amounts.values(), Decimal("0")),
                operator_amounts=operator_amounts,
            )
        )
    return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)


def _segment_count_rows(response: dict[str, Any], dimension_key: str, endpoint: str) -> list[SegmentCountRow]:
    raw_count_rows = response.get("累计数据", {}).get("个数数据") if isinstance(response.get("累计数据"), dict) else None
    if not isinstance(raw_count_rows, list) or not raw_count_rows:
        raise P5UpdateError(f"P5 更新失败：{endpoint}.累计数据.个数数据 缺少数据。")

    rows: list[SegmentCountRow] = []
    for index, raw_row in enumerate(raw_count_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P5UpdateError(f"P5 更新失败：{endpoint}.累计数据.个数数据[{index}] 不是对象。")
        name = _required_text(raw_row, dimension_key, f"{endpoint}.累计数据.个数数据[{index}].{dimension_key}")
        operator_counts = {
            operator: int(
                _parse_decimal(
                    raw_row.get(f"{operator}个数"),
                    f"{endpoint}.累计数据.个数数据.{name}.{operator}个数",
                )
            )
            for operator in OPERATORS
        }
        rows.append(
            SegmentCountRow(
                name=name,
                total_count=sum(operator_counts.values()),
                operator_counts=operator_counts,
            )
        )
    return sorted(rows, key=lambda row: row.total_count, reverse=True)


def _dominant_operator(row: SegmentAmountRow) -> tuple[str, Decimal]:
    operator, amount = max(row.operator_amounts.items(), key=lambda item: item[1])
    share = Decimal("0") if row.total_amount_wan == 0 else amount / row.total_amount_wan * Decimal("100")
    return operator, share


def _segment_summary(rows: list[SegmentAmountRow], label: str) -> str:
    top3 = rows[:3]
    total_amount = sum((row.total_amount_wan for row in rows), Decimal("0"))
    top3_amount = sum((row.total_amount_wan 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.name}{_format_yi_number_from_wan(row.total_amount_wan)}" for row in top3)
    return _wrap_followup_prompt(
        f"行业Top3：{top_text}亿，占比{share.quantize(Decimal('0.1'))}%",
        compact=True,
    )


def _segment_analysis(index: int, row: SegmentAmountRow) -> str:
    operator, share = _dominant_operator(row)
    return _wrap_followup_prompt(
        f"{row.name}：{_format_yi_from_wan(row.total_amount_wan)}，{operator}{share.quantize(Decimal('0.1'))}%",
        compact=True,
    )


def _chart_values_from_amount_rows(rows: list[SegmentAmountRow]) -> list[float]:
    return [float(row.total_amount_wan) for row in rows]


def _chart_values_from_count_rows(rows: list[SegmentCountRow]) -> list[int]:
    return [row.total_count for row in rows]


def build_p5_update_model(
    current_overall_response: dict[str, Any],
    industry_response: dict[str, Any],
    prior_year_overall_response: dict[str, Any],
    previous_month_overall_response: dict[str, Any],
    district: str,
) -> P5UpdateModel:
    cumulative_period_label, monthly_period_label = _period_labels(current_overall_response)
    cumulative_metrics = _operator_metrics(
        current_overall_response,
        "累计数据",
        "累计中标个数",
        "累计中标金额（万元）",
        "个数份额同比变化（百分点）",
        "金额份额同比变化（百分点）",
    )
    monthly_metrics = _operator_metrics(
        current_overall_response,
        "月度数据",
        "单月中标个数",
        "单月中标金额（万元）",
        "个数份额环比变化（百分点）",
        "金额份额环比变化（百分点）",
    )

    cumulative_total_count = sum(metric.count for metric in cumulative_metrics)
    cumulative_total_amount = sum((metric.amount_wan for metric in cumulative_metrics), Decimal("0"))
    monthly_total_count = sum(metric.count for metric in monthly_metrics)
    monthly_total_amount = sum((metric.amount_wan for metric in monthly_metrics), Decimal("0"))
    prior_count, prior_amount = _operator_totals(
        prior_year_overall_response,
        "累计数据",
        "累计中标个数",
        "累计中标金额（万元）",
        "/award/overall.去年同期",
    )
    previous_count, previous_amount = _operator_totals(
        previous_month_overall_response,
        "月度数据",
        "单月中标个数",
        "单月中标金额（万元）",
        "/award/overall.上月",
    )

    cumulative_count_growth = _growth_percent(
        Decimal(cumulative_total_count),
        Decimal(prior_count),
        "/award/overall.去年同期.累计中标个数",
    )
    cumulative_amount_growth = _growth_percent(
        cumulative_total_amount,
        prior_amount,
        "/award/overall.去年同期.累计中标金额（万元）",
    )
    monthly_count_growth = _growth_percent(
        Decimal(monthly_total_count),
        Decimal(previous_count),
        "/award/overall.上月.单月中标个数",
    )
    monthly_amount_growth = _growth_percent(
        monthly_total_amount,
        previous_amount,
        "/award/overall.上月.单月中标金额（万元）",
    )

    industry_rows = _segment_rows(industry_response, "行业", "/award/industry")
    industry_count_rows = _segment_count_rows(industry_response, "行业", "/award/industry")

    replacements = {
        "{{某某行政区}}": district,
        P5_TEXT_PLACEHOLDERS[0]: cumulative_period_label,
        P5_TEXT_PLACEHOLDERS[1]: monthly_period_label,
        P5_TEXT_PLACEHOLDERS[2]: _format_yi_from_wan(cumulative_total_amount),
        P5_TEXT_PLACEHOLDERS[3]: _format_growth(cumulative_amount_growth),
        P5_TEXT_PLACEHOLDERS[4]: f"{cumulative_total_count}个",
        P5_TEXT_PLACEHOLDERS[5]: _format_growth(cumulative_count_growth),
        P5_TEXT_PLACEHOLDERS[6]: _format_yi_from_wan(monthly_total_amount),
        P5_TEXT_PLACEHOLDERS[7]: _format_growth(monthly_amount_growth),
        P5_TEXT_PLACEHOLDERS[8]: f"{monthly_total_count}个",
        P5_TEXT_PLACEHOLDERS[9]: _format_growth(monthly_count_growth),
        P5_TEXT_PLACEHOLDERS[10]: _overall_summary(
            cumulative_period_label,
            cumulative_total_count,
            cumulative_total_amount,
            cumulative_count_growth,
            cumulative_amount_growth,
            "同比",
        ),
        P5_TEXT_PLACEHOLDERS[14]: _overall_summary(
            monthly_period_label,
            monthly_total_count,
            monthly_total_amount,
            monthly_count_growth,
            monthly_amount_growth,
            "环比",
        ),
        P5_TEXT_PLACEHOLDERS[18]: _segment_summary(industry_rows, "从行业规模"),
    }
    for index, operator in enumerate(OPERATORS):
        replacements[P5_TEXT_PLACEHOLDERS[11 + index]] = _operator_phrase(
            cumulative_metrics,
            operator,
            "累计",
            "同比",
        )
        replacements[P5_TEXT_PLACEHOLDERS[15 + index]] = _operator_phrase(
            monthly_metrics,
            operator,
            "单月",
            "环比",
        )
    for index, row in enumerate(industry_rows[:3], start=1):
        replacements[P5_TEXT_PLACEHOLDERS[18 + index]] = _segment_analysis(index, row)

    charts = {
        "chart6": ChartModel(
            title="各运营商累计中标个数",
            series_name="累计中标个数",
            categories=[metric.operator for metric in cumulative_metrics],
            values=[metric.count for metric in cumulative_metrics],
        ),
        "chart7": ChartModel(
            title="各运营商累计中标金额（万元）",
            series_name="累计中标金额（万元）",
            categories=[metric.operator for metric in cumulative_metrics],
            values=[float(metric.amount_wan) for metric in cumulative_metrics],
        ),
        "chart8": ChartModel(
            title="各运营商单月中标个数",
            series_name="单月中标个数",
            categories=[metric.operator for metric in monthly_metrics],
            values=[metric.count for metric in monthly_metrics],
        ),
        "chart9": ChartModel(
            title="各运营商单月中标金额（万元）",
            series_name="单月中标金额（万元）",
            categories=[metric.operator for metric in monthly_metrics],
            values=[float(metric.amount_wan) for metric in monthly_metrics],
        ),
        "chart10": ChartModel(
            title=f"运营商各行业累计中标金额分布（{cumulative_period_label}）",
            series_name="累计中标金额（万元）",
            categories=[row.name for row in industry_rows],
            values=_chart_values_from_amount_rows(industry_rows),
        ),
        "chart11": ChartModel(
            title=f"运营商各行业累计中标个数分布（{cumulative_period_label}）",
            series_name="累计中标个数",
            categories=[row.name for row in industry_count_rows],
            values=_chart_values_from_count_rows(industry_count_rows),
        ),
    }

    return P5UpdateModel(
        cumulative_period_label=cumulative_period_label,
        monthly_period_label=monthly_period_label,
        cumulative_total_count=cumulative_total_count,
        cumulative_total_amount_wan=cumulative_total_amount,
        monthly_total_count=monthly_total_count,
        monthly_total_amount_wan=monthly_total_amount,
        replacements=replacements,
        charts=charts,
    )


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 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 _previous_month(year: int, month: int) -> tuple[int, int]:
    if month == 1:
        return year - 1, 12
    return year, month - 1


def fetch_p5_responses(
    api_base: str,
    year: int,
    month: str,
    district: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
    district = district.strip()
    if not district:
        raise P5UpdateError("P5 更新失败：行政区参数 --district 不能为空。")
    month_number = int(str(month).strip().removesuffix("月"))
    previous_year, previous_month = _previous_month(year, month_number)
    common_params = {"ct": "是", "scope": "行政区", "scope_value": district}
    current_params = {"year": year, "month": month, **common_params}
    return (
        fetch_json(api_base, "/award/overall", current_params),
        fetch_json(api_base, "/award/industry", current_params),
        fetch_json(api_base, "/award/overall", {"year": year - 1, "month": month, **common_params}),
        fetch_json(
            api_base,
            "/award/overall",
            {"year": previous_year, "month": f"{previous_month}月", **common_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 = ""
    missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
    if missing:
        raise P5UpdateError(f"P5 更新失败：模板第 5 页缺少占位符：{', '.join(missing)}。")


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 P5UpdateError(f"P5 更新失败：{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 P5UpdateError(f"P5 更新失败：{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 P5UpdateError(f"P5 更新失败：{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, category_header: str, 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"] = category_header
    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_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)

    chart_parts = {f"ppt/charts/chart{index}.xml" for index in range(6, 12)}
    workbook_parts = {f"ppt/embeddings/Workbook{index}.xlsx" for index in range(6, 12)}
    required_parts = {"ppt/slides/slide5.xml", *chart_parts, *workbook_parts}
    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("ppt/slides/slide5.xml"))
        _replace_slide_text(slide_root, model.replacements)
        updates: dict[str, bytes] = {
            "ppt/slides/slide5.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
        }
        for index in range(6, 12):
            chart_key = f"chart{index}"
            chart_part = f"ppt/charts/{chart_key}.xml"
            updates[chart_part] = _update_chart_xml(source.read(chart_part), chart_key, model.charts[chart_key])
            category_header = "运营商" if index in {6, 7, 8, 9} else "行业"
            workbook_part = f"ppt/embeddings/Workbook{index}.xlsx"
            updates[workbook_part] = _update_workbook(
                source.read(workbook_part),
                category_header,
                model.charts[chart_key],
            )

        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:
                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 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.")
    parser.add_argument("--district", required=True, help="行政区名称，例如 浦东新区。")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    responses = fetch_p5_responses(args.api_base, args.year, args.month, args.district)
    model = build_p5_update_model(*responses, args.district.strip())
    update_p5_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
