"""WASAPI-exclusive, native-rate audio playback for Windows.

This engine intentionally keeps its scope narrow: libsndfile decodes a source
at its original sample rate and PortAudio opens the selected WASAPI endpoint
in exclusive mode.  Integer PCM is sent without software volume processing.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import threading
from typing import Any

import numpy as np
import sounddevice as sd
import soundfile as sf
from PySide6.QtCore import QObject, QTimer, Signal, Slot

from .i18n import tr


class ExclusivePlaybackError(RuntimeError):
    """Raised when native exclusive playback cannot be configured."""


@dataclass(frozen=True, slots=True)
class AudioProbe:
    path: str
    sample_rate: int
    channels: int
    frames: int
    format: str
    subtype: str
    read_dtype: str
    stream_dtype: str

    @property
    def duration_ms(self) -> int:
        if not self.sample_rate:
            return 0
        return int(self.frames * 1000 / self.sample_rate)

    @property
    def format_text(self) -> str:
        bits = {
            "int16": "16 bit",
            "int24": "24 bit",
            "int32": "32 bit",
            "float32": "32 bit float",
        }.get(self.stream_dtype, self.stream_dtype)
        return f"{self.sample_rate / 1000:g} kHz / {bits} / {self.channels} ch"


def _native_dtypes(subtype: str) -> tuple[str, str]:
    subtype = subtype.upper()
    if subtype == "PCM_16":
        return "int16", "int16"
    if subtype == "PCM_24":
        # libsndfile expands 24-bit samples into left-aligned int32 values;
        # pack_native_audio() restores their three-byte representation.
        return "int32", "int24"
    if subtype == "PCM_32":
        return "int32", "int32"
    if subtype in {"FLOAT", "DOUBLE"}:
        return "float32", "float32"
    # Lossy and other libsndfile-supported sources are decoded to float.
    return "float32", "float32"


def stream_dtype_candidates(preferred: str) -> tuple[str, ...]:
    """Return native-first WASAPI formats without changing sample rate."""
    candidates = {
        "float32": ("float32", "int24", "int16"),
        "int32": ("int32", "int24", "int16"),
        "int24": ("int24", "int16"),
        "int16": ("int16", "int24"),
    }.get(preferred, (preferred,))
    return tuple(dict.fromkeys(candidates))


def probe_audio(path: str) -> AudioProbe:
    try:
        with sf.SoundFile(path) as source:
            read_dtype, stream_dtype = _native_dtypes(source.subtype)
            return AudioProbe(
                path=str(Path(path).resolve()),
                sample_rate=int(source.samplerate),
                channels=int(source.channels),
                frames=int(source.frames),
                format=str(source.format),
                subtype=str(source.subtype),
                read_dtype=read_dtype,
                stream_dtype=stream_dtype,
            )
    except (OSError, RuntimeError, sf.LibsndfileError) as exc:
        raise ExclusivePlaybackError(
            tr(
                "This format cannot be decoded by the WASAPI exclusive engine.",
                "この形式はWASAPI排他エンジンでデコードできません。",
            )
        ) from exc


def pack_native_audio(data: np.ndarray, stream_dtype: str) -> bytes:
    """Encode a SoundFile NumPy block for a RawOutputStream."""
    flat = np.ascontiguousarray(data).reshape(-1)
    kind = flat.dtype.kind

    def scaled_float(bits: int) -> np.ndarray:
        scale = float(1 << (bits - 1))
        maximum = (scale - 1.0) / scale
        clipped = np.clip(flat, -1.0, maximum)
        values = np.rint(clipped * scale).astype(np.int64)
        return np.clip(values, -(1 << (bits - 1)), (1 << (bits - 1)) - 1)

    if stream_dtype == "int24":
        if kind == "f":
            reduced = scaled_float(24).astype(np.int32)
        elif flat.dtype.itemsize <= 2:
            reduced = flat.astype(np.int32) << 8
        else:
            reduced = flat.astype(np.int32, copy=False) >> 8
        packed = np.empty((reduced.size, 3), dtype=np.uint8)
        unsigned = reduced.astype(np.uint32, copy=False)
        packed[:, 0] = unsigned & 0xFF
        packed[:, 1] = (unsigned >> 8) & 0xFF
        packed[:, 2] = (unsigned >> 16) & 0xFF
        return packed.tobytes()
    if stream_dtype == "int16":
        if kind == "f":
            converted = scaled_float(16).astype("<i2")
        elif flat.dtype.itemsize > 2:
            converted = (flat.astype(np.int32, copy=False) >> 16).astype("<i2")
        else:
            converted = flat.astype("<i2", copy=False)
        return converted.tobytes()
    if stream_dtype == "int32":
        if kind == "f":
            converted = scaled_float(32).astype("<i4")
        elif flat.dtype.itemsize <= 2:
            converted = (flat.astype(np.int32) << 16).astype("<i4")
        else:
            converted = flat.astype("<i4", copy=False)
        return converted.tobytes()
    if stream_dtype == "float32":
        if kind == "f":
            converted = flat.astype("<f4", copy=False)
        elif flat.dtype.itemsize <= 2:
            converted = flat.astype(np.float32) / float(1 << 15)
        else:
            converted = flat.astype(np.float32) / float(1 << 31)
        return converted.astype("<f4", copy=False).tobytes()
    raise ExclusivePlaybackError(
        tr(
            f"Unsupported output format: {stream_dtype}",
            f"未対応の出力形式です: {stream_dtype}",
        )
    )


def wasapi_output_devices() -> list[dict[str, Any]]:
    """Return PortAudio output endpoints exposed by the Windows WASAPI host."""
    try:
        host_apis = sd.query_hostapis()
        devices = sd.query_devices()
    except sd.PortAudioError:
        return []
    results: list[dict[str, Any]] = []
    for index, device in enumerate(devices):
        if int(device.get("max_output_channels", 0)) <= 0:
            continue
        host_index = int(device.get("hostapi", -1))
        if not 0 <= host_index < len(host_apis):
            continue
        host_name = str(host_apis[host_index].get("name", ""))
        if "WASAPI" not in host_name.upper():
            continue
        results.append(
            {
                "index": index,
                "name": str(device.get("name", f"Device {index}")),
                "channels": int(device.get("max_output_channels", 0)),
                "default_rate": int(float(device.get("default_samplerate", 0))),
            }
        )
    return results


class ExclusivePlayer(QObject):
    positionChanged = Signal(int)
    durationChanged = Signal(int)
    stateChanged = Signal(str)
    mediaEnded = Signal()
    gaplessTransition = Signal(str)
    errorOccurred = Signal(str)
    _streamFinished = Signal(bool, str)

    def __init__(self, parent: QObject | None = None) -> None:
        super().__init__(parent)
        self.path = ""
        self.device_index: int | None = None
        self.probe: AudioProbe | None = None
        self.active_stream_dtype = ""
        self.conversion_note = ""
        self.state = "stopped"
        self._position_frames = 0
        self._playback_start_frame = 0
        self._playback_end_frame = 0
        self._playback_range_active = False
        self._source: sf.SoundFile | None = None
        self._stream: sd.RawOutputStream | None = None
        self._end_reached = False
        self._callback_error = ""
        self._manual_close = False
        self._queue_lock = threading.Lock()
        self._queued_path = ""
        self._queued_probe: AudioProbe | None = None
        self._queued_source: sf.SoundFile | None = None
        self._visualizer_lock = threading.Lock()
        self._visualizer_samples = np.empty((0, 1), dtype=np.float32)

        self._position_timer = QTimer(self)
        self._position_timer.setInterval(100)
        self._position_timer.timeout.connect(self._emit_position)
        self._streamFinished.connect(self._handle_stream_finished)

    def set_device(self, device_index: int | None) -> None:
        if self.device_index == device_index:
            return
        was_playing = self.state == "playing"
        if was_playing:
            self.pause()
        self.device_index = device_index
        if was_playing:
            self.play()

    def set_source(self, path: str) -> AudioProbe:
        self.stop()
        self.path = path
        self.probe = probe_audio(path)
        self.active_stream_dtype = self.probe.stream_dtype
        self.conversion_note = ""
        self._position_frames = 0
        self._playback_start_frame = 0
        self._playback_end_frame = self.probe.frames
        self._playback_range_active = False
        self.durationChanged.emit(self.probe.duration_ms)
        self.positionChanged.emit(0)
        return self.probe

    def play(self) -> None:
        if not self.path or self.probe is None:
            return
        if self.device_index is None:
            raise ExclusivePlaybackError(
                tr(
                    "No WASAPI audio output device is selected.",
                    "WASAPIの音声出力デバイスが選択されていません。",
                )
            )
        if self.state == "playing":
            return
        if self._position_frames >= self.probe.frames:
            self._position_frames = 0
        self._open_stream()
        self.state = "playing"
        self.stateChanged.emit(self.state)
        self._position_timer.start()
        self._start_stream()

    def pause(self) -> None:
        if self.state != "playing":
            return
        self._close_stream()
        self.state = "paused"
        self.stateChanged.emit(self.state)
        self._position_timer.stop()
        self._emit_position()

    def stop(self) -> None:
        self._close_stream()
        with self._visualizer_lock:
            self._visualizer_samples = np.empty((0, 1), dtype=np.float32)
        self._position_frames = 0
        previous = self.state
        self.state = "stopped"
        self._position_timer.stop()
        self.positionChanged.emit(0)
        if previous != "stopped":
            self.stateChanged.emit(self.state)

    def set_position(self, milliseconds: int) -> None:
        if self.probe is None:
            return
        was_playing = self.state == "playing"
        if was_playing:
            self._close_stream()
        frame = int(milliseconds * self.probe.sample_rate / 1000)
        minimum = self._playback_start_frame
        maximum = self._playback_end_frame or self.probe.frames
        self._position_frames = max(minimum, min(frame, maximum))
        self._emit_position()
        if was_playing:
            self._open_stream()
            self.state = "playing"
            self._start_stream()

    def latest_visualizer_samples(self) -> np.ndarray:
        """Return a safe copy of the newest native PCM playback block."""

        with self._visualizer_lock:
            return self._visualizer_samples.copy()

    def set_playback_range(self, start_ms: int, end_ms: int = 0) -> None:
        """Limit playback to an absolute interval in the current source."""

        if self.probe is None:
            return
        was_playing = self.state == "playing"
        if was_playing:
            self._close_stream()
        start_frame = int(max(0, start_ms) * self.probe.sample_rate / 1000)
        end_frame = (
            int(max(0, end_ms) * self.probe.sample_rate / 1000)
            if end_ms
            else self.probe.frames
        )
        start_frame = min(start_frame, self.probe.frames)
        end_frame = max(start_frame, min(end_frame, self.probe.frames))
        self._playback_start_frame = start_frame
        self._playback_end_frame = end_frame
        self._playback_range_active = (
            start_frame > 0 or end_frame < self.probe.frames
        )
        self._position_frames = start_frame
        self._emit_position()
        if was_playing:
            self._open_stream()
            self.state = "playing"
            self._start_stream()

    def position(self) -> int:
        if self.probe is None or not self.probe.sample_rate:
            return 0
        return int(self._position_frames * 1000 / self.probe.sample_rate)

    def duration(self) -> int:
        return self.probe.duration_ms if self.probe else 0

    def output_format_text(self) -> str:
        if self.probe is None:
            return "—"
        dtype = self.active_stream_dtype or self.probe.stream_dtype
        bits = {
            "int16": "16 bit",
            "int24": "24 bit",
            "int32": "32 bit",
            "float32": "32 bit float",
        }.get(dtype, dtype)
        return (
            f"{self.probe.sample_rate / 1000:g} kHz / "
            f"{bits} / {self.probe.channels} ch"
        )

    def queue_source(self, path: str) -> bool:
        """Pre-open a compatible next source for a seamless stream handoff."""
        if self.state != "playing" or self.probe is None:
            self.clear_queued_source()
            return False
        try:
            next_probe = probe_audio(path)
        except ExclusivePlaybackError:
            self.clear_queued_source()
            return False
        compatible = (
            next_probe.sample_rate == self.probe.sample_rate
            and next_probe.channels == self.probe.channels
            and next_probe.stream_dtype == self.probe.stream_dtype
        )
        if not compatible:
            self.clear_queued_source()
            return False
        try:
            queued_source = sf.SoundFile(path)
        except (OSError, RuntimeError, sf.LibsndfileError):
            self.clear_queued_source()
            return False
        with self._queue_lock:
            previous = self._queued_source
            self._queued_path = path
            self._queued_probe = next_probe
            self._queued_source = queued_source
        if previous is not None:
            previous.close()
        return True

    def clear_queued_source(self) -> None:
        with self._queue_lock:
            queued, self._queued_source = self._queued_source, None
            self._queued_path = ""
            self._queued_probe = None
        if queued is not None:
            queued.close()

    def _open_stream(self) -> None:
        assert self.probe is not None
        self._close_stream()
        try:
            settings = sd.WasapiSettings(
                exclusive=True,
                auto_convert=False,
                explicit_sample_format=True,
            )
            self.active_stream_dtype = self._negotiate_stream_dtype(settings)
            self.conversion_note = self._conversion_description()
            self._source = sf.SoundFile(self.path)
            self._source.seek(self._position_frames)
            self._end_reached = False
            self._callback_error = ""
            self._stream = sd.RawOutputStream(
                samplerate=self.probe.sample_rate,
                channels=self.probe.channels,
                dtype=self.active_stream_dtype,
                device=self.device_index,
                # Music playback favors a stable hardware buffer over
                # interactive low latency.
                latency="high",
                blocksize=0,
                extra_settings=settings,
                callback=self._audio_callback,
                finished_callback=self._stream_finished,
            )
        except Exception as exc:
            self._close_stream()
            raise ExclusivePlaybackError(
                tr(
                    f"The DAC rejected exclusive output in {self.probe.format_text}: {exc}",
                    f"DACが {self.probe.format_text} の排他出力を受け付けません: {exc}",
                )
            ) from exc

    def _negotiate_stream_dtype(self, settings) -> str:
        assert self.probe is not None
        failures: list[str] = []
        for dtype in stream_dtype_candidates(self.probe.stream_dtype):
            try:
                sd.check_output_settings(
                    device=self.device_index,
                    channels=self.probe.channels,
                    dtype=dtype,
                    samplerate=self.probe.sample_rate,
                    extra_settings=settings,
                )
                return dtype
            except Exception as exc:
                failures.append(f"{dtype}: {exc}")
        attempted = ", ".join(stream_dtype_candidates(self.probe.stream_dtype))
        detail = failures[-1] if failures else tr(
            "no compatible format", "対応形式なし"
        )
        raise ExclusivePlaybackError(
            tr(
                f"The DAC rejected all attempted formats ({attempted}) at "
                f"{self.probe.sample_rate / 1000:g} kHz. {detail}",
                f"{self.probe.sample_rate / 1000:g} kHzで"
                f"試した形式（{attempted}）をDACが受理しません。{detail}",
            )
        )

    def _conversion_description(self) -> str:
        assert self.probe is not None
        if self.active_stream_dtype == self.probe.stream_dtype:
            if self.probe.subtype.startswith("PCM_"):
                return tr("Native", "ネイティブ")
            return tr("Decoded output", "デコード出力")
        source = {
            "int16": "16bit",
            "int24": "24bit",
            "int32": "32bit",
            "float32": "float32",
        }.get(self.probe.stream_dtype, self.probe.stream_dtype)
        destination = {
            "int16": "16bit",
            "int24": "24bit",
            "int32": "32bit",
            "float32": "float32",
        }.get(self.active_stream_dtype, self.active_stream_dtype)
        return tr(
            f"Converted {source}→{destination}",
            f"変換 {source}→{destination}",
        )

    def _start_stream(self) -> None:
        assert self._stream is not None
        try:
            self._stream.start()
        except Exception as exc:
            self._close_stream()
            self._position_timer.stop()
            self.state = "stopped"
            self.stateChanged.emit(self.state)
            raise ExclusivePlaybackError(
                tr(
                    f"Could not start the WASAPI exclusive stream: {exc}",
                    f"WASAPI排他ストリームを開始できません: {exc}",
                )
            ) from exc

    def _close_stream(self) -> None:
        self._manual_close = True
        # A delayed PortAudio finished callback from an explicit stop/pause
        # must never be mistaken for a naturally completed track.
        self._end_reached = False
        self._callback_error = ""
        stream, self._stream = self._stream, None
        if stream is not None:
            try:
                if stream.active:
                    stream.abort()
                stream.close()
            except sd.PortAudioError:
                pass
        source, self._source = self._source, None
        if source is not None:
            source.close()
        self.clear_queued_source()
        self._manual_close = False

    def _audio_callback(self, outdata, frames, time_info, status) -> None:
        del time_info
        if self._source is None or self.probe is None:
            outdata[:] = b"\0" * len(outdata)
            raise sd.CallbackAbort
        try:
            outdata[:] = b"\0" * len(outdata)
            frames_remaining = frames
            byte_offset = 0
            while frames_remaining > 0:
                assert self._source is not None
                assert self.probe is not None
                playback_end = self._playback_end_frame or self.probe.frames
                available_frames = playback_end - self._position_frames
                if available_frames <= 0:
                    if (
                        not self._playback_range_active
                        and self._activate_queued_source_from_callback()
                    ):
                        continue
                    self._end_reached = True
                    raise sd.CallbackStop
                frames_to_read = min(frames_remaining, available_frames)
                block = self._source.read(
                    frames_to_read,
                    dtype=self.probe.read_dtype,
                    always_2d=True,
                )
                with self._visualizer_lock:
                    self._visualizer_samples = block.copy()
                encoded = pack_native_audio(block, self.active_stream_dtype)
                outdata[byte_offset : byte_offset + len(encoded)] = encoded
                byte_offset += len(encoded)
                frames_read = len(block)
                frames_remaining -= frames_read
                self._position_frames += frames_read
                if self._position_frames >= playback_end:
                    if self._playback_range_active:
                        self._end_reached = True
                        raise sd.CallbackStop
                    if frames_remaining == 0:
                        break
                    if self._activate_queued_source_from_callback():
                        continue
                    self._end_reached = True
                    raise sd.CallbackStop
                if frames_remaining == 0:
                    break
                if frames_read < frames_to_read:
                    if self._activate_queued_source_from_callback():
                        continue
                    self._end_reached = True
                    raise sd.CallbackStop
                if self._activate_queued_source_from_callback():
                    continue
                self._end_reached = True
                raise sd.CallbackStop
            if status:
                self._callback_error = str(status)
        except sd.CallbackStop:
            raise
        except Exception as exc:
            self._callback_error = str(exc)
            raise sd.CallbackAbort from exc

    def _activate_queued_source_from_callback(self) -> bool:
        with self._queue_lock:
            if self._queued_source is None or self._queued_probe is None:
                return False
            next_source = self._queued_source
            next_probe = self._queued_probe
            next_path = self._queued_path
            self._queued_source = None
            self._queued_probe = None
            self._queued_path = ""
        previous_source = self._source
        self._source = next_source
        self.path = next_path
        self.probe = next_probe
        self._position_frames = 0
        self._playback_start_frame = 0
        self._playback_end_frame = next_probe.frames
        self._playback_range_active = False
        self._end_reached = False
        self.conversion_note = self._conversion_description()
        if previous_source is not None:
            previous_source.close()
        self.durationChanged.emit(next_probe.duration_ms)
        self.positionChanged.emit(0)
        self.gaplessTransition.emit(next_path)
        return True

    def _stream_finished(self) -> None:
        if self._manual_close:
            return
        self._streamFinished.emit(self._end_reached, self._callback_error)

    @Slot(bool, str)
    def _handle_stream_finished(self, ended: bool, error: str) -> None:
        self._position_timer.stop()
        self._close_stream()
        self.state = "stopped"
        self.stateChanged.emit(self.state)
        self._emit_position()
        if error and not ended:
            self.errorOccurred.emit(error)
        elif ended:
            self.mediaEnded.emit()

    def _emit_position(self) -> None:
        self.positionChanged.emit(self.position())
