#!/usr/bin/env python3
"""
Native OCI architecture diagrams inside PPTX slides.

This module patches an already-generated deck by appending native PowerPoint
shapes to one or more slides using XML-level edits. The current implementation
targets the architecture slide in OCI Deal Accelerator decks.
"""

import copy
import json
import posixpath
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from pathlib import PurePosixPath

from lxml import etree

try:
    from brand_icon_catalog import (
        brand_icon_media_name,
        brand_icon_png_bytes,
        brand_icon_size,
    )
except ImportError:
    from tools.brand_icon_catalog import (
        brand_icon_media_name,
        brand_icon_png_bytes,
        brand_icon_size,
    )


PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INDEX_PATH = PROJECT_ROOT / "kb" / "diagram" / "oci-pptx-icons-index.json"
DEFAULT_LIBRARY_PATH = PROJECT_ROOT / "kb" / "diagram" / "assets" / "OCI_Icons.pptx"
EMU_PER_INCH = 914400

P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {"p": P_NS, "a": A_NS, "r": R_NS}

SERVICE_COLORS = {
    "database": "AA643B",
    "data": "AA643B",
    "infrastructure": "2D5967",
    "networking": "2D5967",
    "network": "2D5967",
    "integration": "804998",
    "security": "2B6242",
    "observability": "2D5967",
    "management": "2D5967",
    "default": "2D5967",
}

TYPE_TO_ICON = {
    "adb": ["adb_d"],
    "adb_d": ["adb_d"],
    "adb_s": ["adb_d"],
    "adw": ["adw_d"],
    "adw_d": ["adw_d"],
    "atp_d": ["atp_d"],
    "database": ["database"],
    "database_system": ["database_system"],
    "base_db": ["database_system"],
    "base_database": ["database_system"],
    "data_guard": ["data_guard"],
    "data_safe": ["data_safe"],
    "database_management": ["database_management"],
    "load_balancer": ["load_balancer", "public_load_balancer"],
    "public_load_balancer": ["public_load_balancer", "load_balancer"],
    "lb": ["load_balancer", "public_load_balancer"],
    "virtual_machine": ["virtual_machine", "oci_compute"],
    "vm": ["virtual_machine", "oci_compute"],
    "compute": ["oci_compute", "virtual_machine"],
    "compute_instance": ["oci_compute", "virtual_machine"],
    "instance_pool": ["oci_compute", "virtual_machine"],
    "object_storage": ["object_storage"],
    "oci_object_storage": ["object_storage", "oci_object_storage"],
    "storage": ["object_storage"],
    "bucket": ["object_storage"],
    "internet_gateway": ["internet_gateway"],
    "igw": ["internet_gateway"],
    "service_gateway": ["service_gateway"],
    "sgw": ["service_gateway"],
    "functions": ["oci_functions", "functions"],
    "function": ["oci_functions", "functions"],
    "oci_functions": ["oci_functions", "functions"],
    "monitoring": ["monitoring"],
    "notifications": ["notifications"],
    "notification": ["notifications"],
    "dns": ["dns"],
    "oke": ["oci_container_engine_for_kubernetes", "container_engine_for_kubernetes_cluster"],
    "kubernetes": ["oci_container_engine_for_kubernetes", "container_engine_for_kubernetes_cluster"],
    "exadata": ["oracle_exadata_database_service"],
    "exacs": ["oracle_exadata_database_service"],
    "exadata_dedicated": ["oracle_exadata_database_service"],
    "oracle_exadata_database_service": ["oracle_exadata_database_service"],
    "drg": ["drg"],
    "dynamic_routing_gateway": ["drg"],
    "policies": ["policies"],
    "iam_policies": ["policies"],
    "security_list": ["security_lists", "route_table_and_security_list"],
    "security_lists": ["security_lists", "route_table_and_security_list"],
    "route_table": ["route_table_and_security_list"],
    "route_table_and_security_list": ["route_table_and_security_list", "security_lists"],
    "fastconnect": ["fastconnect"],
    "vpn": ["site_to_site_vpn", "site_to_site_vpn_or_fastconnect"],
    "site_to_site_vpn": ["site_to_site_vpn", "site_to_site_vpn_or_fastconnect"],
    "nat_gateway": ["nat_gateway"],
    "natgw": ["nat_gateway"],
    "block_volume": ["block_volume"],
    "file_storage": ["file_storage"],
    "vault": ["vault"],
    "bastion": ["bastion"],
    "web_application_firewall": ["web_application_firewall", "waf"],
    "waf": ["web_application_firewall", "waf"],
    "network_firewall": ["firewall"],
    "firewall": ["firewall"],
    "block_volume": ["block_storage"],
    "block_storage": ["block_storage"],
    "api_gateway": ["api_gateway"],
    "streaming": ["streaming"],
    "oci_streaming": ["oci_streaming", "streaming"],
    "queue": ["queue"],
    "events_service": ["events_service"],
    "vault_secrets": ["vault"],
    "cloud_guard": ["cloud_guard"],
    "security_zone": ["maximum_security_zone"],
    "maximum_security_zone": ["maximum_security_zone"],
    "data_catalog": ["data_catalog"],
    "data_integration": ["oci_data_integration", "integration"],
    "oci_data_integration": ["oci_data_integration", "integration"],
    "integration": ["integration"],
    "full_stack_disaster_recovery": ["full_stack_disaster_recovery"],
    "fsdr": ["full_stack_disaster_recovery"],
    "goldengate": ["goldengate"],
    "logging": ["logging"],
    "logging_analytics": ["logging_analytics"],
    "tag_namespace": ["tag_namespace"],
    "mysql": ["mysql_heatwave", "mysql_database_system", "mysql"],
    "mysql_heatwave": ["mysql_heatwave", "mysql_database_system", "mysql"],
    "heatwave": ["mysql_heatwave", "mysql_database_system", "mysql"],
    "mysql_database_service": ["mysql_heatwave", "mysql_database_system", "mysql"],
    "autonomous_database": ["oracle_autonomous_database", "adb_d"],
    "oracle_autonomous_database": ["oracle_autonomous_database", "adb_d"],
    "refreshable_clone": ["oracle_autonomous_database", "adb_d"],
    "adb_clone": ["oracle_autonomous_database", "adb_d"],
    "oci_goldengate": ["goldengate"],
    "ogg": ["goldengate"],
    "dynamic_routing_gateway": ["drg"],
    # Proactive aliases — natural-language slugs an SA / agent might
    # reach for, all mapped to the canonical icon. Adding ahead of
    # demand so a freshly-authored spec resolves on first try.
    "atp": ["atp_d"],
    "alb": ["load_balancer", "public_load_balancer"],
    "nlb": ["load_balancer", "public_load_balancer"],
    "apigw": ["api_gateway"],
    "lambda": ["oci_functions", "functions"],
    "kms": ["vault"],
    "secret": ["vault"],
    "secrets_manager": ["vault"],
    "kafka": ["oci_streaming", "streaming"],
    "alarm": ["alarms", "monitoring"],
    "alarms": ["alarms", "monitoring"],
    "email": ["email_delivery"],
    "email_delivery": ["email_delivery"],
    "identity": ["oracle_identity"],
    "identity_domains": ["oracle_identity"],
    "iam": ["oracle_identity"],
    "user_group": ["user_group"],
    "group": ["user_group"],
    "iac": ["resource_manager"],
    "terraform": ["resource_manager"],
    "oac": ["analytics"],
    "oracle_analytics_cloud": ["analytics"],
    "oic": ["integration", "oci_data_integration"],
    "oracle_integration_cloud": ["integration"],
    "process_automation": ["oci_process_automation"],
    "oracle_process_automation": ["oci_process_automation"],
    "language": ["oci_language"],
    "ai_language": ["oci_language"],
    "speech": ["oci_speech"],
    "ai_speech": ["oci_speech"],
    "vision": ["oci_vision"],
    "ai_vision": ["oci_vision"],
    "rpc": ["remote_peering"],
    "remote_peering_connection": ["remote_peering"],
    "remote_peering": ["remote_peering"],
    "lpg": ["local_peering_gateway"],
    "local_peering_gateway": ["local_peering_gateway"],
    "local_peering": ["local_peering_gateway"],
    "health_check": ["health_checks"],
    "health_checks": ["health_checks"],
    "cpe": ["customer_premises_equipment"],
    "customer_premises_equipment": ["customer_premises_equipment"],
    "tag": ["tag_namespace"],
    "tags": ["tag_namespace"],
    "compartment": ["compartment"],
    "compartments": ["compartment"],
    # Newer OCI services not yet in the OCI Toolkit v24.2 / OCI_Icons.pptx —
    # fall back to the generic ``database`` stencil with an explicit
    # ``label:`` carrying the real service name. Oracle's own ref archs
    # (e.g. modernize-app-dev-oci-postgresql-redis-opensearch) embed
    # custom inline SVGs because the toolkit lacks these too — using the
    # canonical generic icon is the closest standardized match.
    "redis": ["database"],
    "oci_cache": ["database"],
    "oci_cache_with_redis": ["database"],
    "cache": ["database"],
    "valkey": ["database"],
    "postgresql": ["database"],
    "postgres": ["database"],
    "oci_postgresql": ["database"],
    "opensearch": ["database_opensearch", "database"],
}

ICON_KEYWORD_HINTS = {
    "virtual machine": ["virtual_machine", "oci_compute"],
    "compute": ["oci_compute", "virtual_machine"],
    "load balancer": ["load_balancer", "public_load_balancer"],
    "object storage": ["object_storage"],
    "internet gateway": ["internet_gateway"],
    "service gateway": ["service_gateway"],
    "functions": ["oci_functions", "functions"],
    "monitoring": ["monitoring"],
    "notifications": ["notifications"],
    "data guard": ["data_guard"],
    "data safe": ["data_safe"],
    "adb": ["adb_d"],
    "adw": ["adw_d"],
    "atp": ["atp_d"],
    "database system": ["database_system"],
    "base database": ["database_system"],
    "exadata": ["oracle_exadata_database_service"],
    "exacs": ["oracle_exadata_database_service"],
    "exadata database service": ["oracle_exadata_database_service"],
    "exadata dedicated": ["oracle_exadata_database_service"],
    "oracle exadata": ["oracle_exadata_database_service"],
    "drg": ["drg"],
    "dynamic routing gateway": ["drg"],
    "policies": ["policies"],
    "policy": ["policies"],
    "security list": ["security_lists", "route_table_and_security_list"],
    "security lists": ["security_lists", "route_table_and_security_list"],
    "route table": ["route_table_and_security_list"],
    "fastconnect": ["fastconnect"],
    "vpn": ["site_to_site_vpn", "site_to_site_vpn_or_fastconnect"],
    "nat gateway": ["nat_gateway"],
    "block volume": ["block_volume"],
    "file storage": ["file_storage"],
    "bastion": ["bastion"],
    "cloud guard": ["cloud_guard"],
    "security zone": ["maximum_security_zone"],
    "vault": ["vault"],
    "api gateway": ["api_gateway"],
    "goldengate": ["goldengate"],
    "data catalog": ["data_catalog"],
    "data integration": ["oci_data_integration", "integration"],
    "streaming": ["oci_streaming", "streaming"],
    "full stack disaster recovery": ["full_stack_disaster_recovery"],
    "cloud guard": ["cloud_guard"],
    "logging": ["logging"],
    "data catalog": ["data_catalog"],
    "network firewall": ["network_firewall"],
    "web application firewall": ["web_application_firewall", "waf"],
    "oke": ["oci_container_engine_for_kubernetes", "container_engine_for_kubernetes_cluster"],
    "kubernetes": ["oci_container_engine_for_kubernetes", "container_engine_for_kubernetes_cluster"],
    "mysql": ["mysql", "database_mysql"],
    "mysql heatwave": ["mysql_heatwave", "mysql_database_system", "mysql"],
    "heatwave": ["mysql_heatwave", "mysql_database_system", "mysql"],
    "mysql database service": ["mysql_heatwave", "mysql_database_system", "mysql"],
}


def _emu(value_in: float) -> int:
    return int(round(value_in * EMU_PER_INCH))


def _slug(text: str) -> str:
    chars = []
    for char in text.lower():
        chars.append(char if char.isalnum() else "_")
    return "_".join(part for part in "".join(chars).split("_") if part)


class NativePPTXDiagramRenderer:
    def __init__(self, index_path: Path | str = DEFAULT_INDEX_PATH, library_path: Path | str = DEFAULT_LIBRARY_PATH):
        self.index_path = Path(index_path)
        self.library_path = Path(library_path)
        with open(self.index_path, "r", encoding="utf-8") as handle:
            self.index = json.load(handle)
        self.tokens = self.index.get("design_tokens", {})
        palette = self.tokens.get("palette", {})
        self.bark = palette.get("bark", "312D2A")
        self.bark_soft = palette.get("bark_soft", "66615C")
        self.white = palette.get("white", "FFFFFF")
        self.connector_style = self.tokens.get("connector_style", {})
        self.shape_library_entries = (self.index.get("shape_library") or {}).get("entries", {})
        self.sample_icon_refs = self._build_sample_icon_refs()
        self._source_rels_cache = {}
        self._current_work_dir = None
        self._current_slide_path = None

    def _build_sample_icon_refs(self) -> dict:
        sample_info = self.index.get("semantic_slides", {}).get("landscape_sample_db_dr")
        if not sample_info:
            return {}
        refs = {}
        with zipfile.ZipFile(self.library_path) as archive:
            root = etree.fromstring(archive.read(sample_info["slide_path"]))
            sp_tree = root.find(".//p:spTree", namespaces=NS)
            if sp_tree is None:
                return refs
            for child_index, child in enumerate(sp_tree):
                tag = etree.QName(child).localname
                if tag not in {"grpSp", "sp"}:
                    continue
                texts = [(node.text or "").strip() for node in child.findall(".//a:t", namespaces=NS) if (node.text or "").strip()]
                if not texts:
                    continue
                label = " ".join(texts)
                slug = _slug(label)
                c_nv_pr = child.find(".//p:cNvPr", namespaces=NS)
                bbox = self._extract_bbox(child)
                refs[slug] = {
                    "display_number": sample_info["display_number"],
                    "slide_path": sample_info["slide_path"],
                    "title": sample_info.get("title", ""),
                    "child_index": child_index,
                    "shape_id": int(c_nv_pr.get("id")) if c_nv_pr is not None and c_nv_pr.get("id", "").isdigit() else None,
                    "shape_name": c_nv_pr.get("name") if c_nv_pr is not None else "",
                    "text": " | ".join(texts),
                    "bbox": bbox,
                }
        return refs

    def apply_jobs(self, deck_path: str | Path, jobs: list[dict]):
        deck_path = Path(deck_path)
        if not jobs:
            return
        with tempfile.TemporaryDirectory(prefix="oci-pptx-native-") as tmp_dir:
            work_dir = Path(tmp_dir) / "deck"
            self._extract(deck_path, work_dir)
            for job in jobs:
                self._apply_job(work_dir, job)
            self._repack(work_dir, deck_path)

    def _extract(self, deck_path: Path, work_dir: Path):
        if work_dir.exists():
            shutil.rmtree(work_dir)
        work_dir.mkdir(parents=True, exist_ok=True)
        with zipfile.ZipFile(deck_path) as archive:
            archive.extractall(work_dir)

    def _repack(self, work_dir: Path, deck_path: Path):
        temp_output = deck_path.with_suffix(".tmp.pptx")
        if temp_output.exists():
            temp_output.unlink()
        with zipfile.ZipFile(temp_output, "w", zipfile.ZIP_DEFLATED) as archive:
            for path in sorted(work_dir.rglob("*")):
                if path.is_file():
                    archive.write(path, path.relative_to(work_dir))
        shutil.move(temp_output, deck_path)

    def _resolve_slide_path(self, work_dir: Path, display_number: int) -> Path:
        presentation_xml = work_dir / "ppt" / "presentation.xml"
        rels_xml = work_dir / "ppt" / "_rels" / "presentation.xml.rels"
        pres_root = etree.parse(str(presentation_xml)).getroot()
        rels_root = etree.parse(str(rels_xml)).getroot()
        slide_ids = pres_root.find("p:sldIdLst", namespaces=NS)
        if slide_ids is None:
            raise RuntimeError("presentation.xml has no slide list")
        slide_id = slide_ids.findall("p:sldId", namespaces=NS)[display_number - 1]
        rel_id = slide_id.get(f"{{{R_NS}}}id")
        for rel in rels_root:
            if rel.get("Id") == rel_id:
                return work_dir / "ppt" / rel.get("Target")
        raise RuntimeError(f"Could not resolve slide {display_number}")

    def _apply_job(self, work_dir: Path, job: dict):
        slide_path = self._resolve_slide_path(work_dir, job["slide_number"])
        self._current_work_dir = work_dir
        self._current_slide_path = slide_path
        slide_tree = etree.parse(str(slide_path))
        slide_root = slide_tree.getroot()
        sp_tree = slide_root.find(".//p:spTree", namespaces=NS)
        if sp_tree is None:
            raise RuntimeError(f"{slide_path} has no spTree")
        allocator = self._shape_id_allocator(slide_root)
        for element in self._render_job(job, allocator):
            sp_tree.append(element)
        slide_tree.write(str(slide_path), encoding="UTF-8", xml_declaration=True)
        etree.parse(str(slide_path))
        self._current_work_dir = None
        self._current_slide_path = None

    def _shape_id_allocator(self, slide_root):
        shape_ids = []
        for node in slide_root.findall(".//p:cNvPr", namespaces=NS):
            value = node.get("id")
            if value and value.isdigit():
                shape_ids.append(int(value))
        next_id = max(shape_ids or [1000]) + 1

        def allocate(count: int = 1) -> int:
            nonlocal next_id
            start = next_id
            next_id += count
            return start

        return allocate

    def _render_job(self, job: dict, allocate_id) -> list:
        visual = job.get("visual") or {}
        content = job.get("content_box", {})
        x = int(content["x"])
        y = int(content["y"])
        w = int(content["cx"])
        h = int(content["cy"])
        fragments = []
        service_positions = {}

        on_prem = visual.get("on_prem")
        regions = list(visual.get("regions") or [])
        if visual.get("absolute_layout"):
            # Spec-level geometry validation — same guard the drawio
            # path runs. Catches CONTAINER_TOO_THIN /
            # LABEL_OVERFLOW_PARENT before any XML is emitted.
            try:
                from diagram_spec_validator import validate_spec  # type: ignore
            except ImportError:
                from tools.diagram_spec_validator import validate_spec  # type: ignore
            validate_spec({"absolute_layout": visual["absolute_layout"]},
                          source="absolute_layout (pptx)")
            return self._render_absolute_layout(visual, content, allocate_id)
        if not regions:
            return []

        gap = _emu(0.18)
        region_top = y + _emu(0.05)
        region_height = h - _emu(0.18)

        on_prem_width = _emu(1.55) if on_prem else 0
        if on_prem:
            left_x = x
            left_y = y + region_height - _emu(0.60)
            fragments.append(self._fallback_service_card(
                allocate_id(),
                left_x,
                left_y,
                on_prem_width,
                _emu(0.48),
                on_prem.get("name", "On-Premises"),
                color="70665E",
            ))
            if on_prem.get("connection"):
                fragments.append(self._label_shape(
                    allocate_id(),
                    left_x + on_prem_width + _emu(0.08),
                    left_y + _emu(0.06),
                    _emu(1.1),
                    _emu(0.25),
                    on_prem["connection"],
                    font_size=850,
                    color=self.bark_soft,
                    bold=False,
                    align="l",
                ))

        regions_x = x + on_prem_width + (gap if on_prem else 0)
        regions_width = w - on_prem_width - (gap if on_prem else 0)
        region_frames = self._layout_regions(regions_x, region_top, regions_width, region_height, regions)
        for region, frame in zip(regions, region_frames):
            fragments.extend(self._render_region(region, frame, allocate_id, service_positions))

        for connection in visual.get("connections", []) or []:
            fragments.extend(self._render_connection(connection, service_positions, allocate_id))

        security = visual.get("security_footer")
        if security:
            fragments.append(self._label_shape(
                allocate_id(),
                x,
                y + h - _emu(0.10),
                w,
                _emu(0.25),
                security,
                font_size=760,
                color=self.bark_soft,
                bold=False,
                align="l",
            ))
        return fragments

    def _render_absolute_layout(self, visual: dict, content: dict, allocate_id) -> list:
        layout = visual.get("absolute_layout") or {}
        canvas = layout.get("canvas") or {}
        canvas_w = float(canvas.get("width", 850) or 850)
        canvas_h = float(canvas.get("height", 770) or 770)
        content_x = int(content["x"])
        content_y = int(content["y"])
        content_w = int(content["cx"])
        content_h = int(content["cy"])
        # Geometry scale: canvas px → content_box EMU.
        scale = min(content_w / canvas_w, content_h / canvas_h)
        offset_x = content_x + int((content_w - canvas_w * scale) / 2)
        offset_y = content_y + int((content_h - canvas_h * scale) / 2)

        def sx(value): return offset_x + int(round(float(value) * scale))
        def sy(value): return offset_y + int(round(float(value) * scale))
        def sl(value): return max(1, int(round(float(value) * scale)))

        fragments = []
        service_positions = {}

        for item in layout.get("containers") or []:
            kind = item.get("type", "compartment")
            fill = None
            stroke = "9E9892"
            dashed = False
            rounded = False
            width = 12700
            if kind == "region":
                fill = "F5F4F2"
                rounded = True
            elif kind == "ad":
                fill = "DFDCD8"
                rounded = True
            elif kind in {"vcn", "subnet"}:
                stroke = "AA643B"
                dashed = True
                width = 19050
            fragments.append(self._container_shape(
                allocate_id(), sx(item.get("x", 0)), sy(item.get("y", 0)),
                sl(item.get("w", 100)), sl(item.get("h", 80)),
                stroke_color=stroke, stroke_width=width, dashed=dashed,
                fill=fill, rounded=rounded,
            ))
            label = item.get("label")
            if label:
                color = "AA643B" if kind in {"vcn", "subnet"} else self.bark
                fragments.append(self._label_shape(
                    allocate_id(), sx(item.get("x", 0)) + sl(6), sy(item.get("y", 0)) + sl(4),
                    max(1, sl(item.get("w", 100)) - sl(12)), sl(26),
                    str(label).replace("\\n", "\n"), font_size=int(item.get("fontSize", 820)),
                    color=color, bold=bool(item.get("bold", True)), align=item.get("align", "l"),
                ))

        for item in layout.get("external") or []:
            item_id = item.get("id", _slug(item.get("label", "external")))
            item_x = sx(item.get("x", 0))
            item_y = sy(item.get("y", 0))
            item_w = sl(item.get("w", 70))
            item_h = sl(item.get("h", 52))
            item_label = str(item.get("label", "Users")).replace("\\n", "\n")
            brand_fragments = self._render_brand_icon(
                brand_icon=item.get("brand_icon"),
                x=item_x,
                y=item_y,
                w=item_w,
                h=item_h,
                allocate_id=allocate_id,
                service_positions=service_positions,
                service_id=item_id,
                label=item_label,
                font_size=item.get("fontSize"),
            )
            if brand_fragments:
                fragments.extend(brand_fragments)
                continue
            # Native PPTX currently has no user stencil in the OCI deck; use a
            # compact neutral card instead of importing non-OCI artwork.
            fragments.append(self._fallback_service_card(
                allocate_id(), item_x, item_y,
                item_w, item_h,
                item_label, color="2D5967",
            ))
            service_positions[item_id] = {
                "x": item_x,
                "y": item_y,
                "cx": item_w,
                "cy": item_h,
            }

        for item in layout.get("services") or []:
            x = sx(item.get("x", 0))
            y = sy(item.get("y", 0))
            w = sl(item.get("w", 100))
            h = sl(item.get("h", 80))
            fragments.extend(self._render_service(item, x, y, w, h, allocate_id, service_positions))

        for item in layout.get("labels") or []:
            fragments.append(self._label_shape(
                allocate_id(), sx(item.get("x", 0)), sy(item.get("y", 0)),
                sl(item.get("w", 100)), sl(item.get("h", 24)),
                str(item.get("text", "")).replace("\\n", "\n"),
                font_size=int(item.get("fontSize", 780)),
                color=item.get("color", self.bark),
                bold=bool(item.get("bold", False)),
                align=item.get("align", "ctr"),
            ))

        for conn in layout.get("connections") or []:
            source = service_positions.get(conn.get("from"))
            target = service_positions.get(conn.get("to"))
            if not source or not target:
                continue
            points = conn.get("points")
            if points:
                scaled = [(sx(p[0]), sy(p[1])) for p in points]
            else:
                scaled = [
                    (source["x"] + int(source["cx"] / 2), source["y"] + int(source["cy"] / 2)),
                    (target["x"] + int(target["cx"] / 2), target["y"] + int(target["cy"] / 2)),
                ]
            # Render the polyline as a SINGLE native bent connector
            # (OCI_Icons.pptx convention) instead of multiple disjoint
            # straight `line` shapes.
            bent = self._bent_connector_shape(
                allocate_id(), scaled, self.bark,
                width_emu=12700, dashed=bool(conn.get("dashed")), arrow=True,
            )
            if bent is not None:
                fragments.append(bent)
            if conn.get("flow_order"):
                bx = scaled[0][0] + int((scaled[-1][0] - scaled[0][0]) * float(conn.get("badge_t", 0.25))) - sl(9)
                by = scaled[0][1] + int((scaled[-1][1] - scaled[0][1]) * float(conn.get("badge_t", 0.25))) - sl(9)
                fragments.append(self._badge_shape(allocate_id(), bx, by, sl(18), str(conn["flow_order"])))
            if conn.get("label"):
                lx = sx(conn.get("label_x", canvas_w / 2))
                ly = sy(conn.get("label_y", canvas_h / 2))
                fragments.append(self._label_shape(
                    allocate_id(), lx, ly, sl(conn.get("label_w", 100)), sl(conn.get("label_h", 26)),
                    str(conn.get("label", "")), font_size=720, color=self.bark, bold=False, align="ctr",
                ))

        return fragments

    def _layout_regions(self, x: int, y: int, w: int, h: int, regions: list[dict]) -> list[dict]:
        if len(regions) == 1:
            return [{"x": x, "y": y, "cx": w, "cy": h}]
        if len(regions) == 2:
            gap = _emu(0.20)
            primary_w = int((w - gap) * 0.62)
            secondary_w = w - gap - primary_w
            return [
                {"x": x, "y": y, "cx": primary_w, "cy": h},
                {"x": x + primary_w + gap, "y": y, "cx": secondary_w, "cy": h},
            ]
        gap = _emu(0.16)
        total_gap = gap * (len(regions) - 1)
        width = int((w - total_gap) / len(regions))
        frames = []
        current = x
        for _ in regions:
            frames.append({"x": current, "y": y, "cx": width, "cy": h})
            current += width + gap
        return frames

    def _render_region(self, region: dict, frame: dict, allocate_id, service_positions: dict) -> list:
        fragments = []
        region_ref = self.index["stencils"]["groupings"]["oci_region"]
        fragments.append(self._clone_simple_shape(region_ref, lambda: allocate_id(), frame["x"], frame["y"], frame["cx"], frame["cy"], clear_text=True))
        label_parts = [region.get("name", "OCI Region")]
        if region.get("label"):
            label_parts.append(f"[{region['label']}]")
        fragments.append(self._label_shape(
            allocate_id(),
            frame["x"] + _emu(0.12),
            frame["y"] + _emu(0.06),
            frame["cx"] - _emu(0.24),
            _emu(0.22),
            "  ".join(label_parts),
            font_size=900,
            color=self.bark,
            bold=True,
            align="l",
        ))

        inner_x = frame["x"] + _emu(0.12)
        inner_y = frame["y"] + _emu(0.40)
        inner_w = frame["cx"] - _emu(0.24)
        inner_h = frame["cy"] - _emu(0.52)

        if region.get("availability_domains"):
            fragments.extend(self._render_availability_domains(region, inner_x, inner_y, inner_w, inner_h, allocate_id, service_positions))
        elif region.get("vcn"):
            fragments.extend(self._render_vcn(region["vcn"], inner_x, inner_y, inner_w, inner_h, allocate_id, service_positions))
        else:
            services = list(region.get("services") or [])
            fragments.extend(self._render_service_strip(services, inner_x, inner_y, inner_w, inner_h, allocate_id, service_positions))

        details = region.get("details")
        if details:
            fragments.append(self._label_shape(
                allocate_id(),
                inner_x + _emu(0.04),
                frame["y"] + frame["cy"] - _emu(0.28),
                inner_w - _emu(0.08),
                _emu(0.20),
                details,
                font_size=760,
                color=self.bark_soft,
                bold=False,
                align="ctr",
            ))
        return fragments

    def _render_availability_domains(self, region: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
        fragments = []
        ads = list(region.get("availability_domains") or [])
        gap = _emu(0.12)
        total_gap = gap * max(len(ads) - 1, 0)
        width = int((w - total_gap) / max(len(ads), 1))
        current_x = x
        for ad in ads:
            ad_ref = self.index["stencils"]["groupings"]["availability_domain"]
            fragments.append(self._clone_simple_shape(ad_ref, lambda: allocate_id(), current_x, y, width, h, clear_text=True))
            fragments.append(self._label_shape(
                allocate_id(),
                current_x + _emu(0.03),
                y + _emu(0.05),
                width - _emu(0.06),
                _emu(0.20),
                ad.get("name", "Availability Domain"),
                font_size=850,
                color=self.bark,
                bold=True,
                align="ctr",
            ))
            fragments.extend(self._render_vcn(ad.get("vcn", {}), current_x + _emu(0.04), y + _emu(0.28), width - _emu(0.08), h - _emu(0.34), allocate_id, service_positions))
            current_x += width + gap
        return fragments

    def _render_vcn(self, vcn: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
        fragments = []
        fragments.append(self._container_shape(
            allocate_id(),
            x, y, w, h,
            stroke_color="AE562C",
            stroke_width=19050,
            dashed=True,
            fill=None,
            rounded=False,
        ))
        vcn_label = vcn.get("name", "VCN")
        if vcn.get("cidr"):
            vcn_label = f"{vcn_label}  {vcn['cidr']}"
        fragments.append(self._label_shape(
            allocate_id(),
            x + _emu(0.10),
            y + _emu(0.06),
            w - _emu(0.20),
            _emu(0.20),
            vcn_label,
            font_size=840,
            color="AE562C",
            bold=True,
            align="l",
        ))

        subnets = list(vcn.get("subnets") or [])
        if not subnets:
            return fragments
        gap = _emu(0.10)
        subnet_y = y + _emu(0.32)
        available_h = h - _emu(0.40)
        subnet_h = int((available_h - gap * max(len(subnets) - 1, 0)) / max(len(subnets), 1))
        for subnet in subnets:
            fragments.append(self._container_shape(
                allocate_id(),
                x + _emu(0.10), subnet_y, w - _emu(0.20), subnet_h,
                stroke_color="AE562C",
                stroke_width=12700,
                dashed=True,
                fill="FCFBFA",
                rounded=False,
            ))
            subnet_label = subnet.get("name", "Subnet")
            if subnet.get("cidr"):
                subnet_label = f"{subnet_label}  {subnet['cidr']}"
            fragments.append(self._label_shape(
                allocate_id(),
                x + _emu(0.18),
                subnet_y + _emu(0.05),
                w - _emu(0.36),
                _emu(0.18),
                subnet_label,
                font_size=760,
                color="AE562C",
                bold=True,
                align="l",
            ))
            fragments.extend(self._render_service_strip(
                list(subnet.get("services") or []),
                x + _emu(0.16),
                subnet_y + _emu(0.24),
                w - _emu(0.32),
                subnet_h - _emu(0.28),
                allocate_id,
                service_positions,
            ))
            subnet_y += subnet_h + gap
        return fragments

    def _render_service_strip(self, services: list[dict], x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
        fragments = []
        if not services:
            return fragments
        count = len(services)
        gap = _emu(0.10)
        cols = count if count <= 4 else 4
        rows = (count + cols - 1) // cols
        cell_w = int((w - gap * max(cols - 1, 0)) / max(cols, 1))
        cell_h = int((h - gap * max(rows - 1, 0)) / max(rows, 1))
        for idx, service in enumerate(services):
            row = idx // cols
            col = idx % cols
            cell_x = x + col * (cell_w + gap)
            cell_y = y + row * (cell_h + gap)
            fragments.extend(self._render_service(service, cell_x, cell_y, cell_w, cell_h, allocate_id, service_positions))
        return fragments

    # AWS / GCP color codes — used by _render_service when a spec passes
    # `cloud_icon: <key>`. The drawio renderer uses native mxgraph shape
    # libraries (mxgraph.aws4.*), but those are drawio-only — PowerPoint
    # has no equivalent native stencil. The pragmatic PPTX fallback is a
    # provider-branded card (correct color + label) rendered at 72% of
    # the spec bbox to match the visual weight of OCI line-art icons.
    CLOUD_ICON_COLORS = {
        # AWS (mxgraph.aws4.* color taxonomy)
        "ec2": "ED7100", "eks": "ED7100", "ecs": "ED7100", "lambda": "ED7100",
        "rds": "C925D1", "aurora": "C925D1", "dynamodb": "C925D1",
        "s3": "3F8624",
        "elb": "8C4FFF", "alb": "8C4FFF", "cloudfront": "8C4FFF",
        "vpc": "8C4FFF", "direct_connect": "8C4FFF", "route53": "8C4FFF",
        "kinesis": "8C4FFF",
        "sqs": "E7157B", "sns": "E7157B", "api_gateway": "E7157B",
        "cloudwatch": "E7157B",
        "cognito": "DD344C", "iam": "DD344C",
        "sagemaker": "01A88D", "bedrock": "01A88D",
        # GCP
        "gce": "4285F4", "gke": "4285F4", "cloud_run": "4285F4",
        "bigquery": "669DF6", "cloud_storage": "AECBFA",
        "cloud_sql": "4285F4",
    }
    CLOUD_ICON_LABELS = {
        "ec2": "AWS EC2", "eks": "AWS EKS", "ecs": "AWS ECS", "lambda": "AWS Lambda",
        "rds": "AWS RDS", "aurora": "AWS Aurora", "dynamodb": "DynamoDB",
        "s3": "AWS S3",
        "elb": "AWS ELB", "alb": "AWS ALB", "cloudfront": "CloudFront",
        "vpc": "AWS VPC", "direct_connect": "Direct Connect", "route53": "Route 53",
        "sqs": "AWS SQS", "sns": "AWS SNS", "kinesis": "Kinesis",
        "api_gateway": "API Gateway", "cognito": "Cognito", "iam": "AWS IAM",
        "cloudwatch": "CloudWatch", "sagemaker": "SageMaker", "bedrock": "Bedrock",
        "gce": "GCE", "gke": "GKE", "cloud_run": "Cloud Run",
        "bigquery": "BigQuery", "cloud_storage": "Cloud Storage", "cloud_sql": "Cloud SQL",
    }

    def _render_service(self, service: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
        fragments = []
        service_id = service.get("id") or _slug(service.get("name", service.get("type", "service")))
        label = service.get("name") or service.get("label") or service.get("type", "Service")
        brand_fragments = self._render_brand_icon(
            brand_icon=service.get("brand_icon"),
            x=x,
            y=y,
            w=w,
            h=h,
            allocate_id=allocate_id,
            service_positions=service_positions,
            service_id=service_id,
            label=label,
            font_size=service.get("fontSize"),
        )
        if brand_fragments:
            return brand_fragments
        # Multi-cloud support: spec may carry `cloud_icon: ec2` etc. for
        # AWS/GCP components. Render as a provider-branded card scaled
        # to 72% of the bbox so it visually matches the OCI line-art
        # icons next to it (same rule as drawio renderer — Diego's
        # 2026-04-25 feedback "el tamaño del icono queda muy grande").
        cloud_icon = (service.get("cloud_icon") or "").lower()
        if cloud_icon:
            color = self.CLOUD_ICON_COLORS.get(cloud_icon, "ED7100")
            text = service.get("label") or self.CLOUD_ICON_LABELS.get(cloud_icon, cloud_icon.upper())
            visual_scale = 0.72
            rw = int(w * visual_scale)
            rh = int(h * visual_scale)
            rx = x + (w - rw) // 2
            ry = y + (h - rh) // 2
            fragments.append(self._fallback_service_card(
                allocate_id(), rx, ry, rw, rh, text, color=color,
            ))
            service_positions[service_id] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
            return fragments
        icon_ref = self._resolve_icon_ref(service)
        if icon_ref:
            # Icon fills the spec's full (w, h) bbox. The earlier
            # aspect-ratio-preserving logic ("fit within w/h") left
            # significant whitespace inside the bbox — Diego flagged
            # 2026-04-25 round 3: "Oracle Exadata lo veo muy chico en
            # el pptx" — and the workaround knobs (0.5 floor, 0.8 floor)
            # never closed the gap because the icon's natural aspect
            # ratio (~1.48 wide) rarely matches the spec's (e.g. 0.9).
            #
            # General rule (now baked in): the spec author owns the
            # bbox, so the icon stretches to fill it. Spec authors who
            # want a specific aspect should size their bbox accordingly.
            target_cx = int(w)
            target_cy = int(h)
            target_x = x + max(int((w - target_cx) / 2), 0)
            target_y = y + max(int((h - target_cy) / 2), 0)
            fragments.append(self._clone_translated_block(
                icon_ref, allocate_id, target_x, target_y,
                target_cx=target_cx, target_cy=target_cy,
            ))
            service_positions[service_id] = {
                "x": target_x,
                "y": target_y,
                "cx": target_cx,
                "cy": target_cy,
            }
            if service.get("caption"):
                fragments.append(self._label_shape(
                    allocate_id(),
                    x,
                    y + h - _emu(0.18),
                    w,
                    _emu(0.16),
                    service["caption"],
                    font_size=720,
                    color=self.bark_soft,
                    bold=False,
                    align="ctr",
                ))
            return fragments

        color = SERVICE_COLORS.get(service.get("category"), SERVICE_COLORS.get(service.get("type"), SERVICE_COLORS["default"]))
        fragments.append(self._fallback_service_card(allocate_id(), x + _emu(0.04), y + _emu(0.04), w - _emu(0.08), h - _emu(0.08), label, color=color))
        service_positions[service_id] = {
            "x": x + _emu(0.04),
            "y": y + _emu(0.04),
            "cx": w - _emu(0.08),
            "cy": h - _emu(0.08),
        }
        return fragments

    def _render_connection(self, connection: dict, service_positions: dict, allocate_id) -> list:
        source = service_positions.get(connection.get("from"))
        target = service_positions.get(connection.get("to"))
        if not source or not target:
            return []
        sx = source["x"] + int(source["cx"] / 2)
        sy = source["y"] + int(source["cy"] / 2)
        tx = target["x"] + int(target["cx"] / 2)
        ty = target["y"] + int(target["cy"] / 2)
        label = connection.get("label", "")
        dashed = bool(connection.get("style") == "dashed" or connection.get("dashed"))
        width_emu = int(self.connector_style.get("line_width_soft_emu" if dashed else "line_width_emu", 9525 if dashed else 12700))
        fragments = [self._arrow_shape(allocate_id(), sx, sy, tx, ty, self.bark, width_emu=width_emu, dashed=dashed)]
        if label:
            lx = min(sx, tx) + int(abs(tx - sx) / 2) - _emu(0.40)
            ly = min(sy, ty) + int(abs(ty - sy) / 2) - _emu(0.10)
            fragments.append(self._label_shape(
                allocate_id(),
                lx,
                ly,
                _emu(0.80),
                _emu(0.20),
                label,
                font_size=760,
                color=self.bark,
                bold=False,
                align="ctr",
            ))
        return fragments

    def _resolve_icon_ref(self, service: dict) -> dict | None:
        for key in self._candidate_icon_keys(service):
            ref = self._lookup_icon_ref(key)
            if ref:
                return ref
        return None

    def _candidate_icon_keys(self, service: dict) -> list[str]:
        candidates = []
        seen = set()

        def add(value: str | None):
            if not value:
                return
            if value not in seen:
                seen.add(value)
                candidates.append(value)

        service_type = _slug(service.get("type", ""))
        service_name = _slug(service.get("name", ""))
        for key in TYPE_TO_ICON.get(service_type, []):
            add(key)
        add(service_type)
        add(service_name)

        name_text = " ".join(service_name.split("_"))
        type_text = " ".join(service_type.split("_"))
        for phrase, keys in ICON_KEYWORD_HINTS.items():
            if phrase in name_text or phrase in type_text:
                for key in keys:
                    add(key)
        return candidates

    def _lookup_icon_ref(self, key: str) -> dict | None:
        # Stencil buckets sometimes ship a degraded entry — e.g.
        # database_icons['database'] is tag=sp with bbox=None, which
        # would clone as a zero-size shape (the "OCI PostgreSQL bbox
        # but no icon" symptom Diego hit on the GCP↔OCI deck). Skip
        # any ref that fails the viability check and fall through to
        # the next source (sample_icon_refs, then shape_library, where
        # a usable grpSp ref usually exists).
        database_icons = (self.index.get("stencils") or {}).get("database_icons") or {}
        candidate = database_icons.get(key)
        if candidate and self._is_viable_ref(candidate):
            return candidate
        sample = self.sample_icon_refs.get(key)
        if sample and self._is_viable_ref(sample):
            return sample
        refs = self.shape_library_entries.get(key) or []
        if refs:
            return self._select_preferred_ref(refs)
        return None

    @staticmethod
    def _is_viable_ref(ref: dict) -> bool:
        """A ref must point at a real cloneable shape (slide path,
        index/path-into-shape-tree, non-empty bbox, supported tag) for
        ``_clone_translated_block`` to emit visible geometry."""
        if not ref or not ref.get("slide_path"):
            return False
        if ref.get("child_index") is None and not ref.get("node_path"):
            return False
        bbox = ref.get("bbox") or {}
        if not bbox.get("cx") or not bbox.get("cy"):
            return False
        return ref.get("tag") in {"grpSp", "pic", "sp"}

    def _select_preferred_ref(self, refs: list[dict]) -> dict | None:
        viable = [ref for ref in refs if self._is_viable_ref(ref)]
        if not viable:
            return refs[0] if refs else None

        # OCI_Icons.pptx ships both a full icon group (tag=grpSp, ~0.7"
        # tall) and a tiny "label strip" (tag=sp, ~0.2" tall) under the
        # same slug for many services. The strip renders as just a text
        # label which makes the diagram look broken next to the drawio
        # version. Prefer the icon group whenever one exists.
        icon_shaped = [
            ref for ref in viable
            if ref.get("tag") in {"grpSp", "pic"}
            or (
                int((ref.get("bbox") or {}).get("cy", 0)) >= _emu(0.4)
                and int((ref.get("bbox") or {}).get("cx", 0)) >= _emu(0.4)
            )
        ]
        if icon_shaped:
            viable = icon_shaped

        preferred_slide_order = {
            29: 0,
            30: 1,
            31: 2,
            32: 3,
            34: 4,
            36: 5,
            37: 6,
        }
        tag_order = {"grpSp": 0, "pic": 1, "sp": 2}

        def score(ref: dict):
            bbox = ref.get("bbox") or {}
            cx = int(bbox.get("cx", 0))
            cy = int(bbox.get("cy", 0))
            return (
                1 if cx > _emu(2.25) or cy > _emu(1.25) else 0,
                preferred_slide_order.get(int(ref.get("display_number", 999)), 999),
                tag_order.get(ref.get("tag"), 9),
                abs(cx - _emu(0.82)) + abs(cy - _emu(0.62)),
            )

        return min(viable, key=score)

    def _extract_bbox(self, element) -> dict | None:
        xfrm = element.find(".//a:xfrm", namespaces=NS)
        if xfrm is None:
            return None
        off = xfrm.find("a:off", namespaces=NS)
        ext = xfrm.find("a:ext", namespaces=NS)
        if off is None or ext is None:
            return None
        return {
            "x": int(off.get("x", "0")),
            "y": int(off.get("y", "0")),
            "cx": int(ext.get("cx", "0")),
            "cy": int(ext.get("cy", "0")),
        }

    def _source_child(self, ref: dict):
        with zipfile.ZipFile(self.library_path) as archive:
            root = etree.fromstring(archive.read(ref["slide_path"]))
            sp_tree = root.find(".//p:spTree", namespaces=NS)
            path = list(ref.get("node_path") or [ref["child_index"]])
            child = sp_tree
            for index in path:
                child = list(child)[int(index)]
            return copy.deepcopy(child)

    def _clone_simple_shape(self, ref: dict, allocate_id, x: int, y: int, cx: int, cy: int, clear_text: bool = False):
        element = self._source_child(ref)
        self._assign_ids(element, allocate_id)
        xfrm = element.find(".//a:xfrm", namespaces=NS)
        if xfrm is not None:
            off = xfrm.find("a:off", namespaces=NS)
            ext = xfrm.find("a:ext", namespaces=NS)
            if off is not None:
                off.set("x", str(x))
                off.set("y", str(y))
            if ext is not None:
                ext.set("cx", str(cx))
                ext.set("cy", str(cy))
            ch_off = xfrm.find("a:chOff", namespaces=NS)
            ch_ext = xfrm.find("a:chExt", namespaces=NS)
            if ch_off is not None:
                ch_off.set("x", str(x))
                ch_off.set("y", str(y))
            if ch_ext is not None:
                ch_ext.set("cx", str(cx))
                ch_ext.set("cy", str(cy))
        if clear_text:
            for node in element.findall(".//a:t", namespaces=NS):
                node.text = ""
        return element

    def _clone_translated_block(self, ref: dict, allocate_id, target_x: int, target_y: int,
                                 target_cx: int | None = None, target_cy: int | None = None):
        """Clone an icon group from the OCI library and place it at
        (target_x, target_y). When target_cx/target_cy are provided,
        the wrapper's <a:ext> is resized to match — children keep their
        chExt-relative geometry, so PowerPoint scales them automatically.

        Without scaling, every cloned icon rendered at its NATURAL bbox
        from the library (e.g. 0.75 inch wide) regardless of the spec's
        w/h. On a wide canvas (1180 px → 0.83x scale), the icon was tiny
        relative to its surroundings; on a small canvas (627 px → 1.7x
        scale), the icon was undersized too. Diego flagged 2026-04-25:
        "el tamaño de los iconos parecieran chicos al menos en pptx".
        """
        element = self._source_child(ref)
        self._assign_ids(element, allocate_id)
        self._copy_media_dependencies(element, ref)
        bbox = ref.get("bbox") or {}
        dx = target_x - int(bbox.get("x", 0))
        dy = target_y - int(bbox.get("y", 0))
        for node in element.findall(".//a:off", namespaces=NS):
            node.set("x", str(int(node.get("x", "0")) + dx))
            node.set("y", str(int(node.get("y", "0")) + dy))
        for node in element.findall(".//a:chOff", namespaces=NS):
            node.set("x", str(int(node.get("x", "0")) + dx))
            node.set("y", str(int(node.get("y", "0")) + dy))
        if target_cx and target_cy:
            # Resize the wrapper extent — keep chExt unchanged so child
            # cells scale automatically via the group transform.
            xfrm = element.find(".//a:xfrm", namespaces=NS)
            if xfrm is not None:
                ext = xfrm.find("a:ext", namespaces=NS)
                if ext is not None:
                    ext.set("cx", str(int(target_cx)))
                    ext.set("cy", str(int(target_cy)))
        return element

    def _copy_media_dependencies(self, element, ref: dict):
        if self._current_work_dir is None or self._current_slide_path is None:
            return
        source_rel_map = self._source_slide_relationships(ref["slide_path"])
        rid_mapping = {}
        for node in element.xpath(".//*[@r:embed]", namespaces=NS):
            old_rid = node.get(f"{{{R_NS}}}embed")
            if not old_rid:
                continue
            new_rid = rid_mapping.get(old_rid)
            if not new_rid:
                target = source_rel_map.get(old_rid)
                if not target:
                    continue
                media_rel = self._copy_media_target(ref["slide_path"], target)
                new_rid = self._add_target_slide_relationship(media_rel)
                rid_mapping[old_rid] = new_rid
            node.set(f"{{{R_NS}}}embed", new_rid)

    def _source_slide_relationships(self, slide_path: str) -> dict:
        if slide_path in self._source_rels_cache:
            return self._source_rels_cache[slide_path]
        slide_name = Path(slide_path).name
        rels_path = f"ppt/slides/_rels/{slide_name}.rels"
        with zipfile.ZipFile(self.library_path) as archive:
            try:
                rels_root = etree.fromstring(archive.read(rels_path))
            except KeyError:
                self._source_rels_cache[slide_path] = {}
                return {}
        rel_map = {}
        for rel in rels_root.findall(f"{{{PKG_REL_NS}}}Relationship"):
            rel_map[rel.get("Id")] = rel.get("Target")
        self._source_rels_cache[slide_path] = rel_map
        return rel_map

    def _copy_media_target(self, source_slide_path: str, rel_target: str) -> str:
        source_slide_dir = PurePosixPath(source_slide_path).parent
        source_media_path = str((source_slide_dir / rel_target).as_posix())
        source_media_path = posixpath.normpath(source_media_path)
        while source_media_path.startswith("../"):
            source_media_path = source_media_path[3:]
        if not source_media_path.startswith("ppt/"):
            source_media_path = posixpath.normpath(f"ppt/{source_media_path}")

        with zipfile.ZipFile(self.library_path) as archive:
            media_bytes = archive.read(source_media_path)

        media_dir = self._current_work_dir / "ppt" / "media"
        media_dir.mkdir(parents=True, exist_ok=True)
        source_name = Path(source_media_path).name
        dest_path = media_dir / source_name
        if dest_path.exists() and dest_path.read_bytes() != media_bytes:
            stem = dest_path.stem
            suffix = dest_path.suffix
            counter = 1
            while True:
                candidate = media_dir / f"{stem}_{counter}{suffix}"
                if not candidate.exists() or candidate.read_bytes() == media_bytes:
                    dest_path = candidate
                    break
                counter += 1
        if not dest_path.exists():
            dest_path.write_bytes(media_bytes)
        # PowerPoint refuses to open the deck (and prompts to "repair" it) if
        # any embedded media extension is missing from [Content_Types].xml's
        # Default list. Some OCI_Icons.pptx icons are SVG-backed, so we must
        # ensure every extension we copy in is declared.
        self._ensure_content_type_default(dest_path.suffix.lstrip(".").lower())
        return f"../media/{dest_path.name}"

    _CONTENT_TYPE_BY_EXT = {
        "svg": "image/svg+xml",
        "png": "image/png",
        "jpg": "image/jpeg",
        "jpeg": "image/jpeg",
        "gif": "image/gif",
        "bmp": "image/bmp",
        "tiff": "image/tiff",
        "tif": "image/tiff",
        "emf": "image/x-emf",
        "wmf": "image/x-wmf",
        "ico": "image/x-icon",
        "webp": "image/webp",
    }

    def _ensure_content_type_default(self, extension: str) -> None:
        if not extension or self._current_work_dir is None:
            return
        content_type = self._CONTENT_TYPE_BY_EXT.get(extension)
        if not content_type:
            return
        ct_path = self._current_work_dir / "[Content_Types].xml"
        if not ct_path.exists():
            return
        ct_tree = etree.parse(str(ct_path))
        ct_root = ct_tree.getroot()
        ns = "http://schemas.openxmlformats.org/package/2006/content-types"
        for default in ct_root.findall(f"{{{ns}}}Default"):
            if (default.get("Extension") or "").lower() == extension:
                return
        new_default = etree.SubElement(ct_root, f"{{{ns}}}Default")
        new_default.set("Extension", extension)
        new_default.set("ContentType", content_type)
        ct_tree.write(str(ct_path), encoding="UTF-8", xml_declaration=True, standalone=True)

    def _add_target_slide_relationship(self, rel_target: str) -> str:
        rels_path = self._target_slide_rels_path()
        if rels_path.exists():
            rels_tree = etree.parse(str(rels_path))
            rels_root = rels_tree.getroot()
        else:
            rels_root = etree.Element(f"{{{PKG_REL_NS}}}Relationships")
            rels_tree = etree.ElementTree(rels_root)
        existing = []
        for rel in rels_root.findall(f"{{{PKG_REL_NS}}}Relationship"):
            rid = rel.get("Id", "")
            if rid.startswith("rId") and rid[3:].isdigit():
                existing.append(int(rid[3:]))
        new_rid = f"rId{max(existing or [0]) + 1}"
        rel = etree.SubElement(rels_root, f"{{{PKG_REL_NS}}}Relationship")
        rel.set("Id", new_rid)
        rel.set("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image")
        rel.set("Target", rel_target)
        rels_tree.write(str(rels_path), encoding="UTF-8", xml_declaration=True)
        return new_rid

    def _write_media_blob(self, file_name: str, data: bytes) -> str:
        if self._current_work_dir is None:
            raise RuntimeError("Cannot write media without an active PPTX work dir")
        media_dir = self._current_work_dir / "ppt" / "media"
        media_dir.mkdir(parents=True, exist_ok=True)
        dest_path = media_dir / Path(file_name).name
        if dest_path.exists() and dest_path.read_bytes() != data:
            stem = dest_path.stem
            suffix = dest_path.suffix
            counter = 1
            while True:
                candidate = media_dir / f"{stem}_{counter}{suffix}"
                if not candidate.exists() or candidate.read_bytes() == data:
                    dest_path = candidate
                    break
                counter += 1
        if not dest_path.exists():
            dest_path.write_bytes(data)
        self._ensure_content_type_default(dest_path.suffix.lstrip(".").lower())
        return f"../media/{dest_path.name}"

    def _target_slide_rels_path(self) -> Path:
        slide_name = self._current_slide_path.name
        rels_dir = self._current_slide_path.parent / "_rels"
        rels_dir.mkdir(parents=True, exist_ok=True)
        return rels_dir / f"{slide_name}.rels"

    def _assign_ids(self, element, allocate_id):
        for node in element.findall(".//p:cNvPr", namespaces=NS):
            node.set("id", str(allocate_id()))

    def _picture_shape(self, shape_id: int, rel_id: str, x: int, y: int, cx: int, cy: int,
                       name: str = "Brand Icon"):
        xml = f"""
        <p:pic xmlns:p="{P_NS}" xmlns:a="{A_NS}" xmlns:r="{R_NS}">
          <p:nvPicPr>
            <p:cNvPr id="{shape_id}" name="{name}"/>
            <p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
            <p:nvPr/>
          </p:nvPicPr>
          <p:blipFill>
            <a:blip r:embed="{rel_id}">
              <a:extLst>
                <a:ext uri="{{28A0092B-C50C-407E-A947-70E740481C1C}}">
                  <a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/>
                </a:ext>
              </a:extLst>
            </a:blip>
            <a:stretch><a:fillRect/></a:stretch>
          </p:blipFill>
          <p:spPr>
            <a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
          </p:spPr>
        </p:pic>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _render_brand_icon(self, brand_icon: str | None, x: int, y: int, w: int, h: int,
                           allocate_id, service_positions: dict, service_id: str,
                           label: str | None = None, font_size: int | None = None) -> list:
        if not brand_icon:
            return []
        png_bytes = brand_icon_png_bytes(brand_icon)
        size = brand_icon_size(brand_icon)
        if not png_bytes or not size:
            return []

        label_text = str(label or "").replace("\\n", "\n")
        label_font_size = int(font_size) if font_size is not None else 820
        n_lines = label_text.count("\n") + 1 if label_text else 0
        label_gap = _emu(0.03) if label_text else 0
        label_cy = 0
        if n_lines:
            line_height_pt = max(label_font_size / 100.0, 8.2) * 1.2
            label_cy = max(
                _emu(0.18),
                int(round((line_height_pt * n_lines / 72.0) * EMU_PER_INCH + _emu(0.03))),
            )
        icon_box_h = max(h - label_cy - label_gap, 1)
        natural_w, natural_h = size
        scale = min(w / max(natural_w, 1), icon_box_h / max(natural_h, 1))
        icon_cx = max(int(round(natural_w * scale)), 1)
        icon_cy = max(int(round(natural_h * scale)), 1)
        icon_x = x + max(int((w - icon_cx) / 2), 0)
        icon_y = y + max(int((icon_box_h - icon_cy) / 2), 0)

        media_target = self._write_media_blob(
            brand_icon_media_name(brand_icon, extension="png"),
            png_bytes,
        )
        picture = self._picture_shape(
            allocate_id(),
            self._add_target_slide_relationship(media_target),
            icon_x,
            icon_y,
            icon_cx,
            icon_cy,
        )
        service_positions[service_id] = {
            "x": icon_x,
            "y": icon_y,
            "cx": icon_cx,
            "cy": icon_cy,
        }
        fragments = [picture]
        if label_text:
            label_y = y + h - label_cy
            fragments.append(self._label_shape(
                allocate_id(),
                x,
                label_y,
                w,
                label_cy,
                label_text,
                font_size=label_font_size,
                color=self.bark,
                bold=True,
                align="ctr",
            ))
        return fragments

    def _fallback_service_card(self, shape_id: int, x: int, y: int, cx: int, cy: int, label: str, color: str):
        xml = f"""
        <p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
          <p:nvSpPr>
            <p:cNvPr id="{shape_id}" name="Service Card"/>
            <p:cNvSpPr txBox="1"/>
            <p:nvPr/>
          </p:nvSpPr>
          <p:spPr>
            <a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            <a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 1800"/></a:avLst></a:prstGeom>
            <a:solidFill><a:srgbClr val="FCFBFA"/></a:solidFill>
            <a:ln w="12700"><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:ln>
          </p:spPr>
          <p:txBody>
            <a:bodyPr wrap="square" anchor="ctr" lIns="45720" rIns="45720" tIns="12000" bIns="12000"/>
            <a:lstStyle/>
            <a:p>
              <a:pPr algn="ctr"/>
              <a:r>
                <a:rPr sz="820" b="1">
                  <a:solidFill><a:srgbClr val="{color}"/></a:solidFill>
                  <a:latin typeface="Oracle Sans"/>
                </a:rPr>
                <a:t>{self._escape_text(label)}</a:t>
              </a:r>
            </a:p>
          </p:txBody>
        </p:sp>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _container_shape(self, shape_id: int, x: int, y: int, cx: int, cy: int,
                         stroke_color: str, stroke_width: int = 12700,
                         dashed: bool = False, fill: str | None = None,
                         rounded: bool = False):
        fill_xml = f'<a:solidFill><a:srgbClr val="{fill}"/></a:solidFill>' if fill else "<a:noFill/>"
        dash_xml = "<a:prstDash val=\"dash\"/>" if dashed else "<a:prstDash val=\"solid\"/>"
        if rounded:
            geom = '<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 1800"/></a:avLst></a:prstGeom>'
        else:
            geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
        xml = f"""
        <p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
          <p:nvSpPr>
            <p:cNvPr id="{shape_id}" name="Container"/>
            <p:cNvSpPr/>
            <p:nvPr/>
          </p:nvSpPr>
          <p:spPr>
            <a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            {geom}
            {fill_xml}
            <a:ln w="{stroke_width}">
              <a:solidFill><a:srgbClr val="{stroke_color}"/></a:solidFill>
              {dash_xml}
            </a:ln>
          </p:spPr>
          <p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody>
        </p:sp>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _label_shape(self, shape_id: int, x: int, y: int, cx: int, cy: int, text: str,
                     font_size: int = 900, color: str = "312D2A", bold: bool = True,
                     align: str = "ctr", bg: str | None = None):
        fill = f'<a:solidFill><a:srgbClr val="{bg}"/></a:solidFill>' if bg else "<a:noFill/>"
        xml = f"""
        <p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
          <p:nvSpPr>
            <p:cNvPr id="{shape_id}" name="Label"/>
            <p:cNvSpPr txBox="1"/>
            <p:nvPr/>
          </p:nvSpPr>
          <p:spPr>
            <a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
            {fill}
            <a:ln><a:noFill/></a:ln>
          </p:spPr>
          <p:txBody>
            <a:bodyPr wrap="square" anchor="ctr" lIns="18000" rIns="18000" tIns="0" bIns="0"/>
            <a:lstStyle/>
            <a:p>
              <a:pPr algn="{align}"/>
              <a:r>
                <a:rPr sz="{font_size}" b="{1 if bold else 0}">
                  <a:solidFill><a:srgbClr val="{color}"/></a:solidFill>
                  <a:latin typeface="Oracle Sans"/>
                </a:rPr>
                <a:t>{self._escape_text(text)}</a:t>
              </a:r>
            </a:p>
          </p:txBody>
        </p:sp>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _arrow_shape(self, shape_id: int, x1: int, y1: int, x2: int, y2: int, color: str,
                     width_emu: int = 12700, dashed: bool = False, arrow: bool = True):
        x = min(x1, x2)
        y = min(y1, y2)
        cx = max(abs(x2 - x1), 1)
        cy = max(abs(y2 - y1), 1)
        flip_h = ' flipH="1"' if x2 < x1 else ""
        flip_v = ' flipV="1"' if y2 < y1 else ""
        dash = "<a:prstDash val=\"dash\"/>" if dashed else "<a:prstDash val=\"solid\"/>"
        arrow_xml = '<a:tailEnd type="triangle" w="sm" len="sm"/>' if arrow else ""
        xml = f"""
        <p:cxnSp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
          <p:nvCxnSpPr>
            <p:cNvPr id="{shape_id}" name="Connector"/>
            <p:cNvCxnSpPr/>
            <p:nvPr/>
          </p:nvCxnSpPr>
          <p:spPr>
            <a:xfrm{flip_h}{flip_v}><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            <a:prstGeom prst="line"><a:avLst/></a:prstGeom>
            <a:ln w="{width_emu}">
              <a:solidFill><a:srgbClr val="{color}"/></a:solidFill>
              {dash}
              {arrow_xml}
            </a:ln>
          </p:spPr>
        </p:cxnSp>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _bent_connector_shape(self, shape_id: int, points: list[tuple[int, int]],
                              color: str, width_emu: int = 12700,
                              dashed: bool = False, arrow: bool = True):
        """Render a polyline as a SINGLE OOXML bent connector.

        OCI_Icons.pptx (the template) uses ``bentConnector2/3/5`` for
        elbow paths, NOT a sequence of separate straight ``line``
        shapes. The latter renders as disjoint segments at the bends —
        what Diego flagged: "la distribucion/forma de las flechas me
        gusta mas como quedo en drawio que en pptx" (2026-04-25).

        We use:
          • 2 points (1 segment)  → straightConnector1 (single line)
          • 3 points (1 elbow)    → bentConnector2 (1 bend)
          • 4 points (2 elbows)   → bentConnector3 (2 bends, S-shape)
          • 5+ points             → bentConnector5 (4 bends)

        ``flipH``/``flipV`` are derived from the path's overall
        direction so the bent connector lays out source→target the way
        the spec authored it.
        """
        if len(points) < 2:
            return None
        x_first, y_first = points[0]
        x_last, y_last = points[-1]
        xs = [p[0] for p in points]
        ys = [p[1] for p in points]
        x = min(xs)
        y = min(ys)
        cx = max(max(xs) - x, 1)
        cy = max(max(ys) - y, 1)
        flip_h = ' flipH="1"' if x_last < x_first else ""
        flip_v = ' flipV="1"' if y_last < y_first else ""
        n = len(points)
        if n == 2:
            prst = "straightConnector1"
        elif n == 3:
            prst = "bentConnector2"
        elif n == 4:
            prst = "bentConnector3"
        else:
            prst = "bentConnector5"
        dash = "<a:prstDash val=\"dash\"/>" if dashed else "<a:prstDash val=\"solid\"/>"
        arrow_xml = '<a:tailEnd type="triangle" w="sm" len="sm"/>' if arrow else ""
        xml = f"""
        <p:cxnSp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
          <p:nvCxnSpPr>
            <p:cNvPr id="{shape_id}" name="Connector"/>
            <p:cNvCxnSpPr/>
            <p:nvPr/>
          </p:nvCxnSpPr>
          <p:spPr>
            <a:xfrm{flip_h}{flip_v}><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            <a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>
            <a:ln w="{width_emu}">
              <a:solidFill><a:srgbClr val="{color}"/></a:solidFill>
              {dash}
              {arrow_xml}
            </a:ln>
          </p:spPr>
        </p:cxnSp>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _badge_shape(self, shape_id: int, x: int, y: int, size: int, text: str):
        xml = f"""
        <p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
          <p:nvSpPr><p:cNvPr id="{shape_id}" name="Flow Badge"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
          <p:spPr>
            <a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{size}" cy="{size}"/></a:xfrm>
            <a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>
            <a:solidFill><a:srgbClr val="70665E"/></a:solidFill>
            <a:ln><a:noFill/></a:ln>
          </p:spPr>
          <p:txBody>
            <a:bodyPr wrap="square" anchor="ctr" lIns="0" rIns="0" tIns="0" bIns="0"/>
            <a:lstStyle/>
            <a:p><a:pPr algn="ctr"/><a:r><a:rPr sz="720" b="1">
              <a:solidFill><a:srgbClr val="FCFBFA"/></a:solidFill>
              <a:latin typeface="Oracle Sans"/>
            </a:rPr><a:t>{self._escape_text(text)}</a:t></a:r></a:p>
          </p:txBody>
        </p:sp>
        """
        return etree.fromstring(xml.encode("utf-8"))

    def _escape_text(self, text: str) -> str:
        return (
            text.replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
        )
