"""Integrated GStreamer playback backend rendered through SDL textures."""

from __future__ import annotations

import ctypes
import logging
import os
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable

import gi
import sdl2
from sdl2 import version as sdl_version

from r36s_dlna_browser.player.backend import PlayerBackend

gi.require_version("Gst", "1.0")
gi.require_version("GstApp", "1.0")
gi.require_version("GstVideo", "1.0")
from gi.repository import Gst, GstApp, GstVideo

log = logging.getLogger(__name__)

Gst.init(None)

# GStreamer element names for hardware video decoders, in priority order.
# mppvideodec: Rockchip MPP plugin (gst-mpp), uses /dev/vpu_service on kernel 4.4 BSP.
# v4l2*dec:    Linux V4L2 stateful decoders, available on kernel 5.4+ (rkvdec/hantro).
_HW_DECODER_ELEMENTS = [
    "mppvideodec",
    "v4l2h264dec",
    "v4l2h265dec",
    "v4l2vp8dec",
    "v4l2vp9dec",
]

# VPU device nodes that must be accessible for hardware decode on RK3326.
_HW_VPU_DEVICES = [
    "/dev/vpu_service",   # kernel 4.4 BSP (RK3326 / PX30)
    "/dev/mpp_service",   # newer BSP variants
    "/dev/video10",       # V4L2 vcodec on mainline/5.10+ kernels
    "/dev/video11",
]


def _vpu_device_accessible() -> bool:
    """Return True if at least one VPU device node can be opened by this process."""
    for path in _HW_VPU_DEVICES:
        try:
            fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
            os.close(fd)
            log.info("HW decode: VPU device accessible: %s", path)
            return True
        except OSError:
            pass
    return False


def _probe_hw_decoders(gst_module) -> list:
    """
    Probe for available hardware video decoder GStreamer elements.

    Hardware decode (mppvideodec / V4L2) is only auto-selected when
    R36S_HW_DECODE=1 is set in the environment.  By default the probe is a
    no-op: software avdec_h264 (libav, NEON-optimised) remains preferred
    because mppvideodec outputs NV12 which requires an additional software
    NV12→BGRA conversion step that outweighs the decode speedup.

    Set R36S_HW_DECODE=1 (e.g. in MatHacks.sh) to enable hardware decode with
    zero-copy NV12→SDL upload via SDL_UpdateNVTexture.

    Returns the list of element names whose rank was boosted.
    """
    if os.environ.get("R36S_HW_DECODE", "0") != "1":
        # Still probe and log availability so the user knows the plugin is
        # installed, but do not boost rank.
        if _vpu_device_accessible():
            for name in _HW_DECODER_ELEMENTS:
                if gst_module.ElementFactory.find(name) is not None:
                    log.info(
                        "HW decode: %s available (VPU accessible) — not auto-selected. "
                        "Set R36S_HW_DECODE=1 to enable (experimental).", name
                    )
        return []

    if not _vpu_device_accessible():
        log.info(
            "HW decode: R36S_HW_DECODE=1 but no accessible VPU device node — "
            "falling back to software decode. "
            "Run deploy/arkos/setup_hw_decode.sh as root to fix permissions."
        )
        return []

    found = []
    for name in _HW_DECODER_ELEMENTS:
        factory = gst_module.ElementFactory.find(name)
        if factory is not None:
            try:
                factory.set_rank(gst_module.Rank.PRIMARY + 1)
                found.append(name)
                log.info("HW decode: boosted rank of %s", name)
            except Exception as exc:  # pragma: no cover
                log.warning("HW decode: could not boost rank of %s: %s", name, exc)

    if not found:
        log.info(
            "HW decode: VPU device is accessible but no gst-mpp / V4L2 plugin found. "
            "Run deploy/arkos/setup_hw_decode.sh to install gstreamer1.0-rockchip-mpp."
        )
    return found


def _wm_subsystem_name(subsystem: int) -> str:
    mapping = {
        sdl2.SDL_SYSWM_UNKNOWN: "unknown",
        sdl2.SDL_SYSWM_WINDOWS: "windows",
        sdl2.SDL_SYSWM_X11: "x11",
        sdl2.SDL_SYSWM_DIRECTFB: "directfb",
        sdl2.SDL_SYSWM_COCOA: "cocoa",
        sdl2.SDL_SYSWM_UIKIT: "uikit",
        sdl2.SDL_SYSWM_WAYLAND: "wayland",
        sdl2.SDL_SYSWM_MIR: "mir",
        sdl2.SDL_SYSWM_WINRT: "winrt",
        sdl2.SDL_SYSWM_ANDROID: "android",
        sdl2.SDL_SYSWM_VIVANTE: "vivante",
        sdl2.SDL_SYSWM_OS2: "os2",
        sdl2.SDL_SYSWM_HAIKU: "haiku",
        sdl2.SDL_SYSWM_KMSDRM: "kmsdrm",
        sdl2.SDL_SYSWM_RISCOS: "riscos",
    }
    return mapping.get(subsystem, f"wm-{subsystem}")


def _resolve_window_subsystem(window: object) -> str:
    if not window:
        return "unbound"

    info = sdl2.SDL_SysWMinfo()
    sdl_version.SDL_VERSION(info.version)
    if not sdl2.SDL_GetWindowWMInfo(window, ctypes.byref(info)):
        return "unknown"
    return _wm_subsystem_name(info.subsystem)


def _as_uri(url: str) -> str:
    if "://" in url:
        return url
    return Gst.filename_to_uri(os.path.abspath(url))


class _NoopCallback:
    def __call__(self, *args: Any) -> None:
        return None


@dataclass
class _Frame:
    width: int
    height: int
    pitch: int
    pixels: bytes
    pixel_format: str = "BGRA"  # "BGRA" or "NV12"
    # For NV12: pitch is the Y-plane stride; uv_pixels is the interleaved UV plane.
    uv_pixels: bytes | None = None
    uv_pitch: int = 0


class GStreamerBackend(PlayerBackend):
    """Decode video with GStreamer and render frames through the SDL renderer."""

    def __init__(
        self,
        gst_module=Gst,
        gst_app_module=GstApp,
        gst_video_module=GstVideo,
        appsink_factory: Callable[[], Any] | None = None,
        playbin_factory: Callable[[], Any] | None = None,
        subsystem: str = "test",
    ) -> None:
        self._gst = gst_module
        self._gst_app = gst_app_module
        self._gst_video = gst_video_module
        self._appsink_factory = appsink_factory or self._default_appsink_factory
        self._playbin_factory = playbin_factory or self._default_playbin_factory
        self._event_callback: Callable[..., None] = _NoopCallback()
        self._window = None
        self._wm_subsystem = subsystem
        self._viewport = (640, 480, 0, 0, 0, 0)
        self._pipeline = None
        self._video_sink = None
        self._bus = None
        self._bus_thread = None
        self._bus_running = False
        self._lock = threading.RLock()
        self._frame_lock = threading.RLock()
        self._is_playing = False
        self._paused = False
        self._volume = 100
        self._buffer_percent = 0
        self._last_query = 0.0
        self._latest_frame: _Frame | None = None
        self._frame_dirty = False
        self._texture = None
        self._texture_renderer = None
        self._texture_size = (0, 0)
        self._texture_format: str = "BGRA"
        self._resolution = ""
        self._hw_decoders: list | None = None  # None = not yet probed

    def attach_window(self, window: object) -> None:
        self._window = window
        self._wm_subsystem = _resolve_window_subsystem(window)
        log.info("Playback window subsystem: %s", self._wm_subsystem)

    def set_event_callback(self, callback: Callable[..., None] | None) -> None:
        self._event_callback = callback or _NoopCallback()

    def set_viewport(self, width: int, height: int, top: int, bottom: int, left: int, right: int) -> None:
        self._viewport = (width, height, top, bottom, left, right)

    def has_new_frame(self) -> bool:
        with self._frame_lock:
            return self._frame_dirty

    def render(self, renderer: object) -> bool:
        with self._frame_lock:
            frame = self._latest_frame
            if frame is None:
                return False

            if (
                self._texture is None
                or self._texture_renderer != renderer
                or self._texture_size != (frame.width, frame.height)
                or self._texture_format != frame.pixel_format
            ):
                self._destroy_texture()
                sdl_fmt = (
                    sdl2.SDL_PIXELFORMAT_NV12
                    if frame.pixel_format == "NV12"
                    else sdl2.SDL_PIXELFORMAT_BGRA32
                )
                self._texture = sdl2.SDL_CreateTexture(
                    renderer,
                    sdl_fmt,
                    sdl2.SDL_TEXTUREACCESS_STREAMING,
                    frame.width,
                    frame.height,
                )
                if not self._texture:
                    log.error("Could not create SDL texture for video frame.")
                    return False
                self._texture_renderer = renderer
                self._texture_size = (frame.width, frame.height)
                self._texture_format = frame.pixel_format

            if self._frame_dirty:
                if frame.pixel_format == "NV12" and frame.uv_pixels is not None:
                    # Zero-copy NV12 path: upload Y and UV planes separately.
                    # SDL_UpdateNVTexture avoids a full BGRA conversion on CPU.
                    y_buf = ctypes.create_string_buffer(frame.pixels)
                    uv_buf = ctypes.create_string_buffer(frame.uv_pixels)
                    result = sdl2.SDL_UpdateNVTexture(
                        self._texture, None,
                        y_buf, frame.pitch,
                        uv_buf, frame.uv_pitch,
                    )
                else:
                    pixel_buffer = ctypes.create_string_buffer(frame.pixels)
                    result = sdl2.SDL_UpdateTexture(self._texture, None, pixel_buffer, frame.pitch)
                if result != 0:
                    log.error("Could not upload SDL video texture: %s", sdl2.SDL_GetError())
                    return False
                self._frame_dirty = False

            dst = self._fit_frame_to_viewport(frame.width, frame.height)
            sdl2.SDL_RenderCopy(renderer, self._texture, None, dst)
            return True

    def play(self, url: str) -> None:
        if not url:
            raise ValueError("Playback URL is empty.")
        pipeline = self._ensure_pipeline()
        uri = _as_uri(url)

        with self._lock:
            pipeline.set_state(self._gst.State.NULL)
            pipeline.set_property("uri", uri)
            self._paused = False
            self._buffer_percent = 100
            self._clear_frames()
            result = pipeline.set_state(self._gst.State.PLAYING)

        if result == self._gst.StateChangeReturn.FAILURE:
            raise RuntimeError(f"Failed to start SDL-rendered GStreamer playback: {uri}")

        self._set_playing(True, notify=False)
        self._event_callback("buffering", 100)
        log.info("Starting SDL-rendered GStreamer playback: %s", url)

    def stop(self) -> None:
        with self._lock:
            if self._pipeline is None:
                self._set_playing(False, notify=False)
                return
            self._pipeline.set_state(self._gst.State.NULL)
            self._paused = False
            self._clear_frames()
        self._set_playing(False, notify=True)

    def toggle_pause(self) -> bool:
        with self._lock:
            if self._pipeline is None:
                return False
            self._paused = not self._paused
            target = self._gst.State.PAUSED if self._paused else self._gst.State.PLAYING
            self._pipeline.set_state(target)
        self._event_callback("paused", self._paused)
        return self._paused

    def seek(self, seconds: int) -> None:
        with self._lock:
            if self._pipeline is None or seconds == 0:
                return
            ok_pos, current = self._pipeline.query_position(self._gst.Format.TIME)
            current_ns = current if ok_pos else 0
            target_ns = max(0, current_ns + int(seconds * self._gst.SECOND))
            self._pipeline.seek_simple(
                self._gst.Format.TIME,
                self._gst.SeekFlags.FLUSH | self._gst.SeekFlags.KEY_UNIT,
                target_ns,
            )

    def change_volume(self, delta: int) -> int:
        with self._lock:
            if self._pipeline is None:
                return self._volume
            self._volume = max(0, min(130, self._volume + delta))
            self._pipeline.set_property("volume", self._volume / 100.0)
        self._event_callback("volume", self._volume)
        return self._volume

    def shutdown(self) -> None:
        self._stop_bus_thread()
        with self._lock:
            if self._pipeline is not None:
                self._pipeline.set_state(self._gst.State.NULL)
                self._pipeline = None
                self._video_sink = None
                self._bus = None
            self._clear_frames()
        self._set_playing(False, notify=False)

    def is_playing(self) -> bool:
        return self._is_playing

    def _default_playbin_factory(self):
        pipeline = self._gst.ElementFactory.make("playbin", "player")
        if pipeline is None:
            raise RuntimeError("GStreamer playbin is not available.")
        return pipeline

    def _default_appsink_factory(self):
        sink = self._gst.ElementFactory.make("appsink", "video-output")
        if sink is None:
            raise RuntimeError("GStreamer appsink is not available.")
        return sink

    def _ensure_pipeline(self):
        with self._lock:
            if self._pipeline is not None:
                return self._pipeline

            if self._hw_decoders is None:
                self._hw_decoders = _probe_hw_decoders(self._gst)

            self._pipeline = self._playbin_factory()
            self._configure_playbin_flags(self._pipeline)
            self._video_sink = self._create_appsink()
            self._pipeline.set_property("video-sink", self._video_sink)
            self._pipeline.set_property("volume", self._volume / 100.0)
            self._bus = self._pipeline.get_bus()
            self._start_bus_thread()
            return self._pipeline

    def _configure_playbin_flags(self, pipeline) -> None:
        play_flags = getattr(self._gst, "PlayFlags", None)
        if play_flags is None:
            return

        flags = int(pipeline.get_property("flags"))
        for required in ("AUDIO", "VIDEO"):
            value = getattr(play_flags, required, None)
            if value is not None:
                flags |= int(value)

        for disabled in ("TEXT", "VIS"):
            value = getattr(play_flags, disabled, None)
            if value is not None:
                flags &= ~int(value)

        pipeline.set_property("flags", flags)

    def _create_appsink(self):
        sink = self._appsink_factory()
        sink.set_property("emit-signals", True)
        sink.set_property("sync", True)
        sink.set_property("max-buffers", 2)
        sink.set_property("drop", True)
        # Accept NV12 when hardware decode is active (avoids a software colourspace
        # conversion step); fall back to BGRA for the software-decode path.
        hw_active = os.environ.get("R36S_HW_DECODE", "0") == "1"
        if hw_active:
            caps_str = "video/x-raw,format=NV12;video/x-raw,format=BGRA"
        else:
            caps_str = "video/x-raw,format=BGRA"
        sink.set_property("caps", self._gst.Caps.from_string(caps_str))
        sink.connect("new-sample", self._on_new_sample)
        return sink

    def _on_new_sample(self, sink) -> Any:
        sample = sink.emit("pull-sample")
        if sample is None:
            return self._flow_ok()

        buffer = sample.get_buffer()
        caps = sample.get_caps()
        if buffer is None or caps is None:
            return self._flow_ok()

        info = self._gst_video.VideoInfo.new_from_caps(caps)
        if info is None:
            return self._flow_ok()

        width = int(info.width)
        height = int(info.height)
        resolution = f"{width}x{height}"

        # Detect pixel format from caps to choose the right upload path.
        fmt_str = "BGRA"
        if info.finfo is not None:
            try:
                fmt_str = info.finfo.name.upper()  # e.g. "NV12" or "BGRA"
            except Exception:
                pass

        if fmt_str == "NV12":
            # NV12: Y plane (stride[0]) followed immediately by interleaved UV plane (stride[1]).
            y_size = int(info.stride[0]) * height
            uv_size = int(info.stride[1]) * (height // 2)
            raw = buffer.extract_dup(0, buffer.get_size())
            pixels = raw[:y_size]
            uv_pixels = raw[y_size:y_size + uv_size]
            pitch = int(info.stride[0])
            uv_pitch = int(info.stride[1])
            frame = _Frame(
                width=width, height=height,
                pitch=pitch, pixels=pixels,
                pixel_format="NV12",
                uv_pixels=uv_pixels, uv_pitch=uv_pitch,
            )
        else:
            pitch = int(info.stride[0]) if info.stride else width * 4
            pixels = buffer.extract_dup(0, buffer.get_size())
            frame = _Frame(width=width, height=height, pitch=pitch, pixels=pixels)

        with self._frame_lock:
            self._latest_frame = frame
            self._frame_dirty = True
            if resolution != self._resolution:
                self._resolution = resolution
                self._event_callback("resolution", resolution)

        return self._flow_ok()

    def _flow_ok(self) -> Any:
        flow_return = getattr(self._gst, "FlowReturn", None)
        if flow_return is None:
            return 0
        return flow_return.OK

    def _start_bus_thread(self) -> None:
        if self._bus_thread is not None and self._bus_thread.is_alive():
            return
        self._bus_running = True
        self._bus_thread = threading.Thread(target=self._poll_bus, name="gst-bus", daemon=True)
        self._bus_thread.start()

    def _stop_bus_thread(self) -> None:
        self._bus_running = False
        if self._bus_thread and self._bus_thread.is_alive():
            self._bus_thread.join(timeout=1.0)
        self._bus_thread = None

    def _poll_bus(self) -> None:
        while self._bus_running:
            bus = self._bus
            if bus is None:
                time.sleep(0.05)
                continue

            message = bus.timed_pop_filtered(
                100 * self._gst.MSECOND,
                self._gst.MessageType.ERROR
                | self._gst.MessageType.EOS
                | self._gst.MessageType.BUFFERING
                | self._gst.MessageType.STATE_CHANGED,
            )
            if message is not None:
                self._handle_bus_message(message)

            if time.monotonic() - self._last_query >= 0.25:
                self._last_query = time.monotonic()
                self._emit_playback_metrics()

    def _handle_bus_message(self, message) -> None:
        msg_type = message.type
        if msg_type == self._gst.MessageType.EOS:
            log.info("SDL-rendered GStreamer playback reached end-of-stream")
            with self._lock:
                if self._pipeline is not None:
                    self._pipeline.set_state(self._gst.State.NULL)
                self._clear_frames()
            self._set_playing(False, notify=True)
            return

        if msg_type == self._gst.MessageType.ERROR:
            err, debug = message.parse_error()
            text = err.message if hasattr(err, "message") else str(err)
            if debug:
                text = f"{text} ({debug})"
            log.error("GStreamer playback error: %s", text)
            with self._lock:
                if self._pipeline is not None:
                    self._pipeline.set_state(self._gst.State.NULL)
                self._clear_frames()
            self._set_playing(False, notify=False)
            self._event_callback("error", text)
            return

        if msg_type == self._gst.MessageType.BUFFERING:
            percent = int(message.parse_buffering())
            self._buffer_percent = percent
            self._event_callback("buffering", percent)
            return

        if msg_type == self._gst.MessageType.STATE_CHANGED and message.src == self._pipeline:
            _old, new, _pending = message.parse_state_changed()
            paused = new == self._gst.State.PAUSED
            if new == self._gst.State.PLAYING:
                self._paused = False
                self._event_callback("paused", False)
            elif paused:
                self._paused = True
                self._event_callback("paused", True)

    def _emit_playback_metrics(self) -> None:
        with self._lock:
            pipeline = self._pipeline
            if pipeline is None or not self._is_playing:
                return
            ok_pos, position = pipeline.query_position(self._gst.Format.TIME)
            ok_dur, duration = pipeline.query_duration(self._gst.Format.TIME)
            volume = int(round(float(pipeline.get_property("volume") or 1.0) * 100))

        if ok_pos:
            self._event_callback("position", position / self._gst.SECOND)
        if ok_dur and duration > 0:
            self._event_callback("duration", duration / self._gst.SECOND)
        self._event_callback("volume", volume)
        self._event_callback("buffering", self._buffer_percent)
        if self._resolution:
            self._event_callback("resolution", self._resolution)

    def _fit_frame_to_viewport(self, frame_width: int, frame_height: int) -> sdl2.SDL_Rect:
        width, height, top, bottom, left, right = self._viewport
        viewport_w = max(1, width - left - right)
        viewport_h = max(1, height - top - bottom)
        if frame_width <= 0 or frame_height <= 0:
            return sdl2.SDL_Rect(left, top, viewport_w, viewport_h)

        scale = min(viewport_w / frame_width, viewport_h / frame_height)
        draw_w = max(1, int(frame_width * scale))
        draw_h = max(1, int(frame_height * scale))
        draw_x = left + (viewport_w - draw_w) // 2
        draw_y = top + (viewport_h - draw_h) // 2
        return sdl2.SDL_Rect(draw_x, draw_y, draw_w, draw_h)

    def _destroy_texture(self) -> None:
        if self._texture is not None:
            sdl2.SDL_DestroyTexture(self._texture)
            self._texture = None
        self._texture_renderer = None
        self._texture_size = (0, 0)
        self._texture_format = "BGRA"

    def _clear_frames(self) -> None:
        with self._frame_lock:
            self._latest_frame = None
            self._frame_dirty = False
            self._resolution = ""
            self._destroy_texture()

    def _set_playing(self, value: bool, notify: bool) -> None:
        changed = self._is_playing != value
        self._is_playing = value
        if changed and notify:
            self._event_callback("stopped" if not value else "started")