"""Local/free motion renderer for AutoShortsBot production jobs.

The renderer intentionally uses only local Pillow/FFmpeg drawing and compositing.
It does not call paid video-generation providers. The output is a deterministic
motion prototype: procedural backgrounds, car/mechanism layers, kinetic captions,
and beat-synced movement based on the free production job specs.
"""

from __future__ import annotations

import json
import math
import shutil
import subprocess
import tempfile
import textwrap
from pathlib import Path
from typing import Any

from PIL import Image, ImageDraw, ImageFont

PAID_VIDEO_PROVIDERS = {"kling", "hailuo", "runway", "veo", "pika", "luma"}
LOCAL_RENDER_PROVIDERS = {"local_renderer", "local_ffmpeg"}


PALETTE_BY_MODE = {
    "cinematic_ai_video": ((7, 12, 25), (30, 64, 175), (248, 113, 113)),
    "mechanism_animation": ((2, 6, 23), (15, 118, 110), (251, 191, 36)),
    "kinetic_text_overlay": ((15, 23, 42), (88, 28, 135), (34, 211, 238)),
    "screen_recording_simulation": ((8, 13, 28), (14, 116, 144), (96, 165, 250)),
    "animated_card": ((17, 24, 39), (120, 53, 15), (251, 146, 60)),
}


def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    candidates = [
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
    ]
    for path in candidates:
        if Path(path).exists():
            return ImageFont.truetype(path, size=size)
    return ImageFont.load_default()


def _wrap(text: str, width_chars: int) -> str:
    return "\n".join(textwrap.wrap(" ".join(text.split()), width=max(8, width_chars)))


def _gradient(width: int, height: int, start: tuple[int, int, int], end: tuple[int, int, int]) -> Image.Image:
    image = Image.new("RGB", (width, height), start)
    draw = ImageDraw.Draw(image)
    for y in range(height):
        ratio = y / max(1, height - 1)
        color = tuple(round(start[i] * (1 - ratio) + end[i] * ratio) for i in range(3))
        draw.line([(0, y), (width, y)], fill=color)
    return image


def _ease(progress: float) -> float:
    progress = min(1.0, max(0.0, progress))
    return 0.5 - math.cos(progress * math.pi) / 2


def _caption_layout(text: str, width: int, height: int, pulse: float) -> dict[str, Any]:
    """Calculate a caption layout that stays inside the vertical safe area."""
    text_max_width = round(width * 0.78)
    max_box_width = round(width * 0.88)
    max_block_height = round(height * 0.21)
    pad = max(8, round(width * 0.035))
    preferred_size = max(18, round(width * (0.06 + 0.012 * pulse)))
    min_size = max(10, round(width * 0.038))

    scratch = Image.new("RGB", (width, height))
    draw = ImageDraw.Draw(scratch)
    for size in range(preferred_size, min_size - 1, -1):
        font = _font(size, bold=True)
        # Width-based wrapping alone is too optimistic for German compound words;
        # verify actual pixel width and shrink/re-wrap until it fits.
        wrap_chars = max(8, round(text_max_width / (size * 0.5)))
        wrapped = _wrap(text, wrap_chars)
        raw_lines = wrapped.splitlines()
        if len(raw_lines) > 4:
            raw_lines = raw_lines[:3] + [" ".join(raw_lines[3:])]
        spacing = max(4, round(size * 0.20))
        lines = []
        too_wide = False
        for line in raw_lines:
            box = draw.textbbox((0, 0), line, font=font)
            line_width = box[2] - box[0]
            line_height = box[3] - box[1]
            if line_width > text_max_width:
                too_wide = True
                break
            lines.append({"text": line, "width": line_width, "height": line_height})
        block_height = sum(line["height"] for line in lines) + spacing * max(0, len(lines) - 1)
        if lines and not too_wide and block_height <= max_block_height:
            y = height - round(height * 0.205) - block_height // 2
            box = [
                round((width - max_box_width) / 2),
                y - pad,
                round((width + max_box_width) / 2),
                y + block_height + pad,
            ]
            return {
                "font": font,
                "font_size": size,
                "lines": lines,
                "spacing": spacing,
                "box": box,
                "text_max_width": text_max_width,
                "start_y": y,
            }

    font = _font(min_size, bold=True)
    line = text
    suffix = "…" if len(text) > 1 else ""
    while line:
        candidate = line.rstrip() + suffix
        box = draw.textbbox((0, 0), candidate, font=font)
        if box[2] - box[0] <= text_max_width:
            break
        line = line[:-1]
    candidate = (line.rstrip() + suffix) if line else "…"
    box = draw.textbbox((0, 0), candidate, font=font)
    return {
        "font": font,
        "font_size": min_size,
        "lines": [{"text": candidate, "width": box[2] - box[0], "height": box[3] - box[1]}],
        "spacing": max(4, round(min_size * 0.20)),
        "box": [round(width * 0.06), round(height * 0.72), round(width * 0.94), round(height * 0.84)],
        "text_max_width": text_max_width,
        "start_y": round(height * 0.76),
    }


def _draw_caption(draw: ImageDraw.ImageDraw, text: str, width: int, height: int, accent: tuple[int, int, int], pulse: float) -> None:
    layout = _caption_layout(text, width, height, pulse)
    draw.rounded_rectangle(
        layout["box"],
        radius=round(width * 0.035),
        fill=(255, 255, 255),
        outline=accent,
        width=max(2, width // 180),
    )
    y = layout["start_y"]
    for line in layout["lines"]:
        x = (width - line["width"]) // 2
        draw.text((x, y), line["text"], font=layout["font"], fill=(15, 23, 42))
        y += line["height"] + layout["spacing"]


def _draw_car(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int]) -> None:
    cx = width // 2
    cy = round(height * 0.43)
    scale = width / 540
    bob = round(math.sin(progress * math.tau * 2) * 6 * scale)
    car_w = round(width * 0.58)
    car_h = round(height * 0.12)
    left = cx - car_w // 2
    right = cx + car_w // 2
    top = cy - car_h // 2 + bob
    bottom = cy + car_h // 2 + bob
    draw.rounded_rectangle([left, top, right, bottom], radius=round(24 * scale), fill=(226, 232, 240), outline=accent, width=max(2, round(4 * scale)))
    cabin = [left + round(car_w * 0.23), top - round(car_h * 0.55), right - round(car_w * 0.25), top + round(car_h * 0.15)]
    draw.rounded_rectangle(cabin, radius=round(14 * scale), fill=(148, 163, 184), outline=(226, 232, 240), width=max(1, round(2 * scale)))
    wheel_r = round(width * 0.045)
    for wx in (left + round(car_w * 0.22), right - round(car_w * 0.22)):
        draw.ellipse([wx - wheel_r, bottom - wheel_r, wx + wheel_r, bottom + wheel_r], fill=(2, 6, 23), outline=(248, 250, 252), width=max(2, round(3 * scale)))
        angle = progress * math.tau * 3
        draw.line([wx, bottom, wx + math.cos(angle) * wheel_r, bottom + math.sin(angle) * wheel_r], fill=accent, width=max(1, round(3 * scale)))
    # x-ray scan line and warning pulse
    scan_x = left + round(car_w * _ease(progress))
    draw.line([(scan_x, top - round(60 * scale)), (scan_x, bottom + round(70 * scale))], fill=accent, width=max(2, round(5 * scale)))
    pulse_r = round((30 + 38 * _ease((progress * 2) % 1)) * scale)
    draw.ellipse([cx - pulse_r, cy - pulse_r + bob, cx + pulse_r, cy + pulse_r + bob], outline=accent, width=max(1, round(3 * scale)))


def _draw_mechanism_layers(draw: ImageDraw.ImageDraw, width: int, height: int, progress: float, accent: tuple[int, int, int], twist: bool) -> None:
    labels = ["Preis", "Reparatur", "Standtage", "Marge", "Liquidität"]
    visible = max(1, min(len(labels), math.ceil(progress * len(labels))))
    top = round(height * 0.24)
    layer_h = round(height * 0.072)
    gap = round(height * 0.022)
    for index, label in enumerate(labels[:visible]):
        p = _ease(min(1.0, max(0.0, progress * len(labels) - index)))
        x_offset = round((1 - p) * width * 0.45)
        y = top + index * (layer_h + gap)
        color = accent if (twist and index == 0) else (226, 232, 240)
        fill = (30, 41, 59) if not (twist and index == 0) else (127, 29, 29)
        draw.rounded_rectangle(
            [round(width * 0.12) + x_offset, y, round(width * 0.88) + x_offset, y + layer_h],
            radius=round(width * 0.026),
            fill=fill,
            outline=color,
            width=max(2, width // 180),
        )
        f = _font(max(14, round(width * 0.046)), bold=True)
        draw.text((round(width * 0.17) + x_offset, y + round(layer_h * 0.22)), label, font=f, fill=(248, 250, 252))
        meter_w = round(width * 0.24 * p)
        draw.rounded_rectangle(
            [round(width * 0.58) + x_offset, y + round(layer_h * 0.35), round(width * 0.58) + x_offset + meter_w, y + round(layer_h * 0.63)],
            radius=round(width * 0.015),
            fill=color,
        )
    car_y = round(height * 0.72)
    draw.line([(round(width * 0.5), top + visible * (layer_h + gap)), (round(width * 0.5), car_y - round(height * 0.05))], fill=accent, width=max(2, width // 160))
    _draw_car(draw, width, height, progress, accent)


def _draw_kinetic_words(draw: ImageDraw.ImageDraw, width: int, height: int, text: str, progress: float, accent: tuple[int, int, int]) -> None:
    words = text.split()
    current = words[: max(1, min(len(words), math.ceil(progress * max(1, len(words)))))]
    main = " ".join(current[-5:])
    size = max(28, round(width * (0.105 + 0.025 * math.sin(progress * math.tau * 3))))
    font = _font(size, bold=True)
    wrapped = _wrap(main, max(8, round(width / (size * 0.48))))
    lines = wrapped.splitlines()[:3]
    total_h = 0
    boxes = []
    for line in lines:
        box = draw.textbbox((0, 0), line, font=font)
        boxes.append(box)
        total_h += box[3] - box[1]
    y = height // 2 - total_h // 2
    for line, box in zip(lines, boxes):
        line_w = box[2] - box[0]
        x = (width - line_w) // 2 + round(math.sin(progress * math.tau * 2) * width * 0.025)
        draw.text((x + 3, y + 3), line, font=font, fill=(15, 23, 42))
        draw.text((x, y), line, font=font, fill=(248, 250, 252))
        y += box[3] - box[1] + round(size * 0.18)
    glow = round(width * (0.12 + 0.08 * _ease((progress * 3) % 1)))
    draw.ellipse([width // 2 - glow, round(height * 0.18) - glow, width // 2 + glow, round(height * 0.18) + glow], outline=accent, width=max(2, width // 140))


def _render_frame(job: dict[str, Any], width: int, height: int, frame_index: int, total_frames: int) -> Image.Image:
    mode = job["asset_mode"]
    start, end, accent = PALETTE_BY_MODE.get(mode, PALETTE_BY_MODE["cinematic_ai_video"])
    progress = frame_index / max(1, total_frames - 1)
    # simulated handheld push-in: brighten and offset gradient slightly over time
    image = _gradient(width, height, start, end)
    draw = ImageDraw.Draw(image)

    for i in range(9):
        y = round((i / 8) * height + math.sin(progress * math.tau + i) * height * 0.014)
        draw.line([(0, y), (width, y + round(height * 0.08))], fill=tuple(min(255, c + 16) for c in start), width=max(1, width // 90))

    if mode == "mechanism_animation":
        _draw_mechanism_layers(draw, width, height, progress, accent, twist="teuerste Fehler" in job.get("overlay_text", ""))
    elif mode == "kinetic_text_overlay":
        _draw_kinetic_words(draw, width, height, job["overlay_text"], progress, accent)
    else:
        _draw_car(draw, width, height, progress, accent)
        # local parallax objects / risk tags
        tag_font = _font(max(12, round(width * 0.038)), bold=True)
        for idx, label in enumerate(["-4'000?", "Risiko", "Timing"]):
            p = (progress + idx * 0.18) % 1
            x = round(width * (0.1 + 0.72 * p))
            y = round(height * (0.17 + 0.08 * idx + 0.015 * math.sin(progress * math.tau * 2 + idx)))
            draw.rounded_rectangle([x, y, x + round(width * 0.22), y + round(height * 0.045)], radius=round(width * 0.02), fill=(248, 250, 252), outline=accent, width=max(1, width // 220))
            draw.text((x + round(width * 0.018), y + round(height * 0.01)), label, font=tag_font, fill=(15, 23, 42))

    title_font = _font(max(11, round(width * 0.031)), bold=False)
    draw.text((round(width * 0.06), round(height * 0.045)), f"{job['scene_id']} · {mode.replace('_', ' ')}", font=title_font, fill=(226, 232, 240))
    _draw_caption(draw, job["overlay_text"], width, height, accent, _ease((progress * 4) % 1))
    return image


def local_render_jobs(production_jobs: dict[str, Any]) -> list[dict[str, Any]]:
    """Return only executable local render jobs, excluding text-only LLM refinement."""
    providers = {job["provider"] for job in production_jobs["jobs"]}
    forbidden = providers & PAID_VIDEO_PROVIDERS
    if forbidden:
        raise ValueError(f"Paid video providers are forbidden in local render: {sorted(forbidden)}")
    return [job for job in production_jobs["jobs"] if job["provider"] in LOCAL_RENDER_PROVIDERS]


def _write_frames(render_jobs: list[dict[str, Any]], frame_dir: Path, width: int, height: int, fps: int) -> tuple[int, int]:
    frame_dir.mkdir(parents=True, exist_ok=True)
    frame_number = 0
    motion_events = 0
    for job in render_jobs:
        scene_frames = max(1, round(float(job["duration_seconds"]) * fps))
        motion_events += max(2, math.ceil(float(job["duration_seconds"]) / 2))
        for local_index in range(scene_frames):
            image = _render_frame(job, width, height, local_index, scene_frames)
            image.save(frame_dir / f"frame_{frame_number:06d}.png")
            frame_number += 1
    return frame_number, motion_events


def render_local_motion_video(
    production_jobs: dict[str, Any],
    output_path: Path,
    width: int = 540,
    height: int = 960,
    fps: int = 24,
) -> dict[str, Any]:
    """Render local/free production jobs into a vertical MP4 and metadata JSON."""
    ffmpeg = shutil.which("ffmpeg")
    if not ffmpeg:
        raise RuntimeError("ffmpeg is required to render local motion MP4 files")

    render_jobs = local_render_jobs(production_jobs)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    total_duration: float = sum((float(job["duration_seconds"]) for job in render_jobs), 0.0)

    with tempfile.TemporaryDirectory(prefix="autoshorts_local_motion_") as tmp:
        frame_dir = Path(tmp) / "frames"
        frame_count, motion_event_count = _write_frames(render_jobs, frame_dir, width, height, fps)
        command = [
            ffmpeg,
            "-y",
            "-framerate",
            str(fps),
            "-i",
            str(frame_dir / "frame_%06d.png"),
            "-vf",
            "format=yuv420p",
            "-movflags",
            "+faststart",
            str(output_path),
        ]
        completed = subprocess.run(command, check=True, capture_output=True, text=True)

    metadata_path = output_path.with_suffix(".json")
    metadata = {
        "schema_version": "local_motion_render.v1",
        "video_id": production_jobs["video"]["id"],
        "source_schema_version": production_jobs["schema_version"],
        "output_path": str(output_path),
        "width": width,
        "height": height,
        "fps": fps,
        "frame_count": frame_count,
        "scene_count": len(render_jobs),
        "rendered_duration_seconds": int(total_duration) if total_duration.is_integer() else total_duration,
        "motion_event_count": motion_event_count,
        "paid_credit_providers_used": [],
        "budget_policy": production_jobs["budget_policy"],
        "scenes": [
            {
                "scene_id": job["scene_id"],
                "asset_mode": job["asset_mode"],
                "provider": job["provider"],
                "execution_mode": job["execution_mode"],
                "duration_seconds": job["duration_seconds"],
                "motion_directive": job["motion_directive"],
            }
            for job in render_jobs
        ],
        "ffmpeg_stderr_tail": completed.stderr[-2000:],
    }
    metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

    return {
        "output_path": output_path,
        "metadata_path": metadata_path,
        "video_id": production_jobs["video"]["id"],
        "width": width,
        "height": height,
        "fps": fps,
        "frame_count": frame_count,
        "scene_count": len(render_jobs),
        "rendered_duration_seconds": metadata["rendered_duration_seconds"],
        "motion_event_count": motion_event_count,
        "paid_credit_providers_used": [],
    }
