"""Audio playback implementation using sounddevice.

Provide cross-platform audio output playback from ring buffer audio.
"""

from __future__ import annotations

import threading
from collections.abc import Callable
from enum import Enum, auto
from typing import TYPE_CHECKING

import numpy as np
import sounddevice as sd
from numpy.typing import NDArray

from noteflow.config.constants import DEFAULT_SAMPLE_RATE, POSITION_UPDATE_INTERVAL
from noteflow.infrastructure.logging import get_logger

if TYPE_CHECKING:
    from noteflow.infrastructure.audio.dto import TimestampedAudio

logger = get_logger(__name__)


class PlaybackState(Enum):
    """Playback state machine states."""

    STOPPED = auto()
    PLAYING = auto()
    PAUSED = auto()


class SoundDevicePlayback:
    """sounddevice-based implementation of AudioPlayback.

    Handle audio output playback with position tracking and state management.
    Thread-safe for UI callbacks.
    """

    def __init__(
        self,
        sample_rate: int = DEFAULT_SAMPLE_RATE,
        channels: int = 1,
        on_position_update: Callable[[float], None] | None = None,
    ) -> None:
        """Initialize the playback instance.

        Args:
            sample_rate: Sample rate in Hz (default 16kHz for ASR audio).
            channels: Number of channels (default 1 for mono).
            on_position_update: Optional callback for position updates during playback.
                               Called at ~100ms intervals with current position in seconds.
                               Runs in the audio thread, so keep the callback minimal.
        """
        self._sample_rate = sample_rate
        self._channels = channels

        # Position update callbacks (can have multiple subscribers)
        self._position_callbacks: list[Callable[[float], None]] = []
        if on_position_update is not None:
            self._position_callbacks.append(on_position_update)

        # Playback state
        self._state = PlaybackState.STOPPED
        self._lock = threading.Lock()

        # Audio data
        self._audio_data: NDArray[np.float32] | None = None
        self._total_samples: int = 0
        self._current_sample: int = 0

        # Position callback tracking
        self._callback_interval_samples = int(sample_rate * POSITION_UPDATE_INTERVAL)
        self._last_callback_sample: int = 0

        # Stream
        self._stream: sd.OutputStream | None = None

    def play(self, audio: list[TimestampedAudio]) -> None:
        """Start playback of audio chunks.

        Args:
            audio: List of TimestampedAudio chunks to play, ordered oldest to newest.
        """
        if not audio:
            logger.warning("No audio chunks to play")
            return

        with self._lock:
            # Stop any existing playback
            self._stop_internal()

            # Concatenate all audio frames
            frames = [chunk.frames for chunk in audio]
            self._audio_data = np.concatenate(frames).astype(np.float32)
            self._total_samples = len(self._audio_data)
            self._current_sample = 0
            self._last_callback_sample = 0

            # Create and start stream
            self._start_stream()
            self._state = PlaybackState.PLAYING

            logger.info(
                "Started playback: %d samples (%.2f seconds)",
                self._total_samples,
                self.total_duration,
            )

    def pause(self) -> None:
        """Pause playback.

        Safe to call even if not playing.
        """
        with self._lock:
            if self._state == PlaybackState.PLAYING and self._stream is not None:
                self._stream.stop()
                self._state = PlaybackState.PAUSED
                logger.debug("Paused playback at %.2f seconds", self.current_position)

    def resume(self) -> None:
        """Resume paused playback.

        No-op if not paused.
        """
        with self._lock:
            if self._state == PlaybackState.PAUSED and self._stream is not None:
                self._stream.start()
                self._state = PlaybackState.PLAYING
                logger.debug("Resumed playback from %.2f seconds", self.current_position)

    def stop(self) -> None:
        """Stop playback and reset position.

        Safe to call even if not playing.
        """
        position = 0.0
        with self._lock:
            if self._audio_data is not None:
                position = self._current_sample / self._sample_rate
            self._stop_internal()

        # Notify callbacks so UI can react to stop even if no final tick fired.
        self._notify_position_callbacks(position)

    def _stop_internal(self) -> None:
        """Internal stop without lock (caller must hold lock)."""
        if self._stream is not None:
            try:
                self._stream.stop()
                self._stream.close()
            except sd.PortAudioError as e:
                logger.warning("Error stopping playback stream: %s", e)
            finally:
                self._stream = None

        self._state = PlaybackState.STOPPED
        self._current_sample = 0
        self._audio_data = None
        self._total_samples = 0
        self._last_callback_sample = 0
        logger.debug("Stopped playback")

    def _start_stream(self) -> None:
        """Start the output stream (caller must hold lock)."""

        def _stream_callback(
            outdata: NDArray[np.float32],
            frames: int,
            time_info: object,
            status: sd.CallbackFlags,
        ) -> None:
            """Internal sounddevice output callback."""
            _ = time_info  # Unused

            if status:
                logger.warning("Playback stream status: %s", status)

            fire_callback = False
            position = 0.0

            with self._lock:
                if self._audio_data is None or self._state != PlaybackState.PLAYING:
                    # Output silence
                    outdata.fill(0)
                    return

                # Calculate how many samples we can provide
                available = self._total_samples - self._current_sample
                to_copy = min(frames, available)

                if to_copy > 0:
                    # Copy audio data to output buffer
                    outdata[:to_copy, 0] = self._audio_data[
                        self._current_sample : self._current_sample + to_copy
                    ]
                    self._current_sample += to_copy

                # Fill remaining with silence
                if to_copy < frames:
                    outdata[to_copy:] = 0

                # Check if we should fire position update callback
                elapsed = self._current_sample - self._last_callback_sample
                if elapsed >= self._callback_interval_samples:
                    fire_callback = True
                    position = self._current_sample / self._sample_rate
                    self._last_callback_sample = self._current_sample

                # Check if playback is complete
                if self._current_sample >= self._total_samples:
                    # Schedule stop on another thread to avoid deadlock
                    threading.Thread(target=self._on_playback_complete, daemon=True).start()

            # Fire callbacks outside lock to avoid potential deadlocks
            if fire_callback:
                self._notify_position_callbacks(position)

        try:
            self._stream = sd.OutputStream(
                channels=self._channels,
                samplerate=self._sample_rate,
                dtype=np.float32,
                callback=_stream_callback,
            )
            self._stream.start()
        except sd.PortAudioError as e:
            self._stream = None
            raise RuntimeError(f"Failed to start playback stream: {e}") from e

    def _on_playback_complete(self) -> None:
        """Handle playback completion."""
        logger.info("Playback completed")
        self.stop()

    def seek(self, position: float) -> bool:
        """Seek to a specific position in the audio.

        Thread-safe. Can be called from any thread.

        Args:
            position: Position in seconds from start of audio.

        Returns:
            True if seek was successful, False if no audio loaded or position out of bounds.
        """
        with self._lock:
            if self._audio_data is None:
                logger.warning("Cannot seek: no audio loaded")
                return False

            # Clamp position to valid range
            max_position = self._total_samples / self._sample_rate
            clamped_position = max(0.0, min(position, max_position))

            # Convert to sample position
            self._current_sample = int(clamped_position * self._sample_rate)
            # Reset callback sample so updates resume immediately after seek
            self._last_callback_sample = self._current_sample

            logger.debug(
                "Seeked to %.2f seconds (sample %d)",
                clamped_position,
                self._current_sample,
            )
            position_seconds = clamped_position

        # Notify callbacks to update UI/highlights immediately after seek
        self._notify_position_callbacks(position_seconds)
        return True

    def is_playing(self) -> bool:
        """Check if currently playing audio.

        Returns:
            True if playback is active (not paused or stopped).
        """
        with self._lock:
            return self._state == PlaybackState.PLAYING

    @property
    def current_position(self) -> float:
        """Current playback position in seconds from start of loaded audio."""
        with self._lock:
            return self._current_sample / self._sample_rate

    @property
    def total_duration(self) -> float:
        """Total duration of loaded audio in seconds."""
        with self._lock:
            return self._total_samples / self._sample_rate

    @property
    def state(self) -> PlaybackState:
        """Current playback state."""
        with self._lock:
            return self._state

    @property
    def sample_rate(self) -> int:
        """Sample rate in Hz."""
        return self._sample_rate

    @property
    def channels(self) -> int:
        """Number of channels."""
        return self._channels

    def add_position_callback(
        self,
        callback: Callable[[float], None],
    ) -> None:
        """Add a position update callback.

        Multiple callbacks can be registered. Each receives the current
        position in seconds during playback.

        Args:
            callback: Callback receiving current position in seconds.
        """
        if callback not in self._position_callbacks:
            self._position_callbacks.append(callback)

    def _notify_position_callbacks(self, position: float) -> None:
        """Notify all registered position callbacks.

        Runs without holding the playback lock to avoid deadlocks.
        """
        for callback in list(self._position_callbacks):
            try:
                callback(position)
            # INTENTIONAL BROAD HANDLER: User-provided callback
            # - External code can raise any exception
            # - Must not crash playback thread
            except Exception as e:
                logger.debug("Position update callback error: %s", e)

    def remove_position_callback(
        self,
        callback: Callable[[float], None],
    ) -> None:
        """Remove a position update callback.

        Args:
            callback: Previously registered callback to remove.
        """
        if callback in self._position_callbacks:
            self._position_callbacks.remove(callback)

    def set_position_callback(
        self,
        callback: Callable[[float], None] | None,
    ) -> None:
        """Set or clear the position update callback (replaces all callbacks).

        For backwards compatibility. Use add_position_callback/remove_position_callback
        for multiple subscribers.

        Args:
            callback: Callback receiving current position in seconds,
                      or None to clear all callbacks.
        """
        self._position_callbacks.clear()
        if callback is not None:
            self._position_callbacks.append(callback)
