// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.

// Packetized serial transport with HDLC-style framing and sliding window
//
// Frame format (before byte stuffing):
//   [0x7E] [SEQ] [TYPE] [LEN] [PAYLOAD...] [CRC_LO] [CRC_HI]
//
// The leading 0x7E is the frame flag. A trailing 0x7E closes the frame and
// also serves as the flag for the next frame (back-to-back). SEQ is an 8-bit
// sequence number that wraps naturally. TYPE identifies DATA/ACK/NAK/RST.
// LEN is the payload byte count (0-255). CRC covers SEQ through PAYLOAD.
//
// Byte stuffing (transparency):
//   0x7E -> 0x7D 0x5E   (flag byte escaped)
//   0x7D -> 0x7D 0x5D   (escape byte itself escaped)
// XOR with 0x20 is the standard HDLC transparency method.
//
// CRC-16-CCITT (polynomial 0x1021) over SEQ+TYPE+LEN+PAYLOAD.
// Initial value 0xFFFF. Stored little-endian in the frame.

#include <stdint.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <pc.h>
#include "packet.h"
#include "../rs232/rs232.h"


// ========================================================================
// Internal defines
// ========================================================================

#define FLAG_BYTE               0x7E
#define ESC_BYTE                0x7D
#define ESC_XOR                 0x20

// Frame types
#define FRAME_DATA              0x00
#define FRAME_ACK               0x01
#define FRAME_NAK               0x02
#define FRAME_RST               0x03

// RST frames carry a marker in the SEQ byte so a reset reply can be told
// apart from a reset request.  Only requests are answered -- answering
// every RST with another RST would ping-pong forever once a single RST
// frame is on the wire.
#define RST_MARKER_REQUEST      0x00
#define RST_MARKER_REPLY        0x01

// Header size: SEQ + TYPE + LEN
#define HEADER_SIZE             3
// CRC size
#define CRC_SIZE                2
// Minimum frame size (header + CRC, no payload)
#define MIN_FRAME_SIZE          (HEADER_SIZE + CRC_SIZE)
// Maximum raw frame size (header + max payload + CRC)
#define MAX_FRAME_SIZE          (HEADER_SIZE + PKT_MAX_PAYLOAD + CRC_SIZE)
// Maximum stuffed frame size (worst case: every byte stuffed + flag)
#define MAX_STUFFED_SIZE        (1 + MAX_FRAME_SIZE * 2)

// Slack beyond one max-size stuffed frame -- absorbs short bursts arriving
// between pktPoll calls so we don't drop bytes while processing the current
// frame.
#define RX_BUF_SLACK            64
// Receive buffer must hold at least one max-size stuffed frame
#define RX_BUF_SIZE             (MAX_STUFFED_SIZE + RX_BUF_SLACK)
// Scratch buffer size for draining the serial port in pktPoll
#define RX_READ_CHUNK           128

// Retransmit timeout. 500ms is conservative for a local serial link (RTT
// is < 1ms) but accounts for the remote side being busy. On a real BBS
// connection through the proxy, the round-trip includes TCP latency.
#define RETRANSMIT_TIMEOUT_MS   500

// CRC-16-CCITT initial register value (RFC 1662 / HDLC convention).
#define CRC_INIT_VALUE          0xFFFF

// Receive state machine: three states for HDLC deframing.
// HUNT: discarding bytes until a flag (0x7E) is seen  -- sync acquisition.
// ACTIVE: accumulating frame bytes, watching for flag (end of frame) or
//         escape (next byte is XOR'd).
// ESCAPE: the previous byte was 0x7D; XOR this byte with 0x20 to recover
//         the original value.
#define RX_STATE_HUNT           0
#define RX_STATE_ACTIVE         1
#define RX_STATE_ESCAPE         2


// ========================================================================
// Types
// ========================================================================

// Transmit window slot: retains a copy of sent data so we can retransmit
// on NAK or timeout without the caller keeping its buffer alive. The timer
// tracks when this slot was last (re)transmitted for timeout detection.
typedef struct {
    // Fields are ordered by alignment to minimize struct padding.
    int      len;
    clock_t  timer;
    uint8_t  seq;
    uint8_t  data[PKT_MAX_PAYLOAD];
} TxSlotT;

// Connection state. txSlots is a circular sliding window: logical slot i
// (0..txCount-1) lives at physical index (txHead + i) % PKT_MAX_WINDOW and
// holds sequence txAckSeq + i. txHead is the physical index of the oldest
// unacked frame (sequence txAckSeq). A cumulative ACK advances txHead and
// txAckSeq by the freed count, so the window slides in O(1) with no copying.
struct PktConnS {
    int              com;
    int              windowSize;
    PktRecvCallbackT callback;
    void            *callbackCtx;

    // Transmit state (Go-Back-N sender)
    uint8_t          txNextSeq;    // next sequence number to assign
    uint8_t          txAckSeq;     // oldest unacknowledged sequence
    TxSlotT          txSlots[PKT_MAX_WINDOW];
    int              txCount;      // number of slots in use
    int              txHead;       // physical index of oldest unacked slot

    // Receive state (Go-Back-N receiver: only accepts in-order frames)
    uint8_t          rxExpectSeq;  // next expected sequence number
    uint8_t          rxState;      // RX_STATE_*
    uint8_t          rxFrame[MAX_FRAME_SIZE];
    int              rxFrameLen;

};


// ========================================================================
// Open-connection registry: one slot per COM port so pktOpen can reject
// a second open on a port that already has an active connection.  Two
// packet conns on the same wire would race on the framing stream and
// produce silent corruption.
// ========================================================================

static PktConnT *sOpenConns[RS232_NUM_PORTS];


// ========================================================================
// CRC-16-CCITT lookup table
// ========================================================================

// Regenerated from the polynomial (previous table had 42 corrupted
// entries, breaking the guaranteed error-detection properties).  To
// verify: table[i ^ j] == table[i] ^ table[j] must hold for all i, j.
static const uint16_t sCrcTable[256] = {
    0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
    0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
    0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
    0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
    0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
    0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
    0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
    0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
    0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
    0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
    0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
    0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
    0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
    0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
    0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
    0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
    0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
    0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
    0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
    0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
    0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
    0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
    0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
    0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
    0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
    0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
    0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
    0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
    0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
    0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
    0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
    0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0
};


// ========================================================================
// Prototypes (alphabetical)
// ========================================================================

static int32_t   ackAdvance(PktConnT *conn, uint8_t seq);
static uint16_t  crcCalc(const uint8_t *data, int len);
bool             pktCanSend(PktConnT *conn);
void             pktClose(PktConnT *conn);
int              pktGetPending(PktConnT *conn);
PktConnT        *pktOpen(int com, int windowSize, PktRecvCallbackT callback, void *callbackCtx);
int              pktPoll(PktConnT *conn);
int              pktReset(PktConnT *conn);
int              pktSend(PktConnT *conn, const uint8_t *data, int len, bool block);
static bool      processFrame(PktConnT *conn, const uint8_t *frame, int len);
static void      retransmitCheck(PktConnT *conn);
static bool      rxProcessByte(PktConnT *conn, uint8_t byte);
static void      sendAck(PktConnT *conn, uint8_t seq);
static void      sendFrame(PktConnT *conn, uint8_t seq, uint8_t type, const uint8_t *payload, int len);
static void      sendNak(PktConnT *conn, uint8_t seq);
static void      sendRst(PktConnT *conn, uint8_t marker);
static int       seqInWindow(uint8_t seq, uint8_t base, int size);
static int       txSlotIndex(PktConnT *conn, uint8_t seq);


// Returns how many outstanding slots a cumulative ACK frees, or -1 if the
// ACK's sequence number is outside the live send window.  Wrap-safe via
// unsigned subtraction (txNextSeq - txAckSeq == txCount).
static int32_t ackAdvance(PktConnT *conn, uint8_t seq) {
    uint8_t diff = seq - conn->txAckSeq;

    if (diff > (uint8_t)conn->txCount) {
        return -1;
    }

    return diff;
}


// Table-driven CRC-16-CCITT. Processing one byte per iteration with a
// 256-entry table is ~10x faster than bit-by-bit on a 486. The table
// costs 512 bytes of .rodata  -- a worthwhile trade for a function called
// on every frame received and transmitted.
static uint16_t crcCalc(const uint8_t *data, int len) {
    uint16_t crc = CRC_INIT_VALUE;

    for (int i = 0; i < len; i++) {
        crc = (crc << 8) ^ sCrcTable[(crc >> 8) ^ data[i]];
    }

    return crc;
}


bool pktCanSend(PktConnT *conn) {
    if (!conn) {
        return false;
    }
    return conn->txCount < conn->windowSize;
}


void pktClose(PktConnT *conn) {
    if (!conn) {
        return;
    }

    if (conn->com >= 0 && conn->com < RS232_NUM_PORTS && sOpenConns[conn->com] == conn) {
        sOpenConns[conn->com] = NULL;
    }

    free(conn);
}


int pktGetPending(PktConnT *conn) {
    if (!conn) {
        return PKT_ERR_INVALID_PARAM;
    }
    return conn->txCount;
}


PktConnT *pktOpen(int com, int windowSize, PktRecvCallbackT callback, void *callbackCtx) {
    // Reject out-of-range COMs and duplicate opens.  Two packet
    // connections on the same wire would both try to frame/deframe
    // the same byte stream -- catching this here avoids a subtle
    // protocol corruption downstream.
    if (com < 0 || com >= RS232_NUM_PORTS) {
        return NULL;
    }

    if (sOpenConns[com]) {
        return NULL;
    }

    if (windowSize <= 0) {
        windowSize = PKT_DEFAULT_WINDOW;
    }
    if (windowSize > PKT_MAX_WINDOW) {
        windowSize = PKT_MAX_WINDOW;
    }

    PktConnT *conn = (PktConnT *)calloc(1, sizeof(PktConnT));

    if (!conn) {
        return NULL;
    }

    conn->com         = com;
    conn->windowSize  = windowSize;
    conn->callback    = callback;
    conn->callbackCtx = callbackCtx;
    conn->txNextSeq   = 0;
    conn->txAckSeq    = 0;
    conn->txCount     = 0;
    conn->txHead      = 0;
    conn->rxExpectSeq = 0;
    conn->rxState     = RX_STATE_HUNT;
    conn->rxFrameLen  = 0;

    sOpenConns[com] = conn;
    return conn;
}


// Main polling function  -- must be called frequently (each iteration of the
// app's main loop or event loop). It drains the serial port's RX buffer,
// feeds bytes through the deframing state machine, and checks for
// retransmit timeouts. The callback is invoked synchronously for each
// complete, verified, in-order data frame, so the caller should be prepared
// for re-entrant calls to pktSend from within the callback.
int pktPoll(PktConnT *conn) {
    char    buf[RX_READ_CHUNK];
    int     nRead;
    int     delivered = 0;

    if (!conn) {
        return PKT_ERR_INVALID_PARAM;
    }

    // Read available serial data and feed to state machine
    while ((nRead = rs232Read(conn->com, buf, sizeof(buf))) > 0) {
        for (int i = 0; i < nRead; i++) {
            if (rxProcessByte(conn, (uint8_t)buf[i])) {
                delivered++;
            }
        }
    }

    // Detect disconnected socket/port
    if (nRead < 0) {
        return PKT_ERR_DISCONNECTED;
    }

    // Check for retransmit timeouts
    retransmitCheck(conn);

    return delivered;
}


int pktReset(PktConnT *conn) {
    if (!conn) {
        return PKT_ERR_INVALID_PARAM;
    }

    conn->txNextSeq   = 0;
    conn->txAckSeq    = 0;
    conn->txCount     = 0;
    conn->txHead      = 0;
    conn->rxExpectSeq = 0;
    conn->rxState     = RX_STATE_HUNT;
    conn->rxFrameLen  = 0;

    sendRst(conn, RST_MARKER_REQUEST);

    return PKT_SUCCESS;
}


// Send a data packet. If block=true and the window is full, polls in a
// tight loop until space opens up (an ACK arrives). The data is copied
// into a retransmit slot before sending so the caller can reuse its buffer
// immediately. The window slot tracks the sequence number and timestamp
// for retransmission.
int pktSend(PktConnT *conn, const uint8_t *data, int len, bool block) {
    TxSlotT *slot;

    if (!conn) {
        return PKT_ERR_INVALID_PARAM;
    }
    if (!data || len <= 0 || len > PKT_MAX_PAYLOAD) {
        return PKT_ERR_INVALID_PARAM;
    }

    // Wait for window space if blocking
    if (block) {
        while (conn->txCount >= conn->windowSize) {
            if (pktPoll(conn) == PKT_ERR_DISCONNECTED) {
                return PKT_ERR_DISCONNECTED;
            }
        }
    } else {
        if (conn->txCount >= conn->windowSize) {
            return PKT_ERR_TX_FULL;
        }
    }

    // Store in retransmit buffer
    slot = &conn->txSlots[(conn->txHead + conn->txCount) % PKT_MAX_WINDOW];
    memcpy(slot->data, data, len);
    slot->len   = len;
    slot->seq   = conn->txNextSeq;
    slot->timer = clock();
    conn->txCount++;
    conn->txNextSeq++;

    // Transmit
    sendFrame(conn, slot->seq, FRAME_DATA, slot->data, slot->len);

    return PKT_SUCCESS;
}


// Handle a complete, de-stuffed frame. CRC is verified first; on failure,
// we NAK to request retransmission of the frame we actually expected.
//
// For DATA frames: Go-Back-N receiver logic  -- only accept if seq matches
// rxExpectSeq (strictly in-order). Out-of-order frames within the window
// trigger a NAK; duplicates and out-of-window frames are silently dropped.
//
// For ACK frames: cumulative acknowledgement. The ACK carries the next
// expected sequence number, so we free all slots up to that point.
//
// For NAK frames: the receiver wants us to retransmit from a specific
// sequence. Go-Back-N retransmits that frame AND all subsequent ones.
static bool processFrame(PktConnT *conn, const uint8_t *frame, int len) {
    uint8_t  seq;
    uint8_t  type;
    int      payloadLen;
    uint16_t rxCrc;
    uint16_t calcCrc;
    bool     delivered = false;

    if (len < MIN_FRAME_SIZE) {
        return false;
    }

    // Verify CRC
    calcCrc = crcCalc(frame, len - CRC_SIZE);
    rxCrc   = frame[len - 2] | ((uint16_t)frame[len - 1] << 8);
    if (calcCrc != rxCrc) {
        // CRC mismatch  -- request retransmit of what we expect
        sendNak(conn, conn->rxExpectSeq);
        return false;
    }

    seq        = frame[0];
    type       = frame[1];
    payloadLen = frame[2];

    // Validate payload length against actual frame size
    if (payloadLen + MIN_FRAME_SIZE != len) {
        return false;
    }

    switch (type) {
        case FRAME_DATA:
            if (seq == conn->rxExpectSeq) {
                // In-order delivery.  Copy the payload and advance the
                // receive window BEFORE invoking the callback: the callback
                // may legally re-enter pktSend/pktPoll (see pktPoll), and
                // stale RX state would deliver this same frame twice while
                // nested receive bytes overwrite rxFrame under the reader.
                uint8_t payloadCopy[PKT_MAX_PAYLOAD];

                if (payloadLen > 0) {
                    memcpy(payloadCopy, &frame[HEADER_SIZE], payloadLen);
                }
                conn->rxExpectSeq++;
                sendAck(conn, conn->rxExpectSeq);
                if (conn->callback) {
                    conn->callback(conn->callbackCtx, payloadCopy, payloadLen);
                }
                delivered = true;
            } else if (seqInWindow(seq, conn->rxExpectSeq, conn->windowSize)) {
                // Out of order but in window  -- NAK the one we want
                sendNak(conn, conn->rxExpectSeq);
            } else {
                // Duplicate (already delivered): the sender retransmitted
                // because our ACK was lost.  Re-send the cumulative ACK so
                // its window can advance -- silently discarding here would
                // stall the sender forever.
                sendAck(conn, conn->rxExpectSeq);
            }
            break;

        case FRAME_ACK: {
            // ACK carries the next expected sequence number (cumulative).
            // Free exactly the acknowledged slots; ignore out-of-window ACKs
            // (n < 0) and no-progress duplicates (n == 0).  Advancing txHead
            // by the freed count slides the circular window in O(1): logical
            // slot i still maps to sequence txAckSeq + i, the invariant that
            // txSlotIndex, the NAK loop, and pktSend all depend on.
            int32_t n = ackAdvance(conn, seq);

            if (n > 0) {
                conn->txAckSeq = (uint8_t)(conn->txAckSeq + n);
                conn->txCount -= n;
                conn->txHead = (conn->txHead + n) % PKT_MAX_WINDOW;
            }
            break;
        }

        case FRAME_NAK: {
            // Retransmit from the requested sequence
            int idx = txSlotIndex(conn, seq);
            if (idx >= 0) {
                // Retransmit this slot and all after it (go-back-N)
                for (int i = idx; i < conn->txCount; i++) {
                    TxSlotT *slot = &conn->txSlots[(conn->txHead + i) % PKT_MAX_WINDOW];
                    sendFrame(conn, slot->seq, FRAME_DATA, slot->data, slot->len);
                    slot->timer = clock();
                }
            }
            break;
        }

        case FRAME_RST:
            // Remote reset  -- clear local sequence state.  Only a reset
            // request is answered (with the reply marker); a reply is never
            // answered, so RST frames cannot ping-pong indefinitely.
            conn->txNextSeq   = 0;
            conn->txAckSeq    = 0;
            conn->txCount     = 0;
            conn->txHead      = 0;
            conn->rxExpectSeq = 0;
            if (seq == RST_MARKER_REQUEST) {
                sendRst(conn, RST_MARKER_REPLY);
            }
            break;
    }

    return delivered;
}


// Timer-based retransmission for slots that haven't been ACK'd within the
// timeout. This handles the case where an ACK or NAK was lost  -- without
// this, the connection would stall forever. Each slot is retransmitted
// independently and its timer is reset, creating exponential backoff
// behavior naturally (each retransmit resets the timer).
static void retransmitCheck(PktConnT *conn) {
    clock_t now     = clock();
    clock_t timeout = (clock_t)RETRANSMIT_TIMEOUT_MS * CLOCKS_PER_SEC / 1000;

    for (int i = 0; i < conn->txCount; i++) {
        TxSlotT *slot = &conn->txSlots[(conn->txHead + i) % PKT_MAX_WINDOW];
        if (now - slot->timer >= timeout) {
            sendFrame(conn, slot->seq, FRAME_DATA, slot->data, slot->len);
            slot->timer = now;
        }
    }
}


// Feed one byte from the serial port into the HDLC deframing state machine.
// The flag byte (0x7E) serves double duty: it ends the current frame AND
// starts the next one. This means back-to-back frames share a single flag
// byte, saving bandwidth. A frame is only processed if it meets the minimum
// size requirement (header + CRC), so spurious flags between frames are
// harmless (they just produce zero-length "frames" that are discarded).
static bool rxProcessByte(PktConnT *conn, uint8_t byte) {
    switch (conn->rxState) {
        case RX_STATE_HUNT:
            if (byte == FLAG_BYTE) {
                conn->rxState    = RX_STATE_ACTIVE;
                conn->rxFrameLen = 0;
            }
            break;

        case RX_STATE_ACTIVE:
            if (byte == FLAG_BYTE) {
                // End of frame (or start of next).  Consume the frame length
                // BEFORE processing: processFrame may run a callback that
                // re-enters pktPoll, and stale RX state would re-parse this
                // frame or discard a nested partial frame afterward.
                bool delivered = false;
                int  frameLen  = conn->rxFrameLen;

                conn->rxFrameLen = 0;

                if (frameLen >= MIN_FRAME_SIZE) {
                    delivered = processFrame(conn, conn->rxFrame, frameLen);
                }

                return delivered;
            } else if (byte == ESC_BYTE) {
                conn->rxState = RX_STATE_ESCAPE;
            } else {
                if (conn->rxFrameLen < MAX_FRAME_SIZE) {
                    conn->rxFrame[conn->rxFrameLen++] = byte;
                } else {
                    // Frame too large, discard and hunt for next flag
                    conn->rxState = RX_STATE_HUNT;
                }
            }
            break;

        case RX_STATE_ESCAPE:
            conn->rxState = RX_STATE_ACTIVE;
            byte ^= ESC_XOR;
            if (conn->rxFrameLen < MAX_FRAME_SIZE) {
                conn->rxFrame[conn->rxFrameLen++] = byte;
            } else {
                conn->rxState = RX_STATE_HUNT;
            }
            break;
    }

    return false;
}


static void sendAck(PktConnT *conn, uint8_t seq) {
    sendFrame(conn, seq, FRAME_ACK, 0, 0);
}


// Build a raw frame, compute CRC, byte-stuff it, and transmit.
// The stuffed buffer can be up to 2x the raw size (every byte might need
// escaping) plus the flags. This is stack-allocated because frames are small
// and we're not in a deeply recursive call path.
static void sendFrame(PktConnT *conn, uint8_t seq, uint8_t type, const uint8_t *payload, int len) {
    uint8_t  raw[MAX_FRAME_SIZE];
    uint8_t  stuffed[MAX_STUFFED_SIZE];
    int      rawLen;
    uint16_t crc;
    int      out;

    // Build raw frame: SEQ + TYPE + LEN + PAYLOAD
    raw[0] = seq;
    raw[1] = type;
    raw[2] = (uint8_t)len;
    if (payload && len > 0) {
        memcpy(&raw[3], payload, len);
    }
    rawLen = HEADER_SIZE + len;

    // Append CRC
    crc = crcCalc(raw, rawLen);
    raw[rawLen]     = (uint8_t)(crc & 0xFF);
    raw[rawLen + 1] = (uint8_t)((crc >> 8) & 0xFF);
    rawLen += CRC_SIZE;

    // Byte-stuff into output buffer with leading flag
    out = 0;
    stuffed[out++] = FLAG_BYTE;
    for (int i = 0; i < rawLen; i++) {
        if (raw[i] == FLAG_BYTE) {
            stuffed[out++] = ESC_BYTE;
            stuffed[out++] = FLAG_BYTE ^ ESC_XOR;
        } else if (raw[i] == ESC_BYTE) {
            stuffed[out++] = ESC_BYTE;
            stuffed[out++] = ESC_BYTE ^ ESC_XOR;
        } else {
            stuffed[out++] = raw[i];
        }
    }

    // Trailing flag to close the frame immediately
    stuffed[out++] = FLAG_BYTE;

    // Send via serial port (blocking write)
    rs232Write(conn->com, (const char *)stuffed, out);
}


static void sendNak(PktConnT *conn, uint8_t seq) {
    sendFrame(conn, seq, FRAME_NAK, 0, 0);
}


static void sendRst(PktConnT *conn, uint8_t marker) {
    sendFrame(conn, marker, FRAME_RST, 0, 0);
}


// Check if a sequence number falls within a window starting at base.
// Works correctly with 8-bit wrap-around because unsigned subtraction
// wraps mod 256  -- if seq is "ahead" of base by less than size, diff
// will be a small positive number.
static int seqInWindow(uint8_t seq, uint8_t base, int size) {
    uint8_t diff = seq - base;
    return diff < (uint8_t)size;
}


static int txSlotIndex(PktConnT *conn, uint8_t seq) {
    uint8_t diff = seq - conn->txAckSeq;
    if (diff >= (uint8_t)conn->txCount) {
        return -1;
    }
    return diff;
}
