"""apply: edit native PowerPoint chart data on cloned slides.

Each referenced chart part is cloned, its ``<c:ser>`` caches are rewritten from
the plan's categories / series, and the embedded ``.xlsx`` workbook is rebuilt so
PowerPoint's "Edit Data" view stays consistent. Chart styling / axes / legend
layout are left untouched.
"""

from __future__ import annotations

import io
import re
import sys
import zipfile
from typing import Any
from xml.etree import ElementTree as ET

from .edit_safety import (
    _chart_frames,
    _chart_reference,
    _require_supported_chart_edit,
)
from .ooxml import (
    CHART_CONTENT_TYPE,
    CHART_REL_TYPE,
    NS,
    PACKAGE_REL_TYPE,
    REL_NS,
    XLSX_CONTENT_TYPE,
    _normalize_part,
    _qn,
    _rels_name_for_part,
    _shape_identity,
    _xml_bytes,
)
from .package import _add_content_type_override, _find_relationship, _relative_target
from .selectors import _chart_selectors


def _chart_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, dict[str, str]]:
    maps: dict[str, dict[str, str]] = {}
    for order, container in enumerate(_chart_frames(slide_root), start=1):
        shape_id, shape_name = _shape_identity(container, order)
        chart_kind, rel_id = _chart_reference(container)
        info = {
            "shape_id": shape_id,
            "shape_name": shape_name,
            "rel_id": rel_id,
            "chart_kind": chart_kind,
        }
        maps[f"chart_id:s{source_slide:02d}_ch{shape_id}"] = info
        maps[f"shape_id:{shape_id}"] = info
        if shape_name:
            maps[f"shape_name:{shape_name}"] = info
    return maps


def _max_chart_part_number(entries: dict[str, bytes]) -> int:
    max_number = 0
    pattern = re.compile(r"^ppt/charts/chart(\d+)\.xml$")
    for name in entries:
        match = pattern.match(name)
        if match:
            max_number = max(max_number, int(match.group(1)))
    return max_number


def _max_embedding_part_number(entries: dict[str, bytes]) -> int:
    max_number = 0
    pattern = re.compile(r"^ppt/embeddings/templateFillChart(\d+)\.xlsx$")
    for name in entries:
        match = pattern.match(name)
        if match:
            max_number = max(max_number, int(match.group(1)))
    return max_number


def _chart_part_from_relationship(slide_part: str, rel: ET.Element) -> str:
    target = rel.attrib.get("Target", "")
    if rel.attrib.get("Type") != CHART_REL_TYPE or not target:
        raise RuntimeError("Matched chart shape does not point to a chart relationship")
    return _normalize_part(target, slide_part)


def _chart_type_with_series(chart_root: ET.Element) -> ET.Element:
    plot_area = chart_root.find(".//c:plotArea", NS)
    if plot_area is None:
        raise RuntimeError("Chart XML has no plotArea")
    chart_types: list[ET.Element] = []
    for child in list(plot_area):
        if child.tag.endswith("Chart") and child.findall("c:ser", NS):
            chart_types.append(child)
    if len(chart_types) > 1:
        raise RuntimeError(
            "template-fill chart edits do not support multi-plot / combination charts; "
            "use beautify/main pipeline to redraw the chart, or leave the native chart untouched"
        )
    if chart_types:
        return chart_types[0]
    raise RuntimeError("Chart XML has no editable series")


def _ensure_child(parent: ET.Element, tag: str) -> ET.Element:
    child = parent.find(tag, NS)
    if child is not None:
        return child
    return ET.SubElement(parent, _qn(NS["c"], tag.split(":", 1)[1]))


def _set_val_attr(element: ET.Element, value: str | int | float) -> None:
    element.set("val", str(value))


def _excel_col(index: int) -> str:
    result = ""
    while index:
        index, remainder = divmod(index - 1, 26)
        result = chr(65 + remainder) + result
    return result or "A"


def _write_string_cache(cache: ET.Element, values: list[str]) -> None:
    for child in list(cache):
        cache.remove(child)
    pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount"))
    _set_val_attr(pt_count, len(values))
    for index, value in enumerate(values):
        pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)})
        v = ET.SubElement(pt, _qn(NS["c"], "v"))
        v.text = str(value)


def _write_number_cache(cache: ET.Element, values: list[Any]) -> None:
    for child in list(cache):
        cache.remove(child)
    fmt = ET.SubElement(cache, _qn(NS["c"], "formatCode"))
    fmt.text = "General"
    pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount"))
    _set_val_attr(pt_count, len(values))
    for index, value in enumerate(values):
        pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)})
        v = ET.SubElement(pt, _qn(NS["c"], "v"))
        v.text = str(value)


def _set_series_name(series: ET.Element, name: str, column_index: int) -> None:
    tx = _ensure_child(series, "c:tx")
    for child in list(tx):
        tx.remove(child)
    str_ref = ET.SubElement(tx, _qn(NS["c"], "strRef"))
    formula = ET.SubElement(str_ref, _qn(NS["c"], "f"))
    formula.text = f"Sheet1!${_excel_col(column_index)}$1"
    cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache"))
    _write_string_cache(cache, [name])


def _set_category_cache(series: ET.Element, categories: list[str]) -> None:
    cat = _ensure_child(series, "c:cat")
    for child in list(cat):
        cat.remove(child)
    str_ref = ET.SubElement(cat, _qn(NS["c"], "strRef"))
    formula = ET.SubElement(str_ref, _qn(NS["c"], "f"))
    formula.text = f"Sheet1!$A$2:$A${len(categories) + 1}"
    cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache"))
    _write_string_cache(cache, [str(item) for item in categories])


def _set_value_cache(series: ET.Element, values: list[Any], column_index: int) -> None:
    val = _ensure_child(series, "c:val")
    for child in list(val):
        val.remove(child)
    num_ref = ET.SubElement(val, _qn(NS["c"], "numRef"))
    formula = ET.SubElement(num_ref, _qn(NS["c"], "f"))
    formula.text = f"Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(values) + 1}"
    cache = ET.SubElement(num_ref, _qn(NS["c"], "numCache"))
    _write_number_cache(cache, values)


def _apply_chart_edit_to_chart_xml(chart_root: ET.Element, chart_edit: dict[str, Any]) -> None:
    capability = _require_supported_chart_edit(chart_root)
    for warning in capability.get("warnings", []):
        if not isinstance(warning, dict):
            continue
        code = warning.get("code") or "chart_edit_category_flattened"
        message = warning.get("message") or "chart categories will be flattened"
        print(f"  Warning: {message} [{code}]", file=sys.stderr)
    categories = [str(item) for item in chart_edit.get("categories", [])]
    series_payload = chart_edit.get("series", [])
    if not categories or not isinstance(series_payload, list) or not series_payload:
        raise RuntimeError("Chart edit requires non-empty categories and series")
    chart_type = _chart_type_with_series(chart_root)
    series_nodes = chart_type.findall("c:ser", NS)
    if not series_nodes:
        raise RuntimeError("Chart XML has no editable series")
    template_series = series_nodes[-1]
    while len(series_nodes) < len(series_payload):
        clone = ET.fromstring(ET.tostring(template_series, encoding="utf-8"))
        chart_type.append(clone)
        series_nodes.append(clone)
    for extra in series_nodes[len(series_payload) :]:
        chart_type.remove(extra)
    series_nodes = chart_type.findall("c:ser", NS)
    for index, (series, payload) in enumerate(zip(series_nodes, series_payload), start=0):
        values = payload.get("values", [])
        if len(values) != len(categories):
            raise RuntimeError("Chart series values must match categories length")
        idx = _ensure_child(series, "c:idx")
        order = _ensure_child(series, "c:order")
        _set_val_attr(idx, index)
        _set_val_attr(order, index)
        _set_series_name(series, str(payload.get("name", f"系列{index + 1}")), index + 2)
        _set_category_cache(series, categories)
        _set_value_cache(series, values, index + 2)


def _spreadsheet_relationships(xlsx_entries: dict[str, bytes], part_name: str) -> dict[str, str]:
    rels_name = _rels_name_for_part(part_name)
    if rels_name not in xlsx_entries:
        return {}
    root = ET.fromstring(xlsx_entries[rels_name])
    rels: dict[str, str] = {}
    for rel in root.findall(_qn(REL_NS, "Relationship")):
        rel_id = rel.attrib.get("Id")
        target = rel.attrib.get("Target")
        if rel_id and target:
            rels[rel_id] = _normalize_part(target, part_name)
    return rels


def _first_workbook_sheet(xlsx_entries: dict[str, bytes]) -> str | None:
    workbook_part = "xl/workbook.xml"
    if workbook_part not in xlsx_entries:
        return None
    root = ET.fromstring(xlsx_entries[workbook_part])
    sheets = root.find("{http://schemas.openxmlformats.org/spreadsheetml/2006/main}sheets")
    if sheets is None:
        return None
    first = next(iter(list(sheets)), None)
    if first is None:
        return None
    rel_id = first.attrib.get(_qn(NS["r"], "id"))
    if not rel_id:
        return None
    return _spreadsheet_relationships(xlsx_entries, workbook_part).get(rel_id)


def _spreadsheet_cell_ref(row: int, col: int) -> str:
    return f"{_excel_col(col)}{row}"


def _spreadsheet_cell(value: Any, row: int, col: int) -> ET.Element:
    cell = ET.Element(
        "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c",
        {"r": _spreadsheet_cell_ref(row, col)},
    )
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        v = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v")
        v.text = str(value)
        return cell
    cell.set("t", "inlineStr")
    inline = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}is")
    text = ET.SubElement(inline, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}t")
    text.text = str(value)
    return cell


def _rewrite_chart_workbook(xlsx_bytes: bytes, chart_edit: dict[str, Any]) -> bytes:
    categories = chart_edit.get("categories", [])
    series_payload = chart_edit.get("series", [])
    with zipfile.ZipFile(io.BytesIO(xlsx_bytes)) as zin:
        xlsx_entries = {info.filename: zin.read(info.filename) for info in zin.infolist() if not info.is_dir()}
    sheet_part = _first_workbook_sheet(xlsx_entries) or "xl/worksheets/sheet1.xml"
    if sheet_part not in xlsx_entries:
        return xlsx_bytes
    sheet_root = ET.fromstring(xlsx_entries[sheet_part])
    sheet_ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
    sheet_data = sheet_root.find(_qn(sheet_ns, "sheetData"))
    if sheet_data is None:
        sheet_data = ET.SubElement(sheet_root, _qn(sheet_ns, "sheetData"))
    for child in list(sheet_data):
        sheet_data.remove(child)

    rows = [["Category"] + [str(item.get("name", f"系列{idx + 1}")) for idx, item in enumerate(series_payload)]]
    for row_index, category in enumerate(categories):
        rows.append([category] + [item.get("values", [])[row_index] for item in series_payload])
    for row_index, values in enumerate(rows, start=1):
        row = ET.SubElement(sheet_data, _qn(sheet_ns, "row"), {"r": str(row_index)})
        for col_index, value in enumerate(values, start=1):
            row.append(_spreadsheet_cell(value, row_index, col_index))
    xlsx_entries[sheet_part] = _xml_bytes(sheet_root)

    out_buffer = io.BytesIO()
    with zipfile.ZipFile(out_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout:
        for name, data in xlsx_entries.items():
            zout.writestr(name, data)
    return out_buffer.getvalue()


def _find_chart_workbook_rel(chart_rels_root: ET.Element) -> ET.Element | None:
    for rel in chart_rels_root.findall(_qn(REL_NS, "Relationship")):
        target = rel.attrib.get("Target", "")
        if rel.attrib.get("Type") == PACKAGE_REL_TYPE or target.lower().endswith(".xlsx"):
            return rel
    return None


def _clone_and_update_chart_part(
    entries: dict[str, bytes],
    content_root: ET.Element,
    *,
    source_chart_part: str,
    new_chart_part: str,
    chart_edit: dict[str, Any],
    next_embedding_number: int,
) -> int:
    if source_chart_part not in entries:
        raise RuntimeError(f"Missing chart part: {source_chart_part}")
    chart_root = ET.fromstring(entries[source_chart_part])
    _apply_chart_edit_to_chart_xml(chart_root, chart_edit)
    entries[new_chart_part] = _xml_bytes(chart_root)
    _add_content_type_override(content_root, new_chart_part, CHART_CONTENT_TYPE)

    source_chart_rels = _rels_name_for_part(source_chart_part)
    if source_chart_rels not in entries:
        return next_embedding_number
    new_chart_rels = _rels_name_for_part(new_chart_part)
    chart_rels_root = ET.fromstring(entries[source_chart_rels])
    workbook_rel = _find_chart_workbook_rel(chart_rels_root)
    if workbook_rel is not None:
        workbook_target = workbook_rel.attrib.get("Target", "")
        workbook_part = _normalize_part(workbook_target, source_chart_part)
        if workbook_part in entries:
            next_embedding_number += 1
            new_workbook_part = f"ppt/embeddings/templateFillChart{next_embedding_number}.xlsx"
            entries[new_workbook_part] = _rewrite_chart_workbook(entries[workbook_part], chart_edit)
            workbook_rel.set("Target", _relative_target(new_chart_part, new_workbook_part))
            _add_content_type_override(content_root, new_workbook_part, XLSX_CONTENT_TYPE)
    entries[new_chart_rels] = _xml_bytes(chart_rels_root)
    return next_embedding_number


def _apply_chart_edits_to_slide_package(
    slide_root: ET.Element,
    rels_root: ET.Element,
    entries: dict[str, bytes],
    content_root: ET.Element,
    *,
    source_slide: int,
    new_slide_part: str,
    chart_edits: list[dict[str, Any]],
    next_chart_number: int,
    next_embedding_number: int,
) -> tuple[int, int]:
    maps = _chart_key_maps(slide_root, source_slide)
    cloned_by_rel_id: dict[str, str] = {}
    errors: list[str] = []
    for chart_edit in chart_edits:
        selectors = _chart_selectors(chart_edit)
        chart_info = next((maps[key] for key in selectors if key in maps), None)
        if chart_info is None:
            if chart_edit.get("optional"):
                continue
            errors.append(", ".join(selectors) or "<missing selector>")
            continue
        chart_kind = chart_info.get("chart_kind", "")
        if chart_kind != "classic":
            code = (
                "chart_edit_chartex_unsupported"
                if chart_kind == "chartex"
                else "chart_edit_plot_type_unsupported"
            )
            raise RuntimeError(
                "template-fill chart edits require a supported classic chart "
                f"[{code}]"
            )
        rel_id = chart_info.get("rel_id", "")
        rel = _find_relationship(rels_root, rel_id)
        if rel is None:
            errors.append(f"{selectors[0] if selectors else '<chart>'} relationship={rel_id}")
            continue
        if rel_id not in cloned_by_rel_id:
            next_chart_number += 1
            source_chart_part = _chart_part_from_relationship(new_slide_part, rel)
            new_chart_part = f"ppt/charts/chart{next_chart_number}.xml"
            next_embedding_number = _clone_and_update_chart_part(
                entries,
                content_root,
                source_chart_part=source_chart_part,
                new_chart_part=new_chart_part,
                chart_edit=chart_edit,
                next_embedding_number=next_embedding_number,
            )
            rel.set("Target", _relative_target(new_slide_part, new_chart_part))
            cloned_by_rel_id[rel_id] = new_chart_part
            continue
        chart_root = ET.fromstring(entries[cloned_by_rel_id[rel_id]])
        _apply_chart_edit_to_chart_xml(chart_root, chart_edit)
        entries[cloned_by_rel_id[rel_id]] = _xml_bytes(chart_root)
    if errors:
        raise RuntimeError(f"Missing chart edit target(s) on slide {source_slide}: {'; '.join(errors)}")
    return next_chart_number, next_embedding_number
