# -*- coding: utf-8 -*-
"""ffmpeg/ffprobe discovery and encoder capability detection.

Search order for the ffmpeg binary (all relative to the app folder, so a
copied/zipped distribution works on any machine without edits):
  1. config.json  "ffmpeg_path"          (explicit user override)
  2. <app>/tools/ffmpeg/bin/ffmpeg.exe   (bundled build, ship this with the app)
  3. plain "ffmpeg" on PATH              (dev machines with ffmpeg installed)

Encoder support is probed empirically (a 0.1 s null encode) because version
numbers alone don't tell you whether the installed NVIDIA driver satisfies
the nvenc API level the binary was compiled against. Ship a build whose
nvenc requirement matches older/mainstream drivers (e.g. ffmpeg 8.0.1, which
needs driver 550+ rather than 8.1.x's 610+) — the probe just confirms it at
runtime rather than assuming.
"""
import json
import shutil
import subprocess
import tempfile
from functools import lru_cache
from pathlib import Path

APP_ROOT = Path(__file__).resolve().parent.parent
CONFIG = APP_ROOT / "config.json"

_CANDIDATES = [
    APP_ROOT / "tools" / "ffmpeg" / "bin" / "ffmpeg.exe",
]


def load_config() -> dict:
    if CONFIG.exists():
        return json.loads(CONFIG.read_text(encoding="utf-8"))
    return {}


def save_config(cfg: dict):
    CONFIG.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8")


def _first_existing():
    cfg = load_config()
    p = cfg.get("ffmpeg_path")
    if p and Path(p).exists():
        return Path(p)
    for c in _CANDIDATES:
        if c.exists():
            return c
    w = shutil.which("ffmpeg")
    if w:
        return Path(w)
    raise FileNotFoundError("ffmpeg not found; set ffmpeg_path in app/config.json")


@lru_cache(maxsize=1)
def ffmpeg() -> str:
    return str(_first_existing())


@lru_cache(maxsize=1)
def ffprobe() -> str:
    return str(Path(ffmpeg()).with_name("ffprobe.exe"))


@lru_cache(maxsize=4)
def nvenc_ok(binary: str | None = None) -> bool:
    exe = binary or ffmpeg()
    r = subprocess.run(
        [exe, "-v", "error", "-f", "lavfi",
         "-i", "color=size=640x360:duration=0.1:rate=10",
         "-c:v", "h264_nvenc", "-f", "null", "-"],
        capture_output=True)
    return r.returncode == 0


def pick_encoder(preference: str = "auto") -> str:
    """preference: auto | gpu | cpu  ->  h264_nvenc | libx264"""
    if preference == "cpu":
        return "libx264"
    if preference == "gpu":
        return "h264_nvenc" if nvenc_ok() else "libx264"
    return "h264_nvenc" if nvenc_ok() else "libx264"


def run(cmd: list, timeout: int | None = None) -> subprocess.CompletedProcess:
    r = subprocess.run(cmd, capture_output=True, text=True,
                       encoding="utf-8", errors="replace", timeout=timeout)
    if r.returncode != 0:
        raise RuntimeError(f"ffmpeg failed ({r.returncode}): {(r.stderr or '')[-2000:]}")
    return r


class JobCancelled(Exception):
    """Raised when the user aborts a running job."""


def run_progress(cmd: list, total_seconds: float, on_progress=None,
                 timeout: int | None = None, should_cancel=None):
    """Run ffmpeg while reporting real encode progress.

    `-progress pipe:1` makes ffmpeg emit machine-readable key=value lines on
    stdout; out_time_ms against the known output duration gives a genuine
    percentage rather than a spinner that only pretends to know.
    stderr goes to a temp file so reading one stream can't deadlock on the
    other filling its pipe buffer.
    """
    cmd = list(cmd)
    cmd[1:1] = ["-progress", "pipe:1", "-nostats"]
    with tempfile.TemporaryFile(mode="w+", encoding="utf-8",
                                errors="replace") as errf:
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=errf,
                                text=True, encoding="utf-8", errors="replace",
                                bufsize=1)
        cancelled = False
        try:
            for line in proc.stdout:
                if should_cancel and should_cancel():
                    cancelled = True
                    proc.terminate()
                    break
                line = line.strip()
                if line.startswith("out_time_ms=") and total_seconds > 0:
                    raw = line.split("=", 1)[1]
                    if raw.isdigit() and on_progress:
                        pct = min(99.0, int(raw) / 1e6 / total_seconds * 100)
                        on_progress(pct)
                elif line == "progress=end" and on_progress:
                    on_progress(100.0)
        finally:
            proc.stdout.close()
        if cancelled:
            try:
                proc.wait(timeout=10)
            except subprocess.TimeoutExpired:
                proc.kill()
            raise JobCancelled("使用者中止轉檔")
        rc = proc.wait(timeout=timeout)
        if rc != 0:
            errf.seek(0)
            raise RuntimeError(f"ffmpeg failed ({rc}): {errf.read()[-2000:]}")


def probe_video(path: str) -> dict:
    r = run([ffprobe(), "-v", "error",
             "-select_streams", "v:0",
             "-show_entries", "stream=width,height,r_frame_rate,duration",
             "-show_entries", "format=duration",
             "-of", "json", str(path)])
    j = json.loads(r.stdout)
    st = j["streams"][0]
    dur = float(st.get("duration") or j["format"]["duration"])
    num, den = st["r_frame_rate"].split("/")
    return {"width": st["width"], "height": st["height"],
            "fps": round(int(num) / int(den), 3), "duration": round(dur, 3)}


def probe_duration(path: str) -> float:
    r = run([ffprobe(), "-v", "error", "-show_entries", "format=duration",
             "-of", "default=nw=1:nk=1", str(path)])
    return float(r.stdout.strip())
