"""Background reader for an NMEA/AIS feed delivered over a TCP socket.

Many AIS receivers are exposed on the network by a serial-device server (e.g. a
Tibbo/Moxa box, or software like an AIS multiplexer) as a raw TCP stream. Rather
than bridging that into a virtual COM port, this reader connects straight to
host:port and reads the sentences itself.

It mirrors SerialReader's interface exactly — same signals — so the rest of the
StreamWidget treats serial and TCP sources identically. Reading runs inside a
QThread so the blocking socket I/O never freezes the QGIS GUI thread. TCP is a
byte stream (not message-framed), so incoming bytes are buffered and split on
newlines into whole sentences.
"""
import socket

from qgis.PyQt.QtCore import QThread, pyqtSignal


def split_lines(buffer):
    """Split a bytes buffer into (complete_lines, remainder).

    complete_lines is a list of decoded, stripped str lines; remainder is the
    trailing bytes after the last newline (an as-yet-incomplete sentence). Pure
    function so the framing logic is testable without a socket.
    """
    lines = []
    while b"\n" in buffer:
        raw, buffer = buffer.split(b"\n", 1)
        text = raw.decode("ascii", errors="replace").strip()
        if text:
            lines.append(text)
    return lines, buffer


class TcpReader(QThread):
    """Reads newline-delimited NMEA sentences from a TCP socket off-thread.

    Signals (identical to SerialReader):
        lineReceived(str)  — one decoded, stripped text line
        errorOccurred(str) — connect/read error message
        connected(str)     — socket opened successfully ("host:port")
        disconnected()     — socket closed / loop ended
    """
    lineReceived = pyqtSignal(str)
    errorOccurred = pyqtSignal(str)
    connected = pyqtSignal(str)
    disconnected = pyqtSignal()

    CONNECT_TIMEOUT = 10.0      # seconds to establish the connection
    READ_TIMEOUT = 1.0         # periodic wakeups so stop() is responsive

    def __init__(self, host, port, parent=None, use_tls=False,
                 tls_insecure=False, login_lines=None):
        """Configure a reader for one TCP NMEA endpoint (host:port).

        use_tls       -- wrap the socket in TLS after connecting.
        tls_insecure  -- with TLS, accept a self-signed / unverified server cert
                         (common for private AIS multiplexers). Ignored otherwise.
        login_lines   -- lines to send to the server immediately after connecting
                         (each is CRLF-terminated), e.g. a login command or token.
                         The caller renders these from the credentials; there is
                         no standard AIS login, so the format is server-specific.
        """
        super().__init__(parent)
        self._host = str(host).strip()
        self._port = int(port)
        self._use_tls = bool(use_tls)
        self._tls_insecure = bool(tls_insecure)
        self._login_lines = list(login_lines or [])
        self._running = False
        self._sock = None

    def run(self):
        """Thread body: connect (optionally over TLS), send any login lines, then
        buffer the byte stream and emit each newline-framed sentence until stopped
        or the peer closes."""
        label = f"{self._host}:{self._port}"
        try:
            sock = socket.create_connection(
                (self._host, self._port), timeout=self.CONNECT_TIMEOUT)
            if self._use_tls:
                import ssl
                if self._tls_insecure:
                    # nosec B323 - only when the operator has explicitly ticked
                    # "accept a self-signed certificate" for their own feed.
                    ctx = ssl._create_unverified_context()  # nosec B323
                else:
                    ctx = ssl.create_default_context()
                sock = ctx.wrap_socket(sock, server_hostname=self._host)
            self._sock = sock
        except Exception as exc:
            self.errorOccurred.emit(f"Could not connect to {label}: {exc}")
            self.disconnected.emit()
            return

        # Send credentials/login immediately after connecting, if configured.
        if self._login_lines:
            try:
                payload = "".join(ln + "\r\n" for ln in self._login_lines)
                self._sock.sendall(payload.encode("ascii", "replace"))
            except Exception as exc:
                self.errorOccurred.emit(f"Login send failed on {label}: {exc}")

        self._sock.settimeout(self.READ_TIMEOUT)
        self._running = True
        self.connected.emit(label)
        buffer = b""
        try:
            while self._running:
                try:
                    chunk = self._sock.recv(4096)
                except socket.timeout:
                    continue                       # no data this interval
                except Exception as exc:
                    self.errorOccurred.emit(f"Read error: {exc}")
                    break
                if not chunk:                       # peer closed the connection
                    self.errorOccurred.emit(f"{label} closed the connection.")
                    break
                buffer += chunk
                lines, buffer = split_lines(buffer)
                for line in lines:
                    self.lineReceived.emit(line)
        finally:
            try:
                if self._sock:
                    self._sock.close()
            except Exception:  # nosec B110
                pass
            self.disconnected.emit()

    def stop(self):
        """Ask the loop to finish and wait briefly for the thread to exit."""
        self._running = False
        try:
            if self._sock:
                self._sock.shutdown(socket.SHUT_RDWR)
        except Exception:  # nosec B110
            pass
        self.wait(3000)
