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

本脚本后续用于单独更新 PPT 第 3 页：累计区域招标分析。

取数逻辑草案：
- `/tender/region?year={year}&month={month}&scope=上海`
  使用返回值中的 `累计数据`。

更新内容草案：
- 原生图表：累计区域招标金额与次数分布。
- 表格：各区域累计个数、金额、个数同比增幅、金额同比增幅。
- 文案：根据金额 Top、增速、量价背离等规则生成区域分析。
"""

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 = 3
SLIDE_XML = "ppt/slides/slide3.xml"
DATA_SOURCES = ["/tender/region"]


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)


P3_ANALYSIS_PLACEHOLDERS = [
    "{{P3区域总览：根据金额Top3、合计占比和整体区域集中度生成一句总览}}",
    "{{P3区域分析1：按累计金额排序第1属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析2：按累计金额排序第2属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析3：按累计金额排序第3属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析4：按累计金额排序第4属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析5：按累计金额排序第5属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析6：按累计金额排序第6属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析7：按累计金额排序第7属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析8：按累计金额排序第8属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析9：按累计金额排序第9属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析10：按累计金额排序第10属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析11：按累计金额排序第11属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析12：按累计金额排序第12属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析13：按累计金额排序第13属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析14：按累计金额排序第14属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
    "{{P3区域分析15：按累计金额排序第15属地，介绍属地情况，总结只用一句话说明个数同比、金额同比和区域特征}}",
]

FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析，凸显数据特征"


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


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


@dataclass(frozen=True)
class P3UpdateModel:
    period_label: str
    rows: list[RegionRow]
    table_rows: list[list[str]]
    chart_title: str
    chart_categories: list[str]
    chart_count_values: list[int]
    chart_amount_values: list[float]
    replacements: dict[str, str]


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(raw, field_path)


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


def _format_optional_growth(value: Decimal | None) -> str:
    if value is None:
        return "/"
    return _format_growth(value)


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


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 _period_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/region 缺少统计年份或统计月份。")
    return f"{year}年1-{month_number}月"


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

    rows: list[RegionRow] = []
    for index, raw_row in enumerate(raw_rows, start=1):
        if not isinstance(raw_row, dict):
            raise P3UpdateError(f"P3 更新失败：/tender/region.累计数据[{index}] 不是对象。")
        region = _required_text(raw_row, "属地", f"/tender/region.累计数据[{index}].属地")
        rows.append(
            RegionRow(
                region=region,
                count=int(_parse_decimal(raw_row.get("个数"), f"/tender/region.累计数据.{region}.个数")),
                amount_yi=_parse_decimal(
                    raw_row.get("金额（亿元）"),
                    f"/tender/region.累计数据.{region}.金额（亿元）",
                ),
                count_growth=_parse_optional_decimal(
                    raw_row.get("个数同比增幅"),
                    f"/tender/region.累计数据.{region}.个数同比增幅",
                ),
                amount_growth=_parse_optional_decimal(
                    raw_row.get("金额同比增幅"),
                    f"/tender/region.累计数据.{region}.金额同比增幅",
                ),
            )
        )

    if len(rows) < 15:
        raise P3UpdateError("P3 更新失败：/tender/region.累计数据 少于 15 行。")
    return sorted(rows, key=lambda row: row.amount_yi, reverse=True)[:15]


def _growth_fact_parts(row: RegionRow) -> list[str]:
    parts: list[str] = []
    if row.count_growth is not None:
        parts.append(f"个数同比{_format_growth(row.count_growth)}")
    if row.amount_growth is not None:
        parts.append(f"金额同比{_format_growth(row.amount_growth)}")
    return parts


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


def _summary_text(rows: list[RegionRow]) -> 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.region}{_format_amount(row.amount_yi)}亿元" for row in top3)
    return _wrap_followup_prompt(
        f"累计金额Top3属地为{top_text}，合计金额{_format_amount(top3_amount)}亿元、"
        f"占比{share.quantize(Decimal('0.1'))}%"
    )


def _analysis_text(index: int, row: RegionRow) -> str:
    parts = [
        f"{row.region}属地累计招标金额{_format_amount(row.amount_yi)}亿元",
        f"招标次数{row.count}次",
        *_growth_fact_parts(row),
    ]
    return _wrap_followup_prompt("、".join(parts))


def build_p3_update_model(response: dict[str, Any]) -> P3UpdateModel:
    period_label = _period_label(response)
    rows = _region_rows(response)
    table_rows = [
        [period_label, "个数", "金额（亿元）", "个数增幅", "金额增幅"],
        *[
            [
                row.region,
                str(row.count),
                _format_amount(row.amount_yi),
                _format_optional_growth(row.count_growth),
                _format_optional_growth(row.amount_growth),
            ]
            for row in rows
        ],
    ]
    replacements = {P3_ANALYSIS_PLACEHOLDERS[0]: _summary_text(rows)}
    for index, row in enumerate(rows, start=1):
        replacements[P3_ANALYSIS_PLACEHOLDERS[index]] = _analysis_text(index, row)

    return P3UpdateModel(
        period_label=period_label,
        rows=rows,
        table_rows=table_rows,
        chart_title=f"累计区域招标金额与次数分布（{period_label}）",
        chart_categories=[row.region for row in rows],
        chart_count_values=[row.count for row in rows],
        chart_amount_values=[float(row.amount_yi) for row in 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 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_response(api_base: str, year: int, month: str) -> dict[str, Any]:
    return fetch_json(
        api_base,
        "/tender/region",
        {"year": year, "month": month, "scope": "上海"},
    )


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


def _replace_slide_text(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 P3UpdateError(f"P3 更新失败：模板第 3 页缺少占位符：{', '.join(missing)}。")


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


def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
    table = root.find(".//a:tbl", OOXML_NS)
    if table is None:
        raise P3UpdateError("P3 更新失败：第 3 页未找到表格。")
    xml_rows = table.findall("./a:tr", OOXML_NS)
    if len(xml_rows) < len(table_rows):
        raise P3UpdateError("P3 更新失败：第 3 页表格行数不足。")
    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 P3UpdateError("P3 更新失败：第 3 页表格列数不足。")
        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 _set_chart_title(root: ET.Element, title: str) -> None:
    text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
    if not text_nodes:
        raise P3UpdateError("P3 更新失败：chart3.xml 缺少标题文本节点。")
    text_nodes[0].text = title
    for node in text_nodes[1:]:
        node.text = ""


def _update_chart3_xml(chart_xml: bytes, model: P3UpdateModel) -> bytes:
    root = ET.fromstring(chart_xml)
    _set_chart_title(root, model.chart_title)
    series_nodes = root.findall(".//c:ser", OOXML_NS)
    if len(series_nodes) < 2:
        raise P3UpdateError("P3 更新失败：chart3.xml 图表系列少于 2 个。")

    series_values = [
        [str(value) for value in model.chart_count_values],
        [_format_decimal(Decimal(str(value))) for value in model.chart_amount_values],
    ]
    series_names = ["个数", "金额（亿元）"]
    for index, series in enumerate(series_nodes[:2]):
        name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
        if name_nodes:
            name_nodes[0].text = series_names[index]
        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("P3 更新失败：chart3.xml 缺少分类或数值缓存。")
        _set_cache_points(category_cache, model.chart_categories)
        _set_cache_points(value_cache, series_values[index])
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _update_workbook3(workbook_bytes: bytes, model: P3UpdateModel) -> bytes:
    workbook = load_workbook(BytesIO(workbook_bytes))
    sheet = workbook.active
    sheet["A1"] = model.period_label
    sheet["B1"] = "个数"
    sheet["C1"] = "金额（亿元）"
    sheet["D1"] = "金额（万元）"
    for row_index, row in enumerate(model.rows, start=2):
        sheet.cell(row=row_index, column=1).value = row.region
        sheet.cell(row=row_index, column=2).value = row.count
        sheet.cell(row=row_index, column=3).value = float(row.amount_yi)
        sheet.cell(row=row_index, column=4).value = float(row.amount_yi * Decimal("10000"))
    for row_index in range(len(model.rows) + 2, sheet.max_row + 1):
        for column_index in range(1, 5):
            sheet.cell(row=row_index, column=column_index).value = None
    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)

    required_parts = {
        "ppt/slides/slide3.xml",
        "ppt/charts/chart3.xml",
        "ppt/embeddings/Workbook3.xlsx",
    }
    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)
        _update_slide_table(slide_root, model.table_rows)
        updates = {
            "ppt/slides/slide3.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
            "ppt/charts/chart3.xml": _update_chart3_xml(source.read("ppt/charts/chart3.xml"), model),
            "ppt/embeddings/Workbook3.xlsx": _update_workbook3(
                source.read("ppt/embeddings/Workbook3.xlsx"),
                model,
            ),
        }

        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 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()
    response = fetch_p3_response(args.api_base, args.year, args.month)
    model = build_p3_update_model(response)
    update_p3_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
