from abc import ABC, abstractmethod
from ast import Lambda
from deprecated import deprecated
from typing import Callable, Optional, Dict, Any, List
import serial
import string
import random
import time
from .cdcl.cdcl import CDCLCommand, CDCLParser, CDCLResponse, PlateReaderModeCalculator
from .experiment_results import DataTransmissionParser, ExperimentResults
from .cdcl.experiment_builder import FullExperimentBuilder
from .cdcl.codes import ExperimentMetadataCodes, PlateReaderSpecificInstructionPrefixes, GenericInstructionPrefixes, StartCommandPrefixes, GenericResponsePrefixes, ExperimentType, PlateReaderSpecificResponsePrefixes
from operator import attrgetter


class CerilloBasePlateReader(ABC):
    """Base class for all Cerillo plate readers"""

    def __init__(self, port: str = '/dev/ttyUSB0', baudrate: int = 250000,
                 timeout: float = 120.0, simulate: bool = False):
        self.port = port
        self.baudrate = baudrate
        self.timeout = timeout
        self.simulate = simulate
        self._connection: Optional[serial.Serial] = None
        self.serial_number = None
        self.model_number = None
        self.name = None
        self.status = None
        self.firmware_version = None
        self.wavelengths: List[int] = []
        if simulate:
            self.wavelengths = [450]

    def __repr__(self):
        """Developer-friendly representation"""
        conn_status = "connected" if self._connection and self._connection.is_open else "disconnected"
        sim_str = " [SIMULATION]" if self.simulate else ""

        # Basic info always shown
        parts = [f"{self.__class__.__name__}("]

        # Device info if available
        if self.name:
            parts.append(f"name='{self.name}'")
        if self.serial_number:
            parts.append(f"serial={self.serial_number}")
        if self.model_number:
            parts.append(f"model='{self.model_number}'")
        parts.append(f"wavelengths={self.wavelengths}")

        # Connection info
        parts.append(f"port='{self.port}'")
        parts.append(f"status={conn_status}{sim_str}")
        return ", ".join(parts) + ")"

    def connect(self) -> None:
        """Establish connection to the plate reader"""
        if self.simulate:
            print(f"[SIMULATION] Connected to {self.__class__.__name__}")
            self.serial_number = "12345"
            return

        try:
            self._connection = serial.Serial(
                self.port,
                self.baudrate,
                timeout=self.timeout
            )

            start_time = time.time()
            while (self.serial_number == None):
                # Check if timeout exceeded
                elapsed = time.time() - start_time
                if elapsed > self.timeout:
                    raise TimeoutError(
                        f"Device startup timed out after {self.timeout} seconds waiting for serial number line")

                line = self._connection.readline().decode().strip()
                print(line)
                if (line.startswith(":")):
                    self.serial_number = line[1:line.find(" ")]
                    print(
                        f"Connected to device with serial number {self.serial_number}")

            self.initialize()
        except serial.SerialException as e:
            raise ConnectionError(f"Failed to connect to plate reader: {e}")

    def disconnect(self) -> None:
        """Close connection to the plate reader"""
        if self.simulate:
            print(f"[SIMULATION] Disconnected from {self.__class__.__name__}")
            return

        if self._connection and self._connection.is_open:
            self._connection.close()

    def send_command(self, command: CDCLCommand, timeout: int = None, onNewLineCallback: Callable[[str], None] = None) -> CDCLResponse:
        """
        Send a CDCL command to the connected device and wait for a response.

        Description:
            This method will ensure a connection is present (calling `connect()` if
            necessary), write a properly formatted command, then block until a
            response matching the command id is received. When in simulation mode
            the function returns a synthetic `CDCLResponse`.

        Usage Note:
            Prefer to use higher-level plate reader control commands like `start_endpoint_experiment()`
            instead of this function directly

        Args:
            command: CDCLCommand object to send
            timeout: Per-command timeout in seconds. If None, the instance's
                `timeout` property is used.
            onNewLineCallback: Optional callback function invoked for every
                newline received from the device. The callback receives the
                decoded line as a single string parameter.

        Returns:
            CDCLResponse: Parsed CDCL response matching the sent command id.

        Raises:
            TimeoutError: If the response is not received within `timeout`.
            ConnectionError: When there is a failure opening the serial port.
            TypeError: If the received message isn't a valid CDCL response.
        """
        # set timeout to classes if not passed
        if timeout == None:
            timeout = self.timeout
        if self.simulate:
            return CDCLResponse(
                serial_number=self.serial_number,
                command_id=command.command_id,
                prefix=command.command,
                params=command.params,
                raw_message=f':{self.serial_number} {command.command}*{command.command_id} {command.params}'
            )
        try:

            # Add command ID if needed
            command_id = command.command_id
            if (not command.command_id or command.command_id == ""):
                command_id = ''.join(random.choice(
                    string.ascii_uppercase + string.digits) for _ in range(4))
                command.command_id = command_id
            # Send command (adjust formatting for your device)
            device_command = f"{command.to_message()}\n".encode()
            self.log_serial(direction="send", message=device_command)

            # return early if simulating
            if self.simulate:
                return

            if not self._connection or not self._connection.is_open:
                self.connect()

            # Clear any buffered input before sending command
            self._connection.reset_input_buffer()

            self._connection.write(device_command)
            self._connection.flush()
            # Read response (adjust based on your protocol)
            # clear echo
            response = ''
            echo = 0
            start_time = time.time()
            while (response == ''):
                # Check if timeout exceeded
                elapsed = time.time() - start_time
                if elapsed > timeout:
                    raise TimeoutError(
                        f"Command {command} timed out after {timeout} seconds waiting for response with ID {command_id}")

                line = self._connection.readline().decode().strip()
                self.log_serial(direction="receive", message=line)
                # handle callback
                if onNewLineCallback:
                    onNewLineCallback(line)
                # print(line)
                if (command_id in line):
                    # clear the echo
                    if (not echo):
                        echo = 1
                    else:
                        response = line
            print(f"$Command: {command} | $Response: {response}")

            if not CDCLParser.is_cdcl_response(response):
                raise TypeError(
                    "response is not a valid CDCL response", response)
            response = CDCLParser.parse_response(response)

            return response
        except Exception as e:
            print(f"Command failed: {e}")
            raise

    def log_serial(self, direction: str, message: str, max_width: int = 80):
        """
        Log a serial communication event to stdout.

        The helper prints a single line prefixed with 'TX' or 'RX' and an arrow
        to show direction. The `simulate` flag is included in the printed
        information when set. This is intended for developer debugging, not
        structured logging.

        Args:
            direction: "send" for TX entries or any other string for RX.
            message: Message that was transmitted or received.
            max_width: Optional width to enforce truncation for readability.
        """
        if direction == "send":
            arrow = "→"
            print(f'{"[SIMULATION] " if self.simulate else ""}TX {arrow} {message}')
        else:  # receive
            arrow = "←"
            print(f'{"[SIMULATION] " if self.simulate else ""}RX {arrow} {message}')

    def initialize(self) -> None:
        """
        Initialize metadata on the connected plate reader.

        This method queries the device for info (`GenericInstructionPrefixes.GET_INFO`),
        status, and the available wavelengths, then fills in attributes like
        `name`, `model_number`, `firmware_version`, `status`, and
        `wavelengths` on the instance. In simulation mode the method is a no-op
        and simply prints a message.
        """
        if self.simulate:
            print("[SIMULATION] Plate Reader initialized")
            return
        # TODO get serial number
        info_command = CDCLCommand(
            command=GenericInstructionPrefixes.GET_INFO)
        info_response = self.send_command(info_command, timeout=60)
        print(info_response)
        # find the param that starts with "n" and slice the prefix
        self.name = next(
            x for x in info_response.params if x.startswith("n"))[1:]
        # find the param that starts with "n" and slice the prefix
        self.model_number = next(
            x for x in info_response.params if x.startswith("m"))[1:]
        self.firmware_version = next(
            x for x in info_response.params if x.startswith("f"))[1:]

        status_command = CDCLCommand(
            command=GenericInstructionPrefixes.GET_STATUS)
        status_response = self.send_command(status_command)
        self.status = status_response.params[0]

        wavelength_command = CDCLCommand(
            command=PlateReaderSpecificInstructionPrefixes.GET_WAVELENGTHS)
        wavelength_response = self.send_command(wavelength_command)
        if wavelength_response.params:
            self.wavelengths = list(map(int, wavelength_response.params))

        print(self)

    def calibrate(self, wavelength: int) -> CDCLResponse:
        """
        Calibrate the plate reader for a specific wavelength.

        Args:
            wavelength: The wavelength in nm to calibrate (must be present in
                `self.wavelengths`).

        Returns:
            CDCLResponse object for the calibrate command response.

        Raises:
            ValueError: If the provided wavelength is not supported by device.
        """
        wavelength_index = self.wavelengths.index(wavelength)
        if wavelength_index == -1:
            raise ValueError(f'wavelength {wavelength} not found on device')
        mode = PlateReaderModeCalculator.set_led_mode(0, wavelength_index)
        calibrate_command = CDCLCommand(
            command=PlateReaderSpecificInstructionPrefixes.CALIBRATE, params=[str(mode)])
        return self.send_command(calibrate_command)

    @deprecated(version="0.0.6", reason="Use start_kinetic_experiment() or start_endpoint_experiment() instead")
    def read_absorbance(self, wavelength: int, interval: int, duration: int,  **kwargs) -> Dict[str, Any]:
        """
          @Deprecated as of v0.0.6 - Use start_kinetic_experiment() or start_endpoint_experiment() instead
          Start an endpoint experiment for a given wavelength, reading every interval for a set duration

          Args:
              wavelength: (nm) must be one of the available wavelengths on device, otherwise a ValueError will be thrown
              interval: (seconds) Time in between start of each reading
              duration: (hours) How long the experiment will continue for

          """
        if self.simulate:
            print("[SIMULATION] Read absorbance")

        return self.start_kinetic_experiment(wavelength, interval, duration)

    def start_experiment(self, builder: FullExperimentBuilder) -> Dict[str, any]:
        """
        Send a complete experiment configuration to the device and gather results.

        The function constructs CDCL commands via the provided
        `FullExperimentBuilder`, sends them to the device and blocks until at
        least one data point is received. It returns the constructed
        `ExperimentResults` object.

        Args:
            builder: A `FullExperimentBuilder` instance containing both an
                experiment configuration and plate template.

        Returns:
            ExperimentResults: A populated experiment results container.
        """
        commands = builder.build_all()
        if self.simulate:
            return {}
        # container to store results
        results = None
        for command in commands:
            def onNewDataCallback(line):
                nonlocal results   # <--- allow assignment to outer 'results'
                if CDCLParser.is_cdcl_response(line):
                    response = CDCLParser.parse_response(line)

                    if (response.prefix == GenericResponsePrefixes.GET_EXPERIMENT and response.params[0].startswith('m')):
                        mode = None
                        # loop through metadata (excluding first one which is experiment id)
                        for i in range(1, len(response.params)):
                            if response.params[i].startswith(ExperimentMetadataCodes.MODE.value):
                                mode = response.params[i][1:]
                        wavelength = None
                        if mode:
                            led_index = PlateReaderModeCalculator.get_led_index(
                                int(mode))
                            wavelength = self.wavelengths[led_index]
                        results = ExperimentResults(
                            # strip off the "m"
                            experiment_id=response.params[0][1:],
                            experiment_name=response.params[1],
                            wavelengths=[wavelength],
                        )
                        print("results is defined")

            self.send_command(
                command=command, timeout=None, onNewLineCallback=onNewDataCallback)

        # TODO wait for end of experiment instead of just one data point
        while (len(results.get_data_points()) == 0):
            line = self._connection.readline().decode().strip()
            self.log_serial(direction="receive", message=line)
            if CDCLParser.is_cdcl_response(line):
                response = CDCLParser.parse_response(line)
                # look for the metadata response message
                print(response)
                print(results)
                if (response.prefix == GenericResponsePrefixes.DATA_TRANSMISSION.value):
                    DataTransmissionParser.parse_data_message(line, results)

        # Export examples
        print("\n=== JSON Export ===")
        print(results.to_json())
        return results

    def start_endpoint_experiment(self, wavelength):
        """
        Start an endpoint experiment for a given wavelength

        Args:
            wavelength: (nm) must be one of the available wavelengths on device, otherwise a ValueError will be thrown
        """
        # get wavelength
        mode = self.__convert_wavelength_to_mode(wavelength)
        builder = FullExperimentBuilder()
        builder.experiment().set_type(ExperimentType.ENDPOINT).set_mode(mode)
        return self.start_experiment(builder)

    def start_kinetic_experiment(self, wavelength, interval=300, duration=None):
        """
        Start a kinetic experiment for a given wavelength

        Args:
            wavelength: (nm) must be one of the available wavelengths on device, otherwise a ValueError will be thrown
            interval (seconds): Defaults to 300 seconds (5 minutes)
        """
        mode = self.__convert_wavelength_to_mode(wavelength)
        builder = FullExperimentBuilder()
        builder.experiment().set_type(ExperimentType.KINETIC).set_mode(
            mode).set_interval(interval)
        if duration:
            builder.experiment().set_duration(duration)
        return self.start_experiment(builder)

    def __convert_wavelength_to_mode(self, wavelength) -> int:
        """
        Takes in a wavelength (in nm) and spits out the mode associated with that wavelength
        If the wavelength is not present on the device a ValueError will be thrown
        """
        wavelength_index = self.wavelengths.index(wavelength)
        if wavelength_index == -1:
            raise ValueError(f'wavelength {wavelength} not found on device')
        # TODO set mode to proper for endpoint or kinetic?
        mode = PlateReaderModeCalculator.set_led_mode(983040, wavelength_index)
        return mode
