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

本脚本用于单独更新 PPT 第 7 页：累计行业中标分析。

取数逻辑：
- `/award/industry?year={year}&month={month}&ct=是&scope=上海`
  使用 `累计数据.个数数据` 和 `累计数据.金额数据`。

更新内容：
- 正文：按累计中标金额 Top3 生成行业总览和重点行业分析。
- 表格：各行业三家运营商的个数份额、金额份额。
- 原生图表：chart10 行业累计中标个数，chart11 行业累计中标金额。
"""

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 = 7
SLIDE_XML = "ppt/slides/slide7.xml"
DATA_SOURCES = ["/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}"


P7_TEXT_PLACEHOLDERS = [
    "{{P7行业总览：按累计中标金额Top3说明行业集中度和竞争分化}}",
    "{{P7行业分析1：按累计中标金额排序第1行业，介绍行业情况，说明并总结三家运营商个数份额和金额份额特征}}",
    "{{P7行业分析2：按累计中标金额排序第2行业，介绍行业情况，说明并总结三家运营商个数份额和金额份额特征}}",
    "{{P7行业分析3：按累计中标金额排序第3行业，介绍行业情况，说明并总结三家运营商个数份额和金额份额特征}}",
]

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


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


@dataclass(frozen=True)
class IndustryRow:
    industry: str
    total_amount_wan: Decimal
    counts: dict[str, int]
    amounts_wan: dict[str, Decimal]
    count_shares: dict[str, str]
    amount_shares: dict[str, str]


@dataclass(frozen=True)
class MultiSeriesChartModel:
    title: str
    categories: list[str]
    series: dict[str, list[int | float]]


@dataclass(frozen=True)
class P7UpdateModel:
    period_label: str
    rows: list[IndustryRow]
    table_rows: list[list[str]]
    replacements: dict[str, str]
    charts: dict[str, MultiSeriesChartModel]


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 P7UpdateError(f"P7 更新失败：{field_path} 为空，无法更新第七页。")
    raw = str(value).strip()
    if not raw or raw == "/":
        raise P7UpdateError(f"P7 更新失败：{field_path} 不是可计算数字，无法更新第七页。")
    raw = raw.replace(",", "").removesuffix("%")
    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/industry 缺少统计年份或统计月份。")
    return f"{year}年1-{month}月"


def _raw_rows(response: dict[str, Any], data_key: str) -> list[dict[str, Any]]:
    cumulative = response.get("累计数据")
    if not isinstance(cumulative, dict):
        raise P7UpdateError("P7 更新失败：/award/industry 缺少 累计数据。")
    rows = cumulative.get(data_key)
    if not isinstance(rows, list) or not rows:
        raise P7UpdateError(f"P7 更新失败：/award/industry.累计数据.{data_key} 缺少数据。")
    return rows


def _rows_by_industry(rows: list[dict[str, Any]], data_key: str) -> dict[str, dict[str, Any]]:
    result: dict[str, dict[str, Any]] = {}
    for index, row in enumerate(rows, start=1):
        if not isinstance(row, dict):
            raise P7UpdateError(f"P7 更新失败：/award/industry.累计数据.{data_key}[{index}] 不是对象。")
        industry = _required_text(row, "行业", f"/award/industry.累计数据.{data_key}[{index}].行业")
        result[industry] = row
    return result


def _industry_rows(response: dict[str, Any]) -> list[IndustryRow]:
    count_by_industry = _rows_by_industry(_raw_rows(response, "个数数据"), "个数数据")
    amount_by_industry = _rows_by_industry(_raw_rows(response, "金额数据"), "金额数据")
    missing_amount = sorted(set(count_by_industry) - set(amount_by_industry))
    missing_count = sorted(set(amount_by_industry) - set(count_by_industry))
    if missing_amount or missing_count:
        raise P7UpdateError(
            "P7 更新失败：/award/industry.累计数据 个数数据与金额数据行业不一致。"
        )

    rows: list[IndustryRow] = []
    for industry in count_by_industry:
        count_row = count_by_industry[industry]
        amount_row = amount_by_industry[industry]
        counts = {
            operator: int(
                _parse_decimal(
                    count_row.get(f"{operator}个数"),
                    f"/award/industry.累计数据.个数数据.{industry}.{operator}个数",
                )
            )
            for operator in OPERATORS
        }
        amounts = {
            operator: _parse_decimal(
                amount_row.get(f"{operator}金额（万元）"),
                f"/award/industry.累计数据.金额数据.{industry}.{operator}金额（万元）",
            )
            for operator in OPERATORS
        }
        count_shares = {
            operator: _required_text(
                count_row,
                f"{operator}个数份额",
                f"/award/industry.累计数据.个数数据.{industry}.{operator}个数份额",
            )
            for operator in OPERATORS
        }
        amount_shares = {
            operator: _required_text(
                amount_row,
                f"{operator}金额份额",
                f"/award/industry.累计数据.金额数据.{industry}.{operator}金额份额",
            )
            for operator in OPERATORS
        }
        rows.append(
            IndustryRow(
                industry=industry,
                total_amount_wan=sum(amounts.values(), Decimal("0")),
                counts=counts,
                amounts_wan=amounts,
                count_shares=count_shares,
                amount_shares=amount_shares,
            )
        )
    if len(rows) < 10:
        raise P7UpdateError("P7 更新失败：/award/industry.累计数据 少于 10 个行业。")
    return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)[:10]


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) -> str:
    return f"{{{{{fact_text}；{FOLLOWUP_PROMPT_SUFFIX}}}}}"


def _dominant_operator(shares: dict[str, str]) -> tuple[str, str]:
    scored = {
        operator: _parse_decimal(share, f"P7.{operator}份额")
        for operator, share in shares.items()
    }
    operator, _ = max(scored.items(), key=lambda item: item[1])
    return operator, shares[operator]


def _summary_text(rows: list[IndustryRow]) -> 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.industry}{_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'))}%"
    )


def _analysis_text(index: int, row: IndustryRow) -> str:
    count_operator, count_share = _dominant_operator(row.count_shares)
    amount_operator, amount_share = _dominant_operator(row.amount_shares)
    return _wrap_followup_prompt(
        f"{row.industry}行业：{_format_yi_from_wan(row.total_amount_wan)}，"
        f"个数{count_operator}{count_share}，金额{amount_operator}{amount_share}"
    )


def _table_rows(rows: list[IndustryRow]) -> list[list[str]]:
    return [
        ["行业", "电信个数份额", "移动个数份额", "联通个数份额", "电信金额份额", "移动金额份额", "联通金额份额"],
        *[
            [
                row.industry,
                row.count_shares["电信"],
                row.count_shares["移动"],
                row.count_shares["联通"],
                row.amount_shares["电信"],
                row.amount_shares["移动"],
                row.amount_shares["联通"],
            ]
            for row in rows
        ],
    ]


def build_p7_update_model(response: dict[str, Any]) -> P7UpdateModel:
    period_label = _period_label(response)
    rows = _industry_rows(response)
    replacements = {
        P7_TEXT_PLACEHOLDERS[0]: _summary_text(rows),
    }
    for index, row in enumerate(rows[:3], start=1):
        replacements[P7_TEXT_PLACEHOLDERS[index]] = _analysis_text(index, row)

    charts = {
        "chart10": MultiSeriesChartModel(
            title=f"累计行业中标个数对比（{period_label}）",
            categories=[row.industry for row in rows],
            series={
                f"{operator}个数": [row.counts[operator] for row in rows]
                for operator in OPERATORS
            },
        ),
        "chart11": MultiSeriesChartModel(
            title=f"累计行业中标金额对比（万元，{period_label}）",
            categories=[row.industry for row in rows],
            series={
                f"{operator}金额（万元）": [float(row.amounts_wan[operator]) for row in rows]
                for operator in OPERATORS
            },
        ),
    }
    return P7UpdateModel(
        period_label=period_label,
        rows=rows,
        table_rows=_table_rows(rows),
        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=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) -> dict[str, Any]:
    return fetch_json(
        api_base,
        "/award/industry",
        {"year": year, "month": month, "ct": "是", "scope": "上海"},
    )


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 P7UpdateError(f"P7 更新失败：模板第 7 页缺少占位符：{', '.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 P7UpdateError("P7 更新失败：表格单元格缺少文本节点。")
    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 P7UpdateError("P7 更新失败：第 7 页未找到表格。")
    xml_rows = table.findall("./a:tr", OOXML_NS)
    if len(xml_rows) < len(table_rows):
        raise P7UpdateError("P7 更新失败：第 7 页表格行数不足。")
    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 P7UpdateError("P7 更新失败：第 7 页表格列数不足。")
        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, chart_name: str) -> None:
    text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
    if not text_nodes:
        raise P7UpdateError(f"P7 更新失败：{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: MultiSeriesChartModel) -> bytes:
    root = ET.fromstring(chart_xml)
    _set_chart_title(root, model.title, chart_name)
    series_nodes = root.findall(".//c:ser", OOXML_NS)
    series_items = list(model.series.items())
    if len(series_nodes) < len(series_items):
        raise P7UpdateError(f"P7 更新失败：{chart_name}.xml 图表系列不足。")
    for series_node, (series_name, values) in zip(series_nodes, series_items, strict=False):
        name_nodes = series_node.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
        if name_nodes:
            name_nodes[0].text = series_name
        category_cache = series_node.find("./c:cat//c:strCache", OOXML_NS)
        value_cache = series_node.find("./c:val//c:numCache", OOXML_NS)
        if category_cache is None or value_cache is None:
            raise P7UpdateError(f"P7 更新失败：{chart_name}.xml 缺少分类或数值缓存。")
        _set_cache_points(category_cache, model.categories)
        _set_cache_points(value_cache, [_chart_value_text(value) for value in values])
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def _update_workbook(workbook_bytes: bytes, chart_model: MultiSeriesChartModel) -> 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, len(chart_model.series) + 1)
    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"] = "行业"
    for column_index, series_name in enumerate(chart_model.series, start=2):
        sheet.cell(row=1, column=column_index).value = series_name
    for row_index, category in enumerate(chart_model.categories, start=2):
        sheet.cell(row=row_index, column=1).value = category
        for column_index, values in enumerate(chart_model.series.values(), start=2):
            sheet.cell(row=row_index, column=column_index).value = values[row_index - 2]
    output = BytesIO()
    workbook.save(output)
    return output.getvalue()


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 = {
        "ppt/slides/slide7.xml",
        "ppt/charts/chart10.xml",
        "ppt/charts/chart11.xml",
        "ppt/embeddings/Workbook10.xlsx",
        "ppt/embeddings/Workbook11.xlsx",
    }
    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("ppt/slides/slide7.xml"))
        _replace_slide_text(slide_root, model.replacements)
        _update_slide_table(slide_root, model.table_rows)
        updates = {
            "ppt/slides/slide7.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
            "ppt/charts/chart10.xml": _update_chart_xml(
                source.read("ppt/charts/chart10.xml"),
                "chart10",
                model.charts["chart10"],
            ),
            "ppt/charts/chart11.xml": _update_chart_xml(
                source.read("ppt/charts/chart11.xml"),
                "chart11",
                model.charts["chart11"],
            ),
            "ppt/embeddings/Workbook10.xlsx": _update_workbook(
                source.read("ppt/embeddings/Workbook10.xlsx"),
                model.charts["chart10"],
            ),
            "ppt/embeddings/Workbook11.xlsx": _update_workbook(
                source.read("ppt/embeddings/Workbook11.xlsx"),
                model.charts["chart11"],
            ),
        }

        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 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_p7_response(args.api_base, args.year, args.month)
    model = build_p7_update_model(response)
    update_p7_pptx(args.pptx, args.output, model)


if __name__ == "__main__":
    main()
