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

本脚本用于单独更新 PPT 第 7 页：当月友商中标大单。

取数逻辑：
- `/award/competitor-large-deals?year={year}&month={month}&ct=是&scope=行政区`
  `&scope_value={district}`
  使用 `电信大单` 和 `联通大单`。

更新内容：
- 左表：电信当月中标大单 Top8。
- 右表：联通当月中标大单 Top8。
- 当某一侧无当月大单时，在该侧表格第一行数据写入“无”。
"""

from __future__ import annotations

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


PAGE_NUMBER = 7
SLIDE_XML = "ppt/slides/slide7.xml"
DATA_SOURCES = ["/award/competitor-large-deals"]
TELECOM_SECTION = "电信大单"
UNICOM_SECTION = "联通大单"
TABLE_HEADER = ["招标人", "行业", "项目名称", "中标金额（万元）"]
TOP_N = 8
TELECOM_TITLE_TEXT = "电信当月中标大单"
UNICOM_TITLE_TEXT = "联通当月中标大单"


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)


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


@dataclass(frozen=True)
class DealRow:
    buyer: str
    industry: str
    project_name: str
    amount_wan: str
    amount_value: Decimal


@dataclass(frozen=True)
class P7UpdateModel:
    period_label: str
    telecom_rows: list[DealRow]
    unicom_rows: list[DealRow]
    tables: dict[str, list[list[str]]]


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


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


def _deal_rows(response: dict[str, Any], section: str) -> list[DealRow]:
    raw_rows = response.get(section)
    if not isinstance(raw_rows, list):
        raise P7UpdateError(
            f"P7 更新失败：/award/competitor-large-deals.{section} 缺少列表数据。"
        )

    rows: list[DealRow] = []
    for index, row in enumerate(raw_rows, start=1):
        if not isinstance(row, dict):
            raise P7UpdateError(
                "P7 更新失败："
                f"/award/competitor-large-deals.{section}[{index}] 不是对象。"
            )
        amount_text = _required_text(
            row,
            "中标金额（万元）",
            f"/award/competitor-large-deals.{section}[{index}].中标金额（万元）",
        )
        rows.append(
            DealRow(
                buyer=_required_text(
                    row,
                    "招标人",
                    f"/award/competitor-large-deals.{section}[{index}].招标人",
                ),
                industry=_required_text(
                    row,
                    "行业",
                    f"/award/competitor-large-deals.{section}[{index}].行业",
                ),
                project_name=_required_text(
                    row,
                    "项目名称",
                    f"/award/competitor-large-deals.{section}[{index}].项目名称",
                ),
                amount_wan=amount_text,
                amount_value=_parse_decimal(
                    amount_text,
                    f"/award/competitor-large-deals.{section}[{index}].中标金额（万元）",
                ),
            )
        )
    return sorted(rows, key=lambda row: (-row.amount_value, row.buyer, row.project_name))[:TOP_N]


def _table_rows(rows: list[DealRow]) -> list[list[str]]:
    table_rows = [TABLE_HEADER]
    if rows:
        table_rows.extend(
            [row.buyer, row.industry, row.project_name, row.amount_wan]
            for row in rows
        )
    else:
        table_rows.append(["无", "", "", ""])
    while len(table_rows) < TOP_N + 1:
        table_rows.append(["", "", "", ""])
    return table_rows


def build_p7_update_model(response: dict[str, Any]) -> P7UpdateModel:
    period_label = _period_label(response)
    telecom_rows = _deal_rows(response, TELECOM_SECTION)
    unicom_rows = _deal_rows(response, UNICOM_SECTION)
    return P7UpdateModel(
        period_label=period_label,
        telecom_rows=telecom_rows,
        unicom_rows=unicom_rows,
        tables={
            TELECOM_SECTION: _table_rows(telecom_rows),
            UNICOM_SECTION: _table_rows(unicom_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=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 P7UpdateError(f"P7 更新失败：{endpoint} HTTP {exc.code}: {detail}") from exc
    except URLError as exc:
        raise P7UpdateError(f"P7 更新失败：无法连接 FastAPI {url}: {exc.reason}") from exc
    if status != 200:
        raise P7UpdateError(f"P7 更新失败：{endpoint} HTTP {status}。")
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise P7UpdateError(f"P7 更新失败：{endpoint} 返回值不是 JSON。") from exc
    if not isinstance(data, dict):
        raise P7UpdateError(f"P7 更新失败：{endpoint} 返回 JSON 根节点不是对象。")
    return data


def fetch_p7_response(api_base: str, year: int, month: str, district: str) -> dict[str, Any]:
    district = district.strip()
    if not district:
        raise P7UpdateError("P7 更新失败：行政区参数 --district 不能为空。")
    return fetch_json(
        api_base,
        "/award/competitor-large-deals",
        {
            "year": year,
            "month": month,
            "ct": "是",
            "scope": "行政区",
            "scope_value": district,
        },
    )


def _set_text_nodes(parent: ET.Element, value: str) -> None:
    text_nodes = parent.findall(".//a:t", OOXML_NS)
    if not text_nodes:
        raise P7UpdateError("P7 更新失败：目标元素缺少文本节点。")
    text_nodes[0].text = value
    for node in text_nodes[1:]:
        node.text = ""


def _shape_id(shape: ET.Element) -> str | None:
    cnv = shape.find("./p:nvSpPr/p:cNvPr", OOXML_NS)
    return cnv.attrib.get("id") if cnv is not None else None


def _update_section_titles(root: ET.Element) -> None:
    shapes = {_shape_id(shape): shape for shape in root.findall(".//p:sp", OOXML_NS)}
    title_by_shape_id = {
        "6": TELECOM_TITLE_TEXT,
        "7": UNICOM_TITLE_TEXT,
    }
    missing_ids = sorted(shape_id for shape_id in title_by_shape_id if shape_id not in shapes)
    if missing_ids:
        raise P7UpdateError(
            f"P7 更新失败：第 7 页缺少大单标题形状：{', '.join(missing_ids)}。"
        )
    for shape_id, text in title_by_shape_id.items():
        _set_text_nodes(shapes[shape_id], text)


def _table_frames_sorted_by_x(root: ET.Element) -> list[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)
        x = int(off.attrib.get("x", "0")) if off is not None else 0
        frames.append((x, table))
    if len(frames) != 2:
        raise P7UpdateError("P7 更新失败：第 7 页应包含 2 张大单表。")
    return [table for _, table in sorted(frames, key=lambda item: item[0])]


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 P7UpdateError(f"P7 更新失败：第 7 页{label}表格应为 {len(rows)} 行。")
    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 P7UpdateError(
                f"P7 更新失败：第 7 页{label}表格第 {row_index + 1} 行应为 4 列。"
            )
        for cell, value in zip(cells, row_values, strict=True):
            _set_text_nodes(cell, value)


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

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

        slide_root = ET.fromstring(source.read(SLIDE_XML))
        _update_section_titles(slide_root)
        telecom_table, unicom_table = _table_frames_sorted_by_x(slide_root)
        _update_table(telecom_table, model.tables[TELECOM_SECTION], "电信大单")
        _update_table(unicom_table, model.tables[UNICOM_SECTION], "联通大单")
        updates = {
            SLIDE_XML: ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
        }

        if pptx_path.resolve() == output_path.resolve():
            with tempfile.NamedTemporaryFile(
                prefix="p7-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 7 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()
    response = fetch_p7_response(args.api_base, args.year, args.month, args.district)
    model = build_p7_update_model(response)
    update_p7_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
