# -----------------------
# File: components/uart/uart_logic.py
#
# Purpose
# -------
# Unified UART logic that supports:
#   1) Command send with a protected "capture window" (using the global coordinator)
#   2) A continuous background logger (thread) that **buffers all bytes** into a
#      persistent ring and groups them into sessions (gap-based). No per-byte printing.
#
# Key Ideas
# ---------
# - We use a *global coordinator* (components.scheduler.coordinator) to mark an
#   exclusive "capture window" for the **command** UART path only.
# - The *logger* runs in its own *daemon thread* and continuously reads **unthrottled**.
#   It appends to a persistent ByteRing and closes a session after an inactivity gap.
#   UI/decoding happens elsewhere (no hex lines printed here).
#
# Tuning / Knobs
# --------------
# - self._logger_sleep_s: idle yield when no bytes are available (~20 Hz default).
# - _gap_ns (150 ms): inactivity gap to auto-close the current session.
# - ByteRing capacity (4 MiB default): bounded buffer with drop-oldest policy.
# - capture_max_ms (send_command arg): only affects the command path.

import serial
import time
import serial.tools.list_ports
import threading

from components.console.console_registry import log_main_console
from components.scheduler.timebase import now_ns
from components.uart.pgkomm import send_pgkomm2 as pg_send_pgkomm2
from components.data.db import get_uart_commands
import config.config as config
from components.data.db import ensure_telemetry_schema, insert_telemetry_rows
from components.uart.packet_detector import PacketDetector
from typing import Optional, Callable, List, Dict, Any
from components.buffer.buffer import (
    ByteRing,
)  # persistent byte ring (shared by UART/I2C)

# Global coordinator API:
# - claim_uart_capture(deadline_ns): enters exclusive capture until deadline
# - release_uart_capture(): leaves exclusive capture
# - uart_capture_active(): True if someone is currently in capture
from components.scheduler.coordinator import (
    claim_uart_capture,
    release_uart_capture,
    uart_capture_active,
)
from dataclasses import dataclass

from components.i2c.i2c_logic import I2CLogic


@dataclass
class PacketMark:
    """
    Index entry for a detected fixed-length packet in the ByteRing.

    - off_start/off_end: absolute byte offsets in the ByteRing [start, end)
      (14 bytes total for EF FE ... EE).
    - ts_end_ns: timestamp when the last byte (0xEE) was detected.
    - dropped: True if the bytes were already evicted from the ring when we tried to read them.
    """

    id: int
    off_start: int
    off_end: int
    ts_end_ns: int
    dropped: bool = False


@dataclass
class SessionHeader:
    """
    Metadata for a closed session (no bytes included here)
    - off_start/off_end are absolute offsets into the ByteRing
    - t_start_ns / t_end_ns are for diagnostics/UI only
    """

    id: int
    t_start_ns: int
    t_end_ns: int
    off_start: int
    off_end: int
    dropped_bytes_during: int = 0


@dataclass
class ClosedSession:
    """A ready-to-decode session (header + payload snapshot)."""

    header: SessionHeader
    payload: bytes


class UART_logic:
    """
    Single class for:
      - Connecting / disconnecting a UART port
      - Sending one-shot hex commands with an exclusive capture window
      - Running a continuous logger on the *same* port (optional)

    Threading model
    ---------------
    - The logger runs in a background daemon thread started by start_logger().
      It loops, checks for available bytes, and logs them.
    - The command send path (send_command) runs in the UI/main thread,
      but it briefly "claims" exclusivity via the coordinator so:
        * Other sends are rejected,
        * The logger throttles its output during the capture window.
    """

    def __init__(self):
        # pyserial handle and connection info
        self._run_session_id = now_ns()  # stable for this program run
        self._logger_run_number = 0  # increments each start_logger()
        self._logger_run_tag = None  # e.g., "log0001"

        self.serial = None
        self.port = None
        self.baudrate = None
        self._hex_echo = False  # set True only when you need to see raw hex in console
        # --- Background logger configuration/state ---
        # The logger runs in a separate daemon thread and continuously reads.
        self._logger_thread = None  # threading.Thread or None
        self._logger_running = False  # set True to keep logger loop alive
        self._logger_hex = True  # True: print hex; False: print utf-8 text
        self._logger_sleep_s = (
            0.05  # 50 ms idle sleep (~20 Hz). Tune for CPU vs responsiveness.
        )

        # --- Detected packet marks (EF FE ... 14 ... EE) anchored to ring offsets ---
        self._packet_marks: list[PacketMark] = []
        self._packet_next_id: int = 1
        self._packet_count: int = 0  # visible counter for UI/metrics
        self._on_packet_cb: Optional[Callable[[int, bytes, int], None]] = None

        # Packet detector: we keep it simple and feed from the reader loop
        self._detector = PacketDetector(on_packet=self._on_packet_detected)
        # --- Persistent logging buffer + sessionizer (for the logger thread) ---
        # We keep a bounded byte ring so we can decode/save later without flooding the UI.
        self._ring = ByteRing(capacity_bytes=4 * 1024 * 1024)  # 4 MiB (tune as needed)

        # Sessionizer (gap-based): we open a session at start_logger(), extend on bytes,
        # and auto-close after 150 ms inactivity or when forced.
        self._gap_ns = 150_000_000  # 150 ms inactivity closes a session
        self._next_session_id = 1
        self._active_header = None  # type: Optional[SessionHeader]
        self._last_rx_ns = 0  # last time we saw a byte (ns)
        self._closed_sessions = []  # queue of SessionHeader (payload copied on pop)
        self._sess_lock = threading.Lock()

        # --- I2C integration (silent, on-demand) ---
        self._i2c = None  # type: Optional[object]  # expect components.i2c.i2c_logic.I2CLogic
        self._i2c_cfg = None  # type: Optional[dict]
        self._i2c_results: List[
            Dict[str, Any]
        ] = []  # small bounded buffer for correlation/export

        self._i2c_results_max = 10000
        self._i2c_inflight = False
        self._i2c_inflight_lock = threading.Lock()

        # --- NEW: per-chunk timing references for unique per-packet timestamps ---
        self._chunk_ref_off = 0
        self._chunk_ref_t_ns = 0

        ensure_telemetry_schema()
        self.set_db_writer(insert_telemetry_rows)  # your UART_logic instance

        self.log = lambda level, msg: None  # default: drop logs until UI injects one

        # ONLY for TEST

        self._i2c = I2CLogic()

    # Inject the console logger (must be a callable: (type, message) -> None)
    def set_logger(self, log_func):
        if callable(log_func):
            self.log = log_func

    def set_db_writer(self, writer_callable):
        """
        Inject a DB writer callable. It will be called with rows like:
        {
            'session_id': str,
            'logger_session': str,
            'logger_session_number': int,
            'uart_raw': str | None,
            'i2c_raw': str | None,
        }
        """
        if callable(writer_callable):
            self._db_writer = writer_callable

    # --- Combobox helpers (static lists for UI) ---
    def get_baud_rates(self):
        return ["115200", "9600", "19200", "38400", "57600", "256000"]

    def get_data_bits(self):
        return ["8", "5", "6", "7"]

    def get_stop_bits(self):
        return ["1", "1.5", "2"]

    def get_parity(self):
        return ["Even", "None", "Odd"]

    def _on_packet_detected(
        self, _ts_ns: int, packet: bytes, abs_off_start: int
    ) -> None:
        """
        Realtime packet hook (EF FE ... 14 ... EE).
        - Stores a PacketMark pinned to ByteRing offsets
        - Immediately performs a single I²C read (inline, no worker), silent
        - Saves the I²C result in _i2c_results for later correlation
        """
        # --- derive a unique end timestamp per packet based on byte position ---
        off_start = int(abs_off_start)
        off_end = off_start + len(packet)  # expected 14

        # Compute per-packet end time from the current chunk’s base using UART bitrate.
        # Assume standard 8N1: 1 start + 8 data + 1 stop = 10 bits/byte.
        bits_per_byte = 10
        baud = int(self.baudrate or 115200)
        byte_ns = int((bits_per_byte * 1_000_000_000) // baud)

        # bytes from the beginning of this chunk to the *end* of the packet:
        delta_bytes = max(0, off_end - int(getattr(self, "_chunk_ref_off", 0)))
        base_t = int(getattr(self, "_chunk_ref_t_ns", _ts_ns or now_ns()))
        end_ts = base_t + delta_bytes * byte_ns

        # Store the mark
        mark = PacketMark(
            id=self._packet_next_id,
            off_start=off_start,
            off_end=off_end,
            ts_end_ns=end_ts,
        )
        self._packet_marks.append(mark)
        self._packet_next_id += 1
        self._packet_count += 1

        # Optional external packet callback (kept compatible)
        if self._on_packet_cb:
            try:
                self._on_packet_cb(end_ts, packet, abs_off_start)
            except Exception as e:
                self.log("warning", f"Packet handler error: {e}")

        # --- INLINE I²C READ (immediate-or-skip, no printing) ---
        if self._i2c and self._i2c_cfg:
            addr = self._i2c_cfg["addr"]
            reg = self._i2c_cfg["reg"]
            try:
                res = self._i2c.read_2_bytes(addr, reg)
            except Exception:
                res = {"status": "ERR", "addr": addr, "reg": reg}

            out = {"t_pkt_detected_ns": int(end_ts), "pkt_id": mark.id}
            if isinstance(res, dict):
                out.update(res)
            self._push_i2c_result(out)

    # Return a detailed list (not used by UI currently)
    @staticmethod
    def get_channels_with_product(self):
        ports_info = []
        for port in serial.tools.list_ports.comports():
            ports_info.append(
                {
                    "device": port.device,
                    "description": port.description,
                    "product": port.product,  # ATTRS{product}
                    "manufacturer": port.manufacturer,  # ATTRS{manufacturer}
                    "serial_number": port.serial_number,
                }
            )
        return ports_info

    # Return a simple list of user-facing labels for dropdowns
    def get_channels(self) -> list[str]:
        ports = serial.tools.list_ports.comports()
        if not ports:
            log_main_console("Error", "UART channels returned empty.")
            return []

        labels = []
        for p in ports:
            label = p.device
            if p.product:
                label += f" — {p.product}"  # Append ATTRS{product} to help identify
            labels.append(label)

        log_main_console("success", "UART channels added with product info.")
        return labels

    # Load predefined commands (from DB) for your command table (if used elsewhere)
    def get_predefined_commands(self) -> list[dict]:
        commands = get_uart_commands()
        return [
            {
                "id": id,
                "name": name,
                "description": description,
                "category": category,
                "hex_string": hex_string,
            }
            for id, name, description, category, hex_string in commands
        ]

    # ---------------------------
    # Connection lifecycle
    # ---------------------------
    def connect(
        self,
        port: str,
        baudrate: int = 115200,
        data_bits: int = 8,
        stop_bits: float = 1,
        parity: str = "N",
    ) -> bool:
        """
        Open the UART port with the given settings.
        Note: `port` may be a label like "/dev/ttyUSB0 — RedBox 1017441".
        We split on whitespace and use the first token as the actual device path.
        """
        if config.DEBUG_MODE:
            log_main_console("info", "DEBUG: fake connecting to UART")
            log_main_console(
                "success",
                f"🔗 Connected to {port} | Baud: {baudrate} | Data: {data_bits} Bits | Stop: {stop_bits} Bits | Parity: {parity}.",
            )
            return True

        try:
            # Extract the pure path '/dev/tty*' even if label contains a ' — product' tail
            port_path = str(port).split()[0]
            self.serial = serial.Serial(
                port=port_path,
                baudrate=baudrate,
                bytesize=data_bits,
                stopbits=stop_bits,
                parity=parity,  # If you pass 'N'/'E'/'O': ok. If "None"/"Even"/"Odd", consider a mapper.
                timeout=0.01,  # Short timeout keeps reads responsive/non-blocking-ish
            )
            if self.serial.is_open:
                self.port = port_path
                self.baudrate = baudrate
                log_main_console(
                    "success",
                    f"🔗 Connected to {port_path} | Baud: {baudrate} | Data: {data_bits} Bits | Stop: {stop_bits} Bits | Parity: {parity}.",
                )
                return True
        except Exception as e:
            log_main_console("error", f"❌ Connection failed: {e}")
        return False

    def disconnect(self) -> bool:
        """
        Stop logger if needed and close the port cleanly.
        Safe to call even if already disconnected.
        """
        if config.DEBUG_MODE:
            self.log("info", "DEBUG: fake Disconnecting from UART")
            return True

        # NEW: stop the logger thread first to avoid read/close races
        try:
            self.stop_logger()
        except Exception:
            pass

        if not getattr(self, "serial", None):
            self.log("info", "🔌 Already disconnected (no serial).")
            return True

        try:
            if self.serial.is_open:
                # Flush + attempt to reset buffers while still open
                try:
                    self.serial.flush()
                except Exception:
                    pass
                try:
                    self.serial.reset_input_buffer()
                    self.serial.reset_output_buffer()
                except Exception:
                    pass

                # Close the port
                try:
                    self.serial.close()
                except Exception as e:
                    self.log("error", f"❌ Close failed: {e}")
            else:
                self.log("info", "ℹ️ Port already closed.")
        except Exception as e:
            # Some drivers/platforms can throw on is_open/flush/close — keep it resilient
            self.log("warning", f"Disconnect encountered an issue: {e}")

        # Clear local state so future connects start fresh
        self.serial = None
        self.port = None
        self.baudrate = None

        self.log("info", "🔌 Disconnected")
        return True

    # ---------------------------
    # Helpers
    # ---------------------------
    def _fmt_hex_ascii(self, data: bytes) -> str:
        """Return 'HEX  |  ASCII' view for console."""
        hex_part = data.hex(" ").upper()
        ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in data)
        return f"{hex_part}    |    '{ascii_part}'"

    # ---------------------------
    # Command send + capture (ASCII + HEX logging)
    # ---------------------------
    def send_command(self, hex_command: str, capture_max_ms: int = 300):
        """
        Send a hex-encoded command and read until newline.
        Logs both HEX and printable CHARs.
        """
        if not config.DEBUG_MODE:
            if not self.serial or not self.serial.is_open:
                self.log("error", "⚠️ Not connected.")
                return

        if uart_capture_active():
            self.log("warning", "⛔ Busy: capture window active. Try again shortly.")
            return

        try:
            payload = bytes.fromhex(hex_command.strip())
        except ValueError:
            self.log("error", "❌ Invalid hex string format.")
            return

        deadline_ns = now_ns() + int(capture_max_ms) * 1_000_000
        if not claim_uart_capture(deadline_ns):
            self.log("warning", "⛔ Busy: could not acquire capture window.")
            return

        try:
            # Send
            if not config.DEBUG_MODE:
                self.serial.write(payload)
            self.log("success", f"➡️ {self._fmt_hex_ascii(payload)}")

            # Receive (newline-delimited)
            if config.DEBUG_MODE:
                time.sleep(min(0.2, capture_max_ms / 1000))
                response = b"HP 01 02 03\n"
            else:
                response = self.serial.read_until(expected=b"\n")

            if not response:
                self.log("error", "❌ No response (timeout).")
                return

            self.log("info", f"⬅️ {self._fmt_hex_ascii(response)}")

            return response

        except Exception as e:
            self.log("error", f"❌ UART send error: {e}")
        finally:
            release_uart_capture()

    # ---------------------------
    # PGKomm2 send + framed receive
    # ---------------------------
    # --- PGKomm2 send that discards the PH echo and returns only HP ---
    def send_pgkomm2(self, hex_command: str, capture_max_ms: int = 100):
        """
        Delegate to PGKomm2 module: sends TX, discards PH echo,
        returns first HP reply. Logs HEX+ASCII.
        """
        return pg_send_pgkomm2(self.serial, hex_command, self.log, capture_max_ms)

    # ---------------------------
    # Continuous Logger (thread)
    # ---------------------------
    def start_logger(self, hex_output: bool = True) -> bool:
        """
        Start the background logger thread.

        - If already running: no-op, returns True.
        - If not connected (and not in DEBUG): returns False.

        Logger behavior:
        - Continuously polls the serial port (non-blocking style) and **buffers**
        any available bytes into a persistent ByteRing.
        - Maintains a gap-based session: opens at start, extends on bytes, auto-closes
        after inactivity (default 150 ms). No per-byte console output here; decoding
        and UI happen outside the reader thread.
        """
        if self._logger_running:
            self.log("info", "ℹ️ Logger already running.")
            return True
        if not config.DEBUG_MODE and (not self.serial or not self.serial.is_open):
            self.log("error", "⚠️ Cannot start logger: not connected.")
            return False

        # Configure output style (hex vs text)
        self._logger_hex = bool(hex_output)

        # --- NEW: explicitly open a session now (even before first byte arrives) ---
        with self._sess_lock:
            # We anchor the session start at the current ring end so we have a clean
            # byte interval even if the first bytes arrive a bit later.
            _, ring_end = self._ring.logical_window()
            t0 = now_ns()
            self._active_header = SessionHeader(
                id=self._next_session_id,
                t_start_ns=t0,
                t_end_ns=t0,  # will be updated on first data and on close
                off_start=ring_end,
                off_end=ring_end,  # extended as bytes arrive
                dropped_bytes_during=0,
            )
            self._next_session_id += 1
            self._last_rx_ns = 0  # remains 0 until we see first real byte

            # Reset packet tracking for a fresh run
            self._packet_marks.clear()
            self._packet_next_id = 1
            self._packet_count = 0

        # in start_logger(), before spinning up the thread
        try:
            if self._i2c.connect(1):
                self.configure_i2c(
                    self._i2c, addr_7bit=0x40, angle_reg=0xFE, read_len=2
                )
                self.log("info", "I²C auto-initialized on /dev/i2c-1 for testing")
            else:
                self.log("warning", "I²C init failed (bus not available)")
        except Exception as e:
            self.log("error", f"I²C init exception: {e}")

        # Spin up the thread
        self._logger_running = True
        self._logger_thread = threading.Thread(
            target=self._logger_loop, name="UARTLogger", daemon=True
        )
        self._logger_thread.start()

        self.log("success", "🟢 UART logger started (session opened).")
        self._logger_run_number += 1
        self._logger_run_tag = f"log{self._logger_run_number:04d}"

        # fresh per-run buffers
        self._i2c_results.clear()
        # (Optional) if you want sessions view limited to this run:
        # self._closed_sessions.clear()

        self.log("info", f"Logger run tag: {self._logger_run_tag} ")
        return True

    def stop_logger(self) -> bool:
        """
        Stop the background logger thread if running.
        Joins with a short timeout so shutdown remains responsive.
        Prints a summary + last 10 packets with UART + I²C correlation.
        """
        if not self._logger_running:
            return True

        self._logger_running = False
        t = self._logger_thread
        self._logger_thread = None
        if t and t.is_alive():
            t.join(timeout=1.0)

        self.log("info", "🔴 UART logger stopped.")

        # --- Summary counts (no historical byte counter; use ring window size) ---
        try:
            ring_start, ring_end = self._ring.logical_window()
            uart_buffered = max(0, int(ring_end - ring_start))  # current buffered bytes
            uart_pkts = len(self._packet_marks)

            i2c_results = list(getattr(self, "_i2c_results", []))
            i2c_total = len(i2c_results)
            i2c_ok = sum(1 for r in i2c_results if r.get("status") == "OK")
            i2c_err = sum(
                1
                for r in i2c_results
                if r.get("status") in ("ERR", "ERR_NOT_CONNECTED")
            )
            i2c_skipped = sum(
                1 for r in i2c_results if r.get("status") == "SKIPPED_BUSY"
            )

            self.log(
                "info",
                f"📊 Summary — UART buffered: {uart_buffered} B | UART packets: {uart_pkts} | "
                f"I²C samples: {i2c_total} (OK {i2c_ok}, ERR {i2c_err}, SKIPPED {i2c_skipped})",
            )
        except Exception as e:
            self.log("warning", f"Summary failed: {e}")

        # stats = decode_raw_data.run(session_id=config.SESSION_NAME)
        ##self.log("success", f"Decoded session '{config.SESSION_NAME}': {stats}")
        return True

    def _logger_loop(self):
        """
        Background reader thread (runs until stop_logger() or disconnect):

        Responsibilities
        ----------------
        - Continuously poll the serial port for any available bytes.
        - Append all received bytes into the persistent ByteRing (no printing here).
        - Extend the currently active session with these bytes.
        - Detect inactivity gaps (default 150 ms) and *close* the session,
        enqueueing it for later decoding/saving.
        - On exit, finalize any open session so nothing is lost.

        Notes
        -----
        - No time-based throttling here. The reader stays hot and unthrottled.
        - No capture-window checks (command UART is separate).
        - "Information logging" means: session closed messages + error/warning events.
        We do not log per-byte/hex lines in this loop.
        """
        try:
            # --- Real hardware path ---
            while self._logger_running and self.serial and self.serial.is_open:
                try:
                    # Non-blocking peek of pending bytes.
                    to_read = self.serial.in_waiting or 0
                    if to_read == 0:
                        # No bytes available: check inactivity-based session close.
                        now_t = now_ns()
                        with self._sess_lock:
                            if self._active_header is not None and self._last_rx_ns:
                                if (now_t - self._last_rx_ns) >= self._gap_ns:
                                    self._active_header.t_end_ns = self._last_rx_ns
                                    self._closed_sessions.append(self._active_header)
                                    self.log(
                                        "info",
                                        f"📦 Session {self._active_header.id} closed after gap",
                                    )
                                    # Commit everything to the database
                                    try:
                                        written, failed = (
                                            self.flush_logger_session_to_db()
                                        )
                                        self.log(
                                            "success",
                                            f"💾 Telemetry written: {written} rows (failed {failed}) for {self._logger_run_tag}",
                                        )
                                        # optional cleanup after preview
                                        self._packet_marks.clear()
                                        self._i2c_results.clear()
                                    except Exception as e:
                                        self.log("warning", f"DB flush failed: {e}")
                                    self._active_header = None
                        # Yield CPU very briefly; keep it small for responsiveness.
                        time.sleep(self._logger_sleep_s)
                        continue

                    # Bytes available: read them all in one go.
                    data = self.serial.read(to_read)

                except Exception as e:
                    # Transient serial errors (e.g., unplug). Stay calm and retry.
                    self.log("warning", f"Logger read error: {e}")
                    time.sleep(self._logger_sleep_s)
                    continue

                if not data:
                    # Rare edge case: read() returned empty despite in_waiting>0.
                    time.sleep(self._logger_sleep_s)
                    continue

                # Append to the persistent ring; returns absolute offsets.
                a, b, dropped = self._ring.write(data)
                t = now_ns()

                # --- set per-chunk timestamp/offset for unique packet timing ---
                self._chunk_ref_off = a
                self._chunk_ref_t_ns = t

                try:
                    self._detector.feed(data, t, a)
                except Exception:
                    pass

                # Extend (or open) the current session with these bytes.
                with self._sess_lock:
                    if self._active_header is None:
                        # If for any reason no session is open (should have been opened at start),
                        # open one now so we never lose a contiguous interval.
                        self._active_header = SessionHeader(
                            id=self._next_session_id,
                            t_start_ns=t,
                            t_end_ns=t,
                            off_start=a,
                            off_end=b,
                            dropped_bytes_during=0,
                        )
                        self._next_session_id += 1
                    else:
                        self._active_header.off_end = b
                        self._active_header.t_end_ns = t
                        if dropped:
                            self._active_header.dropped_bytes_during += dropped
                    self._last_rx_ns = t

                # (No printing here; UI/decoder will consume from pop_closed_session())

        finally:
            # Finalize any open session so we don't lose the tail on stop/disconnect.
            with self._sess_lock:
                if self._active_header is not None:
                    self._active_header.t_end_ns = self._last_rx_ns or now_ns()
                    self._closed_sessions.append(self._active_header)
                    self.log(
                        "info",
                        f"📦 Session {self._active_header.id} finalized on shutdown",
                    )
                    self._active_header = None
            # Ensure the running flag is reset even if we broke out due to an error.
            self._logger_running = False

    def pop_closed_session(self) -> Optional[ClosedSession]:
        """
        Pop the oldest closed session and return (header, payload snapshot).
        Use this from your decode/save worker. Safe to call while logger runs.
        """
        with self._sess_lock:
            if not self._closed_sessions:
                return None
            header = self._closed_sessions.pop(0)
        # Copy outside the lock for minimal contention (ByteRing is internally locked)
        payload = self._ring.copy_range(header.off_start, header.off_end)
        return ClosedSession(header=header, payload=payload)

    def closed_count(self) -> int:
        with self._sess_lock:
            return len(self._closed_sessions)

    def stats_snapshot(self) -> dict:
        ring_start, ring_end = self._ring.logical_window()
        with self._sess_lock:
            active_id = self._active_header.id if self._active_header else None
            active_bytes = (
                (self._active_header.off_end - self._active_header.off_start)
                if self._active_header
                else 0
            )
            last_rx_age_ms = (
                None
                if not self._last_rx_ns
                else round((now_ns() - self._last_rx_ns) / 1_000_000)
            )
            closed_ready = len(self._closed_sessions)
        ring_stats = self._ring.stats_snapshot()
        return {
            "port": self.port,
            "baudrate": self.baudrate,
            "ring": ring_stats,
            "window_start_off": ring_start,
            "window_end_off": ring_end,
            "active_session_id": active_id,
            "active_session_bytes": active_bytes,
            "last_rx_age_ms": last_rx_age_ms,
            "closed_ready": closed_ready,
        }

    # ---------------------------
    # I²C configuration + trigger (silent)
    # ---------------------------
    def configure_i2c(
        self,
        i2c_obj,
        *,
        bus_id: int = 1,
        addr_7bit: int = 0x00,
        angle_reg: int = 0x00,
        read_len: int = 2,
    ) -> None:
        """
        Stash the I²C handle and constants for on-demand angle sampling.
        This does NOT connect/disconnect I²C. No logging.
        """
        self._i2c = i2c_obj
        self._i2c_cfg = {
            "bus_id": int(bus_id),
            "addr": int(addr_7bit) & 0x7F,
            "reg": int(angle_reg) & 0xFF,
            "len": int(read_len),
        }

    def _push_i2c_result(self, item: Dict[str, Any]) -> None:
        buf = self._i2c_results
        buf.append(item)
        # bounded buffer (drop oldest)
        if len(buf) > self._i2c_results_max:
            del buf[: len(buf) - self._i2c_results_max]

    def i2c_get_angle(
        self, t_pkt_detected_ns: int, pkt_id: Optional[int] = None
    ) -> None:
        if not self._i2c or not self._i2c_cfg:
            return

        with self._i2c_inflight_lock:
            if self._i2c_inflight:
                return
            self._i2c_inflight = True

        addr = self._i2c_cfg["addr"]
        reg = self._i2c_cfg["reg"]

        def _worker():
            try:
                try:
                    res = self._i2c.read_2_bytes(addr, reg)
                except Exception:
                    res = {"status": "ERR", "addr": addr, "reg": reg}
                out = {"t_pkt_detected_ns": int(t_pkt_detected_ns), "pkt_id": pkt_id}
                if isinstance(res, dict):
                    out.update(res)
                self._push_i2c_result(out)
            finally:
                with self._i2c_inflight_lock:
                    self._i2c_inflight = False

        threading.Thread(target=_worker, name="I2CProbeOnce", daemon=True).start()

    def flush_logger_session_to_db(self) -> tuple[int, int]:
        """
        Persist all packets detected in the current logger session using one bulk insert.
        Returns (written_count, failed_count).
        """
        if not getattr(self, "_db_writer", None):
            return (0, 0)

        # Map pkt_id -> latest i2c sample
        i2c_by_id = {}
        for res in self._i2c_results:
            pid = res.get("pkt_id")
            if pid is not None:
                i2c_by_id[pid] = res

        rows = []
        for mark in self._packet_marks:
            # UART 14B payload snapshot (may be evicted)
            try:
                payload = self._ring.copy_range(mark.off_start, mark.off_end)
                uart_hex = payload.hex(" ").upper()
            except Exception:
                uart_hex = None

            # I2C 2B raw (only when OK)
            i2c_hex = None
            i2c_res = i2c_by_id.get(mark.id)
            if isinstance(i2c_res, dict) and i2c_res.get("status") == "OK":
                b = i2c_res.get("bytes") or []
                if isinstance(b, (list, tuple)) and len(b) == 2:
                    i2c_hex = f"{int(b[0]) & 0xFF:02X} {int(b[1]) & 0xFF:02X}"

            rows.append(
                {
                    "session_id": config.SESSION_NAME,
                    "logger_session": config.CURRENT_COMMAND,
                    "logger_session_number": int(self._next_session_id - 1),
                    "t_ns": int(mark.ts_end_ns),  # NEW: packet timestamp (ns)
                    "uart_raw": uart_hex,
                    "i2c_raw": i2c_hex,
                }
            )

        try:
            written = self._db_writer(rows)  # bulk insert once
            failed = max(0, len(rows) - int(written or 0))
            return (int(written or 0), failed)
        except Exception as e:
            self.log("warning", f"DB bulk write failed: {e}")
            return (0, len(rows))
