import asyncio
from unittest.mock import AsyncMock, MagicMock

import pytest

import aiospb.nodes.nodes as n
from aiospb.infra.inmem import InMemHistorian
from aiospb.messages import Event, Message, MessageSession, SessionIsBroken
from aiospb.nodes.devices import NodeDevice
from aiospb.shared import DataType, DeviceKey, Metric, NodeOptions

TS = 1000
NKEY = DeviceKey("group/node")
HKEY = DeviceKey("hostname")
DKEY = DeviceKey("group/node/device")
METRICS = [Metric(TS, 1, DataType.Int16, name="Metric/Name")]
OPTS = NodeOptions(primary="hostname")


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


@pytest.fixture
def exit(monkeypatch):
    mock = MagicMock()
    monkeypatch.setattr("sys.exit", mock)
    return mock


@pytest.fixture
def run(monkeypatch):
    mock = MagicMock()
    monkeypatch.setattr("subprocess.run", mock)
    return mock


class Stack:
    def __init__(self):
        self._queue = asyncio.Queue()

    def put(self, item):
        self._queue.put_nowait(item)

    def __aiter__(self):
        return self

    async def __anext__(self):
        item = await self._queue.get()
        if isinstance(item, Exception):
            raise item
        return item


@pytest.fixture
def session():
    mock = MagicMock(spec=MessageSession)
    mock.subscribe.return_value = mock.messages = Stack()
    return mock


@pytest.fixture
def root():
    m = MagicMock(spec=NodeDevice)
    m.key = NKEY
    return m


@pytest.fixture
def historian():
    return InMemHistorian()


@pytest.fixture
def device():
    m = MagicMock(spec=NodeDevice)
    m.key = DKEY
    return m


@pytest.fixture
async def node(root, session, historian):
    nod = n.EdgeNode(root, session, historian, OPTS)
    await nod.establish_session()
    yield nod
    await nod.terminate_session()


@pytest.fixture
async def node_without_primary(root, session, historian):
    opts = NodeOptions()
    nod = n.EdgeNode(root, session, historian, opts)
    await nod.establish_session()
    yield nod
    await nod.terminate_session()


class A_EdgeNode:
    async def when_host_is_online(self, key: str, online: bool, session):
        session.messages.put(
            Message(Event.HOST_STATE_HAS_CHANGED, DeviceKey(key), online=online)
        )
        await asyncio.sleep(0.005)

    async def should_rise_devices_when_primary_is_online(
        self, node, root, session, device
    ):
        node.add_device(device)

        await self.when_host_is_online(OPTS.primary, True, session)
        await asyncio.sleep(0.005)

        device.rise.assert_called()
        root.rise.assert_called()

    async def should_die_devices_when_terminate_session(
        self, node, session, root, device
    ):
        node.add_device(device)

        await node.establish_session()
        await self.when_host_is_online(OPTS.primary, True, session)

        await node.terminate_session()

        root.die.assert_called_with()
        device.die.assert_called_with()

    def notify_to_node_from(self, device):
        return device.add_node_notification.call_args[0][0]

    @pytest.mark.usefixtures("node")
    async def should_send_changes_to_historian_if_primary_host_is_offline(
        self, root, session, historian
    ):
        await self.when_host_is_online("no-primary", True, session)
        assert not await historian.load(NKEY)

        await self.notify_to_node_from(root)(
            Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        )

        assert await historian.load(NKEY)
        session.publish.assert_not_called()

    @pytest.mark.usefixtures("node")
    async def should_publish_changes_if_primary_host_is_online(
        self, root, session, historian
    ):
        await self.when_host_is_online(OPTS.primary, True, session)

        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)

        assert not await historian.load(NKEY)
        session.publish.assert_called_with(msg)

    @pytest.mark.usefixtures("node_without_primary")
    async def should_send_changes_to_historian_if_no_host(
        self, root, session, historian
    ):
        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)

        assert await historian.load(NKEY)
        session.publish.assert_not_called()

    @pytest.mark.usefixtures("node_without_primary")
    async def should_publish_changes_if_any_host(self, root, session, historian):
        await self.when_host_is_online("any-host", True, session)

        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)

        session.publish.assert_called()
        assert not await historian.load(NKEY)

    @pytest.mark.usefixtures("node")
    async def should_publish_history_if_any(self, root, session, historian):
        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)

        await self.when_host_is_online(OPTS.primary, True, session)
        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)
        await asyncio.sleep(0.01)

        history = [
            Metric(m.timestamp, m.value, m.data_type, name=m.name, is_historical=True)
            for m in METRICS
        ]
        assert not await historian.load(NKEY)
        session.publish.assert_called_with(
            Message(Event.NODE_DATA_HAS_CHANGED, NKEY, history + METRICS)
        )

    @pytest.mark.usefixtures("node")
    async def should_publish_without_history_if_error(self, root, session, historian):
        historian.load = AsyncMock()
        historian.load.side_effect = Exception("loading historian")

        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)

        await self.when_host_is_online(OPTS.primary, True, session)
        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        await self.notify_to_node_from(root)(msg)
        await asyncio.sleep(0.01)

        session.publish.assert_called_with(
            Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)
        )

    @pytest.mark.usefixtures("node")
    async def should_restablish_session_if_broken_when_publishing(
        self, session, root, logger
    ):
        await self.when_host_is_online(OPTS.primary, True, session)
        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)

        session.publish.side_effect = SessionIsBroken()
        await self.notify_to_node_from(root)(msg)
        await asyncio.sleep(0.01)

        logger.warning.assert_called()
        assert len(session.establish.mock_calls) == 2

    @pytest.mark.usefixtures("node")
    async def should_restablish_session_if_broken_when_incomming_host_messages(
        self, session, logger
    ):
        session.messages.put(SessionIsBroken())
        await asyncio.sleep(0.01)

        logger.error.assert_called
        assert len(session.establish.mock_calls) == 2

    @pytest.mark.usefixtures("node")
    async def should_ignore_exceptions_when_publishing(self, session, root, logger):
        await self.when_host_is_online(OPTS.primary, True, session)
        msg = Message(Event.NODE_DATA_HAS_CHANGED, NKEY, METRICS)

        session.publish.side_effect = Exception("any")
        await self.notify_to_node_from(root)(msg)
        await asyncio.sleep(0.01)

        logger.error.assert_called()
        logger.exception.assert_called()

    @pytest.mark.usefixtures("node")
    async def should_ignore_exceptions_when_incomming_host_messages(
        self, session, logger
    ):
        session.messages.put(Exception("any"))
        await asyncio.sleep(0.01)

        logger.error.assert_called()

    async def should_execute_device_commands(self, node, session, device):
        node.add_device(device)

        session.messages.put(Message(Event.DEVICE_COMMAND_IS_SENT, DKEY, METRICS))
        await asyncio.sleep(0.01)

        device.write_metrics.assert_called_with(METRICS, int(OPTS.write_timeout * 1000))

    @pytest.mark.usefixtures("node")
    async def should_execute_node_commands(self, session, root):
        session.messages.put(Message(Event.NODE_COMMAND_IS_SENT, NKEY, METRICS))
        await asyncio.sleep(0.01)

        root.write_metrics.assert_called_with(METRICS, int(OPTS.write_timeout * 1000))

    async def should_rebirth_nodes(self, session, root, device, node):
        node.add_device(device)
        m1 = Metric(TS, 1, DataType.Int16, alias=1)
        m2 = Metric(TS, 2, DataType.Int16, alias=2)
        session.messages.put(
            Message(
                Event.NODE_COMMAND_IS_SENT,
                NKEY,
                [
                    m1,
                    Metric(TS, True, DataType.Boolean, name="Node Control/Rebirth"),
                    m2,
                ],
            )
        )
        await asyncio.sleep(0.01)

        root.rise.assert_called_with(OPTS.scan_rate)
        assert len(root.write_metrics.mock_calls) == 2
        device.rise.assert_called_with(OPTS.scan_rate)

    async def should_rebirth_device(self, session, root, device, node):
        node.add_device(device)
        m1 = Metric(TS, 1, DataType.Int16, alias=1)
        m2 = Metric(TS, 2, DataType.Int16, alias=2)
        session.messages.put(
            Message(
                Event.DEVICE_COMMAND_IS_SENT,
                DKEY,
                [
                    m1,
                    Metric(TS, True, DataType.Boolean, name="Device Control/Rebirth"),
                    m2,
                ],
            )
        )
        await asyncio.sleep(0.01)

        root.rise.assert_not_called()
        assert len(device.write_metrics.mock_calls) == 2
        device.rise.assert_called_with(OPTS.scan_rate)

    async def should_restart_devices(self, device, node, session):
        node.add_device(device)
        m1 = Metric(TS, 1, DataType.Int16, alias=1)
        m2 = Metric(TS, 2, DataType.Int16, alias=2)
        session.messages.put(
            Message(
                Event.DEVICE_COMMAND_IS_SENT,
                DKEY,
                [
                    m1,
                    Metric(TS, True, DataType.Boolean, name="Device Control/Restart"),
                    m2,
                ],
            )
        )
        await asyncio.sleep(0.01)

        assert len(device.write_metrics.mock_calls) == 2
        device.rise.assert_called_with(OPTS.scan_rate)
        device.die.assert_called_with()

    async def should_restart_nodes(self, node, device, session, root, exit):
        node.add_device(device)
        m1 = Metric(TS, 1, DataType.Int16, alias=1)
        m2 = Metric(TS, 2, DataType.Int16, alias=2)
        session.messages.put(
            Message(
                Event.NODE_COMMAND_IS_SENT,
                DKEY,
                [
                    m1,
                    Metric(TS, True, DataType.Boolean, name="Node Control/Restart"),
                    m2,
                ],
            )
        )
        await asyncio.sleep(0.01)

        assert len(root.write_metrics.mock_calls) == 1  # m2 will be lost
        root.die.assert_called_with()
        device.die.assert_called_with()
        exit.assert_called_with()

    async def should_reboot_nodes(self, node, device, session, root, run):
        node.add_device(device)
        m1 = Metric(TS, 1, DataType.Int16, alias=1)
        m2 = Metric(TS, 2, DataType.Int16, alias=2)

        session.messages.put(
            Message(
                Event.NODE_COMMAND_IS_SENT,
                NKEY,
                [
                    m1,
                    Metric(TS, True, DataType.Boolean, name="Node Control/Reboot"),
                    m2,
                ],
            )
        )

        await asyncio.sleep(0.01)
        root.die.assert_called_with()
        device.die.assert_called_with()
        run.assert_called_with("shutdown -r now", check=True, capture_output=True)
        assert len(root.write_metrics.mock_calls) == 1  # m2 will be lost
