import logging
import threading
import queue
import serial

from typing import Self

from .momonga_exception import (MomongaError,
                                MomongaTimeoutError,
                                MomongaSkCommandUnknownError,
                                MomongaSkCommandUnsupported,
                                MomongaSkCommandInvalidArgument,
                                MomongaSkCommandInvalidSyntax,
                                MomongaSkCommandSerialInputError,
                                MomongaSkCommandFailedToExecute,
                                MomongaSkScanFailure,
                                MomongaSkJoinFailure)
from .momonga_response import (SkVerResponse,
                               SkAppVerResponse,
                               SkInfoResponse,
                               SkScanResponse,
                               SkLl64Response)
from .momonga_device_enum import DeviceType

logger = logging.getLogger(__name__)


class MomongaSkWrapper:
    def __init__(self,
                 dev: str,
                 baudrate: int = 115200,
                 ) -> None:
        self.dev = dev
        self.baudrate = baudrate

        # the following value will be set a pyserial object.
        self.ser = None
        self.publisher_th_breaker = False
        self.publisher_th = None
        self.subscribers = {'cmd_exec_q': queue.Queue()}
        self.device_type: DeviceType = DeviceType.BP35C2

    def __enter__(self) -> Self:
        return self.open()

    def __exit__(self, type, value, traceback) -> None:
        self.close()

    def open(self) -> Self:
        self.ser = serial.Serial(self.dev, self.baudrate)

        try:
            # to drop garbage data in the buffer.
            self.__clear_buf()

            # to check udp payloads returned from the wi-sun module are in ascii format.
            if self.__exec_ropt() != 1:
                logger.warning("Executing 'WOPT 01\\r' command to make the Wi-SUN module return UDP payloads "
                                "in ASCII format. Note: WOPT command can only be executed a limited number of times. "
                                "This configuration is saved in the Wi-SUN module, so this log message should "
                                "no longer appear.")
                self.__exec_wopt(1)  # to make the wi-sun module return udp payloads in ascii format.
        except MomongaSkCommandUnsupported:
            logger.info('ROPT command is unsupported on this hardware. Assuming ASCII output mode.')
        except Exception:
            self.close()
            raise

        for q in self.subscribers.values():
            while not q.empty():
                q.get()

        self.publisher_th_breaker = False  # set True when you want to stop the publisher.
        self.publisher_th = threading.Thread(target=self.received_packet_publisher, daemon=True)
        self.publisher_th.start()

        # Detects device type
        self.detect_device()

    def close(self) -> None:
        if self.publisher_th is not None:
            self.publisher_th_breaker = True
            self.publisher_th.join()
            self.publisher_th = None
        if self.ser is not None and not self.ser.closed:
            self.ser.close()

    def __clear_buf(self) -> None:  # do not call this after open().
        self.ser.write(b'\r\n')
        self.ser.flush()
        timeout = self.ser.timeout
        self.ser.timeout = 2  # will wait the specified seconds.
        while self.ser.read():
            # this loop clears garbage data if it exists.
            pass
        # to undo the timeout.
        self.ser.timeout = timeout

    def __exec_ropt(self) -> int:  # do not call this after open().
        self.ser.write(b'ROPT\r')
        self.ser.flush()
        res = b''
        ok = b'OK '
        fail = b'FAIL'
        while True:
            res += self.ser.read()
            if ok in res and res.endswith(b'\r'):
                break
            elif fail in res and res.endswith(b'\r'):
                decoded = res.decode(errors='replace')
                for line in decoded.splitlines():
                    if line.startswith('FAIL'):
                        self.__raise_fail_response('ROPT', line)
                raise MomongaError('Unexpected ROPT response: %s' % decoded)
        return int(res[res.index(ok) + len(ok):-1].decode())

    def __exec_wopt(self,
                    opt: int,
                    ) -> None:  # do not call this after open().
        supported_opts = (0,  # binary mode
                          1,  # hex ascii mode
                          )
        if opt not in supported_opts:
            raise MomongaError('WOPT command dose not support the given option: %02d' % opt)

        self.ser.write(('WOPT %02d\r' % opt).encode())
        self.ser.flush()
        res = b''
        while True:
            res += self.ser.read()
            if b'OK\r' in res:
                break
        return

    def __readline(self,
                   timeout: int | None = None,
                   ) -> str:
        org_timeout = self.ser.timeout
        self.ser.timeout = timeout
        data_bytes = self.ser.readline()
        self.ser.timeout = org_timeout
        if data_bytes != b'':
            logger.debug('<<< %s' % data_bytes)
        line = data_bytes.decode().split('\r\n')[0]
        return line

    def received_packet_publisher(self) -> None:
        logger.debug('A received packet publisher has been started.')
        while True:
            if self.publisher_th_breaker is True:
                break
            line = self.__readline(timeout=1)
            if line == '':
                continue
            for q in self.subscribers.values():
                q.put(line)  # will dispatch the line to each subscriber
        logger.debug('The received packet publisher has been stopped.')

    def __writeline(self,
                    line: str,
                    payload: bytes | None = None,
                    ) -> None:
        if payload is not None:
            data_bytes = (line + ' ').encode() + payload
        else:
            data_bytes = (line + '\r\n').encode()
        self.ser.write(data_bytes)
        logger.debug('>>> %s' % data_bytes)
        self.ser.flush()

    def exec_command(self,
                     command: list[str],
                     wait_until: str | list[str] = 'OK',
                     timeout: int | None = None,
                     payload: bytes | None = None,
                     ) -> list[str]:
        command = ' '.join([c for c in command if c is not None])

        if type(wait_until) is str:
            wait_until = [wait_until]

        subscriber_q = self.subscribers['cmd_exec_q']
        while not subscriber_q.empty():
            subscriber_q.get()

        self.__writeline(command, payload)

        res = []
        while True:
            r = subscriber_q.get(timeout=timeout)
            if r.startswith('ERXUDP'):
                continue

            if r == '':
                raise MomongaTimeoutError('The command timed out: %s' % (command))
            elif r[:4] == 'FAIL':
                self.__raise_fail_response(command, r)
            else:
                res.append(r)
                matched = False
                for w in wait_until:
                    if r.startswith(w):
                        matched = True
                        break
                if matched is True:
                    break
        return res

    def __raise_fail_response(self, command: str, r: str) -> None:
        error_code = int(r[7:10])
        if 1 <= error_code <= 3:
            raise MomongaSkCommandUnknownError('Unknown error code %s: %s' % (error_code, command))
        elif error_code == 4:
            raise MomongaSkCommandUnsupported('Unsupported command: %s' % (command))
        elif error_code == 5:
            raise MomongaSkCommandInvalidArgument('Invalid argument: %s' % (command))
        elif error_code == 6:
            raise MomongaSkCommandInvalidSyntax('Invalid syntax: %s' % (command))
        elif 7 <= error_code <= 8:
            raise MomongaSkCommandUnknownError('Unknown error code %s: %s' % (error_code, command))
        elif error_code == 9:
            raise MomongaSkCommandSerialInputError('Serial input error: %s' % (command))
        elif error_code == 10:
            raise MomongaSkCommandFailedToExecute(
                'The specified command was accepted but failed to execute: %s' % (command))
        else:
            raise MomongaSkCommandUnknownError('Unknown error code %s: %s' % (error_code, command))

    def skver(self) -> SkVerResponse:
        res = self.exec_command(['SKVER'])
        return SkVerResponse(res)

    def skappver(self) -> SkAppVerResponse:
        res = self.exec_command(['SKAPPVER'])
        return SkAppVerResponse(res)

    def skreset(self) -> None:
        self.exec_command(['SKRESET'])

    def skinfo(self) -> SkInfoResponse:
        res = self.exec_command(['SKINFO'])
        return SkInfoResponse(res)

    def sksreg(self,
               reg: str,
               val: str | int | bytes,
               ) -> None:
        if type(val) is int:
            val = '%X' % val
        elif type(val) is bytes:
            val = val.hex().upper()
        self.exec_command(['SKSREG', reg, val])

    def sksetrbid(self,
                  rbid: str,
                  ) -> None:
        self.exec_command(['SKSETRBID', rbid])

    def sksetpwd(self,
                 pwd: str,
                 ) -> None:
        self.exec_command(['SKSETPWD', '%X' % len(pwd), pwd])

    def skscan(self,
               retry: int = 3,
               ) -> SkScanResponse:
        duration = 6
        for _ in range(retry):
            logger.debug('Trying to scan a PAN... Duration: %d' % duration)
            cmd = []
            match self.device_type:
                case DeviceType.BP35A1:
                    cmd = ['SKSCAN', '2', 'FFFFFFFF', str(duration)]
                case DeviceType.BP35C2:
                    cmd = ['SKSCAN', '2', 'FFFFFFFF', str(duration), '0']
                case _:
                    logger.warning('Unknown device type "%s" detected in skscan. Assuming BP35C2 behavior.', self.device_type)
                    cmd = ['SKSCAN', '2', 'FFFFFFFF', str(duration), '0']
            res = self.exec_command(cmd, 'EVENT 22')
            # estimated execution time: 0.0096s*(2^(DURATION=6)+1)*28 = 17.5s
            # estimated execution time: 0.0096s*(2^(DURATION=7)+1)*28 = 34.7s
            # estimated execution time: 0.0096s*(2^(DURATION=8)+1)*28 = 69.1s
            if 'EPANDESC' in res:
                return SkScanResponse(res, self.device_type)
            duration += 1
        raise MomongaSkScanFailure('Could not find the specified PAN.')

    def skll64(self,
               mac_addr: bytes,
               ) -> SkLl64Response:
        res = self.exec_command(['SKLL64', mac_addr.hex().upper()], 'FE80:')
        return SkLl64Response(res)

    def skjoin(self,
               ip6_addr: str,
               retry: int = 3,
               ) -> None:
        for _ in range(retry):
            logger.debug('Trying to establish a PANA session...')
            res = self.exec_command(['SKJOIN', ip6_addr], ['EVENT 24', 'EVENT 25'])
            # extimated execution time: 2s + 4s + 8s + 8s + 8s + 8s + 8s = 38s ~ 40s
            if res[-1].startswith('EVENT 25'):
                logger.debug('A PANA Session has been established.')
                return
        raise MomongaSkJoinFailure('Could not establish a PANA session.')

    def skterm(self) -> None:
        logger.debug('Trying to terminate the session...')
        res = self.exec_command(['SKTERM'], ['EVENT 27', 'EVENT 28'])
        if res[-1].startswith('EVENT 28'):
            logger.warning('There was no session to terminate.')

    def sksendto(self,
                 ip6_addr: str,
                 data: bytes,
                 handle: int = 1,
                 port: int = 0x0E1A,
                 sec: int = 2,
                 side: int = 0,
                 ) -> None:
        match self.device_type:
            case DeviceType.BP35A1:
                self.exec_command(['SKSENDTO', str(handle), ip6_addr, '%04X' % port,
                                   str(sec), '%04X' % len(data)],
                                  payload=data)
            case DeviceType.BP35C2:
                self.exec_command(['SKSENDTO', str(handle), ip6_addr, '%04X' % port,
                                   str(sec), str(side), '%04X' % len(data)],
                                  payload=data)
            case _:
                logger.warning('Unknown device type "%s" detected in sksendto. Assuming BP35C2 behavior.', self.device_type)
                self.exec_command(['SKSENDTO', str(handle), ip6_addr, '%04X' % port,
                                   str(sec), str(side), '%04X' % len(data)],
                                  payload=data)

    def detect_device(self):
        logger.debug('Trying to detect device...')
        dev_info = self.skinfo()
        if dev_info.side == 65534:
            logger.debug('Device type is BP35A1.')
            self.device_type = DeviceType.BP35A1
        elif dev_info.side < 2:
            logger.debug('Device type is BP35C2.')
            self.device_type = DeviceType.BP35C2
        else:
            logger.warning('Device type is UNKNOWN. Assuming BP35C2.')
            self.device_type = DeviceType.BP35C2
