import asyncio
from unittest.mock import MagicMock

import pytest

import aiospb.nodes.scanning as s
from aiospb.infra.inmem import InMemReadingStore
from aiospb.nodes.devices import MetricsNetwork
from aiospb.nodes.ports import DeviceConnectionIsBroken, ReadingStore
from aiospb.nodes.scanning import Scanner
from aiospb.nodes.values import Reading
from aiospb.shared import (
    DataType,
    DeviceKey,
    Metric,
    PropertySet,
    PropertyValue,
    Quality,
)
from tests.fakes import FakeClock

TS = 1000
NKEY = DeviceKey("group/node")
SCAN_RATE = 10000
READINGS = [
    Reading("Metric/Good", 1, 1, DataType.Int16),
    Reading("Metric/Stale", 2, None, DataType.Boolean, Quality.STALE),
    Reading("Metric/Bad", 3, None, DataType.String, Quality.BAD),
]


@pytest.fixture
def aliases(request):
    param = request.param if hasattr(request, "param") else [1, 2, 3]
    return param


@pytest.fixture
def logger(monkeypatch):
    mock = MagicMock()
    monkeypatch.setattr(s, "logger", mock)
    yield mock


@pytest.fixture
def net():
    return MagicMock(spec=MetricsNetwork)


@pytest.fixture
async def readings():
    store = InMemReadingStore()
    await store.set(READINGS)
    return store


class FakeObserver:
    def __init__(self):
        self._queue = asyncio.Queue()
        self.scan_rate = None

    async def callback(self, metrics: list[Metric]):
        await self._queue.put(metrics)

    def has_callback_called(self):
        return self._queue.qsize() != 0

    def get_metrics(self):
        return self._queue.get_nowait()


@pytest.fixture
async def observer():
    return FakeObserver()


@pytest.fixture
async def clock():
    return FakeClock(TS)


@pytest.fixture
async def scanner(net, aliases, readings, observer, clock):
    s = Scanner(NKEY, aliases, net, readings, observer.callback, clock)
    s.start(SCAN_RATE)
    await asyncio.sleep(0)
    yield s
    s.stop()


class A_Scanner:
    def setup_method(self, _):
        self.clock = None

    async def should_get_tickers(self, clock):
        ticker = clock.get_ticker(SCAN_RATE)
        await clock.asleep(SCAN_RATE)

        assert SCAN_RATE == await ticker.__anext__()
        await clock.asleep(SCAN_RATE)
        assert 2 * SCAN_RATE == await ticker.__anext__()

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    async def should_run_after_start(self, scanner, observer):
        assert scanner.is_running()
        assert not observer.has_callback_called()

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_save_a_reading_modification(self, readings, clock, net):
        net.read.return_value = 2
        await clock.asleep(SCAN_RATE)

        assert await readings.get([1]) == [Reading("Metric/Good", 1, 2, DataType.Int16)]

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_not_notify_anything_if_no_change(self, observer, clock, net):
        net.read.return_value = 1
        await clock.asleep(SCAN_RATE)

        assert not observer.has_callback_called()

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    async def should_save_a_good_initial_reading(
        self, scanner, observer, clock, net, readings
    ):
        assert scanner.is_running()
        assert not observer.has_callback_called()

        net.read.return_value = 2
        await clock.asleep(SCAN_RATE)

        assert int(SCAN_RATE * 0.9) in clock.waits
        assert observer.has_callback_called()
        assert observer.get_metrics() == [Metric(TS + SCAN_RATE, 2, DataType.Int16, 1)]
        assert await readings.get([1]) == [
            Reading("Metric/Good", 1, 2, DataType.Int16, Quality.GOOD)
        ]

    @pytest.mark.parametrize("aliases", [[2]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_scan_a_initial_stale_reading(self, observer, clock, net):
        net.read.return_value = True
        await clock.asleep(SCAN_RATE)

        assert observer.get_metrics() == [
            Metric(
                TS + SCAN_RATE,
                True,
                DataType.Boolean,
                2,
                properties=PropertySet(
                    quality=PropertyValue(Quality.GOOD.value, DataType.Int32)
                ),
            )
        ]

    @pytest.mark.parametrize("aliases", [[3]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_scan_a_initial_bad_reading(self, observer, clock, net):
        net.read.return_value = "new"
        await clock.asleep(SCAN_RATE)

        assert observer.get_metrics() == [
            Metric(
                TS + SCAN_RATE,
                "new",
                DataType.String,
                3,
                properties=PropertySet(
                    quality=PropertyValue(Quality.GOOD.value, DataType.Int32)
                ),
            )
        ]

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_scan_a_stale_reading_if_timed_out(
        self, observer, clock, net, logger
    ):
        net.read.side_effect = TimeoutError()
        await clock.asleep(SCAN_RATE)

        assert observer.get_metrics() == [
            Metric(
                TS + SCAN_RATE,
                1,
                DataType.Int16,
                1,
                properties=PropertySet(
                    quality=PropertyValue(Quality.STALE.value, DataType.Int32)
                ),
            )
        ]
        logger.error.assert_not_called()

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_scan_a_stale_when_device_connection_is_broken(
        self, observer, clock, net, logger
    ):
        net.read.side_effect = DeviceConnectionIsBroken(NKEY, "conn")
        await clock.asleep(SCAN_RATE)

        assert observer.get_metrics() == [
            Metric(
                TS + SCAN_RATE,
                1,
                DataType.Int16,
                1,
                properties=PropertySet(
                    quality=PropertyValue(Quality.STALE.value, DataType.Int32)
                ),
            )
        ]
        logger.error.assert_not_called()

    @pytest.mark.parametrize("aliases", [[1]], indirect=True)
    @pytest.mark.usefixtures("scanner")
    async def should_scan_a_bad_reading(self, observer, clock, net, logger):
        net.read.side_effect = Exception("any reading failure")
        await clock.asleep(SCAN_RATE)

        assert observer.get_metrics() == [
            Metric(
                TS + SCAN_RATE,
                1,
                DataType.Int16,
                1,
                properties=PropertySet(
                    quality=PropertyValue(Quality.BAD.value, DataType.Int32)
                ),
            )
        ]
        logger.exception.assert_called()
        logger.error.assert_called()
