"""
Generic Serial Controller for communicating with devices over serial ports.
"""

import serial
import sys
import time
import serial.tools.list_ports
import logging
from typing import Optional, List, Tuple
from abc import ABC, abstractmethod

logger = logging.getLogger(__name__)


def list_serial_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str, str]]:
    """
    Lists available serial ports on the system.

    This is a utility function that can be used independently of any controller instance.
    It's useful for discovering available serial ports before initializing a controller.

    Args:
        filter_desc: Optional string to filter ports by description (case-insensitive).
                     If provided, only ports whose description contains this string will be returned.

    Returns:
        List of tuples, where each tuple contains (port_name, description, hwid).

    Example:
        >>> from puda_drivers.core import list_serial_ports
        >>> ports = list_serial_ports()
        >>> for port, desc, hwid in ports:
        ...     print(f"{port}: {desc}")
        >>> # Filter for specific devices
        >>> sartorius_ports = list_serial_ports(filter_desc="Sartorius")
    """
    all_ports = serial.tools.list_ports.comports()
    filtered_ports = []
    for port in all_ports:
        if filter_desc and filter_desc.lower() not in port.description.lower():
            continue
        filtered_ports.append((port.device, port.description, port.hwid))
    return filtered_ports


class SerialController(ABC):
    DEFAULT_BAUDRATE = 9600
    DEFAULT_TIMEOUT = 20  # seconds
    POLL_INTERVAL = 0.1  # seconds

    def __init__(self, port_name, baudrate=DEFAULT_BAUDRATE, timeout=DEFAULT_TIMEOUT):
        self._serial = None
        self.port_name = port_name
        self.baudrate = baudrate
        self.timeout = timeout
        self._logger = logger

        self.connect()

    @staticmethod
    def list_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str, str]]:
        """
        Lists available serial ports on the system.

        .. deprecated:: 0.0.1
           Use the module-level function :func:`list_serial_ports` instead.
           This method is kept for backward compatibility.

        Args:
            filter_desc: Optional string to filter ports by description (case-insensitive).

        Returns:
            List of tuples, where each tuple contains (port_name, description, hwid).
        """
        return list_serial_ports(filter_desc)

    def connect(self) -> None:
        """
        Establishes the serial connection to the port.
        """
        if not self.port_name:
            self._logger.error("Port name is not set. Cannot connect.")
            raise IOError("Cannot connect: Serial port name is not set.")

        if self.is_connected:
            self._logger.warning("Already connected. Disconnecting and reconnecting...")
            self.disconnect()

        try:
            self._logger.info(
                f"Attempting connection to {self.port_name} at {self.baudrate} baud."
            )
            self._serial = serial.Serial(
                port=self.port_name,
                baudrate=self.baudrate,
                timeout=self.timeout,
            )
            self._serial.flush()
            self._logger.info(f"Successfully connected to {self.port_name}.")
        except serial.SerialException as e:
            self._serial = None
            self._logger.error(f"Error connecting to port {self.port_name}: {e}")
            raise serial.SerialException(
                f"Error connecting to port {self.port_name}: {e}"
            )

    def disconnect(self) -> None:
        """
        Closes the serial connection.
        """
        if self._serial and self._serial.is_open:
            port_name = self._serial.port
            self._serial.close()
            self._serial = None
            self._logger.info(f"Serial connection to {port_name} closed.")
        else:
            self._logger.warning(
                "Serial port already disconnected or was never connected."
            )

    @property
    def is_connected(self) -> bool:
        """Checks if the serial port is currently open."""
        return self._serial is not None and self._serial.is_open

    def _send_command(self, command: str) -> None:
        """
        Sends a custom protocol command, reads, and returns the response.
        If a timeout occurs, it logs the error and returns None instead of raising an exception.
        """
        if not self.is_connected or not self._serial:
            self._logger.error(
                f"Attempt to send command '{command}' failed: Device not connected."
            )
            # Retain raising an error for being disconnected, as that's a connection state issue
            raise serial.SerialException("Device not connected. Call connect() first.")

        command_bytes = bytes(command, "utf-8")
        self._logger.info(f"-> Sending: {repr(command)}")

        # Send the command
        try:
            self._serial.reset_input_buffer()  # clear input buffer
            self._serial.reset_output_buffer()  # clear output buffer
            self._serial.write(command_bytes)
            self._serial.flush()

        except serial.SerialTimeoutException as e:
            # Log the timeout error and return None as requested (no re-raise)
            self._logger.error(f"Timeout on command '{command}'. Error: {e}")
            return None

        except serial.SerialException as e:
            self._logger.error(
                f"Serial error writing or reading command '{command}'. Error: {e}"
            )
            return None

    def _read_response(self) -> str:
        """
        Generic, blocking read that respects timeout and returns
        all data that arrived within the timeout period.
        """
        if not self.is_connected or not self._serial:
            raise serial.SerialException("Device not connected.")

        start_time = time.time()
        response = b""

        while time.time() - start_time < self.timeout:
            if self._serial.in_waiting > 0:
                # Read all available bytes
                response += self._serial.read(self._serial.in_waiting)

                # 5. Check if the 'ok' response is in the accumulated data
                if b"ok" in response:
                    self._logger.debug(f"<- Received response: {response!r}")
                    return response.decode("utf-8", errors="ignore").strip()
                if b"err" in response:
                    self._logger.error(f"<- Received error: {response!r}")
                    return response.decode("utf-8", errors="ignore").strip()
            else:
                time.sleep(0.1)

        if not response:
            self._logger.warning("<- Received no data (empty response).")
            self._logger.error(f"No response within {self.timeout} seconds.")
            sys.exit(124)  # Exit code for timeout

        self._logger.info(
            f"<- Received: {response.decode('utf-8', errors='ignore').strip()!r}"
        )
        return response.decode("utf-8", errors="ignore").strip()
