#!/usr/bin/env python3
"""
Validate a .pptx file for PowerPoint compatibility.

Checks for issues that LibreOffice tolerates but PowerPoint rejects:
  - Negative shape dimensions (cx, cy ≤ 0) in <a:ext> elements
  - Negative shape positions (x, y < 0) in <a:off> elements
  - Missing required OOXML parts ([Content_Types].xml, presentation.xml, etc.)
  - Malformed XML in any part
  - Broken relationship references (rId in slide XML not found in .rels)
  - Missing Content_Type overrides for slides

Usage:
    python3 validate_pptx_compat.py <file.pptx>

Exit codes:
    0 = all checks passed
    1 = issues found (printed to stderr)
"""

import sys
import zipfile
import xml.etree.ElementTree as ET

NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"
NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
NS_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"


def validate(pptx_path: str) -> list[str]:
    """Return a list of issue strings; empty list means clean."""
    issues = []

    with zipfile.ZipFile(pptx_path, "r") as z:
        # 1. Zip integrity
        bad = z.testzip()
        if bad:
            issues.append(f"BAD ZIP ENTRY: {bad}")

        # 2. XML parse check on all XML/rels parts
        for name in z.namelist():
            if name.endswith(".xml") or name.endswith(".rels"):
                try:
                    ET.parse(z.open(name))
                except ET.ParseError as e:
                    issues.append(f"XML PARSE ERROR in {name}: {e}")

        # 3. Required parts
        required = [
            "[Content_Types].xml",
            "_rels/.rels",
            "ppt/presentation.xml",
            "ppt/_rels/presentation.xml.rels",
            "ppt/theme/theme1.xml",
        ]
        for req in required:
            if req not in z.namelist():
                issues.append(f"MISSING REQUIRED PART: {req}")

        # 4. Slide rels existence
        slides = [
            f for f in z.namelist()
            if f.startswith("ppt/slides/slide") and f.endswith(".xml")
        ]
        for s in sorted(slides):
            rels = s.replace("ppt/slides/", "ppt/slides/_rels/") + ".rels"
            if rels not in z.namelist():
                issues.append(f"MISSING RELS for {s}")

        # 5. Content_Type overrides for slides
        ct = ET.parse(z.open("[Content_Types].xml"))
        ct_ns = {"ct": "http://schemas.openxmlformats.org/package/2006/content-types"}
        overrides = [
            o.get("PartName") for o in ct.findall(".//ct:Override", ct_ns)
        ]
        for s in sorted(slides):
            part_name = "/" + s
            if part_name not in overrides:
                issues.append(f"Missing Content_Type override for {s}")

        # 6. Negative dimensions and positions (THE key check)
        for i in range(1, len(slides) + 1):
            slide_path = f"ppt/slides/slide{i}.xml"
            if slide_path not in z.namelist():
                continue
            root = ET.parse(z.open(slide_path)).getroot()

            for sp in root.iter(f"{{{NS_P}}}sp"):
                spPr = sp.find(f".//{{{NS_P}}}spPr")
                if spPr is None:
                    continue

                # Get shape name for reporting
                cNvPr = sp.find(f".//{{{NS_P}}}cNvPr")
                name = cNvPr.get("name", "?") if cNvPr is not None else "?"

                xfrm = spPr.find(f"{{{NS_A}}}xfrm")
                if xfrm is None:
                    continue

                off = xfrm.find(f"{{{NS_A}}}off")
                ext = xfrm.find(f"{{{NS_A}}}ext")

                if ext is not None:
                    cx = int(ext.get("cx", "0"))
                    cy = int(ext.get("cy", "0"))
                    if cx <= 0 or cy <= 0:
                        issues.append(
                            f"slide{i}: shape \"{name}\" has zero/negative extent: "
                            f"cx={cx} cy={cy}"
                        )

                if off is not None:
                    x = int(off.get("x", "0"))
                    y = int(off.get("y", "0"))
                    if x < 0 or y < 0:
                        issues.append(
                            f"slide{i}: shape \"{name}\" has negative position: "
                            f"x={x} y={y}"
                        )

        # 7. Broken image embed references
        for i in range(1, len(slides) + 1):
            slide_path = f"ppt/slides/slide{i}.xml"
            if slide_path not in z.namelist():
                continue
            root = ET.parse(z.open(slide_path)).getroot()

            rels_path = f"ppt/slides/_rels/slide{i}.xml.rels"
            if rels_path not in z.namelist():
                continue
            rels_tree = ET.parse(z.open(rels_path))
            rels_root = rels_tree.getroot()
            valid_rids = {
                rel.get("Id") for rel in rels_root.findall(f"{NS_REL}Relationship")
            }

            for blip in root.iter(f"{{{NS_A}}}blip"):
                embed = blip.get(f"{{{NS_R}}}embed")
                if embed is not None and embed not in valid_rids:
                    issues.append(
                        f"slide{i}: blipFill embed '{embed}' not found in rels"
                    )

    return issues


def main():
    if len(sys.argv) != 2:
        print("Usage: validate_pptx_compat.py <file.pptx>", file=sys.stderr)
        sys.exit(2)

    pptx_path = sys.argv[1]
    try:
        issues = validate(pptx_path)
    except FileNotFoundError:
        print(f"File not found: {pptx_path}", file=sys.stderr)
        sys.exit(2)
    except zipfile.BadZipFile:
        print(f"Not a valid ZIP/PPTX file: {pptx_path}", file=sys.stderr)
        sys.exit(2)

    if issues:
        print(f"❌ {len(issues)} issue(s) found:", file=sys.stderr)
        for iss in issues:
            print(f"  • {iss}", file=sys.stderr)
        sys.exit(1)
    else:
        print(f"✅ {pptx_path}: all checks passed")
        sys.exit(0)


if __name__ == "__main__":
    main()
