// scenario_mesh — see include/scenario_mesh.h for the design notes.
//
// Tasks 4 & 5 of the firmware-scenario-hotload spec: ESP-NOW frame protocol
// (chunk + reassembly) for relaying Runtime 3 IR to WiFi-disabled peers.

#include "scenario_mesh.h"

#include <string.h>

#include "esp_log.h"
#include "esp_now.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"

static const char *TAG = "scenario_mesh";

static const uint8_t kBroadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

// ─── alias -> MAC peer table (minimal local registry) ───────────────────────

#define MESH_ALIAS_MAX  32
#define MESH_PEERS_MAX  8

typedef struct {
    bool    used;
    char    alias[MESH_ALIAS_MAX];
    uint8_t mac[6];
} mesh_peer_t;

static mesh_peer_t s_peers[MESH_PEERS_MAX];

// ─── send-side ack synchronization ──────────────────────────────────────────

static SemaphoreHandle_t s_send_done;   // given by on_sent for each frame
static volatile esp_now_send_status_t s_last_send_status;
static SemaphoreHandle_t s_send_lock;   // serializes scenario_mesh_send calls

// ─── receive-side reassembly ────────────────────────────────────────────────
//
// One in-flight reassembly slot per distinct (sender MAC, total) tuple. A
// single slot is enough in practice — the master relays to one peer at a time
// and frames arrive in order — but we keep a small array so two senders (or a
// retried transfer) don't clobber each other.

#define MESH_REASM_SLOTS  2

typedef struct {
    bool     used;
    uint8_t  src[6];
    uint16_t total;
    uint16_t received;        // count of distinct frames stored
    bool    *seen;            // [total] frame-arrival bitmap (heap)
    char    *buf;             // [total * PAYLOAD_MAX + 1] (heap)
    size_t   buf_len;         // running max byte offset written + payload
    uint32_t last_tick;       // for staleness eviction
} mesh_reasm_t;

static mesh_reasm_t              s_reasm[MESH_REASM_SLOTS];
static SemaphoreHandle_t         s_reasm_lock;
static scenario_mesh_apply_cb_t  s_apply_cb;

// A completed reassembly is handed off to a worker task rather than applied in
// the Wi-Fi recv-callback context: the apply does filesystem I/O (and triggers
// a reboot) which must not run on the Wi-Fi stack's callback.
typedef struct {
    char  *buf;     // heap, NUL-terminated, ownership transferred to the task
    size_t len;
} mesh_apply_job_t;

static QueueHandle_t s_apply_queue;

// ─── CMD/EVT text frames (header sentinel seq=0xFFFF, total=kind) ───────────

#define MESH_TEXT_SEQ_SENTINEL 0xFFFFu

typedef struct {
    uint8_t kind;
    uint8_t src[6];
    char    text[SCENARIO_MESH_TEXT_MAX + 1];
} mesh_text_job_t;

static scenario_mesh_text_cb_t s_text_cb;
static QueueHandle_t           s_text_queue;

static void text_worker_task(void *arg) {
    (void) arg;
    mesh_text_job_t job;
    for (;;) {
        if (xQueueReceive(s_text_queue, &job, portMAX_DELAY) == pdTRUE) {
            scenario_mesh_text_cb_t cb = s_text_cb;
            if (cb) cb(job.kind, job.src, job.text);
        }
    }
}

static void apply_worker_task(void *arg) {
    (void) arg;
    mesh_apply_job_t job;
    for (;;) {
        if (xQueueReceive(s_apply_queue, &job, portMAX_DELAY) != pdTRUE) {
            continue;
        }
        if (s_apply_cb) {
            esp_err_t aerr = s_apply_cb(job.buf, job.len);
            if (aerr != ESP_OK) {
                ESP_LOGW(TAG, "apply_cb returned %s", esp_err_to_name(aerr));
            }
        } else {
            ESP_LOGW(TAG, "no apply_cb registered — dropping scenario");
        }
        free(job.buf);
    }
}

// ─── peer table helpers ─────────────────────────────────────────────────────

int scenario_mesh_peer_count(void) {
    int n = 0;
    for (int i = 0; i < MESH_PEERS_MAX; i++) {
        if (s_peers[i].used) n++;
    }
    return n;
}

esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) {
    if (!alias || !mac) return ESP_ERR_INVALID_ARG;

    int free_slot = -1;
    for (int i = 0; i < MESH_PEERS_MAX; i++) {
        if (s_peers[i].used && strncmp(s_peers[i].alias, alias,
                                       MESH_ALIAS_MAX) == 0) {
            free_slot = i;  // update existing
            break;
        }
        if (!s_peers[i].used && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) {
        ESP_LOGE(TAG, "peer table full, cannot register \"%s\"", alias);
        return ESP_ERR_NO_MEM;
    }

    s_peers[free_slot].used = true;
    strncpy(s_peers[free_slot].alias, alias, MESH_ALIAS_MAX - 1);
    s_peers[free_slot].alias[MESH_ALIAS_MAX - 1] = '\0';
    memcpy(s_peers[free_slot].mac, mac, 6);

    // Add (or refresh) the ESP-NOW peer entry. esp_now_add_peer fails with
    // ESP_ERR_ESPNOW_EXIST if already present — treat that as success.
    esp_now_peer_info_t pi = {0};
    memcpy(pi.peer_addr, mac, 6);
    pi.channel = 0;        // current channel
    pi.ifidx   = WIFI_IF_STA;
    pi.encrypt = false;
    esp_err_t err = esp_now_add_peer(&pi);
    if (err == ESP_ERR_ESPNOW_EXIST) {
        err = ESP_OK;
    } else if (err != ESP_OK) {
        ESP_LOGW(TAG, "esp_now_add_peer(%s) failed: %s",
                 alias, esp_err_to_name(err));
    }

    ESP_LOGI(TAG, "peer \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x",
             alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    return err;
}

esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]) {
    if (!alias || !mac_out) return ESP_ERR_INVALID_ARG;
    for (int i = 0; i < MESH_PEERS_MAX; i++) {
        if (s_peers[i].used &&
            strncmp(s_peers[i].alias, alias, MESH_ALIAS_MAX) == 0) {
            memcpy(mac_out, s_peers[i].mac, 6);
            return ESP_OK;
        }
    }
    return ESP_ERR_NOT_FOUND;
}

// ─── ESP-NOW callbacks (Wi-Fi task context — keep short) ────────────────────

static void on_sent(const uint8_t *mac, esp_now_send_status_t status) {
    (void) mac;
    s_last_send_status = status;
    if (s_send_done) {
        BaseType_t hp = pdFALSE;
        xSemaphoreGiveFromISR(s_send_done, &hp);
        if (hp) portYIELD_FROM_ISR();
    }
}

// Locate (or allocate) the reassembly slot for this sender/total. Caller holds
// s_reasm_lock. Returns NULL on allocation failure.
static mesh_reasm_t *reasm_slot_for(const uint8_t src[6], uint16_t total) {
    mesh_reasm_t *free_slot = NULL;
    mesh_reasm_t *oldest    = NULL;
    for (int i = 0; i < MESH_REASM_SLOTS; i++) {
        mesh_reasm_t *r = &s_reasm[i];
        if (r->used && r->total == total && memcmp(r->src, src, 6) == 0) {
            return r;
        }
        if (!r->used && !free_slot) free_slot = r;
        if (r->used && (!oldest || r->last_tick < oldest->last_tick)) oldest = r;
    }

    // New transfer. Reuse a free slot, else evict the oldest in-flight one.
    mesh_reasm_t *r = free_slot ? free_slot : oldest;
    if (!r) return NULL;
    if (r->used) {
        ESP_LOGW(TAG, "evicting stale reassembly (%u/%u frames)",
                 r->received, r->total);
        free(r->seen);
        free(r->buf);
    }
    memset(r, 0, sizeof(*r));

    if (total == 0 || (size_t) total * SCENARIO_MESH_PAYLOAD_MAX > SCENARIO_MESH_MAX_BLOB) {
        ESP_LOGW(TAG, "reject transfer: implausible total=%u", total);
        return NULL;
    }
    r->seen = calloc(total, sizeof(bool));
    r->buf  = malloc((size_t) total * SCENARIO_MESH_PAYLOAD_MAX + 1);
    if (!r->seen || !r->buf) {
        free(r->seen);
        free(r->buf);
        ESP_LOGE(TAG, "OOM allocating reassembly for total=%u", total);
        return NULL;
    }
    r->used  = true;
    r->total = total;
    memcpy(r->src, src, 6);
    return r;
}

static void on_recv(const esp_now_recv_info_t *info,
                    const uint8_t *data, int len) {
    if (!info || !data || len < SCENARIO_MESH_HEADER_BYTES) return;
    if (len > SCENARIO_MESH_FRAME_MAX) return;

    // Header: seq:u16, total:u16 (little-endian).
    uint16_t seq   = (uint16_t) (data[0] | (data[1] << 8));
    uint16_t total = (uint16_t) (data[2] | (data[3] << 8));
    const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES;
    int payload_len = len - SCENARIO_MESH_HEADER_BYTES;

    // CMD/EVT text frame: sentinel seq + kind in `total`. Old receivers fall
    // through to the malformed check below and drop it silently.
    if (seq == MESH_TEXT_SEQ_SENTINEL &&
        (total == SCENARIO_MESH_TEXT_CMD || total == SCENARIO_MESH_TEXT_EVT)) {
        if (!s_text_queue || payload_len > SCENARIO_MESH_TEXT_MAX) return;
        mesh_text_job_t job;
        job.kind = (uint8_t) total;
        memcpy(job.src, info->src_addr, 6);
        memcpy(job.text, payload, payload_len);
        job.text[payload_len] = '\0';
        BaseType_t hp = pdFALSE;
        xQueueSendFromISR(s_text_queue, &job, &hp);
        if (hp) portYIELD_FROM_ISR();
        return;
    }
    if (seq >= total) return;  // malformed

    if (!s_reasm_lock) return;
    // Run reassembly off the Wi-Fi callback by doing the bookkeeping under a
    // mutex here; the (potentially slow) apply is deferred to a short task so
    // we never block the Wi-Fi stack inside the recv callback.
    char  *complete_buf = NULL;
    size_t complete_len = 0;

    xSemaphoreTake(s_reasm_lock, portMAX_DELAY);
    mesh_reasm_t *r = reasm_slot_for(info->src_addr, total);
    if (r) {
        if (!r->seen[seq]) {
            r->seen[seq] = true;
            r->received++;
            size_t off = (size_t) seq * SCENARIO_MESH_PAYLOAD_MAX;
            memcpy(r->buf + off, payload, payload_len);
            // Track the highest end offset so the final length is exact even
            // though only the last frame is short.
            if (off + payload_len > r->buf_len) r->buf_len = off + payload_len;
        }
        r->last_tick = (uint32_t) xTaskGetTickCount();

        if (r->received == r->total) {
            r->buf[r->buf_len] = '\0';
            complete_buf = r->buf;
            complete_len = r->buf_len;
            free(r->seen);
            memset(r, 0, sizeof(*r));   // releases the slot; buf handed off
        }
    }
    xSemaphoreGive(s_reasm_lock);

    if (complete_buf) {
        ESP_LOGI(TAG, "reassembled scenario: %u bytes from "
                      "%02x:%02x:%02x:%02x:%02x:%02x",
                 (unsigned) complete_len,
                 info->src_addr[0], info->src_addr[1], info->src_addr[2],
                 info->src_addr[3], info->src_addr[4], info->src_addr[5]);
        // Hand off to the worker task — never touch the filesystem (or reboot)
        // from the Wi-Fi recv-callback context.
        mesh_apply_job_t job = { .buf = complete_buf, .len = complete_len };
        if (!s_apply_queue ||
            xQueueSend(s_apply_queue, &job, 0) != pdTRUE) {
            ESP_LOGW(TAG, "apply queue full/unavailable — dropping scenario");
            free(complete_buf);
        }
    }
}

// ─── init ───────────────────────────────────────────────────────────────────

esp_err_t scenario_mesh_init(scenario_mesh_apply_cb_t apply_cb) {
    s_apply_cb = apply_cb;

    if (!s_send_done)  s_send_done  = xSemaphoreCreateBinary();
    if (!s_send_lock)  s_send_lock  = xSemaphoreCreateMutex();
    if (!s_reasm_lock) s_reasm_lock = xSemaphoreCreateMutex();
    if (!s_send_done || !s_send_lock || !s_reasm_lock) {
        return ESP_ERR_NO_MEM;
    }

    // Receiver path: spin up the apply worker (and its job queue) so completed
    // reassemblies are applied off the Wi-Fi callback. A pure sender (master
    // relay with no apply_cb) skips this to save RAM.
    if (apply_cb && !s_apply_queue) {
        s_apply_queue = xQueueCreate(2, sizeof(mesh_apply_job_t));
        if (!s_apply_queue) return ESP_ERR_NO_MEM;
        BaseType_t ok = xTaskCreate(apply_worker_task, "scn_mesh_apply",
                                    4096, NULL, tskIDLE_PRIORITY + 2, NULL);
        if (ok != pdPASS) {
            vQueueDelete(s_apply_queue);
            s_apply_queue = NULL;
            return ESP_ERR_NO_MEM;
        }
    }

    // esp_now_init() requires Wi-Fi to be started already (the caller brings up
    // STA/AP). A second init returns ESP_ERR_ESPNOW_INTERNAL on some IDF lines;
    // since no other component in this tree owns ESP-NOW yet a hard failure is
    // genuinely fatal, but we keep the relay endpoint optional at the call site.
    esp_err_t err = esp_now_init();
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "esp_now_init: %s", esp_err_to_name(err));
        return err;
    }

    esp_now_register_send_cb(on_sent);
    esp_now_register_recv_cb(on_recv);

    // Broadcast peer (handy for future fan-out; unicast peers are added on
    // demand by scenario_mesh_register_peer).
    esp_now_peer_info_t bcast = {0};
    memcpy(bcast.peer_addr, kBroadcast, 6);
    bcast.channel = 0;
    bcast.ifidx   = WIFI_IF_STA;
    bcast.encrypt = false;
    esp_err_t berr = esp_now_add_peer(&bcast);
    if (berr != ESP_OK && berr != ESP_ERR_ESPNOW_EXIST) {
        ESP_LOGW(TAG, "esp_now_add_peer(broadcast): %s",
                 esp_err_to_name(berr));
    }

    ESP_LOGI(TAG, "scenario_mesh ready (apply_cb=%s)",
             apply_cb ? "set" : "none");
    return ESP_OK;
}

// ─── send (chunk + per-frame ack) ───────────────────────────────────────────

esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
                             const char *data, size_t len) {
    if (!dest_mac || !data || len == 0) return ESP_ERR_INVALID_ARG;
    if (len > SCENARIO_MESH_MAX_BLOB) return ESP_ERR_INVALID_SIZE;

    size_t total = (len + SCENARIO_MESH_PAYLOAD_MAX - 1) /
                   SCENARIO_MESH_PAYLOAD_MAX;
    if (total == 0 || total > 0xFFFF) return ESP_ERR_INVALID_SIZE;

    // Serialize: the single send-done semaphore is shared across frames.
    if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) {
        return ESP_FAIL;
    }

    esp_err_t result = ESP_OK;
    uint8_t frame[SCENARIO_MESH_FRAME_MAX];

    for (size_t seq = 0; seq < total; seq++) {
        size_t off   = seq * SCENARIO_MESH_PAYLOAD_MAX;
        size_t chunk = len - off;
        if (chunk > SCENARIO_MESH_PAYLOAD_MAX) chunk = SCENARIO_MESH_PAYLOAD_MAX;

        frame[0] = (uint8_t) (seq & 0xFF);
        frame[1] = (uint8_t) ((seq >> 8) & 0xFF);
        frame[2] = (uint8_t) (total & 0xFF);
        frame[3] = (uint8_t) ((total >> 8) & 0xFF);
        memcpy(frame + SCENARIO_MESH_HEADER_BYTES, data + off, chunk);

        // Drain any stale ack from a previous frame, then send + await ack.
        xSemaphoreTake(s_send_done, 0);
        s_last_send_status = ESP_NOW_SEND_FAIL;

        esp_err_t serr = esp_now_send(dest_mac, frame,
                                      SCENARIO_MESH_HEADER_BYTES + chunk);
        if (serr != ESP_OK) {
            ESP_LOGW(TAG, "esp_now_send frame %u/%u: %s",
                     (unsigned) seq, (unsigned) total, esp_err_to_name(serr));
            result = serr;
            break;
        }

        if (xSemaphoreTake(s_send_done,
                           pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS))
                != pdTRUE) {
            ESP_LOGW(TAG, "ack timeout on frame %u/%u",
                     (unsigned) seq, (unsigned) total);
            result = ESP_ERR_TIMEOUT;
            break;
        }
        if (s_last_send_status != ESP_NOW_SEND_SUCCESS) {
            ESP_LOGW(TAG, "frame %u/%u not acked by peer",
                     (unsigned) seq, (unsigned) total);
            result = ESP_ERR_TIMEOUT;  // surfaced as a skipped peer
            break;
        }
    }

    xSemaphoreGive(s_send_lock);

    if (result == ESP_OK) {
        ESP_LOGI(TAG, "sent scenario: %u bytes in %u frames",
                 (unsigned) len, (unsigned) total);
    }
    return result;
}

// ─── CMD/EVT text API ───────────────────────────────────────────────────────

esp_err_t scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb) {
    if (!s_text_queue) {
        s_text_queue = xQueueCreate(8, sizeof(mesh_text_job_t));
        if (!s_text_queue) return ESP_ERR_NO_MEM;
        BaseType_t ok = xTaskCreate(text_worker_task, "scn_mesh_text",
                                    3072, NULL, tskIDLE_PRIORITY + 2, NULL);
        if (ok != pdPASS) {
            vQueueDelete(s_text_queue);
            s_text_queue = NULL;
            return ESP_ERR_NO_MEM;
        }
    }
    s_text_cb = cb;
    return ESP_OK;
}

esp_err_t scenario_mesh_send_text(const uint8_t dest_mac[6], uint8_t kind,
                                  const char *text) {
    if (!dest_mac || !text ||
        (kind != SCENARIO_MESH_TEXT_CMD && kind != SCENARIO_MESH_TEXT_EVT)) {
        return ESP_ERR_INVALID_ARG;
    }
    size_t len = strlen(text);
    if (len == 0 || len > SCENARIO_MESH_TEXT_MAX) return ESP_ERR_INVALID_SIZE;

    const bool broadcast = (memcmp(dest_mac, kBroadcast, 6) == 0);

    if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) return ESP_FAIL;

    uint8_t frame[SCENARIO_MESH_FRAME_MAX];
    frame[0] = (uint8_t) (MESH_TEXT_SEQ_SENTINEL & 0xFF);
    frame[1] = (uint8_t) ((MESH_TEXT_SEQ_SENTINEL >> 8) & 0xFF);
    frame[2] = kind;
    frame[3] = 0;
    memcpy(frame + SCENARIO_MESH_HEADER_BYTES, text, len);

    xSemaphoreTake(s_send_done, 0);   // drain stale ack
    s_last_send_status = ESP_NOW_SEND_FAIL;

    esp_err_t result = esp_now_send(dest_mac, frame,
                                    SCENARIO_MESH_HEADER_BYTES + len);
    if (result == ESP_OK) {
        if (xSemaphoreTake(s_send_done,
                           pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS))
                != pdTRUE) {
            result = ESP_ERR_TIMEOUT;
        } else if (!broadcast &&
                   s_last_send_status != ESP_NOW_SEND_SUCCESS) {
            result = ESP_ERR_TIMEOUT;   // unicast: peer silent
        }
    }

    xSemaphoreGive(s_send_lock);

    ESP_LOGI(TAG, "text %s \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x: %s",
             kind == SCENARIO_MESH_TEXT_CMD ? "CMD" : "EVT", text,
             dest_mac[0], dest_mac[1], dest_mac[2],
             dest_mac[3], dest_mac[4], dest_mac[5],
             esp_err_to_name(result));
    return result;
}
