/*
 * el_runtime.c — El language C runtime implementation
 *
 * All functions use el_val_t (= int64_t) as the universal value type.
 * Strings are transported as their pointer address cast to int64_t.
 * On any 64-bit system sizeof(pointer) <= sizeof(int64_t), so this is safe.
 *
 * Compile with:
 *   cc -std=c11 -I<runtime-dir> -lcurl -lpthread -o <prog> <prog>.c el_runtime.c
 *
 * Link requirements: -lcurl (HTTP client + LLM), -lpthread (HTTP server).
 */

/* Feature-test macros must be set before any standard headers. _GNU_SOURCE
 * exposes clock_gettime/CLOCK_REALTIME, strcasecmp, and the dlfcn extensions
 * (RTLD_DEFAULT) — all of which macOS hands us without asking but glibc on
 * Debian gates behind an explicit opt-in.                                   */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include "el_runtime.h"

#include <stdarg.h>
#include <strings.h>     /* strcasecmp */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <math.h>
#include <time.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <dlfcn.h>      /* dlsym for http_set_handler fallback */
#include <unistd.h>
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
#include <curl/curl.h>

/* ── Internal allocators ─────────────────────────────────────────────────── */

/*
 * Per-request string arena
 *
 * Every El string allocated via el_strbuf / el_strdup during an HTTP request
 * is registered in a thread-local arena. When el_request_end() is called at
 * the end of the worker thread, every arena entry is freed — recovering all
 * the intermediate strings from el_str_concat chains (build_system_prompt,
 * engram_compile, etc.) that are otherwise leaked forever.
 *
 * Long-lived allocations (state_set values, engram internal storage) call
 * el_strdup_persist() / el_strbuf_persist() which bypass the arena entirely.
 */

#define EL_ARENA_INITIAL 512

typedef struct {
    char**  ptrs;
    size_t  count;
    size_t  cap;
} ElArena;

static _Thread_local ElArena _tl_arena = {NULL, 0, 0};
static _Thread_local int     _tl_arena_active = 0;

/* Binary-safe fs_read length — set by fs_read, consumed by http_send_response.
 * Allows serving PNGs and other binary files without strlen truncation. */
static _Thread_local size_t  _tl_fs_read_len  = 0;

static void el_arena_track(char* p) {
    if (!_tl_arena_active || !p) return;
    if (_tl_arena.count >= _tl_arena.cap) {
        size_t nc = _tl_arena.cap == 0 ? EL_ARENA_INITIAL : _tl_arena.cap * 2;
        char** grown = realloc(_tl_arena.ptrs, nc * sizeof(char*));
        if (!grown) return; /* can't track — will leak this one ptr, but don't crash */
        _tl_arena.ptrs = grown;
        _tl_arena.cap  = nc;
    }
    _tl_arena.ptrs[_tl_arena.count++] = p;
}

/* Called by http_worker before dispatching the El handler. */
void el_request_start(void) {
    _tl_arena.count  = 0;
    _tl_arena_active = 1;
}

/* Called by http_worker after the El handler returns and the response is sent.
 * Frees every intermediate string allocated during the request. */
void el_request_end(void) {
    _tl_arena_active = 0;
    for (size_t i = 0; i < _tl_arena.count; i++) {
        free(_tl_arena.ptrs[i]);
    }
    _tl_arena.count = 0;
}

/* Persistent allocation — bypasses the arena (state_set, engram internals). */
static char* el_strdup_persist(const char* s) {
    if (!s) return strdup("");
    return strdup(s);
}
static char* el_strbuf_persist(size_t n) {
    char* p = malloc(n + 1);
    if (!p) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    p[0] = '\0';
    return p;
}

static char* el_strdup(const char* s) {
    if (!s) { char* p = strdup(""); el_arena_track(p); return p; }
    char* p = strdup(s);
    el_arena_track(p);
    return p;
}

static char* el_strbuf(size_t n) {
    char* p = malloc(n + 1);
    if (!p) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    p[0] = '\0';
    el_arena_track(p);
    return p;
}

/* Wrap an allocated C string as el_val_t */
static el_val_t el_wrap_str(char* s) {
    return EL_STR(s);
}

/* ── I/O ──────────────────────────────────────────────────────────────────── */

void println(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (str) puts(str);
    else     puts("");
}

void print(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (str) fputs(str, stdout);
}

el_val_t readline(void) {
    char buf[4096];
    if (!fgets(buf, sizeof(buf), stdin)) return el_wrap_str(el_strdup(""));
    size_t len = strlen(buf);
    if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
    return el_wrap_str(el_strdup(buf));
}

/* ── String builtins ─────────────────────────────────────────────────────── */

el_val_t el_str_concat(el_val_t av, el_val_t bv) {
    const char* a = EL_CSTR(av);
    const char* b = EL_CSTR(bv);
    if (!a) a = "";
    if (!b) b = "";
    size_t la = strlen(a);
    size_t lb = strlen(b);
    char* out = el_strbuf(la + lb);
    memcpy(out, a, la);
    memcpy(out + la, b, lb);
    out[la + lb] = '\0';
    return el_wrap_str(out);
}

el_val_t str_eq(el_val_t av, el_val_t bv) {
    const char* a = EL_CSTR(av);
    const char* b = EL_CSTR(bv);
    if (!a || !b) return (el_val_t)(a == b);
    return (el_val_t)(strcmp(a, b) == 0);
}

el_val_t str_starts_with(el_val_t sv, el_val_t prefv) {
    const char* s      = EL_CSTR(sv);
    const char* prefix = EL_CSTR(prefv);
    if (!s || !prefix) return 0;
    size_t lp = strlen(prefix);
    return (el_val_t)(strncmp(s, prefix, lp) == 0);
}

el_val_t str_ends_with(el_val_t sv, el_val_t sufv) {
    const char* s      = EL_CSTR(sv);
    const char* suffix = EL_CSTR(sufv);
    if (!s || !suffix) return 0;
    size_t ls   = strlen(s);
    size_t lsuf = strlen(suffix);
    if (lsuf > ls) return 0;
    return (el_val_t)(strcmp(s + ls - lsuf, suffix) == 0);
}

el_val_t str_len(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    return (el_val_t)strlen(s);
}

el_val_t str_concat(el_val_t a, el_val_t b) {
    return el_str_concat(a, b);
}

el_val_t int_to_str(el_val_t n) {
    char buf[32];
    snprintf(buf, sizeof(buf), "%lld", (long long)n);
    return el_wrap_str(el_strdup(buf));
}

el_val_t str_to_int(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    return (el_val_t)atoll(s);
}

el_val_t str_slice(el_val_t sv, el_val_t start, el_val_t end) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    int64_t len = (int64_t)strlen(s);
    if (start < 0) start = 0;
    if (end > len) end = len;
    if (start >= end) return el_wrap_str(el_strdup(""));
    int64_t sz = end - start;
    char* out = el_strbuf((size_t)sz);
    memcpy(out, s + start, (size_t)sz);
    out[sz] = '\0';
    return el_wrap_str(out);
}

el_val_t str_contains(el_val_t sv, el_val_t subv) {
    const char* s   = EL_CSTR(sv);
    const char* sub = EL_CSTR(subv);
    if (!s || !sub) return 0;
    return (el_val_t)(strstr(s, sub) != NULL);
}

el_val_t str_replace(el_val_t sv, el_val_t fromv, el_val_t tov) {
    const char* s    = EL_CSTR(sv);
    const char* from = EL_CSTR(fromv);
    const char* to   = EL_CSTR(tov);
    if (!s || !from || !to) return el_wrap_str(el_strdup(s ? s : ""));
    size_t ls = strlen(s);
    size_t lf = strlen(from);
    size_t lt = strlen(to);
    if (lf == 0) return el_wrap_str(el_strdup(s));
    size_t count = 0;
    const char* p = s;
    while ((p = strstr(p, from)) != NULL) { count++; p += lf; }
    size_t out_sz = ls + count * lt + 1;
    char* out = el_strbuf(out_sz);
    char* dst = out;
    p = s;
    const char* found;
    while ((found = strstr(p, from)) != NULL) {
        size_t chunk = (size_t)(found - p);
        memcpy(dst, p, chunk); dst += chunk;
        memcpy(dst, to, lt);  dst += lt;
        p = found + lf;
    }
    strcpy(dst, p);
    return el_wrap_str(out);
}

el_val_t str_to_upper(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    for (size_t i = 0; i < n; i++) out[i] = (char)toupper((unsigned char)s[i]);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t str_to_lower(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    for (size_t i = 0; i < n; i++) out[i] = (char)tolower((unsigned char)s[i]);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t str_trim(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    while (*s && isspace((unsigned char)*s)) s++;
    size_t n = strlen(s);
    while (n > 0 && isspace((unsigned char)s[n - 1])) n--;
    char* out = el_strbuf(n);
    memcpy(out, s, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

/* ── Math ────────────────────────────────────────────────────────────────── */

el_val_t el_abs(el_val_t n)             { return n < 0 ? -n : n; }
el_val_t el_max(el_val_t a, el_val_t b) { return a > b ? a : b; }
el_val_t el_min(el_val_t a, el_val_t b) { return a < b ? a : b; }

/* ── Refcounted heap objects ──────────────────────────────────────────────────
 *
 * ElList and ElMap carry a magic-tagged header at offset 0:
 *   { uint32_t magic; uint32_t refcount; ... payload ... }
 *
 * The magic tag distinguishes refcounted objects from raw C strings (whose
 * first byte is printable ASCII < 0x80) and from small integers (which can't
 * be dereferenced). el_retain / el_release sniff the magic and act only on
 * matching values; everything else is a safe no-op.
 *
 * Both ElList and ElMap use INDIRECTION: the header is fixed-size and never
 * moves. The payload arrays (elems, keys, values) live in separate heap
 * allocations, so realloc-grow on append never invalidates the caller's
 * pointer to the header. This is what lets us mutate-in-place safely when
 * the refcount is 1 and copy-on-write when it's higher.
 *
 * Memory model in practice:
 *   Single-owner accumulator (the cg_stmts pattern) — refcount stays at 1,
 *   appends amortize to O(1), total memory O(N) for an N-element list.
 *   Multi-owner branching (the cg_if_stmt pattern) — refcount > 1, each
 *   append on a shared list copies, so the original is preserved for the
 *   else-branch. Persistent semantics where they're needed; mutation where
 *   they're not.                                                              */

#define EL_MAGIC_LIST  0xE15710A1u  /* >= 0x80 in MSB so 'looks_like_string' rejects */
#define EL_MAGIC_MAP   0xE19A704Bu

typedef struct {
    uint32_t magic;
    uint32_t refcount;
} ElHeader;

/* ── List ────────────────────────────────────────────────────────────────── */

typedef struct {
    ElHeader  hdr;
    int64_t   length;
    int64_t   capacity;
    el_val_t* elems;
} ElList;

static ElList* list_alloc(int64_t cap) {
    if (cap < 4) cap = 4;
    ElList* lst = malloc(sizeof(ElList));
    if (!lst) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    lst->hdr.magic = EL_MAGIC_LIST;
    lst->hdr.refcount = 1;
    lst->length   = 0;
    lst->capacity = cap;
    lst->elems    = malloc((size_t)cap * sizeof(el_val_t));
    if (!lst->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    return lst;
}

el_val_t el_list_empty(void) {
    return EL_STR(list_alloc(4));
}

el_val_t el_list_new(el_val_t count, ...) {
    ElList* lst = list_alloc(count > 0 ? count : 4);
    va_list ap;
    va_start(ap, count);
    for (int64_t i = 0; i < count; i++) {
        lst->elems[i] = va_arg(ap, el_val_t);
    }
    va_end(ap);
    lst->length = count;
    return EL_STR(lst);
}

el_val_t el_list_len(el_val_t listv) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    if (!lst) return 0;
    return lst->length;
}

el_val_t el_list_get(el_val_t listv, el_val_t index) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    if (!lst) return 0;
    if (index < 0 || index >= lst->length) return 0;
    return lst->elems[index];
}

el_val_t el_list_append(el_val_t listv, el_val_t elem) {
    ElList* old = (ElList*)(uintptr_t)listv;
    if (!old) {
        ElList* fresh = list_alloc(4);
        fresh->elems[0] = elem;
        fresh->length = 1;
        return EL_STR(fresh);
    }

    /* Uniquely owned: grow the elems buffer in place. The header pointer the
     * caller holds doesn't move (we only realloc the inner array). This is
     * the common case in compiler accumulators, and it's amortized O(1). */
    if (old->hdr.refcount <= 1) {
        if (old->length >= old->capacity) {
            int64_t new_cap = old->capacity > 0 ? old->capacity * 2 : 4;
            el_val_t* grown = realloc(old->elems, (size_t)new_cap * sizeof(el_val_t));
            if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
            old->elems    = grown;
            old->capacity = new_cap;
        }
        old->elems[old->length++] = elem;
        return listv;
    }

    /* Shared: copy-on-write. The original is preserved for its other owners. */
    int64_t new_cap = old->length + 1;
    if (new_cap < 4) new_cap = 4;
    ElList* fresh = malloc(sizeof(ElList));
    if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    fresh->hdr.magic    = EL_MAGIC_LIST;
    fresh->hdr.refcount = 1;
    fresh->length       = old->length + 1;
    fresh->capacity     = new_cap;
    fresh->elems        = malloc((size_t)new_cap * sizeof(el_val_t));
    if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    if (old->length > 0) {
        memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
    }
    fresh->elems[old->length] = elem;
    return EL_STR(fresh);
}

el_val_t el_list_clone(el_val_t listv) {
    /* Shallow copy: the new ElList owns its own header and elems buffer, but
     * the elements themselves are shared (which is what callers want for the
     * cg_if_stmt 'declared' pattern — cloning the spine, not its contents).
     * Used by codegen at scope branch points where two child scopes need to
     * see the same starting set of declared names without each other's
     * mutations.                                                              */
    ElList* old = (ElList*)(uintptr_t)listv;
    if (!old) return el_list_empty();
    int64_t cap = old->capacity > 0 ? old->capacity : 4;
    if (cap < old->length) cap = old->length;
    if (cap < 4) cap = 4;
    ElList* fresh = malloc(sizeof(ElList));
    if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    fresh->hdr.magic    = EL_MAGIC_LIST;
    fresh->hdr.refcount = 1;
    fresh->length       = old->length;
    fresh->capacity     = cap;
    fresh->elems        = malloc((size_t)cap * sizeof(el_val_t));
    if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    if (old->length > 0) {
        memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
    }
    return EL_STR(fresh);
}

/* ── Map ─────────────────────────────────────────────────────────────────── */

typedef struct {
    ElHeader  hdr;
    int64_t   count;
    int64_t   capacity;
    el_val_t* keys;
    el_val_t* values;
} ElMap;

static ElMap* map_alloc(int64_t cap) {
    if (cap < 4) cap = 4;
    ElMap* m = malloc(sizeof(ElMap));
    if (!m) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    m->hdr.magic    = EL_MAGIC_MAP;
    m->hdr.refcount = 1;
    m->count        = 0;
    m->capacity     = cap;
    m->keys         = malloc((size_t)cap * sizeof(el_val_t));
    m->values       = malloc((size_t)cap * sizeof(el_val_t));
    if (!m->keys || !m->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    return m;
}

el_val_t el_map_new(el_val_t pair_count, ...) {
    ElMap* m = map_alloc(pair_count > 0 ? pair_count : 4);
    va_list ap;
    va_start(ap, pair_count);
    for (int64_t i = 0; i < pair_count; i++) {
        m->keys[i]   = va_arg(ap, el_val_t);
        m->values[i] = va_arg(ap, el_val_t);
    }
    va_end(ap);
    m->count = pair_count;
    return EL_STR(m);
}

static ElMap* as_map(el_val_t v) { return (ElMap*)(uintptr_t)v; }

el_val_t el_map_get(el_val_t mapv, el_val_t keyv) {
    ElMap* m = as_map(mapv);
    const char* key = EL_CSTR(keyv);
    if (!m || !key) return 0;
    for (int64_t i = 0; i < m->count; i++) {
        const char* k = EL_CSTR(m->keys[i]);
        if (k && strcmp(k, key) == 0) return m->values[i];
    }
    return 0;
}

el_val_t el_get_field(el_val_t mapv, el_val_t keyv) {
    return el_map_get(mapv, keyv);
}

/* Internal: in-place set on a uniquely-owned map. */
static el_val_t map_set_in_place(ElMap* m, el_val_t keyv, el_val_t value) {
    const char* key = EL_CSTR(keyv);
    if (key) {
        for (int64_t i = 0; i < m->count; i++) {
            const char* k = EL_CSTR(m->keys[i]);
            if (k && strcmp(k, key) == 0) { m->values[i] = value; return EL_STR(m); }
        }
    }
    if (m->count >= m->capacity) {
        int64_t new_cap = m->capacity > 0 ? m->capacity * 2 : 4;
        el_val_t* gk = realloc(m->keys,   (size_t)new_cap * sizeof(el_val_t));
        el_val_t* gv = realloc(m->values, (size_t)new_cap * sizeof(el_val_t));
        if (!gk || !gv) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        m->keys     = gk;
        m->values   = gv;
        m->capacity = new_cap;
    }
    m->keys[m->count]   = keyv;
    m->values[m->count] = value;
    m->count++;
    return EL_STR(m);
}

el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
    ElMap* m = as_map(mapv);
    if (!m) return 0;
    if (m->hdr.refcount <= 1) {
        return map_set_in_place(m, keyv, value);
    }
    /* Shared: copy then set. The original is preserved for its other owners. */
    int64_t new_cap = m->count + 1;
    if (new_cap < 4) new_cap = 4;
    ElMap* fresh = malloc(sizeof(ElMap));
    if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    fresh->hdr.magic    = EL_MAGIC_MAP;
    fresh->hdr.refcount = 1;
    fresh->count        = m->count;
    fresh->capacity     = new_cap;
    fresh->keys         = malloc((size_t)new_cap * sizeof(el_val_t));
    fresh->values       = malloc((size_t)new_cap * sizeof(el_val_t));
    if (!fresh->keys || !fresh->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    if (m->count > 0) {
        memcpy(fresh->keys,   m->keys,   (size_t)m->count * sizeof(el_val_t));
        memcpy(fresh->values, m->values, (size_t)m->count * sizeof(el_val_t));
    }
    return map_set_in_place(fresh, keyv, value);
}

/* ── Refcount ops ─────────────────────────────────────────────────────────── */
/*
 * Both retain and release sniff the magic header to decide whether a value
 * is a refcounted heap object. For small integers, raw C strings, and any
 * value whose magic word doesn't match, both functions are no-ops. This lets
 * codegen emit them on every let-binding without having to track types.
 *
 * Safety: we filter out obvious non-pointers (small magnitudes, misaligned
 * addresses) before dereferencing. For any value that passes the filter and
 * lives in a mapped page, reading the first 4 bytes is safe — strings start
 * with printable ASCII (< 0x80), so their magic word will never collide with
 * EL_MAGIC_LIST (0xE1...) or EL_MAGIC_MAP (0xE1...). Random integers that
 * happen to look like aligned heap pointers are exceedingly unlikely to land
 * on a page whose first 4 bytes match either magic.                          */

static int looks_like_heap_obj(el_val_t v) {
    if (v == 0) return 0;
    int64_t s = (int64_t)v;
    if (s > -0x10000 && s < 0x10000) return 0;     /* small ints */
    uintptr_t p = (uintptr_t)v;
    if (p < 0x10000) return 0;                      /* low addresses */
    if (p & 0x7) return 0;                          /* malloc returns 8-aligned */
    return 1;
}

void el_retain(el_val_t v) {
    if (!looks_like_heap_obj(v)) return;
    ElHeader* h = (ElHeader*)(uintptr_t)v;
    if (h->magic == EL_MAGIC_LIST || h->magic == EL_MAGIC_MAP) {
        h->refcount++;
    }
}

void el_release(el_val_t v) {
    if (!looks_like_heap_obj(v)) return;
    ElHeader* h = (ElHeader*)(uintptr_t)v;
    if (h->magic == EL_MAGIC_LIST) {
        if (h->refcount > 0 && --h->refcount == 0) {
            ElList* l = (ElList*)h;
            free(l->elems);
            l->hdr.magic = 0;   /* poison so use-after-free is detected */
            free(l);
        }
    } else if (h->magic == EL_MAGIC_MAP) {
        if (h->refcount > 0 && --h->refcount == 0) {
            ElMap* m = (ElMap*)h;
            free(m->keys);
            free(m->values);
            m->hdr.magic = 0;
            free(m);
        }
    }
}

/* ── Batch 2/3 forward decls (defined later in JSON section) ────────────── */

typedef struct JsonBuf JsonBuf;
typedef struct JsonParser JsonParser;
static void jb_init(JsonBuf* b);
static void jb_putc(JsonBuf* b, char c);
static void jb_puts(JsonBuf* b, const char* s);
static void jb_emit_escaped(JsonBuf* b, const char* s);
static int  looks_like_string(el_val_t v);
static const char* json_find_key(const char* s, const char* key);
static const char* json_skip_value(const char* p);
static char* jp_parse_string_raw(JsonParser* jp);

/* Struct definitions are visible here because batch 2/3 helpers above use
 * them by value; the bodies (jb_init, etc.) appear in the JSON section. */
struct JsonBuf {
    char*  buf;
    size_t len;
    size_t cap;
};

struct JsonParser {
    const char* p;
    const char* end;
    int         err;
};

/* ── Batch 2: Real HTTP (libcurl client + POSIX-socket server) ───────────── */
/*
 * Client: blocking libcurl easy-handle calls. Errors are returned as a JSON
 * fragment {"error":"..."} so callers can detect via str_starts_with("{") /
 * json_get_string("error", ...).
 *
 * Server: bind/listen/accept loop on a TCP socket. Each accepted connection
 * is handled in its own pthread (detached). A semaphore-style counter caps
 * concurrent in-flight connections at HTTP_MAX_CONNS (64). When the cap is
 * reached, accept() blocks until a worker exits. This prevents runaway
 * thread creation under high load.
 *
 * Handler dispatch: El does not expose first-class function references at
 * the runtime layer, so the second argument to http_serve(port, handler) is
 * treated as a string name (or any el_val_t — the runtime ignores its
 * value and uses the registry). Callers register a C-level handler via
 *
 *     extern void el_runtime_register_handler(const char* name,
 *                                              el_val_t (*fn)(el_val_t,
 *                                                             el_val_t,
 *                                                             el_val_t));
 *
 * and select the active handler by calling http_set_handler("name") from
 * El, or by setting it directly through the C registry. If no handler is
 * registered, the server replies with a 200 carrying a default message so
 * the loop is observable.
 */

/* ── HTTP client write-callback buffer ───────────────────────────────────── */

typedef struct {
    char*  data;
    size_t len;
    size_t cap;
} HttpBuf;

static void httpbuf_init(HttpBuf* b) {
    b->cap = 1024;
    b->len = 0;
    b->data = malloc(b->cap);
    if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->data[0] = '\0';
}

static void httpbuf_append(HttpBuf* b, const void* src, size_t n) {
    if (b->len + n + 1 > b->cap) {
        while (b->len + n + 1 > b->cap) b->cap *= 2;
        b->data = realloc(b->data, b->cap);
        if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    }
    memcpy(b->data + b->len, src, n);
    b->len += n;
    b->data[b->len] = '\0';
}

static size_t http_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
    size_t n = size * nmemb;
    httpbuf_append((HttpBuf*)ud, ptr, n);
    return n;
}

/* JSON-escape an arbitrary C string into an allocated buffer. */
static char* json_escape_alloc(const char* s) {
    if (!s) return el_strdup("");
    JsonBuf b; jb_init(&b);
    for (const char* p = s; *p; p++) {
        unsigned char c = (unsigned char)*p;
        switch (c) {
            case '"':  jb_puts(&b, "\\\""); break;
            case '\\': jb_puts(&b, "\\\\"); break;
            case '\n': jb_puts(&b, "\\n");  break;
            case '\r': jb_puts(&b, "\\r");  break;
            case '\t': jb_puts(&b, "\\t");  break;
            default:
                if (c < 0x20) {
                    char tmp[8]; snprintf(tmp, sizeof(tmp), "\\u%04x", c);
                    jb_puts(&b, tmp);
                } else jb_putc(&b, (char)c);
        }
    }
    return b.buf;
}

static el_val_t http_error_json(const char* msg) {
    char* esc = json_escape_alloc(msg ? msg : "unknown error");
    char* buf = el_strbuf(strlen(esc) + 16);
    sprintf(buf, "{\"error\":\"%s\"}", esc);
    free(esc);
    return el_wrap_str(buf);
}

/* HTTP timeout (ms) — read once from EL_HTTP_TIMEOUT_MS, default 60000.
 * Applied via CURLOPT_TIMEOUT_MS on every libcurl request. */
static long _el_http_timeout_ms = -1;
static long el_http_timeout_ms(void) {
    long v = __atomic_load_n(&_el_http_timeout_ms, __ATOMIC_ACQUIRE);
    if (v >= 0) return v;
    const char* s = getenv("EL_HTTP_TIMEOUT_MS");
    long parsed = 60000L;
    if (s && *s) {
        char* end = NULL;
        long n = strtol(s, &end, 10);
        if (end != s && n > 0) parsed = n;
    }
    __atomic_store_n(&_el_http_timeout_ms, parsed, __ATOMIC_RELEASE);
    return parsed;
}

/* Internal: do a libcurl request; takes optional body/headers, optional method override. */
static el_val_t http_do(const char* method, const char* url, const char* body,
                        struct curl_slist* extra_headers) {
    if (!url || !*url) return http_error_json("empty url");
    CURL* c = curl_easy_init();
    if (!c) return http_error_json("curl_easy_init failed");
    HttpBuf rb; httpbuf_init(&rb);
    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    if (extra_headers) curl_easy_setopt(c, CURLOPT_HTTPHEADER, extra_headers);
    if (method && strcmp(method, "POST") == 0) {
        curl_easy_setopt(c, CURLOPT_POST, 1L);
        curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
        curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
    } else if (method && strcmp(method, "DELETE") == 0) {
        curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
    }
    CURLcode rc = curl_easy_perform(c);
    curl_easy_cleanup(c);
    if (rc != CURLE_OK) {
        free(rb.data);
        const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
        return http_error_json(m);
    }
    return el_wrap_str(rb.data);
}

el_val_t http_get(el_val_t url) {
    return http_do("GET", EL_CSTR(url), NULL, NULL);
}

el_val_t http_post(el_val_t url, el_val_t body) {
    return http_do("POST", EL_CSTR(url), EL_CSTR(body), NULL);
}

el_val_t http_post_json(el_val_t url, el_val_t json_body) {
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(json_body), h);
    curl_slist_free_all(h);
    return r;
}

/* Build a curl_slist from an ElMap of name -> value strings. */
static struct curl_slist* headers_from_map(el_val_t headers_map) {
    struct curl_slist* h = NULL;
    ElMap* m = as_map(headers_map);
    if (!m) return NULL;
    for (int64_t i = 0; i < m->count; i++) {
        const char* k = EL_CSTR(m->keys[i]);
        const char* v = EL_CSTR(m->values[i]);
        if (!k || !v) continue;
        size_t n = strlen(k) + strlen(v) + 4;
        char* line = malloc(n);
        if (!line) continue;
        snprintf(line, n, "%s: %s", k, v);
        h = curl_slist_append(h, line);
        free(line);
    }
    return h;
}

el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do("GET", EL_CSTR(url), NULL, h);
    if (h) curl_slist_free_all(h);
    return r;
}

el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(body), h);
    if (h) curl_slist_free_all(h);
    return r;
}

el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header) {
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/x-www-form-urlencoded");
    const char* a = EL_CSTR(auth_header);
    if (a && *a) {
        size_t n = strlen(a) + 32;
        char* line = malloc(n);
        snprintf(line, n, "Authorization: %s", a);
        h = curl_slist_append(h, line);
        free(line);
    }
    el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(form_body), h);
    curl_slist_free_all(h);
    return r;
}

/* HTTP DELETE — mirrors http_post but with CURLOPT_CUSTOMREQUEST=DELETE.
 * Returns response body on success; on transport failure returns an error
 * JSON fragment (same convention as http_get/http_post). Callers that
 * expect "" on failure should check for a leading '{' and an "error" key. */
el_val_t http_delete(el_val_t url) {
    return http_do("DELETE", EL_CSTR(url), NULL, NULL);
}

/* ── HTTP → file streaming ────────────────────────────────────────────────
 *
 * Why this exists: el_val_t strings are NUL-terminated by convention, so
 * accumulating an HTTP response into an httpbuf and then wrapping its
 * `.data` pointer with el_wrap_str() loses the byte length. Any consumer
 * that does strlen() on the wrapped pointer truncates the body at the
 * first embedded NUL. Audio (MP3, WAV, OGG), images (PNG, JPEG), and any
 * other binary payload hits this. The vessels that download such bodies
 * (e.g. ElevenLabs TTS → MP3) get silently corrupted files.
 *
 * The fix: wire libcurl's CURLOPT_WRITEFUNCTION directly to fwrite()
 * against a fopen()-ed FILE*. The bytes never pass through an el_val_t
 * string, so embedded NULs are preserved verbatim. Caller's contract is
 * just "a file at this path with the response body in it". */

static size_t http_file_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
    FILE* f = (FILE*)ud;
    return fwrite(ptr, size, nmemb, f);
}

/* Internal: stream body to file. method is "GET" or "POST". body may be NULL
 * (GET) or NUL-terminated (POST). headers may be NULL. Returns 1/0. */
static el_val_t http_do_to_file(const char* method, const char* url,
                                const char* body, struct curl_slist* extra_headers,
                                const char* output_path) {
    if (!url || !*url) return 0;
    if (!output_path || !*output_path) return 0;
    FILE* f = fopen(output_path, "wb");
    if (!f) return 0;

    CURL* c = curl_easy_init();
    if (!c) { fclose(f); remove(output_path); return 0; }

    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_file_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, f);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    curl_easy_setopt(c, CURLOPT_FAILONERROR, 1L);  /* 4xx/5xx → CURLE_HTTP_RETURNED_ERROR */
    if (extra_headers) curl_easy_setopt(c, CURLOPT_HTTPHEADER, extra_headers);

    if (method && strcmp(method, "POST") == 0) {
        curl_easy_setopt(c, CURLOPT_POST, 1L);
        curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
        /* For the request body we still rely on strlen — POST bodies are
         * caller-controlled and JSON/text in every known El use case.
         * If a future caller needs a binary POST body, add a *_bytes
         * variant that takes an explicit length, mirroring fs_write_bytes. */
        curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
    }

    CURLcode rc = curl_easy_perform(c);
    curl_easy_cleanup(c);

    /* Flush + close before signalling success, so the file is fully on disk
     * by the time the caller reads back. */
    int flush_ok = (fflush(f) == 0);
    int close_ok = (fclose(f) == 0);

    if (rc != CURLE_OK || !flush_ok || !close_ok) {
        remove(output_path);
        return 0;
    }
    return 1;
}

el_val_t http_get_to_file(el_val_t url, el_val_t headers_map, el_val_t output_path) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do_to_file("GET", EL_CSTR(url), NULL, h, EL_CSTR(output_path));
    if (h) curl_slist_free_all(h);
    return r;
}

el_val_t http_post_to_file(el_val_t url, el_val_t body, el_val_t headers_map, el_val_t output_path) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do_to_file("POST", EL_CSTR(url), EL_CSTR(body), h, EL_CSTR(output_path));
    if (h) curl_slist_free_all(h);
    return r;
}

/* ── HTTP server (POSIX sockets + pthreads) ──────────────────────────────── */

#define HTTP_MAX_CONNS 64

typedef el_val_t (*http_handler_fn)(el_val_t method, el_val_t path, el_val_t body);

typedef struct {
    char*           name;
    http_handler_fn fn;
} HttpHandlerEntry;

static HttpHandlerEntry  _http_handlers[32];
static size_t            _http_handler_count = 0;
static char*             _http_active_handler = NULL;
static pthread_mutex_t   _http_handler_mu = PTHREAD_MUTEX_INITIALIZER;

static pthread_mutex_t   _http_conn_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t    _http_conn_cv = PTHREAD_COND_INITIALIZER;
static int               _http_conn_active = 0;

/* Public C-level API: register a handler by name. Programs that want El
 * `http_serve` to dispatch into their handler call this from main() before
 * http_serve. Not declared in the header to keep the public API minimal —
 * extern lookup works since C symbols are global.                       */
void el_runtime_register_handler(const char* name, http_handler_fn fn);
void el_runtime_register_handler(const char* name, http_handler_fn fn) {
    if (!name || !fn) return;
    pthread_mutex_lock(&_http_handler_mu);
    for (size_t i = 0; i < _http_handler_count; i++) {
        if (strcmp(_http_handlers[i].name, name) == 0) {
            _http_handlers[i].fn = fn;
            pthread_mutex_unlock(&_http_handler_mu);
            return;
        }
    }
    if (_http_handler_count < sizeof(_http_handlers) / sizeof(_http_handlers[0])) {
        _http_handlers[_http_handler_count].name = el_strdup(name);
        _http_handlers[_http_handler_count].fn   = fn;
        _http_handler_count++;
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

void http_set_handler(el_val_t name) {
    const char* n = EL_CSTR(name);
    pthread_mutex_lock(&_http_handler_mu);
    free(_http_active_handler);
    _http_active_handler = el_strdup(n ? n : "");
    /* If the name is not yet in the registry, try dlsym lookup against
     * the running binary's symbol table. Every El `fn name(...)` compiles
     * to a global C symbol with that exact name, so El programs can self-
     * register their own handlers just by calling http_set_handler("name"). */
    if (n && *n) {
        int found = 0;
        for (size_t i = 0; i < _http_handler_count; i++) {
            if (strcmp(_http_handlers[i].name, n) == 0) { found = 1; break; }
        }
        if (!found) {
            void* sym = dlsym(RTLD_DEFAULT, n);
            if (sym && _http_handler_count < sizeof(_http_handlers) / sizeof(_http_handlers[0])) {
                _http_handlers[_http_handler_count].name = el_strdup(n);
                _http_handlers[_http_handler_count].fn   = (http_handler_fn)sym;
                _http_handler_count++;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

static http_handler_fn http_lookup_active(void) {
    http_handler_fn out = NULL;
    pthread_mutex_lock(&_http_handler_mu);
    if (_http_active_handler) {
        for (size_t i = 0; i < _http_handler_count; i++) {
            if (strcmp(_http_handlers[i].name, _http_active_handler) == 0) {
                out = _http_handlers[i].fn; break;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
    return out;
}

/* Auto-detect Content-Type from response body. */
static const char* http_detect_content_type(const char* body) {
    if (!body) return "text/plain; charset=utf-8";
    const char* p = body;
    /* Binary magic bytes — check before stripping whitespace */
    if ((unsigned char)p[0] == 0x89 && p[1]=='P' && p[2]=='N' && p[3]=='G')
        return "image/png";
    if ((unsigned char)p[0] == 0xFF && (unsigned char)p[1] == 0xD8)
        return "image/jpeg";
    if (strncmp(p, "GIF8", 4) == 0)  return "image/gif";
    if (strncmp(p, "RIFF", 4) == 0)  return "image/webp";
    if (strncmp(p, "wOFF", 4) == 0)  return "font/woff";
    if (strncmp(p, "wOF2", 4) == 0)  return "font/woff2";
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (strncasecmp(p, "<!DOCTYPE", 9) == 0) return "text/html; charset=utf-8";
    if (strncasecmp(p, "<html", 5)    == 0) return "text/html; charset=utf-8";
    if (strncasecmp(p, "<svg",  4)    == 0) return "image/svg+xml";
    if (*p == '{' || *p == '[')             return "application/json; charset=utf-8";
    return "text/plain; charset=utf-8";
}

/* Read the full HTTP request from a connection: request line, headers, body.
 *
 * `out_headers_block` is optional (may be NULL). When non-NULL, on success
 * it receives an allocated NUL-terminated copy of the raw header block —
 * everything between the request line's trailing CRLF and the final \r\n\r\n
 * (no leading/trailing CRLFs). The 3-arg http_serve worker passes NULL here
 * (no headers needed). The 4-arg http_serve_v2 worker passes a real pointer
 * so it can build a header map for the El handler.                         */
static int http_read_request(int fd, char** out_method, char** out_path,
                             char** out_body, char** out_headers_block) {
    *out_method = NULL; *out_path = NULL; *out_body = NULL;
    if (out_headers_block) *out_headers_block = NULL;
    /* Read headers until \r\n\r\n */
    size_t cap = 4096, len = 0;
    char* buf = malloc(cap);
    if (!buf) return -1;
    while (1) {
        if (len + 1 >= cap) {
            if (cap >= 1024 * 1024) { free(buf); return -1; }
            cap *= 2;
            buf = realloc(buf, cap);
            if (!buf) return -1;
        }
        ssize_t n = recv(fd, buf + len, cap - len - 1, 0);
        if (n <= 0) { free(buf); return -1; }
        len += (size_t)n;
        buf[len] = '\0';
        if (strstr(buf, "\r\n\r\n")) break;
    }
    /* Parse request line */
    char* sp1 = strchr(buf, ' ');
    if (!sp1) { free(buf); return -1; }
    *sp1 = '\0';
    *out_method = el_strdup(buf);
    char* path_start = sp1 + 1;
    char* sp2 = strchr(path_start, ' ');
    if (!sp2) { free(*out_method); *out_method = NULL; free(buf); return -1; }
    *sp2 = '\0';
    *out_path = el_strdup(path_start);
    char* hdr_end = strstr(sp2 + 1, "\r\n\r\n");
    /* Capture the raw header block (after the request line's CRLF, up to
     * but not including the terminating \r\n\r\n) for callers that asked
     * for it. The legacy 3-arg path passes NULL and skips this. */
    if (out_headers_block) {
        char* hdr_start = strstr(sp2 + 1, "\r\n");
        if (hdr_start && hdr_start < hdr_end) {
            hdr_start += 2;
            size_t hb_len = (size_t)(hdr_end - hdr_start);
            char* hb = malloc(hb_len + 1);
            if (hb) {
                memcpy(hb, hdr_start, hb_len);
                hb[hb_len] = '\0';
                *out_headers_block = hb;
            }
        } else {
            *out_headers_block = el_strdup("");
        }
    }
    /* Find Content-Length */
    long content_length = 0;
    char* hp = sp2 + 1;
    while (hp < hdr_end) {
        char* line_end = strstr(hp, "\r\n");
        /* line_end == hdr_end means we're on the LAST header line — its
         * trailing \r\n is the same \r\n that begins the \r\n\r\n header
         * terminator. Process this line; only stop when line_end is past
         * hdr_end (which means the parser walked off the end of the
         * header block). The previous condition (line_end >= hdr_end)
         * silently dropped any Content-Length that appeared as the last
         * header — exactly what real curl/clients tend to emit. */
        if (!line_end || line_end > hdr_end) break;
        if (strncasecmp(hp, "Content-Length:", 15) == 0) {
            content_length = strtol(hp + 15, NULL, 10);
            if (content_length < 0) content_length = 0;
            if (content_length > 64 * 1024 * 1024) content_length = 64 * 1024 * 1024;
        }
        hp = line_end + 2;
    }
    /* Body: any bytes already read past hdr_end, plus more recv */
    char* body_start = hdr_end + 4;
    size_t body_have = (buf + len) - body_start;
    char* body = malloc((size_t)content_length + 1);
    if (!body) { free(*out_method); free(*out_path); *out_method=NULL; *out_path=NULL; free(buf); return -1; }
    if ((long)body_have > content_length) body_have = (size_t)content_length;
    if (body_have > 0) memcpy(body, body_start, body_have);
    while ((long)body_have < content_length) {
        ssize_t n = recv(fd, body + body_have, (size_t)content_length - body_have, 0);
        if (n <= 0) break;
        body_have += (size_t)n;
    }
    body[body_have] = '\0';
    *out_body = body;
    free(buf);
    return 0;
}

/* Reason phrase for common HTTP statuses. Falls back to "Status" for the
 * long tail — clients only care about the numeric code. */
static const char* http_reason_phrase(int status) {
    switch (status) {
        case 200: return "OK";
        case 201: return "Created";
        case 202: return "Accepted";
        case 204: return "No Content";
        case 301: return "Moved Permanently";
        case 302: return "Found";
        case 303: return "See Other";
        case 304: return "Not Modified";
        case 307: return "Temporary Redirect";
        case 308: return "Permanent Redirect";
        case 400: return "Bad Request";
        case 401: return "Unauthorized";
        case 403: return "Forbidden";
        case 404: return "Not Found";
        case 405: return "Method Not Allowed";
        case 409: return "Conflict";
        case 410: return "Gone";
        case 422: return "Unprocessable Entity";
        case 429: return "Too Many Requests";
        case 500: return "Internal Server Error";
        case 501: return "Not Implemented";
        case 502: return "Bad Gateway";
        case 503: return "Service Unavailable";
        case 504: return "Gateway Timeout";
        default:  return "Status";
    }
}

/* Best-effort send with retry on partial writes. */
static int http_send_all(int fd, const char* p, size_t left) {
    while (left > 0) {
        ssize_t w = send(fd, p, left, 0);
        if (w <= 0) return -1;
        p += w; left -= (size_t)w;
    }
    return 0;
}

/* Discriminator that http_response() embeds at the start of its envelope.
 * A handler returning a string starting with this exact prefix is treated
 * as a structured response; anything else is treated as a raw body. */
#define EL_HTTP_RESPONSE_TAG "{\"el_http_response\":1"

/* Keys that conflict with runtime-managed headers are silently dropped to
 * avoid double-emission — the runtime always emits its own Content-Length
 * and Connection: close. Content-Type from the envelope IS allowed and
 * overrides auto-detection. */
static int http_header_is_managed(const char* k) {
    return strcasecmp(k, "Content-Length") == 0
        || strcasecmp(k, "Connection") == 0;
}

/* Walk an ElMap of header pairs and emit each as `K: V\r\n` into JsonBuf b.
 * Sets *out_saw_content_type to 1 if the map contained an explicit
 * Content-Type so the caller can skip auto-detection. */
static void http_emit_headers_from_map(JsonBuf* b, el_val_t headers_map,
                                       int* out_saw_content_type) {
    *out_saw_content_type = 0;
    if (headers_map == 0) return;
    ElMap* m = (ElMap*)(uintptr_t)headers_map;
    if (!m || m->hdr.magic != EL_MAGIC_MAP) return;
    for (int64_t i = 0; i < m->count; i++) {
        const char* k = EL_CSTR(m->keys[i]);
        const char* v = EL_CSTR(m->values[i]);
        if (!k || !v) continue;
        if (http_header_is_managed(k)) continue;
        if (strcasecmp(k, "Content-Type") == 0) *out_saw_content_type = 1;
        jb_puts(b, k);
        jb_puts(b, ": ");
        jb_puts(b, v);
        jb_puts(b, "\r\n");
    }
}

/* Parse the envelope produced by http_response(). On success returns 1 and
 * populates *out_status, *out_headers_map (an ElMap el_val_t — caller must
 * el_release), and *out_body (allocated). On failure returns 0.
 *
 * Implementation: feeds the entire envelope through the recursive-descent
 * JSON parser (which builds proper ElMap/ElList values), then pulls the
 * three top-level fields by name. Avoids re-stringifying the headers map
 * since json_stringify() does not support nested objects.                */
static int http_parse_envelope(const char* s, int* out_status,
                               el_val_t* out_headers_map, char** out_body,
                               el_val_t* out_parsed_root) {
    if (!s) return 0;
    if (strncmp(s, EL_HTTP_RESPONSE_TAG,
                sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;

    el_val_t parsed = json_parse(EL_STR(s));
    if (parsed == EL_NULL) return 0;

    int      status = 200;
    el_val_t hmap = 0;
    char*    body = NULL;

    el_val_t sv = el_map_get(parsed, EL_STR("status"));
    if (sv != 0) {
        /* status comes back as an integer — el_val_t holds it directly. */
        long sc = (long)sv;
        if (sc >= 100 && sc <= 599) status = (int)sc;
    }

    el_val_t hv = el_map_get(parsed, EL_STR("headers"));
    if (hv != 0) {
        ElMap* hm = (ElMap*)(uintptr_t)hv;
        if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
    }

    el_val_t bv = el_map_get(parsed, EL_STR("body"));
    if (bv != 0) {
        const char* bs = EL_CSTR(bv);
        if (bs) body = el_strdup(bs);
    }
    if (!body) body = el_strdup("");

    *out_status       = status;
    *out_headers_map  = hmap;
    *out_body         = body;
    *out_parsed_root  = parsed;  /* caller releases to free hmap + entries */
    return 1;
}

/* Send a fully-built HTTP response. If `body` starts with the envelope tag,
 * unpack status/headers/body. Otherwise emit the historical 200-OK with
 * auto-detected Content-Type. */
static void http_send_response(int fd, const char* body) {
    if (!body) body = "";

    int      status = 200;
    el_val_t env_headers_map = 0;
    char*    env_body = NULL;
    el_val_t env_parsed_root = 0;
    int      is_envelope = http_parse_envelope(body, &status,
                                               &env_headers_map, &env_body,
                                               &env_parsed_root);

    const char* eff_body = is_envelope ? env_body : body;
    /* Use the real byte count from fs_read if available (handles binary files
     * with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for
     * normal text/JSON responses where _tl_fs_read_len is 0. */
    size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
    _tl_fs_read_len = 0;  /* consume — one-shot per response */

    JsonBuf hdrs; jb_init(&hdrs);
    int saw_content_type = 0;
    if (is_envelope) {
        http_emit_headers_from_map(&hdrs, env_headers_map,
                                   &saw_content_type);
    }
    if (!saw_content_type) {
        jb_puts(&hdrs, "Content-Type: ");
        jb_puts(&hdrs, http_detect_content_type(eff_body));
        jb_puts(&hdrs, "\r\n");
    }

    char status_line[64];
    int sl = snprintf(status_line, sizeof(status_line),
                      "HTTP/1.1 %d %s\r\n",
                      status, http_reason_phrase(status));
    if (sl < 0) {
        if (env_parsed_root) el_release(env_parsed_root);
        free(env_body); free(hdrs.buf); return;
    }

    char tail[128];
    int tl = snprintf(tail, sizeof(tail),
                      "Content-Length: %zu\r\n"
                      "Connection: close\r\n"
                      "\r\n", blen);
    if (tl < 0) {
        if (env_parsed_root) el_release(env_parsed_root);
        free(env_body); free(hdrs.buf); return;
    }

    if (http_send_all(fd, status_line, (size_t)sl) == 0
     && http_send_all(fd, hdrs.buf,    hdrs.len)   == 0
     && http_send_all(fd, tail,        (size_t)tl) == 0
     && http_send_all(fd, eff_body,    blen)       == 0) {
        /* sent successfully */
    }

    if (env_parsed_root) el_release(env_parsed_root);
    free(env_body);
    free(hdrs.buf);
}

typedef struct {
    int fd;
} HttpWorkerArg;

static void* http_worker(void* arg) {
    HttpWorkerArg* a = (HttpWorkerArg*)arg;
    int fd = a->fd;
    free(a);
    char *method = NULL, *path = NULL, *body = NULL;
    if (http_read_request(fd, &method, &path, &body, NULL) == 0) {
        http_handler_fn h = http_lookup_active();
        char* response = NULL;
        el_request_start();                         /* begin per-request arena */
        if (h) {
            el_val_t r = h(EL_STR(method), EL_STR(path), EL_STR(body));
            const char* rs = EL_CSTR(r);
            /* Copy response out BEFORE arena teardown.
             * For binary files, _tl_fs_read_len holds the real byte count —
             * use memcpy instead of strdup so null bytes are preserved. */
            size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
            response = malloc(rlen + 1);
            if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
            else if (response) { response[0] = '\0'; }
        } else {
            response = el_strdup_persist("el-runtime: no http handler registered");
        }
        el_request_end();                           /* free all intermediate strings */
        http_send_response(fd, response);
        free(response);
    }
    free(method); free(path); free(body);
    close(fd);
    /* release a slot */
    pthread_mutex_lock(&_http_conn_mu);
    _http_conn_active--;
    pthread_cond_signal(&_http_conn_cv);
    pthread_mutex_unlock(&_http_conn_mu);
    return NULL;
}

void http_serve(el_val_t port, el_val_t handler) {
    /* If `handler` looks like a string name, register it as the active handler. */
    const char* hname = EL_CSTR(handler);
    if (hname && looks_like_string(handler)) {
        http_set_handler(handler);
    }
    int p = (int)port;
    if (p <= 0 || p > 65535) { fprintf(stderr, "http_serve: invalid port %d\n", p); return; }
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) { perror("socket"); return; }
    int yes = 1;
    setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons((uint16_t)p);
    if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); close(sock); return;
    }
    if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
    fprintf(stderr, "[http] listening on 0.0.0.0:%d\n", p);
    while (1) {
        struct sockaddr_in cli;
        socklen_t clen = sizeof(cli);
        int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
        if (cfd < 0) {
            if (errno == EINTR) continue;
            perror("accept"); break;
        }
        pthread_mutex_lock(&_http_conn_mu);
        while (_http_conn_active >= HTTP_MAX_CONNS) {
            pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
        }
        _http_conn_active++;
        pthread_mutex_unlock(&_http_conn_mu);
        HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
        if (!arg) { close(cfd); continue; }
        arg->fd = cfd;
        pthread_t tid;
        if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
            close(cfd); free(arg);
            pthread_mutex_lock(&_http_conn_mu);
            _http_conn_active--;
            pthread_cond_signal(&_http_conn_cv);
            pthread_mutex_unlock(&_http_conn_mu);
            continue;
        }
        pthread_detach(tid);
    }
    close(sock);
}

/* ── HTTP server v2 — request headers + structured response ──────────────── */
/*
 * v2 widens the handler signature from
 *     (method, path, body) -> body_string
 * to
 *     (method, path, headers_map, body) -> body_string_or_envelope
 *
 * The response envelope is detected uniformly inside http_send_response — so
 * 4-arg handlers can return either a plain body or http_response(...). The
 * 3-arg path stays untouched in spirit (its handlers still build plain
 * bodies; the envelope tag, being `{"el_http_response":1`, will never
 * collide with normal JSON the legacy server.el routes return).
 *
 * Registry is parallel to the 3-arg handler registry: separate name table,
 * separate active-handler slot, separate dlsym fallback. Mixing v1 and v2
 * handlers in the same process is fine — they don't share the active slot. */

typedef el_val_t (*http_handler4_fn)(el_val_t method, el_val_t path,
                                     el_val_t headers_map, el_val_t body);

typedef struct {
    char*             name;
    http_handler4_fn  fn;
} HttpHandler4Entry;

static HttpHandler4Entry _http_handlers4[32];
static size_t            _http_handler4_count = 0;
static char*             _http_active_handler4 = NULL;

void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn);
void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn) {
    if (!name || !fn) return;
    pthread_mutex_lock(&_http_handler_mu);
    for (size_t i = 0; i < _http_handler4_count; i++) {
        if (strcmp(_http_handlers4[i].name, name) == 0) {
            _http_handlers4[i].fn = fn;
            pthread_mutex_unlock(&_http_handler_mu);
            return;
        }
    }
    if (_http_handler4_count <
        sizeof(_http_handlers4) / sizeof(_http_handlers4[0])) {
        _http_handlers4[_http_handler4_count].name = el_strdup(name);
        _http_handlers4[_http_handler4_count].fn   = fn;
        _http_handler4_count++;
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

void http_set_handler_v2(el_val_t name) {
    const char* n = EL_CSTR(name);
    pthread_mutex_lock(&_http_handler_mu);
    free(_http_active_handler4);
    _http_active_handler4 = el_strdup(n ? n : "");
    if (n && *n) {
        int found = 0;
        for (size_t i = 0; i < _http_handler4_count; i++) {
            if (strcmp(_http_handlers4[i].name, n) == 0) { found = 1; break; }
        }
        if (!found) {
            void* sym = dlsym(RTLD_DEFAULT, n);
            if (sym && _http_handler4_count <
                sizeof(_http_handlers4) / sizeof(_http_handlers4[0])) {
                _http_handlers4[_http_handler4_count].name = el_strdup(n);
                _http_handlers4[_http_handler4_count].fn   =
                    (http_handler4_fn)sym;
                _http_handler4_count++;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

static http_handler4_fn http_lookup_active_v2(void) {
    http_handler4_fn out = NULL;
    pthread_mutex_lock(&_http_handler_mu);
    if (_http_active_handler4) {
        for (size_t i = 0; i < _http_handler4_count; i++) {
            if (strcmp(_http_handlers4[i].name,
                       _http_active_handler4) == 0) {
                out = _http_handlers4[i].fn; break;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
    return out;
}

/* Build an ElMap from the raw header block produced by http_read_request.
 * Keys are lowercased (RFC 7230 — case-insensitive); values have leading
 * whitespace trimmed. Repeated headers with the same name are joined with
 * ", " in arrival order, matching standard library behaviour elsewhere. */
static el_val_t http_build_headers_map(const char* hdr_block) {
    el_val_t m = el_map_new(0);
    if (!hdr_block || !*hdr_block) return m;
    const char* p = hdr_block;
    while (*p) {
        const char* line_end = strstr(p, "\r\n");
        const char* end = line_end ? line_end : p + strlen(p);
        const char* colon = NULL;
        for (const char* c = p; c < end; c++) {
            if (*c == ':') { colon = c; break; }
        }
        if (colon && colon > p) {
            size_t klen = (size_t)(colon - p);
            char* key = malloc(klen + 1);
            if (key) {
                for (size_t i = 0; i < klen; i++) {
                    unsigned char ch = (unsigned char)p[i];
                    key[i] = (char)tolower(ch);
                }
                key[klen] = '\0';
                const char* vstart = colon + 1;
                while (vstart < end && (*vstart == ' ' || *vstart == '\t')) vstart++;
                size_t vlen = (size_t)(end - vstart);
                /* Strip trailing OWS just in case. */
                while (vlen > 0
                       && (vstart[vlen - 1] == ' '
                        || vstart[vlen - 1] == '\t')) vlen--;
                /* Coalesce repeats: if key already present, append ", value". */
                el_val_t existing = el_map_get(m, EL_STR(key));
                if (existing != 0 && looks_like_string(existing)) {
                    const char* old = EL_CSTR(existing);
                    size_t olen = strlen(old);
                    char* combined = malloc(olen + 2 + vlen + 1);
                    if (combined) {
                        memcpy(combined, old, olen);
                        memcpy(combined + olen, ", ", 2);
                        memcpy(combined + olen + 2, vstart, vlen);
                        combined[olen + 2 + vlen] = '\0';
                        m = el_map_set(m, EL_STR(key), EL_STR(combined));
                    }
                    free(key);
                } else {
                    char* val = malloc(vlen + 1);
                    if (val) {
                        memcpy(val, vstart, vlen);
                        val[vlen] = '\0';
                        m = el_map_set(m, EL_STR(key), EL_STR(val));
                    } else {
                        free(key);
                    }
                }
            }
        }
        if (!line_end) break;
        p = line_end + 2;
    }
    return m;
}

static void* http_worker_v2(void* arg) {
    HttpWorkerArg* a = (HttpWorkerArg*)arg;
    int fd = a->fd;
    free(a);
    char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL;
    if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) {
        http_handler4_fn h = http_lookup_active_v2();
        char* response = NULL;
        el_request_start();                         /* begin per-request arena */
        if (h) {
            el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
            el_val_t r = h(EL_STR(method), EL_STR(path), hmap, EL_STR(body));
            const char* rs = EL_CSTR(r);
            size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
            response = malloc(rlen + 1);
            if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
            else if (response) { response[0] = '\0'; }
            el_release(hmap);
        } else {
            response = el_strdup_persist(
                "el-runtime: no v2 http handler registered "
                "(call http_set_handler_v2)");
        }
        el_request_end();                           /* free all intermediate strings */
        http_send_response(fd, response);
        free(response);
    }
    free(method); free(path); free(body); free(hdr_block);
    close(fd);
    pthread_mutex_lock(&_http_conn_mu);
    _http_conn_active--;
    pthread_cond_signal(&_http_conn_cv);
    pthread_mutex_unlock(&_http_conn_mu);
    return NULL;
}

void http_serve_v2(el_val_t port, el_val_t handler) {
    const char* hname = EL_CSTR(handler);
    if (hname && looks_like_string(handler)) {
        http_set_handler_v2(handler);
    }
    int p = (int)port;
    if (p <= 0 || p > 65535) {
        fprintf(stderr, "http_serve_v2: invalid port %d\n", p);
        return;
    }
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) { perror("socket"); return; }
    int yes = 1;
    setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons((uint16_t)p);
    if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); close(sock); return;
    }
    if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
    fprintf(stderr, "[http v2] listening on 0.0.0.0:%d\n", p);
    while (1) {
        struct sockaddr_in cli;
        socklen_t clen = sizeof(cli);
        int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
        if (cfd < 0) {
            if (errno == EINTR) continue;
            perror("accept"); break;
        }
        pthread_mutex_lock(&_http_conn_mu);
        while (_http_conn_active >= HTTP_MAX_CONNS) {
            pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
        }
        _http_conn_active++;
        pthread_mutex_unlock(&_http_conn_mu);
        HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
        if (!arg) { close(cfd); continue; }
        arg->fd = cfd;
        pthread_t tid;
        if (pthread_create(&tid, NULL, http_worker_v2, arg) != 0) {
            close(cfd); free(arg);
            pthread_mutex_lock(&_http_conn_mu);
            _http_conn_active--;
            pthread_cond_signal(&_http_conn_cv);
            pthread_mutex_unlock(&_http_conn_mu);
            continue;
        }
        pthread_detach(tid);
    }
    close(sock);
}

/* Build the response envelope a 4-arg handler can return. We hand-write
 * the JSON so the discriminator key always lands first — the runtime's
 * http_parse_envelope() detects it via prefix match. headers_json must be
 * either "" (empty), "{}" (empty object), or a well-formed JSON object
 * literal; anything else will produce a malformed envelope and the runtime
 * will treat the whole string as a plain body (no envelope detected).   */
el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
    long sc = (long)status;
    if (sc < 100 || sc > 599) sc = 200;
    const char* hj = EL_CSTR(headers_json);
    if (!hj || !*hj) hj = "{}";
    /* Light validation: must start with '{' and end with '}'. */
    size_t hlen = strlen(hj);
    int hj_ok = (hlen >= 2 && hj[0] == '{' && hj[hlen - 1] == '}');
    if (!hj_ok) hj = "{}";
    const char* b = EL_CSTR(body);
    if (!b) b = "";

    JsonBuf out; jb_init(&out);
    jb_puts(&out, EL_HTTP_RESPONSE_TAG);  /* {"el_http_response":1 */
    jb_puts(&out, ",\"status\":");
    char num[32];
    snprintf(num, sizeof(num), "%ld", sc);
    jb_puts(&out, num);
    jb_puts(&out, ",\"headers\":");
    jb_puts(&out, hj);
    jb_puts(&out, ",\"body\":");
    jb_emit_escaped(&out, b);
    jb_putc(&out, '}');
    return el_wrap_str(out.buf);
}

/* ── Filesystem ──────────────────────────────────────────────────────────── */

el_val_t fs_read(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    _tl_fs_read_len = 0;
    if (!path) return el_wrap_str(el_strdup(""));
    FILE* f = fopen(path, "rb");
    if (!f) return el_wrap_str(el_strdup(""));
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);
    char* buf = el_strbuf((size_t)sz);
    size_t got = fread(buf, 1, (size_t)sz, f);
    buf[got] = '\0';
    _tl_fs_read_len = got;   /* store real byte count for binary-safe send */
    fclose(f);
    return el_wrap_str(buf);
}

el_val_t fs_write(el_val_t pathv, el_val_t contentv) {
    const char* path    = EL_CSTR(pathv);
    const char* content = EL_CSTR(contentv);
    if (!path || !content) return 0;
    FILE* f = fopen(path, "wb");
    if (!f) return 0;
    size_t n       = strlen(content);
    size_t written = fwrite(content, 1, n, f);
    fclose(f);
    return written == n ? 1 : 0;
}

/* fs_write_bytes — explicit-length binary write. Bypasses strlen so embedded
 * NULs survive. Caller must know the byte count (e.g. from base64_decode,
 * or the fixed 32-byte sha256_bytes/hmac_sha256_bytes outputs).
 *
 * If `length` is negative, treats as failure. If `length` is 0, creates an
 * empty file (still useful as a "touch with content" primitive). */
el_val_t fs_write_bytes(el_val_t pathv, el_val_t bytesv, el_val_t lengthv) {
    const char* path  = EL_CSTR(pathv);
    const char* bytes = EL_CSTR(bytesv);
    int64_t     n     = (int64_t)lengthv;
    if (!path || !bytes) return 0;
    if (n < 0) return 0;
    FILE* f = fopen(path, "wb");
    if (!f) return 0;
    size_t written = (n > 0) ? fwrite(bytes, 1, (size_t)n, f) : 0;
    int flush_ok = (fflush(f) == 0);
    int close_ok = (fclose(f) == 0);
    if (!flush_ok || !close_ok || written != (size_t)n) {
        remove(path);
        return 0;
    }
    return 1;
}

el_val_t fs_list(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    el_val_t lst = el_list_empty();
    if (!path) return lst;
    DIR* d = opendir(path);
    if (!d) return lst;
    struct dirent* e;
    while ((e = readdir(d)) != NULL) {
        if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
        lst = el_list_append(lst, el_wrap_str(el_strdup(e->d_name)));
    }
    closedir(d);
    return lst;
}

/* fs_exists — true iff stat(path) succeeds. Symlinks are followed. */
el_val_t fs_exists(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    if (!path || !*path) return 0;
    struct stat st;
    return (el_val_t)(stat(path, &st) == 0 ? 1 : 0);
}

/* fs_mkdir — create directory at path with mode 0755, mkdir -p semantics.
 * Returns 1 if path exists or was created (incl. all parents); 0 on failure.
 * Walks the path component-by-component so missing intermediate dirs are
 * also created. An existing leaf is not an error. */
el_val_t fs_mkdir(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    if (!path || !*path) return 0;
    size_t n = strlen(path);
    char* buf = malloc(n + 1);
    if (!buf) return 0;
    memcpy(buf, path, n + 1);
    /* Walk components; create each prefix in turn. */
    for (size_t i = 1; i <= n; i++) {
        if (buf[i] == '/' || buf[i] == '\0') {
            char saved = buf[i];
            buf[i] = '\0';
            if (buf[0] != '\0') {
                if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
                    /* Tolerate the case where this prefix exists as a non-dir
                     * only when stat says it's a directory. */
                    struct stat st;
                    if (stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) {
                        free(buf);
                        return 0;
                    }
                }
            }
            buf[i] = saved;
        }
    }
    free(buf);
    return 1;
}

/* ── URL encoding ─────────────────────────────────────────────────────────── */

/* RFC 3986 percent-encoding for URL components (form bodies, query strings).
 * Unreserved set: A-Z a-z 0-9 - _ . ~ — passed through verbatim.
 * Everything else (including space) becomes %XX hex. */
el_val_t url_encode(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    static const char hex[] = "0123456789ABCDEF";
    size_t n = strlen(s);
    char* out = el_strbuf(n * 3);
    size_t o = 0;
    for (size_t i = 0; i < n; i++) {
        unsigned char c = (unsigned char)s[i];
        if ((c >= 'A' && c <= 'Z') ||
            (c >= 'a' && c <= 'z') ||
            (c >= '0' && c <= '9') ||
            c == '-' || c == '_' || c == '.' || c == '~') {
            out[o++] = (char)c;
        } else {
            out[o++] = '%';
            out[o++] = hex[(c >> 4) & 0xF];
            out[o++] = hex[c & 0xF];
        }
    }
    out[o] = '\0';
    return el_wrap_str(out);
}

/* Decode percent-encoded URL component. '+' becomes space (form-encoded);
 * malformed %-escapes are emitted verbatim. */
el_val_t url_decode(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    size_t o = 0;
    for (size_t i = 0; i < n; i++) {
        char c = s[i];
        if (c == '+') {
            out[o++] = ' ';
        } else if (c == '%' && i + 2 < n) {
            char h1 = s[i + 1], h2 = s[i + 2];
            int v1 = (h1 >= '0' && h1 <= '9') ? h1 - '0'
                   : (h1 >= 'a' && h1 <= 'f') ? h1 - 'a' + 10
                   : (h1 >= 'A' && h1 <= 'F') ? h1 - 'A' + 10 : -1;
            int v2 = (h2 >= '0' && h2 <= '9') ? h2 - '0'
                   : (h2 >= 'a' && h2 <= 'f') ? h2 - 'a' + 10
                   : (h2 >= 'A' && h2 <= 'F') ? h2 - 'A' + 10 : -1;
            if (v1 >= 0 && v2 >= 0) {
                out[o++] = (char)((v1 << 4) | v2);
                i += 2;
            } else {
                out[o++] = c;
            }
        } else {
            out[o++] = c;
        }
    }
    out[o] = '\0';
    return el_wrap_str(out);
}

/* ── JSON ────────────────────────────────────────────────────────────────── */

el_val_t json_get(el_val_t jsonv, el_val_t keyv) {
    const char* json = EL_CSTR(jsonv);
    const char* key  = EL_CSTR(keyv);
    if (!json || !key) return el_wrap_str(el_strdup(""));
    size_t klen = strlen(key);
    /* Use a stack buffer for the pattern to avoid arena double-free.
     * Keys in El maps are typically short; 512 bytes is a safe upper bound. */
    char stack_pat[512];
    char* pattern;
    if (klen + 5 <= sizeof(stack_pat)) {
        pattern = stack_pat;
    } else {
        pattern = malloc(klen + 5);
        if (!pattern) return el_wrap_str(el_strdup(""));
    }
    snprintf(pattern, klen + 5, "\"%s\":", key);
    const char* p = strstr(json, pattern);
    if (pattern != stack_pat) free(pattern);
    if (!p) return el_wrap_str(el_strdup(""));
    p += strlen(key) + 3; /* skip "key": */
    while (*p == ' ' || *p == '\t' || *p == '\n') p++;
    if (*p == '"') {
        p++;
        /* Unescape the JSON string value into a clean buffer. */
        size_t cap = strlen(p) + 1;
        char* out = el_strbuf(cap);
        char* w = out;
        while (*p && *p != '"') {
            if (*p == '\\' && *(p+1)) {
                p++;
                switch (*p) {
                    case '"':  *w++ = '"';  break;
                    case '\\': *w++ = '\\'; break;
                    case '/':  *w++ = '/';  break;
                    case 'n':  *w++ = '\n'; break;
                    case 'r':  *w++ = '\r'; break;
                    case 't':  *w++ = '\t'; break;
                    default:   *w++ = *p;   break;
                }
            } else {
                *w++ = *p;
            }
            p++;
        }
        *w = '\0';
        return el_wrap_str(out);
    }
    const char* start = p;
    while (*p && *p != ',' && *p != '}' && *p != ']' && *p != '\n') p++;
    size_t len = (size_t)(p - start);
    char* out = el_strbuf(len);
    memcpy(out, start, len);
    out[len] = '\0';
    return el_wrap_str(out);
}

/* ── Float bit-cast helpers ──────────────────────────────────────────────── */
/* `el_to_float` and `el_from_float` are exposed in el_runtime.h as static
 * inlines so generated programs (which #include the header) can call them
 * for Float literals. No definitions are needed here. */

/* ── JSON parser (recursive descent) ─────────────────────────────────────── */
/*
 * Parsed JSON representation:
 *   - object  -> ElMap (keys & values are el_val_t)
 *   - array   -> ElList
 *   - string  -> EL_STR-wrapped char* (allocated)
 *   - number  -> int (el_val_t) if integer, otherwise el_from_float(double)
 *   - true    -> 1
 *   - false   -> 0
 *   - null    -> EL_NULL (0)
 *
 * Note: there is no runtime type tag — parsed numbers cannot be
 * distinguished from booleans by the runtime alone. The codegen tracks
 * types separately. This matches the rest of el_val_t's type-erased model.
 */

/* JsonParser struct is forward-declared near the HTTP/Engram section. */

static void jp_skip_ws(JsonParser* jp) {
    while (jp->p < jp->end) {
        char c = *jp->p;
        if (c == ' ' || c == '\t' || c == '\n' || c == '\r') jp->p++;
        else break;
    }
}

static el_val_t jp_parse_value(JsonParser* jp);

/* Parse a JSON string literal (the opening " has NOT yet been consumed). */
static char* jp_parse_string_raw(JsonParser* jp) {
    if (jp->p >= jp->end || *jp->p != '"') { jp->err = 1; return el_strdup(""); }
    jp->p++;
    size_t cap = 32, len = 0;
    char* out = malloc(cap);
    if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    while (jp->p < jp->end && *jp->p != '"') {
        char c = *jp->p++;
        if (c == '\\' && jp->p < jp->end) {
            char esc = *jp->p++;
            switch (esc) {
                case '"':  c = '"';  break;
                case '\\': c = '\\'; break;
                case '/':  c = '/';  break;
                case 'b':  c = '\b'; break;
                case 'f':  c = '\f'; break;
                case 'n':  c = '\n'; break;
                case 'r':  c = '\r'; break;
                case 't':  c = '\t'; break;
                case 'u': {
                    /* Skip 4 hex digits; emit '?' as a placeholder */
                    for (int i = 0; i < 4 && jp->p < jp->end; i++) jp->p++;
                    c = '?';
                    break;
                }
                default: c = esc; break;
            }
        }
        if (len + 1 >= cap) {
            cap *= 2;
            out = realloc(out, cap);
            if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        }
        out[len++] = c;
    }
    if (jp->p < jp->end && *jp->p == '"') jp->p++;
    else jp->err = 1;
    out[len] = '\0';
    return out;
}

static el_val_t jp_parse_number(JsonParser* jp) {
    const char* start = jp->p;
    int is_float = 0;
    if (jp->p < jp->end && (*jp->p == '-' || *jp->p == '+')) jp->p++;
    while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
    if (jp->p < jp->end && *jp->p == '.') {
        is_float = 1; jp->p++;
        while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
    }
    if (jp->p < jp->end && (*jp->p == 'e' || *jp->p == 'E')) {
        is_float = 1; jp->p++;
        if (jp->p < jp->end && (*jp->p == '+' || *jp->p == '-')) jp->p++;
        while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
    }
    size_t n = (size_t)(jp->p - start);
    char buf[64];
    if (n >= sizeof(buf)) n = sizeof(buf) - 1;
    memcpy(buf, start, n);
    buf[n] = '\0';
    if (is_float) return el_from_float(strtod(buf, NULL));
    return (el_val_t)strtoll(buf, NULL, 10);
}

static el_val_t jp_parse_array(JsonParser* jp) {
    if (jp->p < jp->end && *jp->p == '[') jp->p++;
    el_val_t lst = el_list_empty();
    jp_skip_ws(jp);
    if (jp->p < jp->end && *jp->p == ']') { jp->p++; return lst; }
    while (jp->p < jp->end) {
        jp_skip_ws(jp);
        el_val_t v = jp_parse_value(jp);
        lst = el_list_append(lst, v);
        jp_skip_ws(jp);
        if (jp->p < jp->end && *jp->p == ',') { jp->p++; continue; }
        if (jp->p < jp->end && *jp->p == ']') { jp->p++; break; }
        jp->err = 1;
        break;
    }
    return lst;
}

static el_val_t jp_parse_object(JsonParser* jp) {
    if (jp->p < jp->end && *jp->p == '{') jp->p++;
    el_val_t m = el_map_new(0);
    jp_skip_ws(jp);
    if (jp->p < jp->end && *jp->p == '}') { jp->p++; return m; }
    while (jp->p < jp->end) {
        jp_skip_ws(jp);
        char* key = jp_parse_string_raw(jp);
        jp_skip_ws(jp);
        if (jp->p < jp->end && *jp->p == ':') jp->p++;
        else { jp->err = 1; free(key); break; }
        jp_skip_ws(jp);
        el_val_t v = jp_parse_value(jp);
        m = el_map_set(m, EL_STR(key), v);
        jp_skip_ws(jp);
        if (jp->p < jp->end && *jp->p == ',') { jp->p++; continue; }
        if (jp->p < jp->end && *jp->p == '}') { jp->p++; break; }
        jp->err = 1;
        break;
    }
    return m;
}

static el_val_t jp_parse_value(JsonParser* jp) {
    jp_skip_ws(jp);
    if (jp->p >= jp->end) { jp->err = 1; return EL_NULL; }
    char c = *jp->p;
    if (c == '"') return el_wrap_str(jp_parse_string_raw(jp));
    if (c == '{') return jp_parse_object(jp);
    if (c == '[') return jp_parse_array(jp);
    if (c == '-' || isdigit((unsigned char)c)) return jp_parse_number(jp);
    if (c == 't' && jp->p + 4 <= jp->end && strncmp(jp->p, "true", 4) == 0) { jp->p += 4; return 1; }
    if (c == 'f' && jp->p + 5 <= jp->end && strncmp(jp->p, "false", 5) == 0) { jp->p += 5; return 0; }
    if (c == 'n' && jp->p + 4 <= jp->end && strncmp(jp->p, "null", 4) == 0) { jp->p += 4; return EL_NULL; }
    jp->err = 1;
    return EL_NULL;
}

el_val_t json_parse(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return EL_NULL;
    JsonParser jp = { .p = s, .end = s + strlen(s), .err = 0 };
    el_val_t v = jp_parse_value(&jp);
    if (jp.err) return EL_NULL;
    return v;
}

/* ── JSON stringify ──────────────────────────────────────────────────────── */
/*
 * Stringify policy: el_val_t is type-erased, so we cannot perfectly
 * round-trip arbitrary values. We use these heuristics:
 *   - If value is an ElList pointer (in the heap range), serialize as array.
 *   - If value is an ElMap pointer, serialize as object.
 *   - If value looks like a printable string pointer, serialize as string.
 *   - Otherwise serialize as integer.
 * This is best-effort. Programs that need exact control should build the
 * string directly. A pointer test is the cheapest way to disambiguate
 * from small integers without a separate type tag.
 */

/* JsonBuf struct is forward-declared near the HTTP section so HTTP helpers
 * can use it. Its definition appears there. */

static void jb_init(JsonBuf* b) {
    b->cap = 64; b->len = 0;
    b->buf = malloc(b->cap);
    if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->buf[0] = '\0';
}

static void jb_reserve(JsonBuf* b, size_t add) {
    if (b->len + add + 1 > b->cap) {
        while (b->len + add + 1 > b->cap) b->cap *= 2;
        b->buf = realloc(b->buf, b->cap);
        if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    }
}

static void jb_putc(JsonBuf* b, char c) {
    jb_reserve(b, 1);
    b->buf[b->len++] = c;
    b->buf[b->len] = '\0';
}

static void jb_puts(JsonBuf* b, const char* s) {
    size_t n = strlen(s);
    jb_reserve(b, n);
    memcpy(b->buf + b->len, s, n);
    b->len += n;
    b->buf[b->len] = '\0';
}

static void jb_emit_escaped(JsonBuf* b, const char* s) {
    jb_putc(b, '"');
    for (; *s; s++) {
        unsigned char c = (unsigned char)*s;
        switch (c) {
            case '"':  jb_puts(b, "\\\""); break;
            case '\\': jb_puts(b, "\\\\"); break;
            case '\b': jb_puts(b, "\\b");  break;
            case '\f': jb_puts(b, "\\f");  break;
            case '\n': jb_puts(b, "\\n");  break;
            case '\r': jb_puts(b, "\\r");  break;
            case '\t': jb_puts(b, "\\t");  break;
            default:
                if (c < 0x20) {
                    char tmp[8];
                    snprintf(tmp, sizeof(tmp), "\\u%04x", c);
                    jb_puts(b, tmp);
                } else {
                    jb_putc(b, (char)c);
                }
                break;
        }
    }
    jb_putc(b, '"');
}

/* Heuristic: is this el_val_t likely a pointer to an ElList?
 * We can't fully verify, but pointers are large addresses, integers small.
 * Treat values whose magnitude exceeds 2^32 as potential pointers and
 * sniff by reading the header conservatively.
 *
 * Simpler heuristic: if the value reads as a printable string, treat as
 * string; otherwise as integer. Lists/Maps are encoded as struct pointers,
 * which have leading binary bytes — so they won't look like strings. */

static int looks_like_string(el_val_t v) {
    if (v == 0) return 0;
    /* Treat plausible heap addresses as candidates */
    uintptr_t p = (uintptr_t)v;
    /* Small integers (positive and negative) are not pointers */
    if ((int64_t)v >= -1000000 && (int64_t)v <= 1000000) return 0;
    if (p < 0x1000) return 0;
    /* Sniff first bytes for printable */
    const unsigned char* s = (const unsigned char*)p;
    for (int i = 0; i < 16; i++) {
        unsigned char c = s[i];
        if (c == '\0') return i > 0; /* terminated string */
        if (c < 0x09 || (c > 0x0d && c < 0x20) || c >= 0x7f) return 0;
    }
    return 1; /* 16+ printable bytes — call it a string */
}

static void jb_emit_value(JsonBuf* b, el_val_t v);

static void jb_emit_int(JsonBuf* b, int64_t n) {
    char tmp[32];
    snprintf(tmp, sizeof(tmp), "%lld", (long long)n);
    jb_puts(b, tmp);
}

static void jb_emit_value(JsonBuf* b, el_val_t v) {
    if (v == EL_NULL) { jb_puts(b, "null"); return; }
    if (looks_like_string(v)) {
        jb_emit_escaped(b, EL_CSTR(v));
        return;
    }
    jb_emit_int(b, (int64_t)v);
}

el_val_t json_stringify(el_val_t v) {
    JsonBuf b; jb_init(&b);
    jb_emit_value(&b, v);
    return el_wrap_str(b.buf);
}

/* ── JSON substring accessors ────────────────────────────────────────────── */
/*
 * These walk the raw JSON string looking for "key": at the top level (depth 1)
 * of an object. They handle escaped quotes, nested objects/arrays, and
 * whitespace around the colon.
 */

/* Find "key": at object-depth == 1 inside the JSON object string `s`.
 * Returns pointer to the first byte of the value, or NULL.            */
static const char* json_find_key(const char* s, const char* key) {
    if (!s || !key) return NULL;
    size_t klen = strlen(key);
    int depth = 0;
    int in_str = 0;
    int escape = 0;
    const char* p = s;
    while (*p) {
        char c = *p;
        if (in_str) {
            if (escape) { escape = 0; }
            else if (c == '\\') { escape = 1; }
            else if (c == '"') {
                /* End of string. If we're at depth 1, check if this was a key. */
                p++;
                if (depth == 1) {
                    /* The string just ended at p-1. Check if it matches key
                     * and is followed by a colon. We need to backtrack to find
                     * the start of this string and compare. */
                }
                in_str = 0;
                continue;
            }
            p++;
            continue;
        }
        if (c == '"') {
            /* Start of a string literal */
            const char* str_start = p + 1;
            const char* q = str_start;
            int e = 0;
            while (*q) {
                if (e) { e = 0; q++; continue; }
                if (*q == '\\') { e = 1; q++; continue; }
                if (*q == '"') break;
                q++;
            }
            size_t slen = (size_t)(q - str_start);
            const char* after = (*q == '"') ? q + 1 : q;
            /* If at depth 1 and matches key and followed by ':' -> got it */
            if (depth == 1 && slen == klen && strncmp(str_start, key, klen) == 0) {
                const char* r = after;
                while (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') r++;
                if (*r == ':') {
                    r++;
                    while (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') r++;
                    return r;
                }
            }
            p = after;
            continue;
        }
        if (c == '{' || c == '[') depth++;
        else if (c == '}' || c == ']') depth--;
        p++;
    }
    return NULL;
}

/* Skip a JSON value starting at p; return pointer past the value end.   */
static const char* json_skip_value(const char* p) {
    if (!p || !*p) return p;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p == '"') {
        p++;
        int e = 0;
        while (*p) {
            if (e) { e = 0; p++; continue; }
            if (*p == '\\') { e = 1; p++; continue; }
            if (*p == '"') { p++; break; }
            p++;
        }
        return p;
    }
    if (*p == '{' || *p == '[') {
        char open  = *p;
        char close = (open == '{') ? '}' : ']';
        int depth = 0;
        int in_str = 0;
        int e = 0;
        while (*p) {
            char c = *p;
            if (in_str) {
                if (e) { e = 0; }
                else if (c == '\\') { e = 1; }
                else if (c == '"') in_str = 0;
                p++;
                continue;
            }
            if (c == '"') { in_str = 1; p++; continue; }
            if (c == open) depth++;
            else if (c == close) { depth--; p++; if (depth == 0) return p; continue; }
            p++;
        }
        return p;
    }
    /* scalar: number, true/false/null */
    while (*p && *p != ',' && *p != '}' && *p != ']' &&
           *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++;
    return p;
}

el_val_t json_get_string(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p || *p != '"') return el_wrap_str(el_strdup(""));
    p++;
    JsonParser jp = { .p = p - 1, .end = json + (json ? strlen(json) : 0), .err = 0 };
    char* parsed = jp_parse_string_raw(&jp);
    if (jp.err) { free(parsed); return el_wrap_str(el_strdup("")); }
    return el_wrap_str(parsed);
}

el_val_t json_get_int(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return 0;
    if (*p == '"' || *p == '{' || *p == '[') return 0;
    return (el_val_t)strtoll(p, NULL, 10);
}

el_val_t json_get_float(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return 0;
    if (*p == '"' || *p == '{' || *p == '[') return 0;
    return el_from_float(strtod(p, NULL));
}

el_val_t json_get_bool(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return 0;
    if (strncmp(p, "true", 4) == 0) return 1;
    return 0;
}

el_val_t json_get_raw(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return el_wrap_str(el_strdup(""));
    const char* end = json_skip_value(p);
    size_t n = (size_t)(end - p);
    char* out = el_strbuf(n);
    memcpy(out, p, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    if (!k) k = "";
    if (!json || !*json) {
        /* Build a fresh object */
        JsonBuf b; jb_init(&b);
        jb_putc(&b, '{');
        jb_emit_escaped(&b, k);
        jb_putc(&b, ':');
        jb_emit_value(&b, value);
        jb_putc(&b, '}');
        return el_wrap_str(b.buf);
    }
    const char* existing = json_find_key(json, k);
    JsonBuf b; jb_init(&b);
    if (existing) {
        const char* end = json_skip_value(existing);
        /* Copy [json .. existing) */
        size_t prefix = (size_t)(existing - json);
        jb_reserve(&b, prefix);
        memcpy(b.buf + b.len, json, prefix);
        b.len += prefix;
        b.buf[b.len] = '\0';
        jb_emit_value(&b, value);
        jb_puts(&b, end);
        return el_wrap_str(b.buf);
    }
    /* Insert before closing '}'. Find last '}' */
    size_t jl = strlen(json);
    if (jl == 0) { free(b.buf); return el_wrap_str(el_strdup("{}")); }
    /* Find last '}' from the end */
    ssize_t close_idx = -1;
    for (ssize_t i = (ssize_t)jl - 1; i >= 0; i--) {
        if (json[i] == '}') { close_idx = i; break; }
    }
    if (close_idx < 0) {
        free(b.buf);
        return el_wrap_str(el_strdup(json));
    }
    /* Determine if object is empty: scan between last '{' and '}' for non-ws */
    int empty = 1;
    for (ssize_t i = close_idx - 1; i >= 0; i--) {
        char c = json[i];
        if (c == '{') break;
        if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { empty = 0; break; }
    }
    /* Copy json[0..close_idx) */
    jb_reserve(&b, (size_t)close_idx);
    memcpy(b.buf + b.len, json, (size_t)close_idx);
    b.len += (size_t)close_idx;
    b.buf[b.len] = '\0';
    if (!empty) jb_putc(&b, ',');
    jb_emit_escaped(&b, k);
    jb_putc(&b, ':');
    jb_emit_value(&b, value);
    /* Append from close_idx onward */
    jb_puts(&b, json + close_idx);
    return el_wrap_str(b.buf);
}

el_val_t json_array_len(el_val_t json_str) {
    const char* s = EL_CSTR(json_str);
    if (!s) return 0;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s != '[') return 0;
    s++;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s == ']') return 0;
    int64_t count = 0;
    while (*s) {
        const char* end = json_skip_value(s);
        if (end == s) break;
        count++;
        s = end;
        while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
        if (*s == ',') { s++; continue; }
        if (*s == ']' || *s == '\0') break;
    }
    return (el_val_t)count;
}

/* json_array_get — return the i-th element of a JSON array as a JSON
 * fragment string. Nested objects and arrays are returned verbatim
 * (json_skip_value tracks brace/bracket depth so nested structures are
 * preserved intact). Out-of-range index → "". */
el_val_t json_array_get(el_val_t json_str, el_val_t index) {
    const char* s = EL_CSTR(json_str);
    int64_t  idx = (int64_t)index;
    if (!s || idx < 0) return el_wrap_str(el_strdup(""));
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s != '[') return el_wrap_str(el_strdup(""));
    s++;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s == ']') return el_wrap_str(el_strdup(""));
    int64_t i = 0;
    while (*s) {
        const char* start = s;
        const char* end   = json_skip_value(s);
        if (end == s) break;
        if (i == idx) {
            size_t n = (size_t)(end - start);
            char* out = el_strbuf(n);
            memcpy(out, start, n);
            out[n] = '\0';
            return el_wrap_str(out);
        }
        i++;
        s = end;
        while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
        if (*s == ',') { s++; while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++; continue; }
        if (*s == ']' || *s == '\0') break;
    }
    return el_wrap_str(el_strdup(""));
}

/* json_array_get_string — same as json_array_get, but assume the element
 * is a JSON string and return the unquoted/unescaped value. Non-string
 * elements yield "". */
el_val_t json_array_get_string(el_val_t json_str, el_val_t index) {
    el_val_t raw = json_array_get(json_str, index);
    const char* s = EL_CSTR(raw);
    if (!s || *s != '"') return el_wrap_str(el_strdup(""));
    JsonParser jp = {
        .p   = s,
        .end = s + strlen(s),
        .err = 0,
    };
    char* parsed = jp_parse_string_raw(&jp);
    if (jp.err) {
        free(parsed);
        return el_wrap_str(el_strdup(""));
    }
    return el_wrap_str(parsed);
}

/* ── Time ────────────────────────────────────────────────────────────────── */

el_val_t time_now(void) {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    int64_t ms = (int64_t)tv.tv_sec * 1000LL + (int64_t)tv.tv_usec / 1000LL;
    return (el_val_t)ms;
}

el_val_t time_now_utc(void) {
    return time_now();
}

el_val_t time_format(el_val_t ts, el_val_t fmt) {
    int64_t ms = (int64_t)ts;
    time_t  s  = (time_t)(ms / 1000);
    int     msec = (int)(ms % 1000);
    if (msec < 0) { msec += 1000; s -= 1; }
    struct tm tm;
    gmtime_r(&s, &tm);
    const char* fmt_str = EL_CSTR(fmt);
    if (!fmt_str || strcmp(fmt_str, "ISO") == 0) {
        char buf[64];
        snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
                 tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
                 tm.tm_hour, tm.tm_min, tm.tm_sec, msec);
        return el_wrap_str(el_strdup(buf));
    }
    char buf[256];
    if (strftime(buf, sizeof(buf), fmt_str, &tm) == 0) buf[0] = '\0';
    return el_wrap_str(el_strdup(buf));
}

el_val_t time_to_parts(el_val_t ts) {
    int64_t ms = (int64_t)ts;
    time_t  s  = (time_t)(ms / 1000);
    int     msec = (int)(ms % 1000);
    if (msec < 0) { msec += 1000; s -= 1; }
    struct tm tm;
    gmtime_r(&s, &tm);
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("year")),   (el_val_t)(tm.tm_year + 1900));
    m = el_map_set(m, EL_STR(el_strdup("month")),  (el_val_t)(tm.tm_mon + 1));
    m = el_map_set(m, EL_STR(el_strdup("day")),    (el_val_t)tm.tm_mday);
    m = el_map_set(m, EL_STR(el_strdup("hour")),   (el_val_t)tm.tm_hour);
    m = el_map_set(m, EL_STR(el_strdup("minute")), (el_val_t)tm.tm_min);
    m = el_map_set(m, EL_STR(el_strdup("second")), (el_val_t)tm.tm_sec);
    m = el_map_set(m, EL_STR(el_strdup("ms")),     (el_val_t)msec);
    return m;
}

el_val_t time_from_parts(el_val_t secs, el_val_t ns, el_val_t tz) {
    (void)tz;
    int64_t s  = (int64_t)secs;
    int64_t n  = (int64_t)ns;
    int64_t ms = s * 1000LL + n / 1000000LL;
    return (el_val_t)ms;
}

el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit) {
    const char* u = EL_CSTR(unit);
    int64_t cur = (int64_t)ts;
    int64_t d   = (int64_t)n;
    int64_t add_ms = d;
    if (u) {
        if      (strcmp(u, "ms")   == 0) add_ms = d;
        else if (strcmp(u, "sec")  == 0) add_ms = d * 1000LL;
        else if (strcmp(u, "min")  == 0) add_ms = d * 60000LL;
        else if (strcmp(u, "hour") == 0) add_ms = d * 3600000LL;
        else if (strcmp(u, "day")  == 0) add_ms = d * 86400000LL;
    }
    return (el_val_t)(cur + add_ms);
}

el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit) {
    int64_t d = (int64_t)ts2 - (int64_t)ts1;
    const char* u = EL_CSTR(unit);
    if (!u || strcmp(u, "ms") == 0) return (el_val_t)d;
    if (strcmp(u, "sec")  == 0) return (el_val_t)(d / 1000LL);
    if (strcmp(u, "min")  == 0) return (el_val_t)(d / 60000LL);
    if (strcmp(u, "hour") == 0) return (el_val_t)(d / 3600000LL);
    if (strcmp(u, "day")  == 0) return (el_val_t)(d / 86400000LL);
    return (el_val_t)d;
}

/* Block the calling thread for `secs` seconds. Negative values are clamped
 * to 0. Used by El programs that poll external resources (e.g. RunPod
 * /status, Engram readiness probes). */
el_val_t sleep_secs(el_val_t secs) {
    int64_t s = (int64_t)secs;
    if (s < 0) s = 0;
    struct timespec ts;
    ts.tv_sec  = (time_t)s;
    ts.tv_nsec = 0;
    nanosleep(&ts, NULL);
    return 0;
}

el_val_t sleep_ms(el_val_t ms) {
    int64_t m = (int64_t)ms;
    if (m < 0) m = 0;
    struct timespec ts;
    ts.tv_sec  = (time_t)(m / 1000LL);
    ts.tv_nsec = (long)((m % 1000LL) * 1000000LL);
    nanosleep(&ts, NULL);
    return 0;
}

/* ── UUID v4 ─────────────────────────────────────────────────────────────── */

static int _el_uuid_seeded = 0;

static void _el_uuid_seed(void) {
    if (!_el_uuid_seeded) {
        srand((unsigned)time(NULL) ^ (unsigned)(uintptr_t)&_el_uuid_seeded);
        _el_uuid_seeded = 1;
    }
}

el_val_t uuid_new(void) {
    _el_uuid_seed();
    unsigned char b[16];
    for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xff);
    /* Version 4 */
    b[6] = (b[6] & 0x0f) | 0x40;
    /* RFC 4122 variant */
    b[8] = (b[8] & 0x3f) | 0x80;
    char buf[37];
    snprintf(buf, sizeof(buf),
             "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
             b[0], b[1], b[2], b[3],
             b[4], b[5],
             b[6], b[7],
             b[8], b[9],
             b[10], b[11], b[12], b[13], b[14], b[15]);
    return el_wrap_str(el_strdup(buf));
}

el_val_t uuid_v4(void) { return uuid_new(); }

/* ── Environment ─────────────────────────────────────────────────────────── */

el_val_t env(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return el_wrap_str(el_strdup(""));
    const char* v = getenv(k);
    return el_wrap_str(el_strdup(v ? v : ""));
}

/* ── In-process state K/V ────────────────────────────────────────────────── */

typedef struct {
    char* key;
    char* value;
} StateEntry;

static StateEntry* _state_entries = NULL;
static size_t      _state_count   = 0;
static size_t      _state_cap     = 0;

static StateEntry* state_find(const char* key) {
    for (size_t i = 0; i < _state_count; i++) {
        if (strcmp(_state_entries[i].key, key) == 0) return &_state_entries[i];
    }
    return NULL;
}

el_val_t state_set(el_val_t key, el_val_t value) {
    const char* k = EL_CSTR(key);
    const char* v = EL_CSTR(value);
    if (!k) return 0;
    if (!v) v = "";
    StateEntry* e = state_find(k);
    if (e) {
        free(e->value);
        /* use persist allocator — state values must survive arena teardown */
        e->value = el_strdup_persist(v);
        return 1;
    }
    if (_state_count >= _state_cap) {
        size_t nc = _state_cap == 0 ? 16 : _state_cap * 2;
        _state_entries = realloc(_state_entries, nc * sizeof(StateEntry));
        if (!_state_entries) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        _state_cap = nc;
    }
    _state_entries[_state_count].key   = el_strdup_persist(k);
    _state_entries[_state_count].value = el_strdup_persist(v);
    _state_count++;
    return 1;
}

el_val_t state_get(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return el_wrap_str(el_strdup(""));
    StateEntry* e = state_find(k);
    return el_wrap_str(el_strdup(e ? e->value : ""));
}

el_val_t state_del(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return 0;
    for (size_t i = 0; i < _state_count; i++) {
        if (strcmp(_state_entries[i].key, k) == 0) {
            free(_state_entries[i].key);
            free(_state_entries[i].value);
            for (size_t j = i + 1; j < _state_count; j++) {
                _state_entries[j - 1] = _state_entries[j];
            }
            _state_count--;
            return 1;
        }
    }
    return 1;
}

el_val_t state_keys(void) {
    el_val_t lst = el_list_empty();
    for (size_t i = 0; i < _state_count; i++) {
        lst = el_list_append(lst, el_wrap_str(el_strdup(_state_entries[i].key)));
    }
    return lst;
}

/* ── Float formatting ────────────────────────────────────────────────────── */

el_val_t float_to_str(el_val_t f) {
    char buf[64];
    snprintf(buf, sizeof(buf), "%g", el_to_float(f));
    return el_wrap_str(el_strdup(buf));
}

el_val_t int_to_float(el_val_t n) {
    return el_from_float((double)(int64_t)n);
}

el_val_t float_to_int(el_val_t f) {
    return (el_val_t)(int64_t)el_to_float(f);
}

el_val_t format_float(el_val_t f, el_val_t decimals) {
    int d = (int)(int64_t)decimals;
    if (d < 0)   d = 0;
    if (d > 30)  d = 30;
    char buf[128];
    snprintf(buf, sizeof(buf), "%.*f", d, el_to_float(f));
    return el_wrap_str(el_strdup(buf));
}

el_val_t decimal_round(el_val_t f, el_val_t decimals) {
    int d = (int)(int64_t)decimals;
    if (d < 0)  d = 0;
    if (d > 15) d = 15;
    double mul = pow(10.0, (double)d);
    double v = el_to_float(f);
    double r = (v >= 0.0 ? floor(v * mul + 0.5) : -floor(-v * mul + 0.5)) / mul;
    return el_from_float(r);
}

el_val_t str_to_float(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (!str) return el_from_float(0.0);
    return el_from_float(strtod(str, NULL));
}

/* ── Math (Float-aware) ──────────────────────────────────────────────────── */

el_val_t math_sqrt(el_val_t f) { return el_from_float(sqrt(el_to_float(f))); }
el_val_t math_log(el_val_t f)  { return el_from_float(log(el_to_float(f))); }
el_val_t math_ln(el_val_t f)   { return el_from_float(log(el_to_float(f))); }
el_val_t math_sin(el_val_t f)  { return el_from_float(sin(el_to_float(f))); }
el_val_t math_cos(el_val_t f)  { return el_from_float(cos(el_to_float(f))); }
el_val_t math_pi(void)         { return el_from_float(3.141592653589793238462643383279502884); }

/* ── String additions ────────────────────────────────────────────────────── */

el_val_t str_index_of(el_val_t s, el_val_t sub) {
    const char* str = EL_CSTR(s);
    const char* sb  = EL_CSTR(sub);
    if (!str || !sb) return -1;
    const char* hit = strstr(str, sb);
    if (!hit) return -1;
    return (el_val_t)(int64_t)(hit - str);
}

el_val_t str_split(el_val_t s, el_val_t sep) {
    const char* str = EL_CSTR(s);
    const char* sp  = EL_CSTR(sep);
    el_val_t lst = el_list_empty();
    if (!str) return lst;
    if (!sp || !*sp) {
        lst = el_list_append(lst, el_wrap_str(el_strdup(str)));
        return lst;
    }
    size_t lp = strlen(sp);
    const char* p = str;
    const char* hit;
    while ((hit = strstr(p, sp)) != NULL) {
        size_t n = (size_t)(hit - p);
        char* out = el_strbuf(n);
        memcpy(out, p, n);
        out[n] = '\0';
        lst = el_list_append(lst, el_wrap_str(out));
        p = hit + lp;
    }
    lst = el_list_append(lst, el_wrap_str(el_strdup(p)));
    return lst;
}

el_val_t str_char_at(el_val_t s, el_val_t i) {
    const char* str = EL_CSTR(s);
    int64_t idx = (int64_t)i;
    if (!str) return el_wrap_str(el_strdup(""));
    int64_t n = (int64_t)strlen(str);
    if (idx < 0 || idx >= n) return el_wrap_str(el_strdup(""));
    char buf[2];
    buf[0] = str[idx];
    buf[1] = '\0';
    return el_wrap_str(el_strdup(buf));
}

el_val_t str_char_code(el_val_t s, el_val_t i) {
    const char* str = EL_CSTR(s);
    int64_t idx = (int64_t)i;
    if (!str) return 0;
    int64_t n = (int64_t)strlen(str);
    if (idx < 0 || idx >= n) return 0;
    return (el_val_t)(unsigned char)str[idx];
}

static el_val_t str_pad(const char* s, int64_t width, const char* pad, int left) {
    if (!s) s = "";
    if (!pad || !*pad) pad = " ";
    int64_t lp = (int64_t)strlen(pad);
    int64_t ls = (int64_t)strlen(s);
    if (ls >= width) return el_wrap_str(el_strdup(s));
    int64_t need = width - ls;
    char* out = el_strbuf((size_t)width);
    if (left) {
        for (int64_t i = 0; i < need; i++) out[i] = pad[i % lp];
        memcpy(out + need, s, (size_t)ls);
    } else {
        memcpy(out, s, (size_t)ls);
        for (int64_t i = 0; i < need; i++) out[ls + i] = pad[i % lp];
    }
    out[width] = '\0';
    return el_wrap_str(out);
}

el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad) {
    return str_pad(EL_CSTR(s), (int64_t)width, EL_CSTR(pad), 1);
}

el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad) {
    return str_pad(EL_CSTR(s), (int64_t)width, EL_CSTR(pad), 0);
}

el_val_t str_format(el_val_t template, el_val_t data) {
    const char* tpl = EL_CSTR(template);
    if (!tpl) return el_wrap_str(el_strdup(""));
    JsonBuf b; jb_init(&b);
    const char* p = tpl;
    while (*p) {
        if (*p == '{') {
            const char* q = p + 1;
            while (*q && *q != '}') q++;
            if (*q == '}') {
                size_t klen = (size_t)(q - p - 1);
                char keybuf[256];
                if (klen < sizeof(keybuf)) {
                    memcpy(keybuf, p + 1, klen);
                    keybuf[klen] = '\0';
                    el_val_t v = el_map_get(data, EL_STR(keybuf));
                    if (v != 0 && looks_like_string(v)) {
                        jb_puts(&b, EL_CSTR(v));
                        p = q + 1;
                        continue;
                    } else if (v != 0) {
                        jb_emit_int(&b, (int64_t)v);
                        p = q + 1;
                        continue;
                    }
                }
                /* Unknown key — leave {key} verbatim */
                jb_reserve(&b, klen + 2);
                memcpy(b.buf + b.len, p, klen + 2);
                b.len += klen + 2;
                b.buf[b.len] = '\0';
                p = q + 1;
                continue;
            }
        }
        jb_putc(&b, *p);
        p++;
    }
    return el_wrap_str(b.buf);
}

el_val_t str_lower(el_val_t s) { return str_to_lower(s); }
el_val_t str_upper(el_val_t s) { return str_to_upper(s); }

/* ── List additions ──────────────────────────────────────────────────────── */

el_val_t list_push(el_val_t list, el_val_t elem) {
    return el_list_append(list, elem);
}

el_val_t list_push_front(el_val_t listv, el_val_t elem) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    if (!lst) {
        el_val_t nl = el_list_empty();
        return el_list_append(nl, elem);
    }
    /* Append to grow capacity, then shift right */
    listv = el_list_append(listv, elem);
    lst = (ElList*)(uintptr_t)listv;
    for (int64_t i = lst->length - 1; i > 0; i--) {
        lst->elems[i] = lst->elems[i - 1];
    }
    lst->elems[0] = elem;
    return EL_STR(lst);
}

el_val_t list_join(el_val_t listv, el_val_t sep) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    const char* sp = EL_CSTR(sep);
    if (!sp) sp = "";
    if (!lst || lst->length == 0) return el_wrap_str(el_strdup(""));
    JsonBuf b; jb_init(&b);
    for (int64_t i = 0; i < lst->length; i++) {
        if (i > 0) jb_puts(&b, sp);
        el_val_t v = lst->elems[i];
        if (v == 0) continue;
        if (looks_like_string(v)) {
            jb_puts(&b, EL_CSTR(v));
        } else {
            char tmp[32];
            snprintf(tmp, sizeof(tmp), "%lld", (long long)v);
            jb_puts(&b, tmp);
        }
    }
    return el_wrap_str(b.buf);
}

el_val_t list_range(el_val_t start, el_val_t end) {
    int64_t a = (int64_t)start;
    int64_t b = (int64_t)end;
    el_val_t lst = el_list_empty();
    for (int64_t i = a; i < b; i++) lst = el_list_append(lst, (el_val_t)i);
    return lst;
}

/* ── Bool helpers ────────────────────────────────────────────────────────── */

el_val_t bool_to_str(el_val_t b) {
    return el_wrap_str(el_strdup(b ? "true" : "false"));
}

/* ── Numeric parsing ─────────────────────────────────────────────────────── */

/* parse_int — strtoll with a default. str_to_int already exists but does not
 * distinguish "0" from a parse failure, so callers that need a sentinel use
 * this. Skips leading whitespace; accepts an optional leading +/-; returns
 * default_val on empty input or no consumed digits. Trailing junk is ignored
 * (atoi-style). */
el_val_t parse_int(el_val_t sv, el_val_t default_val) {
    const char* s = EL_CSTR(sv);
    if (!s) return default_val;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s == '\0') return default_val;
    char* end = NULL;
    long long n = strtoll(s, &end, 10);
    if (end == s) return default_val;
    return (el_val_t)n;
}

/* ── Process ─────────────────────────────────────────────────────────────── */

void exit_program(el_val_t code) {
    exit((int)code);
}

/* getpid_now — current process id. Named with the _now suffix to avoid
 * colliding with the libc `getpid` declaration that the runtime already
 * sees via <unistd.h> (calling it `getpid` would fight the prototype). */
el_val_t getpid_now(void) {
    return (el_val_t)getpid();
}

/* ── args() — command-line argument access ──────────────────────────────────
 * Compiled El programs call args() to get a list of CLI arguments.
 * Call el_runtime_init_args(argc, argv) at the start of C main() to populate.
 * The args list excludes argv[0] (the program name).               */

static el_val_t _el_args_list = 0;

void el_runtime_init_args(int argc, char** argv) {
    _el_args_list = el_list_empty();
    for (int i = 1; i < argc; i++) {
        _el_args_list = el_list_append(_el_args_list, EL_STR(argv[i]));
    }
}

el_val_t args(void) {
    if (!_el_args_list) _el_args_list = el_list_empty();
    return _el_args_list;
}

/* ── CGI identity ────────────────────────────────────────────────────────────
 * Called once at program start by the generated main() of a cgi {} program.
 * Stores CGI identity so dharma_* builtins can reference it.               */

static const char* _el_cgi_name      = NULL;
static const char* _el_cgi_dharma_id = NULL;
static const char* _el_cgi_principal = NULL;
static const char* _el_cgi_network   = NULL;
static const char* _el_cgi_engram    = NULL;

void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal,
                 el_val_t network, el_val_t engram) {
    _el_cgi_name      = EL_CSTR(name);
    _el_cgi_dharma_id = EL_CSTR(dharma_id);
    _el_cgi_principal = EL_CSTR(principal);
    _el_cgi_network   = EL_CSTR(network)   ? EL_CSTR(network)   : "dharma-mainnet";
    _el_cgi_engram    = EL_CSTR(engram)    ? EL_CSTR(engram)    : "http://localhost:8742";
    printf("[cgi] identity: name=%s dharma_id=%s principal=%s network=%s engram=%s\n",
           _el_cgi_name      ? _el_cgi_name      : "(unset)",
           _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unset)",
           _el_cgi_principal ? _el_cgi_principal : "(unset)",
           _el_cgi_network,
           _el_cgi_engram);
}


/* ── Batch 3: Engram in-process graph store ──────────────────────────────── */
/*
 * Single global EngramStore allocated lazily on first call. All node and
 * edge content strings are owned (strdup'd) by the store. Linear arrays
 * with doubling capacity for both nodes and edges.
 *
 * Activation algorithm (engram_activate):
 *   1. Find seed nodes whose content/label/tags contain query (case-insens).
 *   2. BFS up to `depth` hops along outgoing+incoming edges from each seed.
 *   3. activation = seed.salience * product(edge_weights) * 0.7^hops
 *   4. If reached by multiple paths, take max activation.
 *   5. epistemic_confidence = activation * node.confidence
 *   6. Filter: epistemic_confidence >= 0.2
 *   7. Sort descending by activation_strength.
 */

typedef struct EngramNode {
    char*   id;
    char*   content;
    char*   node_type;
    char*   label;
    char*   tier;
    char*   tags;
    char*   metadata;
    double  salience;
    double  importance;
    double  confidence;
    int64_t activation_count;
    int64_t last_activated;
    int64_t created_at;
    int64_t updated_at;
} EngramNode;

typedef struct EngramEdge {
    char*   id;
    char*   from_id;
    char*   to_id;
    char*   relation;
    char*   metadata;
    double  weight;
    double  confidence;
    int64_t created_at;
    int64_t updated_at;
    int64_t last_fired;
} EngramEdge;

typedef struct EngramStore {
    EngramNode* nodes;
    int64_t     node_count;
    int64_t     node_capacity;
    EngramEdge* edges;
    int64_t     edge_count;
    int64_t     edge_capacity;
} EngramStore;

static EngramStore* engram_global = NULL;

static EngramStore* engram_get(void) {
    if (engram_global) return engram_global;
    engram_global = calloc(1, sizeof(EngramStore));
    if (!engram_global) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    engram_global->node_capacity = 16;
    engram_global->nodes = calloc((size_t)engram_global->node_capacity, sizeof(EngramNode));
    engram_global->edge_capacity = 16;
    engram_global->edges = calloc((size_t)engram_global->edge_capacity, sizeof(EngramEdge));
    return engram_global;
}

static int64_t engram_now_ms(void) {
    struct timeval tv; gettimeofday(&tv, NULL);
    return (int64_t)tv.tv_sec * 1000LL + (int64_t)tv.tv_usec / 1000LL;
}

static EngramNode* engram_find_node(const char* id) {
    if (!id) return NULL;
    EngramStore* g = engram_get();
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].id && strcmp(g->nodes[i].id, id) == 0) return &g->nodes[i];
    }
    return NULL;
}

static int64_t engram_find_node_index(const char* id) {
    if (!id) return -1;
    EngramStore* g = engram_get();
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].id && strcmp(g->nodes[i].id, id) == 0) return i;
    }
    return -1;
}

static void engram_grow_nodes(void) {
    EngramStore* g = engram_get();
    if (g->node_count < g->node_capacity) return;
    int64_t nc = g->node_capacity * 2;
    g->nodes = realloc(g->nodes, (size_t)nc * sizeof(EngramNode));
    if (!g->nodes) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    memset(g->nodes + g->node_capacity, 0,
           (size_t)(nc - g->node_capacity) * sizeof(EngramNode));
    g->node_capacity = nc;
}

static void engram_grow_edges(void) {
    EngramStore* g = engram_get();
    if (g->edge_count < g->edge_capacity) return;
    int64_t nc = g->edge_capacity * 2;
    g->edges = realloc(g->edges, (size_t)nc * sizeof(EngramEdge));
    if (!g->edges) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    memset(g->edges + g->edge_capacity, 0,
           (size_t)(nc - g->edge_capacity) * sizeof(EngramEdge));
    g->edge_capacity = nc;
}

/* Build a fresh UUID string. Reuses uuid_new but takes the underlying char*. */
static char* engram_new_id(void) {
    el_val_t v = uuid_new();
    const char* s = EL_CSTR(v);
    return el_strdup(s ? s : "");
}

/* Convert a node into an ElMap of its fields.                            */
static el_val_t engram_node_to_map(const EngramNode* n) {
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("id")),         EL_STR(el_strdup(n->id ? n->id : "")));
    m = el_map_set(m, EL_STR(el_strdup("content")),    EL_STR(el_strdup(n->content ? n->content : "")));
    m = el_map_set(m, EL_STR(el_strdup("node_type")),  EL_STR(el_strdup(n->node_type ? n->node_type : "")));
    m = el_map_set(m, EL_STR(el_strdup("label")),      EL_STR(el_strdup(n->label ? n->label : "")));
    m = el_map_set(m, EL_STR(el_strdup("tier")),       EL_STR(el_strdup(n->tier ? n->tier : "Working")));
    m = el_map_set(m, EL_STR(el_strdup("tags")),       EL_STR(el_strdup(n->tags ? n->tags : "")));
    m = el_map_set(m, EL_STR(el_strdup("metadata")),   EL_STR(el_strdup(n->metadata ? n->metadata : "{}")));
    m = el_map_set(m, EL_STR(el_strdup("salience")),   el_from_float(n->salience));
    m = el_map_set(m, EL_STR(el_strdup("importance")), el_from_float(n->importance));
    m = el_map_set(m, EL_STR(el_strdup("confidence")), el_from_float(n->confidence));
    m = el_map_set(m, EL_STR(el_strdup("activation_count")), (el_val_t)n->activation_count);
    m = el_map_set(m, EL_STR(el_strdup("last_activated")),   (el_val_t)n->last_activated);
    m = el_map_set(m, EL_STR(el_strdup("created_at")),       (el_val_t)n->created_at);
    m = el_map_set(m, EL_STR(el_strdup("updated_at")),       (el_val_t)n->updated_at);
    return m;
}

/* (Node JSON serialization is provided by `engram_emit_node_json` further
 * down in the persistence section — reused by the *_json builtins below.) */
static void engram_emit_node_json(JsonBuf* b, const EngramNode* n);
static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e);

/* Salience may arrive either as a float bit-pattern or as a small integer
 * (e.g. 1, meaning 1.0). Heuristic: if interpreted as double it's in
 * [0.0, 100.0] use it; otherwise treat as int and convert. */
static double engram_decode_score(el_val_t v) {
    double f = el_to_float(v);
    if (!isnan(f) && !isinf(f) && f >= 0.0 && f <= 100.0) return f;
    int64_t n = (int64_t)v;
    return (double)n;
}

static char* engram_first_n_chars(const char* s, size_t n) {
    if (!s) return el_strdup("");
    size_t l = strlen(s);
    if (l > n) l = n;
    char* out = el_strbuf(l);
    memcpy(out, s, l);
    out[l] = '\0';
    return out;
}

el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience) {
    EngramStore* g = engram_get();
    engram_grow_nodes();
    EngramNode* n = &g->nodes[g->node_count];
    memset(n, 0, sizeof(*n));
    n->id        = engram_new_id();
    const char* c  = EL_CSTR(content);
    const char* nt = EL_CSTR(node_type);
    n->content   = el_strdup(c ? c : "");
    n->node_type = el_strdup(nt && *nt ? nt : "Memory");
    n->label     = engram_first_n_chars(c, 60);
    n->tier      = el_strdup("Working");
    n->tags      = el_strdup("");
    n->metadata  = el_strdup("{}");
    n->salience    = engram_decode_score(salience);
    if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
    n->importance  = 0.5;
    n->confidence  = 1.0;
    n->activation_count = 0;
    int64_t now = engram_now_ms();
    n->last_activated = now;
    n->created_at = now;
    n->updated_at = now;
    g->node_count++;
    return el_wrap_str(el_strdup(n->id));
}

el_val_t engram_node_full(el_val_t content, el_val_t node_type, el_val_t label,
                          el_val_t salience, el_val_t importance, el_val_t confidence,
                          el_val_t tier, el_val_t tags) {
    EngramStore* g = engram_get();
    engram_grow_nodes();
    EngramNode* n = &g->nodes[g->node_count];
    memset(n, 0, sizeof(*n));
    n->id        = engram_new_id();
    const char* c  = EL_CSTR(content);
    const char* nt = EL_CSTR(node_type);
    const char* lb = EL_CSTR(label);
    const char* ti = EL_CSTR(tier);
    const char* tg = EL_CSTR(tags);
    n->content   = el_strdup(c ? c : "");
    n->node_type = el_strdup(nt && *nt ? nt : "Memory");
    n->label     = el_strdup(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
    n->tier      = el_strdup(ti && *ti ? ti : "Working");
    n->tags      = el_strdup(tg ? tg : "");
    n->metadata  = el_strdup("{}");
    n->salience    = engram_decode_score(salience);
    n->importance  = engram_decode_score(importance);
    n->confidence  = engram_decode_score(confidence);
    if (n->salience    <= 0.0 || n->salience    > 1.0) n->salience    = 0.5;
    if (n->importance  <= 0.0 || n->importance  > 1.0) n->importance  = 0.5;
    if (n->confidence  <= 0.0 || n->confidence  > 1.0) n->confidence  = 1.0;
    int64_t now = engram_now_ms();
    n->last_activated = now;
    n->created_at = now;
    n->updated_at = now;
    g->node_count++;
    return el_wrap_str(el_strdup(n->id));
}

el_val_t engram_get_node(el_val_t id) {
    const char* sid = EL_CSTR(id);
    EngramNode* n = engram_find_node(sid);
    if (!n) return el_map_new(0);
    return engram_node_to_map(n);
}

void engram_strengthen(el_val_t node_id) {
    const char* sid = EL_CSTR(node_id);
    EngramNode* n = engram_find_node(sid);
    if (!n) return;
    n->salience += 0.05;
    if (n->salience > 1.0) n->salience = 1.0;
    n->activation_count++;
    n->last_activated = engram_now_ms();
    n->updated_at = n->last_activated;
}

void engram_forget(el_val_t node_id) {
    const char* sid = EL_CSTR(node_id);
    if (!sid) return;
    EngramStore* g = engram_get();
    int64_t idx = engram_find_node_index(sid);
    if (idx < 0) return;
    /* Free node strings */
    EngramNode* n = &g->nodes[idx];
    free(n->id); free(n->content); free(n->node_type); free(n->label);
    free(n->tier); free(n->tags); free(n->metadata);
    /* Shift remaining nodes down */
    for (int64_t i = idx + 1; i < g->node_count; i++) {
        g->nodes[i - 1] = g->nodes[i];
    }
    g->node_count--;
    memset(&g->nodes[g->node_count], 0, sizeof(EngramNode));
    /* Remove all incident edges */
    int64_t w = 0;
    for (int64_t r = 0; r < g->edge_count; r++) {
        EngramEdge* e = &g->edges[r];
        int incident = (e->from_id && strcmp(e->from_id, sid) == 0) ||
                       (e->to_id   && strcmp(e->to_id,   sid) == 0);
        if (incident) {
            free(e->id); free(e->from_id); free(e->to_id);
            free(e->relation); free(e->metadata);
        } else {
            if (w != r) g->edges[w] = g->edges[r];
            w++;
        }
    }
    g->edge_count = w;
}

el_val_t engram_node_count(void) {
    return (el_val_t)engram_get()->node_count;
}

static int istr_contains(const char* hay, const char* needle) {
    if (!hay || !needle || !*needle) return 0;
    size_t nl = strlen(needle);
    for (const char* p = hay; *p; p++) {
        if (strncasecmp(p, needle, nl) == 0) return 1;
    }
    return 0;
}

el_val_t engram_search(el_val_t query, el_val_t limit) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t lim = (int64_t)limit;
    if (lim <= 0) lim = 100;
    el_val_t lst = el_list_empty();
    if (!q || !*q) return lst;
    int64_t found = 0;
    for (int64_t i = 0; i < g->node_count && found < lim; i++) {
        EngramNode* n = &g->nodes[i];
        if (istr_contains(n->content, q) ||
            istr_contains(n->label,   q) ||
            istr_contains(n->tags,    q)) {
            lst = el_list_append(lst, engram_node_to_map(n));
            found++;
        }
    }
    return lst;
}

/* Sort node indices by salience desc (small N, insertion sort is fine). */
static void engram_sort_indices_by_salience(int64_t* arr, int64_t n,
                                             const EngramNode* nodes) {
    for (int64_t i = 1; i < n; i++) {
        int64_t key = arr[i];
        double  ks  = nodes[key].salience;
        int64_t j = i - 1;
        while (j >= 0 && nodes[arr[j]].salience < ks) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset) {
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
    int64_t off = (int64_t)offset; if (off < 0) off = 0;
    el_val_t lst = el_list_empty();
    if (g->node_count == 0) return lst;
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) return lst;
    for (int64_t i = 0; i < g->node_count; i++) idx[i] = i;
    engram_sort_indices_by_salience(idx, g->node_count, g->nodes);
    int64_t end = off + lim;
    if (end > g->node_count) end = g->node_count;
    for (int64_t i = off; i < end; i++) {
        lst = el_list_append(lst, engram_node_to_map(&g->nodes[idx[i]]));
    }
    free(idx);
    return lst;
}

void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
    EngramStore* g = engram_get();
    const char* f = EL_CSTR(from_id);
    const char* t = EL_CSTR(to_id);
    const char* r = EL_CSTR(relation);
    if (!f || !t) return;
    engram_grow_edges();
    EngramEdge* e = &g->edges[g->edge_count];
    memset(e, 0, sizeof(*e));
    e->id        = engram_new_id();
    e->from_id   = el_strdup(f);
    e->to_id     = el_strdup(t);
    e->relation  = el_strdup(r && *r ? r : "associate");
    e->metadata  = el_strdup("{}");
    e->weight    = engram_decode_score(weight);
    if (e->weight <= 0.0 || e->weight > 1.0) e->weight = 0.5;
    e->confidence = 1.0;
    int64_t now = engram_now_ms();
    e->created_at = now;
    e->updated_at = now;
    e->last_fired = 0;
    g->edge_count++;
}

el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id) {
    EngramStore* g = engram_get();
    const char* f = EL_CSTR(from_id);
    const char* t = EL_CSTR(to_id);
    if (!f || !t) return 0;
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (e->from_id && e->to_id &&
            strcmp(e->from_id, f) == 0 && strcmp(e->to_id, t) == 0) return 1;
    }
    return 0;
}

/* Reserved helper: edge -> ElMap. Kept around for future builtins.       */
static el_val_t engram_edge_to_map(const EngramEdge* e) __attribute__((unused));
static el_val_t engram_edge_to_map(const EngramEdge* e) {
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("id")),         EL_STR(el_strdup(e->id ? e->id : "")));
    m = el_map_set(m, EL_STR(el_strdup("from_id")),    EL_STR(el_strdup(e->from_id ? e->from_id : "")));
    m = el_map_set(m, EL_STR(el_strdup("to_id")),      EL_STR(el_strdup(e->to_id ? e->to_id : "")));
    m = el_map_set(m, EL_STR(el_strdup("relation")),   EL_STR(el_strdup(e->relation ? e->relation : "")));
    m = el_map_set(m, EL_STR(el_strdup("metadata")),   EL_STR(el_strdup(e->metadata ? e->metadata : "{}")));
    m = el_map_set(m, EL_STR(el_strdup("weight")),     el_from_float(e->weight));
    m = el_map_set(m, EL_STR(el_strdup("confidence")), el_from_float(e->confidence));
    m = el_map_set(m, EL_STR(el_strdup("created_at")), (el_val_t)e->created_at);
    m = el_map_set(m, EL_STR(el_strdup("updated_at")), (el_val_t)e->updated_at);
    m = el_map_set(m, EL_STR(el_strdup("last_fired")), (el_val_t)e->last_fired);
    return m;
}

el_val_t engram_neighbors(el_val_t node_id) {
    EngramStore* g = engram_get();
    const char* sid = EL_CSTR(node_id);
    el_val_t lst = el_list_empty();
    if (!sid) return lst;
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        const char* other = NULL;
        if (e->from_id && strcmp(e->from_id, sid) == 0) other = e->to_id;
        else if (e->to_id && strcmp(e->to_id, sid) == 0) other = e->from_id;
        if (!other) continue;
        EngramNode* n = engram_find_node(other);
        if (n) lst = el_list_append(lst, engram_node_to_map(n));
    }
    return lst;
}

el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
    EngramStore* g = engram_get();
    const char* sid = EL_CSTR(node_id);
    int64_t md = (int64_t)max_depth; if (md <= 0) md = 1;
    const char* dir = EL_CSTR(direction); /* "out" | "in" | "both" (default) */
    el_val_t lst = el_list_empty();
    if (!sid || g->node_count == 0) return lst;
    int64_t start = engram_find_node_index(sid);
    if (start < 0) return lst;
    /* BFS with depth tracking */
    int64_t* visited = calloc((size_t)g->node_count, sizeof(int64_t));
    int64_t* queue   = calloc((size_t)g->node_count, sizeof(int64_t));
    int64_t* depths  = calloc((size_t)g->node_count, sizeof(int64_t));
    if (!visited || !queue || !depths) {
        free(visited); free(queue); free(depths); return lst;
    }
    int64_t qh = 0, qt = 0;
    queue[qt++] = start;
    visited[start] = 1;
    depths[start] = 0;
    while (qh < qt) {
        int64_t cur = queue[qh++];
        const char* cur_id = g->nodes[cur].id;
        int64_t cur_depth = depths[cur];
        if (cur_depth >= md) continue;
        for (int64_t i = 0; i < g->edge_count; i++) {
            EngramEdge* e = &g->edges[i];
            const char* other = NULL;
            int outgoing = e->from_id && strcmp(e->from_id, cur_id) == 0;
            int incoming = e->to_id   && strcmp(e->to_id,   cur_id) == 0;
            if (dir && strcmp(dir, "out") == 0 && !outgoing) continue;
            if (dir && strcmp(dir, "in")  == 0 && !incoming) continue;
            if (outgoing) other = e->to_id;
            else if (incoming) other = e->from_id;
            else continue;
            int64_t oi = engram_find_node_index(other);
            if (oi < 0 || visited[oi]) continue;
            visited[oi] = 1;
            depths[oi]  = cur_depth + 1;
            queue[qt++] = oi;
        }
    }
    /* Emit all visited except the seed */
    for (int64_t i = 0; i < g->node_count; i++) {
        if (visited[i] && i != start) {
            lst = el_list_append(lst, engram_node_to_map(&g->nodes[i]));
        }
    }
    free(visited); free(queue); free(depths);
    return lst;
}

el_val_t engram_edge_count(void) {
    return (el_val_t)engram_get()->edge_count;
}

/* Spreading activation. Returns ElList of {node, activation_strength, hops}. */
el_val_t engram_activate(el_val_t query, el_val_t depth) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t max_depth = (int64_t)depth; if (max_depth <= 0) max_depth = 2;
    el_val_t out = el_list_empty();
    if (!q || g->node_count == 0) return out;

    /* Per-node activation tracking. */
    double*  best_activation = calloc((size_t)g->node_count, sizeof(double));
    int64_t* best_hops       = calloc((size_t)g->node_count, sizeof(int64_t));
    int*     reached         = calloc((size_t)g->node_count, sizeof(int));
    if (!best_activation || !best_hops || !reached) {
        free(best_activation); free(best_hops); free(reached); return out;
    }

    /* Find seeds */
    typedef struct { int64_t idx; double act; } SeedEntry;
    SeedEntry* seeds = malloc((size_t)g->node_count * sizeof(SeedEntry));
    int64_t seed_count = 0;
    if (!seeds) {
        free(best_activation); free(best_hops); free(reached); return out;
    }
    for (int64_t i = 0; i < g->node_count; i++) {
        EngramNode* n = &g->nodes[i];
        if (istr_contains(n->content, q) ||
            istr_contains(n->label,   q) ||
            istr_contains(n->tags,    q)) {
            seeds[seed_count].idx = i;
            seeds[seed_count].act = n->salience;
            seed_count++;
            best_activation[i] = n->salience;
            best_hops[i]       = 0;
            reached[i]         = 1;
        }
    }
    /* BFS from each seed. We'll maintain a queue of (node_idx, depth, act). */
    typedef struct { int64_t idx; int64_t hops; double act; } Frontier;
    Frontier* fr = malloc((size_t)(g->node_count * (max_depth + 1)) * sizeof(Frontier) + 16 * sizeof(Frontier));
    if (!fr) {
        free(best_activation); free(best_hops); free(reached); free(seeds); return out;
    }
    int64_t fhead = 0, ftail = 0;
    int64_t fcap = (int64_t)((size_t)(g->node_count * (max_depth + 1)) + 16);
    for (int64_t s = 0; s < seed_count; s++) {
        if (ftail >= fcap) break;
        fr[ftail].idx  = seeds[s].idx;
        fr[ftail].hops = 0;
        fr[ftail].act  = seeds[s].act;
        ftail++;
    }
    const double DECAY = 0.7;
    while (fhead < ftail) {
        Frontier f = fr[fhead++];
        if (f.hops >= max_depth) continue;
        const char* cur_id = g->nodes[f.idx].id;
        for (int64_t ei = 0; ei < g->edge_count; ei++) {
            EngramEdge* e = &g->edges[ei];
            const char* other = NULL;
            if (e->from_id && strcmp(e->from_id, cur_id) == 0) other = e->to_id;
            else if (e->to_id && strcmp(e->to_id, cur_id) == 0) other = e->from_id;
            else continue;
            int64_t oi = engram_find_node_index(other);
            if (oi < 0) continue;
            double new_act = f.act * e->weight * DECAY;
            int64_t new_hops = f.hops + 1;
            if (!reached[oi] || new_act > best_activation[oi]) {
                best_activation[oi] = new_act;
                best_hops[oi]       = new_hops;
                reached[oi]         = 1;
                if (ftail < fcap) {
                    fr[ftail].idx  = oi;
                    fr[ftail].hops = new_hops;
                    fr[ftail].act  = new_act;
                    ftail++;
                }
            }
        }
    }

    /* Collect, filter by epistemic_confidence >= 0.2, sort desc by activation. */
    typedef struct { int64_t idx; double act; double epist; int64_t hops; } Result;
    Result* results = malloc((size_t)g->node_count * sizeof(Result));
    int64_t rcount = 0;
    if (!results) {
        free(best_activation); free(best_hops); free(reached); free(seeds); free(fr);
        return out;
    }
    for (int64_t i = 0; i < g->node_count; i++) {
        if (!reached[i]) continue;
        double epist = best_activation[i] * g->nodes[i].confidence;
        if (epist < 0.2) continue;
        results[rcount].idx   = i;
        results[rcount].act   = best_activation[i];
        results[rcount].epist = epist;
        results[rcount].hops  = best_hops[i];
        rcount++;
    }
    /* Insertion sort by act desc. */
    for (int64_t i = 1; i < rcount; i++) {
        Result key = results[i];
        int64_t j = i - 1;
        while (j >= 0 && results[j].act < key.act) {
            results[j + 1] = results[j];
            j--;
        }
        results[j + 1] = key;
    }
    for (int64_t i = 0; i < rcount; i++) {
        el_val_t entry = el_map_new(0);
        entry = el_map_set(entry, EL_STR(el_strdup("node")),
                           engram_node_to_map(&g->nodes[results[i].idx]));
        entry = el_map_set(entry, EL_STR(el_strdup("activation_strength")),
                           el_from_float(results[i].act));
        entry = el_map_set(entry, EL_STR(el_strdup("epistemic_confidence")),
                           el_from_float(results[i].epist));
        entry = el_map_set(entry, EL_STR(el_strdup("hops")),
                           (el_val_t)results[i].hops);
        out = el_list_append(out, entry);
    }
    free(best_activation); free(best_hops); free(reached);
    free(seeds); free(fr); free(results);
    return out;
}

/* ── Engram persistence (JSON snapshot) ─────────────────────────────────── */

static void engram_emit_node_json(JsonBuf* b, const EngramNode* n) {
    jb_putc(b, '{');
    jb_puts(b, "\"id\":");          jb_emit_escaped(b, n->id ? n->id : "");
    jb_puts(b, ",\"content\":");    jb_emit_escaped(b, n->content ? n->content : "");
    jb_puts(b, ",\"node_type\":");  jb_emit_escaped(b, n->node_type ? n->node_type : "");
    jb_puts(b, ",\"label\":");      jb_emit_escaped(b, n->label ? n->label : "");
    jb_puts(b, ",\"tier\":");       jb_emit_escaped(b, n->tier ? n->tier : "Working");
    jb_puts(b, ",\"tags\":");       jb_emit_escaped(b, n->tags ? n->tags : "");
    jb_puts(b, ",\"metadata\":");   jb_emit_escaped(b, n->metadata ? n->metadata : "{}");
    char tmp[64];
    snprintf(tmp, sizeof(tmp), ",\"salience\":%g", n->salience);    jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"importance\":%g", n->importance); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"confidence\":%g", n->confidence); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"activation_count\":%lld", (long long)n->activation_count); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"last_activated\":%lld",   (long long)n->last_activated);   jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld",       (long long)n->created_at);       jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"updated_at\":%lld",       (long long)n->updated_at);       jb_puts(b, tmp);
    jb_putc(b, '}');
}

static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e) {
    jb_putc(b, '{');
    jb_puts(b, "\"id\":");        jb_emit_escaped(b, e->id ? e->id : "");
    jb_puts(b, ",\"from_id\":");  jb_emit_escaped(b, e->from_id ? e->from_id : "");
    jb_puts(b, ",\"to_id\":");    jb_emit_escaped(b, e->to_id ? e->to_id : "");
    jb_puts(b, ",\"relation\":"); jb_emit_escaped(b, e->relation ? e->relation : "");
    jb_puts(b, ",\"metadata\":"); jb_emit_escaped(b, e->metadata ? e->metadata : "{}");
    char tmp[64];
    snprintf(tmp, sizeof(tmp), ",\"weight\":%g", e->weight);         jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"confidence\":%g", e->confidence); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld", (long long)e->created_at); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"updated_at\":%lld", (long long)e->updated_at); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"last_fired\":%lld", (long long)e->last_fired); jb_puts(b, tmp);
    jb_putc(b, '}');
}

el_val_t engram_save(el_val_t path) {
    const char* p = EL_CSTR(path);
    if (!p || !*p) return 0;
    EngramStore* g = engram_get();
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"nodes\":[");
    for (int64_t i = 0; i < g->node_count; i++) {
        if (i > 0) jb_putc(&b, ',');
        engram_emit_node_json(&b, &g->nodes[i]);
    }
    jb_puts(&b, "],\"edges\":[");
    for (int64_t i = 0; i < g->edge_count; i++) {
        if (i > 0) jb_putc(&b, ',');
        engram_emit_edge_json(&b, &g->edges[i]);
    }
    jb_puts(&b, "]}");
    FILE* f = fopen(p, "wb");
    if (!f) { free(b.buf); return 0; }
    size_t w = fwrite(b.buf, 1, b.len, f);
    fclose(f);
    int ok = (w == b.len);
    free(b.buf);
    return ok ? 1 : 0;
}

/* Helper: extract a string field from a JSON object substring. */
static char* eg_get_str_field(const char* obj, const char* key) {
    const char* p = json_find_key(obj, key);
    if (!p) return el_strdup("");
    if (*p != '"') return el_strdup("");
    JsonParser jp = { .p = p, .end = p + strlen(p), .err = 0 };
    char* out = jp_parse_string_raw(&jp);
    if (jp.err) { free(out); return el_strdup(""); }
    return out;
}

static double eg_get_num_field(const char* obj, const char* key) {
    const char* p = json_find_key(obj, key);
    if (!p || *p == '"' || *p == '{' || *p == '[') return 0.0;
    return strtod(p, NULL);
}

static int64_t eg_get_int_field(const char* obj, const char* key) {
    const char* p = json_find_key(obj, key);
    if (!p || *p == '"' || *p == '{' || *p == '[') return 0;
    return strtoll(p, NULL, 10);
}

/* Iterate the top-level nodes/edges arrays in a saved snapshot. */
static const char* eg_skip_ws(const char* p) {
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    return p;
}

el_val_t engram_load(el_val_t path) {
    const char* p = EL_CSTR(path);
    if (!p || !*p) return 0;
    FILE* f = fopen(p, "rb");
    if (!f) return 0;
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);
    if (sz <= 0) { fclose(f); return 0; }
    char* data = malloc((size_t)sz + 1);
    if (!data) { fclose(f); return 0; }
    size_t got = fread(data, 1, (size_t)sz, f);
    fclose(f);
    data[got] = '\0';

    /* Reset store */
    EngramStore* g = engram_get();
    for (int64_t i = 0; i < g->node_count; i++) {
        free(g->nodes[i].id); free(g->nodes[i].content); free(g->nodes[i].node_type);
        free(g->nodes[i].label); free(g->nodes[i].tier); free(g->nodes[i].tags);
        free(g->nodes[i].metadata);
    }
    g->node_count = 0;
    for (int64_t i = 0; i < g->edge_count; i++) {
        free(g->edges[i].id); free(g->edges[i].from_id); free(g->edges[i].to_id);
        free(g->edges[i].relation); free(g->edges[i].metadata);
    }
    g->edge_count = 0;

    /* Walk nodes array */
    const char* nodes_p = json_find_key(data, "nodes");
    if (nodes_p) {
        nodes_p = eg_skip_ws(nodes_p);
        if (*nodes_p == '[') {
            nodes_p++;
            nodes_p = eg_skip_ws(nodes_p);
            while (*nodes_p && *nodes_p != ']') {
                if (*nodes_p != '{') { nodes_p++; continue; }
                const char* end = json_skip_value(nodes_p);
                size_t n = (size_t)(end - nodes_p);
                char* obj = malloc(n + 1);
                memcpy(obj, nodes_p, n); obj[n] = '\0';
                engram_grow_nodes();
                EngramNode* nn = &g->nodes[g->node_count];
                memset(nn, 0, sizeof(*nn));
                nn->id        = eg_get_str_field(obj, "id");
                nn->content   = eg_get_str_field(obj, "content");
                nn->node_type = eg_get_str_field(obj, "node_type");
                nn->label     = eg_get_str_field(obj, "label");
                nn->tier      = eg_get_str_field(obj, "tier");
                nn->tags      = eg_get_str_field(obj, "tags");
                nn->metadata  = eg_get_str_field(obj, "metadata");
                if (!nn->metadata || !*nn->metadata) { free(nn->metadata); nn->metadata = el_strdup("{}"); }
                nn->salience    = eg_get_num_field(obj, "salience");
                nn->importance  = eg_get_num_field(obj, "importance");
                nn->confidence  = eg_get_num_field(obj, "confidence");
                nn->activation_count = eg_get_int_field(obj, "activation_count");
                nn->last_activated   = eg_get_int_field(obj, "last_activated");
                nn->created_at       = eg_get_int_field(obj, "created_at");
                nn->updated_at       = eg_get_int_field(obj, "updated_at");
                g->node_count++;
                free(obj);
                nodes_p = end;
                nodes_p = eg_skip_ws(nodes_p);
                if (*nodes_p == ',') { nodes_p++; nodes_p = eg_skip_ws(nodes_p); }
            }
        }
    }
    /* Walk edges array */
    const char* edges_p = json_find_key(data, "edges");
    if (edges_p) {
        edges_p = eg_skip_ws(edges_p);
        if (*edges_p == '[') {
            edges_p++;
            edges_p = eg_skip_ws(edges_p);
            while (*edges_p && *edges_p != ']') {
                if (*edges_p != '{') { edges_p++; continue; }
                const char* end = json_skip_value(edges_p);
                size_t n = (size_t)(end - edges_p);
                char* obj = malloc(n + 1);
                memcpy(obj, edges_p, n); obj[n] = '\0';
                engram_grow_edges();
                EngramEdge* ee = &g->edges[g->edge_count];
                memset(ee, 0, sizeof(*ee));
                ee->id       = eg_get_str_field(obj, "id");
                ee->from_id  = eg_get_str_field(obj, "from_id");
                ee->to_id    = eg_get_str_field(obj, "to_id");
                ee->relation = eg_get_str_field(obj, "relation");
                ee->metadata = eg_get_str_field(obj, "metadata");
                if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = el_strdup("{}"); }
                ee->weight     = eg_get_num_field(obj, "weight");
                ee->confidence = eg_get_num_field(obj, "confidence");
                ee->created_at = eg_get_int_field(obj, "created_at");
                ee->updated_at = eg_get_int_field(obj, "updated_at");
                ee->last_fired = eg_get_int_field(obj, "last_fired");
                g->edge_count++;
                free(obj);
                edges_p = end;
                edges_p = eg_skip_ws(edges_p);
                if (*edges_p == ',') { edges_p++; edges_p = eg_skip_ws(edges_p); }
            }
        }
    }
    free(data);
    return 1;
}

/* ── Engram JSON-string accessors ─────────────────────────────────────────
 * These return pre-serialized JSON strings so callers (especially HTTP
 * handlers) don't have to round-trip ElList/ElMap through json_stringify
 * — which can't reliably distinguish those structures from raw pointers
 * due to el_val_t's type erasure. The runtime knows the real C types and
 * can serialize directly. */

el_val_t engram_get_node_json(el_val_t id) {
    const char* sid = EL_CSTR(id);
    EngramNode* n = engram_find_node(sid);
    if (!n) return el_wrap_str(el_strdup("{}"));
    JsonBuf b; jb_init(&b);
    engram_emit_node_json(&b, n);
    return el_wrap_str(b.buf);
}

el_val_t engram_search_json(el_val_t query, el_val_t limit) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t lim = (int64_t)limit;
    if (lim <= 0) lim = 100;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    int first = 1;
    int64_t found = 0;
    if (q && *q) {
        for (int64_t i = 0; i < g->node_count && found < lim; i++) {
            EngramNode* n = &g->nodes[i];
            if (istr_contains(n->content, q) ||
                istr_contains(n->label,   q) ||
                istr_contains(n->tags,    q)) {
                if (!first) jb_putc(&b, ',');
                engram_emit_node_json(&b, n);
                first = 0;
                found++;
            }
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset) {
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
    int64_t off = (int64_t)offset; if (off < 0) off = 0;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (g->node_count == 0) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    for (int64_t i = 0; i < g->node_count; i++) idx[i] = i;
    engram_sort_indices_by_salience(idx, g->node_count, g->nodes);
    int64_t end = off + lim;
    if (end > g->node_count) end = g->node_count;
    int first = 1;
    for (int64_t i = off; i < end; i++) {
        if (!first) jb_putc(&b, ',');
        engram_emit_node_json(&b, &g->nodes[idx[i]]);
        first = 0;
    }
    free(idx);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
    /* Re-implement here directly so we serialize without going through
     * the ElList path. Walks BFS to max_depth, emits {node, edge, hops}
     * triples. */
    EngramStore* g = engram_get();
    const char* sid = EL_CSTR(node_id);
    int64_t depth = (int64_t)max_depth; if (depth <= 0) depth = 1;
    const char* dir = EL_CSTR(direction); if (!dir) dir = "both";
    int allow_out = (strcmp(dir, "out") == 0) || (strcmp(dir, "both") == 0);
    int allow_in  = (strcmp(dir, "in")  == 0) || (strcmp(dir, "both") == 0);
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (!sid || !*sid) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }

    /* Frontier of (node_id, hops). Cap to a sane size. */
    char** frontier = calloc(1024, sizeof(char*));
    int64_t* frontier_h = calloc(1024, sizeof(int64_t));
    int64_t fc = 0;
    char** visited = calloc(1024, sizeof(char*));
    int64_t vc = 0;
    if (!frontier || !frontier_h || !visited) {
        free(frontier); free(frontier_h); free(visited);
        jb_putc(&b, ']'); return el_wrap_str(b.buf);
    }
    frontier[fc] = el_strdup(sid); frontier_h[fc] = 0; fc++;
    visited[vc++] = el_strdup(sid);

    int first = 1;
    while (fc > 0) {
        char* cur = frontier[0]; int64_t h = frontier_h[0];
        for (int64_t k = 1; k < fc; k++) { frontier[k-1] = frontier[k]; frontier_h[k-1] = frontier_h[k]; }
        fc--;
        if (h >= depth) { free(cur); continue; }
        for (int64_t i = 0; i < g->edge_count; i++) {
            EngramEdge* e = &g->edges[i];
            const char* peer = NULL;
            if (allow_out && e->from_id && strcmp(e->from_id, cur) == 0) peer = e->to_id;
            else if (allow_in && e->to_id && strcmp(e->to_id, cur) == 0) peer = e->from_id;
            if (!peer) continue;
            int seen = 0;
            for (int64_t v = 0; v < vc; v++) {
                if (strcmp(visited[v], peer) == 0) { seen = 1; break; }
            }
            if (seen) continue;
            EngramNode* n = engram_find_node(peer);
            if (!n) continue;
            if (!first) jb_putc(&b, ',');
            jb_puts(&b, "{\"node\":");
            engram_emit_node_json(&b, n);
            jb_puts(&b, ",\"edge\":");
            engram_emit_edge_json(&b, e);
            char tmp[64]; snprintf(tmp, sizeof(tmp), ",\"hops\":%lld}", (long long)(h + 1));
            jb_puts(&b, tmp);
            first = 0;
            if (vc < 1024) visited[vc++] = el_strdup(peer);
            if (fc < 1024 && h + 1 < depth) { frontier[fc] = el_strdup(peer); frontier_h[fc] = h + 1; fc++; }
        }
        free(cur);
    }
    for (int64_t i = 0; i < fc; i++) free(frontier[i]);
    for (int64_t i = 0; i < vc; i++) free(visited[i]);
    free(frontier); free(frontier_h); free(visited);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

el_val_t engram_activate_json(el_val_t query, el_val_t depth) {
    /* Run the existing engram_activate to get the ElList of result maps,
     * then walk that list and serialize each entry into JSON manually.
     * We have the raw nodes via engram_find_node, so we can re-emit
     * directly without trusting json_stringify on the ElMap. */
    el_val_t lst = engram_activate(query, depth);
    ElList* arr = (ElList*)(uintptr_t)lst;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (arr) {
        for (int64_t i = 0; i < arr->length; i++) {
            ElMap* entry = (ElMap*)(uintptr_t)arr->elems[i];
            if (!entry) continue;
            /* The entry map has keys: "node" (ElMap), "activation_strength"
             * (Float bit-pattern), "hops" (Int). Read them from the map
             * directly using el_map_get with EL_STR keys. */
            el_val_t node_map = el_map_get(arr->elems[i], EL_STR("node"));
            el_val_t strength_v = el_map_get(arr->elems[i], EL_STR("activation_strength"));
            el_val_t hops_v = el_map_get(arr->elems[i], EL_STR("hops"));
            /* Look up the underlying EngramNode by id field of the map */
            el_val_t id_v = el_map_get(node_map, EL_STR("id"));
            const char* id_s = EL_CSTR(id_v);
            EngramNode* n = id_s ? engram_find_node(id_s) : NULL;
            if (i > 0) jb_putc(&b, ',');
            jb_puts(&b, "{\"node\":");
            if (n) {
                engram_emit_node_json(&b, n);
            } else {
                jb_puts(&b, "{}");
            }
            char tmp[64];
            snprintf(tmp, sizeof(tmp), ",\"activation_strength\":%g", el_to_float(strength_v));
            jb_puts(&b, tmp);
            snprintf(tmp, sizeof(tmp), ",\"hops\":%lld}", (long long)(int64_t)hops_v);
            jb_puts(&b, tmp);
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

el_val_t engram_stats_json(void) {
    EngramStore* g = engram_get();
    char buf[128];
    snprintf(buf, sizeof(buf),
             "{\"node_count\":%lld,\"edge_count\":%lld}",
             (long long)g->node_count, (long long)g->edge_count);
    return el_wrap_str(el_strdup(buf));
}

/* ── DHARMA network ─────────────────────────────────────────────────────────
 * Real implementation. Peers are addressed by `dharma_id` — either bare
 * (e.g. "ntn-genesis", transport defaults to http://localhost:7770) or
 * "<id>@<url>" where <url> is the peer's Engram-exposed daemon.
 *
 * Channels are logical handles cached per-cgi: `dharma_connect` is
 * idempotent and returns "ch:<cgi_id>". The channel registry below tracks
 * every cgi_id we've connected to and its resolved transport URL.
 *
 * Relationship weights live in the local Engram graph: edges of type
 * "dharma-relation" between a synthetic local node ("dharma:self") and
 * synthetic peer nodes ("dharma:peer:<cgi_id>"). Hebbian increments
 * accumulate in EngramEdge.weight, clamped to [0.0, 1.0].
 *
 * Events arrive over HTTP via the application's request handler, which is
 * expected to call el_runtime_dharma_event_arrive() when it sees a
 * /dharma/event POST. dharma_field() blocks on a per-event-type queue.
 */

#define DHARMA_DEFAULT_URL "http://localhost:7770"

/* Channel registry — one entry per known peer.                             */
typedef struct DharmaChannel {
    char* cgi_id;       /* full dharma_id including any @<url> suffix */
    char* base_id;      /* registry-id portion (before @) for relationship lookup */
    char* url;          /* resolved transport URL */
    char* channel_id;   /* "ch:<cgi_id>" */
} DharmaChannel;

static DharmaChannel* _dharma_channels = NULL;
static size_t         _dharma_channel_count = 0;
static size_t         _dharma_channel_cap   = 0;
static pthread_mutex_t _dharma_channel_mu   = PTHREAD_MUTEX_INITIALIZER;

/* Event queue — per-type linked list. dharma_field blocks on _dharma_event_cv. */
typedef struct DharmaEvent {
    char* event_type;
    char* payload;
    char* source;
    int64_t timestamp;
    struct DharmaEvent* next;
} DharmaEvent;

static DharmaEvent*   _dharma_event_head = NULL;
static DharmaEvent*   _dharma_event_tail = NULL;
static pthread_mutex_t _dharma_event_mu  = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t  _dharma_event_cv  = PTHREAD_COND_INITIALIZER;

/* Split "<id>@<url>" → (base_id, url). If no "@", base_id = full, url = default.
 * Returned strings are heap-allocated; caller must free.                    */
static void dharma_parse_id(const char* full, char** out_base, char** out_url) {
    if (!full) full = "";
    const char* at = strchr(full, '@');
    if (at) {
        size_t bn = (size_t)(at - full);
        char* b = malloc(bn + 1);
        memcpy(b, full, bn); b[bn] = '\0';
        *out_base = b;
        *out_url  = el_strdup(at + 1);
        if (!**out_url) { free(*out_url); *out_url = el_strdup(DHARMA_DEFAULT_URL); }
    } else {
        *out_base = el_strdup(full);
        *out_url  = el_strdup(DHARMA_DEFAULT_URL);
    }
}

/* Find existing channel by full cgi_id. Caller must hold _dharma_channel_mu. */
static DharmaChannel* dharma_find_channel_locked(const char* cgi_id) {
    if (!cgi_id) return NULL;
    for (size_t i = 0; i < _dharma_channel_count; i++) {
        if (_dharma_channels[i].cgi_id &&
            strcmp(_dharma_channels[i].cgi_id, cgi_id) == 0) {
            return &_dharma_channels[i];
        }
    }
    return NULL;
}

/* Add a new channel entry. Caller must hold _dharma_channel_mu.            */
static DharmaChannel* dharma_add_channel_locked(const char* cgi_id) {
    if (_dharma_channel_count >= _dharma_channel_cap) {
        size_t nc = _dharma_channel_cap ? _dharma_channel_cap * 2 : 8;
        _dharma_channels = realloc(_dharma_channels, nc * sizeof(DharmaChannel));
        if (!_dharma_channels) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        memset(_dharma_channels + _dharma_channel_cap, 0,
               (nc - _dharma_channel_cap) * sizeof(DharmaChannel));
        _dharma_channel_cap = nc;
    }
    DharmaChannel* ch = &_dharma_channels[_dharma_channel_count++];
    char* base = NULL; char* url = NULL;
    dharma_parse_id(cgi_id, &base, &url);
    ch->cgi_id  = el_strdup(cgi_id ? cgi_id : "");
    ch->base_id = base;
    ch->url     = url;
    size_t cn = strlen(ch->cgi_id) + 4;
    ch->channel_id = malloc(cn);
    snprintf(ch->channel_id, cn, "ch:%s", ch->cgi_id);
    return ch;
}

el_val_t dharma_connect(el_val_t cgi_id) {
    const char* id = EL_CSTR(cgi_id);
    if (!id || !*id) return el_wrap_str(el_strdup(""));
    pthread_mutex_lock(&_dharma_channel_mu);
    DharmaChannel* ch = dharma_find_channel_locked(id);
    if (!ch) ch = dharma_add_channel_locked(id);
    char* out = el_strdup(ch->channel_id);
    pthread_mutex_unlock(&_dharma_channel_mu);
    return el_wrap_str(out);
}

/* Build an error JSON body — same shape http_error_json uses. */
static el_val_t dharma_error_json(const char* msg) {
    return http_error_json(msg);
}

el_val_t dharma_send(el_val_t channel, el_val_t content) {
    const char* ch_id = EL_CSTR(channel);
    const char* msg   = EL_CSTR(content);
    if (!ch_id || strncmp(ch_id, "ch:", 3) != 0) {
        return dharma_error_json("invalid channel");
    }
    const char* peer_id = ch_id + 3;
    /* Look up channel; if unknown (caller fabricated), auto-register. */
    pthread_mutex_lock(&_dharma_channel_mu);
    DharmaChannel* ch = dharma_find_channel_locked(peer_id);
    if (!ch) ch = dharma_add_channel_locked(peer_id);
    char* url  = el_strdup(ch->url);
    pthread_mutex_unlock(&_dharma_channel_mu);
    /* Build /dharma/recv body. */
    const char* from = _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unknown)";
    char* esc_ch   = json_escape_alloc(ch_id);
    char* esc_from = json_escape_alloc(from);
    char* esc_msg  = json_escape_alloc(msg ? msg : "");
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"channel\":\"");  jb_puts(&b, esc_ch);
    jb_puts(&b, "\",\"from\":\"");   jb_puts(&b, esc_from);
    jb_puts(&b, "\",\"content\":\""); jb_puts(&b, esc_msg);
    jb_puts(&b, "\"}");
    free(esc_ch); free(esc_from); free(esc_msg);
    size_t ul = strlen(url) + 16;
    char* full_url = malloc(ul);
    snprintf(full_url, ul, "%s/dharma/recv", url);
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    el_val_t resp = http_do("POST", full_url, b.buf, h);
    curl_slist_free_all(h);
    free(b.buf); free(full_url); free(url);
    return resp;
}

el_val_t dharma_activate(el_val_t query) {
    const char* q = EL_CSTR(query);
    if (!q) q = "";
    el_val_t out = el_list_empty();
    char* esc_q = json_escape_alloc(q);
    JsonBuf body; jb_init(&body);
    jb_puts(&body, "{\"query\":\""); jb_puts(&body, esc_q); jb_puts(&body, "\"}");
    free(esc_q);

    /* Snapshot the channel list under lock so we can iterate without
     * holding the mutex during network I/O.                                */
    pthread_mutex_lock(&_dharma_channel_mu);
    size_t n = _dharma_channel_count;
    char** urls   = calloc(n ? n : 1, sizeof(char*));
    char** ids    = calloc(n ? n : 1, sizeof(char*));
    char** bases  = calloc(n ? n : 1, sizeof(char*));
    for (size_t i = 0; i < n; i++) {
        urls[i]  = el_strdup(_dharma_channels[i].url);
        ids[i]   = el_strdup(_dharma_channels[i].cgi_id);
        bases[i] = el_strdup(_dharma_channels[i].base_id);
    }
    pthread_mutex_unlock(&_dharma_channel_mu);

    for (size_t i = 0; i < n; i++) {
        size_t ul = strlen(urls[i]) + 32;
        char* full_url = malloc(ul);
        snprintf(full_url, ul, "%s/api/activate", urls[i]);
        struct curl_slist* h = NULL;
        h = curl_slist_append(h, "Content-Type: application/json");
        el_val_t resp = http_do("POST", full_url, body.buf, h);
        curl_slist_free_all(h);
        free(full_url);
        const char* rs = EL_CSTR(resp);
        if (!rs || !*rs) continue;
        if (rs[0] == '{' && strstr(rs, "\"error\"")) continue;

        /* Look up relationship weight (attenuation). */
        double rel_weight = 1.0;
        {
            const char* self_id = "dharma:self";
            char peer_node[512];
            snprintf(peer_node, sizeof(peer_node), "dharma:peer:%s", bases[i]);
            EngramStore* g = engram_get();
            for (int64_t k = 0; k < g->edge_count; k++) {
                EngramEdge* e = &g->edges[k];
                if (e->from_id && e->to_id &&
                    strcmp(e->from_id, self_id) == 0 &&
                    strcmp(e->to_id, peer_node) == 0 &&
                    e->relation && strcmp(e->relation, "dharma-relation") == 0) {
                    rel_weight = e->weight;
                    break;
                }
            }
        }

        /* Iterate the response array. Expect either a top-level array
         * or an object whose "results" field is an array.                 */
        const char* arr = rs;
        while (*arr == ' ' || *arr == '\t' || *arr == '\n' || *arr == '\r') arr++;
        char* arr_owned = NULL;
        if (*arr == '{') {
            el_val_t r = json_get_raw(EL_STR(rs), EL_STR("results"));
            const char* rr = EL_CSTR(r);
            if (rr && *rr == '[') {
                arr_owned = el_strdup(rr);
                arr = arr_owned;
            } else {
                continue;
            }
        }
        if (*arr != '[') { free(arr_owned); continue; }
        const char* p = arr + 1;
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
        while (*p && *p != ']') {
            const char* end = json_skip_value(p);
            size_t en = (size_t)(end - p);
            char* obj = el_strbuf(en);
            memcpy(obj, p, en); obj[en] = '\0';

            /* Pull activation_strength if present, else 1.0. */
            el_val_t act_v = json_get_float(EL_STR(obj), EL_STR("activation_strength"));
            double act = el_to_float(act_v);
            if (!(act > 0.0 && act <= 100.0)) act = 1.0;
            double final_act = act * rel_weight;

            el_val_t entry = el_map_new(0);
            /* node = the inner JSON if present, else the entire obj. */
            el_val_t node_raw = json_get_raw(EL_STR(obj), EL_STR("node"));
            const char* nr = EL_CSTR(node_raw);
            entry = el_map_set(entry, EL_STR(el_strdup("node")),
                               (nr && *nr) ? node_raw : EL_STR(el_strdup(obj)));
            entry = el_map_set(entry, EL_STR(el_strdup("source_cgi")),
                               EL_STR(el_strdup(ids[i])));
            entry = el_map_set(entry, EL_STR(el_strdup("activation_strength")),
                               el_from_float(final_act));
            out = el_list_append(out, entry);
            free(obj);
            p = end;
            while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        }
        free(arr_owned);
    }
    for (size_t i = 0; i < n; i++) { free(urls[i]); free(ids[i]); free(bases[i]); }
    free(urls); free(ids); free(bases);
    free(body.buf);
    return out;
}

void dharma_emit(el_val_t event_type, el_val_t payload) {
    const char* et  = EL_CSTR(event_type);
    const char* pay = EL_CSTR(payload);
    if (!et)  et  = "";
    if (!pay) pay = "";
    const char* src = _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unknown)";
    int64_t ts = engram_now_ms();

    char* esc_et  = json_escape_alloc(et);
    char* esc_pay = json_escape_alloc(pay);
    char* esc_src = json_escape_alloc(src);
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"type\":\"");      jb_puts(&b, esc_et);
    jb_puts(&b, "\",\"payload\":\""); jb_puts(&b, esc_pay);
    jb_puts(&b, "\",\"source\":\"");  jb_puts(&b, esc_src);
    jb_puts(&b, "\",\"timestamp\":"); jb_emit_int(&b, ts);
    jb_putc(&b, '}');
    free(esc_et); free(esc_pay); free(esc_src);

    /* Snapshot URLs to avoid holding the channel mutex during I/O. */
    pthread_mutex_lock(&_dharma_channel_mu);
    size_t n = _dharma_channel_count;
    char** urls = calloc(n ? n : 1, sizeof(char*));
    for (size_t i = 0; i < n; i++) urls[i] = el_strdup(_dharma_channels[i].url);
    pthread_mutex_unlock(&_dharma_channel_mu);

    for (size_t i = 0; i < n; i++) {
        size_t ul = strlen(urls[i]) + 32;
        char* full_url = malloc(ul);
        snprintf(full_url, ul, "%s/dharma/event", urls[i]);
        struct curl_slist* h = NULL;
        h = curl_slist_append(h, "Content-Type: application/json");
        el_val_t r = http_do("POST", full_url, b.buf, h);
        (void)r; /* fire-and-forget — emit is not synchronous */
        curl_slist_free_all(h);
        free(full_url);
    }
    for (size_t i = 0; i < n; i++) free(urls[i]);
    free(urls);
    free(b.buf);
}

void el_runtime_dharma_event_arrive(const char* event_type, const char* payload,
                                    const char* source) {
    DharmaEvent* ev = calloc(1, sizeof(DharmaEvent));
    if (!ev) return;
    ev->event_type = el_strdup(event_type ? event_type : "");
    ev->payload    = el_strdup(payload    ? payload    : "");
    ev->source     = el_strdup(source     ? source     : "");
    ev->timestamp  = engram_now_ms();
    ev->next       = NULL;
    pthread_mutex_lock(&_dharma_event_mu);
    if (_dharma_event_tail) _dharma_event_tail->next = ev;
    else                    _dharma_event_head = ev;
    _dharma_event_tail = ev;
    pthread_cond_broadcast(&_dharma_event_cv);
    pthread_mutex_unlock(&_dharma_event_mu);
}

el_val_t dharma_field(el_val_t event_type) {
    const char* et = EL_CSTR(event_type);
    if (!et) et = "";

    /* Compute deadline: now + 30 seconds. */
    struct timespec deadline;
    clock_gettime(CLOCK_REALTIME, &deadline);
    deadline.tv_sec += 30;

    DharmaEvent* found = NULL;
    pthread_mutex_lock(&_dharma_event_mu);
    while (1) {
        /* Scan queue for matching type; pop and return first match. */
        DharmaEvent* prev = NULL;
        DharmaEvent* cur  = _dharma_event_head;
        while (cur) {
            if (cur->event_type && strcmp(cur->event_type, et) == 0) {
                if (prev) prev->next = cur->next;
                else      _dharma_event_head = cur->next;
                if (_dharma_event_tail == cur) _dharma_event_tail = prev;
                cur->next = NULL;
                found = cur;
                break;
            }
            prev = cur; cur = cur->next;
        }
        if (found) break;
        int rc = pthread_cond_timedwait(&_dharma_event_cv, &_dharma_event_mu, &deadline);
        if (rc == ETIMEDOUT) break;
    }
    pthread_mutex_unlock(&_dharma_event_mu);

    if (!found) return el_map_new(0);
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("type")),
                   EL_STR(el_strdup(found->event_type ? found->event_type : "")));
    m = el_map_set(m, EL_STR(el_strdup("payload")),
                   EL_STR(el_strdup(found->payload ? found->payload : "")));
    m = el_map_set(m, EL_STR(el_strdup("source_cgi")),
                   EL_STR(el_strdup(found->source ? found->source : "")));
    m = el_map_set(m, EL_STR(el_strdup("timestamp")), (el_val_t)found->timestamp);
    free(found->event_type); free(found->payload); free(found->source); free(found);
    return m;
}

/* Locate (or create) the local "dharma:self" node and the synthetic peer
 * node "dharma:peer:<base_id>". Returns the index of the dharma-relation
 * edge, or -1 if not found. If `create` is non-zero, ensure the nodes
 * and edge exist (creating them as needed) and return the edge index.   */
static int64_t dharma_find_or_create_relation_edge(const char* peer_base, int create) {
    if (!peer_base || !*peer_base) return -1;
    EngramStore* g = engram_get();
    const char* self_id = "dharma:self";
    char peer_node[512];
    snprintf(peer_node, sizeof(peer_node), "dharma:peer:%s", peer_base);

    /* Look for the edge first. */
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (e->from_id && e->to_id &&
            strcmp(e->from_id, self_id) == 0 &&
            strcmp(e->to_id, peer_node) == 0 &&
            e->relation && strcmp(e->relation, "dharma-relation") == 0) {
            return i;
        }
    }
    if (!create) return -1;

    /* Ensure self node exists. We use a fixed id (not engram_new_id) so
     * subsequent calls reuse the same one.                              */
    if (!engram_find_node(self_id)) {
        engram_grow_nodes();
        EngramNode* n = &g->nodes[g->node_count];
        memset(n, 0, sizeof(*n));
        n->id        = el_strdup(self_id);
        n->content   = el_strdup(_el_cgi_dharma_id ? _el_cgi_dharma_id : "(self)");
        n->node_type = el_strdup("DharmaSelf");
        n->label     = el_strdup("dharma:self");
        n->tier      = el_strdup("Working");
        n->tags      = el_strdup("dharma");
        n->metadata  = el_strdup("{}");
        n->salience  = 1.0; n->importance = 1.0; n->confidence = 1.0;
        int64_t now  = engram_now_ms();
        n->created_at = now; n->updated_at = now; n->last_activated = now;
        g->node_count++;
    }
    if (!engram_find_node(peer_node)) {
        engram_grow_nodes();
        EngramNode* n = &g->nodes[g->node_count];
        memset(n, 0, sizeof(*n));
        n->id        = el_strdup(peer_node);
        n->content   = el_strdup(peer_base);
        n->node_type = el_strdup("DharmaPeer");
        n->label     = el_strdup(peer_node);
        n->tier      = el_strdup("Working");
        n->tags      = el_strdup("dharma");
        n->metadata  = el_strdup("{}");
        n->salience  = 0.5; n->importance = 0.5; n->confidence = 1.0;
        int64_t now  = engram_now_ms();
        n->created_at = now; n->updated_at = now; n->last_activated = now;
        g->node_count++;
    }
    /* Create the edge with weight 0.0 — caller will increment. */
    engram_grow_edges();
    EngramEdge* e = &g->edges[g->edge_count];
    memset(e, 0, sizeof(*e));
    e->id        = engram_new_id();
    e->from_id   = el_strdup(self_id);
    e->to_id     = el_strdup(peer_node);
    e->relation  = el_strdup("dharma-relation");
    e->metadata  = el_strdup("{}");
    e->weight    = 0.0;
    e->confidence = 1.0;
    int64_t now  = engram_now_ms();
    e->created_at = now; e->updated_at = now;
    int64_t idx = g->edge_count;
    g->edge_count++;
    return idx;
}

void dharma_strengthen(el_val_t cgi_id, el_val_t weight) {
    const char* id = EL_CSTR(cgi_id);
    if (!id || !*id) return;
    char* base = NULL; char* url = NULL;
    dharma_parse_id(id, &base, &url);
    free(url);
    int64_t ei = dharma_find_or_create_relation_edge(base, 1);
    free(base);
    if (ei < 0) return;
    EngramStore* g = engram_get();
    double inc = engram_decode_score(weight);
    if (!(inc >= 0.0)) inc = 0.0;
    double w = g->edges[ei].weight + inc;
    if (w < 0.0) w = 0.0;
    if (w > 1.0) w = 1.0;
    g->edges[ei].weight     = w;
    g->edges[ei].updated_at = engram_now_ms();
    g->edges[ei].last_fired = g->edges[ei].updated_at;
}

el_val_t dharma_relationship(el_val_t cgi_id) {
    const char* id = EL_CSTR(cgi_id);
    if (!id || !*id) return el_from_float(0.0);
    char* base = NULL; char* url = NULL;
    dharma_parse_id(id, &base, &url);
    free(url);
    int64_t ei = dharma_find_or_create_relation_edge(base, 0);
    free(base);
    if (ei < 0) return el_from_float(0.0);
    EngramStore* g = engram_get();
    return el_from_float(g->edges[ei].weight);
}

el_val_t dharma_peers(void) {
    /* Walk dharma-relation edges out of "dharma:self", weight > 0, sort desc. */
    EngramStore* g = engram_get();
    const char* self_id = "dharma:self";
    typedef struct { char* peer_base; double weight; } PeerEntry;
    PeerEntry* peers = malloc((size_t)(g->edge_count + 1) * sizeof(PeerEntry));
    int64_t pcount = 0;
    if (!peers) return el_list_empty();
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (!e->from_id || !e->to_id) continue;
        if (strcmp(e->from_id, self_id) != 0) continue;
        if (!e->relation || strcmp(e->relation, "dharma-relation") != 0) continue;
        if (e->weight <= 0.0) continue;
        const char* prefix = "dharma:peer:";
        size_t pl = strlen(prefix);
        if (strncmp(e->to_id, prefix, pl) != 0) continue;
        peers[pcount].peer_base = el_strdup(e->to_id + pl);
        peers[pcount].weight    = e->weight;
        pcount++;
    }
    /* Sort desc by weight. */
    for (int64_t i = 1; i < pcount; i++) {
        PeerEntry key = peers[i];
        int64_t j = i - 1;
        while (j >= 0 && peers[j].weight < key.weight) {
            peers[j + 1] = peers[j]; j--;
        }
        peers[j + 1] = key;
    }
    el_val_t out = el_list_empty();
    for (int64_t i = 0; i < pcount; i++) {
        out = el_list_append(out, EL_STR(peers[i].peer_base));
    }
    free(peers);
    return out;
}

/* ── Batch 4: LLM (Anthropic API client) ─────────────────────────────────── */
/*
 * All LLM builtins call https://api.anthropic.com/v1/messages with the API
 * key from env ANTHROPIC_API_KEY. Default model is "claude-sonnet-4-5"
 * when the supplied model is empty/null.
 *
 * `llm_call_agentic` runs a real multi-turn tool_use/tool_result loop.
 * Tool handlers are registered with `llm_register_tool(name, fn_name)`,
 * which dlsym()s the named symbol. Each tool handler has the C signature
 *   el_val_t handler(el_val_t input_json);
 * and returns a JSON-string el_val_t result. Iteration is capped at 10.
 */

static const char* LLM_DEFAULT_MODEL = "claude-sonnet-4-5";
static const char* LLM_API_URL       = "https://api.anthropic.com/v1/messages";
static const char* LLM_VERSION       = "2023-06-01";

static const char* llm_resolve_model(const char* m) {
    if (!m || !*m) return LLM_DEFAULT_MODEL;
    return m;
}

/*
 * ── Configurable LLM provider chain ──────────────────────────────────────────
 *
 * Providers are configured via indexed env vars. The runtime tries each in
 * order (0, 1, 2, ...) and returns the first successful non-empty response.
 *
 * Per provider (N = 0, 1, 2, ...):
 *   NEURON_LLM_N_URL    — endpoint URL (base URL; /v1/chat/completions appended
 *                          if format is "openai" and not already in URL)
 *   NEURON_LLM_N_KEY    — API key
 *   NEURON_LLM_N_FORMAT — "openai" (default) or "anthropic"
 *   NEURON_LLM_N_MODEL  — model name override (optional)
 *
 * Example — Neuron inference primary, Anthropic fallback:
 *   NEURON_LLM_0_URL=https://soma.../v1/chat/completions
 *   NEURON_LLM_0_KEY=svc-key
 *   NEURON_LLM_0_FORMAT=openai
 *   NEURON_LLM_0_MODEL=neuron
 *   NEURON_LLM_1_URL=https://api.anthropic.com/v1/messages
 *   NEURON_LLM_1_KEY=sk-ant-...
 *   NEURON_LLM_1_FORMAT=anthropic
 *
 * If no NEURON_LLM_0_URL is set, falls back to legacy ANTHROPIC_API_KEY.
 */

#define LLM_MAX_PROVIDERS 16

/* forward declarations */
static el_val_t llm_extract_text(el_val_t resp_val);
static el_val_t llm_extract_text_openai(el_val_t resp_val);

static el_val_t llm_extract_text_openai(el_val_t resp_val) {
    const char* resp = EL_CSTR(resp_val);
    if (!resp || !*resp) return el_wrap_str(el_strdup(""));
    if (resp[0] == '{' && strstr(resp, "\"error\"")) return el_wrap_str(el_strdup(""));
    const char* choices = json_find_key(resp, "choices");
    if (!choices || *choices != '[') return el_wrap_str(el_strdup(""));
    choices++;
    while (*choices == ' ' || *choices == '\t') choices++;
    if (*choices != '{') return el_wrap_str(el_strdup(""));
    const char* end = json_skip_value(choices);
    size_t n = (size_t)(end - choices);
    char* obj = malloc(n + 1); memcpy(obj, choices, n); obj[n] = '\0';
    const char* msg = json_find_key(obj, "message");
    if (!msg || *msg != '{') { free(obj); return el_wrap_str(el_strdup("")); }
    const char* msg_end = json_skip_value(msg);
    size_t mn = (size_t)(msg_end - msg);
    char* msg_obj = malloc(mn + 1); memcpy(msg_obj, msg, mn); msg_obj[mn] = '\0';
    const char* content = json_find_key(msg_obj, "content");
    el_val_t result = el_wrap_str(el_strdup(""));
    if (content && *content == '"') {
        JsonParser jp = { .p = content, .end = content + strlen(content), .err = 0 };
        char* text = jp_parse_string_raw(&jp);
        if (!jp.err && text) result = el_wrap_str(text);
    }
    free(msg_obj); free(obj);
    return result;
}

/* Send a request to one provider. Returns the raw response string.
 * format: 0 = openai, 1 = anthropic */
static el_val_t llm_provider_request(const char* url, const char* key,
                                      int format, const char* model,
                                      const char* system_str,
                                      const char* user_str) {
    char* esc_sys  = system_str && *system_str ? json_escape_alloc(system_str) : NULL;
    char* esc_user = json_escape_alloc(user_str ? user_str : "");
    JsonBuf b; jb_init(&b);
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");

    if (format == 0) { /* OpenAI */
        char full_url[1024];
        if (strstr(url, "/chat/completions") || strstr(url, "/messages")) {
            snprintf(full_url, sizeof(full_url), "%s", url);
        } else {
            snprintf(full_url, sizeof(full_url), "%s/v1/chat/completions", url);
        }
        { size_t n = strlen(key)+24; char* l=malloc(n); snprintf(l,n,"Authorization: Bearer %s",key); h=curl_slist_append(h,l); free(l); }
        jb_putc(&b, '{');
        jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : "neuron");
        jb_puts(&b, ",\"max_tokens\":4096,\"messages\":[");
        if (esc_sys && *esc_sys) { jb_puts(&b,"{\"role\":\"system\",\"content\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\"},"); }
        jb_puts(&b, "{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}");
        el_val_t resp = http_do("POST", full_url, b.buf, h);
        curl_slist_free_all(h); free(b.buf);
        if (esc_sys) free(esc_sys); free(esc_user);
        return llm_extract_text_openai(resp);
    } else { /* Anthropic */
        { size_t n = strlen(key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",key); h=curl_slist_append(h,l); free(l); }
        { size_t n = strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); }
        jb_putc(&b, '{');
        jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : LLM_DEFAULT_MODEL);
        jb_puts(&b, ",\"max_tokens\":4096");
        if (esc_sys && *esc_sys) { jb_puts(&b,",\"system\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\""); }
        jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}");
        el_val_t resp = http_do("POST", url, b.buf, h);
        curl_slist_free_all(h); free(b.buf);
        if (esc_sys) free(esc_sys); free(esc_user);
        return llm_extract_text(resp);
    }
}

static el_val_t llm_chain_call(const char* system_str, const char* user_str) {
    char url_key[64], key_key[64], fmt_key[64], model_key[64];
    for (int i = 0; i < LLM_MAX_PROVIDERS; i++) {
        snprintf(url_key,   sizeof(url_key),   "NEURON_LLM_%d_URL",    i);
        snprintf(key_key,   sizeof(key_key),   "NEURON_LLM_%d_KEY",    i);
        snprintf(fmt_key,   sizeof(fmt_key),   "NEURON_LLM_%d_FORMAT", i);
        snprintf(model_key, sizeof(model_key), "NEURON_LLM_%d_MODEL",  i);
        const char* url   = getenv(url_key);
        const char* key   = getenv(key_key);
        if (!url || !*url || !key || !*key) break; /* end of chain */
        const char* fmt_s = getenv(fmt_key);
        int fmt = (fmt_s && strcmp(fmt_s, "anthropic") == 0) ? 1 : 0;
        const char* model = getenv(model_key);
        fprintf(stderr, "[llm] trying provider %d (%s)\n", i, url);
        el_val_t result = llm_provider_request(url, key, fmt, model, system_str, user_str);
        const char* t = EL_CSTR(result);
        if (t && *t && t[0] != '{') return result; /* success */
        fprintf(stderr, "[llm] provider %d failed or empty, trying next\n", i);
    }
    /* Legacy fallback: ANTHROPIC_API_KEY */
    const char* api_key = getenv("ANTHROPIC_API_KEY");
    if (!api_key || !*api_key) return http_error_json("no LLM providers configured");
    fprintf(stderr, "[llm] using legacy ANTHROPIC_API_KEY fallback\n");
    return llm_provider_request(LLM_API_URL, api_key, 1, NULL, system_str, user_str);
}

/* Legacy llm_request — kept for backward compat with agentic loop internals */
static el_val_t llm_request(const char* json_body) {
    const char* api_key = getenv("ANTHROPIC_API_KEY");
    if (!api_key || !*api_key) return http_error_json("ANTHROPIC_API_KEY not set");
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    { size_t n=strlen(api_key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",api_key); h=curl_slist_append(h,l); free(l); }
    { size_t n=strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); }
    el_val_t resp = http_do("POST", LLM_API_URL, json_body, h);
    curl_slist_free_all(h);
    return resp;
}

/* Extract concatenated assistant text from an Anthropic /v1/messages
 * response. The response shape is:
 *   {"content":[{"type":"text","text":"..."}, ...], ...}
 * If parsing fails, returns the raw response so the caller can inspect.
 */
static el_val_t llm_extract_text(el_val_t resp_val) {
    const char* resp = EL_CSTR(resp_val);
    if (!resp || !*resp) return el_wrap_str(el_strdup(""));
    /* If error JSON, propagate as-is. */
    if (resp[0] == '{' && strstr(resp, "\"error\"")) {
        return el_wrap_str(el_strdup(resp));
    }
    /* Find "content":[ ... ] */
    const char* p = json_find_key(resp, "content");
    if (!p) return el_wrap_str(el_strdup(resp));
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') return el_wrap_str(el_strdup(resp));
    p++;
    JsonBuf out; jb_init(&out);
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        size_t n = (size_t)(end - p);
        char* obj = malloc(n + 1);
        memcpy(obj, p, n); obj[n] = '\0';
        const char* type_p = json_find_key(obj, "type");
        if (type_p && *type_p == '"') {
            JsonParser jp = { .p = type_p, .end = type_p + strlen(type_p), .err = 0 };
            char* type_s = jp_parse_string_raw(&jp);
            if (!jp.err && type_s && strcmp(type_s, "text") == 0) {
                const char* tp = json_find_key(obj, "text");
                if (tp && *tp == '"') {
                    JsonParser jp2 = { .p = tp, .end = tp + strlen(tp), .err = 0 };
                    char* text_s = jp_parse_string_raw(&jp2);
                    if (!jp2.err && text_s) jb_puts(&out, text_s);
                    free(text_s);
                }
            }
            free(type_s);
        }
        free(obj);
        p = end;
    }
    return el_wrap_str(out.buf);
}

el_val_t llm_call(el_val_t model, el_val_t prompt) {
    const char* u = EL_CSTR(prompt); if (!u) u = "";
    return llm_chain_call(NULL, u);
}

el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt) {
    const char* s = EL_CSTR(system_prompt); if (!s) s = "";
    const char* u = EL_CSTR(user_prompt);   if (!u) u = "";
    return llm_chain_call(s, u);
}

/* ── Tool registry for llm_call_agentic ─────────────────────────────────── */

typedef el_val_t (*llm_tool_fn)(el_val_t input);

typedef struct LlmToolEntry {
    char* name;
    llm_tool_fn fn;
} LlmToolEntry;

static LlmToolEntry  _llm_tools[64];
static size_t        _llm_tool_count = 0;
static pthread_mutex_t _llm_tool_mu = PTHREAD_MUTEX_INITIALIZER;

static llm_tool_fn llm_tool_lookup(const char* name) {
    if (!name) return NULL;
    llm_tool_fn fn = NULL;
    pthread_mutex_lock(&_llm_tool_mu);
    for (size_t i = 0; i < _llm_tool_count; i++) {
        if (strcmp(_llm_tools[i].name, name) == 0) { fn = _llm_tools[i].fn; break; }
    }
    pthread_mutex_unlock(&_llm_tool_mu);
    return fn;
}

void llm_register_tool(el_val_t name, el_val_t handler_fn_name) {
    const char* nm  = EL_CSTR(name);
    const char* sym = EL_CSTR(handler_fn_name);
    if (!nm || !*nm || !sym || !*sym) return;
    void* p = dlsym(RTLD_DEFAULT, sym);
    if (!p) {
        fprintf(stderr, "[llm_register_tool] symbol not found: %s\n", sym);
        return;
    }
    pthread_mutex_lock(&_llm_tool_mu);
    /* Replace existing entry by name. */
    for (size_t i = 0; i < _llm_tool_count; i++) {
        if (strcmp(_llm_tools[i].name, nm) == 0) {
            _llm_tools[i].fn = (llm_tool_fn)p;
            pthread_mutex_unlock(&_llm_tool_mu);
            return;
        }
    }
    if (_llm_tool_count < sizeof(_llm_tools) / sizeof(_llm_tools[0])) {
        _llm_tools[_llm_tool_count].name = el_strdup(nm);
        _llm_tools[_llm_tool_count].fn   = (llm_tool_fn)p;
        _llm_tool_count++;
    }
    pthread_mutex_unlock(&_llm_tool_mu);
}

/* Serialize the El `tools` list into the JSON `tools:[...]` field expected
 * by the Anthropic API. Each tool is an ElMap with name/description/
 * input_schema. input_schema is treated as either a JSON-object string
 * (passed through verbatim) or a missing field (substitute {}).         */
static void llm_emit_tools_json(JsonBuf* b, el_val_t tools_list) {
    jb_putc(b, '[');
    ElList* lst = (ElList*)(uintptr_t)tools_list;
    int64_t n = lst ? lst->length : 0;
    for (int64_t i = 0; i < n; i++) {
        if (i > 0) jb_putc(b, ',');
        ElMap* tm = as_map(lst->elems[i]);
        const char* name = "";
        const char* desc = "";
        const char* schema = "{}";
        if (tm) {
            for (int64_t k = 0; k < tm->count; k++) {
                const char* key = EL_CSTR(tm->keys[k]);
                const char* val = EL_CSTR(tm->values[k]);
                if (!key || !val) continue;
                if (strcmp(key, "name") == 0) name = val;
                else if (strcmp(key, "description") == 0) desc = val;
                else if (strcmp(key, "input_schema") == 0) schema = val;
            }
        }
        char* esc_name = json_escape_alloc(name);
        char* esc_desc = json_escape_alloc(desc);
        jb_puts(b, "{\"name\":\"");        jb_puts(b, esc_name);
        jb_puts(b, "\",\"description\":\""); jb_puts(b, esc_desc);
        jb_puts(b, "\",\"input_schema\":");  jb_puts(b, schema && *schema ? schema : "{}");
        jb_putc(b, '}');
        free(esc_name); free(esc_desc);
    }
    jb_putc(b, ']');
}

/* Walk the assistant `content` array and emit each block back into b,
 * preserving the verbatim JSON of every block — used to re-include the
 * assistant turn in the next request.                                 */
static void llm_emit_content_blocks(JsonBuf* b, const char* resp) {
    const char* p = json_find_key(resp, "content");
    jb_putc(b, '[');
    if (!p) { jb_putc(b, ']'); return; }
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') { jb_putc(b, ']'); return; }
    p++;
    int first = 1;
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        if (!first) jb_putc(b, ',');
        first = 0;
        size_t n = (size_t)(end - p);
        jb_reserve(b, n);
        memcpy(b->buf + b->len, p, n);
        b->len += n;
        b->buf[b->len] = '\0';
        p = end;
    }
    jb_putc(b, ']');
}

/* Concatenate all "text" blocks from a response. Returns owned string. */
static char* llm_concat_text_blocks(const char* resp) {
    JsonBuf out; jb_init(&out);
    if (!resp) return out.buf;
    const char* p = json_find_key(resp, "content");
    if (!p) return out.buf;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') return out.buf;
    p++;
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        size_t n = (size_t)(end - p);
        char* obj = malloc(n + 1);
        memcpy(obj, p, n); obj[n] = '\0';
        const char* tp = json_find_key(obj, "type");
        if (tp && *tp == '"') {
            JsonParser jp = { .p = tp, .end = tp + strlen(tp), .err = 0 };
            char* tname = jp_parse_string_raw(&jp);
            if (!jp.err && tname && strcmp(tname, "text") == 0) {
                const char* xp = json_find_key(obj, "text");
                if (xp && *xp == '"') {
                    JsonParser jp2 = { .p = xp, .end = xp + strlen(xp), .err = 0 };
                    char* txt = jp_parse_string_raw(&jp2);
                    if (!jp2.err && txt) jb_puts(&out, txt);
                    free(txt);
                }
            }
            free(tname);
        }
        free(obj);
        p = end;
    }
    return out.buf;
}

/* Build tool_result message blocks for every tool_use in a response.
 * Appends to `b` an array element for each tool_use; caller wraps. */
static int llm_build_tool_results(JsonBuf* b, const char* resp) {
    int any = 0;
    const char* p = json_find_key(resp, "content");
    if (!p) return 0;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') return 0;
    p++;
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        size_t n = (size_t)(end - p);
        char* obj = malloc(n + 1);
        memcpy(obj, p, n); obj[n] = '\0';

        const char* tp = json_find_key(obj, "type");
        char* type_s = NULL;
        if (tp && *tp == '"') {
            JsonParser jp = { .p = tp, .end = tp + strlen(tp), .err = 0 };
            type_s = jp_parse_string_raw(&jp);
        }
        if (type_s && strcmp(type_s, "tool_use") == 0) {
            /* Extract id, name, input. */
            char* id_s = NULL; char* name_s = NULL;
            const char* idp = json_find_key(obj, "id");
            if (idp && *idp == '"') {
                JsonParser jp = { .p = idp, .end = idp + strlen(idp), .err = 0 };
                id_s = jp_parse_string_raw(&jp);
            }
            const char* np = json_find_key(obj, "name");
            if (np && *np == '"') {
                JsonParser jp = { .p = np, .end = np + strlen(np), .err = 0 };
                name_s = jp_parse_string_raw(&jp);
            }
            el_val_t input_raw = json_get_raw(EL_STR(obj), EL_STR("input"));
            const char* input_s = EL_CSTR(input_raw);
            if (!input_s || !*input_s) input_s = "{}";

            llm_tool_fn fn = llm_tool_lookup(name_s ? name_s : "");
            char* result = NULL;
            int is_error = 0;
            if (!fn) {
                size_t en = strlen(name_s ? name_s : "(null)") + 64;
                result = malloc(en);
                snprintf(result, en, "{\"error\":\"tool not registered: %s\"}",
                         name_s ? name_s : "(null)");
                is_error = 1;
            } else {
                el_val_t out = fn(EL_STR(input_s));
                const char* os = EL_CSTR(out);
                result = el_strdup(os ? os : "");
            }

            if (any) jb_putc(b, ',');
            char* esc_id = json_escape_alloc(id_s ? id_s : "");
            char* esc_res = json_escape_alloc(result ? result : "");
            jb_puts(b, "{\"type\":\"tool_result\",\"tool_use_id\":\"");
            jb_puts(b, esc_id);
            jb_puts(b, "\",\"content\":\"");
            jb_puts(b, esc_res);
            jb_puts(b, "\"");
            if (is_error) jb_puts(b, ",\"is_error\":true");
            jb_putc(b, '}');
            free(esc_id); free(esc_res); free(result);
            free(id_s); free(name_s);
            any = 1;
        }
        free(type_s);
        free(obj);
        p = end;
    }
    return any;
}

el_val_t llm_call_agentic(el_val_t model, el_val_t system, el_val_t user, el_val_t tools) {
    /* Empty tools list → degrade to plain system call. */
    ElList* tl = (ElList*)(uintptr_t)tools;
    if (!tl || tl->length == 0) {
        return llm_call_system(model, system, user);
    }
    const char* m = llm_resolve_model(EL_CSTR(model));
    const char* sys_p = EL_CSTR(system); if (!sys_p) sys_p = "";
    const char* usr_p = EL_CSTR(user);   if (!usr_p) usr_p = "";

    /* Build the static parts: tools JSON and system prompt — these don't
     * change across iterations.                                         */
    JsonBuf tools_buf; jb_init(&tools_buf);
    llm_emit_tools_json(&tools_buf, tools);
    char* esc_sys = json_escape_alloc(sys_p);

    /* messages array, accumulated as a mutable JSON fragment (no surrounding
     * brackets — emitted at request time).                                 */
    JsonBuf msgs; jb_init(&msgs);
    /* First user message. */
    char* esc_user = json_escape_alloc(usr_p);
    jb_puts(&msgs, "{\"role\":\"user\",\"content\":\"");
    jb_puts(&msgs, esc_user);
    jb_puts(&msgs, "\"}");
    free(esc_user);

    char* last_text = el_strdup("");
    el_val_t final_out = 0;
    int reached_cap = 1;

    for (int iter = 0; iter < 10; iter++) {
        /* Build request body. */
        JsonBuf body; jb_init(&body);
        jb_putc(&body, '{');
        jb_puts(&body, "\"model\":"); jb_emit_escaped(&body, m);
        jb_puts(&body, ",\"max_tokens\":4096");
        if (*sys_p) {
            jb_puts(&body, ",\"system\":\"");
            jb_puts(&body, esc_sys);
            jb_puts(&body, "\"");
        }
        jb_puts(&body, ",\"tools\":");
        jb_puts(&body, tools_buf.buf);
        jb_puts(&body, ",\"messages\":[");
        jb_puts(&body, msgs.buf);
        jb_puts(&body, "]}");

        el_val_t resp_v = llm_request(body.buf);
        free(body.buf);
        const char* resp = EL_CSTR(resp_v);
        if (!resp || !*resp) {
            final_out = http_error_json("empty response");
            reached_cap = 0;
            break;
        }
        if (resp[0] == '{' && strstr(resp, "\"error\"") &&
            !json_find_key(resp, "content")) {
            final_out = el_wrap_str(el_strdup(resp));
            reached_cap = 0;
            break;
        }

        /* Update last_text from this response. */
        free(last_text);
        last_text = llm_concat_text_blocks(resp);

        /* Inspect stop_reason. */
        el_val_t sr_v = json_get_string(EL_STR(resp), EL_STR("stop_reason"));
        const char* sr = EL_CSTR(sr_v); if (!sr) sr = "";

        if (strcmp(sr, "end_turn") == 0) {
            final_out = el_wrap_str(el_strdup(last_text));
            reached_cap = 0;
            break;
        }
        if (strcmp(sr, "max_tokens") == 0) {
            size_t ln = strlen(last_text) + 16;
            char* out = malloc(ln);
            snprintf(out, ln, "%s\n[truncated]", last_text);
            final_out = el_wrap_str(out);
            reached_cap = 0;
            break;
        }
        if (strcmp(sr, "tool_use") != 0) {
            /* Unexpected stop reason; return the text we have. */
            final_out = el_wrap_str(el_strdup(last_text));
            reached_cap = 0;
            break;
        }

        /* Append the assistant turn (raw content blocks) to messages. */
        JsonBuf ab; jb_init(&ab);
        jb_puts(&ab, ",{\"role\":\"assistant\",\"content\":");
        llm_emit_content_blocks(&ab, resp);
        jb_putc(&ab, '}');
        jb_puts(&msgs, ab.buf);
        free(ab.buf);

        /* Build tool_result message. */
        JsonBuf tr; jb_init(&tr);
        jb_puts(&tr, ",{\"role\":\"user\",\"content\":[");
        int any = llm_build_tool_results(&tr, resp);
        jb_puts(&tr, "]}");
        if (any) {
            jb_puts(&msgs, tr.buf);
        }
        free(tr.buf);
    }

    if (reached_cap) {
        size_t ln = strlen(last_text) + 32;
        char* out = malloc(ln);
        snprintf(out, ln, "[loop_cap_reached]\n%s", last_text);
        final_out = el_wrap_str(out);
    }
    free(last_text);
    free(esc_sys);
    free(tools_buf.buf);
    free(msgs.buf);
    return final_out;
}

/* base64-encode arbitrary bytes (returns owned C string).
 * Internal helper for llm_vision; the public crypto entry point that El
 * programs call is `base64_encode(el_val_t)` defined in the crypto block
 * at the end of this file. */
static char* el_b64_encode_internal(const unsigned char* src, size_t n) {
    static const char tbl[] =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    size_t out_len = 4 * ((n + 2) / 3);
    char* out = malloc(out_len + 1);
    if (!out) return NULL;
    size_t o = 0;
    for (size_t i = 0; i < n;) {
        uint32_t v = 0; int got = 0;
        v |= (uint32_t)src[i++] << 16; got++;
        if (i < n) { v |= (uint32_t)src[i++] << 8;  got++; }
        if (i < n) { v |= (uint32_t)src[i++];        got++; }
        out[o++] = tbl[(v >> 18) & 0x3f];
        out[o++] = tbl[(v >> 12) & 0x3f];
        out[o++] = (got > 1) ? tbl[(v >> 6) & 0x3f] : '=';
        out[o++] = (got > 2) ? tbl[v & 0x3f]        : '=';
    }
    out[o] = '\0';
    return out;
}

el_val_t llm_vision(el_val_t model, el_val_t system, el_val_t prompt, el_val_t image_url_or_b64) {
    const char* m = llm_resolve_model(EL_CSTR(model));
    const char* s = EL_CSTR(system); if (!s) s = "";
    const char* u = EL_CSTR(prompt); if (!u) u = "";
    const char* img = EL_CSTR(image_url_or_b64); if (!img) img = "";

    /* Choose source mode */
    char* image_block = NULL;
    if (strncasecmp(img, "http://", 7) == 0 || strncasecmp(img, "https://", 8) == 0) {
        char* esc_url = json_escape_alloc(img);
        size_t n = strlen(esc_url) + 128;
        image_block = malloc(n);
        snprintf(image_block, n,
                 "{\"type\":\"image\",\"source\":{\"type\":\"url\",\"url\":\"%s\"}}",
                 esc_url);
        free(esc_url);
    } else if (strncmp(img, "data:", 5) == 0) {
        /* Inline data URL: split media-type and base64 */
        const char* semi = strchr(img + 5, ';');
        const char* comma = strchr(img + 5, ',');
        char media[64] = "image/png";
        if (semi && comma && semi < comma) {
            size_t ml = (size_t)(semi - (img + 5));
            if (ml >= sizeof(media)) ml = sizeof(media) - 1;
            memcpy(media, img + 5, ml); media[ml] = '\0';
        }
        const char* b64 = comma ? comma + 1 : "";
        char* esc_media = json_escape_alloc(media);
        char* esc_b64   = json_escape_alloc(b64);
        size_t n = strlen(esc_media) + strlen(esc_b64) + 192;
        image_block = malloc(n);
        snprintf(image_block, n,
                 "{\"type\":\"image\",\"source\":{\"type\":\"base64\","
                 "\"media_type\":\"%s\",\"data\":\"%s\"}}",
                 esc_media, esc_b64);
        free(esc_media); free(esc_b64);
    } else if (*img) {
        /* Treat as file path: read, base64-encode, attach. */
        FILE* f = fopen(img, "rb");
        if (!f) {
            char err[256]; snprintf(err, sizeof(err), "cannot open image: %s", img);
            return http_error_json(err);
        }
        fseek(f, 0, SEEK_END);
        long sz = ftell(f);
        rewind(f);
        if (sz <= 0) { fclose(f); return http_error_json("empty image file"); }
        unsigned char* buf = malloc((size_t)sz);
        if (!buf) { fclose(f); return http_error_json("oom"); }
        size_t got = fread(buf, 1, (size_t)sz, f);
        fclose(f);
        char* b64 = el_b64_encode_internal(buf, got);
        free(buf);
        if (!b64) return http_error_json("base64 encode failed");
        const char* media = "image/png";
        size_t ilen = strlen(img);
        if (ilen >= 4) {
            if (strcasecmp(img + ilen - 4, ".jpg") == 0 ||
                (ilen >= 5 && strcasecmp(img + ilen - 5, ".jpeg") == 0)) media = "image/jpeg";
            else if (strcasecmp(img + ilen - 4, ".gif") == 0) media = "image/gif";
            else if (strcasecmp(img + ilen - 4, ".webp") == 0) media = "image/webp";
        }
        char* esc_b64 = json_escape_alloc(b64); free(b64);
        size_t n = strlen(esc_b64) + 192;
        image_block = malloc(n);
        snprintf(image_block, n,
                 "{\"type\":\"image\",\"source\":{\"type\":\"base64\","
                 "\"media_type\":\"%s\",\"data\":\"%s\"}}",
                 media, esc_b64);
        free(esc_b64);
    }

    char* esc_sys  = json_escape_alloc(s);
    char* esc_user = json_escape_alloc(u);
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '{');
    jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, m);
    jb_puts(&b, ",\"max_tokens\":4096");
    if (*s) {
        jb_puts(&b, ",\"system\":\"");
        jb_puts(&b, esc_sys);
        jb_puts(&b, "\"");
    }
    jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":[");
    if (image_block) {
        jb_puts(&b, image_block);
        jb_putc(&b, ',');
    }
    jb_puts(&b, "{\"type\":\"text\",\"text\":\"");
    jb_puts(&b, esc_user);
    jb_puts(&b, "\"}]}]}");
    free(esc_sys); free(esc_user); free(image_block);
    el_val_t resp = llm_request(b.buf);
    free(b.buf);
    return llm_extract_text(resp);
}

el_val_t llm_models(void) {
    el_val_t lst = el_list_empty();
    lst = el_list_append(lst, el_wrap_str(el_strdup("claude-sonnet-4-5")));
    lst = el_list_append(lst, el_wrap_str(el_strdup("claude-opus-4-7")));
    lst = el_list_append(lst, el_wrap_str(el_strdup("claude-haiku-4-5")));
    return lst;
}

/* ── Native VM builtin aliases ──────────────────────────────────────────────
 * El source files use native_* names (El VM builtins).
 * When compiled to C, these map directly to el_* runtime functions. */

el_val_t native_list_get(el_val_t list, el_val_t index) {
    return el_list_get(list, index);
}

el_val_t native_list_len(el_val_t list) {
    return el_list_len(list);
}

el_val_t native_list_append(el_val_t list, el_val_t elem) {
    return el_list_append(list, elem);
}

el_val_t native_list_empty(void) {
    return el_list_empty();
}

el_val_t native_list_clone(el_val_t list) {
    return el_list_clone(list);
}

el_val_t native_string_chars(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    el_val_t result = el_list_empty();
    if (!s) return result;
    while (*s) {
        char buf[2];
        buf[0] = *s;
        buf[1] = '\0';
        result = el_list_append(result, EL_STR(strdup(buf)));
        s++;
    }
    return result;
}

el_val_t native_int_to_str(el_val_t n) {
    return int_to_str(n);
}

/* ── Method-call shorthand aliases ──────────────────────────────────────────
 * Short names that result from the method-call convention:
 *   myList.append(x) → append(myList, x)
 *   myList.len()     → len(myList)
 *   myList.get(i)    → get(myList, i)
 *   myMap.map_get(k) → map_get(myMap, k)
 *   myMap.map_set(k,v) → map_set(myMap, k, v)                              */

el_val_t append(el_val_t list, el_val_t elem)  { return el_list_append(list, elem); }
el_val_t len(el_val_t list)                    { return el_list_len(list); }
el_val_t get(el_val_t list, el_val_t index)    { return el_list_get(list, index); }
el_val_t map_get(el_val_t map, el_val_t key)   { return el_map_get(map, key); }
el_val_t map_set(el_val_t map, el_val_t key, el_val_t value) { return el_map_set(map, key, value); }

/* ── Crypto primitives ──────────────────────────────────────────────────────
 *
 * SHA-256 implementation adapted from Brad Conte's public-domain reference
 * (https://github.com/B-Con/crypto-algorithms/blob/master/sha256.c, public
 * domain per the project's LICENSE). HMAC follows RFC 2104. Base64 encoding
 * follows RFC 4648; the URL-safe variant uses the alphabet from §5 of the
 * RFC and omits padding (per JWT/JWS convention).
 *
 * Self-contained: no OpenSSL/libcrypto dependency. The runtime keeps its
 * existing `-lcurl -lpthread -ldl -lm` link line.
 *
 * Binary outputs (sha256_bytes, hmac_sha256_bytes) tag their buffer with a
 * magic header so base64_encode/base64url_encode can recover the exact byte
 * length even when the payload contains embedded NULs. Plain C strings
 * (without the header) fall back to strlen(), preserving the existing API
 * shape for normal text inputs.                                              */

/* Magic-header for length-tagged binary buffers. Layout:
 *   [ uint32_t magic = EL_MAGIC_BIN ][ uint32_t length ][ data... ][ \0 ]
 * The returned el_val_t points at `data`, so consumers that strlen() it still
 * get a sensible (though possibly truncated) view. el_bin_len() recovers the
 * true length by sniffing the 8 bytes preceding the pointer.
 *
 * Magic value chosen with high MSB so it cannot collide with printable ASCII
 * (the same discriminator pattern used by EL_MAGIC_LIST / EL_MAGIC_MAP). */
#define EL_MAGIC_BIN 0xE1B17EAFu

typedef struct {
    uint32_t magic;
    uint32_t length;
} el_bin_hdr_t;

/* Allocate a length-tagged binary buffer; returns pointer to the data area. */
static unsigned char* el_bin_alloc(size_t len) {
    el_bin_hdr_t* hdr = (el_bin_hdr_t*)malloc(sizeof(el_bin_hdr_t) + len + 1);
    if (!hdr) { fputs("el_runtime: out of memory (bin)\n", stderr); exit(1); }
    hdr->magic = EL_MAGIC_BIN;
    hdr->length = (uint32_t)len;
    unsigned char* data = (unsigned char*)(hdr + 1);
    data[len] = '\0';   /* keep NUL-terminated for accidental strlen calls */
    return data;
}

/* Recover length from a possibly-tagged buffer. Returns 1 if tagged. */
static int el_bin_lookup(const void* p, size_t* out_len) {
    if (!p) { *out_len = 0; return 0; }
    /* Avoid reading off the front of a page on tiny pointers (e.g. NULs
     * passed in as int-cast values). 4096 is a safe lower bound on any
     * platform we target.                                              */
    if ((uintptr_t)p < 4096) return 0;
    const el_bin_hdr_t* hdr = (const el_bin_hdr_t*)((const char*)p - sizeof(el_bin_hdr_t));
    if (hdr->magic != EL_MAGIC_BIN) return 0;
    *out_len = hdr->length;
    return 1;
}

/* Effective input length: tagged length if present, else strlen. */
static size_t el_input_len(const char* s) {
    size_t n;
    if (el_bin_lookup(s, &n)) return n;
    return s ? strlen(s) : 0;
}

/* ─── SHA-256 (Brad Conte / public domain) ──────────────────────────────── */

typedef struct {
    unsigned char data[64];
    uint32_t      datalen;
    uint64_t      bitlen;
    uint32_t      state[8];
} el_sha256_ctx_t;

static const uint32_t el_sha256_k[64] = {
    0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
};

#define EL_ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
#define EL_CH(x,y,z)  (((x) & (y)) ^ (~(x) & (z)))
#define EL_MAJ(x,y,z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define EL_EP0(x)     (EL_ROTR(x,2)  ^ EL_ROTR(x,13) ^ EL_ROTR(x,22))
#define EL_EP1(x)     (EL_ROTR(x,6)  ^ EL_ROTR(x,11) ^ EL_ROTR(x,25))
#define EL_SIG0(x)    (EL_ROTR(x,7)  ^ EL_ROTR(x,18) ^ ((x) >> 3))
#define EL_SIG1(x)    (EL_ROTR(x,17) ^ EL_ROTR(x,19) ^ ((x) >> 10))

static void el_sha256_transform(el_sha256_ctx_t* ctx, const unsigned char* data) {
    uint32_t a, b, c, d, e, f, g, h, t1, t2, m[64];
    int i, j;
    for (i = 0, j = 0; i < 16; ++i, j += 4) {
        m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16)
             | ((uint32_t)data[j + 2] << 8) | (uint32_t)data[j + 3];
    }
    for (; i < 64; ++i) {
        m[i] = EL_SIG1(m[i-2]) + m[i-7] + EL_SIG0(m[i-15]) + m[i-16];
    }
    a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3];
    e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7];
    for (i = 0; i < 64; ++i) {
        t1 = h + EL_EP1(e) + EL_CH(e,f,g) + el_sha256_k[i] + m[i];
        t2 = EL_EP0(a) + EL_MAJ(a,b,c);
        h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
    }
    ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d;
    ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h;
}

static void el_sha256_init(el_sha256_ctx_t* ctx) {
    ctx->datalen = 0;
    ctx->bitlen = 0;
    ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85;
    ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a;
    ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c;
    ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19;
}

static void el_sha256_update(el_sha256_ctx_t* ctx, const unsigned char* data, size_t len) {
    for (size_t i = 0; i < len; ++i) {
        ctx->data[ctx->datalen++] = data[i];
        if (ctx->datalen == 64) {
            el_sha256_transform(ctx, ctx->data);
            ctx->bitlen += 512;
            ctx->datalen = 0;
        }
    }
}

static void el_sha256_final(el_sha256_ctx_t* ctx, unsigned char hash[32]) {
    uint32_t i = ctx->datalen;
    if (ctx->datalen < 56) {
        ctx->data[i++] = 0x80;
        while (i < 56) ctx->data[i++] = 0x00;
    } else {
        ctx->data[i++] = 0x80;
        while (i < 64) ctx->data[i++] = 0x00;
        el_sha256_transform(ctx, ctx->data);
        memset(ctx->data, 0, 56);
    }
    ctx->bitlen += (uint64_t)ctx->datalen * 8;
    ctx->data[63] = (unsigned char)( ctx->bitlen        & 0xff);
    ctx->data[62] = (unsigned char)((ctx->bitlen >> 8)  & 0xff);
    ctx->data[61] = (unsigned char)((ctx->bitlen >> 16) & 0xff);
    ctx->data[60] = (unsigned char)((ctx->bitlen >> 24) & 0xff);
    ctx->data[59] = (unsigned char)((ctx->bitlen >> 32) & 0xff);
    ctx->data[58] = (unsigned char)((ctx->bitlen >> 40) & 0xff);
    ctx->data[57] = (unsigned char)((ctx->bitlen >> 48) & 0xff);
    ctx->data[56] = (unsigned char)((ctx->bitlen >> 56) & 0xff);
    el_sha256_transform(ctx, ctx->data);
    for (i = 0; i < 4; ++i) {
        hash[i]      = (ctx->state[0] >> (24 - i * 8)) & 0xff;
        hash[i +  4] = (ctx->state[1] >> (24 - i * 8)) & 0xff;
        hash[i +  8] = (ctx->state[2] >> (24 - i * 8)) & 0xff;
        hash[i + 12] = (ctx->state[3] >> (24 - i * 8)) & 0xff;
        hash[i + 16] = (ctx->state[4] >> (24 - i * 8)) & 0xff;
        hash[i + 20] = (ctx->state[5] >> (24 - i * 8)) & 0xff;
        hash[i + 24] = (ctx->state[6] >> (24 - i * 8)) & 0xff;
        hash[i + 28] = (ctx->state[7] >> (24 - i * 8)) & 0xff;
    }
}

static void el_sha256_oneshot(const unsigned char* data, size_t len, unsigned char out[32]) {
    el_sha256_ctx_t c;
    el_sha256_init(&c);
    el_sha256_update(&c, data, len);
    el_sha256_final(&c, out);
}

/* ─── HMAC-SHA-256 (RFC 2104) ───────────────────────────────────────────── */

static void el_hmac_sha256(const unsigned char* key, size_t key_len,
                           const unsigned char* msg, size_t msg_len,
                           unsigned char out[32]) {
    unsigned char k[64];
    unsigned char k_ipad[64];
    unsigned char k_opad[64];
    unsigned char inner[32];

    if (key_len > 64) {
        el_sha256_oneshot(key, key_len, k);
        memset(k + 32, 0, 32);
    } else {
        memcpy(k, key, key_len);
        memset(k + key_len, 0, 64 - key_len);
    }
    for (int i = 0; i < 64; ++i) {
        k_ipad[i] = k[i] ^ 0x36;
        k_opad[i] = k[i] ^ 0x5c;
    }
    {
        el_sha256_ctx_t c;
        el_sha256_init(&c);
        el_sha256_update(&c, k_ipad, 64);
        el_sha256_update(&c, msg, msg_len);
        el_sha256_final(&c, inner);
    }
    {
        el_sha256_ctx_t c;
        el_sha256_init(&c);
        el_sha256_update(&c, k_opad, 64);
        el_sha256_update(&c, inner, 32);
        el_sha256_final(&c, out);
    }
}

/* ─── Hex helper ────────────────────────────────────────────────────────── */

static el_val_t el_hex_encode(const unsigned char* data, size_t len) {
    static const char digits[] = "0123456789abcdef";
    char* out = el_strbuf(len * 2);
    for (size_t i = 0; i < len; ++i) {
        out[i * 2]     = digits[(data[i] >> 4) & 0xf];
        out[i * 2 + 1] = digits[ data[i]       & 0xf];
    }
    out[len * 2] = '\0';
    return el_wrap_str(out);
}

/* ─── Base64 (RFC 4648) ─────────────────────────────────────────────────── */

static const char el_b64_std_alphabet[64] =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static const char el_b64_url_alphabet[64] =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

el_val_t el_base64_encode_n(const unsigned char* data, size_t len, int url_safe) {
    const char* alphabet = url_safe ? el_b64_url_alphabet : el_b64_std_alphabet;
    /* Standard form is padded to multiple of 4; URL-safe omits padding. */
    size_t out_cap = ((len + 2) / 3) * 4 + 1;
    char* out = el_strbuf(out_cap);
    size_t i = 0, j = 0;
    while (i + 3 <= len) {
        uint32_t v = ((uint32_t)data[i] << 16) | ((uint32_t)data[i+1] << 8) | (uint32_t)data[i+2];
        out[j++] = alphabet[(v >> 18) & 0x3f];
        out[j++] = alphabet[(v >> 12) & 0x3f];
        out[j++] = alphabet[(v >>  6) & 0x3f];
        out[j++] = alphabet[ v        & 0x3f];
        i += 3;
    }
    size_t rem = len - i;
    if (rem == 1) {
        uint32_t v = (uint32_t)data[i] << 16;
        out[j++] = alphabet[(v >> 18) & 0x3f];
        out[j++] = alphabet[(v >> 12) & 0x3f];
        if (!url_safe) { out[j++] = '='; out[j++] = '='; }
    } else if (rem == 2) {
        uint32_t v = ((uint32_t)data[i] << 16) | ((uint32_t)data[i+1] << 8);
        out[j++] = alphabet[(v >> 18) & 0x3f];
        out[j++] = alphabet[(v >> 12) & 0x3f];
        out[j++] = alphabet[(v >>  6) & 0x3f];
        if (!url_safe) { out[j++] = '='; }
    }
    out[j] = '\0';
    return el_wrap_str(out);
}

/* Decode either alphabet — accepts both '+/' and '-_' transparently, and
 * tolerates missing padding (which JWTs typically omit). Whitespace is
 * skipped for robustness. Invalid characters cause the decode to stop and
 * the partial result so far is returned. */
static el_val_t el_base64_decode_any(const char* in) {
    if (!in) {
        unsigned char* empty = el_bin_alloc(0);
        return EL_STR((char*)empty);
    }
    size_t in_len = strlen(in);
    /* Worst case: 3 output bytes per 4 input chars, +1 NUL slack. */
    unsigned char* out = el_bin_alloc(((in_len + 3) / 4) * 3 + 1);

    int8_t lut[256];
    for (int i = 0; i < 256; ++i) lut[i] = -1;
    for (int i = 0; i < 64; ++i) lut[(unsigned char)el_b64_std_alphabet[i]] = (int8_t)i;
    /* Allow URL-safe characters too (so one decoder handles both forms). */
    lut[(unsigned char)'-'] = 62;
    lut[(unsigned char)'_'] = 63;

    uint32_t buf  = 0;
    int      bits = 0;
    size_t   o    = 0;
    for (size_t i = 0; i < in_len; ++i) {
        unsigned char c = (unsigned char)in[i];
        if (c == '=' || c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
        int8_t v = lut[c];
        if (v < 0) break;   /* invalid char — stop */
        buf = (buf << 6) | (uint32_t)v;
        bits += 6;
        if (bits >= 8) {
            bits -= 8;
            out[o++] = (unsigned char)((buf >> bits) & 0xff);
        }
    }
    /* Patch the length header to the actual decoded length. */
    el_bin_hdr_t* hdr = (el_bin_hdr_t*)((char*)out - sizeof(el_bin_hdr_t));
    hdr->length = (uint32_t)o;
    out[o] = '\0';
    return EL_STR((char*)out);
}

/* ─── Public crypto entry points ────────────────────────────────────────── */

el_val_t el_sha256_bytes_n(const unsigned char* data, size_t len) {
    unsigned char* out = el_bin_alloc(32);
    el_sha256_oneshot(data, len, out);
    return EL_STR((char*)out);
}

el_val_t sha256_hex(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    unsigned char digest[32];
    el_sha256_oneshot((const unsigned char*)(s ? s : ""), n, digest);
    return el_hex_encode(digest, 32);
}

el_val_t sha256_bytes(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    return el_sha256_bytes_n((const unsigned char*)(s ? s : ""), n);
}

el_val_t hmac_sha256_hex(el_val_t key, el_val_t message) {
    const char* k = EL_CSTR(key);
    const char* m = EL_CSTR(message);
    size_t kn = el_input_len(k);
    size_t mn = el_input_len(m);
    unsigned char mac[32];
    el_hmac_sha256((const unsigned char*)(k ? k : ""), kn,
                   (const unsigned char*)(m ? m : ""), mn,
                   mac);
    return el_hex_encode(mac, 32);
}

el_val_t hmac_sha256_bytes(el_val_t key, el_val_t message) {
    const char* k = EL_CSTR(key);
    const char* m = EL_CSTR(message);
    size_t kn = el_input_len(k);
    size_t mn = el_input_len(m);
    unsigned char* out = el_bin_alloc(32);
    el_hmac_sha256((const unsigned char*)(k ? k : ""), kn,
                   (const unsigned char*)(m ? m : ""), mn,
                   out);
    return EL_STR((char*)out);
}

el_val_t base64_encode(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    return el_base64_encode_n((const unsigned char*)(s ? s : ""), n, /*url_safe=*/0);
}

el_val_t base64url_encode(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    return el_base64_encode_n((const unsigned char*)(s ? s : ""), n, /*url_safe=*/1);
}

el_val_t base64_decode(el_val_t input) {
    return el_base64_decode_any(EL_CSTR(input));
}

el_val_t base64url_decode(el_val_t input) {
    return el_base64_decode_any(EL_CSTR(input));
}

/* ── Post-quantum cryptography (liboqs + OpenSSL) ───────────────────────────
 *
 * Algorithm choices (per CNSA 2.0 / NIST PQ guidance, as of 2024):
 *   Signatures: CRYSTALS-Dilithium-3   (NIST security level 3, balanced)
 *   KEM:        CRYSTALS-Kyber-768     (NIST security level 3)
 *   Hash:       SHA3-256 (Keccak)      (PQ-aware protocols favour SHA3 over SHA2)
 *   Hybrid:     X25519 || Kyber-768, combined via HKDF-SHA256
 *
 * Why hybrid: Kyber is new. X25519 has 20+ years of analysis. Hybridizing
 * preserves classical security if Kyber falls to a future cryptanalytic
 * advance, and preserves PQ security if X25519 falls to a quantum adversary.
 * "Recordable now, decryptable later" already threatens long-lived classical
 * key exchange — the only safe move for keys protecting durable doctrine
 * (CGI lineage, KindredGrants, Principal-CGI covenants) is to encapsulate
 * with PQ today, even if the classical leg is what the wire shows.
 *
 * Compile-time detection: when <oqs/oqs.h> is unavailable the pq_* functions
 * compile to stubs that return a JSON error envelope. SHA3-256 stays
 * available regardless (it's implemented inline, no liboqs dep). This lets
 * the runtime build cleanly on dev machines without liboqs while production
 * gets the full PQ stack.                                                    */

/* ─── SHA3-256 (Keccak, FIPS 202) ────────────────────────────────────────────
 * Inline reference implementation. ~120 LoC, no external dependency.
 * rate=1088 bits, capacity=512 bits, output=256 bits, padding=0x06.          */

static const uint64_t el_keccak_rc[24] = {
    0x0000000000000001ULL, 0x0000000000008082ULL, 0x800000000000808aULL,
    0x8000000080008000ULL, 0x000000000000808bULL, 0x0000000080000001ULL,
    0x8000000080008081ULL, 0x8000000000008009ULL, 0x000000000000008aULL,
    0x0000000000000088ULL, 0x0000000080008009ULL, 0x000000008000000aULL,
    0x000000008000808bULL, 0x800000000000008bULL, 0x8000000000008089ULL,
    0x8000000000008003ULL, 0x8000000000008002ULL, 0x8000000000000080ULL,
    0x000000000000800aULL, 0x800000008000000aULL, 0x8000000080008081ULL,
    0x8000000000008080ULL, 0x0000000080000001ULL, 0x8000000080008008ULL
};

static const unsigned el_keccak_rho[24] = {
     1,  3,  6, 10, 15, 21, 28, 36, 45, 55,  2, 14,
    27, 41, 56,  8, 25, 43, 62, 18, 39, 61, 20, 44
};

static const unsigned el_keccak_pi[24] = {
    10,  7, 11, 17, 18,  3,  5, 16,  8, 21, 24,  4,
    15, 23, 19, 13, 12,  2, 20, 14, 22,  9,  6,  1
};

#define EL_ROTL64(x, n) (((x) << (n)) | ((x) >> (64 - (n))))

static void el_keccak_f1600(uint64_t s[25]) {
    for (int round = 0; round < 24; ++round) {
        uint64_t bc[5], t;
        for (int i = 0; i < 5; ++i)
            bc[i] = s[i] ^ s[i+5] ^ s[i+10] ^ s[i+15] ^ s[i+20];
        for (int i = 0; i < 5; ++i) {
            t = bc[(i+4) % 5] ^ EL_ROTL64(bc[(i+1) % 5], 1);
            for (int j = 0; j < 25; j += 5) s[j+i] ^= t;
        }
        t = s[1];
        for (int i = 0; i < 24; ++i) {
            int j = el_keccak_pi[i];
            bc[0] = s[j];
            s[j]  = EL_ROTL64(t, el_keccak_rho[i]);
            t = bc[0];
        }
        for (int j = 0; j < 25; j += 5) {
            for (int i = 0; i < 5; ++i) bc[i] = s[j+i];
            for (int i = 0; i < 5; ++i)
                s[j+i] = bc[i] ^ ((~bc[(i+1) % 5]) & bc[(i+2) % 5]);
        }
        s[0] ^= el_keccak_rc[round];
    }
}

static void el_sha3_256_oneshot(const unsigned char* data, size_t len,
                                unsigned char out[32]) {
    uint64_t st[25] = {0};
    unsigned char* sb = (unsigned char*)st;
    const size_t rate = 136;   /* 1088 bits / 8 */
    size_t i = 0;
    while (len - i >= rate) {
        for (size_t k = 0; k < rate; ++k) sb[k] ^= data[i + k];
        el_keccak_f1600(st);
        i += rate;
    }
    size_t rem = len - i;
    for (size_t k = 0; k < rem; ++k) sb[k] ^= data[i + k];
    sb[rem]      ^= 0x06;   /* SHA3 domain-separation byte */
    sb[rate - 1] ^= 0x80;   /* final-block padding bit (high bit of last byte) */
    el_keccak_f1600(st);
    memcpy(out, sb, 32);
}

el_val_t sha3_256_hex(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    unsigned char digest[32];
    el_sha3_256_oneshot((const unsigned char*)(s ? s : ""), n, digest);
    return el_hex_encode(digest, 32);
}

/* ─── Hex decode helper ─────────────────────────────────────────────────────
 * Returns a length-tagged binary buffer (so embedded NULs survive); on
 * odd-length / invalid input returns NULL with *out_len = 0. Caller is
 * responsible for emitting the error envelope.                               */

static int el_hex_nibble(char c) {
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
    return -1;
}

__attribute__((unused))
static unsigned char* el_hex_decode(const char* s, size_t* out_len) {
    *out_len = 0;
    if (!s) return NULL;
    size_t n = strlen(s);
    if (n & 1) return NULL;
    size_t blen = n / 2;
    unsigned char* out = el_bin_alloc(blen);
    for (size_t i = 0; i < blen; ++i) {
        int hi = el_hex_nibble(s[i*2]);
        int lo = el_hex_nibble(s[i*2 + 1]);
        if (hi < 0 || lo < 0) return NULL;
        out[i] = (unsigned char)((hi << 4) | lo);
    }
    *out_len = blen;
    return out;
}

/* JSON error envelope reused across all PQ entry points. */
static el_val_t pq_error(const char* msg) {
    return http_error_json(msg);
}

#if __has_include(<oqs/oqs.h>)
#include <oqs/oqs.h>
#define EL_HAVE_LIBOQS 1
#else
#define EL_HAVE_LIBOQS 0
#endif

#if EL_HAVE_LIBOQS && __has_include(<openssl/evp.h>)
#include <openssl/evp.h>
#define EL_HAVE_OPENSSL 1
#else
#define EL_HAVE_OPENSSL 0
#endif

#if !EL_HAVE_LIBOQS

/* ─── Stubs (liboqs unavailable) ───────────────────────────────────────────
 * Each entry point returns the same JSON error so callers can inspect a
 * single canonical "missing primitive" string. pq_verify is the lone
 * exception — verifying without liboqs simply means "not verified", so
 * returning Bool false (0) keeps the type contract intact.                  */

#define EL_PQ_NO_LIB "liboqs not linked, post-quantum primitives unavailable"

el_val_t pq_keygen_signature(void)                            { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_sign(el_val_t sk, el_val_t msg)                   { (void)sk; (void)msg; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_verify(el_val_t pk, el_val_t msg, el_val_t sig)   { (void)pk; (void)msg; (void)sig; return EL_INT(0); }
el_val_t pq_kem_keygen(void)                                  { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_kem_encaps(el_val_t pk)                           { (void)pk; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_kem_decaps(el_val_t sk, el_val_t ct)              { (void)sk; (void)ct; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_hybrid_keygen(void)                               { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_hybrid_handshake(el_val_t pub)                    { (void)pub; return pq_error(EL_PQ_NO_LIB); }

#else  /* EL_HAVE_LIBOQS */

/* ─── Dilithium-3 / ML-DSA-65 signatures ────────────────────────────────
 *
 * NIST FIPS 204 standardized CRYSTALS-Dilithium as ML-DSA. ML-DSA-65 is the
 * FIPS form of what we historically called Dilithium-3 — same algorithm
 * family, same security level, identical key/sig sizes, but with a couple
 * of standardization-driven tweaks (e.g. domain separation in the message
 * binding). liboqs 0.12+ exposes both names; 0.15+ retired the legacy
 * "Dilithium" constants in favour of "ML-DSA". We prefer ML-DSA-65 if the
 * header advertises it, fall back to Dilithium-3 otherwise. Anything
 * already signed with the older constant remains verifiable against that
 * same constant — callers should pin the algorithm via the OQS_SIG handle's
 * method_name field if they need to interoperate with archival signatures. */

#if defined(OQS_SIG_alg_ml_dsa_65)
#  define EL_DILITHIUM_ALG OQS_SIG_alg_ml_dsa_65
#elif defined(OQS_SIG_alg_dilithium_3)
#  define EL_DILITHIUM_ALG OQS_SIG_alg_dilithium_3
#else
#  define EL_DILITHIUM_ALG "ML-DSA-65"   /* string fallback; runtime probe catches misconfig */
#endif

el_val_t pq_keygen_signature(void) {
    OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
    if (!sig) return pq_error("OQS_SIG_new(dilithium-3) failed");
    unsigned char* pk = (unsigned char*)malloc(sig->length_public_key);
    unsigned char* sk = (unsigned char*)malloc(sig->length_secret_key);
    if (!pk || !sk) { free(pk); free(sk); OQS_SIG_free(sig); return pq_error("oom"); }
    if (OQS_SIG_keypair(sig, pk, sk) != OQS_SUCCESS) {
        free(pk); free(sk); OQS_SIG_free(sig);
        return pq_error("dilithium-3 keypair generation failed");
    }
    el_val_t pk_hex = el_hex_encode(pk, sig->length_public_key);
    el_val_t sk_hex = el_hex_encode(sk, sig->length_secret_key);
    OQS_MEM_secure_free(sk, sig->length_secret_key);
    free(pk);

    const char* pks = EL_CSTR(pk_hex);
    const char* sks = EL_CSTR(sk_hex);
    char* buf = el_strbuf(strlen(pks) + strlen(sks) + 64);
    sprintf(buf, "{\"public_key\":\"%s\",\"secret_key\":\"%s\"}", pks, sks);
    OQS_SIG_free(sig);
    return el_wrap_str(buf);
}

el_val_t pq_sign(el_val_t secret_key_hex, el_val_t message) {
    size_t sk_len = 0;
    unsigned char* sk = el_hex_decode(EL_CSTR(secret_key_hex), &sk_len);
    if (!sk) return pq_error("invalid hex in secret_key");

    OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
    if (!sig) return pq_error("OQS_SIG_new(dilithium-3) failed");
    if (sk_len != sig->length_secret_key) {
        OQS_SIG_free(sig);
        return pq_error("secret_key length mismatch for dilithium-3");
    }

    const char* msg = EL_CSTR(message);
    size_t msg_len = el_input_len(msg);
    unsigned char* signature = (unsigned char*)malloc(sig->length_signature);
    size_t signature_len = sig->length_signature;
    if (!signature) { OQS_SIG_free(sig); return pq_error("oom"); }

    if (OQS_SIG_sign(sig, signature, &signature_len,
                     (const unsigned char*)(msg ? msg : ""), msg_len, sk) != OQS_SUCCESS) {
        free(signature); OQS_SIG_free(sig);
        return pq_error("dilithium-3 sign failed");
    }
    el_val_t sig_hex = el_hex_encode(signature, signature_len);
    free(signature); OQS_SIG_free(sig);
    return sig_hex;
}

el_val_t pq_verify(el_val_t public_key_hex, el_val_t message, el_val_t signature_hex) {
    size_t pk_len = 0, sig_len = 0;
    unsigned char* pk = el_hex_decode(EL_CSTR(public_key_hex), &pk_len);
    unsigned char* signature = el_hex_decode(EL_CSTR(signature_hex), &sig_len);
    if (!pk || !signature) return EL_INT(0);

    OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
    if (!sig) return EL_INT(0);
    if (pk_len != sig->length_public_key) { OQS_SIG_free(sig); return EL_INT(0); }

    const char* msg = EL_CSTR(message);
    size_t msg_len = el_input_len(msg);
    OQS_STATUS rc = OQS_SIG_verify(sig,
                                   (const unsigned char*)(msg ? msg : ""), msg_len,
                                   signature, sig_len, pk);
    OQS_SIG_free(sig);
    return (rc == OQS_SUCCESS) ? EL_INT(1) : EL_INT(0);
}

/* ─── Kyber-768 / ML-KEM-768 KEM ────────────────────────────────────────
 *
 * NIST FIPS 203 standardized CRYSTALS-Kyber as ML-KEM. ML-KEM-768 is the
 * FIPS form of what we historically called Kyber-768. Same situation as
 * Dilithium → ML-DSA: prefer the standardized constant, fall back to the
 * legacy name. liboqs 0.15.0 still exposes OQS_KEM_alg_kyber_768; the
 * algorithm is identical at the wire level to ML-KEM-768 except for FIPS
 * domain-separation tweaks, so the two ciphertexts/keys are NOT
 * cross-compatible. Pin the constant for archival material. */

#if defined(OQS_KEM_alg_ml_kem_768)
#  define EL_KYBER_ALG OQS_KEM_alg_ml_kem_768
#elif defined(OQS_KEM_alg_kyber_768)
#  define EL_KYBER_ALG OQS_KEM_alg_kyber_768
#else
#  define EL_KYBER_ALG "ML-KEM-768"
#endif

el_val_t pq_kem_keygen(void) {
    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    unsigned char* pk = (unsigned char*)malloc(kem->length_public_key);
    unsigned char* sk = (unsigned char*)malloc(kem->length_secret_key);
    if (!pk || !sk) { free(pk); free(sk); OQS_KEM_free(kem); return pq_error("oom"); }
    if (OQS_KEM_keypair(kem, pk, sk) != OQS_SUCCESS) {
        free(pk); free(sk); OQS_KEM_free(kem);
        return pq_error("kyber-768 keypair generation failed");
    }
    el_val_t pk_hex = el_hex_encode(pk, kem->length_public_key);
    el_val_t sk_hex = el_hex_encode(sk, kem->length_secret_key);
    OQS_MEM_secure_free(sk, kem->length_secret_key);
    free(pk);

    const char* pks = EL_CSTR(pk_hex);
    const char* sks = EL_CSTR(sk_hex);
    char* buf = el_strbuf(strlen(pks) + strlen(sks) + 64);
    sprintf(buf, "{\"public_key\":\"%s\",\"secret_key\":\"%s\"}", pks, sks);
    OQS_KEM_free(kem);
    return el_wrap_str(buf);
}

el_val_t pq_kem_encaps(el_val_t public_key_hex) {
    size_t pk_len = 0;
    unsigned char* pk = el_hex_decode(EL_CSTR(public_key_hex), &pk_len);
    if (!pk) return pq_error("invalid hex in public_key");

    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    if (pk_len != kem->length_public_key) {
        OQS_KEM_free(kem);
        return pq_error("public_key length mismatch for kyber-768");
    }
    unsigned char* ct = (unsigned char*)malloc(kem->length_ciphertext);
    unsigned char* ss = (unsigned char*)malloc(kem->length_shared_secret);
    if (!ct || !ss) { free(ct); free(ss); OQS_KEM_free(kem); return pq_error("oom"); }
    if (OQS_KEM_encaps(kem, ct, ss, pk) != OQS_SUCCESS) {
        free(ct); free(ss); OQS_KEM_free(kem);
        return pq_error("kyber-768 encapsulation failed");
    }
    el_val_t ct_hex = el_hex_encode(ct, kem->length_ciphertext);
    el_val_t ss_hex = el_hex_encode(ss, kem->length_shared_secret);
    free(ct);
    OQS_MEM_secure_free(ss, kem->length_shared_secret);

    const char* cts = EL_CSTR(ct_hex);
    const char* sss = EL_CSTR(ss_hex);
    char* buf = el_strbuf(strlen(cts) + strlen(sss) + 64);
    sprintf(buf, "{\"ciphertext\":\"%s\",\"shared_secret\":\"%s\"}", cts, sss);
    OQS_KEM_free(kem);
    return el_wrap_str(buf);
}

el_val_t pq_kem_decaps(el_val_t secret_key_hex, el_val_t ciphertext_hex) {
    size_t sk_len = 0, ct_len = 0;
    unsigned char* sk = el_hex_decode(EL_CSTR(secret_key_hex), &sk_len);
    unsigned char* ct = el_hex_decode(EL_CSTR(ciphertext_hex), &ct_len);
    if (!sk || !ct) return pq_error("invalid hex in inputs");

    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    if (sk_len != kem->length_secret_key || ct_len != kem->length_ciphertext) {
        OQS_KEM_free(kem);
        return pq_error("input length mismatch for kyber-768");
    }
    unsigned char* ss = (unsigned char*)malloc(kem->length_shared_secret);
    if (!ss) { OQS_KEM_free(kem); return pq_error("oom"); }
    /* Kyber is IND-CCA via Fujisaki-Okamoto: decaps always returns *some*
     * shared_secret even on tampered ciphertext (an implicit-rejection value
     * derived from sk). Protocols MUST confirm the shared_secret matches via
     * a subsequent step (e.g. AEAD tag, key-confirmation MAC) — do not
     * assume decaps success implies authenticity. */
    if (OQS_KEM_decaps(kem, ss, ct, sk) != OQS_SUCCESS) {
        free(ss); OQS_KEM_free(kem);
        return pq_error("kyber-768 decapsulation failed");
    }
    el_val_t ss_hex = el_hex_encode(ss, kem->length_shared_secret);
    OQS_MEM_secure_free(ss, kem->length_shared_secret);
    OQS_KEM_free(kem);
    return ss_hex;
}

/* ─── Hybrid handshake (X25519 + Kyber-768, HKDF-SHA256 combined) ─────── */

#if !EL_HAVE_OPENSSL

el_val_t pq_hybrid_keygen(void) {
    return pq_error("hybrid handshake requires OpenSSL (X25519); rebuild with -lcrypto");
}
el_val_t pq_hybrid_handshake(el_val_t pub) {
    (void)pub;
    return pq_error("hybrid handshake requires OpenSSL (X25519); rebuild with -lcrypto");
}

#else  /* EL_HAVE_OPENSSL */

/* HKDF-SHA256 (RFC 5869) — Extract+Expand. Reuses the inline HMAC-SHA256
 * already in this file. Empty salt → 32 zero bytes per the RFC.            */
static void el_hkdf_sha256(const unsigned char* salt, size_t salt_len,
                           const unsigned char* ikm,  size_t ikm_len,
                           const unsigned char* info, size_t info_len,
                           unsigned char* out, size_t out_len) {
    unsigned char zero_salt[32] = {0};
    if (salt_len == 0) { salt = zero_salt; salt_len = 32; }
    unsigned char prk[32];
    el_hmac_sha256(salt, salt_len, ikm, ikm_len, prk);

    unsigned char t[32];
    size_t produced = 0;
    unsigned char counter = 1;
    unsigned char* buf = (unsigned char*)malloc(32 + info_len + 1);
    if (!buf) { fputs("el_runtime: hkdf oom\n", stderr); return; }
    while (produced < out_len) {
        size_t off = 0;
        if (counter > 1) { memcpy(buf, t, 32); off = 32; }
        if (info && info_len) { memcpy(buf + off, info, info_len); off += info_len; }
        buf[off++] = counter;
        el_hmac_sha256(prk, 32, buf, off, t);
        size_t chunk = (out_len - produced > 32) ? 32 : (out_len - produced);
        memcpy(out + produced, t, chunk);
        produced += chunk;
        counter++;
    }
    free(buf);
}

/* X25519 keygen via OpenSSL EVP. Returns 1 on success.
 * Fills pk[32] and sk[32] (raw X25519 byte strings, no DER wrapper). */
static int el_x25519_keygen(unsigned char pk[32], unsigned char sk[32]) {
    EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
    if (!pctx) return 0;
    if (EVP_PKEY_keygen_init(pctx) != 1) { EVP_PKEY_CTX_free(pctx); return 0; }
    EVP_PKEY* key = NULL;
    if (EVP_PKEY_keygen(pctx, &key) != 1) { EVP_PKEY_CTX_free(pctx); return 0; }
    EVP_PKEY_CTX_free(pctx);

    size_t plen = 32, slen = 32;
    if (EVP_PKEY_get_raw_public_key (key, pk, &plen) != 1 || plen != 32) {
        EVP_PKEY_free(key); return 0;
    }
    if (EVP_PKEY_get_raw_private_key(key, sk, &slen) != 1 || slen != 32) {
        EVP_PKEY_free(key); return 0;
    }
    EVP_PKEY_free(key);
    return 1;
}

/* X25519 ECDH: derive 32-byte shared secret from local sk and remote pk. */
static int el_x25519_derive(const unsigned char sk[32], const unsigned char rpk[32],
                            unsigned char ss[32]) {
    EVP_PKEY* my  = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, sk, 32);
    EVP_PKEY* rem = EVP_PKEY_new_raw_public_key (EVP_PKEY_X25519, NULL, rpk, 32);
    if (!my || !rem) { EVP_PKEY_free(my); EVP_PKEY_free(rem); return 0; }
    EVP_PKEY_CTX* dctx = EVP_PKEY_CTX_new(my, NULL);
    if (!dctx) { EVP_PKEY_free(my); EVP_PKEY_free(rem); return 0; }
    int ok = 0;
    size_t out_len = 32;
    if (EVP_PKEY_derive_init(dctx) == 1 &&
        EVP_PKEY_derive_set_peer(dctx, rem) == 1 &&
        EVP_PKEY_derive(dctx, ss, &out_len) == 1 &&
        out_len == 32) ok = 1;
    EVP_PKEY_CTX_free(dctx);
    EVP_PKEY_free(my);
    EVP_PKEY_free(rem);
    return ok;
}

/* Hybrid wire layout (binary form, before hex encode):
 *   public_key  = x25519_pub (32) || kyber_pub (1184)         → 1216 bytes
 *   secret_key  = x25519_sec (32) || kyber_sec (2400)         → 2432 bytes
 *   ciphertext  = ephem_x25519_pub (32) || kyber_ct (1088)    → 1120 bytes
 *   shared_secret = HKDF-SHA256(x25519_ss || kyber_ss, info="el-pq-hybrid-v1", 32 bytes)
 * The keygen result also exposes the four component hex fields for callers
 * that prefer to handle the legs independently.                              */

el_val_t pq_hybrid_keygen(void) {
    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");

    unsigned char xpk[32], xsk[32];
    if (!el_x25519_keygen(xpk, xsk)) {
        OQS_KEM_free(kem);
        return pq_error("X25519 keygen failed");
    }

    unsigned char* kpk = (unsigned char*)malloc(kem->length_public_key);
    unsigned char* ksk = (unsigned char*)malloc(kem->length_secret_key);
    if (!kpk || !ksk) { free(kpk); free(ksk); OQS_KEM_free(kem); return pq_error("oom"); }
    if (OQS_KEM_keypair(kem, kpk, ksk) != OQS_SUCCESS) {
        free(kpk); free(ksk); OQS_KEM_free(kem);
        return pq_error("kyber-768 keypair generation failed");
    }

    size_t pub_len = 32 + kem->length_public_key;
    size_t sec_len = 32 + kem->length_secret_key;
    unsigned char* pub_buf = (unsigned char*)malloc(pub_len);
    unsigned char* sec_buf = (unsigned char*)malloc(sec_len);
    if (!pub_buf || !sec_buf) {
        free(pub_buf); free(sec_buf); free(kpk);
        OQS_MEM_secure_free(ksk, kem->length_secret_key);
        OQS_KEM_free(kem); return pq_error("oom");
    }
    memcpy(pub_buf,      xpk, 32); memcpy(pub_buf + 32, kpk, kem->length_public_key);
    memcpy(sec_buf,      xsk, 32); memcpy(sec_buf + 32, ksk, kem->length_secret_key);

    el_val_t x_pub_hex = el_hex_encode(xpk, 32);
    el_val_t x_sec_hex = el_hex_encode(xsk, 32);
    el_val_t k_pub_hex = el_hex_encode(kpk, kem->length_public_key);
    el_val_t k_sec_hex = el_hex_encode(ksk, kem->length_secret_key);
    el_val_t pub_hex   = el_hex_encode(pub_buf, pub_len);
    el_val_t sec_hex   = el_hex_encode(sec_buf, sec_len);

    OQS_MEM_secure_free(ksk, kem->length_secret_key);
    free(kpk); free(pub_buf); free(sec_buf);
    OQS_KEM_free(kem);
    memset(xsk, 0, 32);   /* best-effort wipe of stack copy */

    const char* xph  = EL_CSTR(x_pub_hex);
    const char* xsh  = EL_CSTR(x_sec_hex);
    const char* kph  = EL_CSTR(k_pub_hex);
    const char* ksh  = EL_CSTR(k_sec_hex);
    const char* pubh = EL_CSTR(pub_hex);
    const char* sech = EL_CSTR(sec_hex);

    char* buf = el_strbuf(strlen(xph) + strlen(xsh) + strlen(kph) + strlen(ksh)
                          + strlen(pubh) + strlen(sech) + 256);
    sprintf(buf,
        "{\"x25519_pub\":\"%s\",\"x25519_sec\":\"%s\","
         "\"kyber_pub\":\"%s\",\"kyber_sec\":\"%s\","
         "\"public_key\":\"%s\",\"secret_key\":\"%s\"}",
        xph, xsh, kph, ksh, pubh, sech);
    return el_wrap_str(buf);
}

/* Initiator-side handshake. Caller supplies the responder's combined public
 * key (x25519_pub || kyber_pub, hex-encoded). The runtime:
 *   1. Generates an ephemeral X25519 keypair, runs ECDH against the
 *      responder's static x25519_pub.
 *   2. Runs Kyber-768 encaps against the responder's kyber_pub → kyber_ct,
 *      kyber_ss.
 *   3. Combined shared = HKDF-SHA256(salt="", ikm = x25519_ss || kyber_ss,
 *                                    info = "el-pq-hybrid-v1", L = 32).
 *   4. Returns combined ciphertext (= ephemeral_x25519_pub || kyber_ct) and
 *      the derived shared_secret.
 *
 * Responder side composition (intentionally not a separate runtime fn —
 * trivial to express in El given pq_kem_decaps + a future x25519_derive
 * primitive): split the ciphertext into ephem_xpk (32) and kyber_ct, run
 * X25519(static_xsk, ephem_xpk) and pq_kem_decaps(static_kyber_sk, kyber_ct),
 * then HKDF-SHA256 with the same salt/info to recover the same shared_secret.
 * If a separate x25519 entry point becomes valuable, add `pq_hybrid_open`
 * here taking (secret_key_combined, ciphertext_combined). */
el_val_t pq_hybrid_handshake(el_val_t remote_pub_combined) {
    size_t pub_len = 0;
    unsigned char* rpub = el_hex_decode(EL_CSTR(remote_pub_combined), &pub_len);
    if (!rpub) return pq_error("invalid hex in remote_pub_combined");

    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    if (pub_len != 32 + kem->length_public_key) {
        OQS_KEM_free(kem);
        return pq_error("remote_pub_combined length mismatch (expected x25519_pub || kyber_pub)");
    }

    unsigned char e_xpk[32], e_xsk[32], x_ss[32];
    if (!el_x25519_keygen(e_xpk, e_xsk)) {
        OQS_KEM_free(kem);
        return pq_error("X25519 ephemeral keygen failed");
    }
    if (!el_x25519_derive(e_xsk, rpub, x_ss)) {
        memset(e_xsk, 0, 32);
        OQS_KEM_free(kem);
        return pq_error("X25519 derive failed");
    }
    memset(e_xsk, 0, 32);   /* ephemeral; not needed after derive */

    unsigned char* k_ct = (unsigned char*)malloc(kem->length_ciphertext);
    unsigned char* k_ss = (unsigned char*)malloc(kem->length_shared_secret);
    if (!k_ct || !k_ss) {
        free(k_ct); free(k_ss); OQS_KEM_free(kem);
        return pq_error("oom");
    }
    if (OQS_KEM_encaps(kem, k_ct, k_ss, rpub + 32) != OQS_SUCCESS) {
        free(k_ct); free(k_ss); OQS_KEM_free(kem);
        return pq_error("kyber-768 encapsulation failed");
    }

    /* HKDF combine: ikm = x_ss || k_ss. */
    size_t ikm_len = 32 + kem->length_shared_secret;
    unsigned char* ikm = (unsigned char*)malloc(ikm_len);
    if (!ikm) {
        free(k_ct); OQS_MEM_secure_free(k_ss, kem->length_shared_secret);
        OQS_KEM_free(kem);
        return pq_error("oom");
    }
    memcpy(ikm,      x_ss, 32);
    memcpy(ikm + 32, k_ss, kem->length_shared_secret);
    unsigned char combined[32];
    static const char info_str[] = "el-pq-hybrid-v1";
    el_hkdf_sha256(NULL, 0, ikm, ikm_len,
                   (const unsigned char*)info_str, sizeof(info_str) - 1,
                   combined, 32);

    memset(x_ss, 0, 32);
    OQS_MEM_secure_free(k_ss, kem->length_shared_secret);
    OQS_MEM_secure_free(ikm,  ikm_len);

    /* Combined ciphertext = ephemeral_x25519_pub || kyber_ct. */
    size_t ct_len = 32 + kem->length_ciphertext;
    unsigned char* combined_ct = (unsigned char*)malloc(ct_len);
    if (!combined_ct) { free(k_ct); OQS_KEM_free(kem); return pq_error("oom"); }
    memcpy(combined_ct,      e_xpk, 32);
    memcpy(combined_ct + 32, k_ct,  kem->length_ciphertext);
    free(k_ct);
    OQS_KEM_free(kem);

    el_val_t ct_hex = el_hex_encode(combined_ct, ct_len);
    el_val_t ss_hex = el_hex_encode(combined, 32);
    free(combined_ct);
    memset(combined, 0, 32);

    const char* cts = EL_CSTR(ct_hex);
    const char* sss = EL_CSTR(ss_hex);
    char* buf = el_strbuf(strlen(cts) + strlen(sss) + 64);
    sprintf(buf, "{\"ciphertext\":\"%s\",\"shared_secret\":\"%s\"}", cts, sss);
    return el_wrap_str(buf);
}

#endif  /* EL_HAVE_OPENSSL */
#endif  /* EL_HAVE_LIBOQS */
