"""Byte transports for ElmLink: serial (USB / classic-Bluetooth SPP), TCP
(WiFi ELM327), and BLE (Bluetooth Low Energy ELM327).

Each transport presents the same tiny synchronous interface so ElmLink's
command loop doesn't care how the bytes move:

    write(data: bytes) -> None
    read(n: int)       -> bytes   (returns b"" on timeout)
    reset_input_buffer() -> None
    close()            -> None

Classic Bluetooth ELM327 pair as a serial port (COMx / /dev/cu.* / rfcomm), so
they use SerialTransport. Only WiFi (TCP) and BLE need dedicated transports.
"""
import socket
import threading
import time


class SerialTransport:
    def __init__(self, port, baud=38400):
        import serial
        self.ser = serial.Serial(port, baud, timeout=0.2)

    def write(self, data):
        self.ser.write(data)

    def read(self, n):
        return self.ser.read(n)

    def reset_input_buffer(self):
        self.ser.reset_input_buffer()

    def close(self):
        try:
            self.ser.close()
        except Exception:
            pass


class TcpTransport:
    """WiFi ELM327 — a raw TCP socket (commonly 192.168.0.10:35000)."""

    def __init__(self, host, port=35000, connect_timeout=5.0, read_timeout=0.2):
        self._rt = read_timeout
        self.sock = socket.create_connection((host, int(port)), timeout=connect_timeout)
        self.sock.settimeout(read_timeout)

    def write(self, data):
        self.sock.sendall(data)

    def read(self, n):
        try:
            return self.sock.recv(n)
        except socket.timeout:
            return b""
        except OSError:
            return b""

    def reset_input_buffer(self):
        self.sock.settimeout(0.05)
        try:
            while self.sock.recv(4096):
                pass
        except Exception:
            pass
        finally:
            self.sock.settimeout(self._rt)

    def close(self):
        try:
            self.sock.close()
        except Exception:
            pass


# Common BLE UART characteristic pairs used by cheap ELM327 dongles
# (write-char, notify-char). We try each until one has both properties.
_BLE_UART_PAIRS = [
    ("0000fff2-0000-1000-8000-00805f9b34fb", "0000fff1-0000-1000-8000-00805f9b34fb"),
    ("0000ffe1-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb"),
    ("6e400002-b5a3-f393-e0a9-e50e24dcca9e", "6e400003-b5a3-f393-e0a9-e50e24dcca9e"),
]


class BleTransport:
    """Experimental BLE ELM327 transport (needs `bleak`). Runs an asyncio loop
    on a background thread and buffers notifications so read()/write() stay
    synchronous. Untested against every dongle -- BLE ELM327 GATT layouts vary."""

    def __init__(self, address, connect_timeout=15.0):
        try:
            import bleak  # noqa: F401
        except ImportError as e:
            raise RuntimeError("BLE support needs the 'bleak' package "
                               "(pip install bleak)") from e
        self.address = address
        self._buf = bytearray()
        self._lock = threading.Lock()
        self._loop = None
        self._client = None
        self._write_char = None
        self._ready = threading.Event()
        self._err = None
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        if not self._ready.wait(connect_timeout) or self._err:
            raise RuntimeError(f"BLE connect failed: {self._err or 'timeout'}")

    def _run(self):
        import asyncio
        from bleak import BleakClient
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)

        async def setup():
            self._client = BleakClient(self.address)
            await self._client.connect()
            notify_char = None
            for svc in self._client.services:
                for ch in svc.characteristics:
                    props = set(ch.properties)
                    if {"write", "write-without-response"} & props and self._write_char is None:
                        self._write_char = ch.uuid
                    if {"notify", "indicate"} & props and notify_char is None:
                        notify_char = ch.uuid
            # prefer a known UART pair if present
            for w, n in _BLE_UART_PAIRS:
                if any(c.uuid == w for s in self._client.services for c in s.characteristics):
                    self._write_char = w
                    notify_char = n
                    break
            if not self._write_char or not notify_char:
                raise RuntimeError("no writable/notify characteristic found")

            def on_notify(_h, data):
                with self._lock:
                    self._buf += bytes(data)

            await self._client.start_notify(notify_char, on_notify)
            self._ready.set()

        try:
            self._loop.run_until_complete(setup())
            self._loop.run_forever()
        except Exception as e:
            self._err = e
            self._ready.set()

    def write(self, data):
        import asyncio
        fut = asyncio.run_coroutine_threadsafe(
            self._client.write_gatt_char(self._write_char, data, response=False), self._loop)
        fut.result(timeout=3.0)

    def read(self, n):
        deadline = time.time() + 0.2
        while time.time() < deadline:
            with self._lock:
                if self._buf:
                    out = bytes(self._buf[:n]); del self._buf[:n]
                    return out
            time.sleep(0.005)
        return b""

    def reset_input_buffer(self):
        with self._lock:
            self._buf.clear()

    def close(self):
        try:
            import asyncio
            asyncio.run_coroutine_threadsafe(self._client.disconnect(), self._loop).result(timeout=3.0)
            self._loop.call_soon_threadsafe(self._loop.stop)
        except Exception:
            pass


def ble_scan(timeout=6.0):
    """Return [(address, name), ...] of nearby BLE devices (needs bleak)."""
    import asyncio
    from bleak import BleakScanner
    devs = asyncio.run(BleakScanner.discover(timeout=timeout))
    return [(d.address, d.name or "?") for d in devs]
