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

本脚本用于单独更新 PPT 第 4 页：单月招标节奏、行业、区域。

取数逻辑：
- `/tender/overall?year={year}&month={month}&scope=上海`
  使用 `月度数据` 更新单月整体指标。
- `/tender/industry?year={year}&month={month}&scope=上海`
  使用 `月度数据` 更新单月行业 Top 表格和文案。
- `/tender/region?year={year}&month={month}&scope=上海`
  使用 `月度数据` 更新单月区域 Top 表格和文案。

更新内容：
- 表格：单月行业金额 Top3、单月区域金额 Top3、单月整体指标。
- 文案：根据次数、金额、环比增幅和量价关系生成简洁分析。
"""

from __future__ import annotations

import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from copy import deepcopy
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 = 4
SLIDE_XML = "ppt/slides/slide4.xml"
DATA_SOURCES = ["/tender/overall", "/tender/industry", "/tender/region"]


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

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


P4_PERIOD_PLACEHOLDER = "{{P4统计月份：如2026年4月}}"
P4_ANALYSIS_PLACEHOLDERS = [
    "{{P4整体月度分析：说明单月次数、金额、次数环比、金额环比和量价特征}}",
    "{{P4行业总览：按月度金额Top3说明金额、合计占比和行业集中度}}",
    "{{P4行业分析1：按月度金额排序第1行业，介绍行业情况，说明并总结个数环比、金额环比和特征}}",
    "{{P4行业分析2：按月度金额排序第2行业，介绍行业情况，说明并总结个数环比、金额环比和特征}}",
    "{{P4行业分析3：按月度金额排序第3行业，介绍行业情况，说明并总结个数环比、金额环比和特征}}",
    "{{P4区域总览：按月度金额Top3说明金额、合计占比和区域集中度}}",
    "{{P4区域分析1：按月度金额排序第1属地，介绍属地情况，说明并总结个数环比、金额环比和特征}}",
    "{{P4区域分析2：按月度金额排序第2属地，介绍属地情况，说明并总结个数环比、金额环比和特征}}",
    "{{P4区域分析3：按月度金额排序第3属地，介绍属地情况，说明并总结个数环比、金额环比和特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析，凸显数据特征"


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


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


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


@dataclass(frozen=True)
class P4UpdateModel:
    period_label: str
    overall: MonthlyOverall
    industry_rows: list[MonthlyRow]
    region_rows: list[MonthlyRow]
    overall_table_rows: list[list[str]]
    industry_table_rows: list[list[str]]
    region_table_rows: list[list[str]]
    replacements: dict[str, str]


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


def _format_growth(value: Decimal) -> str:
    sign = "+" if value > 0 else ""
    return f"{sign}{format(value.quantize(Decimal('0.01')).normalize(), 'f')}%"


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


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


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


def _validate_same_period(
    overall_response: dict[str, Any],
    industry_response: dict[str, Any],
    region_response: dict[str, Any],
) -> str:
    periods = {
        "/tender/overall": _period_label(overall_response, "/tender/overall"),
        "/tender/industry": _period_label(industry_response, "/tender/industry"),
        "/tender/region": _period_label(region_response, "/tender/region"),
    }
    unique_periods = set(periods.values())
    if len(unique_periods) != 1:
        detail = "，".join(f"{endpoint}={period}" for endpoint, period in periods.items())
        raise P4UpdateError(f"P4 更新失败：三个接口统计周期不一致：{detail}。")
    return periods["/tender/overall"]


def _overall(response: dict[str, Any]) -> MonthlyOverall:
    raw_monthly = response.get("月度数据")
    if not isinstance(raw_monthly, dict):
        raise P4UpdateError("P4 更新失败：/tender/overall 响应缺少 月度数据。")
    return MonthlyOverall(
        count=int(_parse_decimal(raw_monthly.get("招标次数"), "/tender/overall.月度数据.招标次数")),
        amount_yi=_parse_decimal(
            raw_monthly.get("招标预算金额（亿元）"),
            "/tender/overall.月度数据.招标预算金额（亿元）",
        ),
        count_growth=_parse_optional_decimal(
            raw_monthly.get("招标次数环比增幅"),
            "/tender/overall.月度数据.招标次数环比增幅",
        ),
        amount_growth=_parse_optional_decimal(
            raw_monthly.get("招标金额环比增幅"),
            "/tender/overall.月度数据.招标金额环比增幅",
        ),
    )


def _monthly_top_rows(response: dict[str, Any], endpoint: str, name_key: str) -> tuple[list[MonthlyRow], Decimal]:
    raw_rows = response.get("月度数据")
    if not isinstance(raw_rows, list) or not raw_rows:
        raise P4UpdateError(f"P4 更新失败：{endpoint} 响应缺少 月度数据。")

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

    if len(sortable_rows) < 3:
        raise P4UpdateError(f"P4 更新失败：{endpoint}.月度数据 少于 3 行。")

    total_amount = sum((row[2] for row in sortable_rows), Decimal("0"))
    top_rows: list[MonthlyRow] = []
    for name, count, amount_yi, raw_row in sorted(sortable_rows, key=lambda row: row[2], reverse=True)[:3]:
        top_rows.append(
            MonthlyRow(
                name=name,
                count=count,
                amount_yi=amount_yi,
                count_growth=_parse_optional_decimal(
                    raw_row.get("个数环比增幅"),
                    f"{endpoint}.月度数据.{name}.个数环比增幅",
                ),
                amount_growth=_parse_optional_decimal(
                    raw_row.get("金额环比增幅"),
                    f"{endpoint}.月度数据.{name}.金额环比增幅",
                ),
            )
        )
    return top_rows, total_amount


def _compact_growth_fact(
    count_growth: Decimal | None,
    amount_growth: Decimal | None,
) -> str:
    if count_growth is not None and amount_growth is not None:
        return f"个数/金额环比{_format_growth(count_growth)}/{_format_growth(amount_growth)}"
    if count_growth is not None:
        return f"个数环比{_format_growth(count_growth)}"
    if amount_growth is not None:
        return f"金额环比{_format_growth(amount_growth)}"
    return "可比较环比数据暂不可得"


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


def _overall_text(period_label: str, overall: MonthlyOverall) -> str:
    parts = [
        f"{period_label}：{overall.count}次/{_format_amount(overall.amount_yi)}亿",
        _compact_growth_fact(overall.count_growth, overall.amount_growth),
    ]
    return _wrap_followup_prompt("、".join(parts))


def _summary_text(rows: list[MonthlyRow], total_amount: Decimal, dimension_text: str) -> str:
    top3_amount = sum((row.amount_yi for row in rows), Decimal("0"))
    share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
    top_text = "/".join(f"{row.name}{_format_amount(row.amount_yi)}" for row in rows)
    return _wrap_followup_prompt(
        f"{dimension_text}Top3：{top_text}亿，合计{_format_amount(top3_amount)}亿/"
        f"{share.quantize(Decimal('0.1'))}%"
    )


def _analysis_text(row: MonthlyRow, dimension_text: str) -> str:
    parts = [
        f"{row.name}：{row.count}次/{_format_amount(row.amount_yi)}亿",
        _compact_growth_fact(row.count_growth, row.amount_growth),
    ]
    return _wrap_followup_prompt("、".join(parts))


def build_p4_update_model(
    overall_response: dict[str, Any],
    industry_response: dict[str, Any],
    region_response: dict[str, Any],
) -> P4UpdateModel:
    period_label = _validate_same_period(overall_response, industry_response, region_response)
    overall = _overall(overall_response)
    industry_rows, industry_total_amount = _monthly_top_rows(industry_response, "/tender/industry", "行业")
    region_rows, region_total_amount = _monthly_top_rows(region_response, "/tender/region", "属地")

    overall_table_rows = [
        ["招标次数", "招标预算金额（亿元）", "招标次数环比（%）", "招标金额环比（%）"],
        [
            str(overall.count),
            _format_amount(overall.amount_yi),
            _format_optional_growth(overall.count_growth),
            _format_optional_growth(overall.amount_growth),
        ],
    ]
    industry_table_rows = [
        ["行业", "个数", "金额（亿元）", "个数环比", "金额环比"],
        *[
            [
                row.name,
                str(row.count),
                _format_amount(row.amount_yi),
                _format_optional_growth(row.count_growth),
                _format_optional_growth(row.amount_growth),
            ]
            for row in industry_rows
        ],
    ]
    region_table_rows = [
        ["区域", "个数", "金额（亿元）", "个数环比", "金额环比"],
        *[
            [
                row.name,
                str(row.count),
                _format_amount(row.amount_yi),
                _format_optional_growth(row.count_growth),
                _format_optional_growth(row.amount_growth),
            ]
            for row in region_rows
        ],
    ]

    replacements = {
        P4_PERIOD_PLACEHOLDER: period_label,
        P4_ANALYSIS_PLACEHOLDERS[0]: _overall_text(period_label, overall),
        P4_ANALYSIS_PLACEHOLDERS[1]: _summary_text(industry_rows, industry_total_amount, "行业"),
        P4_ANALYSIS_PLACEHOLDERS[5]: _summary_text(region_rows, region_total_amount, "属地"),
    }
    for index, row in enumerate(industry_rows, start=1):
        replacements[P4_ANALYSIS_PLACEHOLDERS[index + 1]] = _analysis_text(row, "行业")
    for index, row in enumerate(region_rows, start=1):
        replacements[P4_ANALYSIS_PLACEHOLDERS[index + 5]] = _analysis_text(row, "属地")

    return P4UpdateModel(
        period_label=period_label,
        overall=overall,
        industry_rows=industry_rows,
        region_rows=region_rows,
        overall_table_rows=overall_table_rows,
        industry_table_rows=industry_table_rows,
        region_table_rows=region_table_rows,
        replacements=replacements,
    )


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


def fetch_p4_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/overall", params),
        fetch_json(api_base, "/tender/industry", params),
        fetch_json(api_base, "/tender/region", 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 = {placeholder: 1 for placeholder in replacements}
    required_counts[P4_PERIOD_PLACEHOLDER] = 3
    missing = sorted(
        placeholder
        for placeholder, required_count in required_counts.items()
        if replaced_counts[placeholder] < required_count
    )
    if missing:
        raise P4UpdateError(f"P4 更新失败：模板第 4 页缺少占位符：{', '.join(missing)}。")


def _set_text_nodes(
    parent: ET.Element,
    value: str,
    template_run_properties: ET.Element | None = None,
) -> 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"))
        if template_run_properties is not None:
            run.append(deepcopy(template_run_properties))
        text_node = ET.SubElement(run, _qn("a", "t"))
        text_nodes = [text_node]
    else:
        first_run = parent.find(".//a:r", OOXML_NS)
        if (
            first_run is not None
            and first_run.find("./a:rPr", OOXML_NS) is None
            and template_run_properties is not None
        ):
            first_run.insert(0, deepcopy(template_run_properties))
    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 P4UpdateError("P4 更新失败：第 4 页表格行数不足。")
    column_run_properties: list[ET.Element | None] = []
    if xml_rows:
        for cell in xml_rows[0].findall("./a:tc", OOXML_NS):
            column_run_properties.append(cell.find(".//a:rPr", OOXML_NS))
    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 P4UpdateError("P4 更新失败：第 4 页表格列数不足。")
        for column_index, (cell, value) in enumerate(zip(cells, row_values, strict=True)):
            template_run_properties = (
                column_run_properties[column_index]
                if column_index < len(column_run_properties)
                else None
            )
            _set_text_nodes(cell, value, template_run_properties)


def _update_slide_tables(root: ET.Element, model: P4UpdateModel) -> None:
    tables = root.findall(".//a:tbl", OOXML_NS)
    if len(tables) < 3:
        raise P4UpdateError("P4 更新失败：第 4 页未找到 3 张表。")
    _update_table(tables[0], model.industry_table_rows)
    _update_table(tables[1], model.region_table_rows)
    _update_table(tables[2], model.overall_table_rows)


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

    required_parts = {"ppt/slides/slide4.xml"}
    with ZipFile(pptx_path, "r") as source:
        missing = sorted(required_parts - set(source.namelist()))
        if missing:
            raise P4UpdateError(f"P4 更新失败：PPTX 缺少必要部件：{', '.join(missing)}。")

        slide_root = ET.fromstring(source.read("ppt/slides/slide4.xml"))
        _replace_slide_text(slide_root, model.replacements)
        _update_slide_tables(slide_root, model)
        updates = {
            "ppt/slides/slide4.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
        }

        if pptx_path.resolve() == output_path.resolve():
            with tempfile.NamedTemporaryFile(
                prefix="p4-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 4 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()
    overall_response, industry_response, region_response = fetch_p4_responses(
        args.api_base,
        args.year,
        args.month,
    )
    model = build_p4_update_model(overall_response, industry_response, region_response)
    update_p4_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
