"""Tests for worker start/stop/consume_loop lifecycle methods and kafka producer start/stop.

All Kafka interactions are mocked.
"""

from __future__ import annotations

import asyncio
from datetime import UTC, datetime
from typing import Self
from unittest.mock import AsyncMock, MagicMock, patch

from botocore.exceptions import ClientError
from matyan_api_models.kafka import ControlEvent, IngestionMessage

from matyan_backend.fdb_types import FDBError
from matyan_backend.kafka.producer import ControlEventProducer
from matyan_backend.workers.control import ControlWorker
from matyan_backend.workers.ingestion import IngestionWorker


class _AsyncIter:
    """Helper to make a mock Kafka consumer iterable."""

    def __init__(self, records: list) -> None:
        self._records = records
        self._index = 0

    def __aiter__(self) -> Self:
        return self

    async def __anext__(self) -> MagicMock:
        if self._index >= len(self._records):
            raise StopAsyncIteration
        record = self._records[self._index]
        self._index += 1
        return record


class TestIngestionWorkerLifecycle:
    def test_stop_when_consumer_none(self) -> None:
        worker = IngestionWorker()
        worker._consumer = None  # noqa: SLF001
        asyncio.get_event_loop().run_until_complete(worker.stop())
        assert not worker._running  # noqa: SLF001

    def test_stop_with_consumer(self) -> None:
        worker = IngestionWorker()
        worker._consumer = AsyncMock()  # noqa: SLF001
        asyncio.get_event_loop().run_until_complete(worker.stop())
        worker._consumer.stop.assert_called_once()  # noqa: SLF001

    def test_consume_loop_processes_message(self) -> None:
        worker = IngestionWorker()
        worker._db = MagicMock()  # noqa: SLF001

        msg = IngestionMessage(
            type="create_run",
            run_id="lifecycle1",
            timestamp=datetime.now(UTC),
            payload={},
        )

        mock_record = MagicMock()
        mock_record.value = msg.model_dump_json()

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_message"):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001
            mock_consumer.commit.assert_called_once()  # ty:ignore[unresolved-attribute]

    def test_consume_loop_handles_validation_error(self) -> None:
        worker = IngestionWorker()
        worker._db = MagicMock()  # noqa: SLF001

        mock_record = MagicMock()
        mock_record.value = "not-valid-json"
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001
        mock_consumer.commit.assert_not_called()  # ty:ignore[unresolved-attribute]

    def test_consume_loop_handles_fdb_error(self) -> None:
        worker = IngestionWorker()
        worker._db = MagicMock()  # noqa: SLF001

        msg = IngestionMessage(
            type="create_run",
            run_id="fdb_err",
            timestamp=datetime.now(UTC),
            payload={},
        )

        mock_record = MagicMock()
        mock_record.value = msg.model_dump_json()
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_message", side_effect=FDBError(1000)):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001

    def test_consume_loop_handles_unexpected_error(self) -> None:
        worker = IngestionWorker()
        worker._db = MagicMock()  # noqa: SLF001

        msg = IngestionMessage(
            type="create_run",
            run_id="unexp_err",
            timestamp=datetime.now(UTC),
            payload={},
        )

        mock_record = MagicMock()
        mock_record.value = msg.model_dump_json()
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_message", side_effect=RuntimeError("boom")):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001


class TestControlWorkerLifecycle:
    def test_stop_when_consumer_none(self) -> None:
        worker = ControlWorker()
        worker._consumer = None  # noqa: SLF001
        asyncio.get_event_loop().run_until_complete(worker.stop())
        assert not worker._running  # noqa: SLF001

    def test_stop_with_consumer(self) -> None:
        worker = ControlWorker()
        worker._consumer = AsyncMock()  # noqa: SLF001
        asyncio.get_event_loop().run_until_complete(worker.stop())
        worker._consumer.stop.assert_called_once()  # noqa: SLF001

    def test_consume_loop_processes_event(self) -> None:
        worker = ControlWorker()
        worker._db = MagicMock()  # noqa: SLF001
        worker._s3 = MagicMock()  # noqa: SLF001

        event = ControlEvent(
            type="run_deleted",
            timestamp=datetime.now(UTC),
            payload={"run_id": "cl1"},
        )

        mock_record = MagicMock()
        mock_record.value = event.model_dump_json()

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_event"):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001
            mock_consumer.commit.assert_called_once()  # ty:ignore[unresolved-attribute]

    def test_consume_loop_validation_error(self) -> None:
        worker = ControlWorker()
        worker._db = MagicMock()  # noqa: SLF001
        worker._s3 = MagicMock()  # noqa: SLF001

        mock_record = MagicMock()
        mock_record.value = "bad-json"
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001
        mock_consumer.commit.assert_not_called()  # ty:ignore[unresolved-attribute]

    def test_consume_loop_fdb_error(self) -> None:
        worker = ControlWorker()
        worker._db = MagicMock()  # noqa: SLF001
        worker._s3 = MagicMock()  # noqa: SLF001

        event = ControlEvent(
            type="run_deleted",
            timestamp=datetime.now(UTC),
            payload={"run_id": "fdb_err"},
        )
        mock_record = MagicMock()
        mock_record.value = event.model_dump_json()
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_event", side_effect=FDBError(1000)):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001

    def test_consume_loop_client_error(self) -> None:
        worker = ControlWorker()
        worker._db = MagicMock()  # noqa: SLF001
        worker._s3 = MagicMock()  # noqa: SLF001

        event = ControlEvent(
            type="run_deleted",
            timestamp=datetime.now(UTC),
            payload={"run_id": "s3_err"},
        )
        mock_record = MagicMock()
        mock_record.value = event.model_dump_json()
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_event", side_effect=ClientError({"Error": {"Code": "500"}}, "op")):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001

    def test_consume_loop_unexpected_error(self) -> None:
        worker = ControlWorker()
        worker._db = MagicMock()  # noqa: SLF001
        worker._s3 = MagicMock()  # noqa: SLF001

        event = ControlEvent(
            type="run_deleted",
            timestamp=datetime.now(UTC),
            payload={"run_id": "unexp"},
        )
        mock_record = MagicMock()
        mock_record.value = event.model_dump_json()
        mock_record.offset = 0
        mock_record.partition = 0

        mock_consumer = _AsyncIter([mock_record])
        mock_consumer.commit = AsyncMock()  # ty:ignore[unresolved-attribute]
        worker._consumer = mock_consumer  # type: ignore[assignment]  # noqa: SLF001

        with patch.object(worker, "_handle_event", side_effect=RuntimeError("boom")):
            asyncio.get_event_loop().run_until_complete(worker._consume_loop())  # noqa: SLF001


class TestIngestionWorkerStart:
    def test_start_initializes_and_consumes(self) -> None:
        worker = IngestionWorker()

        mock_consumer_instance = _AsyncIter([])
        mock_consumer_instance.start = AsyncMock()  # ty:ignore[unresolved-attribute]
        mock_consumer_instance.stop = AsyncMock()  # ty:ignore[unresolved-attribute]
        mock_consumer_instance.commit = AsyncMock()  # ty:ignore[unresolved-attribute]

        async def _test() -> None:
            with (
                patch("matyan_backend.workers.ingestion.init_fdb", return_value=MagicMock()),
                patch("matyan_backend.workers.ingestion.ensure_directories"),
                patch("matyan_backend.workers.ingestion.AIOKafkaConsumer", return_value=mock_consumer_instance),
            ):
                await worker.start()

        asyncio.get_event_loop().run_until_complete(_test())
        mock_consumer_instance.start.assert_called_once()  # ty:ignore[unresolved-attribute]
        mock_consumer_instance.stop.assert_called_once()  # ty:ignore[unresolved-attribute]


class TestControlWorkerStart:
    def test_start_initializes_and_consumes(self) -> None:
        worker = ControlWorker()

        mock_consumer_instance = _AsyncIter([])
        mock_consumer_instance.start = AsyncMock()  # ty:ignore[unresolved-attribute]
        mock_consumer_instance.stop = AsyncMock()  # ty:ignore[unresolved-attribute]
        mock_consumer_instance.commit = AsyncMock()  # ty:ignore[unresolved-attribute]

        async def _test() -> None:
            with (
                patch("matyan_backend.workers.control.init_fdb", return_value=MagicMock()),
                patch("matyan_backend.workers.control.ensure_directories"),
                patch("matyan_backend.workers.control.boto3") as mock_boto3,
                patch("matyan_backend.workers.control.AIOKafkaConsumer", return_value=mock_consumer_instance),
            ):
                mock_boto3.client.return_value = MagicMock()
                await worker.start()

        asyncio.get_event_loop().run_until_complete(_test())
        mock_consumer_instance.start.assert_called_once()  # ty:ignore[unresolved-attribute]
        mock_consumer_instance.stop.assert_called_once()  # ty:ignore[unresolved-attribute]


class TestKafkaProducerStartStop:
    def test_start_stop(self) -> None:
        producer = ControlEventProducer()

        mock_aioproducer = AsyncMock()

        async def _test() -> None:
            with patch("matyan_backend.kafka.producer.AIOKafkaProducer", return_value=mock_aioproducer):
                await producer.start()
                assert producer._producer is mock_aioproducer  # noqa: SLF001
                mock_aioproducer.start.assert_called_once()

                await producer.stop()
                mock_aioproducer.stop.assert_called_once()

        asyncio.get_event_loop().run_until_complete(_test())

    def test_stop_when_none(self) -> None:
        producer = ControlEventProducer()
        asyncio.get_event_loop().run_until_complete(producer.stop())

    def test_publish_sends(self) -> None:
        producer = ControlEventProducer()
        producer._producer = AsyncMock()  # noqa: SLF001

        event = ControlEvent(type="test", timestamp=datetime.now(UTC), payload={})

        asyncio.get_event_loop().run_until_complete(producer.publish(event))
        producer._producer.send_and_wait.assert_called_once()  # noqa: SLF001
