"""Tests for BatchManager."""

import asyncio
from datetime import UTC, datetime
from unittest.mock import AsyncMock

import pytest

from ingress_edge.batch import BatchManager
from ingress_edge.types import IngressDataPoint


def make_point(value: int = 1) -> IngressDataPoint:
    """Create a test data point."""
    return IngressDataPoint(
        channel_id="ch_test",
        timestamp=datetime.now(UTC),
        data={"value": value},
    )


class TestBatchManagerInit:
    """Tests for BatchManager initialization."""

    def test_init_with_params(self):
        """Test initialization with parameters."""
        on_flush = AsyncMock()
        manager = BatchManager(
            batch_size=50,
            batch_timeout=2.0,
            on_flush=on_flush,
        )

        assert manager._batch_size == 50
        assert manager._batch_timeout == 2.0
        assert manager._on_flush is on_flush

    def test_init_empty_points(self):
        """Test that manager starts with empty points list."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        assert len(manager._points) == 0

    def test_init_no_timer(self):
        """Test that manager starts with no timer."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        assert manager._timer_task is None


class TestBatchManagerAdd:
    """Tests for add() method."""

    @pytest.mark.asyncio
    async def test_add_appends_point(self):
        """Test that add appends point to list."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        point = make_point()
        await manager.add(point)

        assert len(manager._points) == 1
        assert manager._points[0] == point

    @pytest.mark.asyncio
    async def test_add_multiple_points(self):
        """Test adding multiple points."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        for i in range(5):
            await manager.add(make_point(i))

        assert len(manager._points) == 5

    @pytest.mark.asyncio
    async def test_add_starts_timer_on_first_point(self):
        """Test that timer starts on first point."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        assert manager._timer_task is None

        await manager.add(make_point())

        assert manager._timer_task is not None

        # Clean up timer
        manager._stop_timer()

    @pytest.mark.asyncio
    async def test_add_does_not_restart_timer_on_subsequent_points(self):
        """Test that timer doesn't restart on subsequent points."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())
        first_timer = manager._timer_task

        await manager.add(make_point())
        second_timer = manager._timer_task

        assert first_timer is second_timer

        manager._stop_timer()

    @pytest.mark.asyncio
    async def test_add_flushes_at_batch_size(self):
        """Test that batch flushes when batch_size is reached."""
        on_flush = AsyncMock()
        manager = BatchManager(
            batch_size=3,
            batch_timeout=60.0,  # Long timeout to ensure size triggers flush
            on_flush=on_flush,
        )

        await manager.add(make_point(1))
        await manager.add(make_point(2))

        # Not yet at batch size
        on_flush.assert_not_called()

        await manager.add(make_point(3))

        # Now at batch size, should flush
        on_flush.assert_called_once()
        call_args = on_flush.call_args[0][0]
        assert len(call_args) == 3

    @pytest.mark.asyncio
    async def test_add_clears_points_after_flush(self):
        """Test that points are cleared after flush."""
        manager = BatchManager(
            batch_size=2,
            batch_timeout=60.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point(1))
        await manager.add(make_point(2))

        # After flush, points should be empty
        assert len(manager._points) == 0


class TestBatchManagerFlush:
    """Tests for flush() method."""

    @pytest.mark.asyncio
    async def test_flush_calls_on_flush_callback(self):
        """Test that flush calls the on_flush callback."""
        on_flush = AsyncMock()
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=on_flush,
        )

        await manager.add(make_point(1))
        await manager.add(make_point(2))

        await manager.flush()

        on_flush.assert_called_once()
        call_args = on_flush.call_args[0][0]
        assert len(call_args) == 2

    @pytest.mark.asyncio
    async def test_flush_clears_points(self):
        """Test that flush clears the points list."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())
        await manager.flush()

        assert len(manager._points) == 0

    @pytest.mark.asyncio
    async def test_flush_stops_timer(self):
        """Test that flush stops the timer."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())
        assert manager._timer_task is not None

        await manager.flush()

        # Timer should be stopped/cancelled
        assert manager._timer_task is None or manager._timer_task.done()

    @pytest.mark.asyncio
    async def test_flush_empty_batch_no_op(self):
        """Test that flushing empty batch does nothing."""
        on_flush = AsyncMock()
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=on_flush,
        )

        await manager.flush()

        on_flush.assert_not_called()

    @pytest.mark.asyncio
    async def test_flush_propagates_callback_error(self):
        """Test that flush propagates errors from callback."""
        on_flush = AsyncMock(side_effect=ValueError("Test error"))
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=on_flush,
        )

        await manager.add(make_point())

        with pytest.raises(ValueError, match="Test error"):
            await manager.flush()


class TestBatchManagerTimer:
    """Tests for timer behavior."""

    @pytest.mark.asyncio
    async def test_timer_flushes_after_timeout(self):
        """Test that timer triggers flush after timeout."""
        on_flush = AsyncMock()
        manager = BatchManager(
            batch_size=100,
            batch_timeout=0.05,  # 50ms timeout
            on_flush=on_flush,
        )

        await manager.add(make_point())

        # Wait for timeout
        await asyncio.sleep(0.1)

        on_flush.assert_called_once()

    @pytest.mark.asyncio
    async def test_timer_stops_after_manual_flush(self):
        """Test that timer stops after manual flush."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=1.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())

        await manager.flush()

        # After flush, timer should be None or cancelled/done
        # Give a tiny bit of time for cancellation to propagate
        await asyncio.sleep(0.01)
        assert manager._timer_task is None or manager._timer_task.cancelled() or manager._timer_task.done()

    @pytest.mark.asyncio
    async def test_stop_timer_handles_none(self):
        """Test that _stop_timer handles None timer gracefully."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        # Should not raise
        manager._stop_timer()

    @pytest.mark.asyncio
    async def test_stop_timer_handles_done_task(self):
        """Test that _stop_timer handles already-done task."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=0.01,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())

        # Wait for timer to complete
        await asyncio.sleep(0.05)

        # Should not raise
        manager._stop_timer()


class TestBatchManagerGetPendingCount:
    """Tests for get_pending_count() method."""

    def test_get_pending_count_empty(self):
        """Test pending count when empty."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        assert manager.get_pending_count() == 0

    @pytest.mark.asyncio
    async def test_get_pending_count_with_points(self):
        """Test pending count with points."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())
        await manager.add(make_point())

        assert manager.get_pending_count() == 2

        manager._stop_timer()

    @pytest.mark.asyncio
    async def test_get_pending_count_after_flush(self):
        """Test pending count after flush."""
        manager = BatchManager(
            batch_size=100,
            batch_timeout=5.0,
            on_flush=AsyncMock(),
        )

        await manager.add(make_point())
        await manager.flush()

        assert manager.get_pending_count() == 0
