"""Tests for django_devicehub.routing — MessageRouter."""

import pytest
from django.utils import timezone

from django_devicehub.models import DeviceStatus
from django_devicehub.routing import MessageRouter
from django_devicehub.signals import device_data_received, device_status_changed

from tests.testapp.models import (
    WeatherStationDevice,
    WeatherStationMessage,
    WeatherStationReading,
)


@pytest.fixture
def router():
    return MessageRouter()


@pytest.fixture
def active_device(db):
    """A weather station device ready to receive messages."""
    return WeatherStationDevice.objects.create(
        device_id="ws-100",
        name="Router Test Station",
        is_active=True,
    )


# ---------------------------------------------------------------------------
# Topic pattern matching
# ---------------------------------------------------------------------------


class TestTopicPatternMatching:
    def test_valid_data_topic_matches(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 22.5},
        )
        # Should not raise; the message is handled
        router.handle_message(msg)
        assert WeatherStationMessage.objects.filter(device=active_device).exists()

    def test_valid_status_topic_matches(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"status": "online"},
        )
        router.handle_message(msg)
        assert WeatherStationMessage.objects.filter(device=active_device).exists()

    def test_wrong_prefix_ignored(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "production/weatherstation/ws-100/data",
            {"temperature": 22.5},
        )
        router.handle_message(msg)
        assert WeatherStationMessage.objects.count() == 0

    def test_unknown_message_type_ignored(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/firmware",
            {"version": "2.0"},
        )
        router.handle_message(msg)
        assert WeatherStationMessage.objects.count() == 0

    @pytest.mark.django_db
    def test_completely_invalid_topic_ignored(self, router, mock_mqtt_message):
        msg = mock_mqtt_message("random/topic", {})
        router.handle_message(msg)
        assert WeatherStationMessage.objects.count() == 0

    def test_extra_slashes_ignored(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data/extra",
            {"temperature": 22.5},
        )
        router.handle_message(msg)
        assert WeatherStationMessage.objects.count() == 0


# ---------------------------------------------------------------------------
# _handle_data() — flat payload
# ---------------------------------------------------------------------------


class TestHandleDataFlat:
    @pytest.mark.django_db
    def test_flat_payload_creates_reading(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 25.3, "humidity": 80.1},
        )
        router.handle_message(msg)

        readings = WeatherStationReading.objects.filter(device=active_device)
        assert readings.count() == 1
        reading = readings.first()
        assert reading.temperature == 25.3
        assert reading.humidity == 80.1

    @pytest.mark.django_db
    def test_flat_payload_ignores_unknown_fields(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 25.3, "unknown_field": 42},
        )
        router.handle_message(msg)

        reading = WeatherStationReading.objects.get(device=active_device)
        assert reading.temperature == 25.3
        assert (
            not hasattr(reading, "unknown_field")
            or getattr(reading, "unknown_field", None) is None
        )

    @pytest.mark.django_db
    def test_flat_payload_partial_readings(
        self, router, active_device, mock_mqtt_message
    ):
        """Only temperature sent, other reading columns should be null."""
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 18.0},
        )
        router.handle_message(msg)

        reading = WeatherStationReading.objects.get(device=active_device)
        assert reading.temperature == 18.0
        assert reading.humidity is None
        assert reading.rainfall is None
        assert reading.wind_speed is None


# ---------------------------------------------------------------------------
# _handle_data() — readings list payload
# ---------------------------------------------------------------------------


class TestHandleDataReadingsList:
    @pytest.mark.django_db
    def test_readings_list_creates_reading(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {
                "readings": [
                    {"type": "temperature", "value": 23.0},
                    {"type": "humidity", "value": 65.5},
                ]
            },
        )
        router.handle_message(msg)

        reading = WeatherStationReading.objects.get(device=active_device)
        assert reading.temperature == 23.0
        assert reading.humidity == 65.5

    @pytest.mark.django_db
    def test_readings_list_ignores_unknown_types(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {
                "readings": [
                    {"type": "temperature", "value": 23.0},
                    {"type": "cosmic_radiation", "value": 99.9},
                ]
            },
        )
        router.handle_message(msg)

        reading = WeatherStationReading.objects.get(device=active_device)
        assert reading.temperature == 23.0


# ---------------------------------------------------------------------------
# _handle_data() — device-provided timestamp
# ---------------------------------------------------------------------------


class TestHandleDataTimestamp:
    @pytest.mark.django_db
    def test_device_timestamp_is_used(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {
                "temperature": 20.0,
                "timestamp": "2025-06-15T10:30:00+00:00",
            },
        )
        router.handle_message(msg)

        reading = WeatherStationReading.objects.get(device=active_device)
        assert reading.timestamp.year == 2025
        assert reading.timestamp.month == 6
        assert reading.timestamp.day == 15
        assert reading.timestamp.hour == 10
        assert reading.timestamp.minute == 30

    @pytest.mark.django_db
    def test_missing_timestamp_uses_now(self, router, active_device, mock_mqtt_message):
        before = timezone.now()
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 20.0},
        )
        router.handle_message(msg)
        after = timezone.now()

        reading = WeatherStationReading.objects.get(device=active_device)
        assert before <= reading.timestamp <= after

    @pytest.mark.django_db
    def test_invalid_timestamp_falls_back_to_now(
        self, router, active_device, mock_mqtt_message
    ):
        before = timezone.now()
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 20.0, "timestamp": "not-a-date"},
        )
        router.handle_message(msg)
        after = timezone.now()

        reading = WeatherStationReading.objects.get(device=active_device)
        assert before <= reading.timestamp <= after


# ---------------------------------------------------------------------------
# _handle_data() — updates device heartbeat
# ---------------------------------------------------------------------------


class TestHandleDataDeviceHeartbeat:
    @pytest.mark.django_db
    def test_data_updates_last_seen(self, router, active_device, mock_mqtt_message):
        assert active_device.last_seen is None
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 20.0},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.last_seen is not None

    @pytest.mark.django_db
    def test_data_sets_device_online(self, router, active_device, mock_mqtt_message):
        assert active_device.status == DeviceStatus.OFFLINE
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 20.0},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.status == DeviceStatus.ONLINE


# ---------------------------------------------------------------------------
# _handle_data() — fires signal
# ---------------------------------------------------------------------------


class TestHandleDataSignal:
    @pytest.mark.django_db
    def test_data_fires_signal(self, router, active_device, mock_mqtt_message):
        received_signals = []

        def handler(sender, device, readings, message, **kwargs):
            received_signals.append(
                {
                    "device": device,
                    "readings": readings,
                }
            )

        device_data_received.connect(handler)
        try:
            msg = mock_mqtt_message(
                "test/weatherstation/ws-100/data",
                {"temperature": 25.0, "humidity": 60.0},
            )
            router.handle_message(msg)
            assert len(received_signals) == 1
            assert received_signals[0]["device"] == active_device
            assert received_signals[0]["readings"]["temperature"] == 25.0
        finally:
            device_data_received.disconnect(handler)


# ---------------------------------------------------------------------------
# _handle_status()
# ---------------------------------------------------------------------------


class TestHandleStatus:
    @pytest.mark.django_db
    def test_status_update(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"status": "online"},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.status == DeviceStatus.ONLINE

    @pytest.mark.django_db
    def test_battery_update(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"battery": 87.5},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.battery_level == 87.5

    @pytest.mark.django_db
    def test_battery_level_alias(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"battery_level": 45.0},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.battery_level == 45.0

    @pytest.mark.django_db
    def test_firmware_version_update(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"firmware_version": "2.0.1"},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.firmware_version == "2.0.1"

    @pytest.mark.django_db
    def test_combined_status_update(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"status": "online", "battery": 95.0, "firmware_version": "3.0.0"},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.status == DeviceStatus.ONLINE
        assert active_device.battery_level == 95.0
        assert active_device.firmware_version == "3.0.0"

    @pytest.mark.django_db
    def test_invalid_status_value_ignored(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"status": "exploding"},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        # Status should remain "offline" (the default)
        assert active_device.status == DeviceStatus.OFFLINE

    @pytest.mark.django_db
    def test_status_updates_last_seen(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/status",
            {"status": "online"},
        )
        router.handle_message(msg)
        active_device.refresh_from_db()
        assert active_device.last_seen is not None


# ---------------------------------------------------------------------------
# _handle_status() — fires signal on status change
# ---------------------------------------------------------------------------


class TestHandleStatusSignal:
    @pytest.mark.django_db
    def test_status_change_fires_signal(self, router, active_device, mock_mqtt_message):
        received_signals = []

        def handler(sender, device, old_status, new_status, **kwargs):
            received_signals.append(
                {
                    "device": device,
                    "old_status": old_status,
                    "new_status": new_status,
                }
            )

        device_status_changed.connect(handler)
        try:
            msg = mock_mqtt_message(
                "test/weatherstation/ws-100/status",
                {"status": "online"},
            )
            router.handle_message(msg)
            assert len(received_signals) == 1
            assert received_signals[0]["old_status"] == DeviceStatus.OFFLINE
            assert received_signals[0]["new_status"] == DeviceStatus.ONLINE
        finally:
            device_status_changed.disconnect(handler)

    @pytest.mark.django_db
    def test_no_signal_when_status_unchanged(
        self, router, active_device, mock_mqtt_message
    ):
        """If the reported status matches current, no signal should fire."""
        received_signals = []

        def handler(sender, device, old_status, new_status, **kwargs):
            received_signals.append(True)

        device_status_changed.connect(handler)
        try:
            # Device starts as "offline"; send "offline" status
            msg = mock_mqtt_message(
                "test/weatherstation/ws-100/status",
                {"status": "offline"},
            )
            router.handle_message(msg)
            assert len(received_signals) == 0
        finally:
            device_status_changed.disconnect(handler)


# ---------------------------------------------------------------------------
# Unknown device type
# ---------------------------------------------------------------------------


class TestUnknownDeviceType:
    @pytest.mark.django_db
    def test_unknown_type_is_ignored(self, router, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/unknownthing/dev-001/data",
            {"value": 42},
        )
        router.handle_message(msg)
        # No crash, no messages stored
        assert WeatherStationMessage.objects.count() == 0


# ---------------------------------------------------------------------------
# Unknown device_id
# ---------------------------------------------------------------------------


class TestUnknownDeviceId:
    @pytest.mark.django_db
    def test_unknown_device_id_is_ignored(self, router, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/nonexistent-99/data",
            {"temperature": 20.0},
        )
        router.handle_message(msg)
        assert WeatherStationReading.objects.count() == 0
        assert WeatherStationMessage.objects.count() == 0


# ---------------------------------------------------------------------------
# Invalid JSON payload
# ---------------------------------------------------------------------------


class TestInvalidPayload:
    @pytest.mark.django_db
    def test_invalid_json_handled_gracefully(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            b"this is not json{{{",
        )
        # Should not raise
        router.handle_message(msg)
        # A message record is still created (with null payload_json)
        assert WeatherStationMessage.objects.filter(device=active_device).exists()
        stored = WeatherStationMessage.objects.get(device=active_device)
        assert stored.payload_json is None

    @pytest.mark.django_db
    def test_empty_payload_handled(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            b"",
        )
        router.handle_message(msg)
        # Should not crash
        assert WeatherStationMessage.objects.filter(device=active_device).exists()


# ---------------------------------------------------------------------------
# Message record is stored for valid messages
# ---------------------------------------------------------------------------


class TestMessageRecordCreation:
    @pytest.mark.django_db
    def test_message_record_stored(self, router, active_device, mock_mqtt_message):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 22.5},
        )
        router.handle_message(msg)

        records = WeatherStationMessage.objects.filter(device=active_device)
        assert records.count() == 1
        record = records.first()
        assert record.topic == "test/weatherstation/ws-100/data"
        assert record.payload_json == {"temperature": 22.5}

    @pytest.mark.django_db
    def test_message_marked_completed_on_success(
        self, router, active_device, mock_mqtt_message
    ):
        msg = mock_mqtt_message(
            "test/weatherstation/ws-100/data",
            {"temperature": 22.5},
        )
        router.handle_message(msg)
        record = WeatherStationMessage.objects.get(device=active_device)
        assert record.processing_status == "completed"
