from __future__ import annotations

import json
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlparse

from ._errors import (
    FFmpegNotFoundError,
    FFprobeNotFoundError,
    ProbeError,
    RenderError,
)


@dataclass
class MediaInfo:
    """Structured result from ffprobe."""

    duration: float
    width: int | None = None
    height: int | None = None
    fps: float | None = None
    has_audio: bool = False
    has_video: bool = False


_ffmpeg_path: str | None = None
_ffprobe_path: str | None = None


def find_ffmpeg() -> str:
    """Return the path to an ``ffmpeg`` binary, or raise."""
    global _ffmpeg_path
    if _ffmpeg_path is not None:
        return _ffmpeg_path

    path = shutil.which("ffmpeg")
    if path:
        _ffmpeg_path = path
        return path

    try:
        import static_ffmpeg

        static_ffmpeg.add_paths()
        path = shutil.which("ffmpeg")
        if path:
            _ffmpeg_path = path
            return path
    except ImportError:
        pass

    raise FFmpegNotFoundError()


def find_ffprobe() -> str:
    """Return the path to an ``ffprobe`` binary, or raise."""
    global _ffprobe_path
    if _ffprobe_path is not None:
        return _ffprobe_path

    path = shutil.which("ffprobe")
    if path:
        _ffprobe_path = path
        return path

    try:
        import static_ffmpeg

        static_ffmpeg.add_paths()
        path = shutil.which("ffprobe")
        if path:
            _ffprobe_path = path
            return path
    except ImportError:
        pass

    raise FFprobeNotFoundError()


def probe(source: str) -> MediaInfo:
    """Run ffprobe on *source* and return structured :class:`MediaInfo`."""
    ffprobe = find_ffprobe()
    cmd = [
        ffprobe,
        "-v", "quiet",
        "-print_format", "json",
        "-show_format",
        "-show_streams",
        source,
    ]
    try:
        raw = subprocess.check_output(cmd, stderr=subprocess.PIPE, timeout=30)
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
        raise ProbeError(source, str(exc))

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ProbeError(source, f"JSON parse error: {exc}")

    fmt = data.get("format", {})
    duration = float(fmt.get("duration", 0))
    streams = data.get("streams", [])

    info = MediaInfo(duration=duration)
    for s in streams:
        ct = s.get("codec_type")
        if ct == "video" and not info.has_video:
            info.has_video = True
            info.width = int(s.get("width", 0)) or None
            info.height = int(s.get("height", 0)) or None
            rfr = s.get("r_frame_rate", "30/1")
            try:
                num, den = rfr.split("/")
                info.fps = float(num) / float(den) if float(den) else 30.0
            except (ValueError, ZeroDivisionError):
                info.fps = 30.0
        elif ct == "audio":
            info.has_audio = True

    return info


def run_ffmpeg(
    args: list[str],
    *,
    total_duration: float | None = None,
    progress: Callable[[float], None] | None = None,
) -> None:
    """Execute an ffmpeg command.

    If *progress* is provided, it will be called with the current rendered
    time in seconds, parsed from ffmpeg's ``-progress pipe:1`` output.
    """
    ffmpeg = find_ffmpeg()
    cmd = [ffmpeg, "-y"]
    if progress is not None:
        cmd += ["-progress", "pipe:1"]
    cmd += args

    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE if progress else subprocess.DEVNULL,
        stderr=subprocess.PIPE,
        text=True,
    )

    if progress is not None and proc.stdout is not None:
        for line in proc.stdout:
            line = line.strip()
            if line.startswith("out_time_us="):
                val = line.split("=", 1)[1]
                if val != "N/A":
                    try:
                        secs = int(val) / 1_000_000
                        progress(secs)
                    except ValueError:
                        pass

    _, stderr_data = proc.communicate()

    if proc.returncode != 0:
        raise RenderError(
            f"ffmpeg exited with code {proc.returncode}",
            stderr=stderr_data,
        )


class _DownloadCache:
    """Manages temporary downloads of URL-based assets."""

    def __init__(self) -> None:
        self._tmpdir: tempfile.TemporaryDirectory | None = None  # type: ignore[type-arg]
        self._cache: dict[str, str] = {}

    def _ensure_dir(self) -> str:
        if self._tmpdir is None:
            self._tmpdir = tempfile.TemporaryDirectory(prefix="ponyflash_editor_")
        return self._tmpdir.name

    def resolve(self, url: str) -> str:
        """Download *url* (if not already cached) and return local path."""
        if url in self._cache:
            return self._cache[url]

        import httpx

        tmpdir = self._ensure_dir()
        parsed = urlparse(url)
        filename = os.path.basename(parsed.path) or "download"
        dest = os.path.join(tmpdir, f"{len(self._cache)}_{filename}")

        with httpx.stream("GET", url, follow_redirects=True, timeout=300) as resp:
            resp.raise_for_status()
            with open(dest, "wb") as f:
                for chunk in resp.iter_bytes(chunk_size=65536):
                    f.write(chunk)

        self._cache[url] = dest
        return dest

    def cleanup(self) -> None:
        if self._tmpdir is not None:
            self._tmpdir.cleanup()
            self._tmpdir = None
            self._cache.clear()
