"""Tests for SoundDeviceCapture."""

from __future__ import annotations

from collections.abc import Callable
from types import SimpleNamespace

import numpy as np
import pytest
from numpy.typing import NDArray

from noteflow.config.constants import DEFAULT_SAMPLE_RATE
from noteflow.infrastructure.audio import SoundDeviceCapture

# Test constants for audio capture configuration
CUSTOM_SAMPLE_RATE_HZ = 44100
"""Custom sample rate in Hz for testing non-default audio capture configuration."""

# Type alias for sounddevice callback signature
StreamCallback = Callable[[NDArray[np.float32], int, object, int], None]


class _DummyStream:
    """Mock InputStream that invokes callback on start."""

    def __init__(self, *, callback: StreamCallback, **_: object) -> None:
        self.callback: StreamCallback = callback
        self.active = False

    def start(self) -> None:
        self.active = True
        data = np.zeros((4, 1), dtype=np.float32)
        self.callback(data, len(data), None, 0)

    def stop(self) -> None:
        self.active = False

    def close(self) -> None:
        self.active = False


def _apply_sounddevice_stubs(monkeypatch: pytest.MonkeyPatch) -> None:
    """Apply monkeypatches to stub out sounddevice module."""
    monkeypatch.setattr("noteflow.infrastructure.audio.capture.sd.InputStream", _DummyStream)
    monkeypatch.setattr("noteflow.infrastructure.audio.capture.sd.PortAudioError", RuntimeError)
    monkeypatch.setattr("noteflow.infrastructure.audio.capture.sd.CallbackFlags", int)
    monkeypatch.setattr(
        "noteflow.infrastructure.audio.capture.sd.query_devices",
        lambda: [{"name": "Mic", "max_input_channels": 1, "default_samplerate": DEFAULT_SAMPLE_RATE}],
    )
    monkeypatch.setattr("noteflow.infrastructure.audio.capture.sd.default", SimpleNamespace(device=(0, 1)))


class TestSoundDeviceCapture:
    """Tests for SoundDeviceCapture class."""

    @pytest.fixture
    def capture(self) -> SoundDeviceCapture:
        """Create SoundDeviceCapture instance."""
        return SoundDeviceCapture()

    def test_init_defaults(self, capture: SoundDeviceCapture) -> None:
        """Test capture initializes with correct defaults."""
        assert capture.sample_rate == DEFAULT_SAMPLE_RATE, "sample_rate should default to DEFAULT_SAMPLE_RATE"
        assert capture.channels == 1, "channels should default to 1 (mono)"
        assert capture.current_device_id is None, "current_device_id should be None before start"

    def test_is_capturing_initially_false(self, capture: SoundDeviceCapture) -> None:
        """Test is_capturing returns False when not started."""
        assert capture.is_capturing() is False, "is_capturing should return False when not started"

    def test_list_devices_returns_list(self, capture: SoundDeviceCapture) -> None:
        """Test list_devices returns a list (may be empty in CI)."""
        devices = capture.list_devices()
        assert isinstance(devices, list), "list_devices should return a list"

    def test_get_default_device_returns_without_error(self, capture: SoundDeviceCapture) -> None:
        """Test get_default_device returns DeviceInfo or None without raising."""
        device = capture.get_default_device()
        # In CI environments without audio hardware, device will be None
        # This test just verifies the method returns without error
        assert device is None or device is not None, "get_default_device should return DeviceInfo or None"

    def test_get_default_device_properties_when_available(self, capture: SoundDeviceCapture) -> None:
        """Test get_default_device returns valid device properties when available.

        Note: This test is skipped in CI environments without audio devices.
        """
        device = capture.get_default_device()
        if device is None:
            pytest.skip("No default audio device available")
        assert device is not None, "Device should be available for property checks"
        assert device.device_id >= 0, "device_id should be non-negative"
        assert isinstance(device.name, str), "device name should be a string"
        assert device.channels > 0, "device should have at least one channel"

    def test_stop_when_not_capturing_is_safe(self, capture: SoundDeviceCapture) -> None:
        """Test stop() is safe to call when not capturing."""
        # Should not raise
        capture.stop()
        assert capture.is_capturing() is False, "is_capturing should remain False after stop on idle capture"

    def test_start_when_already_capturing_raises(self, capture: SoundDeviceCapture) -> None:
        """Test start() raises if already capturing.

        Note: This test may be skipped in CI without audio devices.
        """
        devices = capture.list_devices()
        pytest.skip("No audio devices available") if not devices else None

        def dummy_callback(frames: NDArray[np.float32], timestamp: float) -> None:
            pass

        try:
            capture.start(
                device_id=None,
                on_frames=dummy_callback,
                sample_rate=DEFAULT_SAMPLE_RATE,
                channels=1,
            )

            # Second start should raise
            with pytest.raises(RuntimeError, match="Already capturing"):
                capture.start(
                    device_id=None,
                    on_frames=dummy_callback,
                )
        finally:
            capture.stop()

    def test_properties_after_start(self, capture: SoundDeviceCapture) -> None:
        """Test properties reflect configured values after start.

        Note: This test may be skipped in CI without audio devices.
        """
        devices = capture.list_devices()
        pytest.skip("No audio devices available") if not devices else None

        def dummy_callback(frames: NDArray[np.float32], timestamp: float) -> None:
            pass

        try:
            capture.start(
                device_id=None,
                on_frames=dummy_callback,
                sample_rate=CUSTOM_SAMPLE_RATE_HZ,
                channels=1,
            )

            assert capture.sample_rate == CUSTOM_SAMPLE_RATE_HZ, "sample_rate should reflect configured value"
            assert capture.channels == 1, "channels should reflect configured value"
            assert capture.is_capturing() is True, "is_capturing should return True after start"
        finally:
            capture.stop()

    def test_stubbed_stream_invokes_callback(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Start with stubbed stream invokes the on_frames callback."""
        _apply_sounddevice_stubs(monkeypatch)
        captured: list[tuple[NDArray[np.float32], float]] = []

        def on_frames(frames: NDArray[np.float32], timestamp: float) -> None:
            captured.append((frames, timestamp))

        capture = SoundDeviceCapture()
        capture.start(device_id=None, on_frames=on_frames, sample_rate=DEFAULT_SAMPLE_RATE, channels=1)

        assert len(captured) == 1, "callback should have been invoked exactly once"
        _, timestamp = captured[0]
        assert isinstance(timestamp, float), "timestamp should be a float"
        capture.stop()

    def test_stubbed_stream_is_capturing_true_while_active(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """is_capturing returns True while stream is active."""
        _apply_sounddevice_stubs(monkeypatch)

        def noop_callback(frames: NDArray[np.float32], timestamp: float) -> None:
            """No-op callback for testing."""
            pass

        capture = SoundDeviceCapture()
        capture.start(device_id=None, on_frames=noop_callback, sample_rate=DEFAULT_SAMPLE_RATE, channels=1)

        assert capture.is_capturing() is True, "is_capturing should return True while stream is active"
        capture.stop()

    def test_stubbed_stream_is_capturing_false_after_stop(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """is_capturing returns False after stop is called."""
        _apply_sounddevice_stubs(monkeypatch)

        def noop_callback(frames: NDArray[np.float32], timestamp: float) -> None:
            """No-op callback for testing."""
            pass

        capture = SoundDeviceCapture()
        capture.start(device_id=None, on_frames=noop_callback, sample_rate=DEFAULT_SAMPLE_RATE, channels=1)
        capture.stop()

        assert capture.is_capturing() is False, "is_capturing should return False after stop"

    def test_start_wraps_portaudio_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """PortAudio errors should be converted to RuntimeError."""

        class DummyError(Exception): ...

        def failing_stream(**_: object) -> None:
            raise DummyError("boom")

        monkeypatch.setattr("noteflow.infrastructure.audio.capture.sd.InputStream", failing_stream)
        monkeypatch.setattr("noteflow.infrastructure.audio.capture.sd.PortAudioError", DummyError)

        def noop_callback(frames: NDArray[np.float32], timestamp: float) -> None:
            """No-op callback for testing."""
            pass

        capture = SoundDeviceCapture()
        with pytest.raises(RuntimeError, match="Failed to start audio capture"):
            capture.start(device_id=None, on_frames=noop_callback)
