from __future__ import annotations

import ctypes
import sys
from dataclasses import dataclass
from typing import Any, Callable, List, Optional, Tuple

import importlib
import importlib.util
import json
import os
import socket
import subprocess
import time

try:
    import can as python_can
except ImportError:  # pragma: no cover - optional dependency
    python_can = None

class CANError(Exception):
    pass


class EOFError(CANError):
    pass


class ReadError(CANError):
    pass


class WriteError(CANError):
    pass


class UnsupportedConfiguration(CANError):
    pass


CAN_EFF_FLAG = 0x80000000
CAN_RTR_FLAG = 0x40000000
CAN_ERR_FLAG = 0x20000000
KNOWN_PYTHON_CAN_BACKENDS = {"socketcan", "gs_usb", "toucan"}
GS_USB_MACOS_SHIM_ENV = "PYMULATOR_GS_USB_MACOS_SHIM"
GS_USB_FEATURE_TERMINATION = 1 << 11
GS_USB_BREQ_SET_TERMINATION = 12
GS_USB_BREQ_GET_TERMINATION = 13
TOUCAN_CANERR_NOERROR = 0
TOUCAN_CANERR_RX_EMPTY = -30
TOUCAN_CANERR_TIMEOUT = -50
TOUCAN_CANREAD_INFINITE = 65535
TOUCAN_LISTEN_ONLY_FLAG = 1 << 0
TOUCAN_BITRATE_INDEX_BY_BPS = {
    1_000_000: 0,
    800_000: -1,
    500_000: -2,
    250_000: -3,
    125_000: -4,
    100_000: -5,
    50_000: -6,
    20_000: -7,
    10_000: -8,
}


@dataclass
class CANFrame:
    arbitration_id: int
    data: List[int]
    dlc: int = 0
    is_fd: bool = False
    bitrate_switch: bool = False
    error_state_indicator: bool = False

    def __post_init__(self) -> None:
        self.data = list(self.data)
        if self.dlc == 0:
            self.dlc = len(self.data)
        self.dlc = max(int(self.dlc), 0)
        if self.dlc > len(self.data):
            self.dlc = len(self.data)
        self.data = self.data[: self.dlc]

    @property
    def padded_data(self) -> bytes:
        return bytes(self.data[: self.dlc])


class CANInterface:
    def open(
        self,
        bitrate: int,
        *,
        fd: bool = False,
        data_bitrate: int | None = None,
        listen_only: bool = False,
        termination: bool | None = None,
    ) -> None:
        raise NotImplementedError

    def close(self) -> None:
        raise NotImplementedError

    def read(self, timeout_ms: int) -> CANFrame:
        raise NotImplementedError

    def write(self, frame: CANFrame) -> None:
        raise NotImplementedError


def _default_python_can_backend(platform_name: str | None = None) -> str:
    platform_name = platform_name or sys.platform
    if platform_name == "darwin":
        return "gs_usb"
    return "socketcan"


def _split_backend_channel(interface: str) -> Tuple[Optional[str], str]:
    token = interface.strip()
    if ":" not in token:
        return (None, token)
    backend, channel = token.split(":", 1)
    backend = backend.strip().lower()
    if backend in KNOWN_PYTHON_CAN_BACKENDS:
        return (backend, channel.strip())
    return (None, token)


def selected_python_can_backend(interface: str, platform_name: str | None = None) -> str:
    parsed_backend, _parsed_channel = _split_backend_channel(interface)
    return (parsed_backend or _default_python_can_backend(platform_name)).lower()


def missing_python_can_modules(interface: str, platform_name: str | None = None) -> List[str]:
    missing: list[str] = []
    backend = selected_python_can_backend(interface, platform_name=platform_name)
    if python_can is None and backend in {"socketcan", "gs_usb"}:
        missing.append("python-can")
    if backend == "gs_usb" and importlib.util.find_spec("gs_usb") is None:
        missing.append("gs_usb")
    if backend == "toucan" and importlib.util.find_spec("cancorder_utils") is None:
        missing.append("cancorder-utils")
    return missing


def _env_truthy(name: str, default: bool = False) -> bool:
    value = os.getenv(name)
    if value is None:
        return default
    return value.strip().lower() in {"1", "true", "yes", "on"}


def _should_apply_macos_gs_usb_shim() -> bool:
    if sys.platform != "darwin":
        return False
    # Enabled by default on macOS. Disable with PYMULATOR_GS_USB_MACOS_SHIM=0.
    return _env_truthy(GS_USB_MACOS_SHIM_ENV, default=True)


def _install_macos_gs_usb_start_shim() -> bool:
    if not _should_apply_macos_gs_usb_shim():
        return False
    try:
        gs_usb_module = importlib.import_module("gs_usb.gs_usb")
    except Exception:
        return False

    gs_usb_cls = getattr(gs_usb_module, "GsUsb", None)
    if gs_usb_cls is None:
        return False
    if getattr(gs_usb_cls, "_pymulator_start_patched", False):
        return True

    required = [
        "DeviceMode",
        "_GS_USB_BREQ_MODE",
        "GS_CAN_MODE_START",
        "GS_CAN_MODE_NORMAL",
        "GS_CAN_MODE_HW_TIMESTAMP",
        "GS_CAN_MODE_LISTEN_ONLY",
        "GS_CAN_MODE_LOOP_BACK",
        "GS_CAN_MODE_ONE_SHOT",
    ]
    if any(not hasattr(gs_usb_module, name) for name in required):
        return False

    original_start: Callable[..., Any] = gs_usb_cls.start

    def _pymulator_gs_usb_start(
        self: Any,
        flags: int = (
            gs_usb_module.GS_CAN_MODE_NORMAL | gs_usb_module.GS_CAN_MODE_HW_TIMESTAMP
        ),
    ) -> None:
        # Keep python-can behavior where possible, but tolerate detach permission errors
        # on macOS (these otherwise force users to run as root).
        usb_handle = getattr(self, "gs_usb", None)
        reset = getattr(usb_handle, "reset", None)
        if callable(reset):
            try:
                reset()
            except Exception:
                # Continue: reset is best-effort.
                pass

        is_kernel_driver_active = getattr(usb_handle, "is_kernel_driver_active", None)
        detach_kernel_driver = getattr(usb_handle, "detach_kernel_driver", None)
        if callable(is_kernel_driver_active) and callable(detach_kernel_driver):
            try:
                if is_kernel_driver_active(0):
                    try:
                        detach_kernel_driver(0)
                    except Exception:
                        # Common on macOS without sudo; continue without detach.
                        pass
            except Exception:
                # If probing kernel driver state fails, continue and attempt START anyway.
                pass

        flags &= self.device_capability.feature
        flags &= (
            gs_usb_module.GS_CAN_MODE_LISTEN_ONLY
            | gs_usb_module.GS_CAN_MODE_LOOP_BACK
            | gs_usb_module.GS_CAN_MODE_ONE_SHOT
            | gs_usb_module.GS_CAN_MODE_HW_TIMESTAMP
        )
        self.device_flags = flags
        mode = gs_usb_module.DeviceMode(gs_usb_module.GS_CAN_MODE_START, flags)
        self.gs_usb.ctrl_transfer(
            0x41, gs_usb_module._GS_USB_BREQ_MODE, 0, 0, mode.pack()
        )

    gs_usb_cls.start = _pymulator_gs_usb_start
    setattr(gs_usb_cls, "_pymulator_start_patched", True)
    setattr(gs_usb_cls, "_pymulator_original_start", original_start)
    return True


def _normalize_channel_for_backend(channel: str, backend: str) -> str | int:
    token = channel.strip()
    if backend == "socketcan":
        if not token:
            raise UnsupportedConfiguration("socketcan channel is required (example: can0)")
        return token

    if backend == "gs_usb":
        if not token:
            return 0
        lowered = token.lower()
        if lowered.startswith("can") and lowered[3:].isdigit():
            return int(lowered[3:])
        if token.isdigit():
            return int(token)
        raise UnsupportedConfiguration(
            "gs_usb channel must be numeric (example: 0 or gs_usb:0 or can0)"
        )

    if backend == "toucan":
        if not token:
            return 0
        lowered = token.lower()
        if lowered.startswith("can") and lowered[3:].isdigit():
            return int(lowered[3:])
        if token.isdigit():
            return int(token)
        raise UnsupportedConfiguration(
            "toucan channel must be numeric (example: 0 or toucan:0)"
        )

    if not token:
        raise UnsupportedConfiguration(f"{backend} channel is required")
    return token


_HELPER_NAME = "ecumulator-socketcan-configure"
_HELPER_INSTALLED_PATH = "/usr/local/libexec/" + _HELPER_NAME


def _find_helper_script() -> str | None:
    """Locate the SocketCAN helper script.

    Checks the installed path first, then falls back to the scripts/
    directory relative to the package root.
    """
    if os.path.isfile(_HELPER_INSTALLED_PATH) and os.access(_HELPER_INSTALLED_PATH, os.X_OK):
        return _HELPER_INSTALLED_PATH
    pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    local_path = os.path.join(pkg_root, "scripts", _HELPER_NAME)
    if os.path.isfile(local_path) and os.access(local_path, os.X_OK):
        return local_path
    return None


def _helper_args(
    channel: str,
    bitrate: int,
    *,
    fd: bool = False,
    data_bitrate: int | None = None,
    listen_only: bool = False,
) -> list[str]:
    """Build argument list for the helper script."""
    args = [channel, str(bitrate)]
    if fd and data_bitrate is not None:
        args += ["--fd", str(data_bitrate)]
    if listen_only:
        args.append("--listen-only")
    return args


def _is_permission_error(result: subprocess.CompletedProcess[str] | None) -> bool:
    if result is None:
        return True
    stderr = result.stderr.strip()
    return "Operation not permitted" in stderr or "EPERM" in stderr


def _current_interface_matches(
    channel: str,
    bitrate: int,
    *,
    fd: bool = False,
    data_bitrate: int | None = None,
    listen_only: bool = False,
) -> bool:
    """Check if a SocketCAN interface is already up with the desired configuration.

    Uses ``ip -json -detail link show <channel>`` to inspect the current state.
    Returns ``True`` when the interface is operationally up and its bitrate (and
    data_bitrate for FD) already match — meaning reconfiguration can be skipped.
    Returns ``False`` on any error or mismatch so callers fall through to the
    normal configure path.
    """
    try:
        result = subprocess.run(
            ["ip", "-json", "-detail", "link", "show", channel],
            capture_output=True, text=True, timeout=5,
        )
    except Exception:
        return False

    if result.returncode != 0:
        return False

    try:
        links = json.loads(result.stdout)
    except (json.JSONDecodeError, TypeError):
        return False

    if not isinstance(links, list) or len(links) == 0:
        return False

    info = links[0]

    # Interface must be operationally up.
    if info.get("operstate", "").upper() != "UP":
        return False

    # Check CAN-specific linkinfo.
    linkinfo = info.get("linkinfo", {})
    info_data = linkinfo.get("info_data", {})

    # Bitrate must match.
    bittiming = info_data.get("bittiming", {})
    if bittiming.get("bitrate") != bitrate:
        return False

    # For FD: data_bitrate must also match.
    if fd and data_bitrate is not None:
        data_bittiming = info_data.get("data_bittiming", {})
        if data_bittiming.get("bitrate") != data_bitrate:
            return False

    return True


def _configure_socketcan_interface(
    channel: str,
    bitrate: int,
    *,
    fd: bool = False,
    data_bitrate: int | None = None,
    listen_only: bool = False,
) -> None:
    """Configure a SocketCAN interface via ``ip link set`` before opening it.

    SocketCAN's kernel driver requires the interface to be configured with
    ``ip link`` commands — python-can's ``socketcan`` backend only opens a raw
    socket and silently ignores bitrate/data_bitrate kwargs.

    Escalation strategy:
      1. Try direct ``ip link set`` commands (works for root / CAP_NET_ADMIN).
      2. On permission error, try ``pkexec <helper>`` (single password prompt).
      3. If pkexec is unavailable, fall back to ``sudo -n <helper>`` (headless).
    """
    # -- Fast path: skip if already configured correctly -------------------
    if _current_interface_matches(
        channel, bitrate, fd=fd, data_bitrate=data_bitrate, listen_only=listen_only,
    ):
        return

    # -- Step A: try direct ip commands ------------------------------------
    ip_cmds = [
        ["ip", "link", "set", channel, "down"],
        _build_config_cmd(channel, bitrate, fd=fd, data_bitrate=data_bitrate, listen_only=listen_only),
        ["ip", "link", "set", channel, "up"],
    ]

    first_perm_result: subprocess.CompletedProcess[str] | None = None
    for cmd in ip_cmds:
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        except FileNotFoundError:
            raise UnsupportedConfiguration(
                "'ip' command not found; install iproute2 to configure SocketCAN interfaces"
            )
        except PermissionError:
            first_perm_result = None
            break

        if result.returncode != 0:
            if _is_permission_error(result):
                first_perm_result = result
                break
            raise UnsupportedConfiguration(
                f"Failed to configure SocketCAN interface '{channel}': "
                f"{result.stderr.strip()}"
            )
    else:
        return  # all direct commands succeeded

    # -- Step B: try pkexec <helper> ---------------------------------------
    helper = _find_helper_script()
    args = _helper_args(channel, bitrate, fd=fd, data_bitrate=data_bitrate, listen_only=listen_only)

    if helper is not None:
        pkexec_cmd = ["pkexec", helper] + args
        try:
            result = subprocess.run(pkexec_cmd, capture_output=True, text=True, timeout=60)
            if result.returncode == 0:
                return
        except FileNotFoundError:
            pass  # pkexec not installed — fall through to sudo

    # -- Step C: try sudo -n <helper> (headless fallback) ------------------
    if helper is not None:
        try:
            result = subprocess.run(
                ["sudo", "-n", helper] + args,
                capture_output=True, text=True, timeout=10,
            )
            if result.returncode == 0:
                return
        except FileNotFoundError:
            pass  # sudo not installed

    # -- Step D: all escalation paths failed --------------------------------
    raise UnsupportedConfiguration(
        f"Insufficient permissions to configure '{channel}'; "
        "run: sudo scripts/grant-socketcan-caps.sh"
    )


def _build_config_cmd(
    channel: str,
    bitrate: int,
    *,
    fd: bool = False,
    data_bitrate: int | None = None,
    listen_only: bool = False,
) -> list[str]:
    cmd = ["ip", "link", "set", channel, "type", "can", "bitrate", str(bitrate)]
    if fd and data_bitrate is not None:
        cmd += ["dbitrate", str(data_bitrate), "fd", "on"]
    if listen_only:
        cmd += ["listen-only", "on"]
    else:
        cmd += ["listen-only", "off"]
    return cmd


def _gs_usb_feature_flags(bus: Any) -> int:
    adapter = getattr(bus, "gs_usb", None)
    capability = getattr(adapter, "device_capability", None)
    feature = getattr(capability, "feature", 0)
    try:
        return int(feature)
    except (TypeError, ValueError):
        return 0


def _gs_usb_supports_termination(bus: Any) -> bool:
    return (_gs_usb_feature_flags(bus) & GS_USB_FEATURE_TERMINATION) != 0


def _gs_usb_set_termination(bus: Any, enable: bool) -> None:
    adapter = getattr(bus, "gs_usb", None)
    usb_handle = getattr(adapter, "gs_usb", None)
    if usb_handle is None or not hasattr(usb_handle, "ctrl_transfer"):
        raise UnsupportedConfiguration("Unable to access gs_usb control interface for termination setting")
    if not _gs_usb_supports_termination(bus):
        raise UnsupportedConfiguration("Selected gs_usb adapter does not support termination control")

    payload = int(bool(enable)).to_bytes(4, byteorder="little", signed=False)
    try:
        usb_handle.ctrl_transfer(0x41, GS_USB_BREQ_SET_TERMINATION, 0, 0, payload)
        state = usb_handle.ctrl_transfer(0xC1, GS_USB_BREQ_GET_TERMINATION, 0, 0, 4)
    except Exception as exc:
        raise UnsupportedConfiguration(f"Failed to configure gs_usb termination: {exc}") from exc

    try:
        current = int.from_bytes(bytes(state[:4]), byteorder="little", signed=False)
    except Exception:
        return
    if (current != 0) != bool(enable):
        raise UnsupportedConfiguration(
            f"gs_usb termination state verification failed (requested={int(bool(enable))}, actual={current})"
        )


class _TouCANNativeInterface:
    def __init__(self, channel: int) -> None:
        self.channel = int(channel)
        self._module: Any | None = None
        self._device: Any | None = None

    def open(self, bitrate: int, *, listen_only: bool = False) -> None:
        if sys.platform != "darwin":
            raise UnsupportedConfiguration("toucan backend is only supported on macOS")
        try:
            module = importlib.import_module("cancorder_utils.rusoku_canlogger")
        except Exception as exc:
            raise UnsupportedConfiguration(
                "toucan backend requires cancorder-utils (pip install git+https://github.com/Automotive-Swift/CANcorder.git)"
            ) from exc

        bitrate_map = getattr(module, "BITRATE_INDEX_BY_BPS", TOUCAN_BITRATE_INDEX_BY_BPS)
        bitrate_index = bitrate_map.get(int(bitrate))
        if bitrate_index is None:
            supported = ", ".join(str(value) for value in sorted(TOUCAN_BITRATE_INDEX_BY_BPS.keys()))
            raise UnsupportedConfiguration(
                f"Unsupported bitrate for TouCAN ({bitrate}). Supported bitrates: {supported}"
            )

        try:
            device = module.TouCANInterface()
        except Exception as exc:
            raise UnsupportedConfiguration(f"Failed to load TouCAN driver: {exc}") from exc

        op_mode = module.OpMode()
        canmode_default = getattr(module, "CANMODE_DEFAULT", 0)
        op_mode.byte = int(getattr(canmode_default, "value", canmode_default))
        if listen_only:
            op_mode.byte |= TOUCAN_LISTEN_ONLY_FLAG

        init_result = int(device.init(self.channel, op_mode))
        if init_result != int(getattr(module, "CANERR_NOERROR", TOUCAN_CANERR_NOERROR)):
            raise UnsupportedConfiguration(
                f"Failed to initialize TouCAN channel {self.channel}: error {init_result}"
            )

        bitrate_cfg = module.Bitrate()
        bitrate_cfg.index = int(bitrate_index)
        start_result = int(device.start(bitrate_cfg))
        if start_result != int(getattr(module, "CANERR_NOERROR", TOUCAN_CANERR_NOERROR)):
            try:
                device.exit()
            except Exception:
                pass
            raise UnsupportedConfiguration(
                f"Failed to start TouCAN channel {self.channel} at {bitrate}: error {start_result}"
            )

        self._module = module
        self._device = device

    def close(self) -> None:
        if self._device is None:
            return
        try:
            self._device.reset()
        except Exception:
            pass
        try:
            self._device.exit()
        except Exception:
            pass
        self._module = None
        self._device = None

    def _read_timeout_ms(self, timeout_ms: int, deadline: float | None) -> int:
        if deadline is None:
            return TOUCAN_CANREAD_INFINITE
        remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
        if timeout_ms <= 0:
            return 0
        return min(max(remaining_ms, 0), TOUCAN_CANREAD_INFINITE)

    def read(self, timeout_ms: int) -> CANFrame:
        if self._device is None or self._module is None:
            raise ReadError("TouCAN interface not open")

        no_error = int(getattr(self._module, "CANERR_NOERROR", TOUCAN_CANERR_NOERROR))
        rx_empty = int(getattr(self._module, "CANERR_RX_EMPTY", TOUCAN_CANERR_RX_EMPTY))
        deadline: float | None = None if timeout_ms < 0 else time.monotonic() + (max(timeout_ms, 0) / 1000.0)

        while True:
            read_timeout_ms = self._read_timeout_ms(timeout_ms, deadline)
            result, msg = self._device.read(timeout=read_timeout_ms)
            result = int(result)

            if result == no_error and msg is not None:
                flags = getattr(msg, "flags", None)
                if bool(getattr(flags, "sts", 0)):
                    if deadline is not None and time.monotonic() >= deadline:
                        raise ReadError("timeout")
                    continue

                is_rtr = bool(getattr(flags, "rtr", 0))
                is_extended = bool(getattr(flags, "xtd", 0))
                can_id = int(getattr(msg, "id", 0))
                can_id &= 0x1FFFFFFF if is_extended else 0x7FF
                dlc = min(max(int(getattr(msg, "dlc", 0)), 0), 8)
                data = [] if is_rtr else [int(byte) for byte in msg.data[:dlc]]
                return CANFrame(arbitration_id=can_id, data=data, dlc=len(data))

            if result in {rx_empty, TOUCAN_CANERR_TIMEOUT}:
                raise ReadError("timeout")

            raise ReadError(f"TouCAN read error {result}")

    def write(self, frame: CANFrame) -> None:
        if self._device is None or self._module is None:
            raise WriteError("TouCAN interface not open")
        if bool(frame.is_fd) or frame.dlc > 8:
            raise WriteError("TouCAN backend only supports classic CAN frames up to 8 bytes")

        payload = bytes(frame.data[: frame.dlc])
        if len(payload) > 8:
            raise WriteError("TouCAN backend only supports classic CAN frames up to 8 bytes")

        msg = self._module.Message()
        is_extended = frame.arbitration_id > 0x7FF
        msg.id = int(frame.arbitration_id & (0x1FFFFFFF if is_extended else 0x7FF))
        msg.flags.xtd = 1 if is_extended else 0
        msg.flags.rtr = 0
        for flag_name in ("fdf", "brs", "esi", "sts"):
            if hasattr(msg.flags, flag_name):
                setattr(msg.flags, flag_name, 0)
        msg.dlc = len(payload)
        for index, value in enumerate(payload):
            msg.data[index] = value

        can_write = getattr(self._device._lib, "can_write", None)
        if not callable(can_write):
            raise WriteError("TouCAN write function not available in loaded driver")
        result = int(can_write(self._device._handle, ctypes.byref(msg)))
        if result != int(getattr(self._module, "CANERR_NOERROR", TOUCAN_CANERR_NOERROR)):
            raise WriteError(f"TouCAN write error {result}")


class SocketCANInterface(CANInterface):
    def __init__(self, interface: str, backend: str | None = None) -> None:
        _parsed_backend, parsed_channel = _split_backend_channel(interface)
        selected_backend = (backend or selected_python_can_backend(interface)).lower()
        if selected_backend not in KNOWN_PYTHON_CAN_BACKENDS:
            supported = ", ".join(sorted(KNOWN_PYTHON_CAN_BACKENDS))
            raise UnsupportedConfiguration(
                f"Unsupported python-can backend '{selected_backend}'. Supported: {supported}"
            )
        self.backend = selected_backend
        self.channel = _normalize_channel_for_backend(parsed_channel, self.backend)
        self.interface = interface.strip()
        self.bus: Optional["python_can.BusABC"] = None
        self.bitrate = -1
        self.fd = False
        self.data_bitrate: int | None = None
        self.listen_only = False
        self.termination: bool | None = None
        self._toucan: _TouCANNativeInterface | None = None
        self._rcvbuf = _env_int("PYMULATOR_SOCKETCAN_RCVBUF", 8 * 1024 * 1024)
        self._sndbuf = _env_int("PYMULATOR_SOCKETCAN_SNDBUF", 8 * 1024 * 1024)
        self._tx_retry_attempts = _env_int("PYMULATOR_SOCKETCAN_TX_RETRY_ATTEMPTS", 20)
        self._tx_retry_sleep_ms = _env_int("PYMULATOR_SOCKETCAN_TX_RETRY_SLEEP_MS", 2)

    def open(
        self,
        bitrate: int,
        *,
        fd: bool = False,
        data_bitrate: int | None = None,
        listen_only: bool = False,
        termination: bool | None = None,
    ) -> None:
        if self.backend in {"socketcan", "gs_usb"} and python_can is None:
            raise UnsupportedConfiguration("python-can not installed (pip install python-can)")
        if self.backend == "gs_usb" and importlib.util.find_spec("gs_usb") is None:
            raise UnsupportedConfiguration("gs_usb backend requires the gs_usb package (pip install gs_usb)")
        if self.backend == "gs_usb":
            _install_macos_gs_usb_start_shim()
        self.bitrate = bitrate
        self.fd = bool(fd)
        self.data_bitrate = data_bitrate if self.fd else None
        self.listen_only = bool(listen_only)
        self.termination = bool(termination) if termination is not None else None
        if self.backend == "toucan":
            if self.fd:
                raise UnsupportedConfiguration("TouCAN backend does not support CAN-FD")
            if self.termination:
                raise UnsupportedConfiguration("TouCAN backend does not support software-controlled termination")
            self._toucan = _TouCANNativeInterface(int(self.channel))
            self._toucan.open(self.bitrate, listen_only=self.listen_only)
            return

        if self.backend == "socketcan":
            _configure_socketcan_interface(
                self.channel,
                self.bitrate,
                fd=self.fd,
                data_bitrate=self.data_bitrate,
                listen_only=self.listen_only,
            )
        mode_label = "fd" if self.fd else "classic"
        details = (
            f"backend={self.backend}, channel={self.channel}, mode={mode_label}, listen_only={self.listen_only}, "
            f"bitrate={self.bitrate}, data_bitrate={self.data_bitrate if self.data_bitrate is not None else '-'}, "
            f"termination={self.termination if self.termination is not None else '-'}"
        )
        bus_kwargs: dict[str, Any] = {
            "channel": self.channel,
            "bitrate": bitrate,
        }
        if self.fd:
            bus_kwargs["fd"] = True
            if self.data_bitrate is not None:
                bus_kwargs["data_bitrate"] = self.data_bitrate
        try:
            self.bus = python_can.Bus(interface=self.backend, **bus_kwargs)
        except TypeError as interface_exc:
            # Older python-can versions use 'bustype' instead of 'interface'.
            try:
                self.bus = python_can.Bus(bustype=self.backend, **bus_kwargs)
            except TypeError as bustype_exc:
                if self.fd and ("fd" in str(bustype_exc).lower() or "data_bitrate" in str(bustype_exc).lower()):
                    raise UnsupportedConfiguration(
                        "python-can backend does not support CAN-FD configuration; update python-can"
                    ) from bustype_exc
                if "fd" in str(interface_exc).lower() or "data_bitrate" in str(interface_exc).lower():
                    raise UnsupportedConfiguration(
                        "python-can backend does not support CAN-FD configuration; update python-can"
                    ) from interface_exc
                raise UnsupportedConfiguration(f"Failed to open CAN interface ({details}): {bustype_exc}") from bustype_exc
        except Exception as exc:
            raise UnsupportedConfiguration(f"Failed to open CAN interface ({details}): {exc}") from exc
        if self.listen_only and self.bus is not None:
            bus_state = getattr(python_can, "BusState", None)
            passive = getattr(bus_state, "PASSIVE", None) if bus_state is not None else None
            if passive is not None and hasattr(self.bus, "state"):
                try:
                    self.bus.state = passive
                except Exception:
                    pass
        if self.backend == "gs_usb" and self.bus is not None and self.termination is not None:
            if self.termination:
                _gs_usb_set_termination(self.bus, True)
            elif _gs_usb_supports_termination(self.bus):
                # Best effort: only drive termination off when adapter exposes control.
                _gs_usb_set_termination(self.bus, False)
        if hasattr(self.bus, "socket"):
            try:
                # Increase socket buffers to reduce drops under heavy CF bursts.
                self.bus.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, self._rcvbuf)
                self.bus.socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, self._sndbuf)
            except OSError:
                pass

    def close(self) -> None:
        if self.backend == "toucan":
            if self._toucan is not None:
                self._toucan.close()
                self._toucan = None
            self.bitrate = -1
            self.fd = False
            self.data_bitrate = None
            self.listen_only = False
            self.termination = None
            return
        if self.bus:
            if hasattr(self.bus, "shutdown"):
                self.bus.shutdown()
            else:
                self.bus.close()
            self.bus = None
        self.bitrate = -1
        self.fd = False
        self.data_bitrate = None
        self.listen_only = False
        self.termination = None

    def read(self, timeout_ms: int) -> CANFrame:
        if self.backend == "toucan":
            if self._toucan is None:
                raise ReadError("TouCAN interface not open")
            return self._toucan.read(timeout_ms)
        if not self.bus:
            raise ReadError("CAN interface not open")
        timeout = 0 if timeout_ms == 0 else timeout_ms / 1000.0
        try:
            msg = self.bus.recv(timeout=timeout)
        except Exception as exc:
            raise ReadError(str(exc)) from exc
        if msg is None:
            raise ReadError("timeout")
        dlc = getattr(msg, "dlc", len(msg.data))
        payload = list(msg.data[:dlc])
        return CANFrame(
            arbitration_id=msg.arbitration_id,
            data=payload,
            dlc=dlc,
            is_fd=bool(getattr(msg, "is_fd", False)),
            bitrate_switch=bool(getattr(msg, "bitrate_switch", False)),
            error_state_indicator=bool(getattr(msg, "error_state_indicator", False)),
        )

    def write(self, frame: CANFrame) -> None:
        if self.backend == "toucan":
            if self._toucan is None:
                raise WriteError("TouCAN interface not open")
            self._toucan.write(frame)
            return
        if not self.bus:
            raise WriteError("CAN interface not open")
        if python_can is None:
            raise UnsupportedConfiguration("python-can not installed (pip install python-can)")
        payload = bytes(frame.data[:frame.dlc])
        is_fd = bool(frame.is_fd or len(payload) > 8 or frame.dlc > 8)
        msg = python_can.Message(
            arbitration_id=frame.arbitration_id,
            is_extended_id=frame.arbitration_id > 0x7FF,
            data=payload,
            dlc=frame.dlc,
            is_fd=is_fd,
            bitrate_switch=bool(frame.bitrate_switch) if is_fd else False,
            error_state_indicator=bool(frame.error_state_indicator) if is_fd else False,
        )
        attempts = max(self._tx_retry_attempts, 0)
        for attempt in range(attempts + 1):
            try:
                self.bus.send(msg)
                return
            except Exception as exc:
                if "Transmit buffer full" not in str(exc) or attempt >= attempts:
                    raise WriteError(str(exc)) from exc
                time.sleep(max(self._tx_retry_sleep_ms, 0) / 1000.0)


def _env_int(name: str, default: int) -> int:
    value = os.getenv(name)
    if value is None:
        return default
    try:
        return int(value)
    except ValueError:
        return default


class REPLInterface(CANInterface):
    def __init__(self) -> None:
        self.name = "ECUmulator"
        self.model = "ECUmulator REPL"
        self.vendor = "Automotive-Swift"
        self.serial_number = "1234567890"
        self.bitrate = 500000
        self._interactive = True
        self._last_header = "7df"

    def open(
        self,
        bitrate: int,
        *,
        fd: bool = False,
        data_bitrate: int | None = None,
        listen_only: bool = False,
        termination: bool | None = None,
    ) -> None:
        del fd, data_bitrate, listen_only, termination
        self._interactive = sys.stdin.isatty()
        if self._interactive:
            print(f"{self.name} online.")
        else:
            print(f"{self.name} online (batch mode).")

    def close(self) -> None:
        return

    def read(self, timeout_ms: int) -> CANFrame:
        del timeout_ms
        while True:
            if self._interactive:
                try:
                    line = input("> ")
                except (EOFError, KeyboardInterrupt):
                    print("^D")
                    raise EOFError("eof")
            else:
                line = sys.stdin.readline()
                if not line:
                    raise EOFError("eof")
                line = line.rstrip("\n")
            if not line:
                if self._interactive:
                    print("")
                continue
            line = line.strip()
            if line.startswith("quit"):
                raise EOFError("eof")
            if "#" not in line:
                line = f"{self._last_header}#{line}"
            frame = self._frame_from_string(line)
            if frame is None:
                if self._interactive:
                    print("SyntaxError: Not <header>#<message> form")
                continue
            if self._interactive:
                print("")
            return frame

    def write(self, frame: CANFrame) -> None:
        print(self._string_from_frame(frame))

    def _frame_from_string(self, input_line: str) -> CANFrame | None:
        trimmed = input_line.strip()
        allowed = set("0123456789abcdefABCDEF#")
        if any(char not in allowed for char in trimmed):
            return None
        header_str, data_str = trimmed.split("#", 1)
        self._last_header = header_str
        if len(header_str) % 2 == 1:
            header_str = "0" + header_str
        if len(data_str) % 2 == 1:
            data_str = "0" + data_str
        try:
            can_id = int(header_str, 16)
        except ValueError:
            return None
        try:
            payload = [int(data_str[i:i + 2], 16) for i in range(0, len(data_str), 2)]
        except ValueError:
            return None
        return CANFrame(arbitration_id=can_id, data=payload)

    def _string_from_frame(self, frame: CANFrame) -> str:
        header = f"{frame.arbitration_id:X}"
        dlc = f"[{frame.dlc}]"
        payload = " ".join(f"{byte:02X}" for byte in frame.data[: frame.dlc])
        return f"{header}   {dlc}   {payload} "


class NullInterface(CANInterface):
    def __init__(self) -> None:
        self.bitrate = -1

    def open(
        self,
        bitrate: int,
        *,
        fd: bool = False,
        data_bitrate: int | None = None,
        listen_only: bool = False,
        termination: bool | None = None,
    ) -> None:
        del fd, data_bitrate, listen_only, termination
        self.bitrate = bitrate

    def close(self) -> None:
        self.bitrate = -1

    def read(self, timeout_ms: int) -> CANFrame:
        raise ReadError("timeout")

    def write(self, frame: CANFrame) -> None:
        return
