#define _POSIX_C_SOURCE 200809L

/*
 * MCU structured test layout
 *
 * Layer 1: protocol primitives
 *   Build/decode frames, stream parser, sequence rules, protocol state machine.
 *
 * Layer 2: live transport
 *   Serial port I/O, reader thread, event queue, setup/ack wait helpers.
 *
 * Layer 3: scenario catalog
 *   Smoke/full live scenarios that reuse the same transport helpers.
 *
 * Layer 4: protocol self-tests
 *   Offline checks that validate the protocol layer before touching real UART.
 *
 * Layer 5: CLI entry
 *   Select mode, run self-tests first, then optionally run live suites.
 */

#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>

#define DEFAULT_SERIAL_PORT "/dev/ttyS7"
#define DEFAULT_BAUDRATE 460800
#define DEFAULT_UPGRADE_BIN_PATH "/data/local/tmp/mcu_app.bin"
#define DEFAULT_UPGRADE_PROJECT "10040"

#define HB_INTERVAL_MS 2000
#define SETUP_TIMEOUT_MS 3000
#define ACK_TIMEOUT_MS 2500
#define UPGRADE_MESSAGE_TIMEOUT_MS 5000
#define UPGRADE_REQUEST_SETTLE_MS 100
#define UPGRADE_CHUNK_SETTLE_MS 200
/* Keep each 1KB upgrade frame under the MCU's 100ms reassembly timeout while
 * avoiding long bursts that can overrun its poll-based single-byte RX loop. */
#define UPGRADE_UART_WRITE_CHUNK 128
#define UPGRADE_UART_WRITE_GAP_MS 1
#define SUITE_CLOSE_QUIET_MS 1000
#define SUITE_CLOSE_MAX_WAIT_MS 3000
#define SUITE_CLOSE_SETTLE_MS 1000
#define UPGRADE_DATA_ACK_RETRY 3
#define UPGRADE_DATA_RETRY_DELAY_MS 30
#define SETUP_RETRY 2
#define MAX_RETRY 2

#define MAX_FRAME_LEN 2048
#define MAX_PAYLOAD_LEN (MAX_FRAME_LEN - 7)
#define PARSER_BUFFER_LEN (MAX_FRAME_LEN * 2)
#define EVENT_QUEUE_CAPACITY 128

#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))

enum {
    FT_SETUP = 0x01,
    FT_ACK = 0x02,
    FT_SETUPACK = 0x03,
    FT_CLOSE = 0x04,
    FT_HB = 0x05,
    FT_DATA = 0x06,
    FT_TRANSFER = 0x07,
    FT_BOOT = 0x08,
};

enum {
    MT_PUBLIC = 0x01,
    MT_RADIO = 0x05,
    MT_UPDATE = 0x07,
};

enum {
    UPDATE_M2A_UPDATE_ACK = 0x01,
    UPDATE_M2A_UPDATE_LEN = 0x02,
    UPDATE_M2A_UPDATE_IND_AQUIRE = 0x03,
    UPDATE_A2M_UPDATE_REQ = 0x80,
    UPDATE_A2M_UPDATE_BEGIN = 0x81,
    UPDATE_A2M_UPDATE_DATA = 0x82,
    UPDATE_A2M_UPDATE_END = 0x83,
};

enum {
    UPDATE_STATE_MCU_UPGRADE_REQ = 0x04,
};

enum {
    ACK_UPDATE_NONE = 0x00,
    ACK_UPDATE_REJECT = 0x01,
    ACK_UPDATE_REQUEST = 0x02,
    ACK_UPDATE_SUCCESS = 0x03,
    ACK_UPDATE_FAIL = 0x04,
    ACK_UPDATE_RX_LAST_FRAME_OK = 0x05,
};

enum {
    PUBLIC_STATUS_LINE_DETECT = 0x00,
    PUBLIC_STATUS_MCU_VERSION = 0x01,
    PUBLIC_STATUS_TIME_INFO = 0x02,
    PUBLIC_STATUS_SYS_STATUS = 0x03,
    PUBLIC_STATUS_MCU_HARD_VERSION = 0x10,
    PUBLIC_STATUS_MCU_TEMP = 0x12,
};

enum {
    RADIO_BAND_FM = 0x00,
    RADIO_FREQ_87_5_HIGH = 0x22,
    RADIO_FREQ_87_5_LOW = 0x2E,
    RADIO_AM_STEP = 0x09,
    RADIO_FM_STEP = 0x0A,
    RADIO_M2A_FREQ = 0x01,
    RADIO_M2A_PS_PTY = 0x02,
    RADIO_M2A_RT = 0x03,
    RADIO_M2A_TA_PTY_TIPS = 0x04,
    RADIO_M2A_WORK_STATUS = 0x05,
    RADIO_REG_MUTE_OPEN = 0x00,
    RADIO_REG_MUTE_CLOSE = 0x01,
    RADIO_SEEK_STOP = 0x00,
    RADIO_SEEK_AMS = 0x01,
    RADIO_SEEK_FORWARD = 0x02,
    RADIO_SEEK_BACKWARD = 0x03,
};

typedef enum {
    LINK_IDLE = 0,
    LINK_SETUP_SENT,
    LINK_LINKED,
} LinkState;

typedef enum {
    MODE_LIVE_SMOKE = 0,
    MODE_LIVE_FULL,
    MODE_LIVE_RADIO_SEEK,
    MODE_LIVE_UPGRADE,
    MODE_LIVE_LINK_ONLY,
    MODE_SELFTEST_ONLY,
} RunMode;

/* Fully decoded frame. raw[] is preserved so verbose logging can print exactly
 * what arrived on the wire, while payload[] is convenient for assertions. */
typedef struct {
    uint8_t raw[MAX_FRAME_LEN];
    size_t raw_len;
    uint8_t seq;
    uint8_t ft;
    uint8_t payload[MAX_PAYLOAD_LEN];
    size_t payload_len;
} McuFrame;

typedef struct {
    uint8_t buffer[PARSER_BUFFER_LEN];
    size_t len;
} FrameParser;

/* Minimal protocol state mirrored from the MCU link design: one receive serial
 * number, one send serial number, and the current link phase. */
typedef struct {
    LinkState link_state;
    uint8_t recv_seq;
    uint8_t send_seq;
} ProtocolState;

/* Side effects derived from processing a received frame. The live layer uses
 * this to decide whether it should immediately send ACK, mark link up/down, or
 * keep waiting for a matching response. */
typedef struct {
    bool matched_ack;
    uint8_t ack_seq;
    bool got_setupack;
    uint8_t setupack_seq;
    bool send_ack;
    bool link_up;
    bool link_down;
    bool duplicate_rx;
} ProtocolOutcome;

typedef struct {
    McuFrame frame;
    uint64_t timestamp_ms;
} FrameEvent;

/* Reader thread pushes decoded frames into a bounded queue. Wait helpers pop
 * from the queue so business flow can block on "next ACK" or "next SetupAck"
 * without re-parsing UART bytes. */
typedef struct {
    FrameEvent items[EVENT_QUEUE_CAPACITY];
    size_t head;
    size_t tail;
    size_t count;
    pthread_mutex_t mutex;
} EventQueue;

/* Runtime context for live serial tests. It owns UART, parser, protocol state,
 * and the reader thread that continuously turns bytes into events. */
typedef struct {
    int fd;
    bool running;
    bool verbose_hex;
    bool suppress_rx_ack;
    int baudrate;
    const char *port;
    pthread_t rx_thread;
    pthread_t hb_thread;
    pthread_mutex_t lock;
    FrameParser parser;
    ProtocolState state;
    EventQueue events;
    size_t rx_raw_bytes;
    uint64_t last_rx_frame_ms;
    uint64_t last_hb_tx_ms;
    bool hb_thread_started;
    bool tx_heartbeat_enabled;
} LiveContext;

typedef struct {
    RunMode mode;
    const char *port;
    int baudrate;
    const char *upgrade_bin_path;
    const char *upgrade_project;
    bool verbose_hex;
} RunConfig;

typedef size_t (*PayloadBuilderFn)(uint8_t *payload, size_t capacity);

/* One live test case. Most scenarios are "send one frame, wait for ACK, maybe
 * observe a short follow-up window for MCU reports". */
typedef struct {
    const char *id;
    const char *name;
    uint8_t ft;
    uint8_t payload[32];
    size_t payload_len;
    PayloadBuilderFn payload_builder;
    int ack_timeout_ms;
    int retries;
    int observe_ms_after_ack;
    int min_data_frames_after_ack;
    bool detailed_follow_up_log;
} LiveScenario;

typedef struct {
    int total;
    int passed;
    int failed;
} SuiteSummary;

typedef struct {
    int total_frames;
    int radio_frames;
    int other_frames;
} FollowUpSummary;

typedef struct {
    uint8_t *data;
    size_t size;
    uint16_t crc16;
} UpgradeImage;

typedef bool (*SelfTestFn)(char *detail, size_t detail_cap);

typedef struct {
    const char *id;
    const char *name;
    SelfTestFn fn;
} SelfTestCase;

typedef bool (*FrameConsumer)(void *opaque, const McuFrame *frame);

/* -------------------------------------------------------------------------- */
/* Layer 1: protocol primitives                                               */
/* -------------------------------------------------------------------------- */

static uint64_t now_ms(void)
{
    struct timespec ts;

    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL;
}

static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
static bool g_stdout_line_start = true;
static bool g_stderr_line_start = true;
static bool g_other_line_start = true;

static bool *log_line_start_flag(FILE *stream)
{
    if (stream == stderr) {
        return &g_stderr_line_start;
    }
    if (stream == stdout) {
        return &g_stdout_line_start;
    }
    return &g_other_line_start;
}

static void log_build_timestamp(char *buffer, size_t capacity)
{
    struct timespec ts;
    struct tm tm_now;
    time_t seconds;

    clock_gettime(CLOCK_REALTIME, &ts);
    seconds = ts.tv_sec;
    localtime_r(&seconds, &tm_now);
    snprintf(buffer, capacity, "[%04d-%02d-%02d %02d:%02d:%02d.%03ld] ",
        tm_now.tm_year + 1900,
        tm_now.tm_mon + 1,
        tm_now.tm_mday,
        tm_now.tm_hour,
        tm_now.tm_min,
        tm_now.tm_sec,
        ts.tv_nsec / 1000000L);
}

static void log_emit_with_timestamp_locked(FILE *stream, bool *line_start,
    const char *message)
{
    const char *cursor = message;

    if (message == NULL || message[0] == '\0') {
        return;
    }

    while (*cursor != '\0') {
        const char *newline = strchr(cursor, '\n');

        if (*line_start && *cursor != '\n') {
            char timestamp[32];

            log_build_timestamp(timestamp, sizeof(timestamp));
            fputs(timestamp, stream);
            *line_start = false;
        }

        if (newline == NULL) {
            fputs(cursor, stream);
            *line_start = false;
            return;
        }

        fwrite(cursor, 1, (size_t)(newline - cursor + 1), stream);
        *line_start = true;
        cursor = newline + 1;
    }
}

static int log_vfprintf(FILE *stream, const char *format, va_list args)
{
    char stack_buffer[256];
    char *message = stack_buffer;
    size_t message_capacity = sizeof(stack_buffer);
    bool *line_start;
    va_list args_copy;
    int needed;

    va_copy(args_copy, args);
    needed = vsnprintf(stack_buffer, sizeof(stack_buffer), format, args_copy);
    va_end(args_copy);
    if (needed < 0) {
        return needed;
    }

    if ((size_t)needed >= sizeof(stack_buffer)) {
        message_capacity = (size_t)needed + 1U;
        message = (char *)malloc(message_capacity);
        if (message == NULL) {
            return -1;
        }

        va_copy(args_copy, args);
        (void)vsnprintf(message, message_capacity, format, args_copy);
        va_end(args_copy);
    }

    line_start = log_line_start_flag(stream);
    pthread_mutex_lock(&g_log_lock);
    log_emit_with_timestamp_locked(stream, line_start, message);
    pthread_mutex_unlock(&g_log_lock);

    if (message != stack_buffer) {
        free(message);
    }
    return needed;
}

static int log_printf(const char *format, ...)
{
    va_list args;
    int written;

    va_start(args, format);
    written = log_vfprintf(stdout, format, args);
    va_end(args);
    return written;
}

static int log_fprintf(FILE *stream, const char *format, ...)
{
    va_list args;
    int written;

    va_start(args, format);
    written = log_vfprintf(stream, format, args);
    va_end(args);
    return written;
}

static void log_perror(const char *label)
{
    int saved_errno = errno;

    log_fprintf(stderr, "%s: %s\n", label, strerror(saved_errno));
}

#define printf log_printf
#define fprintf log_fprintf
#define perror log_perror

static void sleep_ms(int delay_ms)
{
    struct timespec ts;

    ts.tv_sec = delay_ms / 1000;
    ts.tv_nsec = (long)(delay_ms % 1000) * 1000000L;
    nanosleep(&ts, NULL);
}

static uint8_t seq_next(uint8_t seq)
{
    seq = (uint8_t)(seq + 1U);
    if (seq == 0U) {
        seq = 1U;
    }
    return seq;
}

static uint8_t calc_checksum(const uint8_t *data, size_t len)
{
    uint16_t sum = 0;
    size_t i;

    for (i = 0; i < len; ++i) {
        sum = (uint16_t)(sum + data[i]);
    }
    return (uint8_t)(~(sum & 0xFFU));
}

static const char *frame_type_name(uint8_t ft)
{
    switch (ft) {
    case FT_SETUP:
        return "SETUP";
    case FT_ACK:
        return "ACK";
    case FT_SETUPACK:
        return "SETUPACK";
    case FT_CLOSE:
        return "CLOSE";
    case FT_HB:
        return "HB";
    case FT_DATA:
        return "DATA";
    case FT_TRANSFER:
        return "TRANSFER";
    case FT_BOOT:
        return "BOOT";
    default:
        return "UNKNOWN";
    }
}

static const char *upgrade_cmd_name(uint8_t cmd)
{
    switch (cmd) {
    case UPDATE_M2A_UPDATE_ACK:
        return "UPDATE_ACK";
    case UPDATE_M2A_UPDATE_LEN:
        return "UPDATE_LEN";
    case UPDATE_M2A_UPDATE_IND_AQUIRE:
        return "UPDATE_IND_AQUIRE";
    case UPDATE_A2M_UPDATE_REQ:
        return "UPDATE_REQ";
    case UPDATE_A2M_UPDATE_BEGIN:
        return "UPDATE_BEGIN";
    case UPDATE_A2M_UPDATE_DATA:
        return "UPDATE_DATA";
    case UPDATE_A2M_UPDATE_END:
        return "UPDATE_END";
    default:
        return "UNKNOWN";
    }
}

static const char *upgrade_ack_name(uint8_t ack_type)
{
    switch (ack_type) {
    case ACK_UPDATE_NONE:
        return "NONE";
    case ACK_UPDATE_REJECT:
        return "REJECT";
    case ACK_UPDATE_REQUEST:
        return "REQUEST";
    case ACK_UPDATE_SUCCESS:
        return "SUCCESS";
    case ACK_UPDATE_FAIL:
        return "FAIL";
    case ACK_UPDATE_RX_LAST_FRAME_OK:
        return "RX_LAST_FRAME_OK";
    default:
        return "UNKNOWN";
    }
}

static const char *radio_band_name(uint8_t band)
{
    return ((band & 0x0FU) == 0U) ? "FM" : "AM";
}

static const char *radio_seek_name(uint8_t seek_type)
{
    switch (seek_type & 0x0FU) {
    case RADIO_SEEK_STOP:
        return "STOP";
    case RADIO_SEEK_AMS:
        return "AMS";
    case RADIO_SEEK_FORWARD:
        return "FORWARD";
    case RADIO_SEEK_BACKWARD:
        return "BACKWARD";
    case 0x04U:
        return "PTY";
    case 0x05U:
        return "TA";
    default:
        return "UNKNOWN";
    }
}

static void format_ascii_bytes(char *out, size_t out_cap, const uint8_t *data, size_t len)
{
    size_t i;
    size_t copy_len = 0U;

    if (out_cap == 0U) {
        return;
    }

    while (copy_len < len && copy_len + 1U < out_cap) {
        uint8_t ch = data[copy_len];

        out[copy_len] = (ch >= 32U && ch <= 126U) ? (char)ch : '.';
        ++copy_len;
    }
    out[copy_len] = '\0';

    for (i = copy_len; i > 0U; --i) {
        if (out[i - 1U] != ' ' && out[i - 1U] != '.' && out[i - 1U] != '\0') {
            break;
        }
        out[i - 1U] = '\0';
    }
}

static void print_radio_freq_value(uint8_t band, uint16_t freq)
{
    if ((band & 0x0FU) == 0U) {
        printf("%u.%02uMHz", freq / 100U, freq % 100U);
    } else {
        printf("%ukHz", freq);
    }
}

static void log_radio_follow_up_frame(const McuFrame *frame)
{
    uint8_t msg_type;

    if (frame->payload_len < 2U || frame->payload[0] != MT_RADIO) {
        printf("[OBS] ft=%s len=%u module=0x%02X\n",
            frame_type_name(frame->ft), (unsigned)frame->payload_len,
            frame->payload_len > 0U ? frame->payload[0] : 0U);
        return;
    }

    msg_type = frame->payload[1];
    switch (msg_type) {
    case RADIO_M2A_FREQ:
        if (frame->payload_len >= 9U) {
            uint16_t freq = (uint16_t)(((uint16_t)frame->payload[2] << 8) | frame->payload[3]);
            uint8_t band = frame->payload[4];
            uint8_t seek = frame->payload[5];
            uint8_t status = frame->payload[6];

            printf("[OBS][RADIO][FREQ] ft=%s band=%s freq=",
                frame_type_name(frame->ft), radio_band_name(band));
            print_radio_freq_value(band, freq);
            printf(" seek=%s valid=%u switching=%u signal=%u pty=%u"
                   " sw{TA=%u AF=%u ST=%u LOCAL=%u} flags{ST=%u TP=%u TA=%u AF_BLINK=%u}\n",
                radio_seek_name(seek),
                (unsigned)((status >> 7) & 0x01U),
                (unsigned)((status >> 4) & 0x01U),
                (unsigned)frame->payload[8],
                (unsigned)frame->payload[7],
                (unsigned)((seek >> 4) & 0x01U),
                (unsigned)((seek >> 5) & 0x01U),
                (unsigned)((seek >> 6) & 0x01U),
                (unsigned)((seek >> 7) & 0x01U),
                (unsigned)(status & 0x01U),
                (unsigned)((status >> 1) & 0x01U),
                (unsigned)((status >> 2) & 0x01U),
                (unsigned)((status >> 3) & 0x01U));
        } else {
            printf("[OBS][RADIO][FREQ] ft=%s invalid_len=%u\n",
                frame_type_name(frame->ft), (unsigned)frame->payload_len);
        }
        break;

    case RADIO_M2A_PS_PTY:
        if (frame->payload_len >= 11U) {
            char ps[9];

            format_ascii_bytes(ps, sizeof(ps), frame->payload + 3, 8U);
            printf("[OBS][RADIO][PS] ft=%s pty=%u ps=\"%s\"\n",
                frame_type_name(frame->ft), (unsigned)frame->payload[2], ps);
        } else {
            printf("[OBS][RADIO][PS] ft=%s invalid_len=%u\n",
                frame_type_name(frame->ft), (unsigned)frame->payload_len);
        }
        break;

    case RADIO_M2A_RT:
        if (frame->payload_len >= 3U) {
            char rt[65];

            format_ascii_bytes(rt, sizeof(rt), frame->payload + 2, frame->payload_len - 2U);
            printf("[OBS][RADIO][RT] ft=%s text=\"%s\"\n",
                frame_type_name(frame->ft), rt);
        } else {
            printf("[OBS][RADIO][RT] ft=%s invalid_len=%u\n",
                frame_type_name(frame->ft), (unsigned)frame->payload_len);
        }
        break;

    case RADIO_M2A_TA_PTY_TIPS:
        if (frame->payload_len >= 3U) {
            uint8_t tip = frame->payload[2];
            const char *mode = ((tip >> 4) & 0x0FU) == 0U ? "TA" : "PTY";
            const char *value_name = "UNKNOWN";

            switch (tip & 0x0FU) {
            case 0x00U:
                value_name = "CLEAR";
                break;
            case 0x01U:
                value_name = "NO";
                break;
            case 0x02U:
                value_name = "SEEK";
                break;
            default:
                break;
            }

            printf("[OBS][RADIO][TIP] ft=%s mode=%s value=%s(raw=0x%02X)\n",
                frame_type_name(frame->ft), mode, value_name, tip);
        } else {
            printf("[OBS][RADIO][TIP] ft=%s invalid_len=%u\n",
                frame_type_name(frame->ft), (unsigned)frame->payload_len);
        }
        break;

    case RADIO_M2A_WORK_STATUS:
        if (frame->payload_len >= 3U) {
            printf("[OBS][RADIO][STATUS] ft=%s work_error=%u raw=0x%02X\n",
                frame_type_name(frame->ft),
                (unsigned)(frame->payload[2] & 0x01U),
                frame->payload[2]);
        } else {
            printf("[OBS][RADIO][STATUS] ft=%s invalid_len=%u\n",
                frame_type_name(frame->ft), (unsigned)frame->payload_len);
        }
        break;

    default:
        printf("[OBS][RADIO] ft=%s msg=0x%02X len=%u\n",
            frame_type_name(frame->ft), msg_type, (unsigned)frame->payload_len);
        break;
    }
}

static void print_hex_line(const char *prefix, const uint8_t *data, size_t len)
{
    size_t i;

    printf("%s", prefix);
    for (i = 0; i < len; ++i) {
        printf("%02X ", data[i]);
    }
    printf("\n");
}

static bool is_power_of_two_u16(uint16_t value)
{
    return value != 0U && (value & (uint16_t)(value - 1U)) == 0U;
}

static bool fill_upgrade_project_bytes(const char *project_text, uint8_t project_bytes[5])
{
    size_t project_len;

    if (project_text == NULL) {
        return false;
    }

    project_len = strlen(project_text);
    if (project_len == 0U || project_len > 5U) {
        return false;
    }

    memset(project_bytes, 0, 5U);
    memcpy(project_bytes, project_text, project_len);
    return true;
}

static uint16_t crc16_ccitt_update(uint16_t crc, const uint8_t *data, size_t len)
{
    size_t i;

    for (i = 0U; i < len; ++i) {
        uint8_t bit;

        crc ^= (uint16_t)((uint16_t)data[i] << 8);
        for (bit = 0U; bit < 8U; ++bit) {
            if ((crc & 0x8000U) != 0U) {
                crc = (uint16_t)((crc << 1) ^ 0x1021U);
            } else {
                crc <<= 1;
            }
        }
    }

    return crc;
}

static void upgrade_image_free(UpgradeImage *image)
{
    if (image->data != NULL) {
        free(image->data);
        image->data = NULL;
    }
    image->size = 0U;
    image->crc16 = 0U;
}

static bool upgrade_image_load(const char *path, UpgradeImage *image)
{
    FILE *fp;
    long file_size;
    size_t bytes_read;
    uint8_t *buffer;

    memset(image, 0, sizeof(*image));
    fp = fopen(path, "rb");
    if (fp == NULL) {
        fprintf(stderr, "[FAIL] upgrade.image open %s: %s\n", path, strerror(errno));
        return false;
    }

    if (fseek(fp, 0L, SEEK_END) != 0) {
        fprintf(stderr, "[FAIL] upgrade.image seek-end %s: %s\n", path, strerror(errno));
        fclose(fp);
        return false;
    }

    file_size = ftell(fp);
    if (file_size <= 0L) {
        fprintf(stderr, "[FAIL] upgrade.image invalid size for %s\n", path);
        fclose(fp);
        return false;
    }
    if ((unsigned long)file_size > 0xFFFFFFFFUL) {
        fprintf(stderr, "[FAIL] upgrade.image too large for protocol length field: %s\n", path);
        fclose(fp);
        return false;
    }
    if (fseek(fp, 0L, SEEK_SET) != 0) {
        fprintf(stderr, "[FAIL] upgrade.image seek-set %s: %s\n", path, strerror(errno));
        fclose(fp);
        return false;
    }

    buffer = (uint8_t *)malloc((size_t)file_size);
    if (buffer == NULL) {
        fprintf(stderr, "[FAIL] upgrade.image malloc %ld bytes failed\n", file_size);
        fclose(fp);
        return false;
    }

    bytes_read = fread(buffer, 1U, (size_t)file_size, fp);
    fclose(fp);
    if (bytes_read != (size_t)file_size) {
        fprintf(stderr, "[FAIL] upgrade.image short read: expected %ld got %lu\n",
            file_size, (unsigned long)bytes_read);
        free(buffer);
        return false;
    }

    image->data = buffer;
    image->size = (size_t)file_size;
    image->crc16 = crc16_ccitt_update(0U, buffer, image->size);
    return true;
}

static size_t build_upgrade_request_payload(uint8_t *payload, size_t capacity,
    const char *project, uint32_t bin_size)
{
    uint8_t project_bytes[5];

    if (capacity < 12U || !fill_upgrade_project_bytes(project, project_bytes)) {
        return 0U;
    }

    payload[0] = MT_UPDATE;
    payload[1] = UPDATE_A2M_UPDATE_REQ;
    payload[2] = UPDATE_STATE_MCU_UPGRADE_REQ;
    memcpy(payload + 3U, project_bytes, sizeof(project_bytes));
    payload[8] = (uint8_t)((bin_size >> 24) & 0xFFU);
    payload[9] = (uint8_t)((bin_size >> 16) & 0xFFU);
    payload[10] = (uint8_t)((bin_size >> 8) & 0xFFU);
    payload[11] = (uint8_t)(bin_size & 0xFFU);
    return 12U;
}

static size_t build_upgrade_begin_payload(uint8_t *payload, size_t capacity,
    const char *project, uint32_t bin_size)
{
    uint8_t project_bytes[5];

    if (capacity < 11U || !fill_upgrade_project_bytes(project, project_bytes)) {
        return 0U;
    }

    payload[0] = MT_UPDATE;
    payload[1] = UPDATE_A2M_UPDATE_BEGIN;
    payload[2] = (uint8_t)((bin_size >> 24) & 0xFFU);
    payload[3] = (uint8_t)((bin_size >> 16) & 0xFFU);
    payload[4] = (uint8_t)((bin_size >> 8) & 0xFFU);
    payload[5] = (uint8_t)(bin_size & 0xFFU);
    memcpy(payload + 6U, project_bytes, sizeof(project_bytes));
    return 11U;
}

static size_t build_upgrade_data_payload(uint8_t *payload, size_t capacity,
    uint16_t index, const uint8_t *chunk, size_t chunk_len)
{
    if (capacity < chunk_len + 4U) {
        return 0U;
    }

    payload[0] = MT_UPDATE;
    payload[1] = UPDATE_A2M_UPDATE_DATA;
    payload[2] = (uint8_t)((index >> 8) & 0xFFU);
    payload[3] = (uint8_t)(index & 0xFFU);
    if (chunk_len > 0U) {
        memcpy(payload + 4U, chunk, chunk_len);
    }
    return chunk_len + 4U;
}

static size_t build_upgrade_end_payload(uint8_t *payload, size_t capacity, uint16_t crc16)
{
    if (capacity < 4U) {
        return 0U;
    }

    payload[0] = MT_UPDATE;
    payload[1] = UPDATE_A2M_UPDATE_END;
    payload[2] = (uint8_t)((crc16 >> 8) & 0xFFU);
    payload[3] = (uint8_t)(crc16 & 0xFFU);
    return 4U;
}

static void log_upgrade_follow_up_frame(const McuFrame *frame)
{
    if (frame->payload_len < 2U || frame->payload[0] != MT_UPDATE) {
        printf("[UPGRADE][OBS] ft=%s len=%u module=0x%02X\n",
            frame_type_name(frame->ft), (unsigned)frame->payload_len,
            frame->payload_len > 0U ? frame->payload[0] : 0U);
        return;
    }

    switch (frame->payload[1]) {
    case UPDATE_M2A_UPDATE_ACK:
        if (frame->payload_len >= 3U) {
            printf("[UPGRADE][OBS] %s ack=%s(0x%02X)\n",
                upgrade_cmd_name(frame->payload[1]),
                upgrade_ack_name(frame->payload[2]), frame->payload[2]);
        } else {
            printf("[UPGRADE][OBS] %s invalid_len=%u\n",
                upgrade_cmd_name(frame->payload[1]), (unsigned)frame->payload_len);
        }
        break;

    case UPDATE_M2A_UPDATE_LEN:
        if (frame->payload_len >= 4U) {
            uint16_t pack_len = (uint16_t)(((uint16_t)frame->payload[2] << 8) | frame->payload[3]);
            printf("[UPGRADE][OBS] %s pack_len=%u\n",
                upgrade_cmd_name(frame->payload[1]), (unsigned)pack_len);
        } else {
            printf("[UPGRADE][OBS] %s invalid_len=%u\n",
                upgrade_cmd_name(frame->payload[1]), (unsigned)frame->payload_len);
        }
        break;

    case UPDATE_M2A_UPDATE_IND_AQUIRE:
        if (frame->payload_len >= 4U) {
            uint16_t index = (uint16_t)(((uint16_t)frame->payload[2] << 8) | frame->payload[3]);
            printf("[UPGRADE][OBS] %s index=%u\n",
                upgrade_cmd_name(frame->payload[1]), (unsigned)index);
        } else {
            printf("[UPGRADE][OBS] %s invalid_len=%u\n",
                upgrade_cmd_name(frame->payload[1]), (unsigned)frame->payload_len);
        }
        break;

    default:
        printf("[UPGRADE][OBS] cmd=%s(0x%02X) len=%u\n",
            upgrade_cmd_name(frame->payload[1]), frame->payload[1],
            (unsigned)frame->payload_len);
        break;
    }
}

static size_t build_frame_bytes(uint8_t *out, size_t out_cap, uint8_t seq, uint8_t ft,
    const uint8_t *payload, size_t payload_len)
{
    size_t total_len;

    /* 0xFF 0xAA + len(2) + seq + type + payload + checksum */
    total_len = payload_len + 7U;
    if (out_cap < total_len || payload_len > MAX_PAYLOAD_LEN) {
        return 0U;
    }

    out[0] = 0xFFU;
    out[1] = 0xAAU;
    out[2] = (uint8_t)((total_len >> 8) & 0xFFU);
    out[3] = (uint8_t)(total_len & 0xFFU);
    out[4] = seq;
    out[5] = ft;
    if (payload_len > 0U) {
        memcpy(out + 6, payload, payload_len);
    }
    out[6 + payload_len] = calc_checksum(out + 2, payload_len + 4U);
    return total_len;
}

static bool decode_complete_frame(const uint8_t *raw, size_t raw_len, McuFrame *frame)
{
    size_t payload_len;
    uint16_t declared_len;
    uint8_t checksum;

    /* Decode only fully assembled frames. Stream reassembly stays in parser_feed(). */
    if (raw_len < 7U || raw_len > MAX_FRAME_LEN) {
        return false;
    }
    if (raw[0] != 0xFFU || raw[1] != 0xAAU) {
        return false;
    }

    declared_len = (uint16_t)(((uint16_t)raw[2] << 8) | raw[3]);
    if ((size_t)declared_len != raw_len) {
        return false;
    }

    checksum = calc_checksum(raw + 2, raw_len - 3U);
    if (checksum != raw[raw_len - 1U]) {
        return false;
    }

    payload_len = raw_len - 7U;
    memset(frame, 0, sizeof(*frame));
    memcpy(frame->raw, raw, raw_len);
    frame->raw_len = raw_len;
    frame->seq = raw[4];
    frame->ft = raw[5];
    frame->payload_len = payload_len;
    if (payload_len > 0U) {
        memcpy(frame->payload, raw + 6, payload_len);
    }
    return true;
}

static size_t parser_feed(FrameParser *parser, const uint8_t *data, size_t len,
    FrameConsumer consumer, void *opaque)
{
    size_t consumed_frames = 0;
    size_t offset = 0;
    size_t keep;

    /* If an oversized chunk comes in, keep only the newest bytes. This parser is
     * for diagnostics, so recovering on the newest stream tail is more useful than
     * trying to preserve already-overflowed garbage. */
    if (len > sizeof(parser->buffer)) {
        data += len - sizeof(parser->buffer);
        len = sizeof(parser->buffer);
        parser->len = 0U;
    }

    /* Preserve only a tiny suffix from the previous buffer. That is enough to keep
     * a partial frame header while preventing unbounded parser growth. */
    if (parser->len + len > sizeof(parser->buffer)) {
        keep = parser->len;
        if (keep > 4U) {
            keep = 4U;
        }
        if (keep > 0U) {
            memmove(parser->buffer, parser->buffer + parser->len - keep, keep);
        }
        parser->len = keep;
    }

    memcpy(parser->buffer + parser->len, data, len);
    parser->len += len;

    /* Scan for as many complete frames as possible. Invalid headers/checksum are
     * skipped byte-by-byte so the parser can resynchronize quickly. */
    while (offset + 7U <= parser->len) {
        size_t frame_len;
        McuFrame frame;

        if (parser->buffer[offset] != 0xFFU || parser->buffer[offset + 1U] != 0xAAU) {
            ++offset;
            continue;
        }

        frame_len = (size_t)(((size_t)parser->buffer[offset + 2U] << 8) | parser->buffer[offset + 3U]);
        if (frame_len < 7U || frame_len > MAX_FRAME_LEN) {
            ++offset;
            continue;
        }

        if (offset + frame_len > parser->len) {
            break;
        }

        if (!decode_complete_frame(parser->buffer + offset, frame_len, &frame)) {
            ++offset;
            continue;
        }

        if (!consumer(opaque, &frame)) {
            ++consumed_frames;
            offset += frame_len;
            break;
        }

        ++consumed_frames;
        offset += frame_len;
    }

    if (offset > 0U) {
        if (offset < parser->len) {
            memmove(parser->buffer, parser->buffer + offset, parser->len - offset);
            parser->len -= offset;
        } else {
            parser->len = 0U;
        }
    }

    return consumed_frames;
}

static void protocol_state_reset(ProtocolState *state)
{
    state->link_state = LINK_IDLE;
    state->recv_seq = 1U;
    state->send_seq = 1U;
}

static ProtocolOutcome protocol_on_frame(ProtocolState *state, const McuFrame *frame)
{
    ProtocolOutcome outcome;

    memset(&outcome, 0, sizeof(outcome));

    /* This function intentionally does not perform I/O. It only mutates the local
     * protocol state and reports what the caller should do next. */
    switch (frame->ft) {
    case FT_ACK:
        outcome.ack_seq = frame->seq;
        if (frame->seq == state->send_seq) {
            outcome.matched_ack = true;
            state->send_seq = seq_next(state->send_seq);
        }
        break;

    case FT_SETUPACK:
        outcome.got_setupack = true;
        outcome.setupack_seq = frame->seq;
        outcome.send_ack = true;
        outcome.link_up = true;
        state->recv_seq = frame->seq;
        state->send_seq = seq_next(frame->seq);
        state->link_state = LINK_LINKED;
        break;

    case FT_HB:
        if (state->link_state != LINK_LINKED) {
            break;
        }
        outcome.duplicate_rx = (state->recv_seq == frame->seq);
        state->recv_seq = frame->seq;
        outcome.send_ack = true;
        break;

    case FT_DATA:
    case FT_BOOT:
        if (state->link_state != LINK_LINKED) {
            break;
        }
        outcome.duplicate_rx = (state->recv_seq == frame->seq);
        state->recv_seq = frame->seq;
        outcome.send_ack = true;
        break;

    case FT_TRANSFER:
        break;

    case FT_CLOSE:
        state->link_state = LINK_IDLE;
        outcome.link_down = true;
        break;

    default:
        break;
    }

    return outcome;
}

/* -------------------------------------------------------------------------- */
/* Layer 2: live transport and wait helpers                                   */
/* -------------------------------------------------------------------------- */

static bool event_queue_init(EventQueue *queue)
{
    memset(queue, 0, sizeof(*queue));
    return pthread_mutex_init(&queue->mutex, NULL) == 0;
}

static void event_queue_destroy(EventQueue *queue)
{
    pthread_mutex_destroy(&queue->mutex);
}

static void event_queue_clear(EventQueue *queue)
{
    pthread_mutex_lock(&queue->mutex);
    queue->head = 0U;
    queue->tail = 0U;
    queue->count = 0U;
    pthread_mutex_unlock(&queue->mutex);
}

static void event_queue_push(EventQueue *queue, const McuFrame *frame)
{
    pthread_mutex_lock(&queue->mutex);

    /* Drop the oldest event on overflow. For this test tool, keeping the latest
     * wire activity is more valuable than preserving a stale queue tail. */
    if (queue->count == ARRAY_SIZE(queue->items)) {
        queue->head = (queue->head + 1U) % ARRAY_SIZE(queue->items);
        --queue->count;
    }

    queue->items[queue->tail].frame = *frame;
    queue->items[queue->tail].timestamp_ms = now_ms();
    queue->tail = (queue->tail + 1U) % ARRAY_SIZE(queue->items);
    ++queue->count;
    pthread_mutex_unlock(&queue->mutex);
}

static bool event_queue_pop(EventQueue *queue, FrameEvent *event)
{
    bool has_item;

    pthread_mutex_lock(&queue->mutex);
    has_item = queue->count > 0U;
    if (has_item) {
        *event = queue->items[queue->head];
        queue->head = (queue->head + 1U) % ARRAY_SIZE(queue->items);
        --queue->count;
    }
    pthread_mutex_unlock(&queue->mutex);
    return has_item;
}

static void event_queue_push_front_event(EventQueue *queue, const FrameEvent *event)
{
    pthread_mutex_lock(&queue->mutex);

    /* If the queue is full, drop the newest event so an older deferred frame can
     * be restored to the front in original wire order. */
    if (queue->count == ARRAY_SIZE(queue->items)) {
        queue->tail = (queue->tail + ARRAY_SIZE(queue->items) - 1U) % ARRAY_SIZE(queue->items);
        --queue->count;
    }

    queue->head = (queue->head + ARRAY_SIZE(queue->items) - 1U) % ARRAY_SIZE(queue->items);
    queue->items[queue->head] = *event;
    ++queue->count;
    pthread_mutex_unlock(&queue->mutex);
}

static void event_queue_restore_front(EventQueue *queue, const FrameEvent *events, size_t event_count)
{
    size_t i;

    for (i = event_count; i > 0U; --i) {
        event_queue_push_front_event(queue, &events[i - 1U]);
    }
}

static speed_t baud_to_speed(int baudrate)
{
    switch (baudrate) {
    case 115200:
        return B115200;
#ifdef B460800
    case 460800:
        return B460800;
#endif
    default:
        return (speed_t)0;
    }
}

static int serial_open_port(const char *port, int baudrate)
{
    int fd;
    int flags;
    struct termios tty;
    speed_t speed;

    fd = open(port, O_RDWR | O_NOCTTY | O_NONBLOCK);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    if (tcgetattr(fd, &tty) != 0) {
        perror("tcgetattr");
        close(fd);
        return -1;
    }

    speed = baud_to_speed(baudrate);
    if (speed == (speed_t)0) {
        fprintf(stderr, "Unsupported baudrate: %d\n", baudrate);
        close(fd);
        return -1;
    }

    cfsetispeed(&tty, speed);
    cfsetospeed(&tty, speed);

    tty.c_cflag &= ~(PARENB | CSTOPB | CSIZE | CRTSCTS);
    tty.c_cflag |= CS8 | CREAD | CLOCAL;
    tty.c_iflag &= ~(IXON | IXOFF | IXANY | IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL);
    tty.c_oflag &= ~(OPOST | ONLCR);
    tty.c_lflag &= ~(ICANON | ECHO | ECHOE | ECHONL | ISIG);
    tty.c_cc[VMIN] = 0;
    tty.c_cc[VTIME] = 0;

    if (tcsetattr(fd, TCSANOW, &tty) != 0) {
        perror("tcsetattr");
        close(fd);
        return -1;
    }

    /* Keep O_NONBLOCK only for open/configure. Upgrade DATA frames must stay
     * under the MCU's 100ms reassembly window, and blocking writes avoid the
     * sporadic partial-write/EAGAIN stretches seen with a permanently
     * nonblocking tty. Reads still use select(), so switching back to blocking
     * mode does not change the receive-side polling model. */
    flags = fcntl(fd, F_GETFL);
    if (flags < 0) {
        perror("fcntl(F_GETFL)");
        close(fd);
        return -1;
    }
    if (fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) != 0) {
        perror("fcntl(F_SETFL)");
        close(fd);
        return -1;
    }

    tcflush(fd, TCIOFLUSH);
    return fd;
}

static ssize_t serial_write_all(int fd, const uint8_t *data, size_t len)
{
    size_t written = 0U;

    while (written < len) {
        ssize_t ret;

        ret = write(fd, data + written, len - written);
        if (ret < 0) {
            if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
                sleep_ms(1);
                continue;
            }
            return -1;
        }
        if (ret == 0) {
            break;
        }
        written += (size_t)ret;
    }

    tcdrain(fd);
    return (ssize_t)written;
}

static int serial_wait_writable(int fd, int timeout_ms)
{
    fd_set writefds;
    struct timeval tv;

    FD_ZERO(&writefds);
    FD_SET(fd, &writefds);
    tv.tv_sec = timeout_ms / 1000;
    tv.tv_usec = (timeout_ms % 1000) * 1000;

    return select(fd + 1, NULL, &writefds, NULL, &tv);
}

static ssize_t serial_write_all_paced(int fd, const uint8_t *data, size_t len,
    size_t burst_len, int gap_ms)
{
    size_t written = 0U;

    while (written < len) {
        size_t remaining = len - written;
        size_t request_len = remaining < burst_len ? remaining : burst_len;
        ssize_t ret;

        ret = write(fd, data + written, request_len);
        if (ret < 0) {
            if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
                int wait_ret = serial_wait_writable(fd, gap_ms > 0 ? gap_ms : 1);

                if (wait_ret < 0 && errno != EINTR) {
                    return -1;
                }
                continue;
            }
            return -1;
        }
        if (ret == 0) {
            break;
        }

        written += (size_t)ret;
        if (written < len) {
            sleep_ms(gap_ms);
        }
    }

    if (tcdrain(fd) != 0) {
        return -1;
    }
    return (ssize_t)written;
}

static bool frame_is_large_upgrade_data(uint8_t ft, const uint8_t *payload, size_t payload_len)
{
    return ft == FT_DATA
        && payload_len >= 2U
        && payload[0] == MT_UPDATE
        && payload[1] == UPDATE_A2M_UPDATE_DATA;
}

static int serial_read_some(int fd, uint8_t *buffer, size_t capacity, int timeout_ms)
{
    fd_set readfds;
    struct timeval tv;
    int ret;

    FD_ZERO(&readfds);
    FD_SET(fd, &readfds);
    tv.tv_sec = timeout_ms / 1000;
    tv.tv_usec = (timeout_ms % 1000) * 1000;

    ret = select(fd + 1, &readfds, NULL, NULL, &tv);
    if (ret <= 0) {
        return ret;
    }
    return (int)read(fd, buffer, capacity);
}

static void live_drain_serial(LiveContext *ctx)
{
    uint8_t temp[256];

    /* Safe only before the reader thread starts. Once the reader owns the UART,
     * draining from the main thread would create a second consumer and can steal
     * SetupAck/ACK/business frames from the parser path. */
    while (serial_read_some(ctx->fd, temp, sizeof(temp), 20) > 0) {
    }
}

static bool live_write_frame_locked(LiveContext *ctx, uint8_t seq, uint8_t ft,
    const uint8_t *payload, size_t payload_len)
{
    uint8_t frame[MAX_FRAME_LEN];
    size_t frame_len;
    ssize_t written;

    frame_len = build_frame_bytes(frame, sizeof(frame), seq, ft, payload, payload_len);
    if (frame_len == 0U) {
        return false;
    }

    if (ctx->verbose_hex) {
        print_hex_line("[TX] ", frame, frame_len);
    }

    if (frame_is_large_upgrade_data(ft, payload, payload_len)) {
        written = serial_write_all_paced(ctx->fd, frame, frame_len,
            UPGRADE_UART_WRITE_CHUNK, UPGRADE_UART_WRITE_GAP_MS);
    } else {
        written = serial_write_all(ctx->fd, frame, frame_len);
    }

    return written == (ssize_t)frame_len;
}

static bool live_send_ack_locked(LiveContext *ctx)
{
    return live_write_frame_locked(ctx, ctx->state.recv_seq, FT_ACK, NULL, 0U);
}

static bool live_send_heartbeat_locked(LiveContext *ctx)
{
    return live_write_frame_locked(ctx, ctx->state.recv_seq, FT_HB, NULL, 0U);
}

static bool live_send_setup(LiveContext *ctx)
{
    bool ok;

    pthread_mutex_lock(&ctx->lock);

    /* Setup always restarts numbering from 1 according to the link design. */
    ctx->state.link_state = LINK_SETUP_SENT;
    ctx->state.recv_seq = 1U;
    ctx->state.send_seq = 1U;
    ok = live_write_frame_locked(ctx, 1U, FT_SETUP, NULL, 0U);
    pthread_mutex_unlock(&ctx->lock);
    return ok;
}

static bool live_send_close(LiveContext *ctx)
{
    bool ok;

    pthread_mutex_lock(&ctx->lock);
    ok = live_write_frame_locked(ctx, ctx->state.send_seq, FT_CLOSE, NULL, 0U);
    pthread_mutex_unlock(&ctx->lock);
    return ok;
}

static bool live_send_data_frame(LiveContext *ctx, uint8_t ft, const uint8_t *payload,
    size_t payload_len, uint8_t *seq_out)
{
    bool ok;

    pthread_mutex_lock(&ctx->lock);
    *seq_out = ctx->state.send_seq;
    ok = live_write_frame_locked(ctx, ctx->state.send_seq, ft, payload, payload_len);
    pthread_mutex_unlock(&ctx->lock);
    return ok;
}

static bool live_reader_consume(void *opaque, const McuFrame *frame)
{
    LiveContext *ctx = (LiveContext *)opaque;
    ProtocolOutcome outcome;
    bool ack_ok = true;
    bool log_update_ack = false;
    uint64_t frame_time_ms = now_ms();

    if (ctx->verbose_hex) {
        print_hex_line("[RX] ", frame->raw, frame->raw_len);
    }

    pthread_mutex_lock(&ctx->lock);
    ctx->last_rx_frame_ms = frame_time_ms;
    outcome = protocol_on_frame(&ctx->state, frame);

    /* Reader thread is the only place that auto-ACKs inbound MCU frames. Test
     * logic above this layer only needs to wait for events and make assertions. */
    if (outcome.send_ack && !ctx->suppress_rx_ack) {
        ack_ok = live_send_ack_locked(ctx);
        log_update_ack = frame->payload_len > 0U && frame->payload[0] == MT_UPDATE;
    }
    pthread_mutex_unlock(&ctx->lock);

    if (log_update_ack) {
        printf("[UPGRADE][PROTO] auto-ack rx_seq=0x%02X status=%s\n",
            frame->seq, ack_ok ? "sent" : "write_error");
    }

    event_queue_push(&ctx->events, frame);
    return true;
}

static void *live_reader_thread(void *opaque)
{
    LiveContext *ctx = (LiveContext *)opaque;
    uint8_t readbuf[256];

    while (ctx->running) {
        int n;

        n = serial_read_some(ctx->fd, readbuf, sizeof(readbuf), 50);
        if (n > 0) {
            pthread_mutex_lock(&ctx->lock);
            ctx->rx_raw_bytes += (size_t)n;
            pthread_mutex_unlock(&ctx->lock);

            if (ctx->verbose_hex) {
                print_hex_line("[RX-RAW] ", readbuf, (size_t)n);
            }
            parser_feed(&ctx->parser, readbuf, (size_t)n, live_reader_consume, ctx);
        }
    }
    return NULL;
}

static void *live_heartbeat_thread(void *opaque)
{
    LiveContext *ctx = (LiveContext *)opaque;

    while (ctx->running) {
        bool should_send_hb = false;
        uint8_t hb_seq = 0U;
        uint64_t current_ms = now_ms();

        pthread_mutex_lock(&ctx->lock);
        if (ctx->state.link_state == LINK_LINKED
                && current_ms >= ctx->last_rx_frame_ms + HB_INTERVAL_MS
                && current_ms >= ctx->last_hb_tx_ms + HB_INTERVAL_MS) {
            hb_seq = ctx->state.recv_seq;
            should_send_hb = true;
            if (live_send_heartbeat_locked(ctx)) {
                ctx->last_hb_tx_ms = current_ms;
            } else {
                should_send_hb = false;
            }
        }
        pthread_mutex_unlock(&ctx->lock);

        if (should_send_hb && ctx->verbose_hex) {
            printf("[HB] tx seq=0x%02X idle_for=%llums\n",
                hb_seq, (unsigned long long)(current_ms - ctx->last_rx_frame_ms));
        }
        sleep_ms(100);
    }

    return NULL;
}

static bool live_context_init(LiveContext *ctx, const RunConfig *config)
{
    memset(ctx, 0, sizeof(*ctx));
    ctx->fd = -1;
    ctx->port = config->port;
    ctx->baudrate = config->baudrate;
    ctx->verbose_hex = config->verbose_hex;
    ctx->tx_heartbeat_enabled = false;
    ctx->hb_thread_started = false;

    if (pthread_mutex_init(&ctx->lock, NULL) != 0) {
        return false;
    }
    if (!event_queue_init(&ctx->events)) {
        pthread_mutex_destroy(&ctx->lock);
        return false;
    }
    protocol_state_reset(&ctx->state);
    ctx->last_rx_frame_ms = now_ms();
    ctx->last_hb_tx_ms = 0U;

    ctx->fd = serial_open_port(ctx->port, ctx->baudrate);
    if (ctx->fd < 0) {
        event_queue_destroy(&ctx->events);
        pthread_mutex_destroy(&ctx->lock);
        return false;
    }

    live_drain_serial(ctx);
    ctx->running = true;
    if (pthread_create(&ctx->rx_thread, NULL, live_reader_thread, ctx) != 0) {
        close(ctx->fd);
        ctx->fd = -1;
        event_queue_destroy(&ctx->events);
        pthread_mutex_destroy(&ctx->lock);
        return false;
    }

    if (ctx->tx_heartbeat_enabled) {
        if (pthread_create(&ctx->hb_thread, NULL, live_heartbeat_thread, ctx) != 0) {
            ctx->running = false;
            pthread_join(ctx->rx_thread, NULL);
            close(ctx->fd);
            ctx->fd = -1;
            event_queue_destroy(&ctx->events);
            pthread_mutex_destroy(&ctx->lock);
            return false;
        }
        ctx->hb_thread_started = true;
    }

    return true;
}

static void live_context_destroy(LiveContext *ctx)
{
    if (ctx->running) {
        ctx->running = false;
        if (ctx->hb_thread_started) {
            pthread_join(ctx->hb_thread, NULL);
        }
        pthread_join(ctx->rx_thread, NULL);
    }
    if (ctx->fd >= 0) {
        close(ctx->fd);
        ctx->fd = -1;
    }
    event_queue_destroy(&ctx->events);
    pthread_mutex_destroy(&ctx->lock);
}

static bool live_wait_for_ack(LiveContext *ctx, uint8_t expected_seq, int timeout_ms)
{
    uint64_t deadline = now_ms() + (uint64_t)timeout_ms;
    FrameEvent deferred[EVENT_QUEUE_CAPACITY];
    size_t deferred_count = 0U;

    /* Preserve any earlier business traffic while waiting for the matching ACK.
     * Some MCU flows emit status frames before the ACK, and those must remain
     * available to the later observe window. */
    while (now_ms() < deadline) {
        FrameEvent event;

        if (event_queue_pop(&ctx->events, &event)) {
            if (event.frame.ft == FT_ACK && event.frame.seq == expected_seq) {
                event_queue_restore_front(&ctx->events, deferred, deferred_count);
                return true;
            }
            if (deferred_count < ARRAY_SIZE(deferred)) {
                deferred[deferred_count++] = event;
            }
            continue;
        }
        sleep_ms(10);
    }

    event_queue_restore_front(&ctx->events, deferred, deferred_count);
    return false;
}

static bool live_wait_for_setupack(LiveContext *ctx, int timeout_ms, uint8_t *seq_out)
{
    uint64_t deadline = now_ms() + (uint64_t)timeout_ms;
    FrameEvent deferred[EVENT_QUEUE_CAPACITY];
    size_t deferred_count = 0U;

    /* Setup is treated separately from data ACK because it establishes the link
     * and seeds the initial serial numbers for the rest of the session. */
    while (now_ms() < deadline) {
        FrameEvent event;

        if (event_queue_pop(&ctx->events, &event)) {
            if (event.frame.ft == FT_SETUPACK) {
                event_queue_restore_front(&ctx->events, deferred, deferred_count);
                *seq_out = event.frame.seq;
                return true;
            }
            if (deferred_count < ARRAY_SIZE(deferred)) {
                deferred[deferred_count++] = event;
            }
            continue;
        }
        sleep_ms(10);
    }

    event_queue_restore_front(&ctx->events, deferred, deferred_count);
    return false;
}

static bool frame_is_follow_up_data(const McuFrame *frame);

static bool live_wait_for_update_frame(LiveContext *ctx, int timeout_ms, McuFrame *frame_out)
{
    uint64_t deadline = now_ms() + (uint64_t)timeout_ms;

    while (now_ms() < deadline) {
        FrameEvent event;

        if (event_queue_pop(&ctx->events, &event)) {
            if (frame_is_follow_up_data(&event.frame)
                    && event.frame.payload_len >= 2U
                    && event.frame.payload[0] == MT_UPDATE) {
                *frame_out = event.frame;
                return true;
            }
            continue;
        }
        sleep_ms(10);
    }

    return false;
}

static bool frame_is_follow_up_data(const McuFrame *frame)
{
    return frame->ft == FT_DATA || frame->ft == FT_TRANSFER || frame->ft == FT_BOOT;
}

static bool frame_is_radio_follow_up(const McuFrame *frame)
{
    return frame->payload_len >= 1U && frame->payload[0] == MT_RADIO;
}

static FollowUpSummary live_observe_data_frames(LiveContext *ctx, int observe_ms, bool detailed_log)
{
    uint64_t deadline = now_ms() + (uint64_t)observe_ms;
    FollowUpSummary summary;

    memset(&summary, 0, sizeof(summary));

    /* Some requests trigger follow-up MCU traffic after the ACK. Count both DATA
     * and TRANSFER frames so radio status/seek reports are also visible. */
    while (now_ms() < deadline) {
        FrameEvent event;

        if (event_queue_pop(&ctx->events, &event)) {
            if (frame_is_follow_up_data(&event.frame)) {
                ++summary.total_frames;
                if (frame_is_radio_follow_up(&event.frame)) {
                    ++summary.radio_frames;
                    if (detailed_log) {
                        log_radio_follow_up_frame(&event.frame);
                    }
                } else {
                    ++summary.other_frames;
                }
            }
            continue;
        }
        sleep_ms(10);
    }

    return summary;
}

static bool live_is_linked(LiveContext *ctx)
{
    bool linked;

    pthread_mutex_lock(&ctx->lock);
    linked = ctx->state.link_state == LINK_LINKED;
    pthread_mutex_unlock(&ctx->lock);
    return linked;
}

static void live_wait_for_rx_quiet(LiveContext *ctx, int quiet_ms, int max_wait_ms,
    const char *reason)
{
    uint64_t deadline = now_ms() + (uint64_t)max_wait_ms;
    uint64_t last_rx_ms;

    pthread_mutex_lock(&ctx->lock);
    last_rx_ms = ctx->last_rx_frame_ms;
    pthread_mutex_unlock(&ctx->lock);

    printf("[LINK] drain before %s: wait for %dms quiet (max %dms)\n",
        reason, quiet_ms, max_wait_ms);

    while (now_ms() < deadline) {
        uint64_t current_ms = now_ms();

        pthread_mutex_lock(&ctx->lock);
        last_rx_ms = ctx->last_rx_frame_ms;
        pthread_mutex_unlock(&ctx->lock);

        if (current_ms >= last_rx_ms + (uint64_t)quiet_ms) {
            printf("[LINK] drain before %s: quiet_for=%llums\n",
                reason, (unsigned long long)(current_ms - last_rx_ms));
            return;
        }
        sleep_ms(20);
    }

    printf("[WARN] drain before %s hit max wait=%dms; closing with recent MCU traffic still active\n",
        reason, max_wait_ms);
}

static bool live_do_setup(LiveContext *ctx)
{
    int attempt;
    size_t setup_start_raw_bytes;

    pthread_mutex_lock(&ctx->lock);
    setup_start_raw_bytes = ctx->rx_raw_bytes;
    pthread_mutex_unlock(&ctx->lock);

    /* Strict mode: SetupAck is mandatory. Failure here means the rest of the live
     * suite is not trustworthy, so callers abort early. */
    for (attempt = 0; attempt <= SETUP_RETRY; ++attempt) {
        uint8_t setupack_seq = 0U;

        event_queue_clear(&ctx->events);
        /* Do not read the UART here: the reader thread is already active and is
         * the sole owner of incoming bytes for the rest of the live session. */
        printf("[LINK] setup attempt %d/%d\n", attempt + 1, SETUP_RETRY + 1);
        if (!live_send_setup(ctx)) {
            printf("[FAIL] link.setup write error\n");
            return false;
        }

        if (live_wait_for_setupack(ctx, SETUP_TIMEOUT_MS, &setupack_seq) && live_is_linked(ctx)) {
            printf("[PASS] link.setup seq=0x%02X\n", setupack_seq);
            return true;
        }

        printf("[WARN] link.setup timeout waiting SetupAck\n");
    }

    pthread_mutex_lock(&ctx->lock);
    if (ctx->rx_raw_bytes == setup_start_raw_bytes) {
        printf("[HINT] No UART bytes were observed on %s during setup retries. This"
               " is not a parser-only failure: MCU/link never produced any visible"
               " response for this session.\n",
            ctx->port);
        printf("[HINT] If you recently ran mcu_deadlock_repro or a known-dangerous"
               " radio command such as SWITCH_ON_OFF(Stereo), SEEK_MODE(Forward),"
               " or SEEK_MODE(Stop), recover with vehicle power-off >= 30 seconds"
               " before retesting.\n");
    }
    pthread_mutex_unlock(&ctx->lock);

    printf("[HINT] If MCU logs show SetupAck or business frames were sent but this tool"
           " still sees timeouts, check whether another process is also reading %s.\n",
        ctx->port);
    printf("[HINT] /dev/ttyS7 readers compete for the same bytes, so a resident ARM"
           " service can steal SetupAck/ACK/TRANSFER frames from this test.\n");
    printf("[FAIL] link.setup strict timeout after %d attempts\n", SETUP_RETRY + 1);
    return false;
}

static size_t build_time_info_payload(uint8_t *payload, size_t capacity)
{
    time_t now;
    struct tm local_tm;

    if (capacity < 9U) {
        return 0U;
    }

    now = time(NULL);
    localtime_r(&now, &local_tm);

    payload[0] = MT_PUBLIC;
    payload[1] = 0x83U;
    payload[2] = (uint8_t)(local_tm.tm_year - 100);
    payload[3] = (uint8_t)(local_tm.tm_mon + 1);
    payload[4] = (uint8_t)local_tm.tm_mday;
    payload[5] = (uint8_t)local_tm.tm_hour;
    payload[6] = (uint8_t)local_tm.tm_min;
    payload[7] = (uint8_t)local_tm.tm_sec;
    payload[8] = 0x00U;
    return 9U;
}

/* -------------------------------------------------------------------------- */
/* Layer 3: live scenario catalog                                             */
/* -------------------------------------------------------------------------- */

static const LiveScenario kSmokeScenarios[] = {
    /* Public smoke */
    {"public.req_init_info", "REQ_INIT_INFO", FT_DATA, {MT_PUBLIC, 0x81U}, 2U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 500, 1, false},
    {"public.req_type_info.version", "REQ_TYPE_INFO(MCU_VERSION)", FT_DATA, {MT_PUBLIC, 0x86U, PUBLIC_STATUS_MCU_VERSION}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 200, 1, false},
    {"public.req_mute.off", "REQ_MUTE(OFF)", FT_DATA, {MT_PUBLIC, 0x82U, 0x00U}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"public.time_info.now", "TIME_INFO", FT_DATA, {0}, 0U, build_time_info_payload, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},

    /* Radio smoke */
    {"radio.set_band.fm", "SET_BAND(FM)", FT_DATA, {MT_RADIO, 0x83U, RADIO_BAND_FM, RADIO_FREQ_87_5_HIGH, RADIO_FREQ_87_5_LOW}, 5U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.freq_range", "FREQ_RANGE", FT_DATA, {MT_RADIO, 0x85U, 0x00U, 0x02U, 0x0AU, 0x06U, 0x86U, 0x22U, 0x2EU, 0x2AU, 0x30U, RADIO_AM_STEP, RADIO_FM_STEP}, 13U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.set_freq.87_5", "SET_FREQ(87.5MHz)", FT_DATA, {MT_RADIO, 0x84U, 0x22U, 0x2EU}, 4U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.reg_mute.open", "SET_RADIO_REG_MUTE(OPEN)", FT_DATA, {MT_RADIO, 0x87U, RADIO_REG_MUTE_OPEN}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
};

static const LiveScenario kFullScenarios[] = {
    /* Public representative subset: enough to prove the module path is healthy
     * without dragging every documented public command into default regression. */
    {"public.req_init_info", "REQ_INIT_INFO", FT_DATA, {MT_PUBLIC, 0x81U}, 2U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 500, 1, false},
    {"public.req_type_info.version", "REQ_TYPE_INFO(MCU_VERSION)", FT_DATA, {MT_PUBLIC, 0x86U, PUBLIC_STATUS_MCU_VERSION}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 200, 1, false},
    {"public.req_type_info.line_detect", "REQ_TYPE_INFO(LINE_DETECT)", FT_DATA, {MT_PUBLIC, 0x86U, PUBLIC_STATUS_LINE_DETECT}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 200, 1, false},
    {"public.req_mute.off", "REQ_MUTE(OFF)", FT_DATA, {MT_PUBLIC, 0x82U, 0x00U}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"public.time_info.now", "TIME_INFO", FT_DATA, {0}, 0U, build_time_info_payload, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"public.req_vehicle_cfg", "REQ_VEHICLE_CFG", FT_DATA, {MT_PUBLIC, 0x98U}, 2U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},

    /* Radio full */
    {"radio.set_band.fm", "SET_BAND(FM)", FT_DATA, {MT_RADIO, 0x83U, RADIO_BAND_FM, RADIO_FREQ_87_5_HIGH, RADIO_FREQ_87_5_LOW}, 5U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.freq_range", "FREQ_RANGE", FT_DATA, {MT_RADIO, 0x85U, 0x00U, 0x02U, 0x0AU, 0x06U, 0x86U, 0x22U, 0x2EU, 0x2AU, 0x30U, RADIO_AM_STEP, RADIO_FM_STEP}, 13U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.set_freq.87_5", "SET_FREQ(87.5MHz)", FT_DATA, {MT_RADIO, 0x84U, 0x22U, 0x2EU}, 4U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.ant_pwr.on", "ANT_PWR(ON)", FT_DATA, {MT_RADIO, 0x86U, 0x01U}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.reg_mute.open", "SET_RADIO_REG_MUTE(OPEN)", FT_DATA, {MT_RADIO, 0x87U, RADIO_REG_MUTE_OPEN}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.reg_mute.close", "SET_RADIO_REG_MUTE(CLOSE)", FT_DATA, {MT_RADIO, 0x87U, RADIO_REG_MUTE_CLOSE}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
};

static const LiveScenario kRadioSeekScenarios[] = {
    /* Public init is required before radio_mgr is allowed to emit follow-up
     * reports: public_mgr raises SYSTEM_INIT_OK only after REQ_INIT_INFO. */
    {"public.req_init_info", "REQ_INIT_INFO", FT_DATA, {MT_PUBLIC, 0x81U}, 2U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 500, 1, false},

    /* Self-contained radio bring-up before exploratory seek attempts. */
    {"radio.set_band.fm", "SET_BAND(FM)", FT_DATA, {MT_RADIO, 0x83U, RADIO_BAND_FM, RADIO_FREQ_87_5_HIGH, RADIO_FREQ_87_5_LOW}, 5U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.freq_range", "FREQ_RANGE", FT_DATA, {MT_RADIO, 0x85U, 0x00U, 0x02U, 0x0AU, 0x06U, 0x86U, 0x22U, 0x2EU, 0x2AU, 0x30U, RADIO_AM_STEP, RADIO_FM_STEP}, 13U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.set_freq.87_5", "SET_FREQ(87.5MHz)", FT_DATA, {MT_RADIO, 0x84U, 0x22U, 0x2EU}, 4U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.ant_pwr.on", "ANT_PWR(ON)", FT_DATA, {MT_RADIO, 0x86U, 0x01U}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},
    {"radio.reg_mute.open", "SET_RADIO_REG_MUTE(OPEN)", FT_DATA, {MT_RADIO, 0x87U, RADIO_REG_MUTE_OPEN}, 3U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 0, 0, false},

    /* Keep known-deadlock seek commands out of this suite. We still observe the
     * follow-up window so logs show whether the tuner emitted any search traffic. */
    {"radio.seek.backward", "SEEK_MODE(Backward)", FT_DATA, {MT_RADIO, 0x81U, RADIO_SEEK_BACKWARD, 0x00U}, 4U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 3000, 0, true},
    {"radio.seek.ams", "SEEK_MODE(AMS)", FT_DATA, {MT_RADIO, 0x81U, RADIO_SEEK_AMS, 0x00U}, 4U, NULL, ACK_TIMEOUT_MS, MAX_RETRY, 3000, 0, true},
};

static bool run_live_upgrade(LiveContext *ctx, const RunConfig *config)
{
    UpgradeImage image;
    uint8_t payload[MAX_PAYLOAD_LEN];
    uint16_t pack_len = 0U;
    uint64_t transfer_start_ms = now_ms();
    unsigned long total_packets = 0UL;
    unsigned long total_retries = 0UL;
    bool end_sent = false;

    if (!upgrade_image_load(config->upgrade_bin_path, &image)) {
        return false;
    }

    printf("\n=== Live Upgrade: MCU APP ===\n");
    printf("[UPGRADE] image=%s size=%lu crc16=0x%04X project=%s\n",
        config->upgrade_bin_path, (unsigned long)image.size, image.crc16,
        config->upgrade_project);

    if (!live_do_setup(ctx)) {
        upgrade_image_free(&image);
        return false;
    }

    event_queue_clear(&ctx->events);

    {
        uint8_t seq = 0U;
        size_t payload_len = build_upgrade_request_payload(payload, sizeof(payload),
            config->upgrade_project, (uint32_t)image.size);

        if (payload_len == 0U) {
            printf("[FAIL] upgrade.req invalid project or payload size\n");
            upgrade_image_free(&image);
            return false;
        }
        if (!live_send_data_frame(ctx, FT_DATA, payload, payload_len, &seq)) {
            printf("[FAIL] upgrade.req write error\n");
            upgrade_image_free(&image);
            return false;
        }
        if (!live_wait_for_ack(ctx, seq, ACK_TIMEOUT_MS)) {
            printf("[FAIL] upgrade.req ACK timeout\n");
            upgrade_image_free(&image);
            return false;
        }
        printf("[PASS] upgrade.req ack_seq=0x%02X\n", seq);
    }

    sleep_ms(UPGRADE_REQUEST_SETTLE_MS);

    {
        uint8_t seq = 0U;
        size_t payload_len = build_upgrade_begin_payload(payload, sizeof(payload),
            config->upgrade_project, (uint32_t)image.size);

        if (payload_len == 0U) {
            printf("[FAIL] upgrade.begin invalid project or payload size\n");
            upgrade_image_free(&image);
            return false;
        }
        if (!live_send_data_frame(ctx, FT_DATA, payload, payload_len, &seq)) {
            printf("[FAIL] upgrade.begin write error\n");
            upgrade_image_free(&image);
            return false;
        }
        if (!live_wait_for_ack(ctx, seq, ACK_TIMEOUT_MS)) {
            printf("[FAIL] upgrade.begin ACK timeout\n");
            upgrade_image_free(&image);
            return false;
        }
        printf("[PASS] upgrade.begin ack_seq=0x%02X\n", seq);
    }

    while (true) {
        McuFrame frame;

        if (!live_wait_for_update_frame(ctx, UPGRADE_MESSAGE_TIMEOUT_MS, &frame)) {
            printf("[FAIL] upgrade.observe timeout waiting MCU update message\n");
            upgrade_image_free(&image);
            return false;
        }

        log_upgrade_follow_up_frame(&frame);
        if (frame.payload_len < 2U) {
            continue;
        }

        switch (frame.payload[1]) {
        case UPDATE_M2A_UPDATE_LEN:
            if (frame.payload_len < 4U) {
                printf("[FAIL] upgrade.len invalid payload length=%u\n", (unsigned)frame.payload_len);
                upgrade_image_free(&image);
                return false;
            }

            pack_len = (uint16_t)(((uint16_t)frame.payload[2] << 8) | frame.payload[3]);
            if (!is_power_of_two_u16(pack_len) || pack_len + 4U > MAX_PAYLOAD_LEN) {
                printf("[FAIL] upgrade.len unsupported pack_len=%u (max_payload=%u)\n",
                    (unsigned)pack_len, (unsigned)MAX_PAYLOAD_LEN);
                upgrade_image_free(&image);
                return false;
            }

            total_packets = (unsigned long)((image.size + (size_t)pack_len - 1U) / (size_t)pack_len);
            transfer_start_ms = now_ms();
            printf("[UPGRADE] packets=%lu pack_len=%u bytes=%lu\n",
                total_packets, (unsigned)pack_len, (unsigned long)image.size);
            break;

        case UPDATE_M2A_UPDATE_IND_AQUIRE:
            {
                uint16_t index;
                size_t offset;
                size_t chunk_len;
                size_t payload_len;
                int send_attempt;
                bool chunk_sent = false;

                if (pack_len == 0U) {
                    printf("[FAIL] upgrade.data_ind arrived before upgrade.len\n");
                    upgrade_image_free(&image);
                    return false;
                }
                if (frame.payload_len < 4U) {
                    printf("[FAIL] upgrade.data_ind invalid payload length=%u\n",
                        (unsigned)frame.payload_len);
                    upgrade_image_free(&image);
                    return false;
                }

                index = (uint16_t)(((uint16_t)frame.payload[2] << 8) | frame.payload[3]);
                offset = (size_t)index * (size_t)pack_len;
                if (offset >= image.size) {
                    printf("[FAIL] upgrade.data_ind index=%u exceeds image size=%lu\n",
                        (unsigned)index, (unsigned long)image.size);
                    upgrade_image_free(&image);
                    return false;
                }

                chunk_len = image.size - offset;
                if (chunk_len > (size_t)pack_len) {
                    chunk_len = (size_t)pack_len;
                }

                payload_len = build_upgrade_data_payload(payload, sizeof(payload), index,
                    image.data + offset, chunk_len);
                if (payload_len == 0U) {
                    printf("[FAIL] upgrade.data index=%u payload build failed\n", (unsigned)index);
                    upgrade_image_free(&image);
                    return false;
                }

                /* The MCU RX side polls UART bytes in a tight software loop.
                 * Give it a short gap after its own UPDATE_IND frame before the
                 * host starts streaming the next 1KB upgrade chunk. */
                sleep_ms(UPGRADE_CHUNK_SETTLE_MS);

                /* MCU comm auto-ACKs duplicate DATA frames with the same serial
                 * number and does not re-dispatch them to business logic. That
                 * makes chunk retransmission a safe recovery path for sporadic
                 * lost ACKs during long upgrade frames. */
                for (send_attempt = 0; send_attempt <= UPGRADE_DATA_ACK_RETRY; ++send_attempt) {
                    uint8_t seq = 0U;
                    uint64_t tx_start_ms = now_ms();
                    uint64_t tx_done_ms;
                    uint64_t ack_done_ms;

                    if (!live_send_data_frame(ctx, FT_DATA, payload, payload_len, &seq)) {
                        printf("[FAIL] upgrade.data index=%u write error\n", (unsigned)index);
                        upgrade_image_free(&image);
                        return false;
                    }
                    tx_done_ms = now_ms();
                    if (live_wait_for_ack(ctx, seq, ACK_TIMEOUT_MS)) {
                        size_t bytes_done = offset + chunk_len;
                        uint64_t elapsed_ms;
                        uint64_t tx_ms;
                        uint64_t ack_wait_ms;
                        double percent = ((double)bytes_done * 100.0) / (double)image.size;
                        double rate_kib_s = 0.0;
                        double eta_s = 0.0;

                        ack_done_ms = now_ms();
                        elapsed_ms = ack_done_ms - transfer_start_ms;
                        tx_ms = tx_done_ms - tx_start_ms;
                        ack_wait_ms = ack_done_ms - tx_done_ms;

                        total_retries += (unsigned long)send_attempt;
                        if (bytes_done > image.size) {
                            bytes_done = image.size;
                        }
                        if (elapsed_ms > 0U) {
                            rate_kib_s = ((double)bytes_done * 1000.0)
                                / ((double)elapsed_ms * 1024.0);
                        }
                        if (bytes_done < image.size && elapsed_ms > 0U) {
                            double remaining_bytes = (double)(image.size - bytes_done);
                            double bytes_per_ms = (double)bytes_done / (double)elapsed_ms;

                            if (bytes_per_ms > 0.0) {
                                eta_s = remaining_bytes / bytes_per_ms / 1000.0;
                            }
                        }

                        printf("[UPGRADE] %6.2f%% chunk=%u/%lu bytes=%lu/%lu size=%lu ack_seq=0x%02X",
                            percent, (unsigned)(index + 1U), total_packets,
                            (unsigned long)bytes_done, (unsigned long)image.size,
                            (unsigned long)chunk_len, seq);
                        if (send_attempt > 0) {
                            printf(" retries=%d", send_attempt);
                        }
                        printf(" total_retries=%lu tx=%llums ack_wait=%llums elapsed=%.1fs rate=%.1fKiB/s",
                            total_retries, (unsigned long long)tx_ms,
                            (unsigned long long)ack_wait_ms,
                            (double)elapsed_ms / 1000.0, rate_kib_s);
                        if (eta_s > 0.0) {
                            printf(" eta=%.1fs", eta_s);
                        }
                        printf("\n");
                        chunk_sent = true;
                        break;
                    }

                    if (send_attempt < UPGRADE_DATA_ACK_RETRY) {
                        printf("[WARN] upgrade.data index=%u ACK timeout attempt %d/%d; tx=%llums retry same chunk\n",
                            (unsigned)index, send_attempt + 1, UPGRADE_DATA_ACK_RETRY + 1,
                            (unsigned long long)(tx_done_ms - tx_start_ms));
                        sleep_ms(UPGRADE_DATA_RETRY_DELAY_MS);
                    }
                }

                if (!chunk_sent) {
                    printf("[FAIL] upgrade.data index=%u ACK timeout after %d attempts\n",
                        (unsigned)index, UPGRADE_DATA_ACK_RETRY + 1);
                    upgrade_image_free(&image);
                    return false;
                }
            }
            break;

        case UPDATE_M2A_UPDATE_ACK:
            if (frame.payload_len < 3U) {
                printf("[FAIL] upgrade.ack invalid payload length=%u\n", (unsigned)frame.payload_len);
                upgrade_image_free(&image);
                return false;
            }

            if (frame.payload[2] == ACK_UPDATE_RX_LAST_FRAME_OK) {
                uint8_t seq = 0U;
                size_t payload_len;

                if (end_sent) {
                    break;
                }

                payload_len = build_upgrade_end_payload(payload, sizeof(payload), image.crc16);
                if (payload_len == 0U) {
                    printf("[FAIL] upgrade.end payload build failed\n");
                    upgrade_image_free(&image);
                    return false;
                }
                if (!live_send_data_frame(ctx, FT_DATA, payload, payload_len, &seq)) {
                    printf("[FAIL] upgrade.end write error\n");
                    upgrade_image_free(&image);
                    return false;
                }
                if (!live_wait_for_ack(ctx, seq, ACK_TIMEOUT_MS)) {
                    printf("[FAIL] upgrade.end ACK timeout\n");
                    upgrade_image_free(&image);
                    return false;
                }
                end_sent = true;
                printf("[PASS] upgrade.end ack_seq=0x%02X\n", seq);
                break;
            }

            if (frame.payload[2] == ACK_UPDATE_SUCCESS) {
                printf("[PASS] upgrade.complete ack=%s\n", upgrade_ack_name(frame.payload[2]));
                upgrade_image_free(&image);
                return true;
            }

            if (frame.payload[2] == ACK_UPDATE_REJECT || frame.payload[2] == ACK_UPDATE_FAIL) {
                printf("[FAIL] upgrade.complete ack=%s\n", upgrade_ack_name(frame.payload[2]));
                upgrade_image_free(&image);
                return false;
            }
            break;

        default:
            break;
        }
    }
}

static bool run_live_scenario(LiveContext *ctx, const LiveScenario *scenario)
{
    uint8_t payload[sizeof(scenario->payload)];
    size_t payload_len;
    int attempt;

    /* Either use a static payload or build one dynamically, e.g. TIME_INFO. */
    if (scenario->payload_builder != NULL) {
        payload_len = scenario->payload_builder(payload, sizeof(payload));
    } else {
        payload_len = scenario->payload_len;
        memcpy(payload, scenario->payload, payload_len);
    }

    if (payload_len == 0U && scenario->ft == FT_DATA) {
        printf("[FAIL] %s invalid payload\n", scenario->id);
        return false;
    }

    printf("\n[Test] %s\n", scenario->name);

    /* First phase: normal send/retry path. */
    for (attempt = 0; attempt <= scenario->retries; ++attempt) {
        uint8_t seq = 0U;
        FollowUpSummary observed;
        int matched_follow_up_frames;

        event_queue_clear(&ctx->events);
        if (!live_send_data_frame(ctx, scenario->ft, payload, payload_len, &seq)) {
            printf("[FAIL] %s write error\n", scenario->id);
            return false;
        }

        if (!live_wait_for_ack(ctx, seq, scenario->ack_timeout_ms)) {
            printf("[WARN] %s ACK timeout attempt %d/%d\n",
                scenario->id, attempt + 1, scenario->retries + 1);
            continue;
        }

        memset(&observed, 0, sizeof(observed));
        if (scenario->observe_ms_after_ack > 0) {
            if (scenario->detailed_follow_up_log) {
                printf("[OBS] %s watching follow-up radio reports for %dms\n",
                    scenario->id, scenario->observe_ms_after_ack);
            }
            observed = live_observe_data_frames(ctx, scenario->observe_ms_after_ack,
                scenario->detailed_follow_up_log);
            if (scenario->detailed_follow_up_log && observed.radio_frames == 0) {
                printf("[OBS] %s no follow-up radio reports observed within %dms\n",
                    scenario->id, scenario->observe_ms_after_ack);
            } else if (scenario->detailed_follow_up_log) {
                printf("[OBS] %s follow-up summary: radio=%d other=%d total=%d\n",
                    scenario->id, observed.radio_frames, observed.other_frames,
                    observed.total_frames);
            }
        }

        matched_follow_up_frames = scenario->detailed_follow_up_log
            ? observed.radio_frames
            : observed.total_frames;

        if (matched_follow_up_frames < scenario->min_data_frames_after_ack) {
            printf("[FAIL] %s expected >= %d follow-up data frames, got %d\n",
                scenario->id, scenario->min_data_frames_after_ack, matched_follow_up_frames);
            return false;
        }

        printf("[PASS] %s ack_seq=0x%02X", scenario->id, seq);
        if (scenario->observe_ms_after_ack > 0) {
            if (scenario->detailed_follow_up_log) {
                printf(" follow_up_radio=%d", observed.radio_frames);
                if (observed.other_frames > 0) {
                    printf(" other=%d", observed.other_frames);
                }
            } else {
                printf(" follow_up_data=%d", observed.total_frames);
            }
        }
        printf("\n");
        return true;
    }

    /* Second phase: retry budget exhausted, re-establish the link once and give
     * the scenario one final clean attempt. */
    printf("[WARN] %s exceeded retry budget, relinking once\n", scenario->id);
    if (!live_do_setup(ctx)) {
        printf("[FAIL] %s relink failed\n", scenario->id);
        return false;
    }

    event_queue_clear(&ctx->events);
    {
        uint8_t seq = 0U;
        if (!live_send_data_frame(ctx, scenario->ft, payload, payload_len, &seq)) {
            printf("[FAIL] %s write error after relink\n", scenario->id);
            return false;
        }
        if (!live_wait_for_ack(ctx, seq, scenario->ack_timeout_ms)) {
            printf("[FAIL] %s no ACK after relink\n", scenario->id);
            return false;
        }
        printf("[PASS] %s ack_seq=0x%02X after relink\n", scenario->id, seq);
    }

    return true;
}

static SuiteSummary run_live_suite(LiveContext *ctx, const LiveScenario *scenarios,
    size_t scenario_count, const char *suite_name)
{
    SuiteSummary summary;
    size_t i;

    memset(&summary, 0, sizeof(summary));
    printf("\n=== Live Suite: %s ===\n", suite_name);

    /* Every live suite starts from a fresh strict setup. If setup fails, there is
     * no point executing scenario-level business checks. */
    if (!live_do_setup(ctx)) {
        summary.total = 1;
        summary.failed = 1;
        return summary;
    }

    for (i = 0U; i < scenario_count; ++i) {
        ++summary.total;
        if (run_live_scenario(ctx, &scenarios[i])) {
            ++summary.passed;
        } else {
            ++summary.failed;
        }
    }

    /* Keep the reader thread alive long enough to ACK any late radio/public
     * follow-up traffic before sending the spec close frame and tearing down
     * the UART. Without this drain window the MCU can keep retrying its last
     * outbound DATA frame and trip its work-exception timeout. */
    live_wait_for_rx_quiet(ctx, SUITE_CLOSE_QUIET_MS, SUITE_CLOSE_MAX_WAIT_MS, "close");
    (void)live_send_close(ctx);
    sleep_ms(SUITE_CLOSE_SETTLE_MS);
    return summary;
}

/* -------------------------------------------------------------------------- */
/* Layer 4: offline protocol self-tests                                       */
/* -------------------------------------------------------------------------- */

typedef struct {
    size_t count;
} CollectCounter;

static bool collect_counter_consumer(void *opaque, const McuFrame *frame)
{
    CollectCounter *counter = (CollectCounter *)opaque;
    (void)frame;
    ++counter->count;
    return true;
}

static bool selftest_checksum_setup(char *detail, size_t detail_cap)
{
    uint8_t bytes[] = {0x00U, 0x07U, 0x01U, FT_SETUP};
    uint8_t checksum;

    checksum = calc_checksum(bytes, sizeof(bytes));
    if (checksum != 0xF6U) {
        snprintf(detail, detail_cap, "expected 0xF6 got 0x%02X", checksum);
        return false;
    }
    return true;
}

static bool selftest_build_frame_fields(char *detail, size_t detail_cap)
{
    uint8_t frame[MAX_FRAME_LEN];
    size_t len;

    len = build_frame_bytes(frame, sizeof(frame), 0x22U, FT_DATA,
        (const uint8_t[]){MT_PUBLIC, 0x81U}, 2U);
    if (len != 9U) {
        snprintf(detail, detail_cap, "expected len=9 got %lu", (unsigned long)len);
        return false;
    }
    if (frame[0] != 0xFFU || frame[1] != 0xAAU || frame[4] != 0x22U || frame[5] != FT_DATA) {
        snprintf(detail, detail_cap, "unexpected frame header or control fields");
        return false;
    }
    if (frame[6] != MT_PUBLIC || frame[7] != 0x81U) {
        snprintf(detail, detail_cap, "unexpected payload bytes");
        return false;
    }
    return true;
}

static bool selftest_build_large_update_frame(char *detail, size_t detail_cap)
{
    uint8_t frame[MAX_FRAME_LEN];
    uint8_t payload[1U + 1U + 2U + 1024U] = {0};
    size_t len;

    payload[0] = 0x07U;
    payload[1] = 0x82U;
    len = build_frame_bytes(frame, sizeof(frame), 0x33U, FT_DATA, payload, sizeof(payload));
    if (len != sizeof(payload) + 7U) {
        snprintf(detail, detail_cap, "expected len=%lu got %lu",
            (unsigned long)(sizeof(payload) + 7U), (unsigned long)len);
        return false;
    }
    return true;
}

static bool selftest_upgrade_crc16(char *detail, size_t detail_cap)
{
    static const uint8_t sample[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'};
    uint16_t crc = crc16_ccitt_update(0U, sample, sizeof(sample));

    if (crc != 0x31C3U) {
        snprintf(detail, detail_cap, "expected 0x31C3 got 0x%04X", crc);
        return false;
    }
    return true;
}

static bool selftest_parser_split_frame(char *detail, size_t detail_cap)
{
    FrameParser parser;
    CollectCounter counter;
    uint8_t frame[MAX_FRAME_LEN];
    size_t len;

    memset(&parser, 0, sizeof(parser));
    memset(&counter, 0, sizeof(counter));
    len = build_frame_bytes(frame, sizeof(frame), 0x05U, FT_DATA,
        (const uint8_t[]){MT_PUBLIC, 0x81U}, 2U);

    parser_feed(&parser, frame, 4U, collect_counter_consumer, &counter);
    parser_feed(&parser, frame + 4U, len - 4U, collect_counter_consumer, &counter);
    if (counter.count != 1U) {
        snprintf(detail, detail_cap, "expected 1 decoded frame got %lu", (unsigned long)counter.count);
        return false;
    }
    return true;
}

static bool selftest_parser_skips_garbage(char *detail, size_t detail_cap)
{
    FrameParser parser;
    CollectCounter counter;
    uint8_t buffer[MAX_FRAME_LEN + 3U];
    uint8_t frame[MAX_FRAME_LEN];
    size_t len;

    memset(&parser, 0, sizeof(parser));
    memset(&counter, 0, sizeof(counter));
    len = build_frame_bytes(frame, sizeof(frame), 0x07U, FT_ACK, NULL, 0U);
    buffer[0] = 0x00U;
    buffer[1] = 0x11U;
    buffer[2] = 0x22U;
    memcpy(buffer + 3U, frame, len);

    parser_feed(&parser, buffer, len + 3U, collect_counter_consumer, &counter);
    if (counter.count != 1U) {
        snprintf(detail, detail_cap, "expected 1 frame after garbage got %lu", (unsigned long)counter.count);
        return false;
    }
    return true;
}

static bool selftest_parser_rejects_bad_checksum(char *detail, size_t detail_cap)
{
    FrameParser parser;
    CollectCounter counter;
    uint8_t frame[MAX_FRAME_LEN];
    size_t len;

    memset(&parser, 0, sizeof(parser));
    memset(&counter, 0, sizeof(counter));
    len = build_frame_bytes(frame, sizeof(frame), 0x09U, FT_DATA,
        (const uint8_t[]){MT_PUBLIC, 0x81U}, 2U);
    frame[len - 1U] ^= 0x01U;

    parser_feed(&parser, frame, len, collect_counter_consumer, &counter);
    if (counter.count != 0U) {
        snprintf(detail, detail_cap, "expected 0 frames got %lu", (unsigned long)counter.count);
        return false;
    }
    return true;
}

static bool selftest_parser_two_frames(char *detail, size_t detail_cap)
{
    FrameParser parser;
    CollectCounter counter;
    uint8_t frame_a[MAX_FRAME_LEN];
    uint8_t frame_b[MAX_FRAME_LEN];
    uint8_t buffer[MAX_FRAME_LEN * 2U];
    size_t len_a;
    size_t len_b;

    memset(&parser, 0, sizeof(parser));
    memset(&counter, 0, sizeof(counter));
    len_a = build_frame_bytes(frame_a, sizeof(frame_a), 0x11U, FT_ACK, NULL, 0U);
    len_b = build_frame_bytes(frame_b, sizeof(frame_b), 0x12U, FT_HB, NULL, 0U);
    memcpy(buffer, frame_a, len_a);
    memcpy(buffer + len_a, frame_b, len_b);

    parser_feed(&parser, buffer, len_a + len_b, collect_counter_consumer, &counter);
    if (counter.count != 2U) {
        snprintf(detail, detail_cap, "expected 2 frames got %lu", (unsigned long)counter.count);
        return false;
    }
    return true;
}

static bool selftest_seq_wrap(char *detail, size_t detail_cap)
{
    (void)detail;
    (void)detail_cap;
    return seq_next(0xFFU) == 0x01U && seq_next(0x00U) == 0x01U;
}

static bool selftest_setupack_links_state(char *detail, size_t detail_cap)
{
    ProtocolState state;
    ProtocolOutcome outcome;
    McuFrame frame;
    uint8_t raw[MAX_FRAME_LEN];
    size_t len;

    protocol_state_reset(&state);
    state.link_state = LINK_SETUP_SENT;
    len = build_frame_bytes(raw, sizeof(raw), 0x01U, FT_SETUPACK, NULL, 0U);
    if (!decode_complete_frame(raw, len, &frame)) {
        snprintf(detail, detail_cap, "failed to decode setupack frame");
        return false;
    }

    outcome = protocol_on_frame(&state, &frame);
    if (!outcome.got_setupack || !outcome.send_ack || state.link_state != LINK_LINKED) {
        snprintf(detail, detail_cap, "setupack did not transition link state");
        return false;
    }
    if (state.recv_seq != 0x01U || state.send_seq != 0x02U) {
        snprintf(detail, detail_cap, "unexpected seq state recv=0x%02X send=0x%02X",
            state.recv_seq, state.send_seq);
        return false;
    }
    return true;
}

static bool selftest_ack_advances_send_seq(char *detail, size_t detail_cap)
{
    ProtocolState state;
    ProtocolOutcome outcome;
    McuFrame frame;
    uint8_t raw[MAX_FRAME_LEN];
    size_t len;

    protocol_state_reset(&state);
    state.link_state = LINK_LINKED;
    state.send_seq = 0x2AU;
    len = build_frame_bytes(raw, sizeof(raw), 0x2AU, FT_ACK, NULL, 0U);
    if (!decode_complete_frame(raw, len, &frame)) {
        snprintf(detail, detail_cap, "failed to decode ack frame");
        return false;
    }

    outcome = protocol_on_frame(&state, &frame);
    if (!outcome.matched_ack || state.send_seq != 0x2BU) {
        snprintf(detail, detail_cap, "send seq not advanced, now 0x%02X", state.send_seq);
        return false;
    }
    return true;
}

static bool selftest_duplicate_data_requests_ack(char *detail, size_t detail_cap)
{
    ProtocolState state;
    ProtocolOutcome outcome;
    McuFrame frame;
    uint8_t raw[MAX_FRAME_LEN];
    size_t len;

    protocol_state_reset(&state);
    state.link_state = LINK_LINKED;
    state.recv_seq = 0x55U;
    len = build_frame_bytes(raw, sizeof(raw), 0x55U, FT_DATA,
        (const uint8_t[]){MT_PUBLIC, 0x81U}, 2U);
    if (!decode_complete_frame(raw, len, &frame)) {
        snprintf(detail, detail_cap, "failed to decode data frame");
        return false;
    }

    outcome = protocol_on_frame(&state, &frame);
    if (!outcome.duplicate_rx || !outcome.send_ack) {
        snprintf(detail, detail_cap, "duplicate data did not request ACK");
        return false;
    }
    return true;
}

static const SelfTestCase kSelfTests[] = {
    {"proto.checksum.setup", "checksum for setup frame", selftest_checksum_setup},
    {"proto.build_frame.fields", "frame builder populates fields", selftest_build_frame_fields},
    {"proto.build_frame.large_update", "frame builder supports 1024-byte upgrade chunk", selftest_build_large_update_frame},
    {"proto.upgrade.crc16", "upgrade CRC16 matches MCU algorithm", selftest_upgrade_crc16},
    /* Parser behavior */
    {"proto.parser.split", "parser handles split frame", selftest_parser_split_frame},
    {"proto.parser.garbage", "parser skips garbage prefix", selftest_parser_skips_garbage},
    {"proto.parser.bad_checksum", "parser rejects bad checksum", selftest_parser_rejects_bad_checksum},
    {"proto.parser.multi", "parser handles concatenated frames", selftest_parser_two_frames},

    /* Sequence/state rules */
    {"proto.seq.wrap", "sequence skips zero on wrap", selftest_seq_wrap},
    {"proto.state.setupack", "setupack transitions link state", selftest_setupack_links_state},
    {"proto.state.ack", "ack advances send sequence", selftest_ack_advances_send_seq},
    {"proto.state.duplicate_data", "duplicate data still triggers ACK", selftest_duplicate_data_requests_ack},
};

static SuiteSummary run_selftests(void)
{
    SuiteSummary summary;
    size_t i;

    /* Self-tests always run first. They are cheap, deterministic, and catch local
     * regressions before UART debugging gets involved. */
    memset(&summary, 0, sizeof(summary));
    printf("=== Protocol Self-tests ===\n");
    for (i = 0U; i < ARRAY_SIZE(kSelfTests); ++i) {
        char detail[128] = {0};
        bool ok;

        ++summary.total;
        ok = kSelfTests[i].fn(detail, sizeof(detail));
        if (ok) {
            ++summary.passed;
            printf("[PASS] %s\n", kSelfTests[i].id);
        } else {
            ++summary.failed;
            printf("[FAIL] %s", kSelfTests[i].id);
            if (detail[0] != '\0') {
                printf(" - %s", detail);
            }
            printf("\n");
        }
    }
    printf("Self-tests: total=%d passed=%d failed=%d\n",
        summary.total, summary.passed, summary.failed);
    return summary;
}

/* -------------------------------------------------------------------------- */
/* Layer 5: CLI and top-level orchestration                                   */
/* -------------------------------------------------------------------------- */

static void print_usage(const char *argv0)
{
    printf("Usage:\n");
    printf("  %s                          # default: self-tests + live-full\n", argv0);
    printf("  %s --selftest\n", argv0);
    printf("  %s --live-link [--port /dev/ttyS7] [--baud 460800]\n", argv0);
    printf("  %s --live-smoke [--port /dev/ttyS7] [--baud 460800]\n", argv0);
    printf("  %s --live-full [--port /dev/ttyS7] [--baud 460800]\n", argv0);
    printf("  %s --live-radio-seek [--port /dev/ttyS7] [--baud 460800]\n", argv0);
    printf("  %s --live-upgrade [--upgrade-bin /data/local/tmp/mcu_app.bin] [--upgrade-project 10040]\n", argv0);
    printf("Options:\n");
    printf("  --quiet-hex    Hide TX/RX hex dumps\n");
    printf("  --upgrade-bin  Path to the MCU app binary on the Android target\n");
    printf("  --upgrade-project  Project number payload (1-5 bytes, default 10040)\n");
    printf("  --help         Show this message\n");
}

static bool parse_int_arg(const char *text, int *value_out)
{
    char *end = NULL;
    long parsed;

    errno = 0;
    parsed = strtol(text, &end, 10);
    if (errno != 0 || end == text || *end != '\0') {
        return false;
    }
    *value_out = (int)parsed;
    return true;
}

static bool parse_args(int argc, char **argv, RunConfig *config)
{
    int i;

    /* Default to the full suite so a plain invocation immediately exercises all
     * currently supported live scenarios after the offline self-tests pass. */
    config->mode = MODE_LIVE_FULL;
    config->port = DEFAULT_SERIAL_PORT;
    config->baudrate = DEFAULT_BAUDRATE;
    config->upgrade_bin_path = DEFAULT_UPGRADE_BIN_PATH;
    config->upgrade_project = DEFAULT_UPGRADE_PROJECT;
    config->verbose_hex = true;

    for (i = 1; i < argc; ++i) {
        if (strcmp(argv[i], "--selftest") == 0) {
            config->mode = MODE_SELFTEST_ONLY;
        } else if (strcmp(argv[i], "--live-link") == 0) {
            config->mode = MODE_LIVE_LINK_ONLY;
        } else if (strcmp(argv[i], "--live-smoke") == 0) {
            config->mode = MODE_LIVE_SMOKE;
        } else if (strcmp(argv[i], "--live-full") == 0) {
            config->mode = MODE_LIVE_FULL;
        } else if (strcmp(argv[i], "--live-radio-seek") == 0) {
            config->mode = MODE_LIVE_RADIO_SEEK;
        } else if (strcmp(argv[i], "--live-upgrade") == 0) {
            config->mode = MODE_LIVE_UPGRADE;
        } else if (strcmp(argv[i], "--quiet-hex") == 0) {
            config->verbose_hex = false;
        } else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
            ++i;
            config->port = argv[i];
        } else if (strcmp(argv[i], "--baud") == 0 && i + 1 < argc) {
            ++i;
            if (!parse_int_arg(argv[i], &config->baudrate)) {
                fprintf(stderr, "Invalid baudrate: %s\n", argv[i]);
                return false;
            }
        } else if (strcmp(argv[i], "--upgrade-bin") == 0 && i + 1 < argc) {
            ++i;
            config->upgrade_bin_path = argv[i];
        } else if (strcmp(argv[i], "--upgrade-project") == 0 && i + 1 < argc) {
            ++i;
            config->upgrade_project = argv[i];
        } else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
            print_usage(argv[0]);
            exit(0);
        } else {
            fprintf(stderr, "Unknown argument: %s\n", argv[i]);
            return false;
        }
    }

    if (!fill_upgrade_project_bytes(config->upgrade_project, (uint8_t [5]){0})) {
        fprintf(stderr, "Invalid upgrade project: %s\n", config->upgrade_project);
        return false;
    }

    return true;
}

int main(int argc, char **argv)
{
    RunConfig config;
    SuiteSummary self_summary;
    SuiteSummary live_summary;
    LiveContext ctx;
    bool live_ok = false;

    setvbuf(stdout, NULL, _IONBF, 0);

    if (!parse_args(argc, argv, &config)) {
        print_usage(argv[0]);
        return 1;
    }

    /* Execution order is fixed:
     *   1. parse CLI
     *   2. run offline protocol self-tests
     *   3. if requested, open UART and run live link/smoke/full suite
     */
    printf("=== MCU Structured Test ===\n");
    printf("Mode: ");
    switch (config.mode) {
    case MODE_SELFTEST_ONLY:
        printf("selftest\n");
        break;
    case MODE_LIVE_LINK_ONLY:
        printf("live-link\n");
        break;
    case MODE_LIVE_FULL:
        printf("live-full\n");
        break;
    case MODE_LIVE_RADIO_SEEK:
        printf("live-radio-seek\n");
        break;
    case MODE_LIVE_UPGRADE:
        printf("live-upgrade\n");
        break;
    case MODE_LIVE_SMOKE:
    default:
        printf("live-smoke\n");
        break;
    }

    self_summary = run_selftests();
    if (self_summary.failed > 0) {
        return 1;
    }
    if (config.mode == MODE_SELFTEST_ONLY) {
        return 0;
    }

    printf("Serial: %s @ %d baud\n", config.port, config.baudrate);
    if (config.mode == MODE_LIVE_UPGRADE) {
        printf("Upgrade: %s (project=%s)\n", config.upgrade_bin_path, config.upgrade_project);
    }
    if (!live_context_init(&ctx, &config)) {
        fprintf(stderr, "Failed to initialize live serial context.\n");
        return 1;
    }

    memset(&live_summary, 0, sizeof(live_summary));
    if (config.mode == MODE_LIVE_LINK_ONLY) {
        live_summary.total = 1;
        live_ok = live_do_setup(&ctx);
        if (live_ok) {
            live_summary.passed = 1;
        } else {
            live_summary.failed = 1;
        }
    } else if (config.mode == MODE_LIVE_FULL) {
        live_summary = run_live_suite(&ctx, kFullScenarios, ARRAY_SIZE(kFullScenarios), "FULL");
    } else if (config.mode == MODE_LIVE_RADIO_SEEK) {
        live_summary = run_live_suite(&ctx, kRadioSeekScenarios,
            ARRAY_SIZE(kRadioSeekScenarios), "RADIO-SEEK");
    } else if (config.mode == MODE_LIVE_UPGRADE) {
        live_summary.total = 1;
        live_ok = run_live_upgrade(&ctx, &config);
        if (live_ok) {
            live_summary.passed = 1;
        } else {
            live_summary.failed = 1;
        }
    } else {
        live_summary = run_live_suite(&ctx, kSmokeScenarios, ARRAY_SIZE(kSmokeScenarios), "SMOKE");
    }

    live_context_destroy(&ctx);

    printf("\n=== Summary ===\n");
    printf("Self-tests : total=%d passed=%d failed=%d\n",
        self_summary.total, self_summary.passed, self_summary.failed);
    printf("Live-tests : total=%d passed=%d failed=%d\n",
        live_summary.total, live_summary.passed, live_summary.failed);

    return (live_summary.failed == 0) ? 0 : 1;
}