#!/usr/bin/env python3
"""
PPT Master - Video Motion Plan

Derive an effect-aware video motion plan from one resolved SVG-to-PPTX
conversion trace. The plan preserves animation order, direction, duration,
and timing anchors while adding deterministic video-only motion parameters.

Usage:
    python3 scripts/video_motion_plan.py <conversion_trace.json> [options]

Examples:
    python3 scripts/video_motion_plan.py validation/deck.trace.json --force
    python3 scripts/video_motion_plan.py validation/deck.trace.json \
        --style dynamic -o validation/video_motion_plan.json --force

Dependencies:
    None (standard library only)

See scripts/docs/video-motion-plan.md for the downstream renderer contract.
"""

from __future__ import annotations

import argparse
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET

_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from console_encoding import configure_utf8_stdio  # noqa: E402

configure_utf8_stdio()


VIDEO_MOTION_SCHEMA = "ppt-master.video-motion-plan.v1"
VIDEO_MOTION_STYLES = ("adaptive", "restrained", "dynamic")
_STYLE_MULTIPLIERS = {
    "adaptive": 1.0,
    "restrained": 0.72,
    "dynamic": 1.28,
}
_VIDEO_EFFECT_ALIASES = {
    "entrance_appear": "appear",
    "entrance_fade": "fade",
    "entrance_fly": "fly",
    "entrance_zoom": "zoom",
    "entrance_wipe": "wipe_down",
    "entrance_split": "split",
    "entrance_blinds": "blinds",
    "entrance_checkerboard": "checkerboard",
    "entrance_dissolve": "dissolve",
    "entrance_random_bars": "random_bars",
    "entrance_peek": "wipe_up",
    "entrance_wheel": "wheel",
    "entrance_box": "box",
    "entrance_circle": "circle",
    "entrance_diamond": "diamond",
    "entrance_plus": "plus",
    "entrance_strips": "strips",
    "entrance_wedge": "wedge",
    "entrance_stretch": "stretch",
    "entrance_expand": "expand",
    "entrance_swivel": "swivel",
    "entrance_ascend": "fly_top",
}
_SVG_NS = "http://www.w3.org/2000/svg"
_EMU_PER_PX = 9525
_NUMBER_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?")


def _read_json_object(path: Path) -> dict[str, Any]:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise ValueError(f"conversion trace not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise ValueError(f"invalid conversion trace JSON: {path}: {exc}") from exc
    if not isinstance(value, dict):
        raise ValueError(f"conversion trace must be a JSON object: {path}")
    return value


def _positive_int(value: object, field: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
        raise ValueError(f"{field} must be a positive integer: {value!r}")
    return value


def _non_negative_int(value: object, field: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
        raise ValueError(f"{field} must be a non-negative integer: {value!r}")
    return value


def _finite_positive_float(value: object, field: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{field} must be a finite positive number: {value!r}")
    number = float(value)
    if not math.isfinite(number) or number <= 0:
        raise ValueError(f"{field} must be a finite positive number: {value!r}")
    return number


def _resolve_svg_path(raw: object, trace_path: Path) -> Path:
    if not isinstance(raw, str) or not raw.strip():
        raise ValueError("conversion trace slide is missing its SVG path")
    path = Path(raw)
    if path.is_absolute() and path.is_file():
        return path

    candidates = [Path.cwd() / path, trace_path.parent / path]
    candidates.extend(parent / path for parent in trace_path.parents)
    for candidate in candidates:
        if candidate.is_file():
            return candidate.resolve()
    raise ValueError(
        f"SVG referenced by conversion trace was not found: {raw}; "
        "run the command from the repository root or regenerate the trace"
    )


def _parse_canvas_emu(svg_path: Path) -> tuple[int, int]:
    try:
        root = ET.parse(svg_path).getroot()
    except (OSError, ET.ParseError) as exc:
        raise ValueError(f"cannot read SVG canvas: {svg_path}: {exc}") from exc

    values = [
        float(match)
        for match in _NUMBER_RE.findall(root.get("viewBox") or "")
    ]
    if len(values) != 4 or values[2] <= 0 or values[3] <= 0:
        raise ValueError(f"SVG must have a positive four-number viewBox: {svg_path}")
    return (
        max(1, round(values[2] * _EMU_PER_PX)),
        max(1, round(values[3] * _EMU_PER_PX)),
    )


def _event_score(event: dict[str, Any]) -> int:
    score = 0
    if event.get("tag") == "g":
        score += 4
    if isinstance(event.get("id"), str) and event["id"].strip():
        score += 2
    if event.get("decision") == "native":
        score += 1
    return score


def _shape_events(slide: dict[str, Any]) -> dict[int, dict[str, Any]]:
    events = slide.get("events", [])
    if not isinstance(events, list):
        raise ValueError("conversion trace slide events must be a list")
    selected: dict[int, dict[str, Any]] = {}
    for raw_event in events:
        if not isinstance(raw_event, dict):
            continue
        shape_id = raw_event.get("shape_id")
        bounds = raw_event.get("bounds_emu")
        if (
            isinstance(shape_id, int)
            and shape_id > 0
            and isinstance(bounds, list)
            and len(bounds) == 4
            and all(isinstance(value, int) for value in bounds)
        ):
            current = selected.get(shape_id)
            if current is None or _event_score(raw_event) >= _event_score(current):
                selected[shape_id] = raw_event
    return selected


def _direction_for_effect(
    effect: str,
    filter_name: object,
    effect_options: object,
) -> str | None:
    if isinstance(effect_options, dict):
        raw_direction = effect_options.get("direction")
        if isinstance(raw_direction, str) and raw_direction:
            return raw_direction.replace("_", "-")
    effect = _VIDEO_EFFECT_ALIASES.get(effect, effect)
    explicit = {
        "fly": "down",
        "fly_left": "left",
        "fly_right": "right",
        "fly_top": "up",
        "cut": "left",
        "wipe": "left",
        "wipe_left": "left",
        "wipe_right": "right",
        "wipe_up": "up",
        "wipe_down": "down",
        "peek": "down",
    }
    if effect in explicit:
        return explicit[effect]
    if isinstance(filter_name, str):
        match = re.search(
            r"\((?:from)?(TopLeft|TopRight|BottomLeft|BottomRight|"
            r"UpLeft|UpRight|DownLeft|DownRight|Top|Bottom|Left|Right|Up|Down)\)",
            filter_name,
            re.IGNORECASE,
        )
        if match:
            value = match.group(1).lower()
            return {
                "top": "up",
                "bottom": "down",
                "topleft": "up-left",
                "topright": "up-right",
                "bottomleft": "down-left",
                "bottomright": "down-right",
                "upleft": "up-left",
                "upright": "up-right",
                "downleft": "down-left",
                "downright": "down-right",
            }.get(value, value)
    return None


def _area_ratio(bounds: list[int], canvas_emu: tuple[int, int]) -> float:
    width = max(0, bounds[2] - bounds[0])
    height = max(0, bounds[3] - bounds[1])
    canvas_area = canvas_emu[0] * canvas_emu[1]
    return (width * height / canvas_area) if canvas_area else 0.0


def _adaptive_multiplier(
    style: str,
    page_role: object,
    object_count: int,
    area_ratio: float,
) -> float:
    multiplier = _STYLE_MULTIPLIERS[style]
    if style == "adaptive":
        if page_role in {"cover", "hero", "closing", "section"}:
            multiplier *= 1.08
        if object_count >= 5:
            multiplier *= 0.82
        elif object_count == 1:
            multiplier *= 1.06
        if area_ratio >= 0.42:
            multiplier *= 0.78
        elif 0 < area_ratio <= 0.10:
            multiplier *= 1.08
    return max(0.55, min(1.45, multiplier))


def _travel_vector(direction: str | None, magnitude: float) -> list[float]:
    vectors = {
        "left": [-magnitude, 0.0],
        "right": [magnitude, 0.0],
        "up": [0.0, -magnitude],
        "down": [0.0, magnitude],
        "up-left": [-magnitude * 0.72, -magnitude * 0.72],
        "up-right": [magnitude * 0.72, -magnitude * 0.72],
        "down-left": [-magnitude * 0.72, magnitude * 0.72],
        "down-right": [magnitude * 0.72, magnitude * 0.72],
    }
    return [round(value, 4) for value in vectors.get(direction, [0.0, 0.0])]


def _video_effect(
    effect: str,
    direction: str | None,
    multiplier: float,
) -> dict[str, Any]:
    effect = _VIDEO_EFFECT_ALIASES.get(effect, effect)
    common: dict[str, Any] = {
        "easing": "ease_out_cubic",
        "opacity_from": 0.0,
        "scale_from": 1.0,
        "travel_canvas_ratio": [0.0, 0.0],
        "blur_px": 0.0,
        "overshoot": 0.0,
        "mask_feather_px": 0.0,
        "motion_blur": 0.0,
    }

    if effect == "appear":
        common.update({
            "family": "hard_reveal",
            "opacity_from": 1.0,
            "easing": "step_end",
        })
    elif effect == "fade":
        common.update({
            "family": "soft_fade",
            "scale_from": round(1.0 - 0.008 * multiplier, 4),
            "blur_px": round(3.0 * multiplier, 2),
        })
    elif effect == "dissolve":
        common.update({
            "family": "grain_dissolve",
            "scale_from": round(1.0 - 0.006 * multiplier, 4),
            "blur_px": round(2.0 * multiplier, 2),
            "grain": round(0.26 * multiplier, 3),
        })
    elif effect in {"fly", "fly_left", "fly_right", "fly_top", "cut"}:
        magnitude = 0.045 * multiplier
        common.update({
            "family": "directional_slide",
            "direction": direction,
            "travel_canvas_ratio": _travel_vector(direction, magnitude),
            "blur_px": round(4.5 * multiplier, 2),
            "overshoot": round(0.012 * multiplier, 4),
            "motion_blur": round(0.22 * multiplier, 3),
        })
    elif effect in {
        "wipe",
        "wipe_left",
        "wipe_right",
        "wipe_up",
        "wipe_down",
        "peek",
    }:
        common.update({
            "family": "soft_mask_reveal",
            "direction": direction,
            "travel_canvas_ratio": _travel_vector(
                direction,
                0.012 * multiplier,
            ),
            "mask_feather_px": round(18.0 * multiplier, 2),
            "blur_px": round(1.5 * multiplier, 2),
        })
    elif effect in {"zoom", "expand", "stretch"}:
        common.update({
            "family": "focus_scale",
            "scale_from": round(1.0 - 0.055 * multiplier, 4),
            "blur_px": round(3.5 * multiplier, 2),
            "overshoot": round(0.008 * multiplier, 4),
        })
    elif effect == "split":
        common.update({
            "family": "split_mask",
            "mask_axis": "vertical",
            "mask_feather_px": round(12.0 * multiplier, 2),
        })
    elif effect in {"box", "circle", "diamond", "plus"}:
        common.update({
            "family": "shape_mask",
            "pattern": effect,
            "scale_from": round(1.0 - 0.025 * multiplier, 4),
            "mask_feather_px": round(10.0 * multiplier, 2),
        })
    elif effect in {
        "blinds",
        "checkerboard",
        "random_bars",
        "strips",
        "wedge",
        "wheel",
    }:
        common.update({
            "family": "pattern_reveal",
            "pattern": effect,
            "mask_feather_px": round(8.0 * multiplier, 2),
        })
    elif effect == "swivel":
        common.update({
            "family": "soft_swivel",
            "scale_from": round(1.0 - 0.025 * multiplier, 4),
            "rotation_from_deg": round(-4.0 * multiplier, 2),
            "blur_px": round(3.0 * multiplier, 2),
        })
    else:
        raise ValueError(f"unsupported resolved animation effect for video: {effect}")
    return common


def _transition_plan(raw_motion: object) -> dict[str, Any]:
    if not isinstance(raw_motion, dict):
        return {
            "source_effect": None,
            "video_effect": "cut",
            "duration_ms": 0,
            "easing": "linear",
        }
    effect = raw_motion.get("effect")
    duration = raw_motion.get("duration_ms")
    if effect is None:
        video_effect = "cut"
    elif effect == "fade":
        video_effect = "crossfade"
    elif effect in {"push", "cover"}:
        video_effect = "directional_push"
    elif effect in {"wipe", "split", "strips"}:
        video_effect = f"soft_{effect}"
    else:
        video_effect = "adaptive_crossfade"
    return {
        "source_effect": effect,
        "video_effect": video_effect,
        "duration_ms": duration if isinstance(duration, int) else 0,
        "easing": "ease_in_out_cubic",
    }


def build_video_motion_plan(
    trace_path: str | Path,
    *,
    style: str = "adaptive",
    default_slide_duration: float = 5.0,
) -> dict[str, Any]:
    """Build one renderer-neutral, effect-aware video motion plan."""
    path = Path(trace_path).resolve()
    if style not in VIDEO_MOTION_STYLES:
        raise ValueError(
            f"unknown video motion style {style!r}; valid styles: "
            f"{', '.join(VIDEO_MOTION_STYLES)}"
        )
    default_duration_ms = round(
        _finite_positive_float(
            default_slide_duration,
            "default slide duration",
        )
        * 1000
    )
    trace = _read_json_object(path)
    raw_slides = trace.get("slides")
    if not isinstance(raw_slides, list) or not raw_slides:
        raise ValueError("conversion trace must contain a non-empty slides list")

    slides: list[dict[str, Any]] = []
    total_objects = 0
    enhanced_objects = 0
    for raw_slide in raw_slides:
        if not isinstance(raw_slide, dict):
            raise ValueError("conversion trace slide entries must be objects")
        slide_num = _positive_int(raw_slide.get("slide_num"), "slide_num")
        svg_path = _resolve_svg_path(raw_slide.get("svg"), path)
        canvas_emu = _parse_canvas_emu(svg_path)
        event_index = _shape_events(raw_slide)

        animation = raw_slide.get("animation", {})
        if not isinstance(animation, dict):
            raise ValueError(f"slide {slide_num} animation summary must be an object")
        rows = animation.get("rows", [])
        if not isinstance(rows, list):
            raise ValueError(f"slide {slide_num} animation rows must be a list")

        objects: list[dict[str, Any]] = []
        for order, raw_row in enumerate(rows, 1):
            if not isinstance(raw_row, dict):
                raise ValueError(f"slide {slide_num} animation row must be an object")
            shape_id = _positive_int(
                raw_row.get("shape_id"),
                f"slide {slide_num} animation shape_id",
            )
            trigger = raw_row.get("trigger")
            if trigger == "on-click":
                raise ValueError(
                    f"slide {slide_num} uses on-click animation; video motion "
                    "requires click-free after-previous or with-previous timing"
                )
            if trigger not in {"after-previous", "with-previous"}:
                raise ValueError(
                    f"slide {slide_num} has unsupported video trigger: {trigger!r}"
                )
            effect = raw_row.get("effect")
            if not isinstance(effect, str) or not effect:
                raise ValueError(
                    f"slide {slide_num} animation row has no resolved effect"
                )
            start_ms = _non_negative_int(
                raw_row.get("offset_ms"),
                f"slide {slide_num} animation offset_ms",
            )
            duration_ms = _positive_int(
                raw_row.get("duration_ms"),
                f"slide {slide_num} animation duration_ms",
            )
            playback_duration_ms = raw_row.get(
                "playback_duration_ms",
                duration_ms,
            )
            playback_duration_ms = _positive_int(
                playback_duration_ms,
                f"slide {slide_num} animation playback_duration_ms",
            )
            event = event_index.get(shape_id)
            if event is None:
                raise ValueError(
                    f"slide {slide_num} animation shape {shape_id} has no "
                    "conversion-trace bounds"
                )
            bounds = list(event["bounds_emu"])
            area_ratio = _area_ratio(bounds, canvas_emu)
            multiplier = _adaptive_multiplier(
                style,
                raw_slide.get("page_role"),
                len(rows),
                area_ratio,
            )
            direction = _direction_for_effect(
                effect,
                raw_row.get("filter_name"),
                raw_row.get("effect_options"),
            )
            video = _video_effect(effect, direction, multiplier)
            video["duration_ms"] = duration_ms

            group_id = event.get("id")
            if not isinstance(group_id, str) or not group_id.strip():
                group_id = f"shape-{shape_id}"
            objects.append({
                "group_id": group_id,
                "shape_id": shape_id,
                "order": order,
                "source_effect": effect,
                "trigger": trigger,
                "start_ms": start_ms,
                "duration_ms": duration_ms,
                "playback_duration_ms": playback_duration_ms,
                "effect_options": raw_row.get("effect_options", {}),
                "repeat_count": raw_row.get("repeat_count"),
                "repeat_duration_ms": raw_row.get("repeat_duration_ms"),
                "auto_reverse": raw_row.get("auto_reverse"),
                "bounds_emu": bounds,
                "area_ratio": round(area_ratio, 6),
                "video": video,
            })
            total_objects += 1
            if video["family"] != "hard_reveal":
                enhanced_objects += 1

        motion = raw_slide.get("motion")
        advance_after_ms = (
            motion.get("advance_after_ms")
            if isinstance(motion, dict)
            else None
        )
        content_end_ms = max(
            (
                item["start_ms"] + item["playback_duration_ms"]
                for item in objects
            ),
            default=0,
        )
        if isinstance(advance_after_ms, int) and advance_after_ms > 0:
            slide_duration_ms = max(advance_after_ms, content_end_ms)
            duration_source = "recorded-advance"
        else:
            slide_duration_ms = max(default_duration_ms, content_end_ms + 750)
            duration_source = "default-hold"

        slides.append({
            "slide_num": slide_num,
            "svg": str(svg_path),
            "page_role": raw_slide.get("page_role"),
            "canvas_emu": list(canvas_emu),
            "duration_ms": slide_duration_ms,
            "duration_source": duration_source,
            "transition": _transition_plan(motion),
            "objects": objects,
        })

    return {
        "schema": VIDEO_MOTION_SCHEMA,
        "source_trace": str(path),
        "source_pptx": trace.get("output"),
        "style": style,
        "locks": {
            "object_identity": True,
            "object_order": True,
            "semantic_direction": True,
            "timing_anchor": True,
            "source_effect": True,
        },
        "optimizer_scope": [
            "easing",
            "travel_distance",
            "opacity",
            "scale",
            "mask_feather",
            "blur",
            "motion_blur",
            "overshoot",
        ],
        "slide_count": len(slides),
        "object_count": total_objects,
        "enhanced_object_count": enhanced_objects,
        "slides": slides,
    }


def write_video_motion_plan(
    trace_path: str | Path,
    output_path: str | Path,
    *,
    style: str = "adaptive",
    default_slide_duration: float = 5.0,
    force: bool = False,
) -> Path:
    """Build and write one video motion plan."""
    output = Path(output_path)
    if output.exists() and not force:
        raise FileExistsError(
            f"output already exists: {output}; pass --force to overwrite"
        )
    plan = build_video_motion_plan(
        trace_path,
        style=style,
        default_slide_duration=default_slide_duration,
    )
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(
        json.dumps(plan, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    return output.resolve()


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Derive an effect-aware video motion plan from a resolved "
            "SVG-to-PPTX conversion trace."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "trace",
        help="Path to validation/<output_stem>.trace.json",
    )
    parser.add_argument(
        "-o",
        "--output",
        default=None,
        help="Output JSON path; default: <trace>.video-motion.json",
    )
    parser.add_argument(
        "--style",
        choices=VIDEO_MOTION_STYLES,
        default="adaptive",
        help="Video-only enhancement intensity; default: adaptive",
    )
    parser.add_argument(
        "--default-slide-duration",
        type=float,
        default=5.0,
        help="Fallback seconds for slides without recorded advance timing",
    )
    parser.add_argument(
        "--force",
        action="store_true",
        help="Overwrite an existing output plan",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    trace_path = Path(args.trace)
    output_path = (
        Path(args.output)
        if args.output
        else trace_path.with_suffix(".video-motion.json")
    )
    try:
        written = write_video_motion_plan(
            trace_path,
            output_path,
            style=args.style,
            default_slide_duration=args.default_slide_duration,
            force=args.force,
        )
    except (FileExistsError, ValueError) as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    plan = _read_json_object(written)
    print(written)
    print(
        f"Slides: {plan['slide_count']}; objects: {plan['object_count']}; "
        f"video-enhanced: {plan['enhanced_object_count']}",
        file=sys.stderr,
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
