"""Runtime live stream abstractions for frame production.

LiveStream is the runtime counterpart of PictureSource (config/storage layer).
Each PictureSource type maps to a LiveStream implementation:

    ScreenCapturePictureSource  →  ScreenCaptureLiveStream
    ProcessedPictureSource      →  ProcessedLiveStream
    StaticImagePictureSource    →  StaticImageLiveStream

LiveStreams are managed by LiveStreamManager which handles sharing and
reference counting — multiple devices using the same PictureSource
share a single LiveStream instance.
"""

import threading
import time
from abc import ABC, abstractmethod
from typing import List, Optional

import numpy as np

from wled_controller.core.capture_engines.base import CaptureStream, ScreenCapture
from wled_controller.core.filters import ImagePool, PostprocessingFilter
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer

logger = get_logger(__name__)


class LiveStream(ABC):
    """Abstract base for a runtime frame source.

    A LiveStream produces frames at some frequency. Consumers call
    get_latest_frame() to read the most recent frame (non-blocking).
    """

    @property
    @abstractmethod
    def target_fps(self) -> int:
        """Frame rate this stream targets."""

    @property
    @abstractmethod
    def display_index(self) -> Optional[int]:
        """Display index being captured, or None for non-capture streams."""

    @abstractmethod
    def start(self) -> None:
        """Start producing frames. Called once when the stream is first needed."""

    @abstractmethod
    def stop(self) -> None:
        """Stop producing frames and release resources."""

    @abstractmethod
    def get_latest_frame(self) -> Optional[ScreenCapture]:
        """Get the most recent frame.

        Returns:
            ScreenCapture with image data (RGB), or None if no frame available yet.
        """


class ScreenCaptureLiveStream(LiveStream):
    """Live stream backed by a CaptureStream with a dedicated capture thread.

    Runs a background thread that captures frames at the target FPS and stores
    the latest frame under a lock. Consumers read the cached frame via
    get_latest_frame() (non-blocking).

    The dedicated thread naturally satisfies thread affinity requirements
    for capture libraries (DXGI, GDI, WGC).
    """

    def __init__(self, capture_stream: CaptureStream, fps: int):
        self._capture_stream = capture_stream
        self._fps = fps
        self._frame_time = 1.0 / fps if fps > 0 else 1.0
        self._latest_frame: Optional[ScreenCapture] = None
        self._frame_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None

    @property
    def target_fps(self) -> int:
        return self._fps

    @property
    def display_index(self) -> Optional[int]:
        return self._capture_stream.display_index

    def start(self) -> None:
        if self._running:
            return

        self._capture_stream.initialize()
        self._running = True
        self._thread = threading.Thread(
            target=self._capture_loop,
            name=f"live-capture-{self._capture_stream.display_index}",
            daemon=True,
        )
        self._thread.start()
        logger.info(
            f"ScreenCaptureLiveStream started "
            f"(display={self._capture_stream.display_index}, fps={self._fps})"
        )

    def stop(self) -> None:
        if not self._running:
            return

        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning("Capture thread did not terminate within 5s")
            self._thread = None

        self._capture_stream.cleanup()
        self._latest_frame = None
        logger.info(
            f"ScreenCaptureLiveStream stopped "
            f"(display={self._capture_stream.display_index})"
        )

    def get_latest_frame(self) -> Optional[ScreenCapture]:
        with self._frame_lock:
            return self._latest_frame

    def _capture_loop(self) -> None:
        frame_time = self._frame_time
        consecutive_errors = 0
        try:
            with high_resolution_timer():
                while self._running:
                    loop_start = time.perf_counter()
                    try:
                        frame = self._capture_stream.capture_frame()
                        if frame is not None:
                            with self._frame_lock:
                                self._latest_frame = frame
                            consecutive_errors = 0
                        else:
                            # Small sleep when no frame available to avoid CPU spinning
                            time.sleep(0.001)
                    except Exception as e:
                        consecutive_errors += 1
                        logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}")
                        # Backoff on repeated errors to avoid CPU spinning
                        if consecutive_errors > 5:
                            backoff = min(1.0, 0.1 * (consecutive_errors - 5))
                            time.sleep(backoff)
                            continue

                    # Throttle to target FPS
                    elapsed = time.perf_counter() - loop_start
                    remaining = frame_time - elapsed
                    if remaining > 0:
                        time.sleep(remaining)
        except Exception as e:
            logger.error(
                f"Fatal capture loop error (display={self._capture_stream.display_index}): {e}",
                exc_info=True,
            )
        finally:
            self._running = False


class ProcessedLiveStream(LiveStream):
    """Live stream that applies postprocessing filters to a source stream.

    A background thread polls the source for new frames, applies the filter
    chain, and caches the result.  Consumers call get_latest_frame() which
    returns the pre-computed result under a lock (sub-microsecond).
    """

    def __init__(
        self,
        source: LiveStream,
        filters: List[PostprocessingFilter],
    ):
        self._source = source
        self._filters = filters
        self._image_pool = ImagePool()
        self._latest_frame: Optional[ScreenCapture] = None
        self._frame_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        # True when at least one filter requests idle-tick processing (e.g. frame interpolation).
        # The processing loop then runs at 2× source rate and runs the full filter chain on idle
        # ticks so those filters can produce interpolated output.
        self._has_idle_filters: bool = any(
            getattr(f, "supports_idle_frames", False) for f in filters
        )

    @property
    def target_fps(self) -> int:
        base = self._source.target_fps
        return base * 2 if self._has_idle_filters else base

    @property
    def display_index(self) -> Optional[int]:
        return self._source.display_index

    def start(self) -> None:
        # Source lifecycle managed by LiveStreamManager; only start our thread
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._process_loop,
            name="processed-stream",
            daemon=True,
        )
        self._thread.start()
        logger.info("ProcessedLiveStream background thread started")

    def stop(self) -> None:
        # Source lifecycle managed by LiveStreamManager; only stop our thread
        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning("ProcessedLiveStream thread did not terminate within 5s")
            self._thread = None
        self._latest_frame = None

    def get_latest_frame(self) -> Optional[ScreenCapture]:
        with self._frame_lock:
            return self._latest_frame

    def _process_loop(self) -> None:
        """Background thread: poll source, apply filters, cache result."""
        cached_source_frame: Optional[ScreenCapture] = None
        # Ring buffer: 3 slots guarantees consumer finished with oldest buffer.
        # At most 2 frames are in flight (one in _latest_frame, one being
        # processed by a consumer), so the 3rd slot is always safe to reuse.
        _ring: List[Optional[np.ndarray]] = [None, None, None]
        _ring_idx = 0
        # Separate buffer for idle-tick source copies (not part of the ring buffer)
        _idle_src_buf: Optional[np.ndarray] = None
        fps = self.target_fps
        frame_time = 1.0 / fps if fps > 0 else 1.0

        try:
            with high_resolution_timer():
                while self._running:
                    loop_start = time.perf_counter()
                    try:
                        source_frame = self._source.get_latest_frame()
                        if source_frame is None or source_frame is cached_source_frame:
                            # Idle tick — run filter chain when any filter requests idle processing
                            if self._has_idle_filters and cached_source_frame is not None:
                                src = cached_source_frame.image
                                h, w, c = src.shape
                                if _idle_src_buf is None or _idle_src_buf.shape != (h, w, c):
                                    _idle_src_buf = np.empty((h, w, c), dtype=np.uint8)
                                np.copyto(_idle_src_buf, src)
                                idle_image = _idle_src_buf

                                for f in self._filters:
                                    result = f.process_image(idle_image, self._image_pool)
                                    if result is not None:
                                        if idle_image is not _idle_src_buf:
                                            self._image_pool.release(idle_image)
                                        idle_image = result

                                # Only publish a new frame when the filter chain produced actual
                                # interpolated output (idle_image advanced past the input buffer).
                                if idle_image is not _idle_src_buf:
                                    processed = ScreenCapture(
                                        image=idle_image,
                                        width=idle_image.shape[1],
                                        height=idle_image.shape[0],
                                        display_index=cached_source_frame.display_index,
                                    )
                                    with self._frame_lock:
                                        self._latest_frame = processed

                            elapsed = time.perf_counter() - loop_start
                            remaining = frame_time - elapsed
                            time.sleep(max(remaining, 0.001))
                            continue

                        cached_source_frame = source_frame

                        # Reuse ring buffer slot instead of allocating a new copy each frame
                        src = source_frame.image
                        h, w, c = src.shape
                        buf = _ring[_ring_idx]
                        if buf is None or buf.shape != (h, w, c):
                            buf = np.empty((h, w, c), dtype=np.uint8)
                            _ring[_ring_idx] = buf
                        _ring_idx = (_ring_idx + 1) % 3

                        np.copyto(buf, src)
                        image = buf

                        for f in self._filters:
                            result = f.process_image(image, self._image_pool)
                            if result is not None:
                                # Release intermediate filter output back to pool
                                # (don't release the ring buffer itself)
                                if image is not buf:
                                    self._image_pool.release(image)
                                image = result

                        processed = ScreenCapture(
                            image=image,
                            width=image.shape[1],
                            height=image.shape[0],
                            display_index=source_frame.display_index,
                        )
                        with self._frame_lock:
                            self._latest_frame = processed
                    except Exception as e:
                        logger.error(f"Filter processing error: {e}")
                        time.sleep(0.01)
        except Exception as e:
            logger.error(f"Fatal processing loop error: {e}", exc_info=True)
        finally:
            self._running = False


class StaticImageLiveStream(LiveStream):
    """Live stream that always returns the same static image."""

    def __init__(self, image: np.ndarray):
        self._image = image
        h, w = image.shape[:2]
        self._frame = ScreenCapture(
            image=image, width=w, height=h, display_index=-1
        )

    @property
    def target_fps(self) -> int:
        return 1

    @property
    def display_index(self) -> Optional[int]:
        return None

    def start(self) -> None:
        pass

    def stop(self) -> None:
        pass

    def get_latest_frame(self) -> Optional[ScreenCapture]:
        return self._frame
