"""
wivexx_media.py — local media utilities  (v0.1, images first)
═══════════════════════════════════════════════════════════════════════════════
Quality-first image operations built on Pillow (no external tools needed):

    convert_image(src, fmt, ...)      → re-encode to another format, keeping quality
    resize_image(src, ...)            → high-quality LANCZOS resize, aspect-preserving
    compress_image(src, ...)          → shrink file size; hit a target KB or a preset
    palette(src, n=6)                 → dominant colours as hex + coverage %
    image_info(src)                   → {w, h, format, mode, kb}

Design notes (the "keep the quality" part):
  • resize uses LANCZOS (the best downscale filter Pillow ships) and preserves the
    ICC colour profile, EXIF, and DPI so colours/print size don't drift.
  • convert keeps ICC/EXIF/DPI; JPEG uses 4:4:4 chroma (subsampling=0) + a high
    quality floor so it never softens; PNG/WEBP keep alpha; WEBP can go lossless.
  • compress searches the quality knob (binary search) to land just under a target
    size while staying as high-quality as possible — never a fixed low quality.

VIDEO / AUDIO ops (convert/resize/compress, frame grab, caption burn-in) need an
ffmpeg backend and live in a separate section once that's wired (PyAV is available;
a static ffmpeg binary is the other option). Not implemented here yet.
"""
from __future__ import annotations

import io
import json
import logging
import os
import shutil
import subprocess
import tempfile
from typing import Optional

from PIL import Image, ImageDraw, ImageFont

_logger = logging.getLogger("jarvis")

# ── ffmpeg / ffprobe location (bundled in %LOCALAPPDATA%\Wivexx\ffmpeg, else PATH) ──
_CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
_EXE = ".exe" if os.name == "nt" else ""
_TOOL_CACHE: dict = {}


def _known_local_appdata() -> str:
    """Ask Windows for the real LocalAppData folder via the Known Folder API. This is
    env-INDEPENDENT — it works even when Wivexx's silent launcher (VBS/pythonw) starts
    the process with a stripped environment where LOCALAPPDATA / USERPROFILE are unset."""
    if os.name != "nt":
        return ""
    try:
        import ctypes
        from ctypes import wintypes

        class _GUID(ctypes.Structure):
            _fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD),
                        ("Data3", wintypes.WORD), ("Data4", ctypes.c_ubyte * 8)]

        # FOLDERID_LocalAppData = {F1B32785-6FBA-4FCF-9D55-7B8E7F157091}
        fid = _GUID(0xF1B32785, 0x6FBA, 0x4FCF,
                    (ctypes.c_ubyte * 8)(0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91))
        out = ctypes.c_wchar_p()
        shell32 = ctypes.windll.shell32
        shell32.SHGetKnownFolderPath.argtypes = [
            ctypes.POINTER(_GUID), wintypes.DWORD, wintypes.HANDLE,
            ctypes.POINTER(ctypes.c_wchar_p)]
        rc = shell32.SHGetKnownFolderPath(ctypes.byref(fid), 0, None, ctypes.byref(out))
        if rc == 0 and out.value:
            path = out.value
            try:
                ctypes.windll.ole32.CoTaskMemFree(out)
            except Exception:
                pass
            return path
    except Exception:
        pass
    return ""


def _local_appdata() -> str:
    """Best path to %LOCALAPPDATA%, robust to a stripped launcher environment."""
    la = os.environ.get("LOCALAPPDATA")
    if la and os.path.isdir(la):
        return la
    la = _known_local_appdata()                    # ask Windows directly (no env needed)
    if la and os.path.isdir(la):
        return la
    base = os.environ.get("USERPROFILE") or os.path.expanduser("~")
    if base and base != "~":
        return os.path.join(base, "AppData", "Local")
    return ""


def _locate(name: str) -> str:
    if _TOOL_CACHE.get(name):                      # cache only SUCCESSFUL finds → self-heals
        return _TOOL_CACHE[name]
    cands = []
    la = _local_appdata()                          # bundled at %LOCALAPPDATA%\Wivexx\ffmpeg
    if la:
        cands.append(os.path.join(la, "Wivexx", "ffmpeg", name + _EXE))
    cands.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin", name + _EXE))
    found = ""
    for c in cands:
        if os.path.isfile(c):
            found = c
            break
    if not found:
        found = shutil.which(name) or ""
    if found:                                      # don't cache a miss (env may improve later)
        _TOOL_CACHE[name] = found
    else:
        try:
            _logger.warning("media: %s NOT found — tried %s (LOCALAPPDATA=%r)",
                            name, cands, os.environ.get("LOCALAPPDATA"))
        except Exception:
            pass
    return found


def ffmpeg_exe() -> str:
    return _locate("ffmpeg")


def ffprobe_exe() -> str:
    return _locate("ffprobe")


def has_ffmpeg() -> bool:
    return bool(ffmpeg_exe())

# ── format support ───────────────────────────────────────────────────────────
# alias → Pillow format id. Read support is broad; these are the ones we also
# WRITE reliably with good quality control.
IMAGE_WRITE = {
    "jpg": "JPEG", "jpeg": "JPEG", "jpe": "JPEG", "jfif": "JPEG",
    "png": "PNG", "webp": "WEBP", "bmp": "BMP", "dib": "BMP",
    "gif": "GIF", "tif": "TIFF", "tiff": "TIFF", "ico": "ICO",
    "ppm": "PPM", "pgm": "PPM", "tga": "TGA", "pdf": "PDF",
}
# extra formats Pillow can usually READ (so convert FROM them works)
IMAGE_READ_EXTRA = {"heic", "heif", "avif", "jp2", "j2k", "pcx", "xbm", "psd", "eps"}

_ALPHA_OK = {"PNG", "WEBP", "TIFF", "ICO", "GIF"}          # formats that keep transparency
_QUALITY_PRESETS = {"max": 98, "high": 92, "medium": 82, "small": 68}


def _ext(path: str) -> str:
    return os.path.splitext(path)[1].lower().lstrip(".")


def _out_path(src: str, fmt: str, dst: Optional[str], suffix: str = "") -> str:
    if dst:
        return dst
    base, _ = os.path.splitext(src)
    return f"{base}{suffix}.{fmt}"


def _open(src: str) -> Image.Image:
    im = Image.open(src)
    im.load()
    return im


def _save_kwargs(pil_fmt: str, quality: int, src_im: Image.Image) -> dict:
    """Quality-first encoder options + carry ICC / EXIF / DPI across."""
    kw: dict = {}
    icc = src_im.info.get("icc_profile")
    if icc:
        kw["icc_profile"] = icc
    dpi = src_im.info.get("dpi")
    if dpi:
        kw["dpi"] = dpi
    if pil_fmt == "JPEG":
        kw.update(quality=int(quality), subsampling=0,        # 4:4:4 — no chroma loss
                  optimize=True, progressive=True)
        exif = src_im.info.get("exif")
        if exif:
            kw["exif"] = exif
    elif pil_fmt == "WEBP":
        if quality >= 98:
            kw.update(lossless=True, quality=100, method=6)
        else:
            kw.update(quality=int(quality), method=6)
        exif = src_im.info.get("exif")
        if exif:
            kw["exif"] = exif
    elif pil_fmt == "PNG":
        kw.update(optimize=True, compress_level=9)            # lossless
    elif pil_fmt == "TIFF":
        kw.update(compression="tiff_lzw")                     # lossless
    return kw


def _prep_mode(im: Image.Image, pil_fmt: str) -> Image.Image:
    """Make the image mode safe for the target format (flatten alpha for JPEG, etc.)."""
    if pil_fmt == "JPEG":
        if im.mode in ("RGBA", "LA", "P"):
            bg = Image.new("RGB", im.size, (255, 255, 255))
            rgba = im.convert("RGBA")
            bg.paste(rgba, mask=rgba.split()[-1])
            return bg
        if im.mode != "RGB":
            return im.convert("RGB")
    elif pil_fmt in ("WEBP", "PNG"):
        if im.mode not in ("RGB", "RGBA", "L", "LA", "P"):
            return im.convert("RGBA")
    return im


def _kb(path: str) -> float:
    try:
        return round(os.path.getsize(path) / 1024.0, 1)
    except Exception:
        return 0.0


# ── public: info ─────────────────────────────────────────────────────────────
def image_info(src: str) -> dict:
    im = _open(src)
    return {"ok": True, "w": im.width, "h": im.height, "format": im.format,
            "mode": im.mode, "kb": _kb(src), "path": src}


# ── public: convert ──────────────────────────────────────────────────────────
def convert_image(src: str, fmt: str, dst: Optional[str] = None,
                  quality: str = "high") -> dict:
    """Re-encode `src` to `fmt` ('png','jpg','webp','tiff',…), keeping quality.
    Returns {ok, path, from, to, kb} or {ok:False, error}."""
    fmt = fmt.lower().lstrip(".")
    if fmt == "jpeg":
        fmt = "jpg"
    pil_fmt = IMAGE_WRITE.get(fmt)
    if not pil_fmt:
        return {"ok": False, "error": f"Can't write .{fmt}"}
    try:
        im = _open(src)
        im = _prep_mode(im, pil_fmt)
        q = _QUALITY_PRESETS.get(quality, 92)
        out = _out_path(src, fmt, dst)
        im.save(out, pil_fmt, **_save_kwargs(pil_fmt, q, im))
        return {"ok": True, "path": out, "from": _ext(src), "to": fmt, "kb": _kb(out)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ── public: resize ───────────────────────────────────────────────────────────
def resize_image(src: str, width: Optional[int] = None, height: Optional[int] = None,
                 scale: Optional[float] = None, max_side: Optional[int] = None,
                 dst: Optional[str] = None, quality: str = "high") -> dict:
    """High-quality resize. Give ONE of: width/height (aspect kept if only one),
    scale (0–1+), or max_side (longest edge clamp). Re-saves in the source format."""
    try:
        im = _open(src)
        w0, h0 = im.size
        if scale:
            nw, nh = max(1, round(w0 * scale)), max(1, round(h0 * scale))
        elif max_side:
            if max(w0, h0) <= max_side:
                nw, nh = w0, h0
            elif w0 >= h0:
                nw, nh = max_side, max(1, round(h0 * max_side / w0))
            else:
                nh, nw = max_side, max(1, round(w0 * max_side / h0))
        elif width and height:
            nw, nh = width, height
        elif width:
            nw, nh = width, max(1, round(h0 * width / w0))
        elif height:
            nh, nw = height, max(1, round(w0 * height / h0))
        else:
            return {"ok": False, "error": "Specify width, height, scale, or max_side."}
        out_im = im.resize((nw, nh), Image.LANCZOS) if (nw, nh) != (w0, h0) else im
        fmt = _ext(src)
        pil_fmt = IMAGE_WRITE.get("jpg" if fmt == "jpeg" else fmt, im.format or "PNG")
        out_im = _prep_mode(out_im, pil_fmt)
        out = _out_path(src, fmt or "png", dst, suffix=f"_{nw}x{nh}")
        q = _QUALITY_PRESETS.get(quality, 92)
        out_im.save(out, pil_fmt, **_save_kwargs(pil_fmt, q, im))
        return {"ok": True, "path": out, "from": f"{w0}x{h0}", "to": f"{nw}x{nh}", "kb": _kb(out)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ── public: compress ─────────────────────────────────────────────────────────
def compress_image(src: str, dst: Optional[str] = None, target_kb: Optional[float] = None,
                   quality: str = "high", fmt: Optional[str] = None) -> dict:
    """Shrink the file. With target_kb, binary-search the quality knob to land just
    under it at the HIGHEST quality that fits (lossy formats only). Without a target,
    apply the `quality` preset. Defaults to keeping the source format unless `fmt`."""
    try:
        im = _open(src)
        out_fmt = (fmt or _ext(src)).lower().lstrip(".")
        if out_fmt == "jpeg":
            out_fmt = "jpg"
        pil_fmt = IMAGE_WRITE.get(out_fmt, "JPEG")
        prepped = _prep_mode(im, pil_fmt)
        out = _out_path(src, out_fmt, dst, suffix="_min")

        if target_kb and pil_fmt in ("JPEG", "WEBP"):
            lo, hi, best, best_q = 30, 96, None, 30
            for _ in range(8):                              # binary search ≤8 probes
                q = (lo + hi) // 2
                buf = io.BytesIO()
                prepped.save(buf, pil_fmt, **_save_kwargs(pil_fmt, q, im))
                size_kb = buf.tell() / 1024.0
                if size_kb <= target_kb:
                    best, best_q = buf.getvalue(), q
                    lo = q + 1                              # try higher quality
                else:
                    hi = q - 1
            if best is None:                                # even q=30 too big → take it
                buf = io.BytesIO()
                prepped.save(buf, pil_fmt, **_save_kwargs(pil_fmt, 30, im))
                best, best_q = buf.getvalue(), 30
            with open(out, "wb") as f:
                f.write(best)
            return {"ok": True, "path": out, "kb": _kb(out), "from_kb": _kb(src),
                    "quality": best_q}

        q = _QUALITY_PRESETS.get(quality, 82)
        prepped.save(out, pil_fmt, **_save_kwargs(pil_fmt, q, im))
        return {"ok": True, "path": out, "kb": _kb(out), "from_kb": _kb(src), "quality": q}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ── public: colour palette ───────────────────────────────────────────────────
def palette(src: str, n: int = 6) -> dict:
    """Dominant colours of an image → list of {hex, rgb, pct}, sorted by coverage."""
    try:
        im = _open(src).convert("RGB")
        im.thumbnail((400, 400), Image.LANCZOS)             # speed; coverage stays representative
        n = max(1, min(16, int(n)))
        q = im.quantize(colors=n, method=Image.Quantize.MEDIANCUT)
        pal = q.getpalette()                                # flat [r,g,b, r,g,b, ...]
        counts = q.getcolors() or []                        # [(count, index), ...]
        total = sum(c for c, _ in counts) or 1
        out = []
        for count, idx in sorted(counts, key=lambda x: -x[0]):
            r, g, b = pal[idx * 3], pal[idx * 3 + 1], pal[idx * 3 + 2]
            out.append({"hex": "#%02X%02X%02X" % (r, g, b), "rgb": [r, g, b],
                        "pct": round(100.0 * count / total, 1)})
        return {"ok": True, "colors": out}
    except Exception as e:
        return {"ok": False, "error": str(e), "colors": []}


# ── translate-image recompose (the Pillow half; the OCR+translate half is the
#    Claude-vision call in wivexx_api, which hands us the blocks) ───────────────
_FONT_CACHE: dict = {}


def _parse_hex(s) -> Optional[tuple]:
    if not s or not isinstance(s, str):
        return None
    s = s.strip().lstrip("#")
    if len(s) == 3:
        s = "".join(c * 2 for c in s)
    if len(s) != 6:
        return None
    try:
        return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
    except Exception:
        return None


def _sample_bg(im: Image.Image, box: list) -> tuple:
    """Average a few pixels just OUTSIDE the text box → the background colour to
    paint over the original text with."""
    W, H = im.size
    x0, y0, x1, y1 = box
    cy, cx = (y0 + y1) // 2, (x0 + x1) // 2
    pts = []
    for sx, sy in [(x0 - 2, cy), (x1 + 2, cy), (cx, y0 - 2), (cx, y1 + 2)]:
        if 0 <= sx < W and 0 <= sy < H:
            pts.append(im.getpixel((sx, sy)))
    if not pts:
        pts = [im.getpixel((min(W - 1, max(0, x0)), min(H - 1, max(0, y0))))]
    n = len(pts)
    return (sum(p[0] for p in pts) // n, sum(p[1] for p in pts) // n, sum(p[2] for p in pts) // n)


def _contrast_ink(bg: tuple) -> tuple:
    lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]
    return (24, 24, 24) if lum > 140 else (240, 240, 240)


def _font(size: int) -> "ImageFont.FreeTypeFont":
    size = max(8, int(size))
    if size in _FONT_CACHE:
        return _FONT_CACHE[size]
    here = os.path.dirname(os.path.abspath(__file__))
    for p in (os.path.join(here, "ui", "fonts", "Inter-Regular.ttf"),
              "C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/arial.ttf"):
        try:
            f = ImageFont.truetype(p, size)
            _FONT_CACHE[size] = f
            return f
        except Exception:
            pass
    f = ImageFont.load_default()
    _FONT_CACHE[size] = f
    return f


def _wrap(dr, text: str, font, maxw: float) -> list:
    lines, cur = [], ""
    for word in text.split():
        t = (cur + " " + word).strip()
        if not cur or dr.textlength(t, font=font) <= maxw:
            cur = t
        else:
            lines.append(cur)
            cur = word
    if cur:
        lines.append(cur)
    return lines or [text]


def _fit_text(dr, text: str, pw: int, ph: int):
    """Largest font size whose word-wrapped text fits inside (pw, ph)."""
    lo, hi, best = 8, max(10, int(ph)), None
    while lo <= hi:
        mid = (lo + hi) // 2
        f = _font(mid)
        lines = _wrap(dr, text, f, pw)
        lh = mid * 1.18
        widest = max((dr.textlength(ln, font=f) for ln in lines), default=0)
        if lh * len(lines) <= ph and widest <= pw:
            best = (f, lines, mid)
            lo = mid + 1
        else:
            hi = mid - 1
    if best is None:
        f = _font(8)
        best = (f, _wrap(dr, text, f, pw), 8)
    return best


def recompose_translation(src: str, blocks: list, dst: Optional[str] = None) -> dict:
    """Paint translated text over the original. `blocks` = [{bbox:[x,y,w,h] as 0–1
    fractions, text, color:'#hex', bg:'#hex', align:'left|center|right'}]. Covers
    each original text box with its background colour, then redraws the translation
    fitted into the same box (size/wrap auto)."""
    try:
        im = Image.open(src).convert("RGB")
        W, H = im.size
        dr = ImageDraw.Draw(im)
        drawn = 0
        for b in (blocks or []):
            bb = b.get("bbox") or []
            if len(bb) < 4:
                continue
            x, y, w, h = (float(bb[0]), float(bb[1]), float(bb[2]), float(bb[3]))
            px, py = int(x * W), int(y * H)
            pw, ph = max(2, int(w * W)), max(2, int(h * H))
            pad = max(1, int(ph * 0.10))
            box = [max(0, px - pad), max(0, py - pad), min(W, px + pw + pad), min(H, py + ph + pad)]
            bg = _parse_hex(b.get("bg")) or _sample_bg(im, box)
            dr.rectangle(box, fill=bg)                       # cover the original text
            text = (b.get("text") or "").strip()
            if not text:
                continue
            color = _parse_hex(b.get("color")) or _contrast_ink(bg)
            align = (b.get("align") or "left").lower()
            font, lines, fsize = _fit_text(dr, text, pw, ph)
            lh = int(fsize * 1.18)
            ty = py + max(0, (ph - lh * len(lines)) // 2)
            for ln in lines:
                lw = dr.textlength(ln, font=font)
                tx = px + (pw - lw) // 2 if align == "center" else \
                    (px + pw - lw if align == "right" else px)
                dr.text((tx, ty), ln, font=font, fill=color)
                ty += lh
            drawn += 1
        fmt = _ext(src)
        fmt = "jpg" if fmt == "jpeg" else (fmt or "png")
        pil_fmt = IMAGE_WRITE.get(fmt, "PNG")
        out = _out_path(src, fmt, dst, suffix="_translated")
        _prep_mode(im, pil_fmt).save(out, pil_fmt, **_save_kwargs(pil_fmt, 92, im))
        return {"ok": True, "path": out, "blocks": drawn, "kb": _kb(out)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ══════════════════════════════════════════════════════════════════════════════
#  VIDEO / AUDIO  (ffmpeg backend)
# ══════════════════════════════════════════════════════════════════════════════
VIDEO_FORMATS = {"mp4", "mkv", "mov", "webm", "avi", "m4v", "wmv", "flv", "gif"}
AUDIO_FORMATS = {"mp3", "wav", "flac", "aac", "m4a", "ogg", "opus", "wma", "aiff"}

# CRF (lower = better) per quality preset, per video codec family
_X264_CRF = {"max": 16, "high": 20, "medium": 24, "small": 28}
_VP9_CRF = {"max": 24, "high": 30, "medium": 34, "small": 38}


def _run(args: list, timeout: Optional[float] = 1800) -> tuple:
    exe = ffmpeg_exe()
    if not exe:
        return (False, "ffmpeg isn't installed.")
    try:
        p = subprocess.run([exe, "-hide_banner", "-loglevel", "error", "-y"] + args,
                           capture_output=True, text=True, timeout=timeout,
                           creationflags=_CREATE_NO_WINDOW)
        if p.returncode != 0:
            return (False, (p.stderr or "").strip()[-400:] or "ffmpeg failed")
        return (True, "")
    except Exception as e:
        return (False, str(e))


def _even(n: int) -> int:
    n = int(round(n))
    return max(2, n - (n % 2))


def _stream_rotation(v: dict) -> int:
    """Display rotation in degrees (0/90/180/270) from tags.rotate OR a Display
    Matrix side-data entry. Cameras/phones often record the sensor in landscape and
    set a rotation flag so the clip *displays* portrait — ffprobe's stream width/height
    are the RAW (pre-rotation) dims, so we must read this to know the real orientation."""
    rot = 0
    try:
        tr = (v.get("tags") or {}).get("rotate")
        if tr is not None:
            rot = int(float(tr))
    except Exception:
        pass
    try:
        for sd in (v.get("side_data_list") or []):
            if sd.get("rotation") is not None:
                rot = int(float(sd["rotation"]))
                break
    except Exception:
        pass
    return ((rot % 360) + 360) % 360


def web_playable(vcodec: Optional[str], pix_fmt: Optional[str], profile: Optional[str]) -> bool:
    """Can a WebView2/Chromium <video> decode this stream? Chromium only reliably
    handles 8-bit 4:2:0 — high-end camera files (10-bit, 4:2:2/4:4:4, HEVC, AV1-less
    builds) won't play, so the editor must fall back to a transcoded preview proxy."""
    vc = (vcodec or "").lower()
    pf = (pix_fmt or "").lower()
    pr = (profile or "").lower()
    if pf and ("420" not in pf or any(b in pf for b in ("10le", "10be", "p10", "12le", "12be", "p12"))):
        return False                          # not 8-bit 4:2:0
    if any(k in pr for k in ("4:2:2", "4:4:4", "422", "444", "high 10", "high 4", "intra")):
        return False
    return vc in ("h264", "avc", "avc1", "vp8", "vp9", "av1", "theora")


def media_info(src: str) -> dict:
    fp = ffprobe_exe()
    if not fp:
        return {"ok": False, "error": "ffprobe isn't installed."}
    try:
        p = subprocess.run([fp, "-v", "error", "-print_format", "json",
                            "-show_format", "-show_streams", src],
                           capture_output=True, text=True, creationflags=_CREATE_NO_WINDOW)
        data = json.loads(p.stdout or "{}")
        v = next((s for s in data.get("streams", []) if s.get("codec_type") == "video"), {})
        a = next((s for s in data.get("streams", []) if s.get("codec_type") == "audio"), {})
        fmt = data.get("format", {})
        dur = float(fmt.get("duration", 0) or 0)
        rot = _stream_rotation(v)
        w_raw, h_raw = v.get("width"), v.get("height")
        # report DISPLAY dimensions (swap for a ¼/¾-turn rotation) so callers see the
        # orientation the viewer actually sees, not the raw sensor frame.
        if rot in (90, 270) and w_raw and h_raw:
            w_disp, h_disp = h_raw, w_raw
        else:
            w_disp, h_disp = w_raw, h_raw
        return {"ok": True, "w": w_disp, "h": h_disp, "w_raw": w_raw, "h_raw": h_raw,
                "rotation": rot, "duration": round(dur, 2), "vcodec": v.get("codec_name"),
                "pix_fmt": v.get("pix_fmt"), "profile": v.get("profile"),
                "web_playable": web_playable(v.get("codec_name"), v.get("pix_fmt"), v.get("profile")),
                "acodec": a.get("codec_name"), "has_video": bool(v), "has_audio": bool(a),
                "kb": _kb(src)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def _video_args(fmt: str, quality: str) -> list:
    if fmt == "webm":
        return ["-c:v", "libvpx-vp9", "-crf", str(_VP9_CRF.get(quality, 30)), "-b:v", "0",
                "-row-mt", "1", "-c:a", "libopus", "-b:a", "160k"]
    if fmt in ("wmv",):
        return ["-c:v", "wmv2", "-q:v", "3", "-c:a", "wmav2", "-b:a", "192k"]
    if fmt in ("flv",):
        return ["-c:v", "flv", "-q:v", "4", "-c:a", "aac", "-b:a", "160k"]
    # mp4/mkv/mov/m4v/avi → H.264, quality-first
    return ["-c:v", "libx264", "-crf", str(_X264_CRF.get(quality, 20)), "-preset", "slow",
            "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]


def _audio_args(fmt: str, quality: str) -> list:
    q = quality
    if fmt == "mp3":
        return ["-c:a", "libmp3lame", "-q:a", "0" if q in ("max", "high") else "3"]
    if fmt in ("m4a", "aac"):
        return ["-c:a", "aac", "-b:a", "256k" if q in ("max", "high") else "160k"]
    if fmt == "flac":
        return ["-c:a", "flac"]                                   # lossless
    if fmt == "wav":
        return ["-c:a", "pcm_s16le"]
    if fmt == "aiff":
        return ["-c:a", "pcm_s16be"]
    if fmt == "ogg":
        return ["-c:a", "libvorbis", "-q:a", "6" if q in ("max", "high") else "4"]
    if fmt == "opus":
        return ["-c:a", "libopus", "-b:a", "160k"]
    if fmt == "wma":
        return ["-c:a", "wmav2", "-b:a", "192k"]
    return ["-c:a", "aac", "-b:a", "192k"]


def _to_gif(src: str, out: str, quality: str) -> dict:
    fps = 15 if quality in ("max", "high") else 12
    width = 640 if quality in ("max", "high") else 480
    vf = f"fps={fps},scale={width}:-1:flags=lanczos"
    pal = out + ".palette.png"
    ok, err = _run(["-i", src, "-vf", vf + ",palettegen=stats_mode=diff", pal])
    if not ok:
        return {"ok": False, "error": err}
    ok, err = _run(["-i", src, "-i", pal, "-lavfi",
                    vf + "[x];[x][1:v]paletteuse=dither=sierra2_4a", out])
    try:
        os.remove(pal)
    except Exception:
        pass
    return {"ok": ok, "path": out, "kb": _kb(out)} if ok else {"ok": False, "error": err}


def convert_media(src: str, fmt: str, dst: Optional[str] = None, quality: str = "high") -> dict:
    """Convert an audio/video file to `fmt` (mp4, mkv, webm, mov, gif, mp3, wav, flac,
    aac, m4a, ogg, opus, …), quality-first. ffmpeg-backed."""
    fmt = fmt.lower().lstrip(".")
    out = _out_path(src, fmt, dst)
    if fmt == "gif":
        return _to_gif(src, out, quality)
    if fmt in AUDIO_FORMATS:
        args = ["-i", src, "-vn"] + _audio_args(fmt, quality) + [out]
    elif fmt in VIDEO_FORMATS:
        args = ["-i", src] + _video_args(fmt, quality) + [out]
    else:
        return {"ok": False, "error": f"Unsupported media format .{fmt}"}
    ok, err = _run(args)
    return {"ok": ok, "path": out, "kb": _kb(out)} if ok else {"ok": False, "error": err}


def resize_video(src: str, width: Optional[int] = None, height: Optional[int] = None,
                 scale: Optional[float] = None, max_side: Optional[int] = None,
                 dst: Optional[str] = None, quality: str = "high") -> dict:
    """High-quality (LANCZOS) video resize, aspect-preserving. Re-encodes H.264."""
    info = media_info(src)
    if not info.get("ok") or not info.get("has_video"):
        return {"ok": False, "error": info.get("error", "Not a video.")}
    w0, h0 = info["w"], info["h"]
    if scale:
        nw, nh = _even(w0 * scale), _even(h0 * scale)
    elif max_side:
        if max(w0, h0) <= max_side:
            nw, nh = _even(w0), _even(h0)
        elif w0 >= h0:
            nw, nh = _even(max_side), _even(h0 * max_side / w0)
        else:
            nh, nw = _even(max_side), _even(w0 * max_side / h0)
    elif width and height:
        nw, nh = _even(width), _even(height)
    elif width:
        nw, nh = _even(width), _even(h0 * width / w0)
    elif height:
        nh, nw = _even(height), _even(w0 * height / h0)
    else:
        return {"ok": False, "error": "Specify width, height, scale, or max_side."}
    out = _out_path(src, _ext(src) or "mp4", dst, suffix=f"_{nw}x{nh}")
    crf = _X264_CRF.get(quality, 20)
    ok, err = _run(["-i", src, "-vf", f"scale={nw}:{nh}:flags=lanczos",
                    "-c:v", "libx264", "-crf", str(crf), "-preset", "slow",
                    "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", out])
    return {"ok": ok, "path": out, "from": f"{w0}x{h0}", "to": f"{nw}x{nh}", "kb": _kb(out)} \
        if ok else {"ok": False, "error": err}


def compress_video(src: str, target_mb: Optional[float] = None, quality: str = "high",
                   dst: Optional[str] = None) -> dict:
    """Shrink a video. With target_mb, two-pass to hit that size (audio reserved);
    otherwise CRF by preset (quality-first, visually lossless-ish at 'high')."""
    out = _out_path(src, "mp4", dst, suffix="_min")
    if target_mb:
        info = media_info(src)
        dur = info.get("duration") or 0
        if dur <= 0:
            return {"ok": False, "error": "Couldn't read the video duration."}
        a_kbps = 128
        total_kbps = (float(target_mb) * 8192.0) / dur            # kbit/s for the whole file
        v_kbps = max(100, int(total_kbps - a_kbps))
        log = os.path.join(tempfile.gettempdir(), "wvx_2pass")
        null = "NUL" if os.name == "nt" else "/dev/null"
        ok, err = _run(["-i", src, "-c:v", "libx264", "-b:v", f"{v_kbps}k", "-preset", "slow",
                        "-pass", "1", "-passlogfile", log, "-an", "-f", "mp4", null])
        if not ok:
            return {"ok": False, "error": err}
        ok, err = _run(["-i", src, "-c:v", "libx264", "-b:v", f"{v_kbps}k", "-preset", "slow",
                        "-pass", "2", "-passlogfile", log, "-pix_fmt", "yuv420p",
                        "-c:a", "aac", "-b:a", f"{a_kbps}k", "-movflags", "+faststart", out])
        for ext in ("-0.log", "-0.log.mbtree", ".log", ".log.mbtree"):
            try:
                os.remove(log + ext)
            except Exception:
                pass
        return {"ok": ok, "path": out, "kb": _kb(out), "from_kb": _kb(src)} \
            if ok else {"ok": False, "error": err}
    crf = _X264_CRF.get(quality, 20)
    ok, err = _run(["-i", src, "-c:v", "libx264", "-crf", str(crf), "-preset", "slow",
                    "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k",
                    "-movflags", "+faststart", out])
    return {"ok": ok, "path": out, "kb": _kb(out), "from_kb": _kb(src)} \
        if ok else {"ok": False, "error": err}


def available() -> bool:
    return True            # image ops always available (Pillow ships with the app)


if __name__ == "__main__":
    # quick self-test on a generated gradient image
    import tempfile
    d = tempfile.mkdtemp()
    src = os.path.join(d, "t.png")
    g = Image.new("RGB", (800, 600))
    px = g.load()
    for y in range(600):
        for x in range(0, 800, 4):
            px[x, y] = ((x * 255) // 800, (y * 255) // 600, 90)
            for k in range(1, 4):
                if x + k < 800:
                    px[x + k, y] = px[x, y]
    g.save(src)
    print("info   :", image_info(src))
    print("convert:", convert_image(src, "webp"))
    print("resize :", resize_image(src, max_side=320))
    print("compress(target 40kb):", compress_image(src, target_kb=40, fmt="jpg"))
    print("palette:", palette(src, 5))
