"""Tests for StreamConsumer — Redis Streams consumer group framework.

Tests cover:
- Consumer group creation (idempotent, handles BUSYGROUP)
- Batch processing — handler receives decoded entries, XACK sent after success
- Dead-letter — entries exceeding max_retries moved to dead:{stream_key}
- XCLAIM recovery — pending entries from crashed consumers reclaimed
- Graceful shutdown — stop() causes run() to exit after current batch
- Synergy: EventStreamMixin saves → StreamConsumer processes entries end-to-end
- Empty stream — consumer blocks briefly then returns 0
- Handler exception — entries stay pending, consumer loop continues
- process_batch() returns int count
"""

import sys
import os
import asyncio
import time

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.dirname(SCRIPT_DIR))

import pytest  # noqa: E402
from src import popoto  # noqa: E402
from src.popoto.streams.consumer import StreamConsumer  # noqa: E402
from src.popoto.fields.event_stream import EventStreamMixin  # noqa: E402
from src.popoto.redis_db import POPOTO_REDIS_DB  # noqa: E402

# --- Test Models ---


class ConsumerTestItem(EventStreamMixin, popoto.Model):
    """Model that writes to a stream for consumer tests."""

    _stream_name = "consumer_test"

    name = popoto.UniqueKeyField()
    value = popoto.StringField(default="")


# --- Helpers ---

STREAM_KEY = "stream:consumer_test"
DEAD_LETTER_KEY = f"dead:{STREAM_KEY}"


def _cleanup_streams():
    """Remove all test streams and consumer groups."""
    for key in [STREAM_KEY, DEAD_LETTER_KEY]:
        POPOTO_REDIS_DB.delete(key)


def _cleanup_model():
    """Delete all ConsumerTestItem instances."""
    try:
        for key in POPOTO_REDIS_DB.smembers(
            ConsumerTestItem._meta.db_class_set_key.redis_key
        ):
            POPOTO_REDIS_DB.delete(key)
        POPOTO_REDIS_DB.delete(ConsumerTestItem._meta.db_class_set_key.redis_key)
    except Exception:
        pass


def _xadd_entries(stream_key, count, prefix="entry"):
    """Add test entries directly to a stream."""
    ids = []
    for i in range(count):
        entry_id = POPOTO_REDIS_DB.xadd(
            stream_key,
            {
                "model": "Test",
                "pk": f"{prefix}_{i}",
                "op": "create",
                "ts": str(time.time()),
            },
        )
        ids.append(entry_id)
    return ids


def _make_handler(collected):
    """Create an async handler that appends entries to collected list."""

    async def handler(entries):
        collected.extend(entries)

    return handler


def _make_failing_handler(fail_count, collected):
    """Create a handler that fails the first N calls, then succeeds."""
    call_count = {"n": 0}

    async def handler(entries):
        call_count["n"] += 1
        if call_count["n"] <= fail_count:
            raise RuntimeError(f"Intentional failure #{call_count['n']}")
        collected.extend(entries)

    return handler


# --- Fixtures ---


@pytest.fixture(autouse=True)
def cleanup():
    """Clean up test streams and models before and after each test."""
    _cleanup_streams()
    _cleanup_model()
    yield
    _cleanup_streams()
    _cleanup_model()


# --- Tests: Consumer Group Creation ---


class TestConsumerGroupCreation:
    def test_group_creation_on_first_process_batch(self):
        """Consumer group is created on first process_batch call."""
        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="test_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        count = consumer.process_batch_sync()
        assert count == 0

        # Verify group exists via XINFO GROUPS
        groups = POPOTO_REDIS_DB.xinfo_groups(STREAM_KEY)
        group_names = [
            g["name"].decode() if isinstance(g["name"], bytes) else g["name"]
            for g in groups
        ]
        assert "test_group" in group_names

    def test_group_creation_is_idempotent(self):
        """Creating the same group twice does not raise."""
        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="idempotent_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        # First call creates the group
        consumer.process_batch_sync()
        # Reset the cached flag to force re-creation attempt
        consumer._group_ensured = False
        # Second call should succeed (BUSYGROUP caught)
        consumer.process_batch_sync()

        groups = POPOTO_REDIS_DB.xinfo_groups(STREAM_KEY)
        group_names = [
            g["name"].decode() if isinstance(g["name"], bytes) else g["name"]
            for g in groups
        ]
        assert group_names.count("idempotent_group") == 1


# --- Tests: Batch Processing ---


class TestBatchProcessing:
    def test_process_batch_returns_count(self):
        """process_batch returns the number of entries processed."""
        _xadd_entries(STREAM_KEY, 3)

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="count_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        count = consumer.process_batch_sync()
        assert count == 3

    def test_handler_receives_decoded_entries(self):
        """Handler receives entries with all fields decoded to str."""
        _xadd_entries(STREAM_KEY, 2, prefix="decoded")

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="decode_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        consumer.process_batch_sync()

        assert len(collected) == 2
        for entry_id, fields in collected:
            assert isinstance(entry_id, str)
            assert isinstance(fields, dict)
            for k, v in fields.items():
                assert isinstance(k, str), f"Key {k!r} is not str"
                assert isinstance(v, str), f"Value {v!r} is not str"

    def test_entries_are_acked_after_processing(self):
        """Entries are ACKed after successful handler execution."""
        _xadd_entries(STREAM_KEY, 2)

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="ack_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        consumer.process_batch_sync()

        # Check pending count — should be 0 after ACK
        pending = POPOTO_REDIS_DB.xpending(STREAM_KEY, "ack_group")
        assert pending["pending"] == 0

    def test_empty_stream_returns_zero(self):
        """process_batch on empty stream returns 0 after blocking timeout."""
        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="empty_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        count = consumer.process_batch_sync()
        assert count == 0
        assert len(collected) == 0

    def test_batch_size_limits_entries(self):
        """batch_size controls how many entries are read per call."""
        _xadd_entries(STREAM_KEY, 10)

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="batch_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            batch_size=3,
            block_ms=100,
        )
        count = consumer.process_batch_sync()
        assert count == 3
        assert len(collected) == 3


# --- Tests: Handler Exception ---


class TestHandlerException:
    def test_handler_exception_leaves_entries_pending(self):
        """When the handler raises, entries remain pending (not ACKed)."""
        _xadd_entries(STREAM_KEY, 2)

        async def failing_handler(entries):
            raise RuntimeError("Intentional failure")

        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="fail_group",
            consumer_name="worker-1",
            handler=failing_handler,
            block_ms=100,
        )
        # process_batch propagates handler exceptions (XACK never reached)
        with pytest.raises(RuntimeError, match="Intentional failure"):
            consumer.process_batch_sync()

        # Entries should still be pending (sync client unaffected by async loop)
        pending = POPOTO_REDIS_DB.xpending(STREAM_KEY, "fail_group")
        assert pending["pending"] == 2


# --- Tests: Dead-Letter ---


class TestDeadLetter:
    def test_entries_exceeding_max_retries_are_dead_lettered(self):
        """Entries that exceed max_retries are moved to the dead-letter stream."""
        entry_ids = _xadd_entries(STREAM_KEY, 1)

        # Create consumer group and read the entry to make it pending
        POPOTO_REDIS_DB.xgroup_create(STREAM_KEY, "dl_group", id="0", mkstream=True)

        # Read and don't ACK — simulates failed processing
        # We need to read multiple times to bump the delivery count
        for _ in range(4):
            result = POPOTO_REDIS_DB.xreadgroup(
                "dl_group",
                "crashed-worker",
                {STREAM_KEY: ">"},
                count=10,
            )
            if not result:
                # Re-claim to bump delivery count
                POPOTO_REDIS_DB.xclaim(
                    STREAM_KEY,
                    "dl_group",
                    "crashed-worker",
                    min_idle_time=0,
                    message_ids=entry_ids,
                )

        # Now run our consumer with claim_timeout_ms=0 so it picks up immediately
        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="dl_group",
            consumer_name="recovery-worker",
            handler=_make_handler(collected),
            max_retries=3,
            claim_timeout_ms=0,
            block_ms=100,
        )
        consumer.process_batch_sync()

        # Check that the dead-letter stream has the entry
        dead_entries = POPOTO_REDIS_DB.xrange(DEAD_LETTER_KEY)
        assert len(dead_entries) >= 1

        _, dead_fields = dead_entries[0]
        assert dead_fields[b"original_stream"] == STREAM_KEY.encode()
        assert b"failure_count" in dead_fields
        assert b"last_error" in dead_fields
        assert b"dead_letter_ts" in dead_fields

    def test_dead_letter_max_length(self):
        """Dead-letter stream respects max_length parameter."""
        # Create group and add entries
        POPOTO_REDIS_DB.xgroup_create(STREAM_KEY, "dl_max_group", id="0", mkstream=True)

        for i in range(5):
            entry_id = _xadd_entries(STREAM_KEY, 1, prefix=f"dl_max_{i}")[0]
            # Read and re-claim to bump delivery count above max_retries
            for _ in range(5):
                POPOTO_REDIS_DB.xreadgroup(
                    "dl_max_group",
                    "crashed",
                    {STREAM_KEY: ">"},
                    count=10,
                )
                POPOTO_REDIS_DB.xclaim(
                    STREAM_KEY,
                    "dl_max_group",
                    "crashed",
                    min_idle_time=0,
                    message_ids=[entry_id],
                )

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="dl_max_group",
            consumer_name="recovery",
            handler=_make_handler(collected),
            max_retries=3,
            claim_timeout_ms=0,
            block_ms=100,
            dead_letter_max_length=3,
        )
        consumer.process_batch_sync()

        dead_entries = POPOTO_REDIS_DB.xrange(DEAD_LETTER_KEY)
        # With approximate trimming, may be slightly over, but should be bounded
        assert len(dead_entries) <= 5  # approximate MAXLEN allows some over


# --- Tests: Graceful Shutdown ---


class TestGracefulShutdown:
    def test_stop_exits_run_loop(self):
        """stop() causes run() to exit after the current batch."""
        _xadd_entries(STREAM_KEY, 5)

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="stop_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )

        async def run_and_stop():
            # Reset cached async connection for this new event loop
            from src.popoto import redis_db

            redis_db._POPOTO_ASYNC_REDIS_DB = None

            # Schedule stop after a short delay
            async def delayed_stop():
                await asyncio.sleep(0.3)
                consumer.stop()

            asyncio.create_task(delayed_stop())
            await consumer.run()

        asyncio.run(run_and_stop())

        # Consumer should have processed the entries then stopped
        assert len(collected) >= 5
        assert consumer._running is False


# --- Tests: Synergy with EventStreamMixin ---


class TestEventStreamSynergy:
    def test_event_stream_mixin_saves_consumed_by_stream_consumer(self):
        """End-to-end: EventStreamMixin save → StreamConsumer processes."""
        # Create model instances to produce stream entries
        item1 = ConsumerTestItem(name="synergy_1", value="first")
        item1.save()
        item2 = ConsumerTestItem(name="synergy_2", value="second")
        item2.save()

        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="synergy_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        count = consumer.process_batch_sync()

        assert count == 2
        assert len(collected) == 2

        # Verify entry content matches what EventStreamMixin produces
        ops = [fields["op"] for _, fields in collected]
        assert "create" in ops

        models = [fields["model"] for _, fields in collected]
        assert all(m == "ConsumerTestItem" for m in models)

    def test_event_stream_mixin_delete_consumed(self):
        """Delete events from EventStreamMixin are consumed correctly."""
        item = ConsumerTestItem(name="synergy_del", value="will_delete")
        item.save()

        # Consume the create event first
        collected = []
        consumer = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="synergy_del_group",
            consumer_name="worker-1",
            handler=_make_handler(collected),
            block_ms=100,
        )
        consumer.process_batch_sync()
        collected.clear()

        # Now delete
        item.delete()
        consumer.process_batch_sync()

        assert len(collected) == 1
        _, fields = collected[0]
        assert fields["op"] == "delete"


# --- Tests: Multiple Consumers ---


class TestMultipleConsumers:
    def test_two_consumers_share_work(self):
        """Two consumers in the same group each get a subset of entries."""
        _xadd_entries(STREAM_KEY, 6)

        collected_a = []
        collected_b = []
        consumer_a = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="shared_group",
            consumer_name="worker-a",
            handler=_make_handler(collected_a),
            batch_size=3,
            block_ms=100,
        )
        consumer_b = StreamConsumer(
            stream_key=STREAM_KEY,
            group_name="shared_group",
            consumer_name="worker-b",
            handler=_make_handler(collected_b),
            batch_size=3,
            block_ms=100,
        )

        count_a = consumer_a.process_batch_sync()
        count_b = consumer_b.process_batch_sync()

        # Together they should have consumed all 6
        assert count_a + count_b == 6
        assert len(collected_a) + len(collected_b) == 6


# --- Tests: Import ---


class TestImport:
    def test_importable_from_popoto(self):
        """StreamConsumer is importable from the top-level popoto package."""
        assert hasattr(popoto, "StreamConsumer")
        assert popoto.StreamConsumer is StreamConsumer

    def test_importable_from_streams_subpackage(self):
        """StreamConsumer is importable from popoto.streams."""
        from src.popoto.streams import StreamConsumer as SC

        assert SC is StreamConsumer
