"""Win11 OHD fireworks present via mpv D3D11 (GPU textured quad).

Jackpot text = osd-overlay bare ASS events, screen-centered with \\an5
(no \\pos — avoids OSD PlayRes mismatch shifting text).
Undulation = live \\fscx/\\fscy IPC updates (osd-overlay ignores ASS \\t timing).
FPS/hwdec status is log-only.
"""
from __future__ import annotations

import json
import math
import os
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any, BinaryIO, Optional

IS_WINDOWS = sys.platform == "win32"

_HWDEC = "d3d11va"
_VO = "gpu"
_GPU_API = "d3d11"

JACKPOT_SCALE_AMPLITUDE = 0.12  # +/- 12%
JACKPOT_SCALE_PERIOD_SEC = 2.2
JACKPOT_SCALE_HZ = 30.0
JACKPOT_OSD_ID = 2
HUD_OSD_ID = 1


def find_mpv_exe() -> Optional[Path]:
    """Prefer app-local runtime\\mpv\\mpv.exe (no WinGet Links)."""
    override = os.environ.get("DYZINEFRAME_MPV", "").strip()
    if override:
        p = Path(override)
        if p.is_file():
            return p

    app = Path(__file__).resolve().parent
    local = app / "runtime" / "mpv" / "mpv.exe"
    if local.is_file():
        return local

    which = shutil.which("mpv")
    if which:
        p = Path(which)
        if p.is_file():
            return p
    return None


def mpv_available() -> bool:
    return find_mpv_exe() is not None


def _ass_escape(text: str) -> str:
    return (
        text.replace("\\", "\\\\")
        .replace("{", "\\{")
        .replace("}", "\\}")
    )


def _jackpot_ass_event(*, amount_text: str, play_h: int, scale_pct: float) -> str:
    """Single centered ASS event (title + amount). No Dialogue: prefix, no \\pos."""
    title_fs = max(48, min(72, play_h // 14))
    amount_fs = max(112, min(200, play_h // 5))
    title = _ass_escape("JACKPOT WINNER")
    amount = _ass_escape(amount_text)
    sp = max(80, min(140, int(round(scale_pct))))
    # \\an5 = center of OSD. \\N = hard line break. Scale both lines together.
    return (
        f"{{\\an5\\fscx{sp}\\fscy{sp}\\fs{title_fs}"
        f"\\c&H0000D7FF&\\b1\\bord3\\shad0}}{title}"
        f"\\N{{\\fs{amount_fs}\\c&H00FFFFFF&\\b1\\bord4\\shad0}}{amount}"
    )


class MpvFireworksPresent:
    """Own one mpv child for a fireworks celebration."""

    def __init__(self) -> None:
        self._proc: Optional[subprocess.Popen] = None
        self._ipc_name: Optional[str] = None
        self._ipc_fh: Optional[BinaryIO] = None
        self._hwdec_logged = False
        self._last_hud = ""
        self._t0 = 0.0
        self._has_jackpot = False
        self._amount_text = ""
        self._screen_h = 1080
        self._jackpot_logged = False
        self._last_scale_pct = -1

    @property
    def ipc_pipe(self) -> Optional[str]:
        if not self._ipc_name:
            return None
        return rf"\\.\pipe\{self._ipc_name}"

    def is_running(self) -> bool:
        return self._proc is not None and self._proc.poll() is None

    def start(
        self,
        media: Path | str,
        *,
        screen_w: int,
        screen_h: int,
        loop_inf: bool,
        amount_text: str = "",
        duration_sec: float = 30.0,
        show_hud: bool = False,
    ) -> bool:
        """Start fullscreen D3D11 present. Returns False if mpv missing/fails."""
        del show_hud, duration_sec  # on-screen HUD off; duration owned by play loop
        self.stop()
        exe = find_mpv_exe()
        if exe is None:
            return False

        media_path = Path(media)
        if not media_path.is_file():
            print(f"mpv fireworks: media missing {media_path}", flush=True)
            return False

        geo_w = int(screen_w)
        geo_h = int(screen_h)
        self._screen_h = geo_h
        self._amount_text = amount_text or ""
        self._has_jackpot = bool(self._amount_text)

        self._ipc_name = f"dyzineframe-mpv-{os.getpid()}-{uuid.uuid4().hex[:8]}"
        ipc = self.ipc_pipe
        assert ipc is not None

        cmd: list[str] = [
            str(exe),
            "--no-config",
            "--really-quiet",
            "--keep-open=no",
            "--force-window=immediate",
            "--no-border",
            "--fullscreen",
            "--ontop=yes",
            "--background=color",
            "--background-color=#FF000000",
            f"--geometry={geo_w}x{geo_h}+0+0",
            f"--hwdec={_HWDEC}",
            f"--vo={_VO}",
            f"--gpu-api={_GPU_API}",
            "--osc=no",
            "--osd-level=0",
            "--no-input-default-bindings",
            "--input-cursor=no",
            "--cursor-autohide=always",
            f"--input-ipc-server={ipc}",
            "--loop=inf" if loop_inf else "--loop=no",
            str(media_path.resolve()),
        ]

        creationflags = 0
        if IS_WINDOWS:
            creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)

        err_fd, err_name = tempfile.mkstemp(prefix="dyzine-mpv-", suffix=".err")
        os.close(err_fd)
        err_path = Path(err_name)
        err_handle: Optional[BinaryIO] = None

        try:
            err_handle = open(err_path, "wb")
            self._proc = subprocess.Popen(
                cmd,
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=err_handle,
                creationflags=creationflags,
            )
        except OSError as exc:
            print(f"mpv fireworks: start failed: {exc}", flush=True)
            if err_handle is not None:
                try:
                    err_handle.close()
                except Exception:
                    pass
            try:
                err_path.unlink(missing_ok=True)
            except OSError:
                pass
            self._proc = None
            return False

        def _dump_mpv_err(prefix: str) -> None:
            if err_handle is not None:
                try:
                    err_handle.flush()
                except Exception:
                    pass
            try:
                text = err_path.read_text(encoding="utf-8", errors="replace").strip()
            except OSError:
                text = ""
            if text:
                for line in text.splitlines()[:8]:
                    safe = line.encode("ascii", errors="replace").decode("ascii")
                    print(f"{prefix}: {safe}", flush=True)

        deadline = time.perf_counter() + 5.0
        ipc_up = False
        vo_up = False
        while time.perf_counter() < deadline:
            if self._proc.poll() is not None:
                print(
                    f"mpv fireworks: exited early code={self._proc.returncode}",
                    flush=True,
                )
                _dump_mpv_err("mpv fireworks stderr")
                if err_handle is not None:
                    try:
                        err_handle.close()
                    except Exception:
                        pass
                try:
                    err_path.unlink(missing_ok=True)
                except OSError:
                    pass
                self._proc = None
                return False
            if not ipc_up:
                ipc_up = self._open_ipc()
            if ipc_up and not vo_up:
                vo = self._get_prop("vo-configured")
                if vo is True or vo == "yes" or vo == 1:
                    vo_up = True
                else:
                    tpos = self._get_prop("time-pos")
                    if tpos is not None:
                        try:
                            if float(tpos) >= 0.0:
                                vo_up = True
                        except (TypeError, ValueError):
                            pass
            if ipc_up and vo_up:
                break
            time.sleep(0.05)

        if err_handle is not None:
            try:
                err_handle.close()
            except Exception:
                pass
        try:
            err_path.unlink(missing_ok=True)
        except OSError:
            pass

        self._t0 = time.perf_counter()
        if self._has_jackpot:
            self.update_jackpot_scale(self._t0)

        print(
            f"mpv fireworks: started pid={self._proc.pid} hwdec={_HWDEC} "
            f"vo={_VO} gpu-api={_GPU_API} vo_ready={vo_up} "
            f"jackpot_undulate={self._has_jackpot}",
            flush=True,
        )
        return True

    def update_jackpot_scale(self, now: Optional[float] = None) -> None:
        """Rewrite centered jackpot overlay with sine \\fscx/\\fscy."""
        if not self._has_jackpot or not self.is_running():
            return
        t = (now if now is not None else time.perf_counter()) - self._t0
        period = max(0.25, float(JACKPOT_SCALE_PERIOD_SEC))
        amp = max(0.0, float(JACKPOT_SCALE_AMPLITUDE))
        scale_pct = 100.0 * (1.0 + amp * math.sin((2.0 * math.pi * t) / period))
        sp = max(80, min(140, int(round(scale_pct))))
        if sp == self._last_scale_pct and self._jackpot_logged:
            return
        self._last_scale_pct = sp
        data = _jackpot_ass_event(
            amount_text=self._amount_text,
            play_h=self._screen_h,
            scale_pct=float(sp),
        )
        msg = self._ipc_send(["osd-overlay", JACKPOT_OSD_ID, "ass-events", data])
        if not self._jackpot_logged:
            err = (msg or {}).get("error")
            print(
                f"mpv fireworks: jackpot overlay ipc={err or 'no-reply'} "
                f"center=an5 undulate=fscx",
                flush=True,
            )
            self._jackpot_logged = True

    def _open_ipc(self) -> bool:
        if self._ipc_fh is not None:
            return True
        pipe = self.ipc_pipe
        if not pipe:
            return False
        try:
            self._ipc_fh = open(pipe, "r+b", buffering=0)
            return True
        except OSError:
            self._ipc_fh = None
            return False

    def _close_ipc(self) -> None:
        fh = self._ipc_fh
        self._ipc_fh = None
        if fh is None:
            return
        try:
            fh.close()
        except OSError:
            pass

    def stop(self) -> None:
        if self.is_running():
            try:
                self._ipc_send(["osd-overlay", JACKPOT_OSD_ID, "none", ""])
                self._ipc_send(["osd-overlay", HUD_OSD_ID, "none", ""])
            except Exception:
                pass
            try:
                self._ipc_send(["quit"])
            except OSError:
                pass
        proc = self._proc
        self._proc = None
        self._close_ipc()
        if proc is not None and proc.poll() is None:
            try:
                proc.wait(timeout=2.0)
            except subprocess.TimeoutExpired:
                proc.kill()
                try:
                    proc.wait(timeout=2.0)
                except subprocess.TimeoutExpired:
                    pass
        self._ipc_name = None
        self._hwdec_logged = False
        self._last_hud = ""
        self._t0 = 0.0
        self._has_jackpot = False
        self._amount_text = ""
        self._jackpot_logged = False
        self._last_scale_pct = -1

    def _ipc_send(self, command: list[Any]) -> Optional[dict]:
        if not self.is_running():
            return None
        if self._ipc_fh is None and not self._open_ipc():
            return None
        fh = self._ipc_fh
        assert fh is not None
        payload = (json.dumps({"command": command}) + "\n").encode("utf-8")
        try:
            fh.write(payload)
            for _ in range(40):
                line = fh.readline()
                if not line:
                    break
                try:
                    msg = json.loads(line.decode("utf-8", errors="replace"))
                except json.JSONDecodeError:
                    continue
                if "error" in msg or "data" in msg:
                    return msg
        except OSError:
            self._close_ipc()
            return None
        return None

    def _get_prop(self, name: str) -> Any:
        msg = self._ipc_send(["get_property", name])
        if not msg or msg.get("error") != "success":
            return None
        return msg.get("data")

    def poll_status_log(self) -> Optional[str]:
        """One-shot log of real hwdec/vo/fps (no on-screen HUD)."""
        if not self.is_running() or self._hwdec_logged:
            return None

        hwdec = self._get_prop("hwdec-current")
        if hwdec is None or hwdec == "" or hwdec is False:
            hwdec_s = "no"
        else:
            hwdec_s = str(hwdec)

        vo = self._get_prop("current-vo")
        vo_s = str(vo) if vo else _VO

        fps = self._get_prop("estimated-display-fps")
        if fps is None:
            fps = self._get_prop("display-fps")
        try:
            fps_f = float(fps) if fps is not None else 0.0
        except (TypeError, ValueError):
            fps_f = 0.0

        age = time.perf_counter() - self._t0
        if hwdec_s == "no" and age < 1.5:
            return None

        self._hwdec_logged = True
        label = f"{fps_f:4.1f} fps hwdec={hwdec_s} vo={vo_s}"
        print(f"mpv fireworks: {label}", flush=True)
        self._last_hud = label
        return label


def play_with_mpv(
    media: Path | str,
    *,
    screen_w: int,
    screen_h: int,
    duration_sec: float = 0.0,
    loop_count: int = 1,
    amount_text: str = "",
    show_hud: bool = False,
    pump_events=None,
    before_stop=None,
    after_stop=None,
) -> bool:
    """Run celebration on mpv. Returns False if mpv could not start (caller falls back)."""
    if not IS_WINDOWS:
        return False

    loop_inf = bool(duration_sec and duration_sec > 0)
    present = MpvFireworksPresent()
    if not present.start(
        media,
        screen_w=screen_w,
        screen_h=screen_h,
        loop_inf=loop_inf,
        amount_text=amount_text or "",
        duration_sec=float(duration_sec) if duration_sec and duration_sec > 0 else 60.0,
        show_hud=False,
    ):
        return False

    if not loop_inf and int(loop_count) > 1:
        present._ipc_send(["set_property", "loop", max(0, int(loop_count) - 1)])

    deadline = (
        time.perf_counter() + float(duration_sec)
        if duration_sec and duration_sec > 0
        else None
    )
    last_status_t = 0.0
    last_scale_t = 0.0
    scale_dt = 1.0 / max(10.0, float(JACKPOT_SCALE_HZ))
    undulate = bool(amount_text)

    try:
        while present.is_running():
            if pump_events is not None:
                try:
                    pump_events()
                except Exception:
                    pass

            now = time.perf_counter()
            if deadline is not None and now >= deadline:
                break

            if undulate and (now - last_scale_t) >= scale_dt:
                present.update_jackpot_scale(now)
                last_scale_t = now

            if (now - last_status_t) >= 0.5:
                present.poll_status_log()
                last_status_t = now

            time.sleep(0.01)
    finally:
        if before_stop is not None:
            try:
                before_stop()
            except Exception:
                pass
        present.stop()
        if after_stop is not None:
            try:
                after_stop()
            except Exception:
                pass

    return True
