"""Async serial transport for LC29H GNSS modules.

Wraps pyserial-asyncio for non-blocking UART communication.
Default: /dev/ttyS0 at 115200 baud (Waveshare HAT UART1).

The Waveshare LC29H HAT connects via GPIO UART (ttyS0 on Raspberry Pi)
or USB-UART bridge. The module defaults to 9600 baud from factory
but Waveshare's sample code uses 115200.

The transport supports a "response watcher" mechanism for use in
server contexts where a background monitor task reads all serial
data. Command queries register an expected prefix and receive their
response via the watcher, avoiding a race between the monitor and
command logic for the same serial stream.
"""

import asyncio
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass, field

import serial_asyncio

logger = logging.getLogger(__name__)


@dataclass
class _ResponseWatcher:
    """A pending response expectation."""
    prefix: str
    future: asyncio.Future
    created: float = field(default=0.0)


class SerialTransport:
    """Async serial connection to LC29H module."""

    def __init__(self, port: str = "/dev/ttyS0", baud: int = 115200):
        self.port = port
        self.baud = baud
        self._reader: asyncio.StreamReader | None = None
        self._writer: asyncio.StreamWriter | None = None
        self._connected = False
        self._watchers: list[_ResponseWatcher] = []
        self._monitor_active = False

    @property
    def connected(self) -> bool:
        return self._connected

    async def __aenter__(self) -> "SerialTransport":
        await self.connect()
        return self

    async def __aexit__(self, *exc) -> None:
        await self.disconnect()

    async def connect(self) -> None:
        """Open serial connection."""
        logger.info("Connecting to %s at %d baud", self.port, self.baud)
        self._reader, self._writer = await serial_asyncio.open_serial_connection(
            url=self.port,
            baudrate=self.baud,
            bytesize=8,
            parity="N",
            stopbits=1,
            rtscts=False,
            xonxoff=False,
        )
        self._connected = True
        logger.info("Connected to %s", self.port)

    async def disconnect(self) -> None:
        """Close serial connection."""
        if self._writer:
            self._writer.close()
            self._connected = False
            logger.info("Disconnected from %s", self.port)

    async def write(self, data: bytes) -> None:
        """Write framed command bytes to serial port."""
        if not self._writer:
            raise ConnectionError("Not connected")
        self._writer.write(data)
        await self._writer.drain()
        logger.debug("TX: %s", data.decode("ascii", errors="replace").strip())

    async def read_line(self, timeout: float = 2.0) -> str:
        """Read a single NMEA line (terminated by \\r\\n).

        Args:
            timeout: Read timeout in seconds.

        Returns:
            The received line as a string (stripped of \\r\\n).

        Raises:
            TimeoutError: If no line received within timeout.
        """
        if not self._reader:
            raise ConnectionError("Not connected")
        try:
            line = await asyncio.wait_for(self._reader.readline(), timeout=timeout)
            decoded = line.decode("ascii", errors="replace").strip()
            logger.debug("RX: %s", decoded)
            return decoded
        except TimeoutError:
            raise TimeoutError(f"No response within {timeout}s") from None

    async def read_stream(self, timeout: float = 0.5) -> AsyncIterator[str]:
        """Continuously yield NMEA lines from the serial port.

        Yields lines until no data arrives within timeout (useful for
        reading all pending NMEA sentences after a command).

        Args:
            timeout: Silence timeout — stop yielding after this many
                     seconds of no data.
        """
        if not self._reader:
            raise ConnectionError("Not connected")
        while True:
            try:
                line = await asyncio.wait_for(self._reader.readline(), timeout=timeout)
                decoded = line.decode("ascii", errors="replace").strip()
                if decoded:
                    yield decoded
            except TimeoutError:
                break

    def dispatch_line(self, line: str) -> bool:
        """Check a line against pending response watchers.

        Called by a background monitor task for every line read.
        If the line matches a watcher's prefix, the watcher's future
        is resolved and True is returned (line was consumed).
        """
        for watcher in self._watchers:
            if not watcher.future.done() and line.startswith(watcher.prefix):
                watcher.future.set_result(line)
                self._watchers.remove(watcher)
                return True
        return False

    async def _wait_for_prefix(self, prefix: str, timeout: float) -> str | None:
        """Wait for a response matching prefix, using watchers if available.

        If a background monitor is calling dispatch_line(), this
        registers a watcher and awaits it. Otherwise falls back to
        direct reading (for CLI use without a monitor).
        """
        loop = asyncio.get_running_loop()
        future: asyncio.Future[str] = loop.create_future()
        watcher = _ResponseWatcher(prefix=prefix, future=future, created=loop.time())
        self._watchers.append(watcher)

        try:
            return await asyncio.wait_for(future, timeout=timeout)
        except TimeoutError:
            return None
        finally:
            if watcher in self._watchers:
                self._watchers.remove(watcher)

    async def send_command(self, cmd: bytes, timeout: float = 2.0) -> str | None:
        """Send a command and wait for an ACK response.

        Sends the command, then waits for $PAIR001 (the standard ACK).
        Uses response watchers when a monitor task is active, or
        reads directly for standalone CLI use.
        """
        await self.write(cmd)
        if self._monitor_active:
            return await self._wait_for_prefix("$PAIR001", timeout)
        # Fallback: direct read (no monitor running)
        deadline = asyncio.get_running_loop().time() + timeout
        while asyncio.get_running_loop().time() < deadline:
            remaining = deadline - asyncio.get_running_loop().time()
            try:
                line = await self.read_line(timeout=min(remaining, 0.5))
                if line.startswith("$PAIR001"):
                    return line
            except TimeoutError:
                continue
        return None

    async def query(self, cmd: bytes, response_prefix: str, timeout: float = 2.0) -> str | None:
        """Send a query command and wait for a specific response prefix.

        Args:
            cmd: Framed command bytes.
            response_prefix: Expected response start (e.g. "$PAIR021", "$PQTMVERNO").
            timeout: Maximum wait time.
        """
        await self.write(cmd)
        if self._monitor_active:
            return await self._wait_for_prefix(response_prefix, timeout)
        # Fallback: direct read
        deadline = asyncio.get_running_loop().time() + timeout
        while asyncio.get_running_loop().time() < deadline:
            remaining = deadline - asyncio.get_running_loop().time()
            try:
                line = await self.read_line(timeout=min(remaining, 0.5))
                if line.startswith(response_prefix):
                    return line
            except TimeoutError:
                continue
        return None
