"""Background serial-port connection used by each QTFM sensor panel."""

from __future__ import annotations

import re
import threading
import time
from dataclasses import dataclass
from queue import Empty, Queue

import serial
from PyQt5.QtCore import QThread, pyqtSignal


@dataclass(frozen=True)
class QTFMMeasurement:
    """One scalar QTFM measurement extracted from a serial data line."""

    value_nt: float
    valid: bool
    timestamp_ms: int | None
    counter: int | None
    host_time: float


_SCALAR_FIELD = re.compile(rb"!([+-]?\d+(?:\.\d*)?)([_*]?)")
_TIMESTAMP_FIELD = re.compile(rb">(\d+)")
_COUNTER_FIELD = re.compile(rb"@(\d+)")


def parse_qtfm_measurement(
    line: bytes,
    host_time: float | None = None,
) -> QTFMMeasurement | None:
    """Parse the scalar value and optional timing fields from one QTFM line."""
    scalar_match = _SCALAR_FIELD.search(line)
    if scalar_match is None:
        return None

    try:
        value_nt = float(scalar_match.group(1))
    except ValueError:
        return None

    marker = scalar_match.group(2)
    timestamp_match = _TIMESTAMP_FIELD.search(line)
    counter_match = _COUNTER_FIELD.search(line)

    return QTFMMeasurement(
        value_nt=value_nt,
        valid=marker != b"*",
        timestamp_ms=(
            int(timestamp_match.group(1))
            if timestamp_match is not None
            else None
        ),
        counter=(
            int(counter_match.group(1))
            if counter_match is not None
            else None
        ),
        host_time=time.monotonic() if host_time is None else host_time,
    )


def parse_qtfm_status_line(line: bytes) -> str | None:
    """Return an original printable sensor line that is not scalar data."""
    stripped = line.strip(b"\r\n")
    if not stripped or parse_qtfm_measurement(stripped) is not None:
        return None
    if not all(byte == 9 or 32 <= byte <= 126 for byte in stripped):
        return None
    try:
        return stripped.decode("ascii")
    except UnicodeDecodeError:
        return None


class StreamQualityMonitor:
    """Classify a serial byte stream without fully parsing sensor data."""

    GRACE_SECONDS = 2.0
    VALID_FRAME_COUNT = 3
    _QTFM_FRAME = re.compile(rb"![+-]?\d+(?:\.\d*)?[_*=?]?")

    def __init__(self, started_at: float | None = None) -> None:
        self.started_at = time.monotonic() if started_at is None else started_at
        self.total_bytes = 0
        self.total_lines = 0
        self.valid_frames = 0
        self.invalid_bytes = 0
        self.last_valid_at: float | None = None
        self._buffer = bytearray()

    def feed(self, chunk: bytes, now: float | None = None) -> list[bytes]:
        """Add bytes, inspect framing, and return complete lines."""
        if not chunk:
            return []

        observed_at = time.monotonic() if now is None else now
        complete_lines: list[bytes] = []
        self.total_bytes += len(chunk)
        self.invalid_bytes += sum(
            byte not in (9, 10, 13) and not 32 <= byte <= 126
            for byte in chunk
        )
        self._buffer.extend(chunk)

        while b"\n" in self._buffer:
            line, _, remainder = self._buffer.partition(b"\n")
            self._buffer = bytearray(remainder)
            line = line.rstrip(b"\r")
            self.total_lines += 1
            complete_lines.append(bytes(line))

            is_ascii = all(
                byte == 9 or 32 <= byte <= 126
                for byte in line
            )
            if is_ascii and self._QTFM_FRAME.search(line):
                self.valid_frames += 1
                self.last_valid_at = observed_at

        # A wrong baud rate can produce a long stream with no newline framing.
        # Bound memory while retaining enough of the tail to find future frames.
        if len(self._buffer) > 8192:
            self._buffer = self._buffer[-4096:]

        return complete_lines

    def evaluate(self, now: float | None = None) -> str:
        """Return checking, valid, silent, garbled, or unrecognized."""
        observed_at = time.monotonic() if now is None else now

        if self.valid_frames >= self.VALID_FRAME_COUNT:
            return "valid"

        if observed_at - self.started_at < self.GRACE_SECONDS:
            return "checking"

        if self.total_bytes == 0:
            return "silent"

        invalid_ratio = self.invalid_bytes / self.total_bytes
        if invalid_ratio > 0.05:
            return "garbled"

        if self.total_lines >= self.VALID_FRAME_COUNT:
            return "unrecognized"

        if self.total_bytes >= 128:
            return "garbled"

        return "unrecognized"


class SerialConnection(QThread):
    """Open and continuously drain one serial port without blocking the UI.

    It extracts scalar measurements and forwards other printable sensor lines
    verbatim so the UI can display the device's own status messages.
    """

    opened = pyqtSignal(str)
    activity = pyqtSignal(int, int, int)
    stream_health = pyqtSignal(str)
    measurement_received = pyqtSignal(object)
    status_line_received = pyqtSignal(str)
    #: Every complete line, data and status alike, batched for the terminal.
    raw_lines = pyqtSignal(list)
    command_written = pyqtSignal(bytes)
    command_write_failed = pyqtSignal(bytes, str)
    connection_error = pyqtSignal(str)
    closed = pyqtSignal()

    def __init__(self, port: str, baudrate: int, parent=None) -> None:
        super().__init__(parent)
        self.port = port
        self.baudrate = baudrate
        self._stop_event = threading.Event()
        self._serial: serial.Serial | None = None
        self._write_queue: Queue[bytes] = Queue()

    def run(self) -> None:
        """Own the serial handle for the lifetime of this worker thread."""
        self._stop_event.clear()
        monitor = StreamQualityMonitor()
        last_report = time.monotonic()
        last_health = ""
        pending_raw: list[str] = []

        try:
            self._serial = serial.Serial(
                port=self.port,
                baudrate=self.baudrate,
                timeout=0.1,
                write_timeout=0.1,
                inter_byte_timeout=None,
            )
            self.opened.emit(self.port)

            while not self._stop_event.is_set():
                self._flush_write_queue()
                waiting = self._serial.in_waiting
                chunk = self._serial.read(waiting if waiting > 0 else 1)

                if chunk:
                    observed_at = time.monotonic()
                    lines = monitor.feed(chunk, observed_at)
                    for line in lines:
                        pending_raw.append(
                            line.decode("ascii", errors="replace")
                        )
                        measurement = parse_qtfm_measurement(line, observed_at)
                        if measurement is not None:
                            self.measurement_received.emit(measurement)
                            continue
                        status_line = parse_qtfm_status_line(line)
                        if status_line is not None:
                            self.status_line_received.emit(status_line)

                now = time.monotonic()
                if now - last_report >= 0.25:
                    if pending_raw:
                        self.raw_lines.emit(pending_raw)
                        pending_raw = []
                    health = monitor.evaluate(now)
                    self.activity.emit(
                        monitor.total_bytes,
                        monitor.total_lines,
                        monitor.valid_frames,
                    )
                    if health != last_health:
                        self.stream_health.emit(health)
                        last_health = health
                    last_report = now

            if pending_raw:
                self.raw_lines.emit(pending_raw)
            self.activity.emit(
                monitor.total_bytes,
                monitor.total_lines,
                monitor.valid_frames,
            )

        except serial.SerialException as exc:
            if not self._stop_event.is_set():
                self.connection_error.emit(self._friendly_error(exc))
        except OSError as exc:
            if not self._stop_event.is_set():
                self.connection_error.emit(f"Port error: {exc}")
        finally:
            if self._serial is not None and self._serial.is_open:
                self._serial.close()
            self._serial = None
            self.closed.emit()

    def send_command(self, command: bytes) -> bool:
        """Queue a short sensor command for writing on the serial thread."""
        if not command or not self.isRunning():
            return False
        self._write_queue.put(bytes(command))
        return True

    def _flush_write_queue(self) -> None:
        if self._serial is None or not self._serial.is_open:
            return

        while True:
            try:
                command = self._write_queue.get_nowait()
            except Empty:
                return
            try:
                self._serial.write(command)
                self._serial.flush()
                self.command_written.emit(command)
            except (serial.SerialException, serial.SerialTimeoutException) as exc:
                self.command_write_failed.emit(command, str(exc))
                raise

    def stop(self) -> None:
        """Request a clean close and wait briefly for the worker to finish."""
        self._stop_event.set()
        if self.isRunning():
            self.wait(1500)

    @staticmethod
    def _friendly_error(exc: serial.SerialException) -> str:
        message = str(exc)
        lowered = message.lower()

        if "access is denied" in lowered or "permission" in lowered:
            return "Port is already open in another application."
        if "cannot find" in lowered or "not found" in lowered:
            return "Device was not found. Check its USB connection."
        if "semaphore timeout" in lowered:
            return "The device stopped responding. Reconnect its USB cable."
        return f"Serial error: {message}"
