from types import TracebackType

import serial

BAUD_RATE = 9600
SET_FIBER_PORT_CMD_PREFIX = b"\x01\x35\x00"
SET_FIBER_PORT_ACK = b"\x01\x35\xff\xff"
HOME_CMD = b"\x01\x30\x00\x00"
SET_FIBER_PORT_RESPONSE_LENGTH = 4


class AgiltronSelfAlignError(Exception):
    """Raised when communication with the Agiltron SelfAlign fails.

    This includes serial transport failures, protocol timeouts, and
    unexpected device responses.
    """


def check_port(fiber_port: int, number_of_ports: int) -> bool:
    """
    Check if given fiber port is an integer and in the range of valid fiber ports.

    Args:
        fiber_port (int): fiber port to switch to
        number_of_ports (int): total number of fiber ports on the switch

    Raises:
        TypeError: unsupported type for the fiber port
        ValueError: fiber port out of range

    Returns:
        bool: returns True if the fiber port is valid, False otherwise
    """
    if not isinstance(fiber_port, int):
        raise TypeError(f"unsupported type for fiber port: {type(fiber_port)}")
    v = (fiber_port > 0) and (fiber_port <= number_of_ports)
    if not v:
        raise ValueError(f"fiber port {fiber_port} out of range")
    else:
        return v


class AgiltronSelfAlign:
    """Driver for the Agiltron SelfAlign 1xN fiber switch over serial.

    The switch uses a compact binary protocol. `set_fiber_port()` expects an
    acknowledgement frame equal to `SET_FIBER_PORT_ACK`.
    """

    def __init__(
        self,
        resource_name: str,
        timeout: float = 2.0,
        number_of_ports: int = 16,
        baudrate: int = BAUD_RATE,
        write_timeout: float | None = None,
        inter_byte_timeout: float | None = None,
    ):
        """Open the serial connection to the switch.

        Args:
            resource_name (str): Serial port name (for example, `COM8` on Windows).
            timeout (float): Read timeout in seconds.
            number_of_ports (int): Total available ports on the switch.
            baudrate (int): Serial baudrate.
            write_timeout (float | None): Write timeout in seconds. If omitted,
                uses `timeout`.
            inter_byte_timeout (float | None): Maximum interval between bytes
                when receiving data.

        Raises:
            ValueError: If `number_of_ports` is not positive.
            AgiltronSelfAlignError: If opening the serial connection fails.
        """
        if number_of_ports <= 0:
            raise ValueError("number_of_ports must be greater than 0")

        effective_write_timeout = timeout if write_timeout is None else write_timeout

        try:
            self.instrument = serial.Serial(
                port=resource_name,
                baudrate=baudrate,
                bytesize=serial.EIGHTBITS,
                parity=serial.PARITY_NONE,
                timeout=timeout,
                write_timeout=effective_write_timeout,
                inter_byte_timeout=inter_byte_timeout,
            )
        except serial.SerialException as exc:
            raise AgiltronSelfAlignError(
                f"failed to open serial port {resource_name!r}"
            ) from exc
        self.number_of_ports = number_of_ports
        self.fiber_port: int | None = None

    def set_fiber_port(self, fiber_port: int) -> None:
        """
        Switch fiber switch to port `fiber_port`.

        Args:
            fiber_port (int): fiber port to switch to

        Raises:
            TypeError: If `fiber_port` is not an integer.
            ValueError: If `fiber_port` is outside the valid range.
            AgiltronSelfAlignError: On serial I/O failure, timeout/partial
                response, or invalid device acknowledgement.
        """
        check_port(fiber_port, self.number_of_ports)
        if fiber_port != self.fiber_port:
            cmd = SET_FIBER_PORT_CMD_PREFIX + (fiber_port - 1).to_bytes(
                length=1,
                byteorder="big",
                signed=False,
            )
            try:
                self.instrument.write(cmd)
                ret = self.instrument.read(SET_FIBER_PORT_RESPONSE_LENGTH)
            except serial.SerialException as exc:
                raise AgiltronSelfAlignError(
                    "serial communication failed while setting fiber port to "
                    f"{fiber_port}"
                ) from exc

            if len(ret) < SET_FIBER_PORT_RESPONSE_LENGTH:
                raise AgiltronSelfAlignError(
                    "timeout or partial response while setting fiber port: "
                    f"expected {SET_FIBER_PORT_RESPONSE_LENGTH} bytes, got {len(ret)}"
                )

            if ret != SET_FIBER_PORT_ACK:
                raise AgiltronSelfAlignError(
                    "invalid return message "
                    f"{ret!r}, fiber port not set to {fiber_port}"
                )
            self.fiber_port = fiber_port

    def home(self) -> None:
        """
        Home the fiber switch, i.e. move to port 1.

        Raises:
            AgiltronSelfAlignError: If serial communication fails.
        """
        try:
            self.instrument.write(HOME_CMD)
        except serial.SerialException as exc:
            raise AgiltronSelfAlignError(
                "serial communication failed while homing"
            ) from exc

    def close(self) -> None:
        """Close the underlying serial connection."""
        if self.instrument.is_open:
            try:
                self.instrument.close()
            except serial.SerialException as exc:
                raise AgiltronSelfAlignError(
                    "failed to close serial connection"
                ) from exc

    def __enter__(self) -> "AgiltronSelfAlign":
        """Return the instance for context-managed usage."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        """Ensure the serial connection is closed on context exit."""
        self.close()
