from binascii import hexlify, unhexlify
import time


# Module specific Exception
class LoRaException(Exception):
    '''Parent exception class for all module exceptions'''
    pass


class ConnectionError(LoRaException):
    '''Parent device (PC or pyb) failing to connect to LoRa module'''
    pass


class TransmissionError(LoRaException):
    '''Error during transmission (in LoRa or serial communication)'''
    pass


class ReceptionError(LoRaException):
    '''Error during reception (in LoRa or serial communication)'''
    pass


class TimeoutError(LoRaException):
    '''LoRa module reception timeout error'''
    pass


class ConfigurationError(LoRaException):
    '''Parent device (PC or pyb) failing to configure LoRa module for task'''
    pass


class LoRa(object):
    '''Representing a physical connection to a RN2483 LoRa module'''

    # Helper Function
    def _set_parent_dev(self):
        '''Set parent device (PC or micropython board)'''
        try:
            import pyb
        except:
            self._parent_dev = 'pc'
        else:
            self._parent_dev = 'pyb'

    # Create connection to LoRa module from parent device
    def _open(self, serPort=1):
        '''Open serial connection to LoRa module'''
        if self._parent_dev == 'pyb':
            # via micropython board
            import pyb

            # try to connect
            try:
                ser = pyb.UART(serPort, 57600)
                ser.init(baudrate=57600,
                         bits=8,
                         parity=None,
                         stop=1,
                         timeout=self._read_timeout_serial,
                         timeout_char=self._read_timeout_serial)
            # error handling
            except:
                raise ConnectionError("No LoRa module found.")

        else:
            # via computer
            import serial

            # try to connect
            try:
                # set serial write timeout to same value as read timeout
                # to avoid being blocking for too long
                timeout = self._read_timeout_serial / 1000

                ser = serial.Serial(serPort,
                                    baudrate=57600,
                                    bytesize=serial.EIGHTBITS,
                                    parity=serial.PARITY_NONE,
                                    stopbits=serial.STOPBITS_ONE,
                                    timeout=timeout,
                                    write_timeout=timeout)
            # error handling
            except serial.SerialException:
                raise ConnectionError("No LoRa module at given port.")
            except ValueError:
                raise ConnectionError("Wrong configuration parameter given.")

        self.port = serPort
        self._parent_ser = ser

    # End connection to parent device
    def _close(self):
        '''Close serial connection to LoRa module'''
        if self._parent_dev == 'pc':
            self._parent_ser.close()

    # Communicate with LoRa module via parent device
    def _ser_write(self, strIn):
        '''Write via serial connection to LoRa module'''
        # reset error handling flags
        transmit_failed = False
        transmit_incomplete = False

        if self._parent_dev == 'pc':
            # format input string
            data = "{}\r\n".format(strIn)
            data = data.encode('utf-8')

            # try to send data (blocking)
            try:
                transmitted_bytes = self._parent_ser.write(data)
            except serial.SerialTimeoutException:
                transmit_failed = True
            else:
                if transmitted_bytes < len(data):
                    transmit_incomplete = True
        else:
            # format input string
            data = "{}\r\n".format(strIn)

            # send data (blocking)
            transmitted_bytes = self._parent_ser.write(data)

            # verify transmit
            if transmitted_bytes == 0:
                transmit_failed = True
            elif transmitted_bytes < len(data):
                transmit_incomplete = True

        # error handling
        if transmit_failed:
            raise TransmissionError("Write failed due to timeout.")
        if transmit_incomplete:
            raise TransmissionError("Write was incomplete.")

        # debugging
        if self._debug:
            print("LoRa:_ser_write : {}".format(data))

    def _ser_read(self):
        '''Read via serial connection from LoRa module'''
        # read one line from parent device (blocking)
        received_bytes = self._parent_ser.readline()

        # verification of timeout
        if received_bytes is None or len(received_bytes) == 0:
            raise TimeoutError("Timeout occurred during reception.")

        # format output string
        if self._parent_dev == 'pc':
            received_bytes = received_bytes.decode('utf-8')

        # debugging
        if self._debug:
            print("LoRa:_ser_read : {}".format(received_bytes.strip()))

        return received_bytes
    
    def _flush_serial_input(self):
        '''Buidar el buffer d'entrada sèrie'''
        if self._parent_dev == 'pc':
            self._parent_ser.reset_input_buffer()
        else:
            self._parent_ser.read(self._parent_ser.any())

    def _ser_write_read_verify(self, strIn, expected_response=None, max_retries=5, retry_delay=0.5):
        '''Perform serial write and read and verify the read output with retry mechanism'''
        retries = 0
        while retries < max_retries:
            # Clear the input buffer before each command
            self._flush_serial_input()

            # transmit data
            self._ser_write(strIn)
    
            # wait for answer
            answer = self._ser_read()
            answer_stripped = answer.strip()
    
            # if expected_response is given, verify it
            if expected_response is not None:
                if answer_stripped == expected_response:
                    return answer
                elif answer_stripped == 'busy':
                    # Wait and retry
                    if self._debug:
                        print(f"Module busy, retrying command '{strIn}' in {retry_delay} seconds...")
                    time.sleep(retry_delay)
                    retries += 1
                    continue
                else:
                    # Handle unexpected response
                    if self._debug:
                        print(f"Expected '{expected_response}', but got '{answer_stripped}'")
                    raise ReceptionError(f"Expected '{expected_response}', but got '{answer_stripped}'")
            else:
                # No expected response specified
                return answer
        # If max retries exceeded
        raise ReceptionError(f"Module is busy after {max_retries} retries.")
    
    def get_status(self):
        '''Obtenir l'estat actual del mòdul LoRa'''
        self._ser_write_read_verify("sys get status")
        status = self._ser_read().strip()
        if self._debug:
            print(f"LoRa:get_status : {status}")
        return status

    # LoRa communication functions
    def recv(self):
        '''Set LoRa module in receiver mode and read received data'''
        if self._debug:
            print("LoRa:recv : Prepare for reception.")

        # Stop any ongoing radio receive operation
        try:
            self._ser_write_read_verify("radio rxstop", "ok")
        except Exception as e:
            if self._debug:
                print(f"Warning: Could not stop radio receive operation: {e}")

        # Clear any pending input
        self._parent_ser.reset_input_buffer()

        # try to configure device as receiver
        try:
            # Only send 'mac pause' if in MAC mode
            if self.mode == 'mac':
                self._ser_write_read_verify("mac pause")
            self._ser_write_read_verify("radio rx 0", "ok")
        except (TransmissionError, ReceptionError) as e:
            if self._debug:
                print(f"Error during setting receiver mode: {e}")
            raise ConfigurationError("Configuration as receiver failed.")
        else:
            # obtain SNR value
            if self._debug:
                try:
                    self._ser_write("radio get snr")
                    snr = self._ser_read()
                    # range -128 to 127
                    print("LoRa:recv : SNR = {0}".format(snr.strip()))
                except ReceptionError:
                    raise ConfigurationError("Could not obtain SNR value.")

            # wait for incoming data
            if self._debug:
                print("LoRa:recv : Receiving...")
            try:
                received_data = self._ser_read()
            except (ReceptionError, TimeoutError) as e:
                raise e
            else:
                # verify if no LoRa timeout occurred
                if received_data.strip() == 'radio_err':
                    raise TimeoutError("Timeout occurred during reception.")

                # strip data
                received_data = received_data[8:].strip()

                if self._debug:
                    print("LoRa:recv : {}".format(received_data))

                return received_data

    def send(self, tx_data):
        '''Set LoRa module in transmitter mode and send data'''
        if self._debug:
            print("LoRa:send : Prepare for transmission.")

        # Pause the MAC (no need to verify the response)
        self._ser_write("mac pause")
        # Read and discard the response
        self._ser_read()

        if self._debug:
            print("LoRa:send : Sending...")
        try:
            # send data
            self._ser_write_read_verify("radio tx " + str(tx_data), "ok")

            # read out transmission verification
            ret = self._ser_read()
            if ret.strip() != "radio_tx_ok":
                raise TransmissionError("No transmission confirmation " +
                                        "received.")
        except (TransmissionError, ReceptionError):
            raise TransmissionError("Error while sending.")
        else:
            if self._debug:
                print("LoRa:send : " + str(tx_data))

    def recv_str(self):
        '''Set LoRa module in receiver mode and read received ASCI string'''
        # receive data
        rx_data = self.recv()
        text = ""

        # try to convert data to string
        try:
            text = unhexlify(rx_data)
        except TypeError:
            raise ReceptionError("Received data has odd length.")

        return text.decode()

    def send_str(self, tx_str):
        '''Set LoRa module in transmitter mode and send ASCI string'''
        try:
            if self._debug:
                print(f"send_str: Original string: {tx_str}")
            tx_bytes = tx_str.encode('utf-8')
            if self._debug:
                print(f"send_str: Encoded bytes: {tx_bytes}")
            tx_data = hexlify(tx_bytes).decode('utf-8')
            if self._debug:
                print(f"send_str: Hex data to send: {tx_data}")
        except Exception as e:
            raise TransmissionError(f"Transmitted string cannot be converted: {e}")
        else:
            self.send(tx_data)

    # Constructor
    def __init__(self, port, timeout_serial=2000, timeout_lora=2000,
                 debug=False):
        '''Open serial connection to LoRa module and configure it'''

        # set internal parameters
        self._read_timeout_serial = timeout_serial  # in ms
        self._read_timeout_lora = timeout_lora      # in ms, 0 = endless
        self._debug = debug
        self.mode = None  # 'mac' or 'radio'

        try:
            if self._debug:
                print("LoRa:__init__ : Start initialization.")

            # get parent device
            self._set_parent_dev()

            # open serial connection on parent device
            self._open(port)

            # **Read and discard any initial messages**
            if self._debug:
                print("LoRa:__init__ : Flushing initial messages.")
            while True:
                try:
                    initial_msg = self._ser_read()
                    if self._debug:
                        print(f"LoRa:__init__ : Initial message: {initial_msg.strip()}")
                except TimeoutError:
                    break  # No more data to read
                except Exception as e:
                    print(f"Error reading initial messages: {e}")
                    break

            # **Send sys reset to restart the module**
            self._ser_write("sys reset")

            # **Add a delay to allow the module to reset**
            time.sleep(2)

            # **Temporarily increase the timeout to read the reset message**
            original_timeout = self._parent_ser.timeout
            self._parent_ser.timeout = 2  # 2 seconds

            reset_message = self._ser_read()
            if self._debug:
                print(f"LoRa module reset message: {reset_message.strip()}")

            # **Restore the original timeout**
            self._parent_ser.timeout = original_timeout

            if self._debug:
                print(f"LoRa module reset message: {reset_message.strip()}")

            # **Pause the MAC layer**
            self._ser_write("mac pause")
            pause_response = self._ser_read()
            if self._debug:
                print(f"MAC pause response: {pause_response.strip()}")

            # Continue with radio configuration
            self._ser_write_read_verify("radio set mod lora", "ok")

        except (TransmissionError, ReceptionError, TimeoutError) as e:
            raise ConfigurationError(f"Initial configuration failed: {e}")
        else:
            if self._debug:
                print("LoRa:__init__ : Connection established and " +
                    "configuration completed successfully.")

    # Destructor
    def __exit__(self, exc_type, exc_value, traceback):
        '''Close serial connection to LoRa module'''
        self._close()

    def __enter__(self):
        return self

    def __repr__(self):
        return "<LoRa module connection on port %d>".format(self.port)
