"""Continuous serial frame reader (Live-mode source)"""
from __future__ import annotations

import logging
import threading
import time
from typing import Callable

from pygui.backend.models.frame import Frame
from pygui.backend.wafer.family_spec import sensor_count_for
from pygui.serialcomm.data_parser import _convert_hex_to_temp

log = logging.getLogger(__name__)

ParseFrame = Callable[[bytes, int], Frame]

# Max garbage bytes discarded (no sync marker found) before giving up on a
# corrupt stream. Counted in BYTES, not read attempts — attempts scale with
# data rate (a slow trickle could take minutes to trip an attempt-count cap),
# bytes don't. Real frames are <=500B, so a healthy stream never comes close;
# 16KB is ~32 frames of slack, ~1.4s of pure garbage at 115200 baud. Empty
# reads (idle port) never count — C#'s findMessageStart waits forever on quiet.
_MAX_RESYNC_BYTES = 16384

# Largest believable payload_len. Real frames are ≤ 488 bytes (244 words);
# anything bigger means we latched onto a false 0xAA 0x88 inside data and
# read garbage as a length — without this cap, _accumulate would swallow up
# to 64KB of good frames waiting for a packet that doesn't exist.
# ponytail: loose cap; exact per-family length check if false frames slip CRC.
_MAX_PAYLOAD_LEN = 4096


def _crc16(data: bytes) -> int:
    """CRC16/MODBUS (poly 0xA001, init 0xFFFF) — mirrors C# CalculateCRC16."""
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 1:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc

class StreamReader:

    def __init__(self, transport, parse_frame: ParseFrame,
                on_frame: Callable[[Frame], None],
                on_error: Callable[[Exception], None] | None = None,
                family_code: str = "A") -> None:
        self._transport = transport
        self._parse = parse_frame
        self._on_frame = on_frame
        self._on_error = on_error or (lambda e: None)
        self._family_code = family_code or "A"
        self._thread: threading.Thread | None = None
        self._stop = threading.Event()
        self.error_count = 0
        # Cumulative count of resync events (lost sync marker) for stream stats.
        self.resync_count = 0

    def start(self) -> None:
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, daemon = True)
        self._thread.start()

    def _read_chunk(self, min_bytes: int = 1) -> bytes:
        """Read a chunk of available bytes from the transport.

        Reads up to 256 bytes in one shot. If nothing is immediately
        available, yields briefly to avoid a busy spin.
        """
        chunk: bytes = self._transport.read(max(min_bytes, 256))
        if not chunk:
            time.sleep(0.005)
        return chunk

    def _accumulate(self, needed: int) -> bytearray:
        """Accumulate *needed* bytes using buffered reads."""
        buf = bytearray()
        while len(buf) < needed and not self._stop.is_set():
            chunk = self._transport.read(needed - len(buf))
            if not chunk:
                time.sleep(0.005)
                continue
            buf.extend(chunk)
        return buf

    def _run(self) -> None:
        try:
            # Protocol is decided by the first byte: 0xAA is the binary sync
            # marker's lead byte and can never appear in the ASCII hex-dump
            # stream (0-9/A-F only) real hardware has been observed sending
            # — see docs/adr/0007. Anything else is ASCII.
            first = b""
            while not first and not self._stop.is_set():
                first = self._transport.read(1)
            if self._stop.is_set() or not first:
                return

            if first[0] == 0xAA:
                second = b""
                while not second and not self._stop.is_set():
                    second = self._transport.read(1)
                if self._stop.is_set():
                    return
                if second and second[0] == 0x88:
                    self._run_binary(bytearray(first + second))
                    return
                # Lone 0xAA, not a real marker — feed both bytes into the
                # ASCII path instead of discarding them.
                self._run_ascii(bytes(first) + bytes(second))
                return

            self._run_ascii(bytes(first))
        except Exception as exc:
            if self._stop.is_set():
                # stop() closes the transport to unblock a pending read —
                # that self-inflicted close raises here too (e.g. EBADF).
                # Not a real stream fault, so don't report it as one.
                log.debug("StreamReader thread exiting after stop(): %s", exc)
            else:
                log.exception("StreamReader thread encountered error: %s", exc)
                try:
                    self._on_error(exc)
                except Exception:
                    pass
        finally:
            try:
                self._transport.close()
            except Exception:
                pass

    def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
        log.info("StreamReader: starting binary stream parsing")
        buf = bytearray(initial_bytes)
        resync_bytes = 0
        logged_head = False

        while not self._stop.is_set():
            # Find sync marker in buffer.
            idx = 0
            synced = False
            while idx < len(buf) - 1:
                if buf[idx] == 0xAA and buf[idx + 1] == 0x88:
                    synced = True
                    break
                idx += 1

            if synced:
                if not logged_head:
                    log.debug("StreamReader: first bytes on wire: %s",
                            bytes(buf[:64]).hex())
                    logged_head = True
                resync_bytes = 0
                buf = buf[idx:]
                # Accumulate header (5 bytes after sync).
                while len(buf) < 7 and not self._stop.is_set():
                    chunk = self._read_chunk()
                    buf.extend(chunk)

                if self._stop.is_set():
                    return

                payload_len = int.from_bytes(buf[5:7], byteorder='little')
                if payload_len > _MAX_PAYLOAD_LEN:
                    # False sync marker inside data — the "length" is garbage.
                    # Skip past the marker and rescan what we already have.
                    self.resync_count += 1
                    buf = buf[2:]
                    continue
                total_packet_len = 2 + 5 + payload_len + 2

                # Accumulate the rest of the packet in one buffered read.
                remaining = total_packet_len - len(buf)
                if remaining > 0:
                    extra = self._accumulate(remaining)
                    buf.extend(extra)

                if self._stop.is_set():
                    return

                # CRC16 over sync+header+payload, trailing 2 bytes LE —
                # mirrors C# VerifyData. A failed CRC is a corrupt or
                # misframed packet: drop it, rescan, keep streaming.
                received_crc = int.from_bytes(
                    buf[7 + payload_len:9 + payload_len], byteorder='little'
                )
                if _crc16(bytes(buf[:7 + payload_len])) != received_crc:
                    self.error_count += 1
                    if self.error_count <= 10:
                        log.warning(
                            "CRC mismatch on binary packet (seq bytes %s) — dropped",
                            bytes(buf[3:5]).hex(),
                        )
                    buf = buf[2:]
                    continue

                header = buf[2:7]
                seq = int.from_bytes(header[1:3], byteorder='little')
                payload = buf[7:7 + payload_len]

                try:
                    frame = self._parse(payload, seq)  # type: ignore[arg-type]
                    self._on_frame(frame)
                except Exception as exc:
                    self.error_count += 1
                    if self.error_count <= 10:
                        log.warning("Parse error on binary payload: %s", exc)
                    self._on_error(exc)

                buf = buf[total_packet_len:]
            else:
                # Resync: keep only trailing 0xAA if present, then read more.
                if len(buf) > 0 and buf[-1] == 0xAA:
                    buf = bytearray([0xAA])
                else:
                    buf.clear()

                chunk = self._read_chunk()
                if not chunk:
                    # Quiet port — waiting for data is not a resync failure.
                    # The device pauses between scans; C#'s findMessageStart
                    # waits forever. Only actual garbage counts toward the cap.
                    continue
                if not logged_head:
                    log.debug("StreamReader: first bytes on wire: %s",
                            bytes((buf + chunk)[:64]).hex())
                    logged_head = True
                self.resync_count += 1
                resync_bytes += len(chunk)
                if resync_bytes > _MAX_RESYNC_BYTES:
                    # buf was already cleared/reset at the top of this
                    # iteration — the bytes worth dumping are in buf+chunk
                    # (the carryover byte, if any, plus what was just read).
                    # Raw wire content is DEBUG-only — never in the ERROR
                    # line the terminal/Activity Log surface to the user.
                    log.debug("StreamReader: discarded bytes before giving up: %s",
                            bytes((buf + chunk)[:64]).hex())
                    log.error(
                        "StreamReader: giving up after %d garbage bytes "
                        "(no 0xAA 0x88 sync marker found in stream)",
                        resync_bytes,
                    )
                    self._on_error(
                        TimeoutError("Binary stream resync failed: no sync marker")
                    )
                    return
                buf.extend(chunk)

    def _run_ascii(self, initial_bytes: bytes) -> None:
        """Parse the ASCII hex-dump live stream: fixed-width blocks of
        4-char hex words, one word per channel slot (up to 256 slots; only
        the family's real sensor_count are populated, see docs/adr/0007)."""
        log.info("StreamReader: starting ASCII hex dump stream parsing")

        valid_words = sensor_count_for(self._family_code)
        block_words = 256
        word_char_len = 4
        chars_needed = block_words * word_char_len  # total hex chars per block

        hex_chars = bytearray()
        for b in initial_bytes:
            if chr(b) in "0123456789ABCDEFabcdef":
                hex_chars.append(b)

        seq = 0
        while not self._stop.is_set():
            # Accumulate a full block of hex characters using buffered reads.
            while len(hex_chars) < chars_needed and not self._stop.is_set():
                chunk = self._read_chunk(min_bytes=chars_needed - len(hex_chars))
                for byte in chunk:
                    if chr(byte) in "0123456789ABCDEFabcdef":
                        hex_chars.append(byte)

            if self._stop.is_set():
                return

            # Parse one block's worth of words (buffered reads may overshoot
            # chars_needed — cap here so the remainder rolls into the next
            # iteration instead of being folded into this frame and lost).
            word_buffer = []
            for i in range(0, min(len(hex_chars), chars_needed), word_char_len):
                word_str = hex_chars[i:i + word_char_len]
                if len(word_str) < word_char_len:
                    break
                word_buffer.append(bytes(word_str))

            if not word_buffer:
                continue

            hex_chars = hex_chars[len(word_buffer) * word_char_len:]

            valid_hex_words = word_buffer[:valid_words]
            values = []
            for hex_val in valid_hex_words:
                try:
                    swapped = hex_val[2:4] + hex_val[0:2]
                    t = _convert_hex_to_temp(swapped.decode(), self._family_code)
                    values.append(t)
                except Exception:
                    values.append(0.0)

            try:
                frame = Frame(seq=seq, time=time.monotonic(), values=values)
                self._on_frame(frame)
                seq += 1
            except Exception as exc:
                self.error_count += 1
                if self.error_count <= 10:
                    log.warning("Error emitting ASCII frame: %s", exc)
                self._on_error(exc)

    def stop(self) -> None:
        self._stop.set()
        # Close the port out from under any blocked transport.read() call —
        # relying on _stop alone means a thread stuck in a longer-than-
        # expected read (or one the OS driver is slow to unblock) keeps the
        # port open, and a subsequent startStream() then opens a *second*
        # handle to the same port, racing the still-live old reader for
        # bytes (garbled/duplicated frames on the next Live session).
        try:
            self._transport.close()
        except Exception:
            pass
        if self._thread is not None and self._thread.is_alive():
            self._thread.join(timeout = 2.0)
            if self._thread.is_alive():
                log.error(
                    "StreamReader: worker thread still alive %.1fs after "
                    "stop() — port may not be released for the next session",
                    2.0,
                )
            self._thread = None
