import minimalmodbus
from typing import List, Union, Any
import logging
logger = logging.getLogger(__name__)

from .read_tag.read_tag import ReadTag

class SensorReadError(Exception):
    """Exception raised when sensor read operation fails.

    Attributes
    ----------
    tag : ReadTag
        The tag that failed to read.
    original_exception : Exception
        The underlying exception that caused the failure.
    """
    def __init__(self, tag: ReadTag, original_exception: Exception):
        self.tag = tag
        self.original_exception = original_exception
        super().__init__(
            f"Failed to read {type(tag).__name__}: {original_exception}"
        )




class Sensor:
    """Generic sensor instrument configured with valid tags and namespace.

    The Sensor class provides a base for all sensor implementations,
    handling Modbus RTU communication, tag validation, and dispatch to
    sensor-specific read functions.

    Parameters
    ----------
    port : str
        Serial port name (e.g., 'COM3' on Windows, '/dev/ttyUSB0' on Linux).
    valid_tags : List[ReadTag]
        List of ReadTag instances that are valid for this sensor.
    namespace : dict
        Dispatch namespace dictionary containing the multipledispatch Dispatcher
        for this sensor's tag-specific read functions.
    slave_address : int, optional
        Modbus slave address (default: 1).
    baudrate : int, optional
        Serial baudrate (default: 19200).
    stopbits : int, optional
        Number of stop bits (default: 1).
    bytesize : int, optional
        Number of data bits (default: 8).
    parity : str, optional
        Parity setting (default: 'N'). Options: 'N' (none), 'E' (even), 'O' (odd).
    timeout : float, optional
        Serial timeout in seconds (default: 0.5).

    Attributes
    ----------
    instrument : minimalmodbus.Instrument
        The underlying Modbus RTU instrument used for communication.

    Raises
    ------
    ValueError
        If invalid tags are provided to `read()`.
    IOError
        If Modbus communication fails.

    Examples
    --------
    Basic usage with a GMP252 CO2 sensor:

    >>> from atmospyre.sensors.implementations.gmp252 import GMP252, CO2, TEMPERATURE
    >>> sensor = GMP252(port='/dev/ttyUSB0', slave_address=1)
    >>> result = sensor.read([CO2, TEMPERATURE])
    >>> print(f"CO2: {result[CO2]} ppm, Temp: {result[TEMPERATURE]} °C")
    CO2: 415.2 ppm, Temp: 23.5 °C

    Custom serial configuration:

    >>> sensor = GMP252(
    ...     port='/dev/ttyUSB0',
    ...     baudrate=9600,
    ...     timeout=1.0,
    ...     stopbits=2
    ... )

    Notes
    -----
    The Sensor class uses ``multipledispatch`` to implement type-based dispatch ([example](https://www.geeksforgeeks.org/python/dispatch-decorator-in-python/)).

    Each sensor has its own dispatch namespace to avoid conflicts. When you call
    ``read(tag)``, the sensor validates the tag and dispatches to the appropriate
    sensor-specific read function based on the tag's type.

    Current tight coupling to ``minimalmodbus`` is undesired and will be refactored.
    """

    def __init__(
        self,
        port: str,
        valid_tags: List[ReadTag],
        namespace: dict,
        slave_address: int = 1,
        baudrate: int = 19200,
        stopbits: int = 1,
        bytesize: int = 8,
        parity: str = 'N',
        timeout: float = 0.5
    ):
        """Initialize sensor with port, slave address, and valid tags.

        See class docstring for parameter details.
        """
        self.instrument = minimalmodbus.Instrument(port, slave_address)
        self._valid_tags = valid_tags
        self._namespace = namespace

        # Configure serial settings
        self.instrument.serial.baudrate = baudrate
        self.instrument.serial.stopbits = stopbits
        self.instrument.serial.bytesize = bytesize
        self.instrument.serial.parity = parity
        self.instrument.serial.timeout = timeout
        self.instrument.mode = minimalmodbus.MODE_RTU
        self.instrument.close_port_after_each_call = True

    def read(self, tags: Union[ReadTag, List[ReadTag]]) -> dict[ReadTag, Any]:
        """Read one or more measurements using tag dispatch.

        Parameters
        ----------
        tags : ReadTag or List[ReadTag]
            Single ReadTag instance or list of ReadTag instances to read.
            All tags must be in the sensor's valid tag list.

        Returns
        -------
        dict[ReadTag, Any]
            Dictionary mapping each input tag to its measured value.

        Raises
        ------
        ValueError
            If any tag is not in the sensor's valid tag list.
        SensorReadError
            If reading any tag fails due to communication errors.
            The exception contains the failed tag and original error.

        Examples
        --------
        >>> result = sensor.read(CO2)
        >>> print(result[CO2])
        415.2

        >>> try:
        ...     result = sensor.read([CO2, TEMPERATURE])
        ... except SensorReadError as e:
        ...     print(f"Failed to read {type(e.tag).__name__}")
        ...     print(f"Reason: {e.original_exception}")
        """
        # Normalize input to list
        if isinstance(tags, ReadTag):
            tags = [tags]

        # Validate all tags upfront
        for tag in tags:
            if tag not in self._valid_tags:
                raise ValueError(
                    f"Tag {type(tag).__name__} not valid for this sensor. "
                    f"Valid tags: {[type(t).__name__ for t in self._valid_tags]}"
                )

        # Get the dispatchers registered at the sensors namespace
        _read = self._namespace['_read']

        results = {}
        for tag in tags:
            try:
                value = _read(self.instrument, tag)
                results[tag] = value

            except Exception as e:
                logger.error(
                    f"Failed to read {type(tag).__name__}: {e}",
                    exc_info=True
                )
                raise SensorReadError(tag, e) from e

        return results

    def get_valid_tags(self) -> List[ReadTag]:
        """Get list of all valid tags for this sensor.

        Returns a copy of the valid tags list to prevent external modification.

        Returns
        -------
        List[ReadTag]
            Copy of the list of valid ReadTag instances for this sensor.

        Examples
        --------
        >>> tags = sensor.get_valid_tags()
        >>> print(f"This sensor supports {len(tags)} measurements")
        This sensor supports 5 measurements
        >>>
        >>> for tag in tags:
        ...     meta = tag.metadata
        ...     print(f"- {type(tag).__name__}: {meta.description} ({meta.unit})")
        - CO2Tag: CO2 concentration (float) (ppm)
        - TemperatureTag: Measurement temperature (°C)
        - StatusTag: General device status (None)
        """
        return self._valid_tags.copy()