"""Echo cancellation tests: LiveKitAEC, PassthroughAEC, and factory behavior."""

import struct
from unittest.mock import MagicMock, patch

import pytest

from easycat.audio_format import PCM16_MONO_16K, PCM16_MONO_48K, AudioChunk
from easycat.echo_cancellation import (
    EchoCancellationConfig,
    LiveKitAEC,
    PassthroughAEC,
    _frame_samples_for_rate,
    create_echo_canceller,
)


def test_frame_samples_common_rates():
    """frame_samples_for_rate returns correct 10ms frame sizes."""
    assert _frame_samples_for_rate(8000) == 80
    assert _frame_samples_for_rate(16000) == 160
    assert _frame_samples_for_rate(24000) == 240
    assert _frame_samples_for_rate(48000) == 480


def test_frame_samples_uncommon_rate_rejected():
    """frame_samples_for_rate rejects rates WebRTC AEC does not support."""
    with pytest.raises(ValueError, match="Unsupported sample rate 44100"):
        _frame_samples_for_rate(44100)


# ── LiveKitAEC tests ──────────────────────────────────────────────


def test_livekit_aec_fails_without_library():
    """LiveKitAEC should raise ImportError if livekit is missing."""
    with (
        patch(
            "easycat.echo_cancellation.require_module",
            side_effect=ImportError("livekit unavailable"),
        ),
        pytest.raises(ImportError, match="livekit"),
    ):
        LiveKitAEC()


def _fake_audio_frame(data: bytes, **_kwargs: object) -> MagicMock:
    """Return a mock AudioFrame whose .data holds the raw bytes."""
    frame = MagicMock()
    frame.data = bytearray(data)
    return frame


@pytest.mark.asyncio
async def test_livekit_aec_process_mocked():
    """LiveKitAEC.process with mocked APM should process and reassemble frames."""
    mock_rtc = MagicMock()
    mock_apm = MagicMock()

    def mock_process_stream(frame_data: bytes) -> bytes:
        # Return the frame unchanged.
        return frame_data

    mock_apm.process_stream.side_effect = mock_process_stream
    mock_rtc.AudioProcessingModule.return_value = mock_apm
    mock_rtc.AudioFrame.side_effect = _fake_audio_frame

    with patch("easycat.echo_cancellation.require_module", return_value=mock_rtc):
        aec = LiveKitAEC()

    # 20ms at 16kHz = 320 samples = 640 bytes
    samples = [100] * 320
    data = struct.pack(f"<{len(samples)}h", *samples)
    chunk = AudioChunk(data=data, format=PCM16_MONO_16K)

    result = await aec.process(chunk)

    assert result.format == PCM16_MONO_16K
    assert len(result.data) == len(data)
    # 20ms = 2 frames of 10ms each
    assert mock_apm.process_stream.call_count == 2


@pytest.mark.asyncio
async def test_livekit_aec_process_buffers_subframe_remainder():
    """A sub-frame remainder is buffered rather than zero-padded mid-stream.

    AEC3 advances its adaptive filter a whole 10 ms frame at a time, so a
    chunk that is not an exact multiple of a frame must only submit its whole
    frames and carry the sub-frame tail forward to the next call.
    """
    mock_rtc = MagicMock()
    mock_apm = MagicMock()

    def mock_process_stream(frame_data: bytes) -> bytes:
        return frame_data

    mock_apm.process_stream.side_effect = mock_process_stream
    mock_rtc.AudioProcessingModule.return_value = mock_apm
    mock_rtc.AudioFrame.side_effect = _fake_audio_frame

    with patch("easycat.echo_cancellation.require_module", return_value=mock_rtc):
        aec = LiveKitAEC()

    # 15ms at 16kHz = 240 samples = 480 bytes; frame = 10ms = 160 samples = 320 bytes.
    # Only one whole frame (320 bytes) is processed; the remaining 160 bytes is buffered.
    samples = [200] * 240
    data = struct.pack(f"<{len(samples)}h", *samples)
    chunk = AudioChunk(data=data, format=PCM16_MONO_16K)

    result = await aec.process(chunk)

    assert mock_apm.process_stream.call_count == 1  # one whole 320-byte frame
    assert len(result.data) == 320  # no mid-stream zero-padding
    assert len(aec._near_buffer) == 160  # sub-frame remainder carried forward

    # A follow-up chunk of 80 samples (160 bytes) completes the buffered frame,
    # flushing exactly one more whole frame without padding.
    more = struct.pack("<80h", *([200] * 80))
    result2 = await aec.process(AudioChunk(data=more, format=PCM16_MONO_16K))

    assert mock_apm.process_stream.call_count == 2
    assert len(result2.data) == 320
    assert aec._near_buffer == b""


def test_livekit_aec_feed_reference_mocked():
    """LiveKitAEC.feed_reference with mocked APM should call process_reverse_stream."""
    mock_rtc = MagicMock()
    mock_apm = MagicMock()
    mock_rtc.AudioProcessingModule.return_value = mock_apm
    mock_rtc.AudioFrame.side_effect = _fake_audio_frame

    with patch("easycat.echo_cancellation.require_module", return_value=mock_rtc):
        aec = LiveKitAEC()

    # 10ms at 16kHz = 160 samples = 320 bytes (exactly 1 frame)
    samples = [50] * 160
    data = struct.pack(f"<{len(samples)}h", *samples)
    chunk = AudioChunk(data=data, format=PCM16_MONO_16K)

    aec.feed_reference(chunk)

    assert mock_apm.process_reverse_stream.call_count == 1


def test_livekit_aec_feed_reference_multiple_frames():
    """LiveKitAEC.feed_reference splits multi-frame chunks correctly."""
    mock_rtc = MagicMock()
    mock_apm = MagicMock()
    mock_rtc.AudioProcessingModule.return_value = mock_apm
    mock_rtc.AudioFrame.side_effect = _fake_audio_frame

    with patch("easycat.echo_cancellation.require_module", return_value=mock_rtc):
        aec = LiveKitAEC()

    # 30ms at 48kHz = 1440 samples = 2880 bytes -> 3 frames of 480 samples
    samples = [75] * 1440
    data = struct.pack(f"<{len(samples)}h", *samples)
    chunk = AudioChunk(data=data, format=PCM16_MONO_48K)

    aec.feed_reference(chunk)

    assert mock_apm.process_reverse_stream.call_count == 3


@pytest.mark.asyncio
async def test_livekit_aec_rejects_mismatched_near_far_rates():
    """AEC should reject far-end reference at a different rate than near-end."""
    mock_rtc = MagicMock()
    mock_apm = MagicMock()
    mock_rtc.AudioProcessingModule.return_value = mock_apm
    mock_rtc.AudioFrame.side_effect = _fake_audio_frame

    with patch("easycat.echo_cancellation.require_module", return_value=mock_rtc):
        aec = LiveKitAEC()

    near = AudioChunk(data=b"\x00\x00" * 160, format=PCM16_MONO_16K)
    await aec.process(near)

    far = AudioChunk(data=b"\x00\x00" * 480, format=PCM16_MONO_48K)
    with pytest.raises(ValueError, match="sample rates must match"):
        aec.feed_reference(far)


# ── PassthroughAEC tests ─────────────────────────────────────────


@pytest.mark.asyncio
async def test_passthrough_returns_unchanged():
    """PassthroughAEC should return the chunk unchanged."""
    aec = PassthroughAEC()
    chunk = AudioChunk(data=b"\x01\x02" * 80, format=PCM16_MONO_16K)
    result = await aec.process(chunk)
    assert result is chunk


def test_passthrough_feed_reference_noop():
    """PassthroughAEC.feed_reference should accept a chunk without error."""
    aec = PassthroughAEC()
    chunk = AudioChunk(data=b"\x00\x00" * 160, format=PCM16_MONO_16K)
    aec.feed_reference(chunk)  # should not raise


# ── Factory tests ────────────────────────────────────────────────


def test_factory_disabled_returns_passthrough():
    """Factory with enabled=False returns PassthroughAEC."""
    result = create_echo_canceller(EchoCancellationConfig(enabled=False))
    assert isinstance(result, PassthroughAEC)


def test_factory_default_returns_passthrough():
    """Factory with default config returns PassthroughAEC (disabled by default)."""
    result = create_echo_canceller()
    assert isinstance(result, PassthroughAEC)


def test_factory_enabled_without_livekit_falls_back():
    """Factory with enabled=True but no livekit falls back to passthrough."""
    with patch(
        "easycat.echo_cancellation.require_module",
        side_effect=ImportError("livekit unavailable"),
    ):
        result = create_echo_canceller(EchoCancellationConfig(enabled=True))
        assert isinstance(result, PassthroughAEC)


def test_factory_enabled_without_livekit_strict_fails():
    """Strict fallback policy should fail when enabled AEC cannot be loaded."""
    with (
        patch(
            "easycat.echo_cancellation.require_module",
            side_effect=ImportError("livekit unavailable"),
        ),
        pytest.raises(RuntimeError, match="LiveKit AEC is unavailable"),
    ):
        create_echo_canceller(EchoCancellationConfig(enabled=True, fallback_policy="error"))


def test_echo_cancellation_config_rejects_unknown_fallback_policy():
    """EchoCancellationConfig should reject unknown fallback policies."""
    with pytest.raises(ValueError, match="Unknown echo cancellation fallback_policy 'silent'"):
        EchoCancellationConfig(enabled=True, fallback_policy="silent")  # type: ignore[arg-type]


def test_factory_enabled_with_livekit():
    """Factory with enabled=True and livekit available returns LiveKitAEC."""
    mock_rtc = MagicMock()
    mock_rtc.AudioProcessingModule.return_value = MagicMock()

    with patch("easycat.echo_cancellation.require_module", return_value=mock_rtc):
        result = create_echo_canceller(EchoCancellationConfig(enabled=True))
        assert isinstance(result, LiveKitAEC)
