#  Copyright (c) ETH Zurich, SIS ID and HVL D-ITET
#
"""
Controller for the Pico Technology PT-104 temperature logger device.
The controller is written as a wrapper around Pico Technology driver for the PT-104
device.

This code is directly based on: https://github.com/trombastic/Pico_PT104/ .

Extra installation
~~~~~~~~~~~~~~~~~~

Pico Technology driver for the PT-104 device is available only on Windows and on Linux.

To use this PT-104 device wrapper:

1. install the :code:`hvl_ccb` package with a :code:`picotech` extra feature::

        $ pip install "hvl_ccb[picotech]"

   this will install the Python bindings for the library.

2. install the library

    * on Windows: download and install PicoSDK from https://www.picotech.com/downloads
      (choose "PicoLog Data Loggers" > "PT-104" > "Software");
    * on Linux:
        - for Ubuntu/Debian, install :code:`libusbpt104` from :code:`.deb` file found in
          https://labs.picotech.com/debian/pool/main/libu/libusbpt104/ (note: at the
          moment the PT-104 driver is not a part of the official :code:`picoscope`
          package; cf.
          https://www.picotech.com/support/topic40626.html );
        - for any other supported Linux distribution, follow instructions to install
          the "USB PT-104 devices" drivers in https://www.picotech.com/downloads/linux ;

"""

import logging
from abc import ABC
from ctypes import byref, c_long, c_short, c_ushort
from dataclasses import dataclass, field
from enum import IntEnum
from functools import wraps
from ipaddress import IPv4Address, IPv6Address
from time import time

import aenum
from picosdk.errors import (  # type: ignore[import-untyped,import-not-found]
    CannotFindPicoSDKError,
)

try:
    from picosdk.usbPT104 import (  # type: ignore[import-untyped,import-not-found]
        usbPt104,
    )
except CannotFindPicoSDKError as exc:
    # PicoSDK Python wrapper tries to import PicoSDK system library already on import
    raise ImportError from exc

from hvl_ccb.comm import NullCommunicationProtocol
from hvl_ccb.configuration import configdataclass
from hvl_ccb.dev.base import DeviceError, SingleCommDevice
from hvl_ccb.utils.validation import validate_and_resolve_host, validate_tcp_port

logger = logging.getLogger(__name__)


class Pt104Error(DeviceError):
    """
    Error to indicate communication issues with the pt104.
    """


def require_started(method):  # noqa: ANN201
    """
    Check if device `is_started` and raise an `Pt104Error` if not.

    :param method: `Pt104` instance method to wrap
    :return: Whatever `method` returns
    """

    @wraps(method)
    def wrapper(self, *args, **kwargs):
        if not self.is_started:
            err_msg = f"{self} is not started. Run `start()` method first."
            logger.error(err_msg)
            raise Pt104Error(err_msg)
        return method(self, *args, **kwargs)

    return wrapper


class Pt104Channels(IntEnum):
    CHANNEL_1 = 1
    CHANNEL_2 = 2
    CHANNEL_3 = 3
    CHANNEL_4 = 4
    CHANNEL_5 = 5
    CHANNEL_6 = 6
    CHANNEL_7 = 7
    CHANNEL_8 = 8
    MAX_CHANNELS = CHANNEL_8


class Pt104Wires(IntEnum):
    WIRES_2 = 2
    WIRES_3 = 3
    WIRES_4 = 4
    MIN_WIRES = WIRES_2
    MAX_WIRES = WIRES_4


class Pt104DataTypes(IntEnum):
    OFF = 0
    PT100 = 1
    PT1000 = 2
    RESISTANCE_TO_375R = 3
    RESISTANCE_TO_10K = 4
    DIFFERENTIAL_TO_115MV = 5
    DIFFERENTIAL_TO_2500MV = 6
    SINGLE_ENDED_TO_115MV = 7
    SINGLE_ENDED_TO_2500MV = 8

    @property
    def is_active(self) -> bool:
        return self is not Pt104DataTypes.OFF


_pt104_ct_dict = usbPt104.COMMUNICATION_TYPE()


class Pt104CommunicationType(aenum.IntEnum, init="value open_unit"):  # type: ignore[call-arg]
    USB = (
        _pt104_ct_dict["CT_USB"],
        usbPt104._OpenUnit,  # UsbPt104OpenUnit
    )
    ETHERNET = (
        _pt104_ct_dict["CT_ETHERNET"],
        usbPt104._OpenUnitViaIp,  # UsbPt104OpenUnitViaIP
    )

    @property
    def requires_host(self) -> bool:
        return self == self.__class__.ETHERNET


@configdataclass
class Pt104DeviceConfig:
    """
    Configuration dataclass for PT104
    """

    #: Serial number
    serial_number: str
    #: Interface to communicate on
    interface: Pt104CommunicationType
    #: Host TCP/IP address, if applicable
    host: str | IPv4Address | IPv6Address | None = None
    #: TCP port number, if applicable
    port: int | None = None

    def clean_values(self) -> None:
        if self.interface.requires_host:
            self.force_value(  # type: ignore[attr-defined]
                "host",
                validate_and_resolve_host(self.host, logger),  # type: ignore[arg-type]
            )
            validate_tcp_port(self.port, logger)

    @property
    def host_address(self) -> str | None:
        if self.host:
            port_sufix = f":{self.port}" if self.port else ""
            return f"{self.host}{port_sufix}"
        return None


@dataclass
class Pt104ChannelConfig:
    data_type: Pt104DataTypes = Pt104DataTypes.OFF
    nb_wires: Pt104Wires = Pt104Wires.WIRES_4
    low_pass_filter: bool = True
    value: c_long = field(default_factory=lambda: c_long(0))
    last_query: float = field(init=False)

    def __post_init__(self) -> None:
        self.last_query = time()


class Pt104(SingleCommDevice, ABC):
    """
    PicoTech pt104 data logger class.
    """

    def __init__(self, com, dev_config=None) -> None:
        """
        Constructor for pt104.

        :param com: object to use as communication protocol.
        """

        # Call superclass constructor
        super().__init__(com, dev_config)

        self.channels: dict[Pt104Channels, Pt104ChannelConfig] = {
            ch: Pt104ChannelConfig()
            for ch in (
                Pt104Channels.CHANNEL_1,
                Pt104Channels.CHANNEL_2,
                Pt104Channels.CHANNEL_3,
                Pt104Channels.CHANNEL_4,
            )
        }
        self._handle: c_short | None = None

    @staticmethod
    def default_com_cls() -> type[NullCommunicationProtocol]:
        return NullCommunicationProtocol

    @staticmethod
    def config_cls():
        return Pt104DeviceConfig

    def _validate_channel(self, channel: int) -> None:
        if channel not in self.channels:
            msg = (
                f"Invalid channel {channel}; available channels are: "
                f"{', '.join([str(ch) for ch in self.channels])}."
            )
            raise ValueError(msg)

    @property
    def is_connected(self) -> bool:
        """Check the connection status.

        :return: `True` if connection with device is initiated, `False` otherwise.
        """
        return self._handle is not None

    @property
    def is_started(self) -> bool:
        """Check if device is started.

        :return: `True` if device is started, `False` otherwise.
        """
        return self.is_connected

    def start(self) -> None:
        """Connect to a Pt-104A data acquisition module via USB or Ethernet"""
        logger.info(f"Starting device {self}")
        super().start()

        if self.is_connected:
            self.stop()

        self._handle = c_short()

        open_unit_extra_args = []
        if self.config.interface.requires_host:
            open_unit_extra_args.append(self.config.host_address.encode())
        status_unit = self.config.interface.open_unit(
            byref(self._handle),
            self.config.serial_number.encode(),
            *open_unit_extra_args,
        )

        if status_unit != 0:
            self._handle = None
            msg = (
                "Communication with pt104 could not be established, "
                f"error code: {status_unit}"
            )
            logger.error(msg)
            raise Pt104Error(msg)

        self.set_channels()

    @property
    def active_channel_count(self) -> int:
        """return the number of active channels
        :return: number of active channels
        """
        n = 0
        for conf in self.channels.values():
            if conf.data_type.is_active:
                n += 1
        return n

    def stop(self) -> None:
        """disconnect from the unit
        :return: bool
        """
        logger.info(f"Stopping device {self}")
        usbPt104.UsbPt104CloseUnit(self._handle)
        self._handle = None
        super().stop()

    @require_started
    def set_channel(
        self,
        channel: Pt104Channels,
        data_type: Pt104DataTypes,
        nb_wires: Pt104Wires,
        low_pass_filter: bool = True,
    ) -> None:
        """writes the channel configuration to self.channels and the device.
        :param channel: channel number (Pt104Channels)
        :param data_type: data type of the connected probe (DataType)
        :param nb_wires: number of wires (Pt104Wires)
        :param low_pass_filter: use the low pass filter [True, False]
        :return: status
        """
        logger.info(f"Configuring channel: {channel}")
        self._validate_channel(channel)
        self.channels[channel].data_type = data_type
        self.channels[channel].nb_wires = nb_wires
        self.channels[channel].low_pass_filter = low_pass_filter

        cs = usbPt104.UsbPt104SetChannel(self._handle, channel, data_type, nb_wires)
        if cs != 0:
            msg = f"Setting channel {channel} failed with error {cs}"
            logger.error(msg)
            raise Pt104Error(msg)

    def set_channels(self) -> None:
        """sets the channel configuration from self.channels"""
        for channel in self.channels:
            self.set_channel(
                channel,
                self.channels[channel].data_type,
                self.channels[channel].nb_wires,
            )

    @require_started
    def get_value(self, channel: Pt104Channels, raw_value: bool = False) -> float:
        """queries the measurement value from the unit
        :param channel: channel number (Pt104Channels)
        :param raw_value: skip conversion
        :return: measured value
        """
        self._validate_channel(channel)
        result = None
        while result is None:
            self._wait_for_conversion(channel)
            status_channel = usbPt104.UsbPt104GetValue(
                self._handle,
                channel,
                byref(self.channels[channel].value),
                self.channels[channel].low_pass_filter,
            )
            self.channels[channel].last_query = time()
            if status_channel == 0:
                if raw_value:
                    return float(self.channels[channel].value.value)
                return self.scale_value(
                    float(self.channels[channel].value.value), channel
                )

            msg = f"Error while reading {channel}, error code {status_channel}"
            logger.error(msg)
            raise Pt104Error(msg)
        return None

    @property
    def get_value_channel_1(self) -> float:
        """queries the measurement value from channel 1
        :return: scaled measured value
        """
        return self.get_value(Pt104Channels.CHANNEL_1)

    @property
    def get_value_channel_2(self) -> float:
        """queries the measurement value from channel 2
        :return: scaled measured value
        """
        return self.get_value(Pt104Channels.CHANNEL_2)

    @property
    def get_value_channel_3(self) -> float:
        """queries the measurement value from channel 3
        :return: scaled measured value
        """
        return self.get_value(Pt104Channels.CHANNEL_3)

    @property
    def get_value_channel_4(self) -> float:
        """queries the measurement value from channel 4
        :return: scaled measured value
        """
        return self.get_value(Pt104Channels.CHANNEL_4)

    def set_mains(self, sixty_hertz: bool = False) -> None:
        """This function is used to inform the driver of the
        local mains (line) frequency.
        This helps the driver to filter out electrical noise.
        :param sixty_hertz: mains frequency is sixty
        :return: success
        """
        if sixty_hertz:
            sixty = c_ushort(1)
            logger.info("set mains freq to 60 Hz")
        else:
            sixty = c_ushort(0)
            logger.info("set mains freq to 50 Hz")
        usbPt104.UsbPt104SetMains(self._handle, sixty)

    def _wait_for_conversion(self, channel: Pt104Channels) -> None:
        """wait until the adc conversion is finished
        :param channel: channel number (Pt104Channels)
        :return:
        """
        conversion_time = self.active_channel_count * 0.75
        last_query = self.channels[channel].last_query
        while last_query + conversion_time > time():
            pass

    def scale_value(self, value: float, channel: Pt104Channels) -> float:
        """scales the value from the device.
        :param value: value to convert as float
        :param channel: channel number (Pt104Channels)
        :return: Temperature in °C, Resistance in mOhm, Voltage in mV
        """
        if self.channels[channel].data_type in [
            Pt104DataTypes.PT100,
            Pt104DataTypes.PT1000,
        ]:
            return value / 10.0**3  # °C
        if self.channels[channel].data_type == Pt104DataTypes.RESISTANCE_TO_375R:
            return value / 10.0**3  # mOhm
        if self.channels[channel].data_type == Pt104DataTypes.RESISTANCE_TO_10K:
            return value  # mOhm
        if self.channels[channel].data_type in [
            Pt104DataTypes.DIFFERENTIAL_TO_115MV,
            Pt104DataTypes.SINGLE_ENDED_TO_115MV,
        ]:
            return value / 10.0**9  # mV
        if self.channels[channel].data_type in [
            Pt104DataTypes.DIFFERENTIAL_TO_2500MV,
            Pt104DataTypes.SINGLE_ENDED_TO_2500MV,
        ]:
            return value / 10.0**8  # mV

        msg = "selected datatype not recognized"
        raise Pt104Error(msg)
