// scenario_server.c — minimal HTTP server for receiving Runtime 3 IR scenarios
// on the ESP32-S3-BOX-3. Mirrors the master's game_endpoint handler but is
// self-contained (no shared component) since box3_voice is a separate IDF
// project. Storage uses the existing SPIFFS partition declared in
// partitions.csv (master uses LittleFS — both work, we match the local table).

#include "scenario_server.h"

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

#include "cJSON.h"
#include "cmd_exec.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_spiffs.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

/* BSP SD card support (bsp_sdcard_mount, BSP_SD_MOUNT_POINT) */
#include "bsp/esp-bsp.h"

#define TAG "scenario_srv"

#define MAX_SCENARIO_BYTES (64 * 1024)
#define SPIFFS_LABEL       "spiffs"
#define SPIFFS_BASE        "/spiffs"
#define SCENARIO_PATH      SPIFFS_BASE "/scenario.json"
#define SCENARIO_BAK       SPIFFS_BASE "/scenario.bak"

static httpd_handle_t s_server = NULL;
static bool s_spiffs_mounted = false;

// ---------- helpers ----------

static esp_err_t send_json(httpd_req_t *req, const char *status_line, const char *body) {
    httpd_resp_set_status(req, status_line);
    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_sendstr(req, body);
}

static esp_err_t send_error(httpd_req_t *req, const char *status_line, const char *message) {
    char buf[192];
    snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : "");
    return send_json(req, status_line, buf);
}

static esp_err_t mount_spiffs_lazy(void) {
    if (s_spiffs_mounted) return ESP_OK;
    esp_vfs_spiffs_conf_t conf = {
        .base_path = SPIFFS_BASE,
        .partition_label = SPIFFS_LABEL,
        .max_files = 6,
        .format_if_mount_failed = true,
    };
    esp_err_t err = esp_vfs_spiffs_register(&conf);
    if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) {
        s_spiffs_mounted = true;
        ESP_LOGI(TAG, "spiffs '%s' mounted at %s", conf.partition_label, conf.base_path);
        return ESP_OK;
    }
    ESP_LOGE(TAG, "spiffs mount failed: %s", esp_err_to_name(err));
    return err;
}

static void deferred_restart_task(void *arg) {
    (void) arg;
    vTaskDelay(pdMS_TO_TICKS(800));
    ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR");
    esp_restart();
}

static void schedule_restart(void) {
    xTaskCreate(deferred_restart_task, "scenario_restart",
                4096, NULL, tskIDLE_PRIORITY + 1, NULL);
}

// ---------- handlers ----------

static esp_err_t handle_healthz_get(httpd_req_t *req) {
    httpd_resp_set_type(req, "text/plain");
    return httpd_resp_sendstr(req, "ok");
}

// Shared internal apply path. Returns ESP_OK on success and fills the optional
// out-params; on failure returns a specific esp_err_t and (if non-NULL) sets a
// static reason string. The HTTP handler and the ESP-NOW receiver both call
// this — the single `_scenario_apply` the spec mandates.
static esp_err_t scenario_apply_internal(const char *body, size_t len,
                                         int *steps_count_out,
                                         char *entry_out, size_t entry_cap,
                                         const char **err_msg_out) {
    if (err_msg_out) *err_msg_out = NULL;
    if (steps_count_out) *steps_count_out = 0;
    if (entry_out && entry_cap) entry_out[0] = '\0';

    if (!body || len == 0 || len > MAX_SCENARIO_BYTES) {
        if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes";
        return ESP_ERR_INVALID_SIZE;
    }
    if (mount_spiffs_lazy() != ESP_OK) {
        if (err_msg_out) *err_msg_out = "spiffs mount failed";
        return ESP_FAIL;
    }

    cJSON *root = cJSON_Parse(body);
    if (!root) {
        if (err_msg_out) *err_msg_out = "malformed json";
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version");
    if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) {
        cJSON_Delete(root);
        if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1";
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
    if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
        cJSON_Delete(root);
        if (err_msg_out) *err_msg_out = "steps must be a non-empty array";
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario");
    const cJSON *entry = scenario_obj
        ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL;
    if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) {
        strncpy(entry_out, entry->valuestring, entry_cap - 1);
        entry_out[entry_cap - 1] = '\0';
    }
    int steps_count = cJSON_GetArraySize(steps);
    cJSON_Delete(root);
    if (steps_count_out) *steps_count_out = steps_count;

    // Rotate current -> .bak
    struct stat st;
    if (stat(SCENARIO_PATH, &st) == 0) {
        unlink(SCENARIO_BAK);
        if (rename(SCENARIO_PATH, SCENARIO_BAK) != 0) {
            ESP_LOGW(TAG, "rename .json -> .bak failed (errno=%d)", errno);
        }
    }

    FILE *f = fopen(SCENARIO_PATH, "wb");
    if (!f) {
        ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", SCENARIO_PATH, errno);
        if (err_msg_out) *err_msg_out = "scenario write open failed";
        return ESP_FAIL;
    }
    size_t written = fwrite(body, 1, len, f);
    fclose(f);
    if (written != len) {
        unlink(SCENARIO_PATH);
        if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH);
        if (err_msg_out) *err_msg_out = "scenario write short";
        return ESP_FAIL;
    }

    ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s",
             len, steps_count, (entry_out && entry_cap) ? entry_out : "");
    schedule_restart();
    return ESP_OK;
}

// Public thin wrapper used by the ESP-NOW receiver (matches
// scenario_mesh_apply_cb_t: esp_err_t (*)(const char *, size_t)).
esp_err_t scenario_apply_buffer(const char *data, size_t len) {
    const char *emsg = NULL;
    esp_err_t err = scenario_apply_internal(data, len, NULL, NULL, 0, &emsg);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s",
                 emsg ? emsg : esp_err_to_name(err));
    }
    return err;
}

static esp_err_t handle_scenario_post(httpd_req_t *req) {
    if (req->content_len <= 0 || req->content_len > MAX_SCENARIO_BYTES) {
        ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", (int) req->content_len);
        return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes");
    }
    char *body = (char *) malloc((size_t) req->content_len + 1);
    if (!body) return send_error(req, "500 Internal Server Error", "out of memory");
    int total = 0;
    while (total < (int) req->content_len) {
        int got = httpd_req_recv(req, body + total, req->content_len - total);
        if (got <= 0) {
            if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
            free(body);
            return send_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    int steps_count = 0;
    char entry_str[64] = {0};
    const char *emsg = NULL;
    esp_err_t aerr = scenario_apply_internal(body, (size_t) total, &steps_count,
                                             entry_str, sizeof(entry_str), &emsg);
    free(body);
    if (aerr != ESP_OK) {
        const char *status = (aerr == ESP_ERR_INVALID_ARG ||
                              aerr == ESP_ERR_INVALID_SIZE)
            ? "400 Bad Request" : "500 Internal Server Error";
        return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr));
    }

    char buf[256];
    snprintf(buf, sizeof(buf),
             "{\"status\":\"ok\",\"board\":\"box3_voice\",\"steps_count\":%d,"
             "\"entry_step_id\":\"%s\",\"bytes\":%d,\"reload\":\"reboot_pending\"}",
             steps_count, entry_str, total);
    return send_json(req, "200 OK", buf);
}

// ---------- POST /game/cmd ----------
//
// WiFi-direct CMD endpoint: receives {op,a} JSON frames (≤512 bytes) and
// forwards them to cmd_exec_handle(). Replaces ESP-NOW CMD path for annexes.

#define MAX_CMD_BYTES 512

static esp_err_t handle_cmd_post(httpd_req_t *req) {
    if (req->content_len <= 0 || req->content_len > MAX_CMD_BYTES) {
        ESP_LOGW(TAG, "POST /game/cmd: bad content_len=%d", (int)req->content_len);
        return send_error(req, "400 Bad Request", "body must be 1..512 bytes");
    }
    char body[MAX_CMD_BYTES + 1];
    int total = 0;
    while (total < (int)req->content_len) {
        int got = httpd_req_recv(req, body + total, req->content_len - total);
        if (got <= 0) {
            if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
            return send_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    ESP_LOGI(TAG, "POST /game/cmd (%d B): %.*s", total, total < 80 ? total : 80, body);
    esp_err_t err = cmd_exec_handle(body, (size_t)total);
    if (err == ESP_ERR_INVALID_ARG) {
        return send_error(req, "400 Bad Request", "malformed cmd json or missing op");
    }
    return send_json(req, "200 OK", "{\"ok\":true}");
}

// ---------- POST /game/file ----------
//
// Write binary assets directly to the SD card over HTTP.
// Query param: ?path=sd/<relative/path>  (only "sd/" prefix accepted)
// Route: sd/<…> → /sdcard/<…>  (mkdir -p, up to 8 MiB, anti-traversal).
// Returns 503 if SD is not mounted, 403 on bad path, 200 JSON on success.

#define MAX_FILE_BYTES (8L * 1024L * 1024L)

static bool s_sd_mounted = false;

static esp_err_t ensure_sd_mounted(void) {
    if (s_sd_mounted) return ESP_OK;
    esp_err_t ret = bsp_sdcard_mount();
    if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) {
        s_sd_mounted = true;
        ESP_LOGI(TAG, "file: SD mounted at %s", BSP_SD_MOUNT_POINT);
        return ESP_OK;
    }
    ESP_LOGW(TAG, "file: SD mount failed: %s", esp_err_to_name(ret));
    return ret;
}

static esp_err_t handle_file_post(httpd_req_t *req) {
    char query[160], path_param[96];
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
        httpd_query_key_value(query, "path", path_param,
                              sizeof(path_param)) != ESP_OK) {
        return send_error(req, "400 Bad Request", "missing path param");
    }

    // Only accept sd/<…> prefix; reject traversal and bare directory paths.
    if (strncmp(path_param, "sd/", 3) != 0 ||
        strstr(path_param, "..") ||
        path_param[strlen(path_param) - 1] == '/') {
        return send_error(req, "403 Forbidden", "path must start with sd/ and name a file");
    }

    if (req->content_len <= 0 || (long)req->content_len > MAX_FILE_BYTES) {
        return send_error(req, "400 Bad Request", "file too large or empty");
    }

    if (ensure_sd_mounted() != ESP_OK) {
        return send_error(req, "503 Service Unavailable", "sd_not_mounted");
    }

    // Build absolute destination: /sdcard/<…> (strip the "sd/" prefix).
    char full[192];
    snprintf(full, sizeof(full), "%s/%s", BSP_SD_MOUNT_POINT, path_param + 3);

    // mkdir -p for all intermediate directories.
    const size_t root_len = strlen(BSP_SD_MOUNT_POINT) + 1; // e.g. strlen("/sdcard/")
    for (char *p = full + root_len; *p; p++) {
        if (*p == '/') {
            *p = '\0';
            mkdir(full, 0775);   // EEXIST is fine
            *p = '/';
        }
    }

    FILE *f = fopen(full, "wb");
    if (!f) {
        ESP_LOGE(TAG, "file: fopen %s failed (errno=%d)", full, errno);
        return send_error(req, "500 Internal Server Error", "open failed");
    }

    char buf[1024];
    int remaining = (int)req->content_len;
    while (remaining > 0) {
        const int want = remaining < (int)sizeof(buf) ? remaining : (int)sizeof(buf);
        int got = httpd_req_recv(req, buf, want);
        if (got <= 0) {
            if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
            fclose(f);
            unlink(full);
            return send_error(req, "400 Bad Request", "recv failed");
        }
        if (fwrite(buf, 1, (size_t)got, f) != (size_t)got) {
            fclose(f);
            unlink(full);
            return send_error(req, "500 Internal Server Error", "write failed");
        }
        remaining -= got;
    }
    fclose(f);

    char resp[192];
    snprintf(resp, sizeof(resp),
             "{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%d}",
             path_param, (int)req->content_len);
    ESP_LOGI(TAG, "file: stored %s (%d B)", full, (int)req->content_len);
    return send_json(req, "200 OK", resp);
}

// ---------- public init ----------

httpd_handle_t scenario_server_handle(void) {
    return s_server;
}

esp_err_t scenario_server_start(void) {
    if (s_server) {
        ESP_LOGW(TAG, "scenario_server already running");
        return ESP_OK;
    }
    httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
    cfg.server_port = 80;
    cfg.max_uri_handlers = 12;
    cfg.stack_size = 8192;

    esp_err_t err = httpd_start(&s_server, &cfg);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(err));
        return err;
    }

    static const httpd_uri_t uri_healthz = {
        .uri = "/healthz", .method = HTTP_GET,
        .handler = handle_healthz_get, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_scenario = {
        .uri = "/game/scenario", .method = HTTP_POST,
        .handler = handle_scenario_post, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_file = {
        .uri = "/game/file", .method = HTTP_POST,
        .handler = handle_file_post, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_cmd = {
        .uri = "/game/cmd", .method = HTTP_POST,
        .handler = handle_cmd_post, .user_ctx = NULL,
    };
    httpd_register_uri_handler(s_server, &uri_healthz);
    httpd_register_uri_handler(s_server, &uri_scenario);
    httpd_register_uri_handler(s_server, &uri_file);
    httpd_register_uri_handler(s_server, &uri_cmd);

    ESP_LOGI(TAG, "scenario server up on :80 (GET /healthz, POST /game/scenario, POST /game/file, POST /game/cmd)");
    return ESP_OK;
}
