#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from zipfile import ZipFile


NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
DECK_FILES = {
    "dimensions": "Dimensions.pptx",
    "dave-dot-v2": "Dave Dot v.2.pptx",
    "dave-dot-v3": "Dave Dot v.3.pptx",
    "dave-dot-v4": "Dave Dot v.4.pptx",
    "dave-dot-show": "Dave Dot v.pptx",
    "dave-dot-update": "Dave Dot Up-Date.pptx",
    "dave-dot-logo": "Dave Dot O.LOGO.pptx",
    "dave-dot-tbd": "Dave Dot tbd.pptx",
    "dave-dot": "Dave Dot.pptx",
    "homework-site": "Welcome to David’s Homework Site.pptx",
    "index": "index.pptx",
}


def relationship_map(archive: ZipFile, name: str) -> dict[str, dict[str, str | None]]:
    if name not in archive.namelist():
        return {}
    root = ET.fromstring(archive.read(name))
    return {
        item.get("Id", ""): {
            "target": item.get("Target"),
            "mode": item.get("TargetMode"),
            "type": (item.get("Type") or "").rsplit("/", 1)[-1],
        }
        for item in root.findall(f"{{{REL_NS}}}Relationship")
    }


def shape_bounds(shape: ET.Element, slide_width: int, slide_height: int) -> dict[str, float] | None:
    transform = shape.find("./p:spPr/a:xfrm", NS) or shape.find("./p:xfrm", NS)
    if transform is None:
        transform = shape.find(".//a:xfrm", NS)
    if transform is None:
        return None
    offset = transform.find("a:off", NS)
    extent = transform.find("a:ext", NS)
    if offset is None or extent is None:
        return None
    x = int(offset.get("x", "0"))
    y = int(offset.get("y", "0"))
    width = int(extent.get("cx", "0"))
    height = int(extent.get("cy", "0"))
    if width <= 0 or height <= 0:
        return None
    return {
        "x": round(x / slide_width * 100, 4),
        "y": round(y / slide_height * 100, 4),
        "width": round(width / slide_width * 100, 4),
        "height": round(height / slide_height * 100, 4),
    }


def animation_effects(
    slide: ET.Element, slide_width: int, slide_height: int
) -> list[dict[str, object]]:
    parent_map = {child: parent for parent in slide.iter() for child in parent}
    shape_map: dict[str, ET.Element] = {}
    for shape in (
        slide.findall(".//p:sp", NS)
        + slide.findall(".//p:pic", NS)
        + slide.findall(".//p:graphicFrame", NS)
        + slide.findall(".//p:cxnSp", NS)
        + slide.findall(".//p:grpSp", NS)
    ):
        properties = shape.find(".//p:cNvPr", NS)
        if properties is not None and properties.get("id"):
            shape_map[properties.get("id", "")] = shape

    effects: list[dict[str, object]] = []
    for node in slide.findall(".//p:cTn", NS):
        if node.get("nodeType") != "afterEffect":
            continue
        target = node.find(".//p:spTgt", NS)
        if target is None:
            continue
        shape = shape_map.get(target.get("spid", ""))
        bounds = shape_bounds(shape, slide_width, slide_height) if shape is not None else None
        if bounds is None:
            continue

        preset_class = node.get("presetClass", "")
        animated = node.find(".//p:animEffect", NS)
        if preset_class == "path" or node.find(".//p:animMotion", NS) is not None:
            effect = "motion"
        elif preset_class == "exit" or (
            animated is not None and animated.get("transition") == "out"
        ):
            effect = "fade-out"
        elif animated is None:
            effect = "appear"
        else:
            effect = "fade-in"

        delay = 0
        ancestor = parent_map.get(node)
        while ancestor is not None:
            if ancestor.tag == f"{{{NS['p']}}}cTn":
                for condition in ancestor.findall("./p:stCondLst/p:cond", NS):
                    value = condition.get("delay", "")
                    if value.isdigit() and int(value) > 0:
                        delay = int(value)
                        break
            if delay:
                break
            ancestor = parent_map.get(ancestor)

        durations = []
        for timing in node.findall(".//p:cTn", NS):
            value = timing.get("dur", "")
            if value.isdigit():
                durations.append(int(value))
        duration = max(durations or [650])
        text = " ".join((item.text or "").strip() for item in shape.findall(".//a:t", NS)).strip()
        payload: dict[str, object] = {
                **bounds,
                "effect": effect,
                "delay": delay,
                "duration": duration,
                "shapeId": target.get("spid"),
                "label": text[:160] or "Animated presentation object",
        }
        motion = node.find(".//p:animMotion", NS)
        if motion is not None:
            segments = re.findall(
                r"[ML]\s+([-+0-9.Ee]+)\s+([-+0-9.Ee]+)", motion.get("path", "")
            )
            if len(segments) >= 2:
                start_x, start_y = map(float, segments[0])
                end_x, end_y = map(float, segments[-1])
                payload["motionX"] = round((end_x - start_x) * 100, 4)
                payload["motionY"] = round((end_y - start_y) * 100, 4)
        effects.append(payload)
    return effects


def action_from_link(link: ET.Element, relationships: dict[str, dict[str, str | None]]) -> dict[str, object] | None:
    action = link.get("action") or ""
    relation_id = link.get(f"{{{NS['r']}}}id", "")
    relation = relationships.get(relation_id)
    if action.startswith("ppaction://hlinkshowjump?jump="):
        return {"kind": "show", "target": action.split("jump=", 1)[1]}
    if action == "ppaction://hlinksldjump" and relation:
        target = str(relation.get("target") or "")
        match = re.search(r"slide(\d+)\.xml", target)
        if match:
            return {"kind": "slide", "target": int(match.group(1))}
    if relation and relation.get("type") == "hyperlink":
        return {"kind": "external", "target": relation.get("target")}
    return None


def table_hotspots(
    frame: ET.Element,
    bounds: dict[str, float],
    relationships: dict[str, dict[str, str | None]],
) -> list[dict[str, object]]:
    columns = [int(item.get("w", "0")) for item in frame.findall(".//a:tblGrid/a:gridCol", NS)]
    rows = frame.findall(".//a:tr", NS)
    row_heights = [int(row.get("h", "0")) for row in rows]
    total_width = sum(columns)
    total_height = sum(row_heights)
    if total_width <= 0 or total_height <= 0:
        return []
    hotspots: list[dict[str, object]] = []
    consumed_height = 0
    for row, row_height in zip(rows, row_heights):
        consumed_width = 0
        for column_index, cell in enumerate(row.findall("./a:tc", NS)):
            if column_index >= len(columns):
                break
            column_width = columns[column_index]
            text = " ".join((node.text or "").strip() for node in cell.findall(".//a:t", NS)).strip()
            for link in cell.findall(".//a:hlinkClick", NS):
                action = action_from_link(link, relationships)
                if action is None:
                    continue
                hotspots.append(
                    {
                        "x": round(bounds["x"] + bounds["width"] * consumed_width / total_width, 4),
                        "y": round(bounds["y"] + bounds["height"] * consumed_height / total_height, 4),
                        "width": round(bounds["width"] * column_width / total_width, 4),
                        "height": round(bounds["height"] * row_height / total_height, 4),
                        **action,
                        "label": text[:160] or "Presentation table action",
                    }
                )
            consumed_width += column_width
        consumed_height += row_height
    return hotspots


def extract_deck(path: Path) -> dict[str, object]:
    with ZipFile(path) as archive:
        presentation = ET.fromstring(archive.read("ppt/presentation.xml"))
        slide_size = presentation.find("p:sldSz", NS)
        slide_width = int(slide_size.get("cx", "9144000"))
        slide_height = int(slide_size.get("cy", "6858000"))
        slide_names = sorted(
            (name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
            key=lambda name: int(re.search(r"\d+", name).group()),
        )
        slides: dict[str, object] = {}
        for slide_number, slide_name in enumerate(slide_names, 1):
            slide = ET.fromstring(archive.read(slide_name))
            relationships = relationship_map(
                archive, f"ppt/slides/_rels/slide{slide_number}.xml.rels"
            )
            hotspots: list[dict[str, object]] = []
            candidates = (
                slide.findall(".//p:sp", NS)
                + slide.findall(".//p:pic", NS)
                + slide.findall(".//p:graphicFrame", NS)
                + slide.findall(".//p:cxnSp", NS)
            )
            for shape in candidates:
                bounds = shape_bounds(shape, slide_width, slide_height)
                if bounds is None:
                    continue
                if shape.tag == f"{{{NS['p']}}}graphicFrame":
                    hotspots.extend(table_hotspots(shape, bounds, relationships))
                    continue
                text = " ".join(
                    (node.text or "").strip() for node in shape.findall(".//a:t", NS)
                ).strip()
                for link in shape.findall(".//a:hlinkClick", NS):
                    action = action_from_link(link, relationships)
                    if action is None:
                        continue
                    hotspots.append(
                        {
                            **bounds,
                            **action,
                            "label": text[:160] or "Presentation action",
                        }
                    )
            transition = slide.find(".//p:transition", NS)
            has_timing = slide.find(".//p:timing", NS) is not None
            animations = animation_effects(slide, slide_width, slide_height) if has_timing else []
            if hotspots or transition is not None or has_timing:
                slides[str(slide_number)] = {
                    "hotspots": hotspots,
                    "transition": transition is not None,
                    "timing": has_timing,
                    "animations": animations,
                }
        return {
            "slideWidth": slide_width,
            "slideHeight": slide_height,
            "slides": slides,
        }


def main() -> None:
    parser = argparse.ArgumentParser(description="Extract DaveDot PowerPoint interactions from PPTX working copies")
    parser.add_argument("pptx_dir", type=Path)
    parser.add_argument("output", type=Path)
    args = parser.parse_args()
    payload = {
        "format": "davedot-presentation-interactions-v1",
        "decks": {
            deck_id: extract_deck(args.pptx_dir / filename)
            for deck_id, filename in DECK_FILES.items()
        },
    }
    args.output.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()
