/*
 * event_recorder.c
 *
 * Golf cart violation event recorder for KL630.
 *
 * Channel A (iPad / BLE path):
 *   Write JSON to UART → DX-BT24 BLE module → iPad on every level change.
 *   bt_uart_init() must be called before event_recorder_init().
 *
 * Channel B (OOB / cloud path):
 *   After event ends, wait upload_delay_ms, then:
 *     tar czf <sd_path>/event_<id>.tar.gz  (images + event.json)
 *     POST tar.gz to /api/upload
 *
 * JPEG snapshots must be taken from app_header_send_inference (VMF thread),
 * so event_recorder_provide_frame() handles the actual VMF_SNAP call.
 * The state machine runs in event_recorder_update() (recv callback).
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <dirent.h>
#include <errno.h>

#include <MemBroker/mem_broker.h>
#include <vmf/video_snapshot_mechanism.h>
#include <vmf/video_source.h>

#include "event_recorder.h"
#include "bt_uart.h"
#include "stdc_post_process.h"  /* THR_*_COLLISION constants */

/* ── External (from kdp2_host_stream.c) ──────────────────────────────────── */
extern VMF_VSRC_HANDLE_T *g_ptVsrcHandle;

/* ── Config ──────────────────────────────────────────────────────────────── */
/* Channel A: JSON events → BT UART → iPad (via DX-BT24 BLE module)
 * Initialized by bt_uart_init() in kp_firmware.c.  No local config needed. */

/* Channel B (tar.gz upload → OOB / cloud path)
 * Same endpoint as capture JPG upload (golf.cgi), just posting tar.gz.
 * Simulation: http://192.168.0.114:8081/api/upload
 * Production: http://192.168.0.99/api/golf.cgi   */
static char s_up_host[64]  = "192.168.0.99";
static int  s_up_port      = 80;
static char s_up_path[128] = "/api/golf.cgi";

static char s_sd_path[256] = "/tmp/sdcard/events";
static long long s_sd_max_bytes = (long long)7 * 1024 * 1024 * 1024;  /* 7 GB — must be long long on 32-bit ARM */
static int  s_upload_delay_ms = 60000;
static int  s_enabled      = 0;

/* ── VMF SNAP ────────────────────────────────────────────────────────────── */
#define SNAP_BUF_SIZE  (2 * 1024 * 1024)
#define SNAP_QP        75

static VMF_SNAP_HANDLE_T *s_snap = NULL;

/* ── Grass state machine ─────────────────────────────────────────────────── */
typedef enum {
    GRASS_IDLE = 0,
    GRASS_L1,       /* T        — initial detection      */
    GRASS_L2,       /* T + 6s   — continued violation    */
    GRASS_L3,       /* T + 10s  — maximum severity       */
    GRASS_DONE,     /* cleared  — waiting for upload     */
} GrassState;

/* How long grass must be absent before the violation is considered cleared.
 * 2 s hysteresis prevents momentary STDC flicker from resetting the level timer. */
#define GRASS_EXIT_HYSTERESIS_MS 2000

typedef struct {
    GrassState      state;
    struct timeval  t_l1;           /* time of L1 entry                */
    struct timeval  t_last_active;  /* last frame where on_grass = true */
    struct timeval  t_done;         /* time of DONE entry              */
    char            event_id[24];   /* timestamp-based unique id       */
    char            work_dir[256];  /* /tmp/ev_<id>/                   */
    int             max_level;
    int             upload_busy;    /* upload thread running           */
} GrassCtx;

static GrassCtx         g_grass;
static pthread_mutex_t  g_grass_mtx = PTHREAD_MUTEX_INITIALIZER;

/* ── Pending snapshot request ────────────────────────────────────────────── */
typedef struct {
    int  active;
    char filename[64];    /* e.g. "level1.jpg"    */
    char work_dir[256];   /* destination directory */
    int  immediate_upload;/* 1 = upload right after snap (single-shot) */
    char event_id[24];
    char event_type[16];
    int  max_level;
} SnapReq;

static SnapReq         g_snap_req;
static pthread_mutex_t g_snap_mtx = PTHREAD_MUTEX_INITIALIZER;

/* ── Single-shot debounce ────────────────────────────────────────────────── */
static int g_last_person = 0;
static int g_last_bunker = 0;
static int g_last_pond   = 0;
static int g_last_tree   = 0;

/* ═══════════════════════════════════════════════════════════════════════════
 * URL / network helpers (Channel B only)
 * ═══════════════════════════════════════════════════════════════════════════ */

static void parse_url_into(const char *url,
                           char *host, size_t host_n,
                           int *port,
                           char *path, size_t path_n)
{
    const char *p = url;
    /* Default to HTTP port 80 when URL does not include :port */
    if (port) *port = 80;
    if (strncmp(p, "http://", 7) == 0) p += 7;
    const char *slash = strchr(p, '/');
    char hostport[80] = {0};
    if (slash) {
        size_t n = (size_t)(slash - p);
        if (n >= sizeof(hostport)) n = sizeof(hostport) - 1;
        strncpy(hostport, p, n);
        if (path) snprintf(path, path_n, "%s", slash);
    } else {
        snprintf(hostport, sizeof(hostport), "%s", p);
        if (path) snprintf(path, path_n, "/");
    }
    char *colon = strchr(hostport, ':');
    if (colon) {
        *colon = '\0';
        if (port) *port = atoi(colon + 1);
    }
    if (host) snprintf(host, host_n, "%s", hostport);
}

static int open_socket_to(const char *host, int port)
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) return -1;
    struct timeval tv = { 5, 0 };
    setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
    setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    struct sockaddr_in srv = {0};
    srv.sin_family = AF_INET;
    srv.sin_port   = htons((uint16_t)port);
    if (inet_aton(host, &srv.sin_addr) == 0 ||
        connect(sock, (struct sockaddr *)&srv, sizeof(srv)) != 0) {
        close(sock);
        return -1;
    }
    return sock;
}

/* ── Channel B: POST tar.gz via kCurl (same method as golf.cgi JPEG upload) ── */
/* kCurl lives in the firmware bin directory — use absolute path so CWD doesn't matter */
#define KCURL_PATH "/mnt/flash/plus/kp_firmware/kp_firmware_0/kp_firmware/bin/kCurl"

static void http_post_file(const char *filepath)
{
    const char *basename = strrchr(filepath, '/');
    basename = basename ? basename + 1 : filepath;

    /* Pass filename as query param so the server can name the file correctly.
     * Format mirrors golf.cgi usage: kCurl --data-binary @file URL */
    char url[320];
    snprintf(url, sizeof(url), "http://%s:%d%s?filename=%s",
             s_up_host, s_up_port, s_up_path, basename);

    char cmd[640];
    snprintf(cmd, sizeof(cmd),
             KCURL_PATH " --data-binary @'%s' '%s' >> /tmp/fw.log 2>&1",
             filepath, url);

    printf("[EVT] upload: kCurl → %s\n", url);
    int rc = system(cmd);
    if (rc != 0)
        printf("[EVT] upload: kCurl failed (rc=%d)\n", rc);
    else
        printf("[EVT] upload: %s sent OK\n", basename);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * Helpers
 * ═══════════════════════════════════════════════════════════════════════════ */

/* UTC+8 offset for Taiwan time (TZ env may not be set on embedded device) */
#define TZ_OFFSET_SEC (8 * 3600)

/* UTC time with Z suffix — reserved for future use */
__attribute__((unused))
static void now_iso_utc(char *buf, size_t n)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    struct tm *t = gmtime(&tv.tv_sec);
    strftime(buf, n, "%Y-%m-%dT%H:%M:%SZ", t);
}

/* Taiwan time (UTC+8) — used in tar.gz filenames and event.json for local readability */
static void now_iso(char *buf, size_t n)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    time_t tw = tv.tv_sec + TZ_OFFSET_SEC;
    struct tm *t = gmtime(&tw);
    strftime(buf, n, "%Y-%m-%dT%H:%M:%S+08:00", t);
}

static long elapsed_ms_tv(const struct timeval *since)
{
    struct timeval now;
    gettimeofday(&now, NULL);
    return (now.tv_sec  - since->tv_sec)  * 1000L
         + (now.tv_usec - since->tv_usec) / 1000L;
}

static void make_work_dir(const char *event_id, char *out, size_t n)
{
    snprintf(out, n, "/tmp/ev_%s", event_id);
    mkdir(out, 0755);
}

/* ── Write event.json into work_dir ─────────────────────────────────────── */
static void write_event_json(const char *work_dir, const char *event_id,
                              const char *type, int max_level,
                              float duration_sec,
                              const char images[][64], int image_count)
{
    char path[320];
    snprintf(path, sizeof(path), "%s/event.json", work_dir);
    FILE *f = fopen(path, "w");
    if (!f) { printf("[EVT] write_event_json: fopen failed: %s\n", path); return; }

    char ts[32];
    now_iso(ts, sizeof(ts));

    fprintf(f, "{\n");
    fprintf(f, "  \"id\": \"%s\",\n", event_id);
    fprintf(f, "  \"date\": \"%s\",\n", ts);
    fprintf(f, "  \"type\": \"%s\",\n", type);
    fprintf(f, "  \"max_level\": %d,\n", max_level);
    fprintf(f, "  \"duration_sec\": %.1f,\n", duration_sec);
    fprintf(f, "  \"images\": [");
    for (int i = 0; i < image_count; i++) {
        fprintf(f, "\"%s\"%s", images[i], i < image_count - 1 ? ", " : "");
    }
    fprintf(f, "]\n}\n");
    fclose(f);
}

/* ── Build tar.gz on SD card ─────────────────────────────────────────────── */
static void build_targz(const char *work_dir, const char *event_id,
                         char *out_path, size_t out_n)
{
    char ts[20];
    struct timeval tv;
    gettimeofday(&tv, NULL);
    time_t tw = tv.tv_sec + TZ_OFFSET_SEC;
    struct tm *t = gmtime(&tw);
    strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", t);

    snprintf(out_path, out_n, "%s/event_%s_%s.tar.gz", s_sd_path, event_id, ts);

    /* Ensure SD events dir exists */
    mkdir(s_sd_path, 0755);

    char cmd[768];
    /* BusyBox tar: pipe to gzip -c (explicit stdout mode required on some builds).
     * Run cd in subshell so && pipe chain works correctly.
     * stderr goes to fw.log so we can see the real error if it fails. */
    snprintf(cmd, sizeof(cmd),
        "(cd '%s' && tar cf - . 2>>/tmp/fw.log) | gzip -c > '%s'",
        work_dir, out_path);
    int sys_rc = system(cmd);

    /* FAT/SD metadata may not be flushed yet — sync before stat */
    sync();

    /* Verify the file was actually created */
    struct stat _st;
    if (stat(out_path, &_st) != 0) {
        printf("[EVT] tar/gzip failed (shell rc=%d) — file not created\n", sys_rc);
        out_path[0] = '\0';   /* signal failure to caller */
        return;
    }
    printf("[EVT] created %s (%ld bytes)\n", out_path, (long)_st.st_size);
}

/* ── SD card cleanup: delete oldest .tar.gz if total > limit ────────────── */
typedef struct { char path[320]; time_t mtime; } FileEntry;

static int cmp_mtime(const void *a, const void *b)
{
    return (int)((const FileEntry *)a)->mtime - (int)((const FileEntry *)b)->mtime;
}

static void sd_cleanup(void)
{
    DIR *d = opendir(s_sd_path);
    if (!d) return;

    FileEntry files[256];
    int count = 0;
    long long total = 0;
    struct dirent *ent;
    while ((ent = readdir(d)) && count < 256) {
        if (!strstr(ent->d_name, ".tar.gz")) continue;
        char fp[320];
        snprintf(fp, sizeof(fp), "%s/%s", s_sd_path, ent->d_name);
        struct stat st;
        if (stat(fp, &st) != 0) continue;
        files[count].mtime = st.st_mtime;
        snprintf(files[count].path, sizeof(files[count].path), "%s", fp);
        total += st.st_size;
        count++;
    }
    closedir(d);

    if (total <= s_sd_max_bytes) return;

    /* Sort by mtime ascending (oldest first) */
    qsort(files, count, sizeof(FileEntry), cmp_mtime);
    for (int i = 0; i < count && total > s_sd_max_bytes; i++) {
        struct stat st;
        if (stat(files[i].path, &st) == 0) {
            total -= st.st_size;
            remove(files[i].path);
            printf("[EVT] SD cleanup: deleted %s\n", files[i].path);
        }
    }
}

/* ── Remove work dir ─────────────────────────────────────────────────────── */
static void rm_work_dir(const char *dir)
{
    char cmd[320];
    snprintf(cmd, sizeof(cmd), "rm -rf '%s' 2>/dev/null", dir);
    system(cmd);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * JSON event fire — bt_uart_send_json() is non-blocking (internal queue +
 * dedicated writer thread), so we call it directly without a wrapper thread.
 * ═══════════════════════════════════════════════════════════════════════════ */

static void fire_json_async(const char *event_id, const char *type, int level)
{
    (void)event_id;   /* not included in BT payload — kept for caller compatibility */

    if (!type) type = "unknown";

    char json[64];
    /* Compact format fits in a single BLE packet:
     * e.g. {"class":"lane","level":1}  = 26 bytes */
    snprintf(json, sizeof(json), "{\"class\":\"%s\",\"level\":%d}", type, level);
    bt_uart_send_json(json);   /* enqueues and returns immediately */
}

/* ═══════════════════════════════════════════════════════════════════════════
 * Upload thread (build tar.gz → upload → SD cleanup)
 * ═══════════════════════════════════════════════════════════════════════════ */

typedef struct {
    char work_dir[256];
    char event_id[24];
    char event_type[16];
    int  max_level;
    float duration_sec;
    char images[4][64];
    int  image_count;
    int  delay_ms;
} UploadArg;

static void *upload_thread(void *arg)
{
    UploadArg *a = (UploadArg *)arg;

    printf("[EVT] upload_thread: start id=%s type=%s delay=%dms\n",
           a->event_id, a->event_type, a->delay_ms);

    if (a->delay_ms > 0) {
        printf("[EVT] upload: waiting %d ms before packaging...\n", a->delay_ms);
        usleep((useconds_t)a->delay_ms * 1000);
    }

    /* Write event.json */
    printf("[EVT] upload: writing event.json to %s\n", a->work_dir);
    write_event_json(a->work_dir, a->event_id, a->event_type,
                     a->max_level, a->duration_sec,
                     (const char (*)[64])a->images, a->image_count);

    /* Build tar.gz on SD card */
    printf("[EVT] upload: building tar.gz\n");
    char tgz_path[384];
    build_targz(a->work_dir, a->event_id, tgz_path, sizeof(tgz_path));

    /* Upload Channel B */
    if (tgz_path[0]) {
        printf("[EVT] upload: posting %s to %s:%d%s\n",
               tgz_path, s_up_host, s_up_port, s_up_path);
        http_post_file(tgz_path);
    }

    /* SD card cleanup */
    sd_cleanup();

    /* Cleanup temp work dir */
    rm_work_dir(a->work_dir);

    /* Signal grass upload done */
    pthread_mutex_lock(&g_grass_mtx);
    if (g_grass.upload_busy) {
        g_grass.upload_busy = 0;
        if (g_grass.state == GRASS_DONE)
            g_grass.state = GRASS_IDLE;
    }
    pthread_mutex_unlock(&g_grass_mtx);

    free(a);
    return NULL;
}

static void launch_upload(const char *work_dir, const char *event_id,
                          const char *event_type, int max_level,
                          float duration_sec,
                          const char images[][64], int image_count,
                          int delay_ms)
{
    UploadArg *a = (UploadArg *)malloc(sizeof(UploadArg));
    if (!a) return;

    snprintf(a->work_dir,    sizeof(a->work_dir),    "%s", work_dir);
    snprintf(a->event_id,    sizeof(a->event_id),    "%s", event_id);
    snprintf(a->event_type,  sizeof(a->event_type),  "%s", event_type);
    a->max_level    = max_level;
    a->duration_sec = duration_sec;
    a->delay_ms     = delay_ms;
    a->image_count  = (image_count <= 4) ? image_count : 4;
    for (int i = 0; i < a->image_count; i++)
        snprintf(a->images[i], sizeof(a->images[i]), "%s", images[i]);

    pthread_t t;
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    if (pthread_create(&t, &attr, upload_thread, a) != 0) {
        printf("[EVT] launch_upload: pthread_create failed — upload dropped id=%s\n", a->event_id);
        free(a);
    }
    pthread_attr_destroy(&attr);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * VMF_SNAP (lazy init)
 * ═══════════════════════════════════════════════════════════════════════════ */

static int snap_lazy_init(void)
{
    if (s_snap) return 0;
    if (!g_ptVsrcHandle) {
        printf("[EVT] snap_lazy_init: vsrc not ready\n");
        return -1;
    }
    VMF_SNAP_INITOPT_T opt;
    memset(&opt, 0, sizeof(opt));
    opt.pszOutPinPrefix = "vsrc_ssm";
    opt.dwStreamIdx     = 0;         /* stream0 = 1920×1080 */
    opt.pVsrcHandle     = g_ptVsrcHandle;
    opt.dwQp            = SNAP_QP;
    s_snap = VMF_SNAP_Init(&opt);
    if (!s_snap) {
        printf("[EVT] VMF_SNAP_Init failed\n");
        return -1;
    }
    printf("[EVT] VMF_SNAP_Init OK\n");
    return 0;
}

/* ── Save JPEG from snap buffer to file ──────────────────────────────────── */
static void save_jpeg(const char *path, const uint8_t *buf, int size)
{
    FILE *f = fopen(path, "wb");
    if (!f) {
        printf("[EVT] save_jpeg: cannot open %s\n", path);
        return;
    }
    fwrite(buf, 1, (size_t)size, f);
    fclose(f);
    printf("[EVT] saved %s (%d bytes)\n", path, size);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * State machine helpers
 * ═══════════════════════════════════════════════════════════════════════════ */

static void request_snap(const char *filename, const char *work_dir,
                         const char *event_id, const char *event_type,
                         int max_level, int immediate_upload)
{
    pthread_mutex_lock(&g_snap_mtx);
    if (!g_snap_req.active) {
        g_snap_req.active           = 1;
        g_snap_req.immediate_upload = immediate_upload;
        g_snap_req.max_level        = max_level;
        snprintf(g_snap_req.filename,   sizeof(g_snap_req.filename),   "%s", filename);
        snprintf(g_snap_req.work_dir,   sizeof(g_snap_req.work_dir),   "%s", work_dir);
        snprintf(g_snap_req.event_id,   sizeof(g_snap_req.event_id),   "%s", event_id);
        snprintf(g_snap_req.event_type, sizeof(g_snap_req.event_type), "%s", event_type);
    } else {
        printf("[EVT] snap dropped: already pending %s (new: %s/%s)\n",
               g_snap_req.filename, event_id, filename);
    }
    pthread_mutex_unlock(&g_snap_mtx);
}

/* Enter a grass level: fire JSON + request snap */
static void grass_enter_level(int level)
{
    char ts[32];
    now_iso(ts, sizeof(ts));
    printf("[EVT] grass Level %d  id=%s\n", level, g_grass.event_id);

    /* spec: type = "lane" (超出車道邊界), not "grass" */
    fire_json_async(g_grass.event_id, "lane", level);

    char filename[32];
    snprintf(filename, sizeof(filename), "level%d.jpg", level);
    request_snap(filename, g_grass.work_dir,
                 g_grass.event_id, "grass", level, 0 /* not immediate */);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * Public API
 * ═══════════════════════════════════════════════════════════════════════════ */

void event_recorder_init(const char *upload_url,
                         const char *sd_path,
                         int         sd_max_mb,
                         int         upload_delay_ms,
                         int         enable)
{
    s_enabled         = enable;
    s_upload_delay_ms = (upload_delay_ms >= 0) ? upload_delay_ms : 60000;
    s_sd_max_bytes    = (long long)sd_max_mb * 1024 * 1024;

    if (upload_url && *upload_url) parse_url_into(upload_url,
                                                  s_up_host, sizeof(s_up_host),
                                                  &s_up_port,
                                                  s_up_path, sizeof(s_up_path));
    if (sd_path    && *sd_path)    snprintf(s_sd_path, sizeof(s_sd_path), "%s", sd_path);

    printf("[EVT] init: enable=%d ch_a=bt_uart ch_b=%s:%d%s sd=%s max=%dMB delay=%dms\n",
           s_enabled,
           s_up_host, s_up_port, s_up_path,
           s_sd_path, sd_max_mb, s_upload_delay_ms);
}

/* ── Recv callback: drives state machine ─────────────────────────────────── */
void event_recorder_update(const stdc_analysis_t *ana)
{
    if (!s_enabled) return;

    /*
     * Grass trigger rules (per spec: 超出車道邊界 = type "lane"):
     *   - Initial entry into L1 requires on_grass AND is_moving
     *     (prevents false trigger if cart was parked on grass at startup)
     *   - Once in L1/L2/L3, only on_grass is checked for hysteresis exit
     *     (is_moving may flicker while driving slowly → don't let it break the timer)
     *   - Level timers count wall-clock from t_l1 regardless of brief gaps
     *   - GRASS_EXIT_HYSTERESIS_MS of sustained !on_grass required to end event
     */
    int on_grass     = ana->on_grass;
    int grass_trigger = on_grass && ana->is_moving;

    /* ── Grass state machine ─────────────────────────────────────────────── */
    pthread_mutex_lock(&g_grass_mtx);

    switch (g_grass.state) {

    case GRASS_IDLE:
        if (grass_trigger) {
            gettimeofday(&g_grass.t_l1, NULL);
            g_grass.t_last_active = g_grass.t_l1;
            g_grass.max_level = 1;
            g_grass.upload_busy = 0;
            snprintf(g_grass.event_id, sizeof(g_grass.event_id),
                     "%ld", g_grass.t_l1.tv_sec);
            make_work_dir(g_grass.event_id, g_grass.work_dir, sizeof(g_grass.work_dir));
            g_grass.state = GRASS_L1;
            pthread_mutex_unlock(&g_grass_mtx);
            grass_enter_level(1);
            return;
        }
        break;

    case GRASS_L1:
        if (on_grass) {
            gettimeofday(&g_grass.t_last_active, NULL);
            if (elapsed_ms_tv(&g_grass.t_l1) >= 6000) {
                g_grass.max_level = 2;
                g_grass.state = GRASS_L2;
                pthread_mutex_unlock(&g_grass_mtx);
                grass_enter_level(2);
                return;
            }
        } else if (elapsed_ms_tv(&g_grass.t_last_active) >= GRASS_EXIT_HYSTERESIS_MS) {
            /* Sustained absence — event ends */
            g_grass.state = GRASS_DONE;
            gettimeofday(&g_grass.t_done, NULL);
            g_grass.upload_busy = 1;
            pthread_mutex_unlock(&g_grass_mtx);
            fire_json_async(g_grass.event_id, "lane", 0);
            float dur = (float)elapsed_ms_tv(&g_grass.t_l1) / 1000.0f;
            const char imgs[4][64] = { "level1.jpg", "", "", "" };
            launch_upload(g_grass.work_dir, g_grass.event_id, "grass",
                          g_grass.max_level, dur, imgs, 1, s_upload_delay_ms);
            return;
        }
        /* else: still within hysteresis window — keep L1, timer keeps running */
        break;

    case GRASS_L2:
        if (on_grass) {
            gettimeofday(&g_grass.t_last_active, NULL);
            if (elapsed_ms_tv(&g_grass.t_l1) >= 10000) {
                g_grass.max_level = 3;
                g_grass.state = GRASS_L3;
                pthread_mutex_unlock(&g_grass_mtx);
                grass_enter_level(3);
                return;
            }
        } else if (elapsed_ms_tv(&g_grass.t_last_active) >= GRASS_EXIT_HYSTERESIS_MS) {
            g_grass.state = GRASS_DONE;
            gettimeofday(&g_grass.t_done, NULL);
            g_grass.upload_busy = 1;
            pthread_mutex_unlock(&g_grass_mtx);
            fire_json_async(g_grass.event_id, "lane", 0);
            float dur = (float)elapsed_ms_tv(&g_grass.t_l1) / 1000.0f;
            const char imgs[4][64] = { "level1.jpg", "level2.jpg", "", "" };
            launch_upload(g_grass.work_dir, g_grass.event_id, "grass",
                          g_grass.max_level, dur, imgs, 2, s_upload_delay_ms);
            return;
        }
        break;

    case GRASS_L3:
        if (on_grass) {
            gettimeofday(&g_grass.t_last_active, NULL);
        } else if (elapsed_ms_tv(&g_grass.t_last_active) >= GRASS_EXIT_HYSTERESIS_MS) {
            g_grass.state = GRASS_DONE;
            gettimeofday(&g_grass.t_done, NULL);
            g_grass.upload_busy = 1;
            pthread_mutex_unlock(&g_grass_mtx);
            fire_json_async(g_grass.event_id, "lane", 0);
            float dur = (float)elapsed_ms_tv(&g_grass.t_l1) / 1000.0f;
            const char imgs[4][64] = { "level1.jpg", "level2.jpg", "level3.jpg", "" };
            launch_upload(g_grass.work_dir, g_grass.event_id, "grass",
                          g_grass.max_level, dur, imgs, 3, s_upload_delay_ms);
            return;
        }
        break;

    case GRASS_DONE:
        /* Upload thread will reset to IDLE when done */
        break;
    }

    pthread_mutex_unlock(&g_grass_mtx);

    /* ── Single-shot events (Collision ROI) ─────────────────────────────── */
    /*
     * Trigger only when the hazard appears inside the collision ROI
     * (centre 25%–75% × 25%–70% of the frame), not from global class ratios.
     * Only fire on rising edge (0→1 transition) to avoid repeated triggers.
     */
    struct { int cur; int *last; const char *type; } singles[] = {
        { ana->col_person_ratio >= THR_PERSON_COLLISION, &g_last_person, "person" },
        { ana->col_bunker_ratio >= THR_BUNKER_COLLISION, &g_last_bunker, "bunker" },
        { ana->col_pond_ratio   >= THR_POND_COLLISION,   &g_last_pond,   "pond"   },
        { ana->col_tree_ratio   >= THR_TREE_COLLISION,   &g_last_tree,   "tree"   },
    };

    for (int i = 0; i < 4; i++) {
        if (singles[i].cur && !*singles[i].last) {
            const char *type = singles[i].type;

            char ev_id[32];
            struct timeval now;
            gettimeofday(&now, NULL);
            /* Include type in id to avoid work_dir collision with grass events at same second */
            snprintf(ev_id, sizeof(ev_id), "%ld_%s", now.tv_sec, type);

            char work_dir[256];
            make_work_dir(ev_id, work_dir, sizeof(work_dir));

            printf("[EVT] single-shot: %s  id=%s\n", type, ev_id);
            fire_json_async(ev_id, type, 1);
            request_snap("snapshot.jpg", work_dir, ev_id, type, 1, 1 /* immediate */);
        }
        *singles[i].last = singles[i].cur;
    }
}

/* ── Send callback: take VMF_SNAP if one is pending ─────────────────────── */
void event_recorder_provide_frame(void)
{
    if (!s_enabled) return;

    pthread_mutex_lock(&g_snap_mtx);
    if (!g_snap_req.active) {
        pthread_mutex_unlock(&g_snap_mtx);
        return;
    }
    /* Copy request locally and clear the flag */
    SnapReq req = g_snap_req;
    g_snap_req.active = 0;
    pthread_mutex_unlock(&g_snap_mtx);

    if (snap_lazy_init() != 0) return;

    uint8_t *jpeg = (uint8_t *)MemBroker_GetMemory(SNAP_BUF_SIZE, VMF_ALIGN_TYPE_DEFAULT);
    if (!jpeg) {
        printf("[EVT] snap: MemBroker_GetMemory failed\n");
        return;
    }

    int jpeg_size = VMF_SNAP_ProcessOneFrame(s_snap, 1920, 1080, SNAP_BUF_SIZE, jpeg);
    int snap_ok = (jpeg_size > 0);
    if (snap_ok) {
        char out_path[384];
        snprintf(out_path, sizeof(out_path), "%s/%s", req.work_dir, req.filename);
        save_jpeg(out_path, jpeg, jpeg_size);
    } else {
        printf("[EVT] snap: ProcessOneFrame failed (%d) — upload will proceed without image\n", jpeg_size);
    }
    MemBroker_FreeMemory(jpeg);

    /* Single-shot: launch upload immediately (delay_ms = 0).
     * Always upload even if snap failed — event.json still carries the record. */
    if (req.immediate_upload) {
        const char imgs[4][64] = { "snapshot.jpg", "", "", "" };
        int image_count = snap_ok ? 1 : 0;
        launch_upload(req.work_dir, req.event_id, req.event_type,
                      req.max_level, 0.0f, imgs, image_count, 0);
    }
}
