"""Edge Nodes are implemented following the State Design Pattern.
All states are inside this module
"""
import abc
import asyncio
import logging
from asyncio import create_subprocess_shell
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from .. import Clock, UtcClock
from .. import messages as m
from ..data import (
    DataType,
    Metric,
    MetricBirth,
    MetricChange,
    MetricChangeRequest,
    PropertySet,
    PropertyValue,
    Quality,
    Sample,
    ValueType,
)
from ..mqtt import MqttClient, SpbMessage
from .stores import HistoricalStore

if TYPE_CHECKING:
    from .nodes import Scanner


class Seq:
    """Rotatory sequency number from 0 to 255"""

    def __init__(self):
        self._value = 255

    def __call__(self) -> int:
        """If it is called, the previous value is increased and return it"""
        self._value = 0 if self._value == 255 else self._value + 1
        return self._value

    @property
    def value(self) -> int:
        """Return their value (not increasing)"""
        return self._value


class MetricsSpace(abc.ABC):
    # @abc.abstractmethod
    # async def smap(self) -> dict[str, int]:
    #     """List all name of metrics available and their aliases, 0 it does not have"""

    @abc.abstractmethod
    async def get_metrics(self, filter: str = "") -> list[Metric]:
        """List metrics of space with all its properties"""

    @abc.abstractmethod
    async def read_metric(self, metric_name: str = "", alias: str = 0) -> Sample:
        """Read a metric and return a sample with value, timestamp and quality value"""

    @abc.abstractmethod
    async def write_metric(
        self, value: ValueType, metric_name: str = "", alias: int = 0
    ) -> Sample:
        """Write a metric and return a sample with value changed, timestamp and quality value"""


class NodeState(abc.ABC):
    @abc.abstractmethod
    async def establish_session(self, metrics_space: MetricsSpace):
        ...

    @abc.abstractmethod
    async def terminate_session(self):
        ...


@dataclass
class NodeContext:
    name: str
    mqtt_client: MqttClient
    store: HistoricalStore
    primary_hostname: str
    scanner: "Scanner"
    bd_seq: Seq = field(default_factory=Seq)
    clock: Clock = field(default_factory=UtcClock)
    state: NodeState | None = None
    metrics_space: MetricsSpace | None = None


class Online(NodeState):
    def __init__(self, context: NodeContext):
        self._context = context
        self._scanner = context.scanner
        self._name = context.name
        self._metrics_space = context.metrics_space
        self._mqtt_client = context.mqtt_client
        self._primary_host = context.primary_hostname
        self._clock = context.clock
        self._bd_seq = context.bd_seq
        self._store = context.store

        self._outcomming_task = asyncio.create_task(self._send_node_messages())
        self._incomming_task = asyncio.create_task(self._recieve_host_messages())
        self._continue = True

    async def _recieve_host_messages(self):
        while self._continue:
            message = await self._mqtt_client.deliver_message()
            content = message.payload.to_dict()
            if (
                message.topic.value.endswith(f"STATE/{self._primary_host}")
                and not content["online"]
            ):
                self._context.state = Offline(self._context)
                break

            if message.is_a("NCMD"):
                content = message.payload.to_dict()
                commands = content["metrics"]
                writtings = []
                for command in commands:
                    if command["name"] == "Node Control/Scan Rate":
                        writtings.append(await self._change_scan_rate(command["value"]))
                    elif command["name"] == "Node Control/Reboot":
                        await self._reboot()
                    elif command["name"] == "Node Control/Rebirth":
                        await self._rebirth()
                    else:
                        for dto in message.payload.metrics:
                            writtings.append(await self._write_metric(dto))

                if writtings:
                    await m.NdataContent(writtings).send(
                        self._mqtt_client,
                        self._clock.timestamp(),
                        self._name,
                        self._seq(),
                    )

    async def _write_metric(self, dto: m.MetricDataTransfer):
        if not self._metrics_space:
            raise ValueError("Metric Space is not configured")
        sample = await self._metrics_space.write_metric(
            dto.value, metric_name=dto.name, alias=dto.alias
        )
        name, alias = dto.alias, dto.name
        if dto.alias:
            alias = dto.alias
            name = ""

        return m.MetricDataTransfer(
            sample.timestamp,
            sample.value,
            dto.datatype,
            alias,
            name,
            properties=PropertySet(
                quality=PropertyValue(sample.quality.value, DataType.Int32)
            ),
        )

    async def _reboot(self):
        ts = self._clock.timestamp()
        await m.NdataContent(
            [
                m.MetricDataTransfer(
                    ts, True, DataType.Boolean, name="Node Control/Reboot"
                )
            ]
        ).send(self._mqtt_client, ts, self._name, self._seq())
        await self.terminate_session()
        process = await create_subprocess_shell(
            "reboot", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
        )

        # Wait for the command to complete
        out_err = await process.communicate()

    async def _change_scan_rate(self, scan_rate_in_ms):
        self._scanner.scan_rate = scan_rate_in_ms / 1000
        return m.MetricDataTransfer(
            self._clock.timestamp(),
            scan_rate_in_ms,
            DataType.Int64,
            name="Node Control/Scan Rate",
        )

    def _add_node_dtos(self):
        ts = self._clock.timestamp()
        return [
            m.MetricDataTransfer(ts, self._bd_seq.value, DataType.Int64, name="bdSeq"),
            m.MetricDataTransfer(
                ts, False, DataType.Boolean, name="Node Control/Rebirth"
            ),
            m.MetricDataTransfer(
                ts, False, DataType.Boolean, name="Node Control/Reboot"
            ),
            m.MetricDataTransfer(
                ts,
                int(self._scanner.scan_rate * 1000),
                DataType.Int64,
                name="Node Control/Scan Rate",
            ),
        ]

    async def _send_birth_certificate(self):
        if not self._metrics_space:
            raise ValueError("Context should already has a metrics space")
        if self._scanner.is_running:
            await self._scanner.stop()

        births = await self._scanner.start(self._metrics_space)

        dtos = self._add_node_dtos() + [
            m.MetricDataTransfer.from_metric_birth(birth) for birth in births
        ]
        await m.NbirthContent(dtos).send(
            self._mqtt_client, self._clock.timestamp(), self._name, self._seq()
        )

    async def _send_data_message(self):
        historical_dtos = []
        if self._store.has_dtos():
            historical_dtos = await self._store.load_all_dtos()

        changes = await self._scanner.deliver_changes()

        dtos = [m.MetricDataTransfer.from_metric_change(change) for change in changes]
        await m.NdataContent(historical_dtos + dtos).send(
            self._mqtt_client, self._clock.timestamp(), self._name, self._seq()
        )

        if self._store.has_dtos():
            await self._store.clear()

    async def _send_death_certificate(self):
        await m.NdeathContent(
            m.MetricDataTransfer(
                self._clock.timestamp(),
                self._bd_seq.value,
                DataType.Int64,
                name="bdSeq",
            )
        ).send(self._mqtt_client, self._clock.timestamp(), self._name)

    async def _rebirth(self):
        self._outcomming_task.cancel()
        try:
            await self._outcomming_task
        except asyncio.CancelledError:
            ...

        ts = self._clock.timestamp()
        await m.NdataContent(
            [
                m.MetricDataTransfer(
                    ts, True, DataType.Boolean, name="Node Control/Rebirth"
                )
            ]
        ).send(self._mqtt_client, ts, self._name)

        self._outcomming_task = asyncio.create_task(self._send_node_messages())

    async def _send_node_messages(self):
        self._seq = Seq()
        await self._send_birth_certificate()
        while True:
            await self._send_data_message()

    async def establish_session(self, metric_space: MetricsSpace):
        """Not allowed"""
        raise RuntimeError(
            (
                "Not allowed to run establish_session method when state"
                ' is "online", run before terminate_session method'
            )
        )

    async def terminate_session(self):
        """Terminate cleanly the session"""
        await self._cancel_tasks()
        await self._send_death_certificate()
        await m.DisconnectContent().send(
            self._mqtt_client, self._clock.timestamp(), self._name
        )

        self._context.state = Disconnected(self._context)

    async def _allow_pending_messages_to_be_sent(self):
        try:
            await self._outcomming_task
        except asyncio.CancelledError:
            pass

    async def _cancel_tasks(self):
        """Stops tasks, cancel incomming, wait messages to be sent."""
        self._incomming_task.cancel()
        self._outcomming_task.cancel()
        await self._allow_pending_messages_to_be_sent()


class Offline(NodeState):
    """Gateway connected to mqtt broker, but primary host is offline"""

    def __init__(self, context: NodeContext):
        self._context = context
        self._mqtt_client = context.mqtt_client
        self._primary_host = context.primary_hostname
        self._clock = context.clock
        self._name = context.name
        self._store = context.store
        self._scanner = context.scanner
        self._recieving_messages = asyncio.create_task(self._recieve_host_messages())
        self._storing_changes = asyncio.create_task(self._store_changes())

    async def _store_changes(self):
        while True:
            changes = await self._scanner.deliver_changes()
            changes = sorted(changes, key=lambda change: change.timestamp)
            await self._store.save_dtos(
                [m.MetricDataTransfer.from_metric_change(change) for change in changes]
            )

    async def _recieve_host_messages(self):
        while True:
            message = await self._mqtt_client.deliver_message()
            content = message.payload.to_dict()
            if (
                message.topic.value.endswith(f"STATE/{self._primary_host}")
                and content["online"]
            ):
                self._recieving_messages.cancel()
                self._storing_changes.cancel()
                self._context.state = Online(self._context)
                break

    async def establish_session(self, _: MetricsSpace):
        """Not allowed, only when is disconnected to Mqtt Broker"""
        raise RuntimeError(
            f'State "offline" can not establish session, terminate_session before'
        )

    async def terminate_session(self):
        """Close session cleanly"""
        self._recieving_messages.cancel()
        self._storing_changes.cancel()
        await m.DisconnectContent().send(
            self._mqtt_client, self._clock.timestamp(), self._name
        )
        self._context.state = Disconnected(self._context)


class Disconnected(NodeState):
    def __init__(self, context: NodeContext):
        self._context = context
        self._mqtt_client = context.mqtt_client
        self._clock = context.clock
        self._primary_host = context.primary_hostname
        self._name = context.name
        self._bd_seq = context.bd_seq

    async def establish_session(self, metrics_space: MetricsSpace):
        """Connect to mqtt broker"""
        self._context.metrics_space = metrics_space
        await m.NconnectContent(self._primary_host).send(
            self._mqtt_client, self._clock.timestamp(), self._name, self._bd_seq()
        )
        self._context.state = Offline(self._context)

    async def terminate_session(
        self,
    ):
        """Dont do anything!!"""
