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

import argparse
import json
import math
import re
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import List, Tuple, Optional

import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter

try:
    from moviepy.editor import VideoClip, concatenate_videoclips, AudioFileClip
except Exception as exc:  # pragma: no cover
    raise SystemExit(
        "MoviePy is required. Install dependencies with: pip install -r requirements.txt\n"
        f"Import error: {exc}"
    )


BG = (10, 14, 22)
BG2 = (16, 22, 34)
TEXT = (244, 247, 251)
MUTED = (144, 156, 178)
ACCENT = (112, 166, 255)
ACCENT_2 = (107, 228, 183)
CARD = (20, 28, 44)
CARD_2 = (28, 38, 58)
STROKE = (44, 58, 86)
SHADOW = (0, 0, 0, 110)


@dataclass
class Scene:
    scene: int
    start_s: float
    end_s: float
    on_screen_text: str
    voiceover: str
    visual: str

    @property
    def duration(self) -> float:
        return self.end_s - self.start_s


@dataclass
class Subtitle:
    start_s: float
    end_s: float
    text: str


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Render the SquareMCP launch video locally from the generated shot list and captions."
    )
    parser.add_argument("--shotlist", default="squaremcp_shotlist.json", help="Path to shot list JSON")
    parser.add_argument("--captions", default="squaremcp_launch_captions.srt", help="Path to captions SRT")
    parser.add_argument("--output", default="squaremcp_launch.mp4", help="Output MP4 path")
    parser.add_argument("--voiceover", default=None, help="Optional voiceover audio file")
    parser.add_argument("--assets-dir", default=None, help="Optional directory containing scene1.png ... scene7.png")
    parser.add_argument("--width", type=int, default=1080, help="Video width")
    parser.add_argument("--height", type=int, default=1920, help="Video height")
    parser.add_argument("--fps", type=int, default=24, help="Frames per second")
    parser.add_argument("--no-captions", action="store_true", help="Disable burned-in captions")
    parser.add_argument("--draft", action="store_true", help="Lower-resolution faster render")
    return parser.parse_args()


def load_scenes(path: Path) -> List[Scene]:
    data = json.loads(path.read_text(encoding="utf-8"))
    return [Scene(**item) for item in data]


_TIME_RE = re.compile(
    r"(?P<h>\d\d):(?P<m>\d\d):(?P<s>\d\d),(?P<ms>\d\d\d)\s+-->\s+"
    r"(?P<h2>\d\d):(?P<m2>\d\d):(?P<s2>\d\d),(?P<ms2>\d\d\d)"
)


def ts_to_seconds(h: str, m: str, s: str, ms: str) -> float:
    return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0


def parse_srt(path: Optional[Path]) -> List[Subtitle]:
    if not path or not path.exists():
        return []
    blocks = re.split(r"\n\s*\n", path.read_text(encoding="utf-8").strip())
    subtitles: List[Subtitle] = []
    for block in blocks:
        lines = [ln.rstrip() for ln in block.splitlines() if ln.strip()]
        if len(lines) < 2:
            continue
        timing_line = lines[1] if lines[0].strip().isdigit() else lines[0]
        match = _TIME_RE.match(timing_line)
        if not match:
            continue
        start = ts_to_seconds(match["h"], match["m"], match["s"], match["ms"])
        end = ts_to_seconds(match["h2"], match["m2"], match["s2"], match["ms2"])
        text_lines = lines[2:] if lines[0].strip().isdigit() else lines[1:]
        subtitles.append(Subtitle(start, end, "\n".join(text_lines)))
    return subtitles


def ease_in_out(x: float) -> float:
    x = max(0.0, min(1.0, x))
    return x * x * (3 - 2 * x)


def lerp(a: float, b: float, x: float) -> float:
    return a + (b - a) * x


def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    candidates = []
    if bold:
        candidates.extend([
            "DejaVuSans-Bold.ttf",
            "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
            "Arial Bold.ttf",
            "Arial.ttf",
        ])
    else:
        candidates.extend([
            "DejaVuSans.ttf",
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
            "Arial.ttf",
        ])
    for cand in candidates:
        try:
            return ImageFont.truetype(cand, size=size)
        except Exception:
            pass
    return ImageFont.load_default()


def text_size(draw: ImageDraw.ImageDraw, text: str, font) -> Tuple[int, int]:
    bbox = draw.multiline_textbbox((0, 0), text, font=font, spacing=10)
    return bbox[2] - bbox[0], bbox[3] - bbox[1]


def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> str:
    parts = re.split(r"\n+", text)
    wrapped_parts = []
    for part in parts:
        words = part.split()
        if not words:
            wrapped_parts.append("")
            continue
        lines = []
        current = words[0]
        for word in words[1:]:
            candidate = f"{current} {word}"
            w = draw.textbbox((0, 0), candidate, font=font)[2]
            if w <= max_width:
                current = candidate
            else:
                lines.append(current)
                current = word
        lines.append(current)
        wrapped_parts.append("\n".join(lines))
    return "\n".join(wrapped_parts)


def rounded_box(draw: ImageDraw.ImageDraw, box, fill, outline=None, radius=24, width=2):
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)


def add_shadow(base: Image.Image, box, radius=28):
    shadow = Image.new("RGBA", base.size, (0, 0, 0, 0))
    sd = ImageDraw.Draw(shadow)
    sd.rounded_rectangle(box, radius=28, fill=SHADOW)
    shadow = shadow.filter(ImageFilter.GaussianBlur(radius=radius))
    base.alpha_composite(shadow)


def draw_gradient_background(img: Image.Image, t: float):
    arr = np.zeros((img.height, img.width, 3), dtype=np.uint8)
    y = np.linspace(0, 1, img.height)[:, None]
    x = np.linspace(0, 1, img.width)[None, :]
    pulse = 0.5 + 0.5 * math.sin(t * 0.55)
    r = (BG[0] * (1 - y) + BG2[0] * y + 8 * pulse * x).clip(0, 255)
    g = (BG[1] * (1 - y) + BG2[1] * y + 10 * pulse * x).clip(0, 255)
    b = (BG[2] * (1 - y) + BG2[2] * y + 20 * pulse * x).clip(0, 255)
    arr[:, :, 0] = r.astype(np.uint8)
    arr[:, :, 1] = g.astype(np.uint8)
    arr[:, :, 2] = b.astype(np.uint8)
    bg = Image.fromarray(arr, mode="RGB").convert("RGBA")
    img.alpha_composite(bg)

    draw = ImageDraw.Draw(img)
    step = max(40, img.width // 18)
    grid_alpha = 30
    for xx in range(0, img.width, step):
        draw.line((xx, 0, xx, img.height), fill=(255, 255, 255, grid_alpha), width=1)
    for yy in range(0, img.height, step):
        draw.line((0, yy, img.width, yy), fill=(255, 255, 255, grid_alpha), width=1)


def paste_asset_background(img: Image.Image, asset_path: Optional[Path], t: float):
    if not asset_path or not asset_path.exists():
        return
    asset = Image.open(asset_path).convert("RGBA")
    scale = max(img.width / asset.width, img.height / asset.height) * 1.08
    new_size = (int(asset.width * scale), int(asset.height * scale))
    asset = asset.resize(new_size, Image.LANCZOS)
    pan_x = int((asset.width - img.width) * (0.04 + 0.02 * math.sin(t * 0.3)))
    pan_y = int((asset.height - img.height) * (0.04 + 0.02 * math.cos(t * 0.2)))
    asset = asset.crop((pan_x, pan_y, pan_x + img.width, pan_y + img.height))
    overlay = Image.new("RGBA", img.size, (5, 8, 14, 150))
    img.alpha_composite(asset)
    img.alpha_composite(overlay)


def draw_top_label(draw, width, height):
    font = get_font(max(18, width // 42), bold=True)
    label = "SQUAREMCP"
    pad_x = width * 0.08
    pad_y = height * 0.06
    tw = draw.textbbox((0, 0), label, font=font)[2]
    rounded_box(draw, (pad_x - 18, pad_y - 12, pad_x + tw + 18, pad_y + 42), fill=(18, 24, 38, 215), outline=(55, 75, 110), radius=18)
    draw.text((pad_x, pad_y), label, font=font, fill=TEXT)


def draw_main_text(draw, width, height, text, progress):
    font = get_font(max(34, width // 16), bold=True)
    body_font = get_font(max(22, width // 32), bold=False)
    max_width = int(width * 0.78)
    wrapped = wrap_text(draw, text.replace(" | ", "\n"), font, max_width)
    tw, th = text_size(draw, wrapped, font)
    x = int(width * 0.08)
    y = int(height * 0.18 + (1 - ease_in_out(min(progress * 1.6, 1.0))) * 40)
    box = (x - 26, y - 22, x + tw + 28, y + th + 26)
    draw.rounded_rectangle(box, radius=30, fill=(14, 18, 30, 185), outline=(52, 68, 98), width=2)
    draw.multiline_text((x, y), wrapped, font=font, fill=TEXT, spacing=12)
    return y + th + 30, body_font


def draw_footer_caption(draw, width, height, subtitles: List[Subtitle], current_t: float):
    active = None
    for sub in subtitles:
        if sub.start_s <= current_t < sub.end_s:
            active = sub.text
            break
    if not active:
        return
    font = get_font(max(24, width // 28), bold=True)
    max_width = int(width * 0.78)
    active = wrap_text(draw, active, font, max_width)
    tw, th = text_size(draw, active, font)
    x = (width - tw) // 2
    y = int(height * 0.82)
    pad = 22
    draw.rounded_rectangle((x - pad, y - pad, x + tw + pad, y + th + pad), radius=26, fill=(8, 10, 16, 205), outline=(56, 72, 110), width=2)
    draw.multiline_text((x, y), active, font=font, fill=TEXT, spacing=8, align="center")


def draw_node(draw, cx, cy, r, label, progress, active=False):
    fill = ACCENT if active else CARD_2
    outline = (160, 208, 255) if active else STROKE
    draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=fill, outline=outline, width=4)
    font = get_font(max(22, int(r * 0.33)), bold=True)
    bbox = draw.textbbox((0, 0), label, font=font)
    draw.text((cx - (bbox[2] - bbox[0]) / 2, cy - (bbox[3] - bbox[1]) / 2), label, font=font, fill=TEXT)


def draw_scene_visual(scene: Scene, draw: ImageDraw.ImageDraw, img: Image.Image, local_t: float, progress: float, width: int, height: int):
    if scene.scene == 1:
        cy = int(height * 0.58)
        x1, x2, x3 = int(width * 0.18), int(width * 0.5), int(width * 0.82)
        draw_node(draw, x1, cy, int(width * 0.07), "Agent", progress, active=True)
        rounded_box(draw, (x2 - 120, cy - 90, x2 + 120, cy + 90), fill=CARD, outline=(90, 116, 168), radius=30, width=3)
        mid_font = get_font(max(28, width // 28), bold=True)
        label = "Gateway"
        bbox = draw.textbbox((0, 0), label, font=mid_font)
        draw.text((x2 - (bbox[2] - bbox[0]) / 2, cy - 15), label, font=mid_font, fill=TEXT)
        draw_node(draw, x3, cy, int(width * 0.07), "Tools", progress, active=False)
        p = ease_in_out(min(local_t / max(scene.duration * 0.7, 0.001), 1.0))
        stop_x = lerp(x1 + 76, x2 - 124, p)
        draw.line((x1 + 78, cy, stop_x, cy), fill=ACCENT_2, width=10)
        if p > 0.96:
            draw.ellipse((stop_x - 10, cy - 10, stop_x + 10, cy + 10), fill=ACCENT_2)
    elif scene.scene == 2:
        cards = ["APIs", "Databases", "Workflows", "Ops Tools"]
        positions = [(0.08, 0.56), (0.54, 0.56), (0.08, 0.75), (0.54, 0.75)]
        for idx, (label, pos) in enumerate(zip(cards, positions), start=1):
            delay = (idx - 1) * 0.12
            p = ease_in_out((progress - delay) / 0.65)
            if p <= 0:
                continue
            x = int(width * pos[0])
            y = int(height * pos[1] + (1 - p) * 80)
            w = int(width * 0.32)
            h = int(height * 0.11)
            add_shadow(img, (x, y, x + w, y + h), radius=18)
            rounded_box(draw, (x, y, x + w, y + h), fill=CARD, outline=STROKE, radius=28)
            f = get_font(max(26, width // 30), bold=True)
            draw.text((x + 28, y + 26), label, font=f, fill=TEXT)
            # Fake interface lines
            for line_idx in range(3):
                yy = y + 64 + line_idx * 14
                draw.line((x + 28, yy, x + w - 28 - line_idx * 40, yy), fill=MUTED, width=4)
    elif scene.scene == 3:
        left = (int(width * 0.08), int(height * 0.52), int(width * 0.46), int(height * 0.76))
        right = (int(width * 0.52), int(height * 0.52), int(width * 0.92), int(height * 0.76))
        add_shadow(img, left, radius=20)
        add_shadow(img, right, radius=20)
        rounded_box(draw, left, fill=CARD, outline=STROKE, radius=28)
        rounded_box(draw, right, fill=CARD, outline=STROKE, radius=28)
        title_font = get_font(max(26, width // 28), bold=True)
        small = get_font(max(20, width // 36), bold=False)
        draw.text((left[0] + 26, left[1] + 22), "Policy Controls", font=title_font, fill=TEXT)
        for i, label in enumerate(["OAuth required", "Scoped tools", "Write actions blocked"]):
            yy = left[1] + 84 + i * 54
            draw.rounded_rectangle((left[0] + 26, yy, left[0] + 74, yy + 28), radius=14, fill=(36, 56, 86), outline=(98, 132, 188))
            knob_x = left[0] + 50 + (18 if i != 2 else 0)
            draw.ellipse((knob_x, yy + 4, knob_x + 20, yy + 24), fill=ACCENT_2 if i != 2 else MUTED)
            draw.text((left[0] + 92, yy - 2), label, font=small, fill=TEXT)
        draw.text((right[0] + 26, right[1] + 22), "Audit Trail", font=title_font, fill=TEXT)
        for i, item in enumerate(["tool.call github.list_prs", "auth.ok service_account", "policy.allow read_only"]):
            yy = right[1] + 84 + i * 52
            draw.rounded_rectangle((right[0] + 22, yy, right[2] - 22, yy + 36), radius=16, fill=CARD_2, outline=(60, 78, 116))
            draw.text((right[0] + 36, yy + 6), item, font=small, fill=TEXT)
    elif scene.scene == 4:
        title = "SquareMCP"
        subtitle = "Managed MCP gateway for internal tools"
        title_font = get_font(max(56, width // 10), bold=True)
        subtitle_font = get_font(max(28, width // 28), bold=False)
        bbox = draw.textbbox((0, 0), title, font=title_font)
        tw = bbox[2] - bbox[0]
        draw.text(((width - tw) / 2, height * 0.52), title, font=title_font, fill=TEXT)
        bbox2 = draw.textbbox((0, 0), subtitle, font=subtitle_font)
        sw = bbox2[2] - bbox2[0]
        draw.text(((width - sw) / 2, height * 0.52 + 120), subtitle, font=subtitle_font, fill=MUTED)
        cy = int(height * 0.74)
        x1, x2, x3 = int(width * 0.16), int(width * 0.5), int(width * 0.84)
        draw_node(draw, x1, cy, int(width * 0.05), "Agent", progress, active=True)
        rounded_box(draw, (x2 - 130, cy - 74, x2 + 130, cy + 74), fill=CARD, outline=(90, 116, 168), radius=26, width=3)
        mid_font = get_font(max(24, width // 32), bold=True)
        label = "SquareMCP"
        bbox = draw.textbbox((0, 0), label, font=mid_font)
        draw.text((x2 - (bbox[2] - bbox[0]) / 2, cy - 12), label, font=mid_font, fill=TEXT)
        draw_node(draw, x3, cy, int(width * 0.05), "Tools", progress, active=False)
        draw.line((x1 + 58, cy, x2 - 132, cy), fill=ACCENT_2, width=8)
        draw.line((x2 + 132, cy, x3 - 58, cy), fill=ACCENT, width=8)
    elif scene.scene == 5:
        shell = (int(width * 0.08), int(height * 0.5), int(width * 0.92), int(height * 0.8))
        add_shadow(img, shell, radius=24)
        rounded_box(draw, shell, fill=(15, 21, 34), outline=(64, 85, 126), radius=34)
        title_font = get_font(max(26, width // 28), bold=True)
        small = get_font(max(20, width // 38), bold=False)
        draw.text((shell[0] + 28, shell[1] + 22), "Gateway Runtime", font=title_font, fill=TEXT)
        left_panel = (shell[0] + 28, shell[1] + 72, shell[0] + int(width * 0.28), shell[3] - 28)
        mid_panel = (left_panel[2] + 22, shell[1] + 72, left_panel[2] + 22 + int(width * 0.22), shell[3] - 28)
        right_panel = (mid_panel[2] + 22, shell[1] + 72, shell[2] - 28, shell[3] - 28)
        for box, label in [(left_panel, "Connected Tools"), (mid_panel, "Access"), (right_panel, "Audit Log")]:
            rounded_box(draw, box, fill=CARD, outline=STROKE, radius=24)
            draw.text((box[0] + 18, box[1] + 14), label, font=small, fill=MUTED)
        for i, tool in enumerate(["postgres.query", "github.repos", "zendesk.ticket"]):
            yy = left_panel[1] + 52 + i * 58
            draw.rounded_rectangle((left_panel[0] + 14, yy, left_panel[2] - 14, yy + 42), radius=16, fill=CARD_2, outline=(58, 76, 110))
            draw.text((left_panel[0] + 28, yy + 10), tool, font=small, fill=TEXT)
        for i, item in enumerate(["Auth OK", "Read only", "Tenant scoped"]):
            yy = mid_panel[1] + 56 + i * 70
            draw.rounded_rectangle((mid_panel[0] + 16, yy, mid_panel[2] - 16, yy + 48), radius=18, fill=(25, 37, 56), outline=(74, 96, 138))
            draw.text((mid_panel[0] + 28, yy + 13), item, font=small, fill=ACCENT_2 if i == 0 else TEXT)
        logs = [
            "12:01 auth.ok",
            "12:01 tool.call postgres.query",
            "12:01 policy.allow",
            "12:02 result.ok",
        ]
        for i, log in enumerate(logs):
            yy = right_panel[1] + 48 + i * 44
            draw.text((right_panel[0] + 18, yy), log, font=small, fill=TEXT if i % 2 == 0 else MUTED)
            draw.line((right_panel[0] + 18, yy + 28, right_panel[2] - 18, yy + 28), fill=(50, 66, 96), width=2)
    elif scene.scene == 6:
        titles = ["AI startups", "Copilot teams", "Regulated teams"]
        subs = ["Move fast with guardrails", "Connect real internal systems", "Keep logs and policy boundaries"]
        widths = [0.26, 0.26, 0.26]
        start_x = 0.08
        gap = 0.03
        top = int(height * 0.56)
        title_font = get_font(max(24, width // 34), bold=True)
        body_font = get_font(max(20, width // 42), bold=False)
        x = start_x
        for idx, (title, sub, w_pct) in enumerate(zip(titles, subs, widths)):
            p = ease_in_out((progress - idx * 0.12) / 0.7)
            if p <= 0:
                x += w_pct + gap
                continue
            box_w = int(width * w_pct)
            box_h = int(height * 0.18)
            xx = int(width * x)
            yy = int(top + (1 - p) * 70)
            add_shadow(img, (xx, yy, xx + box_w, yy + box_h), radius=18)
            rounded_box(draw, (xx, yy, xx + box_w, yy + box_h), fill=CARD, outline=STROKE, radius=26)
            draw.text((xx + 20, yy + 18), title, font=title_font, fill=TEXT)
            wrapped = wrap_text(draw, sub, body_font, box_w - 40)
            draw.multiline_text((xx + 20, yy + 66), wrapped, font=body_font, fill=MUTED, spacing=8)
            x += w_pct + gap
    elif scene.scene == 7:
        title = "squaremcp.com"
        font = get_font(max(52, width // 12), bold=True)
        bbox = draw.textbbox((0, 0), title, font=font)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
        x = (width - tw) / 2
        y = height * 0.60
        draw.rounded_rectangle((x - 30, y - 24, x + tw + 30, y + th + 24), radius=30, fill=(14, 18, 30, 190), outline=(70, 94, 140), width=2)
        draw.text((x, y), title, font=font, fill=TEXT)
        small = get_font(max(24, width // 32), bold=False)
        msg = "Managed MCP gateway for internal tools"
        bbox2 = draw.textbbox((0, 0), msg, font=small)
        draw.text(((width - (bbox2[2] - bbox2[0])) / 2, y - 80), msg, font=small, fill=MUTED)


def make_scene_clip(scene: Scene, subtitles: List[Subtitle], width: int, height: int, assets_dir: Optional[Path], show_captions: bool) -> VideoClip:
    asset_path = assets_dir / f"scene{scene.scene}.png" if assets_dir else None

    def frame_fn(t: float):
        local_t = max(0.0, min(scene.duration, t))
        progress = local_t / max(scene.duration, 0.001)
        img = Image.new("RGBA", (width, height), (0, 0, 0, 255))
        draw_gradient_background(img, scene.start_s + local_t)
        paste_asset_background(img, asset_path, scene.start_s + local_t)
        draw = ImageDraw.Draw(img)
        draw_top_label(draw, width, height)
        draw_main_text(draw, width, height, scene.on_screen_text, progress)
        draw_scene_visual(scene, draw, img, local_t, progress, width, height)
        if show_captions:
            draw_footer_caption(draw, width, height, subtitles, scene.start_s + local_t)
        return np.array(img.convert("RGB"))

    return VideoClip(frame_fn, duration=scene.duration)


def attach_audio(clip: VideoClip, voiceover_path: Optional[Path]) -> VideoClip:
    if not voiceover_path:
        return clip
    audio = AudioFileClip(str(voiceover_path))
    if audio.duration > clip.duration:
        audio = audio.subclip(0, clip.duration)
    return clip.set_audio(audio)


def main() -> None:
    args = parse_args()
    width = 720 if args.draft else args.width
    height = 1280 if args.draft else args.height

    shotlist_path = Path(args.shotlist)
    captions_path = Path(args.captions) if args.captions else None
    output_path = Path(args.output)
    assets_dir = Path(args.assets_dir) if args.assets_dir else None
    voiceover_path = Path(args.voiceover) if args.voiceover else None

    scenes = load_scenes(shotlist_path)
    subtitles = [] if args.no_captions else parse_srt(captions_path)

    clips = [make_scene_clip(scene, subtitles, width, height, assets_dir, not args.no_captions) for scene in scenes]
    final = concatenate_videoclips(clips, method="compose")
    final = attach_audio(final, voiceover_path)

    final.write_videofile(
        str(output_path),
        fps=args.fps,
        codec="libx264",
        audio_codec="aac" if voiceover_path else None,
        preset="medium" if not args.draft else "ultrafast",
        threads=4,
    )


if __name__ == "__main__":
    main()
