# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Shared base for frame-oriented MeshCore transports (USB, TCP).

Frame format: start_marker(1) + length_le(2) + payload(N)
  Host→Device: 0x3C + length + payload
  Device→Host: 0x3E + length + payload
"""

import logging
import threading
from collections.abc import Callable
from enum import IntEnum, auto

from gi.repository import GLib

log = logging.getLogger(__name__)

TX_MARKER = 0x3C  # Host → Device
RX_MARKER = 0x3E  # Device → Host
HEADER_SIZE = 3
MAX_RX_PAYLOAD = 176  # accept frames up to 176 bytes (firmware 1.16+)
MAX_TX_PAYLOAD = 172  # send at most 172 bytes for backward compat with older firmware


class ConnectionState(IntEnum):
    DISCONNECTED = auto()
    SCANNING = auto()
    CONNECTING = auto()
    CONNECTED = auto()
    DISCONNECTING = auto()


class FrameTransport:
    """Base class for frame-oriented transports (USB serial, TCP).

    Subclasses must implement:
      - _write_bytes(frame: bytes)  — send raw bytes over the wire
      - _close_io()                 — close the underlying port/socket
      - _read_loop(gen: int)        — background read thread body
      - connect_device(path: str)   — establish the connection
    """

    # Subclass label for log messages
    _LABEL = "Transport"
    # Set of valid RX markers — override in TCP to also accept 0x3C
    _RX_MARKERS = frozenset((RX_MARKER,))

    def __init__(self):
        self._state = ConnectionState.DISCONNECTED
        self._connected_address: str | None = None
        self._read_thread: threading.Thread | None = None
        self._running = False
        self._rx_buffer = bytearray()
        self._connect_gen = 0

        # Callbacks — same interface as BleTransport
        self.on_state_changed: Callable | None = None
        self.on_data_received: Callable[[bytes], None] | None = None
        self.on_device_discovered: Callable | None = None
        self.on_passkey_requested: Callable | None = None

    # ─── Properties ───────────────────────────────────────────

    @property
    def state(self):
        return self._state

    @property
    def is_connected(self) -> bool:
        return self._state == ConnectionState.CONNECTED

    @property
    def connected_address(self) -> str | None:
        return self._connected_address

    @property
    def discovered_devices(self) -> list:
        return []

    # ─── State management ─────────────────────────────────────

    def _set_state(self, state):
        if self._state != state:
            old = self._state
            self._state = state
            log.info(
                f"{self._LABEL} state: {ConnectionState(old).name} -> "
                f"{ConnectionState(state).name}"
            )
            if self.on_state_changed:
                self.on_state_changed(state)

    # ─── Sending ──────────────────────────────────────────────

    def send_data(self, data: bytes):
        """Send a protocol frame wrapped in the wire framing."""
        if not self._is_io_ready() or self._state != ConnectionState.CONNECTED:
            log.warning(f"{self._LABEL}: cannot send, state={self._state}")
            return

        if len(data) > MAX_TX_PAYLOAD:
            log.error(f"Frame too large: {len(data)} > {MAX_TX_PAYLOAD}")
            return

        frame = bytearray(HEADER_SIZE + len(data))
        frame[0] = TX_MARKER
        frame[1] = len(data) & 0xFF
        frame[2] = (len(data) >> 8) & 0xFF
        frame[3:] = data

        try:
            self._write_bytes(bytes(frame))
            log.debug(f"{self._LABEL} TX [{len(data)} bytes]: {data[:20].hex()}...")
        except Exception as e:
            log.error(f"{self._LABEL} write failed: {e}")
            GLib.idle_add(self._handle_disconnect)

    # ─── Receiving ────────────────────────────────────────────

    def _process_rx_buffer(self):
        """Parse complete frames from the receive buffer."""
        while len(self._rx_buffer) >= HEADER_SIZE:
            marker = self._rx_buffer[0]
            if marker not in self._RX_MARKERS:
                idx = None
                for i, b in enumerate(self._rx_buffer):
                    if b in self._RX_MARKERS:
                        idx = i
                        break
                if idx is None:
                    self._rx_buffer.clear()
                    return
                log.debug(f"{self._LABEL}: skipping {idx} invalid bytes")
                del self._rx_buffer[:idx]
                continue

            payload_len = self._rx_buffer[1] | (self._rx_buffer[2] << 8)

            if payload_len > MAX_RX_PAYLOAD:
                log.debug(f"{self._LABEL}: invalid payload length {payload_len}, skipping")
                del self._rx_buffer[0]
                continue

            frame_size = HEADER_SIZE + payload_len
            if len(self._rx_buffer) < frame_size:
                return

            payload = bytes(self._rx_buffer[HEADER_SIZE:frame_size])
            del self._rx_buffer[:frame_size]

            log.debug(f"{self._LABEL} RX [{len(payload)} bytes]: {payload[:20].hex()}...")

            if self.on_data_received:
                GLib.idle_add(self._deliver_data, payload)

    def _deliver_data(self, data: bytes):
        """Deliver received data on the main thread."""
        if self.on_data_received:
            self.on_data_received(data)
        return False

    # ─── Disconnect handling ──────────────────────────────────

    def _handle_disconnect(self, gen: int = -1):
        """Handle unexpected disconnect. Ignore stale callbacks."""
        if gen != -1 and gen != self._connect_gen:
            log.debug(
                f"{self._LABEL}: ignoring stale disconnect " f"(gen {gen} != {self._connect_gen})"
            )
            return
        if self._state in (ConnectionState.DISCONNECTED, ConnectionState.DISCONNECTING):
            return
        log.info(f"{self._LABEL} disconnected")
        self._running = False
        self._close_io()
        self._connected_address = None
        self._set_state(ConnectionState.DISCONNECTED)

    def _start_read_thread(self):
        """Start the background read thread (call after IO is ready)."""
        self._running = True
        self._rx_buffer.clear()
        self._connect_gen += 1
        gen = self._connect_gen
        self._read_thread = threading.Thread(
            target=self._read_loop,
            args=(gen,),
            daemon=True,
        )
        self._read_thread.start()

    # ─── Stubs / no-ops ───────────────────────────────────────

    def connect_by_address(self, address: str):
        self.connect_device(address)

    def pair_device(self, device_path: str):
        self.connect_device(device_path)

    def start_scan(self):
        pass

    def stop_scan(self):
        pass

    # ─── Abstract (must be implemented by subclass) ───────────

    def _is_io_ready(self) -> bool:
        """Return True if the underlying IO object is available."""
        raise NotImplementedError

    def _write_bytes(self, frame: bytes):
        """Write raw bytes to the transport."""
        raise NotImplementedError

    def _close_io(self):
        """Close the underlying port/socket."""
        raise NotImplementedError

    def _read_loop(self, gen: int):
        """Background thread body — read data and feed _rx_buffer."""
        raise NotImplementedError

    def connect_device(self, device_path: str):
        """Establish connection to the given device/endpoint."""
        raise NotImplementedError

    def disconnect(self):
        """Gracefully disconnect."""
        raise NotImplementedError
