"""Tests for sluice._bootstep — Celery Bootstep integration."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch

import sluice._bootstep as _bootstep_module
from sluice._bootstep import SluiceBootstep, install_bootstep

if TYPE_CHECKING:
    import pytest
    from celery import Celery

# Shared test connection ID (valid UUID format).
_TEST_CONN_ID = "550e8400-e29b-41d4-a716-446655440000"


class TestSluiceBootstep:
    """Tests for the SluiceBootstep class."""

    def test_bootstep_has_sluice_label(self) -> None:
        """The bootstep should identify itself as 'sluice'."""
        assert SluiceBootstep.label == "sluice"

    def test_bootstep_start_calls_auto_configure(
        self,
        celery_app: Celery,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Bootstep.start() should run auto_configure on the app."""
        parent = MagicMock()
        parent.app = celery_app

        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)

        with caplog.at_level(logging.INFO, logger="sluice"):
            step.start(parent)

        assert "Bootstep started" in caplog.text
        # auto_configure should have set the flags.
        assert celery_app.conf.worker_send_task_events is True
        assert celery_app.conf.task_send_sent_event is True
        assert celery_app.conf.task_track_started is True

    def test_bootstep_start_creates_buffer(
        self,
        celery_app: Celery,
    ) -> None:
        """Bootstep.start() should create and start an event buffer."""
        parent = MagicMock()
        parent.app = celery_app

        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        assert step._buffer is not None
        # Clean up.
        step.stop(parent)

    def test_bootstep_reads_api_key_from_module(self) -> None:
        """Bootstep should read the API key from module-level storage."""
        _bootstep_module._api_key = "sk_test_from_module"
        _bootstep_module._connection_id = _TEST_CONN_ID
        parent = MagicMock()
        step = SluiceBootstep(parent)
        assert step._api_key == "sk_test_from_module"

    def test_bootstep_reads_connection_id_from_module(self) -> None:
        """Bootstep should read the connection ID from module-level storage."""
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        parent = MagicMock()
        step = SluiceBootstep(parent)
        assert step._connection_id == _TEST_CONN_ID

    def test_bootstep_start_never_crashes(
        self,
        monkeypatch: pytest.MonkeyPatch,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Bootstep.start() must never crash the worker, even on errors."""
        parent = MagicMock()

        def _broken_auto_configure(app):  # type: ignore[no-untyped-def]  # noqa: ANN001
            raise RuntimeError("simulated auto_configure failure")

        monkeypatch.setattr(_bootstep_module, "auto_configure", _broken_auto_configure)

        step = SluiceBootstep(parent)

        with caplog.at_level(logging.ERROR, logger="sluice"):
            # This should NOT raise — it should log the exception.
            step.start(parent)

        assert "Bootstep startup failed" in caplog.text

    def test_bootstep_stop_logs(
        self,
        celery_app: Celery,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Bootstep.stop() should log shutdown."""
        parent = MagicMock()
        parent.app = celery_app

        step = SluiceBootstep(parent)

        with caplog.at_level(logging.INFO, logger="sluice"):
            step.stop(parent)

        assert "Bootstep stopped" in caplog.text

    def test_bootstep_stop_flushes_buffer(
        self,
        celery_app: Celery,
    ) -> None:
        """Bootstep.stop() should stop and flush the event buffer."""
        parent = MagicMock()
        parent.app = celery_app

        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)

        # Manually set a mock buffer so we can verify stop() is called.
        mock_buffer = MagicMock()
        step._buffer = mock_buffer

        step.stop(parent)

        mock_buffer.stop.assert_called_once()
        assert step._buffer is None

    def test_bootstep_stop_exception_never_crashes(
        self,
        monkeypatch: pytest.MonkeyPatch,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Bootstep.stop() must never crash, even if buffer.stop() fails."""
        parent = MagicMock()
        step = SluiceBootstep(parent)

        # Set a buffer that raises on stop.
        mock_buffer = MagicMock()
        mock_buffer.stop.side_effect = RuntimeError("simulated failure")
        step._buffer = mock_buffer

        with caplog.at_level(logging.ERROR, logger="sluice"):
            step.stop(parent)

        assert "Error during bootstep shutdown" in caplog.text


class TestInstallBootstep:
    """Tests for the install_bootstep() function."""

    def test_registers_bootstep_on_consumer_blueprint(self, celery_app: Celery) -> None:
        """install_bootstep() should add the step to the consumer blueprint."""
        install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)

        # The bootstep should be registered in the consumer steps.
        assert SluiceBootstep in celery_app.steps["consumer"]

    def test_stores_api_key_at_module_level(self, celery_app: Celery) -> None:
        """install_bootstep() should store the API key at module level."""
        install_bootstep(celery_app, "sk_test_stored", _TEST_CONN_ID)
        assert _bootstep_module._api_key == "sk_test_stored"

    def test_stores_connection_id_at_module_level(self, celery_app: Celery) -> None:
        """install_bootstep() should store the connection ID at module level."""
        install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)
        assert _bootstep_module._connection_id == _TEST_CONN_ID

    def test_idempotent_install(self, celery_app: Celery) -> None:
        """Installing the bootstep twice should not cause errors."""
        install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)
        install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)

        # Steps is a set, so duplicates are ignored.
        consumer_steps = list(celery_app.steps["consumer"])
        sluice_steps = [s for s in consumer_steps if s is SluiceBootstep]
        assert len(sluice_steps) == 1

    def test_idempotent_install_logs_only_once(
        self,
        celery_app: Celery,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """install_bootstep() should only log registration on first call."""
        with caplog.at_level(logging.INFO, logger="sluice"):
            install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)
            caplog.clear()
            install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)

        assert "Bootstep registered" not in caplog.text

    def test_install_logs_registration(
        self,
        celery_app: Celery,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """install_bootstep() should log that registration happened."""
        with caplog.at_level(logging.INFO, logger="sluice"):
            install_bootstep(celery_app, "sk_test_123", _TEST_CONN_ID)

        assert "Bootstep registered" in caplog.text


class TestEventSignalHandlers:
    """Tests for the Celery signal handlers connected by the bootstep."""

    def test_event_handlers_connected_on_start(
        self,
        celery_app: Celery,
    ) -> None:
        """start() should connect Celery signal handlers."""
        parent = MagicMock()
        parent.app = celery_app

        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)

        with patch("sluice._bootstep._connect_event_handlers") as mock_connect:
            step.start(parent)
            mock_connect.assert_called_once()

        step.stop(parent)

    def test_task_sent_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_sent signal should normalize and push an event to the buffer."""
        from celery.signals import task_sent

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            # Mock the buffer's push method.
            mock_push = MagicMock()
            step._buffer.push = mock_push
            # Fire the signal.
            task_sent.send(
                sender=None,
                headers={"id": "test-uuid", "task": "my_task", "queue": "default"},
            )

            # Verify event was pushed in the new wire format.
            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["type"] == "job.state_change"
            assert pushed_event["framework"] == "celery"
            assert pushed_event["job"]["externalId"] == "test-uuid"
            assert pushed_event["job"]["state"] == "unknown"
        finally:
            step.stop(parent)

    def test_task_prerun_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_prerun signal should push an 'active' state event."""
        from celery.signals import task_prerun

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_sender = MagicMock()
            mock_sender.name = "my_task"

            task_prerun.send(sender=mock_sender, task_id="task-123")

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-123"
            assert pushed_event["job"]["state"] == "active"
        finally:
            step.stop(parent)

    def test_task_failure_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_failure signal should push a 'failed' state event."""
        from celery.signals import task_failure

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_sender = MagicMock()
            mock_sender.name = "my_task"

            task_failure.send(
                sender=mock_sender,
                task_id="task-456",
                exception=ValueError("bad input"),
                traceback="Traceback...",
            )

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-456"
            assert pushed_event["job"]["state"] == "failed"
            assert "bad input" in pushed_event["job"]["error"]
        finally:
            step.stop(parent)

    def test_task_sent_none_headers_skipped(
        self,
        celery_app: Celery,
    ) -> None:
        """task_sent with headers=None should not push any event."""
        from celery.signals import task_sent

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            task_sent.send(sender=None, headers=None)

            mock_push.assert_not_called()
        finally:
            step.stop(parent)

    def test_task_received_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_received signal should push a 'queued' state event."""
        from celery.signals import task_received

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_request = MagicMock()
            mock_request.id = "task-recv-1"
            mock_request.name = "my_task"
            mock_request.hostname = "worker@host"

            task_received.send(sender=None, request=mock_request)

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-recv-1"
            assert pushed_event["job"]["state"] == "queued"
        finally:
            step.stop(parent)

    def test_task_postrun_success_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_postrun with SUCCESS state should push a 'completed' event."""
        from celery.signals import task_postrun

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_sender = MagicMock()
            mock_sender.name = "my_task"

            task_postrun.send(
                sender=mock_sender, task_id="task-post-1", state="SUCCESS"
            )

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-post-1"
            assert pushed_event["job"]["state"] == "completed"
        finally:
            step.stop(parent)

    def test_task_postrun_non_success_skipped(
        self,
        celery_app: Celery,
    ) -> None:
        """task_postrun with non-SUCCESS state should not push any event."""
        from celery.signals import task_postrun

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            task_postrun.send(sender=None, task_id="task-post-2", state="FAILURE")

            mock_push.assert_not_called()
        finally:
            step.stop(parent)

    def test_task_retry_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_retry signal should push a 'retrying' state event."""
        from celery.signals import task_retry

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_sender = MagicMock()
            mock_sender.name = "my_task"
            mock_request = MagicMock()
            mock_request.id = "task-retry-1"

            task_retry.send(
                sender=mock_sender, request=mock_request, reason="ConnectionError"
            )

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-retry-1"
            assert pushed_event["job"]["state"] == "retrying"
        finally:
            step.stop(parent)

    def test_task_revoked_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_revoked signal should push a 'cancelled' state event."""
        from celery.signals import task_revoked

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_sender = MagicMock()
            mock_sender.name = "my_task"
            mock_request = MagicMock()
            mock_request.id = "task-revoke-1"

            task_revoked.send(sender=mock_sender, request=mock_request)

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-revoke-1"
            assert pushed_event["job"]["state"] == "cancelled"
        finally:
            step.stop(parent)

    def test_task_rejected_signal_pushes_event(
        self,
        celery_app: Celery,
    ) -> None:
        """task_rejected signal should push a 'failed' state event."""
        from celery.signals import task_rejected

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            mock_push = MagicMock()
            step._buffer.push = mock_push
            mock_message = MagicMock()
            mock_message.headers = {"id": "task-reject-1", "task": "my_task"}

            task_rejected.send(sender=None, message=mock_message)

            assert mock_push.called
            pushed_event = mock_push.call_args[0][0]
            assert pushed_event["job"]["externalId"] == "task-reject-1"
            assert pushed_event["job"]["state"] == "failed"
        finally:
            step.stop(parent)

    def test_signal_handler_never_crashes(
        self,
        celery_app: Celery,
    ) -> None:
        """Signal handlers must never raise, even if normalization fails."""
        from celery.signals import task_prerun

        parent = MagicMock()
        parent.app = celery_app
        _bootstep_module._api_key = "sk_test_123"
        _bootstep_module._connection_id = _TEST_CONN_ID
        step = SluiceBootstep(parent)
        step.start(parent)

        try:
            # Replace push with something that raises.
            step._buffer.push = MagicMock(side_effect=RuntimeError("boom"))

            # This should NOT raise.
            task_prerun.send(sender=None, task_id="task-789")
        finally:
            step.stop(parent)
