use std::cell::Cell;
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Instant;

// Audit 2026-06-11 H-C1 — thread-local deadline consulted by the Rhai
// `on_progress` hook so a runaway script (e.g., `loop {}`) actually
// terminates instead of holding its `spawn_blocking` OS thread until
// the per-op budget self-exhausts (which can be tens of seconds). The
// orchestrator client wraps each `execute*` call in a
// `DeadlineGuard::set(Some(now + timeout))` so invoke re-entries on the
// same thread inherit the same deadline.
thread_local! {
    static CURRENT_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) };
}

/// RAII guard that sets the current-thread deadline for the lifetime of
/// the guard, restoring whatever was there before on drop.
///
/// Use via [`Engine::execute_with_deadline`] / [`Engine::execute_ast_with_deadline`]
/// at the orchestrator boundary; SDK invoke re-entries piggyback on the
/// existing thread-local without touching it.
pub struct DeadlineGuard {
    prev: Option<Instant>,
}

impl DeadlineGuard {
    /// Set the current-thread deadline. Returns a guard whose `Drop`
    /// restores the prior value.
    #[must_use]
    pub fn set(deadline: Option<Instant>) -> Self {
        let prev = CURRENT_DEADLINE.with(|c| c.replace(deadline));
        Self { prev }
    }
}

impl Drop for DeadlineGuard {
    fn drop(&mut self) {
        CURRENT_DEADLINE.with(|c| c.set(self.prev));
    }
}

fn current_deadline() -> Option<Instant> {
    CURRENT_DEADLINE.with(Cell::get)
}

use chrono::{DateTime, Utc};
use picloud_shared::{
    ScriptId, ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
    SDK_VERSION,
};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope, AST};
use serde_json::Value as Json;

use crate::module_resolver::{
    extract_imports, new_module_cache, validate_module_source, ModuleCache, PicloudModuleResolver,
};
use crate::sandbox::Limits;
use crate::sdk;
use crate::sdk::bridge::{
    dynamic_to_json, dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES,
};
use crate::types::{
    ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
};

/// Default capacity for the module cache. Sized assuming a small fleet
/// of distinct modules per process; can be overridden via
/// `PICLOUD_MODULE_CACHE_SIZE`.
const DEFAULT_MODULE_CACHE_SIZE: usize = 512;

/// Preconfigured Rhai engine with sandbox limits applied and the SDK
/// `Services` bundle attached.
///
/// One `Engine` is constructed at process startup and reused across
/// invocations. `execute` is **synchronous** — it owns the per-call
/// scope and log buffer. Wall-clock timeouts and offloading off the
/// async runtime belong to the caller (orchestrator-core's
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
///
/// The `Services` bundle is empty in v1.1.0; subsequent v1.1.x PRs add
/// service handles (KV, docs, …) and `sdk::register_all` wires them
/// into each per-call Rhai engine.
pub struct Engine {
    limits: Limits,
    services: Services,
    /// v1.1.3: shared compiled-module cache. Per-key
    /// `(app_id, name)`; invalidated lazily by `updated_at` mismatch
    /// at resolver time.
    module_cache: Arc<ModuleCache>,
    /// v1.1.9: back-reference set by the picloud binary after
    /// `Arc::new(Engine::new(...))`. The `invoke` SDK bridge reads it
    /// to re-enter the engine synchronously for `invoke()`. `Weak` so
    /// holding the Engine in an Arc doesn't create a strong cycle.
    /// `None` until `set_self_weak` runs; the bridge surfaces a clear
    /// error if invoke is called without the back-reference being set
    /// (which only happens in tests that don't wire it).
    self_weak: OnceLock<Weak<Engine>>,
    /// F-P-004: per-Engine AST cache keyed on `(script_id, updated_at)`.
    /// Populated by `compile_for_identity`; consumed by the SDK invoke
    /// bridge so the synchronous re-entry path doesn't re-parse the
    /// callee on every invoke. Independent of the orchestrator-core
    /// `LocalExecutorClient` AST cache — that one caches HTTP-path
    /// dispatch; this one caches function-call dispatch.
    #[allow(clippy::type_complexity)]
    invoke_ast_cache: Mutex<HashMap<ScriptId, (DateTime<Utc>, Arc<AST>)>>,
}

impl Engine {
    #[must_use]
    pub fn new(limits: Limits, services: Services) -> Self {
        let cap = std::env::var("PICLOUD_MODULE_CACHE_SIZE")
            .ok()
            .and_then(|s| s.parse::<usize>().ok())
            .unwrap_or(DEFAULT_MODULE_CACHE_SIZE);
        Self::with_module_cache_capacity(limits, services, cap)
    }

    /// Explicit capacity for tests that exercise LRU eviction.
    #[must_use]
    pub fn with_module_cache_capacity(
        limits: Limits,
        services: Services,
        module_cache_capacity: usize,
    ) -> Self {
        Self {
            limits,
            services,
            module_cache: new_module_cache(module_cache_capacity),
            self_weak: OnceLock::new(),
            invoke_ast_cache: Mutex::new(HashMap::new()),
        }
    }

    /// F-P-004: synchronous-invoke fast path. Returns a cached AST when
    /// (script_id, updated_at) matches; otherwise compiles, inserts,
    /// returns. Used by the `invoke` SDK bridge to skip the per-call
    /// parse when one script calls another.
    ///
    /// # Errors
    ///
    /// Propagates `ExecError::Parse` from the inner compile step.
    pub fn compile_for_identity(
        &self,
        script_id: ScriptId,
        updated_at: DateTime<Utc>,
        source: &str,
    ) -> Result<Arc<AST>, ExecError> {
        {
            let cache = self
                .invoke_ast_cache
                .lock()
                .expect("invoke ast cache poisoned");
            if let Some((ts, ast)) = cache.get(&script_id) {
                if *ts == updated_at {
                    return Ok(ast.clone());
                }
            }
        }
        let ast = self.compile(source)?;
        let mut cache = self
            .invoke_ast_cache
            .lock()
            .expect("invoke ast cache poisoned");
        cache.insert(script_id, (updated_at, ast.clone()));
        Ok(ast)
    }

    /// v1.1.9: install the back-reference used by the `invoke` SDK
    /// bridge for synchronous re-entry. Idempotent (subsequent calls
    /// are no-ops). The picloud binary calls this right after
    /// `Arc::new(Engine::new(...))`:
    ///
    /// ```ignore
    /// let engine = Arc::new(Engine::new(limits, services));
    /// engine.set_self_weak(Arc::downgrade(&engine));
    /// ```
    pub fn set_self_weak(&self, weak: Weak<Engine>) {
        let _ = self_weak_set(&self.self_weak, weak);
    }

    /// Internal accessor used by the `invoke` SDK bridge — returns
    /// `Some(strong)` if the back-reference was installed and the
    /// engine is still alive.
    #[must_use]
    pub fn self_arc(&self) -> Option<Arc<Engine>> {
        self.self_weak.get().and_then(Weak::upgrade)
    }

    #[must_use]
    pub fn limits(&self) -> &Limits {
        &self.limits
    }

    /// Shared compiled-module cache. Exposed so tests can introspect
    /// the cache state (length, contents) under a Mutex lock.
    #[must_use]
    pub fn module_cache(&self) -> &Arc<ModuleCache> {
        &self.module_cache
    }

    /// Parse-only validation for endpoint scripts. Surfaced at script-
    /// upload time so syntax errors are caught before the first
    /// invocation. Returns the script's literal-path `import "<name>"`
    /// declarations so the repo can populate the dep-graph table.
    pub fn validate(&self, source: &str) -> Result<ValidatedScript, ExecError> {
        // Validation uses a fresh `RhaiEngine` without service hooks
        // attached — modules are only resolved at execute() time, so
        // the resolver during validate is intentionally Dummy (no DB
        // access here; we just need the parser).
        let engine = build_engine(self.limits, None);
        extract_imports(&engine, source).map_err(ExecError::Parse)
    }

    /// Module-shape validation (v1.1.3). Compiles, rejects any top-
    /// level statement that isn't `fn`/`const`/`import`, and returns
    /// the declared imports.
    pub fn validate_module(&self, source: &str) -> Result<ValidatedScript, ExecError> {
        let engine = build_engine(self.limits, None);
        validate_module_source(&engine, source).map_err(ExecError::Parse)
    }

    /// Compile `source` to a reusable AST. Lets callers (the
    /// orchestrator's script cache) compile once and execute many
    /// times against the same AST.
    pub fn compile(&self, source: &str) -> Result<Arc<AST>, ExecError> {
        let engine = build_engine(self.limits, None);
        engine
            .compile(source)
            .map(Arc::new)
            .map_err(|e| ExecError::Parse(e.to_string()))
    }

    /// Like [`Self::execute`] but also installs `deadline` on the
    /// current thread so the Rhai `on_progress` hook can interrupt a
    /// runaway script before `tokio::time::timeout` fires (which only
    /// drops the future — the OS thread keeps running). Audit
    /// 2026-06-11 H-C1.
    pub fn execute_with_deadline(
        &self,
        source: &str,
        req: ExecRequest,
        deadline: Option<Instant>,
    ) -> Result<ExecResponse, ExecError> {
        let _guard = DeadlineGuard::set(deadline);
        self.execute(source, req)
    }

    /// Like [`Self::execute_ast`] but installs `deadline` on the current
    /// thread. See [`Self::execute_with_deadline`] for the rationale.
    pub fn execute_ast_with_deadline(
        &self,
        ast: &Arc<AST>,
        req: ExecRequest,
        deadline: Option<Instant>,
    ) -> Result<ExecResponse, ExecError> {
        let _guard = DeadlineGuard::set(deadline);
        self.execute_ast(ast, req)
    }

    /// Execute `source` against `req`. Op-budget protection comes from
    /// Rhai's `set_max_operations`; the wall-clock deadline (when set
    /// via [`DeadlineGuard`] / [`Self::execute_with_deadline`]) aborts
    /// any per-op step that crosses it. Per-script sandbox overrides on
    /// the request replace the engine's defaults field-by-field; the
    /// manager already clamped them against the admin ceiling.
    pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
        let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
        // Compile inline so the source-only path stays available for
        // tests and one-off callers that don't pre-cache an AST.
        let engine_for_compile = build_engine(effective_limits, None);
        let ast = engine_for_compile
            .compile(source)
            .map(Arc::new)
            .map_err(|e| ExecError::Parse(e.to_string()))?;
        self.execute_ast(&ast, req)
    }

    /// v1.1.3: execute a pre-compiled AST. The orchestrator's script
    /// cache hands compiled ASTs in directly; this path skips the
    /// per-call compile.
    pub fn execute_ast(&self, ast: &Arc<AST>, req: ExecRequest) -> Result<ExecResponse, ExecError> {
        // Audit #2: bound per-execution durable fan-out. The scope is
        // re-entrancy aware — a fresh dispatched execution (a new task on a
        // pooled thread) resets the budget, while a synchronous invoke() /
        // interceptor re-entry nested in the SAME call stack shares it, so
        // fan-out is counted across the whole synchronous chain. Held to the end
        // of the call; its Drop re-zeroes the counter on the outermost exit.
        let _emit_scope = crate::sdk::emit_budget::EmissionBudgetScope::enter();
        let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
        let mut engine = build_engine(effective_limits, Some(logs.clone()));

        // Per-call context handed to every stateful SDK service via the
        // `sdk::register_all` hook. The Arc lets future service closures
        // capture cheap clones of the cx for use at script-call time.
        let cx = Arc::new(SdkCallCx {
            app_id: req.app_id,
            script_id: req.script_id,
            principal: req.principal.clone(),
            execution_id: req.execution_id,
            request_id: req.request_id,
            trigger_depth: req.trigger_depth,
            root_execution_id: req.root_execution_id,
            is_dead_letter_handler: req.is_dead_letter_handler,
            event: req.event.clone(),
        });
        // v1.1.3: replace the no-op `DummyModuleResolver` build_engine
        // installed with the real per-call resolver. The resolver owns
        // `cx.clone()` so cross-app isolation derives from this exact
        // call's context, not from any script-passed argument.
        // The lexical origin for `import` resolution (§5.5): the top-level
        // script's defining node. Falls back to the executing app when the
        // request predates Phase 4b (old payloads / cluster wire).
        let default_origin = req
            .script_owner
            .unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
        let resolver = PicloudModuleResolver::new(
            self.services.modules.clone(),
            cx.clone(),
            self.module_cache.clone(),
            effective_limits.module_import_depth_max,
            default_origin,
        );
        engine.set_module_resolver(resolver);
        let self_engine = self.self_arc();
        sdk::register_all(
            &mut engine,
            &self.services,
            cx,
            effective_limits,
            self_engine,
        );

        let mut scope = Scope::new();
        scope.push_constant("ctx", build_ctx_map(&req));

        let started = Instant::now();
        let value: Dynamic = engine
            .eval_ast_with_scope(&mut scope, ast.as_ref())
            .map_err(map_eval_error)?;
        let duration = started.elapsed();

        let logs = Arc::try_unwrap(logs).map_or_else(
            |arc| arc.lock().map(|g| g.clone()).unwrap_or_default(),
            |m| m.into_inner().unwrap_or_default(),
        );

        let (status_code, headers, body) = parse_response(value)?;

        Ok(ExecResponse {
            status_code,
            headers,
            body,
            logs,
            stats: ExecStats {
                duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
                operations: 0,
            },
        })
    }
}

impl ScriptValidator for Engine {
    fn validate(&self, source: &str) -> Result<ValidatedScript, ValidationError> {
        Engine::validate(self, source).map_err(|e| match e {
            ExecError::Parse(msg) => ValidationError::Syntax(msg),
            other => ValidationError::Syntax(other.to_string()),
        })
    }

    fn validate_module(&self, source: &str) -> Result<ValidatedScript, ValidationError> {
        Engine::validate_module(self, source).map_err(|e| match e {
            ExecError::Parse(msg) => ValidationError::ModuleShape(msg),
            other => ValidationError::ModuleShape(other.to_string()),
        })
    }
}

/// Tiny helper to make the `set_self_weak` body idempotent without
/// pulling the impl up into the public API.
fn self_weak_set(slot: &OnceLock<Weak<Engine>>, weak: Weak<Engine>) -> Result<(), Weak<Engine>> {
    slot.set(weak)
}

// ----------------------------------------------------------------------------
// Engine construction
// ----------------------------------------------------------------------------

fn build_engine(limits: Limits, logs: Option<Arc<Mutex<Vec<LogEntry>>>>) -> RhaiEngine {
    let mut engine = RhaiEngine::new();

    engine.set_max_operations(limits.max_operations);
    engine.set_max_string_size(limits.max_string_size);
    engine.set_max_array_size(limits.max_array_size);
    engine.set_max_map_size(limits.max_map_size);
    engine.set_max_call_levels(limits.max_call_levels);
    engine.set_max_expr_depths(limits.max_expr_depth, limits.max_expr_depth);

    // Audit 2026-06-11 H-C1 — wall-clock interrupter. Rhai invokes the
    // progress callback once per operation; returning `Some(_)` triggers
    // `ErrorTerminated`, which propagates out of `eval_ast_with_scope`.
    // The deadline is read from a thread-local set by the orchestrator's
    // `execute_with_deadline` / `execute_ast_with_deadline` entry points;
    // `None` (the default) is a no-op so tests, validation, and bare
    // `execute*` callers see the previous behavior.
    engine.on_progress(|_ops| {
        if let Some(deadline) = current_deadline() {
            if Instant::now() >= deadline {
                return Some(Dynamic::UNIT);
            }
        }
        None
    });

    // Reject `import` — scripts cannot pull external modules.
    engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver);

    // Rhai's built-in `print` and `debug` map to stdout/stderr by
    // default; we never want scripts dumping there directly. Disable
    // them so scripts route all output through `log::*` instead.
    //
    // Audit 2026-06-11 (F-SE-H-03) — `debug` was previously left
    // enabled despite this comment, so a script could write
    // attacker-controlled bytes (control chars, ANSI escapes) into the
    // operator's stderr/journald stream. Disable it to match `print`.
    engine.disable_symbol("print");
    engine.disable_symbol("debug");

    if let Some(logs) = logs {
        engine.register_static_module("log", build_log_module(logs).into());
    }

    // Stateless utility modules — regex::/random::/time::/json::/base64::/
    // hex::/url::. Always registered, including in the parse-only validate
    // path, so script authors get consistent surface in both phases.
    sdk::stdlib::register_stdlib(&mut engine);

    engine
}

fn build_log_module(logs: Arc<Mutex<Vec<LogEntry>>>) -> Module {
    let mut module = Module::new();
    register_log_fn(&mut module, "trace", LogLevel::Trace, &logs);
    register_log_fn(&mut module, "info", LogLevel::Info, &logs);
    register_log_fn(&mut module, "warn", LogLevel::Warn, &logs);
    register_log_fn(&mut module, "error", LogLevel::Error, &logs);
    // No `log::debug` — `debug` is a Rhai reserved keyword. Use
    // `log::trace` for sub-info-level diagnostics.
    module
}

fn register_log_fn(
    module: &mut Module,
    name: &str,
    level: LogLevel,
    logs: &Arc<Mutex<Vec<LogEntry>>>,
) {
    // Single-argument form: `log::info("message")`.
    let logs_single = logs.clone();
    module.set_native_fn(name, move |msg: &str| {
        push_log(&logs_single, level, msg, None);
        Ok::<_, Box<EvalAltResult>>(())
    });

    // Two-argument form: `log::info("message", #{ user: 42 })`.
    let logs_struct = logs.clone();
    module.set_native_fn(name, move |msg: &str, data: Dynamic| {
        let json = dynamic_to_json(&data);
        push_log(&logs_struct, level, msg, Some(json));
        Ok::<_, Box<EvalAltResult>>(())
    });
}

fn push_log(logs: &Arc<Mutex<Vec<LogEntry>>>, level: LogLevel, message: &str, data: Option<Json>) {
    if let Ok(mut g) = logs.lock() {
        g.push(LogEntry {
            timestamp: Utc::now(),
            level,
            message: message.to_string(),
            data,
        });
    }
}

// ----------------------------------------------------------------------------
// ctx construction
// ----------------------------------------------------------------------------

fn build_ctx_map(req: &ExecRequest) -> Map {
    let mut ctx = Map::new();
    ctx.insert("sdk_version".into(), SDK_VERSION.into());
    ctx.insert("execution_id".into(), req.execution_id.to_string().into());
    ctx.insert("script_id".into(), req.script_id.to_string().into());
    ctx.insert("script_name".into(), req.script_name.clone().into());
    ctx.insert("request_id".into(), req.request_id.to_string().into());
    ctx.insert(
        "invocation_type".into(),
        invocation_type_str(req.invocation_type).into(),
    );
    // Read-only depth of this call in a trigger/invoke chain: 0 for direct
    // ingress, +1 per synchronous re-entry (`invoke`) or dispatched handler.
    // Exposed so a script can observe where it sits in the chain (the same value
    // the platform bounds via `trigger_depth_max`).
    ctx.insert(
        "trigger_depth".into(),
        (i64::from(req.trigger_depth)).into(),
    );

    let mut request = Map::new();
    request.insert("path".into(), req.path.clone().into());
    request.insert("method".into(), req.method.clone().into());

    let mut headers = Map::new();
    for (k, v) in &req.headers {
        headers.insert(k.clone().into(), v.clone().into());
    }
    request.insert("headers".into(), headers.into());

    request.insert("body".into(), json_to_dynamic(req.body.clone()));

    // SDK 1.1 additions — route-captured params, query string, prefix
    // tail. Empty when not applicable so scripts can always read them.
    let mut params = Map::new();
    for (k, v) in &req.params {
        params.insert(k.clone().into(), v.clone().into());
    }
    request.insert("params".into(), params.into());

    let mut query = Map::new();
    for (k, v) in &req.query {
        query.insert(k.clone().into(), v.clone().into());
    }
    request.insert("query".into(), query.into());

    request.insert("rest".into(), req.rest.clone().into());

    ctx.insert("request".into(), request.into());

    // Triggered invocations: surface the originating event as
    // `ctx.event`. Direct ingress (HTTP request, manual run) leaves
    // the key absent so scripts can test `if "event" in ctx`.
    if let Some(event) = req.event.as_ref() {
        ctx.insert("event".into(), trigger_event_to_dynamic(event));
    }

    ctx
}

/// Convert a `TriggerEvent` into the `ctx.event` Rhai shape defined in
/// `docs/v1.1.x-design-notes.md` §4 (the dead-letter sub-shape) and
/// §2/blueprint §9 (KV). Each variant becomes a Rhai map with a
/// `source` discriminant plus per-source fields.
#[allow(clippy::too_many_lines)]
fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
    let mut m = Map::new();
    m.insert("source".into(), event.source().into());
    match event {
        TriggerEvent::Kv {
            op,
            collection,
            key,
            value,
        } => {
            m.insert("op".into(), op.as_str().into());
            let mut kv_map = Map::new();
            kv_map.insert("collection".into(), collection.clone().into());
            kv_map.insert("key".into(), key.clone().into());
            kv_map.insert(
                "value".into(),
                value.clone().map_or(Dynamic::UNIT, json_to_dynamic),
            );
            m.insert("kv".into(), kv_map.into());
        }
        TriggerEvent::Docs {
            op,
            collection,
            id,
            data,
            prev_data,
        } => {
            m.insert("op".into(), op.as_str().into());
            let mut docs_map = Map::new();
            docs_map.insert("collection".into(), collection.clone().into());
            docs_map.insert("id".into(), id.clone().into());
            docs_map.insert(
                "data".into(),
                data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
            );
            docs_map.insert(
                "prev_data".into(),
                prev_data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
            );
            m.insert("docs".into(), docs_map.into());
        }
        TriggerEvent::Cron {
            schedule,
            timezone,
            scheduled_at,
            fired_at,
        } => {
            // `ctx.event.op` is always "tick" for cron (the only op a
            // schedule produces). Mirrors the docs/v1.1.x-design-notes
            // §7 shape.
            m.insert("op".into(), "tick".into());
            let mut cron_map = Map::new();
            cron_map.insert("schedule".into(), schedule.clone().into());
            cron_map.insert("timezone".into(), timezone.clone().into());
            cron_map.insert("scheduled_at".into(), scheduled_at.to_rfc3339().into());
            cron_map.insert("fired_at".into(), fired_at.to_rfc3339().into());
            m.insert("cron".into(), cron_map.into());
        }
        TriggerEvent::Files {
            op,
            collection,
            id,
            name,
            content_type,
            size,
            checksum,
            prev,
        } => {
            m.insert("op".into(), op.as_str().into());
            let mut files_map = Map::new();
            files_map.insert("collection".into(), collection.clone().into());
            files_map.insert("id".into(), id.clone().into());
            files_map.insert("name".into(), name.clone().into());
            files_map.insert("content_type".into(), content_type.clone().into());
            files_map.insert(
                "size".into(),
                i64::try_from(*size).unwrap_or(i64::MAX).into(),
            );
            files_map.insert("checksum".into(), checksum.clone().into());
            files_map.insert(
                "prev".into(),
                prev.clone().map_or(Dynamic::UNIT, json_to_dynamic),
            );
            m.insert("files".into(), files_map.into());
        }
        TriggerEvent::Pubsub {
            topic,
            message,
            published_at,
        } => {
            // `ctx.event.op` is always "publish" for pub/sub (the only
            // op a publish produces).
            m.insert("op".into(), "publish".into());
            let mut ps = Map::new();
            ps.insert("topic".into(), topic.clone().into());
            ps.insert("message".into(), json_to_dynamic(message.clone()));
            ps.insert("published_at".into(), published_at.to_rfc3339().into());
            m.insert("pubsub".into(), ps.into());
        }
        TriggerEvent::Queue {
            queue_name,
            message,
            enqueued_at,
            attempt,
            message_id,
        } => {
            // `ctx.event.op` is always "receive" for queue (the only op
            // a queue:receive trigger surfaces).
            m.insert("op".into(), "receive".into());
            let mut q = Map::new();
            q.insert("queue_name".into(), queue_name.clone().into());
            q.insert("message".into(), json_to_dynamic(message.clone()));
            q.insert("enqueued_at".into(), enqueued_at.to_rfc3339().into());
            q.insert("attempt".into(), i64::from(*attempt).into());
            q.insert("message_id".into(), message_id.clone().into());
            m.insert("queue".into(), q.into());
        }
        TriggerEvent::Email {
            from,
            to,
            cc,
            subject,
            text,
            html,
            received_at,
            message_id,
        } => {
            // `ctx.event.op` is always "receive" for inbound email.
            m.insert("op".into(), "receive".into());
            let mut em = Map::new();
            em.insert("from".into(), from.clone().into());
            let to_arr: rhai::Array = to.iter().map(|a| Dynamic::from(a.clone())).collect();
            em.insert("to".into(), to_arr.into());
            let cc_arr: rhai::Array = cc.iter().map(|a| Dynamic::from(a.clone())).collect();
            em.insert("cc".into(), cc_arr.into());
            em.insert("subject".into(), subject.clone().into());
            em.insert(
                "text".into(),
                text.clone().map_or(Dynamic::UNIT, Dynamic::from),
            );
            em.insert(
                "html".into(),
                html.clone().map_or(Dynamic::UNIT, Dynamic::from),
            );
            em.insert("received_at".into(), received_at.to_rfc3339().into());
            em.insert(
                "message_id".into(),
                message_id.clone().map_or(Dynamic::UNIT, Dynamic::from),
            );
            m.insert("email".into(), em.into());
        }
        TriggerEvent::DeadLetter {
            dead_letter_id,
            original,
            attempts,
            last_error,
            trigger_id,
            script_id,
            first_attempt_at,
            last_attempt_at,
        } => {
            let mut dl = Map::new();
            dl.insert("id".into(), dead_letter_id.to_string().into());
            dl.insert("original".into(), trigger_event_to_dynamic(original));
            dl.insert("attempts".into(), i64::from(*attempts).into());
            dl.insert("last_error".into(), last_error.clone().into());
            dl.insert(
                "trigger_id".into(),
                trigger_id
                    .map(|id| Dynamic::from(id.to_string()))
                    .unwrap_or(Dynamic::UNIT),
            );
            dl.insert(
                "script_id".into(),
                script_id
                    .map(|id| Dynamic::from(id.to_string()))
                    .unwrap_or(Dynamic::UNIT),
            );
            dl.insert(
                "first_attempt_at".into(),
                first_attempt_at.to_rfc3339().into(),
            );
            dl.insert(
                "last_attempt_at".into(),
                last_attempt_at.to_rfc3339().into(),
            );
            m.insert("dead_letter".into(), dl.into());
        }
    }
    m.into()
}

fn invocation_type_str(it: InvocationType) -> &'static str {
    match it {
        InvocationType::Http => "http",
        InvocationType::Function => "function",
        InvocationType::Scheduled => "scheduled",
    }
}

// ----------------------------------------------------------------------------
// Response parsing
// ----------------------------------------------------------------------------

fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
    // Convention: a Map with a `statusCode` field is the structured shape.
    // Anything else is treated as a 200 response with the value as body.
    if value.is_map() {
        if let Some(map) = value.clone().try_cast::<Map>() {
            if map.contains_key("statusCode") {
                return parse_structured_response(map);
            }
        }
    }
    // The response body is otherwise an uncapped Rhai→JSON exit — bound it so a
    // cheaply-aliased huge value can't OOM the node on materialization.
    let body = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)
        .map_err(|e| ExecError::InvalidResponse(e.to_string()))?;
    Ok((200, BTreeMap::new(), body))
}

fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
    let status_dyn = map
        .get("statusCode")
        .ok_or_else(|| ExecError::InvalidResponse("missing statusCode".into()))?;
    let status_code: i64 = status_dyn
        .as_int()
        .map_err(|_| ExecError::InvalidResponse("statusCode must be an integer".into()))?;
    let status_code = u16::try_from(status_code)
        .map_err(|_| ExecError::InvalidResponse("statusCode out of HTTP range".into()))?;

    let mut headers: BTreeMap<String, String> = BTreeMap::new();
    if let Some(h) = map.get("headers") {
        if let Some(h_map) = h.clone().try_cast::<Map>() {
            for (k, v) in h_map {
                headers.insert(k.to_string(), v.to_string());
            }
        }
    }

    let body = match map.get("body") {
        Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
            .map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
        None => Json::Null,
    };

    Ok((status_code, headers, body))
}

// ----------------------------------------------------------------------------
// Error mapping
// ----------------------------------------------------------------------------

fn map_eval_error(err: Box<EvalAltResult>) -> ExecError {
    match *err {
        EvalAltResult::ErrorTooManyOperations(_) => ExecError::OperationBudgetExceeded,
        EvalAltResult::ErrorParsing(parse_err, _) => ExecError::Parse(parse_err.to_string()),
        other => ExecError::Runtime(other.to_string()),
    }
}
