from __future__ import annotations

import argparse
import json
import shutil
from pathlib import Path
from typing import Any

from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml import parse_xml
from pptx.oxml.ns import nsdecls
from pptx.util import Pt


C_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Fallback PPT renderer using python-pptx (no COM).")
    p.add_argument("--template", required=True)
    p.add_argument("--output", required=True)
    p.add_argument("--operations", required=True)
    return p.parse_args()


def find_shape(slide, shape_id: int | None, shape_name: str | None):
    for shp in slide.shapes:
        if shape_id and getattr(shp, "shape_id", None) == shape_id:
            return shp
    if shape_name:
        for shp in slide.shapes:
            if getattr(shp, "name", "") == shape_name:
                return shp
    return None


def apply_text_ops(prs: Presentation, ops: list[dict[str, Any]]) -> None:
    for item in ops:
        slide = prs.slides[int(item["slide"]) - 1]
        shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
        if shape is None or not getattr(shape, "has_text_frame", False):
            raise RuntimeError(f"Text target not found on slide {item.get('slide')}")
        shape.text_frame.text = str(item.get("new_text", ""))


def get_target_box(item: dict[str, Any]) -> tuple[int, int, int, int]:
    box = item.get("target_box") or {}
    return (
        int(box.get("left", 0)),
        int(box.get("top", 0)),
        int(box.get("width", 0)),
        int(box.get("height", 0)),
    )


def remove_shape(shape) -> None:
    el = shape._element
    el.getparent().remove(el)


def set_text_style(tf, *, size: int, bold: bool, rgb: RGBColor, align=PP_ALIGN.CENTER) -> None:
    tf.vertical_anchor = MSO_ANCHOR.MIDDLE
    tf.margin_left = 0
    tf.margin_right = 0
    tf.margin_top = 0
    tf.margin_bottom = 0
    if not tf.paragraphs:
        tf.add_paragraph()
    p = tf.paragraphs[0]
    p.alignment = align
    if not p.runs:
        p.add_run()
    run = p.runs[0]
    run.font.size = Pt(size)
    run.font.bold = bold
    run.font.color.rgb = rgb


def _first(root, local_name: str):
    matches = root.xpath(f'.//*[local-name()="{local_name}"]')
    return matches[0] if matches else None


def _direct_children(el, local_name: str):
    return [child for child in el if child.tag == f"{{{C_NS}}}{local_name}"]


def _set_child_val(el, local_name: str, value: str) -> None:
    child = None
    for candidate in el:
        if candidate.tag == f"{{{C_NS}}}{local_name}":
            child = candidate
            break
    if child is None:
        child = parse_xml(f'<c:{local_name} {nsdecls("c")} val="{value}"/>')
        el.append(child)
    else:
        child.set("val", value)


def _make_shape_props(hex_color: str, *, width: int = 19050, no_fill: bool = False) -> str:
    fill = "<a:noFill/>" if no_fill else f'<a:solidFill><a:srgbClr val="{hex_color}"/></a:solidFill>'
    return (
        f'<c:spPr {nsdecls("c", "a")}>'
        f'<a:ln w="{width}"><a:solidFill><a:srgbClr val="{hex_color}"/></a:solidFill></a:ln>'
        f"{fill}"
        "</c:spPr>"
    )


def _make_marker_series_props(hex_color: str, *, line: bool) -> str:
    line_xml = (
        f'<a:ln w="25400"><a:solidFill><a:srgbClr val="{hex_color}"/></a:solidFill></a:ln>'
        if line
        else '<a:ln><a:noFill/></a:ln>'
    )
    return (
        f'<c:spPr {nsdecls("c", "a")}>'
        f"{line_xml}"
        '<a:noFill/>'
        '</c:spPr>'
    )


def _insert_after(parent, after_el, new_el) -> None:
    parent.insert(parent.index(after_el) + 1, new_el)


def _format_combo_axes(chart, *, primary_max: float, primary_unit: float, secondary_min: float, secondary_max: float, secondary_unit: float) -> None:
    root = chart._element
    val_axes = root.xpath('.//*[local-name()="valAx"]')
    cat_axis = _first(root, "catAx")
    primary_axis = val_axes[0] if val_axes else None
    secondary_axis = val_axes[1] if len(val_axes) > 1 else None

    if primary_axis is not None:
        scaling = _direct_children(primary_axis, "scaling")[0] if _direct_children(primary_axis, "scaling") else None
        if scaling is not None:
            scaling.clear()
            scaling.append(parse_xml(f'<c:orientation {nsdecls("c")} val="minMax"/>'))
            scaling.append(parse_xml(f'<c:max {nsdecls("c")} val="{primary_max}"/>'))
            scaling.append(parse_xml(f'<c:min {nsdecls("c")} val="0"/>'))
        _set_child_val(primary_axis, "majorUnit", str(primary_unit))

    if secondary_axis is not None:
        scaling = _direct_children(secondary_axis, "scaling")[0] if _direct_children(secondary_axis, "scaling") else None
        if scaling is not None:
            scaling.clear()
            scaling.append(parse_xml(f'<c:orientation {nsdecls("c")} val="minMax"/>'))
            scaling.append(parse_xml(f'<c:max {nsdecls("c")} val="{secondary_max}"/>'))
            scaling.append(parse_xml(f'<c:min {nsdecls("c")} val="{secondary_min}"/>'))
        _set_child_val(secondary_axis, "majorUnit", str(secondary_unit))
        _set_child_val(secondary_axis, "numFmt", "0%")
        # numFmt uses a different attribute name, so fix it after helper insertion.
        num_fmt = _direct_children(secondary_axis, "numFmt")[0] if _direct_children(secondary_axis, "numFmt") else None
        if num_fmt is not None:
            num_fmt.attrib.clear()
            num_fmt.set("formatCode", "0%")
            num_fmt.set("sourceLinked", "0")

    if cat_axis is not None:
        _set_child_val(cat_axis, "tickLblSkip", "1")
        _set_child_val(cat_axis, "tickMarkSkip", "1")


def convert_last_series_to_secondary_line(chart, *, primary_max: float, primary_unit: float, secondary_min: float, secondary_max: float, secondary_unit: float, color: str = "B000C9", line: bool = False) -> None:
    root = chart._element
    plot_area = _first(root, "plotArea")
    source_chart = _first(root, "barChart")
    if source_chart is None:
        source_chart = _first(root, "lineChart")
    if plot_area is None or source_chart is None:
        return
    series = _direct_children(source_chart, "ser")
    if len(series) < 2:
        return

    moving = series[-1]
    source_chart.remove(moving)
    moving.insert(3, parse_xml(_make_marker_series_props(color, line=line)))
    # Line-series marker belongs before category/value references in chart XML.
    moving.insert(4, parse_xml(f'<c:marker {nsdecls("c", "a")}><c:symbol val="circle"/><c:size val="6"/>{_make_shape_props(color)}</c:marker>'))
    moving.append(parse_xml(f'<c:smooth {nsdecls("c")} val="0"/>'))

    primary_cat = _direct_children(source_chart, "axId")[0].get("val")
    secondary_val = "90210002"
    secondary_cat = primary_cat
    line_chart = parse_xml(
        f'<c:lineChart {nsdecls("c")}>'
        '<c:grouping val="standard"/>'
        '<c:varyColors val="0"/>'
        f'<c:marker val="1"/>'
        '<c:smooth val="0"/>'
        f'<c:axId val="{secondary_cat}"/>'
        f'<c:axId val="{secondary_val}"/>'
        '</c:lineChart>'
    )
    # Series must appear before marker/smooth/axId elements.
    line_chart.insert(2, moving)
    _insert_after(plot_area, source_chart, line_chart)

    primary_val_axis = _first(root, "valAx")
    secondary_axis = parse_xml(
        f'<c:valAx {nsdecls("c")}>'
        f'<c:axId val="{secondary_val}"/>'
        '<c:scaling><c:orientation val="minMax"/></c:scaling>'
        '<c:delete val="0"/>'
        '<c:axPos val="r"/>'
        '<c:majorTickMark val="out"/>'
        '<c:minorTickMark val="none"/>'
        '<c:tickLblPos val="nextTo"/>'
        f'<c:crossAx val="{secondary_cat}"/>'
        '<c:crosses val="max"/>'
        '</c:valAx>'
    )
    if primary_val_axis is not None:
        _insert_after(plot_area, primary_val_axis, secondary_axis)
    else:
        plot_area.append(secondary_axis)

    _format_combo_axes(
        chart,
        primary_max=primary_max,
        primary_unit=primary_unit,
        secondary_min=secondary_min,
        secondary_max=secondary_max,
        secondary_unit=secondary_unit,
    )


def apply_slide12_table(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide12 table target shape not found and no target_box provided")

        for shp in list(slide.shapes):
            if not getattr(shp, "has_table", False):
                continue
            if abs(shp.top - top) < 1500000:
                remove_shape(shp)

    headers = list(item["headers"])
    rows = [list(r) for r in item["rows"]]
    gf = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
    table = gf.table

    base_widths = [92, 128, 144, 110, 144, 110, 122, 144, 110, 144, 110]
    if len(headers) == len(base_widths):
        total = sum(base_widths)
        for i, bw in enumerate(base_widths):
            table.columns[i].width = int(width * bw / total)

    header_bg = RGBColor(0x5F, 0x6F, 0xCE)
    grid = RGBColor(0xD5, 0xD5, 0xD5)
    black = RGBColor(0x11, 0x11, 0x11)
    white = RGBColor(0xFF, 0xFF, 0xFF)
    lfl_pos = RGBColor(0xD1, 0x1F, 0x1F)
    lfl_neg = RGBColor(0x0F, 0x9D, 0x58)

    for c, text in enumerate(headers):
        cell = table.cell(0, c)
        cell.text = str(text)
        cell.fill.solid()
        cell.fill.fore_color.rgb = header_bg
        set_text_style(cell.text_frame, size=10, bold=True, rgb=white)

    for r, row in enumerate(rows, start=1):
        for c, value in enumerate(row):
            cell = table.cell(r, c)
            v = str(value)
            cell.text = v
            cell.fill.solid()
            cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
            color = black
            if r == len(rows) and c > 0:
                if v.startswith("-"):
                    color = lfl_neg
                elif not v.startswith("0"):
                    color = lfl_pos
            set_text_style(cell.text_frame, size=10, bold=(r == len(rows)), rgb=color)


def add_line_chart(slide, left, top, width, height, panel: dict[str, Any], month: int):
    cats = []
    cur = panel["current_values"]
    prev = panel["previous_values"]
    lfl = [float(v) / 100.0 for v in panel["lfl_values"]]
    for i in range(len(cur)):
        d = i + 1
        cats.append(f"{month}-{d}" if d in (1, 8, 16, 23, len(cur)) else "")

    data = CategoryChartData()
    data.categories = cats
    data.add_series(panel["current_name"], tuple(cur))
    data.add_series(panel["previous_name"], tuple(prev))
    data.add_series("LFL", tuple(lfl))

    gf = slide.shapes.add_chart(
        XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, data
    )
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = str(panel["title"])
    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.TOP
    chart.value_axis.minimum_scale = 0.0
    chart.value_axis.maximum_scale = float(panel["value_axis_max"])
    chart.value_axis.major_unit = float(panel["value_axis_major_unit"])
    chart.value_axis.tick_labels.font.size = Pt(9)
    chart.category_axis.tick_labels.font.size = Pt(8)
    return gf


def apply_slide12_dual_line(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide12 dual-line target shape not found and no target_box provided")

        for shp in list(slide.shapes):
            if not getattr(shp, "has_chart", False):
                continue
            if abs(shp.top - top) < 1800000:
                remove_shape(shp)

    panels = list(item["panels"])
    gap = int(width * 0.01)
    panel_w = int((width - gap) / 2)
    month = int(item.get("month", 1))
    add_line_chart(slide, left, top, panel_w, height, panels[0], month)
    add_line_chart(slide, left + panel_w + gap, top, panel_w, height, panels[1], month)


def add_bar_chart(slide, left, top, width, height, entries, report_label, compare_label):
    cats = [e["name"] for e in entries]
    report_vals = [e["report_uv"] for e in entries]
    compare_vals = [e["compare_uv"] for e in entries]
    data = CategoryChartData()
    data.categories = cats
    data.add_series(report_label, tuple(report_vals))
    data.add_series(compare_label, tuple(compare_vals))
    gf = slide.shapes.add_chart(
        XL_CHART_TYPE.COLUMN_CLUSTERED, left, top, width, height, data
    )
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = "Entry Traffic LFL"
    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.TOP
    chart.category_axis.tick_labels.font.size = Pt(7)
    chart.value_axis.tick_labels.font.size = Pt(8)
    return gf


def top_donut_rows(entries: list[dict[str, Any]], field: str):
    pairs = [(e["name"], float(e[field])) for e in entries]
    pairs.sort(key=lambda x: x[1], reverse=True)
    # Keep fewer slices to avoid unreadable, overlapping labels.
    top = pairs[:5]
    other = sum(v for _, v in pairs[5:])
    if other > 0:
        top.append(("Other", other))
    return top


def add_donut_chart(slide, left, top, width, height, rows, title):
    data = CategoryChartData()
    data.categories = [r[0] for r in rows]
    data.add_series("UV", tuple([r[1] for r in rows]))
    gf = slide.shapes.add_chart(XL_CHART_TYPE.DOUGHNUT, left, top, width, height, data)
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = title
    chart.has_legend = False
    plot = chart.plots[0]
    plot.has_data_labels = True
    labels = plot.data_labels
    labels.show_category_name = False
    labels.show_percentage = False
    labels.show_value = False
    labels.font.size = Pt(10)
    labels.font.bold = True

    total = sum(float(v) for _, v in rows)
    if total > 0:
        series = chart.series[0]
        for idx, (name, value) in enumerate(rows):
            pct = int(round((float(value) / total) * 100))
            point = series.points[idx]
            point.data_label.has_text_frame = True
            point.data_label.text_frame.text = f"{name}\n{pct}%"
            point.data_label.text_frame.paragraphs[0].font.size = Pt(10)
            point.data_label.text_frame.paragraphs[0].font.bold = True
    return gf


def apply_slide12_entry_mix(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide12 entry-mix target shape not found and no target_box provided")

        for shp in list(slide.shapes):
            if not getattr(shp, "has_chart", False):
                continue
            if abs(shp.top - top) < 1800000:
                remove_shape(shp)

    entries = list(item["entries"])
    report_year = int(item["report_year"])
    compare_year = int(item["compare_year"])
    report_label = f"{str(report_year)[-2:]}UV"
    compare_label = f"{str(compare_year)[-2:]}UV"

    # Give donut charts much more space for labels.
    left_w = int(width * 0.30)
    right_w = width - left_w
    gap = int(width * 0.01)
    donut_w = int((right_w - gap) / 2)
    donut_left = left + left_w

    add_bar_chart(slide, left, top, left_w, height, entries, report_label, compare_label)
    report_rows = top_donut_rows(entries, "report_uv")
    compare_rows = top_donut_rows(entries, "compare_uv")
    donut_top = max(0, top - int(height * 0.08))
    donut_h = int(height * 1.16)
    add_donut_chart(slide, donut_left, donut_top, donut_w, donut_h, report_rows, f"{str(report_year)[-2:]} Entry Mix")
    add_donut_chart(
        slide,
        donut_left + donut_w + gap,
        donut_top,
        donut_w,
        donut_h,
        compare_rows,
        f"{str(compare_year)[-2:]} Entry Mix",
    )


def cleanup_shapes_in_zone(slide, left: int, top: int, width: int, height: int, *, charts: bool = False, tables: bool = False, textboxes: bool = False) -> None:
    right = left + width
    bottom = top + height
    pad = 500000
    for shp in list(slide.shapes):
        keep = True
        if charts and getattr(shp, "has_chart", False):
            keep = False
        if tables and getattr(shp, "has_table", False):
            keep = False
        if textboxes and getattr(shp, "has_text_frame", False):
            keep = False
        if keep:
            continue
        cx = shp.left + int(shp.width / 2)
        cy = shp.top + int(shp.height / 2)
        if (left - pad) <= cx <= (right + pad) and (top - pad) <= cy <= (bottom + pad):
            remove_shape(shp)


def apply_slide14_live_session_summary_table(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide14 summary table target not found and no target_box provided")
        cleanup_shapes_in_zone(slide, left, top, width, height, tables=True)

    headers = list(item["headers"])
    rows = [list(r) for r in item["rows"]]
    gf = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
    table = gf.table

    base_widths = [90, 116, 108, 98, 92, 120, 116, 116, 128, 118, 116]
    if len(headers) == len(base_widths):
        total = sum(base_widths)
        for i, bw in enumerate(base_widths):
            table.columns[i].width = int(width * bw / total)

    header_bg = RGBColor(0x5F, 0x6F, 0xCE)
    black = RGBColor(0x11, 0x11, 0x11)
    white = RGBColor(0xFF, 0xFF, 0xFF)
    lfl_pos = RGBColor(0xD1, 0x1F, 0x1F)
    lfl_neg = RGBColor(0x0F, 0x9D, 0x58)

    for c, text in enumerate(headers):
        cell = table.cell(0, c)
        cell.text = str(text)
        cell.fill.solid()
        cell.fill.fore_color.rgb = header_bg
        set_text_style(cell.text_frame, size=9, bold=True, rgb=white)

    for r, row in enumerate(rows, start=1):
        for c, value in enumerate(row):
            cell = table.cell(r, c)
            v = str(value)
            cell.text = v
            cell.fill.solid()
            cell.fill.fore_color.rgb = white
            color = black
            if r == len(rows) and c > 0:
                if v.startswith("-"):
                    color = lfl_neg
                elif v.startswith("+"):
                    color = lfl_pos
            set_text_style(cell.text_frame, size=9, bold=(r == len(rows)), rgb=color)


def add_daily_sales_penetration_chart(
    slide,
    left: int,
    top: int,
    width: int,
    height: int,
    days: list[int],
    sales: list[float],
    penetration: list[float],
    sales_axis_max: float,
    sales_axis_step: float,
    penetration_axis_max: float,
    penetration_axis_step: float,
):
    cats = [str(d) if d in (1, 4, 8, 12, 16, 20, 24, 28, len(days)) else "" for d in days]
    data = CategoryChartData()
    data.categories = cats
    data.add_series("Live Sales", tuple(sales))
    data.add_series("Penetration", tuple(penetration))
    gf = slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, left, top, width, height, data)
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = "Daily Live Sales & Penetration"
    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.TOP
    chart.value_axis.minimum_scale = 0.0
    chart.value_axis.maximum_scale = float(sales_axis_max)
    chart.value_axis.major_unit = float(sales_axis_step)
    chart.value_axis.tick_labels.font.size = Pt(9)
    chart.category_axis.tick_labels.font.size = Pt(8)
    return gf


def add_daily_conversion_chart(slide, left: int, top: int, width: int, height: int, days: list[int], conv: list[float], axis_max: float, axis_step: float):
    cats = [str(d) if d in (1, 4, 8, 12, 16, 20, 24, 28, len(days)) else "" for d in days]
    data = CategoryChartData()
    data.categories = cats
    data.add_series("Conversion", tuple(conv))
    gf = slide.shapes.add_chart(XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, data)
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = "Daily Conversion Rate"
    chart.has_legend = False
    chart.value_axis.minimum_scale = 0.0
    chart.value_axis.maximum_scale = float(axis_max)
    chart.value_axis.major_unit = float(axis_step)
    chart.value_axis.tick_labels.font.size = Pt(9)
    chart.category_axis.tick_labels.font.size = Pt(8)
    try:
        chart.value_axis.tick_labels.number_format = "0%"
    except Exception:
        pass
    return gf


def add_donut_chart_compact(slide, left: int, top: int, width: int, height: int, rows, title: str):
    data = CategoryChartData()
    data.categories = [r[0] for r in rows]
    data.add_series("Mix", tuple([r[1] for r in rows]))
    gf = slide.shapes.add_chart(XL_CHART_TYPE.DOUGHNUT, left, top, width, height, data)
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = title
    chart.has_legend = False
    plot = chart.plots[0]
    plot.has_data_labels = True
    labels = plot.data_labels
    labels.show_category_name = True
    labels.show_percentage = True
    labels.show_value = False
    labels.font.size = Pt(7)
    try:
        chart.series[0].doughnut_hole_size = 68
    except Exception:
        pass
    return gf


def apply_slide14_live_session_trend(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide14 trend target not found and no target_box provided")
        cleanup_shapes_in_zone(slide, left, top, width, height, charts=True)

    days = [int(v) for v in item["days"]]
    sales_values = [float(v) for v in item["sales_values"]]
    penetration_values = [float(v) for v in item.get("penetration_values") or item.get("conversion_values")]
    sales_axis_max = float(item["sales_axis_max"])
    sales_axis_step = float(item["sales_axis_major_unit"])
    penetration_axis_max = float(item.get("penetration_axis_max") or item.get("conversion_axis_max"))
    penetration_axis_step = float(item.get("penetration_axis_major_unit") or item.get("conversion_axis_major_unit"))

    add_daily_sales_penetration_chart(
        slide,
        left,
        top,
        width,
        height,
        days,
        sales_values,
        penetration_values,
        sales_axis_max,
        sales_axis_step,
        penetration_axis_max,
        penetration_axis_step,
    )


def add_share_bar_chart(
    slide,
    left: int,
    top: int,
    width: int,
    height: int,
    rows: list[dict[str, Any]],
    *,
    title: str,
    max_scale: float,
):
    data = CategoryChartData()
    data.categories = [str(r["name"]) for r in rows]
    data.add_series("Share", tuple([float(r["share"]) for r in rows]))
    gf = slide.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED, left, top, width, height, data)
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = title
    chart.has_legend = False
    chart.category_axis.tick_labels.font.size = Pt(8)
    chart.value_axis.minimum_scale = 0.0
    chart.value_axis.maximum_scale = max_scale
    chart.value_axis.major_unit = 0.05
    try:
        chart.value_axis.tick_labels.number_format = "0%"
    except Exception:
        pass
    plot = chart.plots[0]
    plot.has_data_labels = False
    return gf


def add_tail_share_bar(slide, left: int, top: int, width: int, height: int, rows: list[dict[str, Any]]):
    rows = rows[:6]
    data = CategoryChartData()
    data.categories = [str(r["name"]) for r in rows]
    data.add_series("Share", tuple([float(r["share"]) for r in rows]))
    gf = slide.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED, left, top, width, height, data)
    chart = gf.chart
    chart.has_title = True
    chart.chart_title.text_frame.text = "L3 Tail Share"
    chart.has_legend = False
    chart.category_axis.tick_labels.font.size = Pt(7)
    chart.value_axis.minimum_scale = 0.0
    chart.value_axis.maximum_scale = 0.2
    chart.value_axis.major_unit = 0.05
    try:
        chart.value_axis.tick_labels.number_format = "0%"
    except Exception:
        pass
    chart.plots[0].has_data_labels = False
    return gf


def apply_slide14_goods_category_mix(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide14 goods mix target not found and no target_box provided")
        cleanup_shapes_in_zone(slide, left, top, width, height, charts=True)

    level1_rows = list(item.get("level1_rows") or [])
    level3_top_rows = list(item.get("level3_top_rows") or [])
    level3_tail_rows = list(item.get("level3_tail_rows") or [])

    gap = int(width * 0.025)
    left_w = int(width * 0.31)
    mid_w = int(width * 0.37)
    right_w = width - left_w - mid_w - gap * 2

    donut_rows = [(str(r["name"]), float(r["value"])) for r in level1_rows]
    add_donut_chart_compact(slide, left, top, left_w, height, donut_rows, "L1 Mix")
    add_share_bar_chart(
        slide,
        left + left_w + gap,
        top,
        mid_w,
        height,
        level3_top_rows,
        title="Top5 L3 Share",
        max_scale=0.7,
    )
    add_tail_share_bar(slide, left + left_w + mid_w + gap * 2, top, right_w, height, level3_tail_rows)


def apply_slide15_goods_summary_table(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide15 summary table target not found and no target_box provided")
        cleanup_shapes_in_zone(slide, left, top, width, height, tables=True)

    headers = list(item["headers"])
    rows = [list(r) for r in item["rows"]]
    gf = slide.shapes.add_table(len(rows) + 1, len(headers), left, top, width, height)
    table = gf.table

    base_widths = [56, 70, 74, 78, 68, 72, 110, 70, 74, 92, 92, 96, 66, 72]
    if len(headers) == len(base_widths):
        total = sum(base_widths)
        for i, bw in enumerate(base_widths):
            table.columns[i].width = int(width * bw / total)

    header_bg = RGBColor(0x5F, 0x6F, 0xCE)
    ttl_bg = RGBColor(0xC4, 0xC6, 0xEB)
    black = RGBColor(0x11, 0x11, 0x11)
    white = RGBColor(0xFF, 0xFF, 0xFF)
    lfl_pos = RGBColor(0xD1, 0x1F, 0x1F)
    lfl_neg = RGBColor(0x0F, 0x9D, 0x58)
    lfl_cols = {5, 8, 11, 13}

    for c, text in enumerate(headers):
        cell = table.cell(0, c)
        cell.text = str(text)
        cell.fill.solid()
        cell.fill.fore_color.rgb = header_bg
        set_text_style(cell.text_frame, size=8, bold=True, rgb=white)

    for r, row in enumerate(rows, start=1):
        is_ttl = str(row[0]).upper() == "TTL"
        for c, value in enumerate(row):
            cell = table.cell(r, c)
            v = str(value)
            cell.text = v
            cell.fill.solid()
            cell.fill.fore_color.rgb = ttl_bg if is_ttl else white
            color = black
            if c in lfl_cols:
                if v.startswith("-"):
                    color = lfl_neg
                elif v.startswith("+"):
                    color = lfl_pos
            set_text_style(cell.text_frame, size=9, bold=is_ttl, rgb=color)


def apply_slide15_top5_tables(slide, item: dict[str, Any]) -> None:
    shape = find_shape(slide, item.get("shape_id"), item.get("shape_name"))
    if shape is not None:
        left, top, width, height = shape.left, shape.top, shape.width, shape.height
        remove_shape(shape)
    else:
        left, top, width, height = get_target_box(item)
        if width <= 0 or height <= 0:
            raise RuntimeError("slide15 top5 target not found and no target_box provided")
        cleanup_shapes_in_zone(slide, left, top, width, height, tables=True, textboxes=True)

    bags_rows = list(item.get("bags_rows") or [])
    shoes_rows = list(item.get("shoes_rows") or [])

    gap = int(width * 0.025)
    panel_w = int((width - gap) / 2)
    title_h = int(height * 0.13)
    table_top = top + title_h
    table_h = height - title_h

    title1 = slide.shapes.add_textbox(left, top, panel_w, title_h)
    title1.text_frame.text = "BAGS TOP5"
    set_text_style(title1.text_frame, size=14, bold=True, rgb=RGBColor(0x11, 0x11, 0x11))
    title2 = slide.shapes.add_textbox(left + panel_w + gap, top, panel_w, title_h)
    title2.text_frame.text = "SHOES TOP5"
    set_text_style(title2.text_frame, size=14, bold=True, rgb=RGBColor(0x11, 0x11, 0x11))

    def add_top5_table(x: int, rows: list[dict[str, Any]]):
        headers = ["Image", "Article", "QTY", "Note"]
        table_shape = slide.shapes.add_table(6, 4, x, table_top, panel_w, table_h)
        table = table_shape.table
        col_widths = [26, 38, 14, 22]
        total = sum(col_widths)
        for i, cw in enumerate(col_widths):
            table.columns[i].width = int(panel_w * cw / total)
        header_bg = RGBColor(0x72, 0x79, 0xDE)
        white = RGBColor(0xFF, 0xFF, 0xFF)
        black = RGBColor(0x11, 0x11, 0x11)
        for c, h in enumerate(headers):
            cell = table.cell(0, c)
            cell.text = h
            cell.fill.solid()
            cell.fill.fore_color.rgb = header_bg
            set_text_style(cell.text_frame, size=10, bold=True, rgb=white)
        for r in range(1, 6):
            row = rows[r - 1] if r - 1 < len(rows) else {"article": "", "qty": "", "note": "", "image_path": ""}
            vals = ["", str(row.get("article", "")), str(row.get("qty", "")), str(row.get("note", ""))]
            for c, val in enumerate(vals):
                cell = table.cell(r, c)
                cell.text = val
                cell.fill.solid()
                cell.fill.fore_color.rgb = white
                align = PP_ALIGN.LEFT if c in (1, 3) else PP_ALIGN.CENTER
                set_text_style(cell.text_frame, size=8 if c in (1, 3) else 9, bold=False, rgb=black, align=align)

            image_path = str(row.get("image_path") or "")
            cell = table.cell(r, 0)
            image_left = x
            for col_idx in range(0):
                image_left += table.columns[col_idx].width
            image_top = table_top + int(table_h / 6) * r
            image_w = table.columns[0].width
            image_h = int(table_h / 6)
            pad = int(min(image_w, image_h) * 0.08)
            if image_path and Path(image_path).exists():
                slide.shapes.add_picture(image_path, image_left + pad, image_top + pad, image_w - pad * 2, image_h - pad * 2)
            else:
                rect = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, image_left + pad, image_top + pad, image_w - pad * 2, image_h - pad * 2)
                rect.fill.solid()
                rect.fill.fore_color.rgb = RGBColor(0xF2, 0xF2, 0xF2)
                rect.line.color.rgb = RGBColor(0xD0, 0xD0, 0xD0)

    add_top5_table(left, bags_rows)
    add_top5_table(left + panel_w + gap, shoes_rows)


def apply_native_ops(prs: Presentation, ops: list[dict[str, Any]]) -> None:
    for item in ops:
        slide = prs.slides[int(item["slide"]) - 1]
        typ = item.get("type")
        if typ == "slide12_traffic_table":
            apply_slide12_table(slide, item)
        elif typ == "slide12_dual_line_lfl":
            apply_slide12_dual_line(slide, item)
        elif typ == "slide12_entry_mix":
            apply_slide12_entry_mix(slide, item)
        elif typ == "slide14_live_session_summary_table":
            apply_slide14_live_session_summary_table(slide, item)
        elif typ == "slide14_live_session_trend":
            apply_slide14_live_session_trend(slide, item)
        elif typ == "slide14_goods_category_mix":
            apply_slide14_goods_category_mix(slide, item)
        elif typ == "slide15_goods_summary_table":
            apply_slide15_goods_summary_table(slide, item)
        elif typ == "slide15_top5_tables":
            apply_slide15_top5_tables(slide, item)
        else:
            raise RuntimeError(f"Unsupported native op type in openxml fallback: {typ}")


def main() -> None:
    args = parse_args()
    template = Path(args.template).resolve()
    output = Path(args.output).resolve()
    ops_path = Path(args.operations).resolve()

    if not template.exists():
        raise SystemExit(f"Template not found: {template}")
    if not ops_path.exists():
        raise SystemExit(f"Operations not found: {ops_path}")

    if template != output:
        output.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(template, output)

    ops = json.loads(ops_path.read_text(encoding="utf-8"))
    text_ops = list(ops.get("replace_text") or [])
    native_ops = list(ops.get("replace_native_charts") or [])
    if not text_ops and not native_ops:
        output.parent.mkdir(parents=True, exist_ok=True)
        if template != output:
            print(str(output))
        else:
            print(str(template))
        return

    prs = Presentation(str(output))
    apply_text_ops(prs, text_ops)
    apply_native_ops(prs, native_ops)
    tmp_out = output.with_name(output.stem + ".tmp-render.pptx")
    prs.save(str(tmp_out))
    final_out = output
    try:
        if output.exists():
            output.unlink()
        tmp_out.replace(output)
    except PermissionError:
        final_out = output.with_name(output.stem + ".rendered.pptx")
        if final_out.exists():
            final_out.unlink()
        tmp_out.replace(final_out)
    print(str(final_out))


if __name__ == "__main__":
    main()
