"""Scene runner — Playwright primitives that execute a VideoSpec.

Public API: `create_video(target, *, duration_seconds, voice, style, ...)`
returns a `RenderResult` with the path to the produced mp4.

All Playwright + ffmpeg + TTS imports are lazy and live inside the
functions that use them. This module imports cleanly without the
[video] extra installed; calling `create_video()` without it raises
`VideoDepsMissing` with an actionable "pip install 'agntspace[video]'"
message.
"""

from __future__ import annotations

import asyncio
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

from . import compose as compose_mod
from . import tts as tts_mod
from .specs import Action, Scene, VideoSpec, validate_spec

log = logging.getLogger(__name__)


class VideoDepsMissing(RuntimeError):
    """Raised when `create_video()` is called without the [video] extra installed."""


@dataclass
class RenderResult:
    """Outcome of a render. Returned by create_video()."""

    path: Path
    duration_seconds: float
    scene_count: int
    narration_word_count: int
    style: str
    voice: str
    tts_provider_used: str = ""    # "edge-tts" / "piper-tts" / "pyttsx3"
    warnings: list[str] = field(default_factory=list)
    extras: dict[str, Any] = field(default_factory=dict)


_SLUG_SANITIZE = re.compile(r"[^a-z0-9\-]+")


# ── Public API ──────────────────────────────────────────────────

async def create_video(
    target: str,
    *,
    duration_seconds: int = 60,
    voice: str = "liam",
    style: str = "walkthrough",
    output_dir: Path | None = None,
    output_name: str = "",
    base_url: str = "http://localhost:8000",
    skill_loader: Any = None,
    vault_reader: Any = None,
    llm: Any = None,
    vault_root: Path | None = None,
) -> RenderResult:
    """Produce a narrated mp4 video from any vault artifact or URL.

    Args:
        target: One of `"wiki:slug"`, `"kb:slug"`, `"project:slug"`,
                `"journal:YYYY-MM-DD"`, `"url:https://..."`.
        duration_seconds: Target video duration. Capped at 180s by styles.yaml.
        voice: Voice key from styles.yaml (default "liam").
        style: One of `"tutorial"`, `"promo"`, `"walkthrough"`.
        output_dir: Override output directory. Defaults to
                    `<vault_root>/agntspace-projects/personal/output/videos/`.
        output_name: Filename stem (without .mp4). Auto-generated if empty.
        base_url: Live AgntSpace server URL the browser drives.
        skill_loader: SkillLoader for config; required.
        vault_reader: VaultReader; required for wiki/kb/project/journal targets.
        llm: LLM provider; required (for scene-outline generation).
        vault_root: Vault root path; required for default output_dir.

    Returns:
        RenderResult with path to mp4 + metadata.

    Raises:
        VideoDepsMissing: if [video] extra not installed.
        ValueError: if target malformed or unknown.
    """

    # ── 1. Dispatch to adapter → VideoSpec
    from . import adapters  # deferred to avoid circular import at module-load

    spec = await adapters.adapter_for_target(
        target,
        max_seconds=duration_seconds,
        style=style,
        voice=voice,
        llm=llm,
        vault_reader=vault_reader,
        skill_loader=skill_loader,
    )
    spec.duration_seconds = duration_seconds
    if not spec.name:
        spec.name = _slug_from_target(target)

    # ── 2. Validate
    issues = validate_spec(spec, max_scenes=_max_scenes(skill_loader),
                           max_seconds=_max_seconds(skill_loader))
    if any(i.startswith(("spec has zero", "duplicate scene")) for i in issues):
        raise ValueError(f"VideoSpec invalid: {issues}")
    warnings = list(issues)

    # ── 3. Resolve output path
    output_dir = output_dir or _default_output_dir(vault_root, target)
    output_dir.mkdir(parents=True, exist_ok=True)
    timestamp = datetime.now().strftime("%Y-%m-%d-%H%M")
    stem = output_name or f"{spec.name}-{timestamp}"
    mp4_path = output_dir / f"{stem}.mp4"
    webm_path = output_dir / f"{stem}.webm"
    tts_dir = output_dir / f".{stem}-tts"

    # ── 4. Generate TTS per scene (durations drive Playwright pacing)
    style_cfg = _style_preset(skill_loader, style)
    rate = style_cfg.get("rate", "-5%")

    durations: dict[str, float] = {}
    audio_paths: dict[str, Path] = {}
    tts_provider_seen = ""
    narration_words = 0

    for scene in spec.scenes:
        text = tts_mod.sanitize_for_tts(scene.narration)
        narration_words += len(text.split())
        try:
            result = await tts_mod.synthesize_scene(
                scene.id, text,
                voice=voice, rate=rate,
                output_dir=tts_dir, skill_loader=skill_loader,
            )
        except tts_mod.TTSProviderUnavailable as exc:
            raise RuntimeError(f"TTS failed for scene {scene.id!r}: {exc}") from exc
        durations[scene.id] = result.duration_seconds
        audio_paths[scene.id] = result.path
        if result.provider_used and result.provider_used != "empty":
            tts_provider_seen = result.provider_used

    # ── 5. Record via Playwright → webm + timings
    timings = await _render_spec(
        spec, durations=durations,
        webm_path=webm_path, base_url=base_url,
        style_cfg=style_cfg, skill_loader=skill_loader,
    )

    # ── 6. Compose final mp4
    audio_clips = [
        (sid, audio_paths[sid], float(timings.get(sid, 0.0)))
        for sid in timings
        if sid in audio_paths and audio_paths[sid].exists()
    ]

    compose_result = compose_mod.compose(
        video_path=webm_path,
        audio_clips=audio_clips,
        output_path=mp4_path,
    )

    # ── 7. Cleanup intermediates (keep webm for debugging — small)
    try:
        for p in tts_dir.glob("*.mp3"):
            p.unlink(missing_ok=True)
        tts_dir.rmdir()
    except OSError:
        pass

    return RenderResult(
        path=mp4_path,
        duration_seconds=compose_result.duration_seconds,
        scene_count=len(spec.scenes),
        narration_word_count=narration_words,
        style=style,
        voice=voice,
        tts_provider_used=tts_provider_seen,
        warnings=warnings,
        extras={"webm": str(webm_path), "audio_clips_placed": compose_result.audio_clip_count},
    )


# ── Internal: Playwright render loop ────────────────────────────

async def _render_spec(
    spec: VideoSpec,
    *,
    durations: dict[str, float],
    webm_path: Path,
    base_url: str,
    style_cfg: dict[str, Any],
    skill_loader: Any,
) -> dict[str, float]:
    """Drive Playwright through every scene, return {scene_id: mark_time_s}."""

    try:
        from playwright.async_api import async_playwright
    except ImportError as exc:
        raise VideoDepsMissing(
            "Playwright not installed. Install with: pip install 'agntspace[video]' "
            "&& playwright install chromium"
        ) from exc

    viewport_w, viewport_h = spec.viewport
    buffer_s = float(style_cfg.get("scene_buffer_seconds", 2.0))
    timings: dict[str, float] = {}

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context(
            viewport={"width": viewport_w, "height": viewport_h},
            record_video_dir=str(webm_path.parent),
            record_video_size={"width": viewport_w, "height": viewport_h},
        )
        page = await context.new_page()

        # Navigate to base once before any scenes so first scene's relative
        # navigate() has a page to work on.
        try:
            await page.goto(base_url, wait_until="networkidle", timeout=30000)
        except Exception as exc:  # noqa: BLE001
            log.warning("Initial navigation to %s failed: %s (continuing)", base_url, exc)

        render_start = time.perf_counter()

        for scene in spec.scenes:
            scene_start = time.perf_counter() - render_start
            timings[scene.id] = scene_start
            dwell = float(durations.get(scene.id, 10.0)) + buffer_s
            log.info("scene %s mark=%.1fs dwell=%.1fs", scene.id, scene_start, dwell)

            await _run_actions(page, scene, base_url=base_url, style_cfg=style_cfg)

            # Hold for the remainder of the scene's dwell
            elapsed_in_scene = (time.perf_counter() - render_start) - scene_start
            wait_more = max(0.0, dwell - elapsed_in_scene)
            if wait_more > 0:
                await asyncio.sleep(wait_more)

            # Clear caption at the end of the scene
            await _clear_caption(page)

        await page.close()
        await context.close()

        # Rename Playwright's auto-named webm to our target.
        # Playwright uses different naming across versions: older drops
        # "page@<hash>.webm", newer just "<hash>.webm". Pick the largest
        # untamed webm in the directory (excluding our target if it
        # already exists from a previous run).
        try:
            candidates = sorted(
                (f for f in webm_path.parent.glob("*.webm") if f != webm_path),
                key=lambda p: p.stat().st_size,
                reverse=True,
            )
            if candidates:
                # Best-effort rename; Windows may briefly hold the handle.
                for attempt in range(5):
                    try:
                        if webm_path.exists():
                            webm_path.unlink()
                        candidates[0].rename(webm_path)
                        break
                    except OSError:
                        await asyncio.sleep(0.3)
                # Cleanup any other stray webms
                for stale in candidates[1:]:
                    try:
                        stale.unlink()
                    except OSError:
                        pass
        except Exception as exc:  # noqa: BLE001
            log.warning("webm rename failed: %s", exc)

        await browser.close()

    return timings


async def _run_actions(
    page: Any,
    scene: Scene,
    *,
    base_url: str,
    style_cfg: dict[str, Any],
) -> None:
    """Execute a scene's action list on the Playwright page."""

    for action in scene.actions:
        try:
            await _dispatch_action(page, action, base_url=base_url, style_cfg=style_cfg)
        except Exception as exc:  # noqa: BLE001
            # Best-effort: log and continue so one bad action doesn't kill the whole scene
            log.warning("scene %s action %s(%r) failed: %s",
                        scene.id, action.type, action.target, exc)


async def _dispatch_action(
    page: Any,
    action: Action,
    *,
    base_url: str,
    style_cfg: dict[str, Any],
) -> None:
    atype = action.type
    if atype == "navigate":
        url = _resolve_url(str(action.target), base_url)
        await page.goto(url, wait_until="networkidle", timeout=30000)
    elif atype == "scroll":
        target = action.target
        if isinstance(target, int) or (isinstance(target, str) and target.lstrip("-").isdigit()):
            y = int(target)
            await page.evaluate(f"window.scrollTo({{ top: {y}, behavior: 'smooth' }})")
        elif isinstance(target, str) and target:
            try:
                loc = page.locator(target).first
                await loc.scroll_into_view_if_needed(timeout=5000)
            except Exception:  # noqa: BLE001
                pass
        if action.dwell_seconds > 0:
            await asyncio.sleep(action.dwell_seconds)
    elif atype == "caption":
        text = action.text or ""
        dwell = action.dwell_seconds if action.dwell_seconds > 0 else 0.0
        await _inject_caption(page, text, dwell, style_cfg)
    elif atype == "wait_for_audio":
        # No-op on the action side — the outer loop holds for the full
        # scene dwell after all actions complete.
        return
    elif atype == "nav_click":
        label = str(action.target)
        # Try aria-label first (stable), then nav text
        selector = f'[aria-label="{label}"], [data-nav="{label}"], nav button:has-text("{label}"), nav a:has-text("{label}")'
        try:
            await page.locator(selector).first.click(timeout=5000)
            await page.wait_for_load_state("networkidle", timeout=15000)
        except Exception as exc:  # noqa: BLE001
            log.warning("nav_click %r failed: %s", label, exc)
    elif atype == "click":
        await page.locator(str(action.target)).first.click(timeout=10000)
    elif atype == "type":
        loc = page.locator(str(action.target)).first
        await loc.click()
        await loc.fill(action.text or "")
    elif atype == "wait":
        ms = int(action.target) if str(action.target).isdigit() else int(action.dwell_seconds * 1000)
        await asyncio.sleep(ms / 1000.0)
    else:
        log.warning("Unknown action type: %s", atype)


async def _inject_caption(page: Any, text: str, dwell_seconds: float, style_cfg: dict[str, Any]) -> None:
    """Inject a DOM caption overlay. Persists until cleared or replaced."""

    css_block = style_cfg.get("caption_style", {}) or {}
    await page.evaluate(
        """({ text, css }) => {
            const old = document.getElementById('demo-caption');
            if (old) old.remove();
            if (!text || !text.trim()) return;
            const el = document.createElement('div');
            el.id = 'demo-caption';
            el.textContent = text;
            Object.assign(el.style, {
                position: 'fixed',
                bottom: (css.bottom_offset_px || 40) + 'px',
                left: '50%',
                transform: 'translateX(-50%)',
                background: css.background || 'rgba(0,0,0,0.88)',
                color: css.color || '#fff',
                padding: css.padding || '14px 32px',
                borderRadius: css.border_radius || '12px',
                fontSize: (css.font_size || 22) + 'px',
                fontFamily: css.font_family || "'Space Grotesk','Inter',system-ui,sans-serif",
                fontWeight: css.font_weight || '500',
                letterSpacing: css.letter_spacing || '0.3px',
                zIndex: '99999',
                maxWidth: css.max_width || '80%',
                textAlign: 'center',
                backdropFilter: css.backdrop_filter || 'blur(8px)',
                border: css.border || '1px solid rgba(129,140,248,0.3)',
                boxShadow: css.box_shadow || '0 8px 32px rgba(0,0,0,0.4)',
                opacity: '0',
                transition: 'opacity 300ms ease',
            });
            document.body.appendChild(el);
            requestAnimationFrame(() => { el.style.opacity = '1'; });
        }""",
        {"text": text, "css": css_block},
    )


async def _clear_caption(page: Any) -> None:
    try:
        await page.evaluate(
            "() => { const el = document.getElementById('demo-caption'); if (el) el.remove(); }"
        )
    except Exception:  # noqa: BLE001
        pass


# ── Helpers ─────────────────────────────────────────────────────

def _resolve_url(target: str, base_url: str) -> str:
    if target.startswith(("http://", "https://")):
        return target
    if not target:
        return base_url
    base = base_url.rstrip("/")
    path = target if target.startswith("/") else "/" + target
    return base + path


def _slug_from_target(target: str) -> str:
    _, _, value = target.partition(":")
    slug = value.lower()
    # Strip scheme for URLs, keep domain-ish path
    if "://" in slug:
        slug = slug.split("://", 1)[1]
    slug = _SLUG_SANITIZE.sub("-", slug).strip("-") or "video"
    return slug[:80]


def _load_styles_cfg(skill_loader: Any) -> dict[str, Any]:
    """Load styles.yaml via the skill loader. Returns {} on any failure."""
    if skill_loader is None:
        return {}
    try:
        data = skill_loader.load_skill_config_file("video-producer", "styles.yaml")
        return data if isinstance(data, dict) else {}
    except Exception:  # noqa: BLE001
        return {}


def _style_preset(skill_loader: Any, style: str) -> dict[str, Any]:
    default = {
        "rate": "-5%",
        "seconds_per_word_min": 0.30,
        "scene_buffer_seconds": 2.0,
        "caption_style": {},
    }
    cfg = _load_styles_cfg(skill_loader)
    if not cfg:
        return default
    preset = (cfg.get("presets") or {}).get(style) or {}
    merged = {**default, **preset, "caption_style": cfg.get("caption_style") or {}}
    return merged


def _max_scenes(skill_loader: Any) -> int:
    cfg = _load_styles_cfg(skill_loader)
    return int((cfg.get("limits") or {}).get("max_scenes", 12))


def _max_seconds(skill_loader: Any) -> int:
    cfg = _load_styles_cfg(skill_loader)
    return int((cfg.get("limits") or {}).get("max_duration_seconds", 180))


def _default_output_dir(vault_root: Path | None, target: str) -> Path:
    root = Path(vault_root) if vault_root else Path.cwd()
    kind, _, value = target.partition(":")
    if kind == "project" and value:
        return root / "agntspace-projects" / value / "output" / "videos"
    return root / "agntspace-projects" / "personal" / "output" / "videos"
