"""Tests for sluice._buffer — event buffering and flushing."""

from __future__ import annotations

import threading
import time
from unittest.mock import MagicMock, patch

from sluice._buffer import EventBuffer


def _make_buffer(
    *,
    max_batch_size: int = 50,
    flush_interval_s: float = 5.0,
    max_buffer_size: int = 10_000,
) -> tuple[EventBuffer, MagicMock]:
    """Create a buffer with a mocked transport."""
    transport = MagicMock()
    transport.send_batch.return_value = True
    buf = EventBuffer(
        transport,
        max_batch_size=max_batch_size,
        flush_interval_s=flush_interval_s,
        max_buffer_size=max_buffer_size,
    )
    return buf, transport


class TestEventBuffer:
    """Tests for EventBuffer core operations."""

    def test_push_and_flush(self) -> None:
        """Events pushed should be flushed as a single batch."""
        buf, transport = _make_buffer()

        buf.push({"event_type": "test", "id": "1"})
        buf.push({"event_type": "test", "id": "2"})

        assert buf.pending_count == 2

        sent = buf.flush()
        assert sent == 2
        assert buf.pending_count == 0

        transport.send_batch.assert_called_once()
        batch = transport.send_batch.call_args[0][0]
        assert len(batch) == 2
        assert batch[0]["id"] == "1"
        assert batch[1]["id"] == "2"

    def test_flush_empty_buffer(self) -> None:
        """Flushing an empty buffer should return 0."""
        buf, transport = _make_buffer()
        assert buf.flush() == 0
        transport.send_batch.assert_not_called()

    def test_bounded_buffer_drops_oldest(self) -> None:
        """Buffer should drop oldest events when at capacity."""
        buf, _transport = _make_buffer(max_buffer_size=3)

        buf.push({"id": "1"})
        buf.push({"id": "2"})
        buf.push({"id": "3"})
        buf.push({"id": "4"})  # This should drop id=1.

        assert buf.pending_count == 3

        buf.flush()
        batch = _transport.send_batch.call_args[0][0]
        ids = [e["id"] for e in batch]
        assert ids == ["2", "3", "4"]

    def test_transport_failure_returns_zero(self) -> None:
        """If transport fails, flush returns 0."""
        buf, transport = _make_buffer()
        transport.send_batch.return_value = False

        buf.push({"event_type": "test"})
        sent = buf.flush()
        assert sent == 0

    def test_transport_exception_returns_zero(self) -> None:
        """If transport raises, flush returns 0 and doesn't crash."""
        buf, transport = _make_buffer()
        transport.send_batch.side_effect = RuntimeError("network error")

        buf.push({"event_type": "test"})
        sent = buf.flush()
        assert sent == 0


class TestEventBufferBackgroundFlushing:
    """Tests for the background flush thread."""

    def test_periodic_flush(self) -> None:
        """Background thread should flush after the interval."""
        buf, transport = _make_buffer(flush_interval_s=0.1)
        buf.start()

        try:
            buf.push({"event_type": "test"})
            # Wait for the periodic flush to fire.
            time.sleep(0.3)
            assert transport.send_batch.called
        finally:
            buf.stop()

    def test_batch_size_trigger(self) -> None:
        """Reaching batch size should trigger an immediate flush."""
        buf, transport = _make_buffer(max_batch_size=3, flush_interval_s=60.0)
        buf.start()

        try:
            buf.push({"id": "1"})
            buf.push({"id": "2"})
            buf.push({"id": "3"})  # Triggers flush.

            # Wait for the flush thread to process.
            time.sleep(0.2)
            assert transport.send_batch.called
            batch = transport.send_batch.call_args[0][0]
            assert len(batch) == 3
        finally:
            buf.stop()

    def test_stop_flushes_remaining(self) -> None:
        """stop() should flush remaining events."""
        buf, transport = _make_buffer(flush_interval_s=60.0)
        buf.start()

        buf.push({"event_type": "test"})
        buf.stop()

        assert transport.send_batch.called
        assert buf.pending_count == 0

    def test_start_is_idempotent(self) -> None:
        """Calling start() twice should not create a second thread."""
        buf, _transport = _make_buffer()
        buf.start()
        thread1 = buf._flush_thread
        buf.start()
        thread2 = buf._flush_thread

        assert thread1 is thread2
        buf.stop()

    def test_daemon_thread(self) -> None:
        """Flush thread should be a daemon thread."""
        buf, _transport = _make_buffer()
        buf.start()

        assert buf._flush_thread is not None
        assert buf._flush_thread.daemon is True
        buf.stop()

    def test_thread_safety(self) -> None:
        """Multiple threads pushing concurrently should not corrupt the buffer."""
        buf, transport = _make_buffer(max_batch_size=100, flush_interval_s=60.0)
        total_events = 100
        errors: list[Exception] = []

        def push_events(start: int) -> None:
            try:
                for i in range(start, start + 10):
                    buf.push({"id": str(i)})
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=push_events, args=(i * 10,)) for i in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors
        assert buf.pending_count == total_events

        sent = buf.flush()
        assert sent == total_events


class TestEventBufferBatching:
    """Tests for batch size enforcement during flush."""

    def test_large_buffer_flushed_in_batches(self) -> None:
        """Events exceeding batch size should be sent in multiple batches."""
        buf, transport = _make_buffer(max_batch_size=3)

        for i in range(7):
            buf.push({"id": str(i)})

        buf.flush()

        # Should have been called 3 times: 3 + 3 + 1.
        assert transport.send_batch.call_count == 3
        sizes = [len(call[0][0]) for call in transport.send_batch.call_args_list]
        assert sizes == [3, 3, 1]


class TestPreforkChildTransport:
    """Tests for the forked child process code path."""

    def test_child_uses_lightweight_transport(self) -> None:
        """In a forked child, push() should use a child transport with
        max_retries=1 and short timeouts instead of the parent transport."""
        buf, parent_transport = _make_buffer()
        # Give the parent transport the attributes the child builder reads.
        parent_transport._api_key = "test-key"
        parent_transport._connection_id = "conn-123"
        parent_transport._ingest_url = "http://localhost:9999/api/ingest"

        # Simulate being in a forked child by making getpid() return a
        # different PID than the owner.
        original_pid = buf._owner_pid

        with (
            patch("sluice._buffer.os.getpid", return_value=original_pid + 1),
            patch("sluice._transport.SluiceTransport") as MockTransport,
        ):
            mock_child = MagicMock()
            mock_child.send_batch.return_value = True
            MockTransport.return_value = mock_child

            buf.push({"event_type": "test", "id": "1"})

            # The child transport should have been created with
            # max_retries=1 and short timeouts.
            MockTransport.assert_called_once()
            call_kwargs = MockTransport.call_args
            assert call_kwargs.kwargs["max_retries"] == 1
            assert call_kwargs.kwargs["timeout_connect"] == 1.0
            assert call_kwargs.kwargs["timeout_read"] == 1.0

            # The child transport's send_batch should have been called,
            # NOT the parent transport's.
            mock_child.send_batch.assert_called_once()
            parent_transport.send_batch.assert_not_called()

    def test_child_transport_cached_per_pid(self) -> None:
        """The child transport should be lazily created and reused for
        the same PID."""
        buf, parent_transport = _make_buffer()
        parent_transport._api_key = "test-key"
        parent_transport._connection_id = "conn-123"
        parent_transport._ingest_url = "http://localhost:9999/api/ingest"

        original_pid = buf._owner_pid

        with (
            patch("sluice._buffer.os.getpid", return_value=original_pid + 1),
            patch("sluice._transport.SluiceTransport") as MockTransport,
        ):
            mock_child = MagicMock()
            mock_child.send_batch.return_value = True
            MockTransport.return_value = mock_child

            buf.push({"event_type": "test", "id": "1"})
            buf.push({"event_type": "test", "id": "2"})

            # Transport constructor should only be called once (cached).
            MockTransport.assert_called_once()
            # But send_batch should be called twice.
            assert mock_child.send_batch.call_count == 2

    def test_child_transport_failure_does_not_crash(self) -> None:
        """If the child transport fails, push() should not raise."""
        buf, parent_transport = _make_buffer()
        parent_transport._api_key = "test-key"
        parent_transport._connection_id = ""
        parent_transport._ingest_url = "http://localhost:9999/api/ingest"

        original_pid = buf._owner_pid

        with (
            patch("sluice._buffer.os.getpid", return_value=original_pid + 1),
            patch("sluice._transport.SluiceTransport") as MockTransport,
        ):
            mock_child = MagicMock()
            mock_child.send_batch.side_effect = RuntimeError("network error")
            MockTransport.return_value = mock_child

            # Should not raise.
            buf.push({"event_type": "test", "id": "1"})
