"""Main components of sparkplug standard"""

import abc
import asyncio
import logging
from uuid import uuid4

from .. import Clock, UtcClock
from ..data import DataType, Metric, MetricBirth, MetricChange, Quality, Sample
from ..mqtt import MqttClient
from .states import Disconnected, MetricsSpace, NodeContext, Seq
from .stores import HistoricalStore, OnmemHistoricalStore

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)


class MetricsConnectionError(Exception):
    ...


class EdgeNode:
    """Gateway connected to mqtt and to one or more hardware devices"""

    def __init__(
        self,
        name: str,
        mqtt_client: MqttClient,
        store: HistoricalStore | None = None,
        scan_rate: float = 60.0,  # Default, scan every minute
        primary_hostname: str = "",
        clock: Clock | None = None,
    ):
        self._name = name
        clock = clock or UtcClock()
        self._context = NodeContext(
            name,
            mqtt_client,
            store or OnmemHistoricalStore(),
            primary_hostname,
            Scanner(clock, scan_rate),
            Seq(),
            clock,
        )
        self._context.state = Disconnected(self._context)

    @property
    def group(self) -> str:
        return self._name.split("/")[0]

    @property
    def name(self) -> str:
        return self._name.split("/")[1]

    @property
    def state(self) -> str:
        return self._context.state.__class__.__name__.lower()

    async def establish_session(self, metrics_space: MetricsSpace):
        """Establish a session for a supplied metrics space"""
        await self._context.state.establish_session(metrics_space)

    async def terminate_session(self):
        """Terminate cleanly the session and"""
        if self._context.state is None:
            return
        await self._context.state.terminate_session()


class _Scanning:
    def __init__(
        self,
        clock: Clock,
        space: MetricsSpace,
        queue: asyncio.Queue,
        changes: list[MetricChange],
        scan_rate: float,
    ):
        self._clock = clock
        self._space = space
        self._queue = queue
        self.changes = changes
        self.scan_rate = scan_rate

        self._readings = set()
        self._task = None

    def _when_scan(self):
        return (
            ((self._clock.timestamp() // int(self.scan_rate * 1000)) + 1)
            * self.scan_rate
            * 1000
        )

    async def _scan_metrics(self, when_start: float):
        epoch = self._clock.timestamp()
        if when_start > epoch:
            await asyncio.sleep((when_start - epoch) / 1000)

        when_start_next = self._when_scan()

        tasks = [
            asyncio.create_task(
                self._space.read_metric(change.metric_name),
                name=self._create_task_name("reading"),
            )
            for change in self.changes
        ]
        for task in tasks:
            self._readings.add(task)
            task.add_done_callback(self._readings.remove)
        _, pendings = await asyncio.wait(tasks, timeout=self.scan_rate)

        for task in pendings:  # These readings has needed too much time
            task.cancel()

        samples = []
        for task in tasks:
            if task in pendings:
                sample = Sample(epoch, None, Quality.STALE)
            elif task.exception():
                sample = Sample(epoch, None, Quality.STALE)
            else:
                sample = task.result()
            samples.append(sample)

        changes = [
            change.update_from_sample(sample)
            for change, sample in zip(self.changes, samples)
        ]

        self.changes = [
            change or last_change for change, last_change in zip(changes, self.changes)
        ]
        self._task = asyncio.create_task(
            self._scan_metrics(
                when_start_next,
            ),
            name=self._create_task_name(),
        )

        changes = [change for change in changes if change is not None]
        if changes:
            await self._queue.put(changes)

    def _create_task_name(self, prefix="scanning"):
        return f"{prefix}-{uuid4()}"

    def start(self):
        when = self._when_scan()
        self._task = asyncio.create_task(self._scan_metrics(when))

    def stop(self):
        if self._task is None:
            return

        self._task.cancel()
        for reading in self._readings:
            reading.cancel()


class Scanner:
    def __init__(self, clock: Clock, scan_rate: float = 60.0):
        self._clock = clock
        self._scan_rate = scan_rate

        self._queue = asyncio.Queue()
        self._space = None
        self._scannings = {}
        self._space = None

    @property
    def scan_rate(self) -> float:
        return self._scan_rate

    @property
    def is_running(self) -> bool:
        return bool(self._scannings)

    @property
    def metrics_space(self) -> MetricsSpace | None:
        return self._space

    @scan_rate.setter
    def scan_rate(self, value: float):
        if self._space:
            scanning = self._scannings.pop(self._scan_rate)
            scanning.stop()

            scanning = _Scanning(
                self._clock, self._space, self._queue, scanning.changes, value
            )
            scanning.start()
            self._scannings[value] = scanning

        self._scan_rate = value

    def deliver_size(self) -> int:
        """Return number of batches of changes pending to read by client"""
        return self._queue.qsize()

    async def deliver_changes(self) -> list[MetricChange]:
        """Return last changes"""
        return await self._queue.get()

    async def start(self, metrics_space: MetricsSpace) -> list[MetricBirth]:
        self._space = metrics_space

        metrics = await metrics_space.get_metrics()

        readings = [
            asyncio.create_task(self._space.read_metric(metric.name))
            for metric in metrics
        ]
        samples = await asyncio.gather(*readings)

        births = [
            MetricBirth.from_metric(metric, sample)
            for metric, sample in zip(metrics, samples)
        ]

        changes = {self._scan_rate: []}

        for birth in births:
            if "Properties/" in birth.name:  # Properties will not be scanned
                continue

            if "scan_rate" in birth.properties:
                scan_rate = birth.properties["scan_rate"].value
                if type(scan_rate) is not int:
                    raise ValueError(f"Scan rate can not be of type {type(scan_rate)}")

                scan_rate = scan_rate / 1000
                if scan_rate == 0.0:  # No scan this metric
                    continue
                else:
                    if scan_rate not in changes:
                        changes[scan_rate] = []
                    changes[scan_rate].append(birth.extract_change())

            changes[self._scan_rate].append(birth.extract_change())

        self._scan_tasks = {}
        for scan_rate, changes_by_scan_rate in changes.items():
            scanning = _Scanning(
                self._clock, metrics_space, self._queue, changes_by_scan_rate, scan_rate
            )
            scanning.start()
            self._scannings[scan_rate] = scanning

        return births

    async def stop(self):
        """Stop all open scanning tasks"""
        for scanning in self._scannings.values():
            scanning.stop()
        self._scannings = {}
        await asyncio.sleep(0)  # Allow expand all cancellations
