"""Tests for TimestampedRingBuffer."""

from __future__ import annotations

import numpy as np
import pytest

from noteflow.infrastructure.audio import TimestampedAudio, TimestampedRingBuffer
from tests.conftest import approx_float

# Test constants for ring buffer configuration
DEFAULT_MAX_DURATION_SEC = 30.0
"""Default maximum buffer duration in seconds (30 seconds)."""

CUSTOM_MAX_DURATION_SEC = 15.0
"""Custom maximum buffer duration in seconds for testing configuration."""


class TestTimestampedRingBuffer:
    """Tests for TimestampedRingBuffer class."""

    def test_init_with_valid_duration(self) -> None:
        """Test buffer initialization with valid max_duration."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        assert buffer.max_duration == 10.0, "max_duration should match configured value"
        assert buffer.duration == 0.0, "initial duration should be zero"
        assert buffer.chunk_count == 0, "initial chunk_count should be zero"

    def test_init_with_default_duration(self) -> None:
        """Test buffer uses default max_duration of 30 seconds."""
        buffer = TimestampedRingBuffer()
        assert buffer.max_duration == DEFAULT_MAX_DURATION_SEC, "default max_duration should be 30 seconds"

    def test_init_with_invalid_duration_raises(self) -> None:
        """Test buffer raises on non-positive max_duration."""
        with pytest.raises(ValueError, match="max_duration must be positive"):
            TimestampedRingBuffer(max_duration=0.0)

        with pytest.raises(ValueError, match="max_duration must be positive"):
            TimestampedRingBuffer(max_duration=-1.0)

    def test_push_single_chunk(self, sample_timestamped_audio: TimestampedAudio) -> None:
        """Test pushing single audio chunk."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        buffer.push(sample_timestamped_audio)

        assert buffer.chunk_count == 1, "chunk_count should be 1 after single push"
        assert buffer.duration == sample_timestamped_audio.duration, (
            "duration should match pushed audio duration"
        )

    def test_push_multiple_chunks(self, timestamped_audio_sequence: list[TimestampedAudio]) -> None:
        """Test pushing multiple audio chunks."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        for audio in timestamped_audio_sequence:
            buffer.push(audio)

        assert buffer.chunk_count == 10, "chunk_count should be 10 after pushing 10 chunks"
        assert buffer.duration == approx_float(1.0, rel=1e-9), (
            "duration should be 1.0s for 10 chunks of 0.1s each"
        )

    def test_push_evicts_old_at_capacity(self) -> None:
        """Test old chunks are evicted when buffer exceeds max_duration."""
        buffer = TimestampedRingBuffer(max_duration=0.5)  # 500ms max

        # Push 10 chunks of 0.1s each (1.0s total)
        for i in range(10):
            audio = TimestampedAudio(
                frames=np.zeros(1600, dtype=np.float32),
                timestamp=float(i) * 0.1,
                duration=0.1,
            )
            buffer.push(audio)

        # Should only keep ~5 chunks (0.5s worth)
        assert buffer.duration <= 0.5, "duration should not exceed max_duration of 0.5s"
        assert buffer.chunk_count <= 6, (
            "chunk_count should be at most 6 (may keep one extra during eviction)"
        )

    def test_get_window_returns_requested_duration(
        self, timestamped_audio_sequence: list[TimestampedAudio]
    ) -> None:
        """Test get_window returns chunks for requested duration."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        for audio in timestamped_audio_sequence:
            buffer.push(audio)

        # Request 0.3 seconds (should get ~3 chunks)
        window = buffer.get_window(0.3)
        total_duration = sum(a.duration for a in window)

        assert total_duration >= 0.3, "window duration should be at least 0.3s as requested"
        assert len(window) >= 3, "window should contain at least 3 chunks for 0.3s"

    def test_get_window_empty_returns_empty(self) -> None:
        """Test get_window on empty buffer returns empty list."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        window = buffer.get_window(1.0)

        assert window == [], "get_window on empty buffer should return empty list"

    def test_get_window_negative_returns_empty(
        self, sample_timestamped_audio: TimestampedAudio
    ) -> None:
        """Test get_window with negative duration returns empty list."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        buffer.push(sample_timestamped_audio)

        window = buffer.get_window(-1.0)
        assert window == [], "get_window with negative duration should return empty list"

    def test_get_window_zero_returns_empty(
        self, sample_timestamped_audio: TimestampedAudio
    ) -> None:
        """Test get_window with zero duration returns empty list."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        buffer.push(sample_timestamped_audio)

        window = buffer.get_window(0.0)
        assert window == [], "get_window with zero duration should return empty list"

    def test_get_window_exceeds_buffer_returns_all(
        self, timestamped_audio_sequence: list[TimestampedAudio]
    ) -> None:
        """Test get_window with duration > buffer returns all chunks."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        for audio in timestamped_audio_sequence:
            buffer.push(audio)

        window = buffer.get_window(100.0)  # Request more than available
        assert len(window) == 10, "get_window exceeding buffer should return all chunks"

    def test_get_window_chronological_order(
        self, populated_ring_buffer: TimestampedRingBuffer
    ) -> None:
        """Test get_window returns chunks in chronological order."""
        window = populated_ring_buffer.get_window(1.0)

        # Verify timestamps are increasing (generator expression, no loop)
        is_chronological = all(
            window[i].timestamp >= window[i - 1].timestamp for i in range(1, len(window))
        )
        assert is_chronological, "window chunks should be in chronological order"

    def test_get_all_returns_all_chunks(
        self, populated_ring_buffer: TimestampedRingBuffer
    ) -> None:
        """Test get_all returns all buffered chunks."""
        all_chunks = populated_ring_buffer.get_all()
        assert len(all_chunks) == 10, "should return all 10 chunks"

    def test_clear_removes_all(self, populated_ring_buffer: TimestampedRingBuffer) -> None:
        """Test clear removes all chunks and resets duration."""
        populated_ring_buffer.clear()

        assert populated_ring_buffer.chunk_count == 0, "chunk_count should be 0"
        assert populated_ring_buffer.duration == 0.0, "duration should be 0"
        assert len(populated_ring_buffer) == 0, "len should be 0"

    def test_ring_buffer_duration_property(
        self, populated_ring_buffer: TimestampedRingBuffer
    ) -> None:
        """Test duration property tracks total buffered duration."""
        # 10 chunks * 0.1s each = 1.0s total
        expected_duration = 1.0
        assert populated_ring_buffer.duration == approx_float(expected_duration, rel=1e-9), (
            "duration should match sum of all chunk durations"
        )

    def test_ring_buffer_empty_duration_is_zero(self) -> None:
        """Test duration is zero for empty buffer."""
        buffer = TimestampedRingBuffer(max_duration=10.0)  # seconds
        assert buffer.duration == 0.0, "empty buffer should have zero duration"

    def test_chunk_count_property(self, populated_ring_buffer: TimestampedRingBuffer) -> None:
        """Test chunk_count property tracks number of chunks."""
        assert populated_ring_buffer.chunk_count == 10, "should have 10 chunks"

    def test_max_duration_property(self) -> None:
        """Test max_duration property returns configured value."""
        buffer = TimestampedRingBuffer(max_duration=CUSTOM_MAX_DURATION_SEC)
        assert buffer.max_duration == CUSTOM_MAX_DURATION_SEC, "max_duration should return configured value"

    def test_len_returns_chunk_count(
        self, timestamped_audio_sequence: list[TimestampedAudio]
    ) -> None:
        """Test __len__ returns chunk count."""
        buffer = TimestampedRingBuffer(max_duration=10.0)
        for audio in timestamped_audio_sequence:
            buffer.push(audio)

        assert len(buffer) == buffer.chunk_count, "__len__ should equal chunk_count"
