const std = @import("std");
const Allocator = std.mem.Allocator;
const types = @import("types.zig");
const inst_mod = @import("inst.zig");
const mod_mod = @import("module.zig");
const errors = @import("../errors.zig");

const TypeId = types.TypeId;
const TypeTable = types.TypeTable;
const StringId = types.StringId;
const Ref = inst_mod.Ref;
const BlockId = inst_mod.BlockId;
const FuncId = inst_mod.FuncId;
const Inst = inst_mod.Inst;
const Op = inst_mod.Op;
const Function = inst_mod.Function;
const Block = inst_mod.Block;
const Module = mod_mod.Module;
const Builder = mod_mod.Builder;

// ── Value ───────────────────────────────────────────────────────────────

pub const Value = union(enum) {
    int: i64,
    float: f64,
    boolean: bool,
    string: []const u8,
    null_val,
    void_val,
    undef,
    aggregate: []const Value,
    slot_ptr: u32, // index into the frame's local slots
    func_ref: FuncId,
    closure: ClosureVal,
    type_tag: TypeId,
    heap_ptr: HeapPtr, // pointer into heap-allocated memory
    /// Byte-granular raw pointer. Produced by `index_gep` on a string /
    /// `[*]u8` aggregate whose data field is itself a raw integer pointer
    /// (e.g. from libc_malloc). Store/load through this variant operate
    /// on a single byte — matching the heap_ptr semantics for the same
    /// op shape.
    byte_ptr: usize,

    pub const ClosureVal = struct {
        func: FuncId,
        env: ?[]const Value,
    };

    /// A pointer to heap-allocated memory, with an optional byte offset.
    pub const HeapPtr = struct {
        id: u32, // index into Interpreter.heap
        offset: u32 = 0,
    };

    pub fn asInt(self: Value) ?i64 {
        return switch (self) {
            .int => |v| v,
            else => null,
        };
    }

    pub fn asFloat(self: Value) ?f64 {
        return switch (self) {
            .float => |v| v,
            .int => |v| @floatFromInt(v), // implicit int→float for convenience
            else => null,
        };
    }

    pub fn asBool(self: Value) ?bool {
        return switch (self) {
            .boolean => |v| v,
            else => null,
        };
    }

    pub fn isNull(self: Value) bool {
        return self == .null_val;
    }

    /// Extract the TypeId from a first-class Type value. Returns null
    /// for anything else — including `.int(N)` where N happens to be
    /// a valid TypeId enum value. The kinds are distinct: a Type IS
    /// NOT an int. Use this helper instead of `asInt` when reading a
    /// TypeId out of a Value to keep the kind-distinction honest.
    pub fn asTypeId(self: Value) ?TypeId {
        return switch (self) {
            .type_tag => |id| id,
            else => null,
        };
    }

    /// Get the string content, whether from a literal or a heap-backed string aggregate.
    pub fn asString(self: Value, interp: *const Interpreter) ?[]const u8 {
        return switch (self) {
            .string => |s| s,
            .aggregate => |fields| {
                // String fat pointer: { heap_ptr/string/raw_int_ptr, int(len) }
                if (fields.len == 2) {
                    const len: usize = @intCast(fields[1].asInt() orelse return null);
                    switch (fields[0]) {
                        .heap_ptr => |hp| {
                            const mem = interp.heapSlice(hp) orelse return null;
                            return if (len <= mem.len) mem[0..len] else null;
                        },
                        .string => |s| return if (len <= s.len) s[0..len] else s,
                        // Raw host pointer (e.g. from CAllocator.alloc →
                        // libc_malloc). Read `len` bytes back from real
                        // memory.
                        .int => |addr| {
                            const p: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(addr)));
                            return p[0..len];
                        },
                        else => return null,
                    }
                }
                return null;
            },
            else => null,
        };
    }
};

// ── Error ───────────────────────────────────────────────────────────────

pub const InterpError = error{
    CannotEvalComptime,
    TypeError,
    OutOfBounds,
    DivisionByZero,
    StackOverflow,
    Unreachable,
};

const compiler_hooks = @import("compiler_hooks.zig");
pub const BuildConfig = compiler_hooks.BuildConfig;
const host_ffi = @import("host_ffi.zig");

// ── Interpreter ─────────────────────────────────────────────────────────

pub const Interpreter = struct {
    module: *const Module,
    alloc: Allocator,
    output: std.ArrayList(u8),
    call_depth: u32 = 0,
    max_call_depth: u32 = 256,
    /// Active sx call-frame chain (oldest→newest), maintained across `call` for
    /// `trace.print_interpreter_frames()` (ERR E4.1). Only sx-bodied frames are
    /// tracked — foreign calls return before the frame is pushed.
    call_chain: std.ArrayList(FuncId) = .empty,

    /// File → source text (the diagnostics' import_sources). Set by the host
    /// where available so `.trace_resolve` can turn a `(func_id, span.start)`
    /// frame into `file:line:col` at comptime (ERR E3.0 slice 3b). Null → the
    /// resolver degrades to line/col 1:1.
    source_map: ?*const std.StringHashMap([:0]const u8) = null,

    // Heap: dynamically allocated memory blocks
    heap: std.ArrayList([]u8),

    // Global values: evaluated comptime globals, indexed by GlobalId
    global_values: std.AutoHashMap(u32, Value),

    // Mutable build configuration — set by LLVMEmitter, written by #run blocks
    build_config: ?*BuildConfig = null,

    // Compiler hook registry for #compiler methods
    hooks: compiler_hooks.Registry,

    // First op tag that bailed with InterpError, captured the first
    // time the interpreter unwinds so callers can surface "op=foo at
    // <file>:<offset>" alongside the bare error name. Static so it
    // survives Interpreter teardown (lifetime: program global).
    pub var last_bail_op: ?[]const u8 = null;
    pub var last_bail_file: ?[]const u8 = null;
    pub var last_bail_offset: u32 = 0;
    pub var last_bail_builtin: ?[]const u8 = null;
    /// Free-text explanation of WHY the bail happened — set at sites
    /// that currently can't handle a specific Value/op combination
    /// (raw-pointer loads, struct_gep through `*void`, etc.). The host
    /// diagnostic renderer surfaces this so users see "load through
    /// raw pointer not supported" instead of a bare `CannotEvalComptime`.
    pub var last_bail_detail: ?[]const u8 = null;

    /// Set `last_bail_detail` to a static message and return the error.
    /// Use at sites where a specific raw-pointer Value tag isn't handled
    /// so users get a clear explanation instead of guessing.
    fn bailDetail(comptime msg: []const u8) InterpError {
        if (last_bail_detail == null) last_bail_detail = msg;
        return error.CannotEvalComptime;
    }

    /// Like `bailDetail` but returns a `TypeError` — for foreign-arg
    /// marshalling sites that previously erased the reason.
    fn typeErrorDetail(comptime msg: []const u8) InterpError {
        if (last_bail_detail == null) last_bail_detail = msg;
        return error.TypeError;
    }

    pub fn init(module: *const Module, alloc: Allocator) Interpreter {
        var hooks = compiler_hooks.Registry.init(alloc);
        hooks.registerDefaults();
        return .{
            .module = module,
            .alloc = alloc,
            .output = std.ArrayList(u8).empty,
            .heap = std.ArrayList([]u8).empty,
            .global_values = std.AutoHashMap(u32, Value).init(alloc),
            .hooks = hooks,
        };
    }

    /// Provide the file→source map so `.trace_resolve` can compute file:line:col
    /// for comptime trace frames. Optional — absent in unit tests.
    pub fn setSourceMap(self: *Interpreter, sm: *const std.StringHashMap([:0]const u8)) void {
        self.source_map = sm;
    }

    pub fn deinit(self: *Interpreter) void {
        // Free all heap allocations
        for (self.heap.items) |block| {
            self.alloc.free(block);
        }
        self.heap.deinit(self.alloc);
        self.output.deinit(self.alloc);
        self.call_chain.deinit(self.alloc);
        self.global_values.deinit();
        self.hooks.deinit();
    }

    /// Write `val` to the raw host address `addr` using exactly the
    /// number of bytes declared by `val_ty`. Used when the
    /// protocol-dispatch chain bottoms out at a foreign-libc-malloc
    /// pointer and sx code stores through it. Comptime safety is the
    /// caller's responsibility — wild writes will fault.
    fn storeAtRawPtr(self: *Interpreter, addr: i64, val: Value, val_ty: @import("types.zig").TypeId) InterpError!void {
        const dst: [*]u8 = @ptrFromInt(@as(usize, @bitCast(addr)));
        const width = self.module.types.typeSizeBytes(val_ty);
        switch (val) {
            .int => |v| {
                // Width is whatever the declared IR type says (s8..s64,
                // u8..u64, usize, pointer-as-int, bool-after-extension).
                // The Value tag itself is .int regardless.
                if (width == 0 or width > 8) return bailDetail("comptime store of int through raw pointer: unexpected declared width (expected 1..8 bytes)");
                const bytes = std.mem.toBytes(v);
                @memcpy(dst[0..width], bytes[0..width]);
            },
            .float => |v| {
                switch (width) {
                    8 => {
                        const bytes = std.mem.toBytes(v);
                        @memcpy(dst[0..8], &bytes);
                    },
                    4 => {
                        const f32_v: f32 = @floatCast(v);
                        const bytes = std.mem.toBytes(f32_v);
                        @memcpy(dst[0..4], &bytes);
                    },
                    else => return bailDetail("comptime store of float through raw pointer: unexpected declared width (expected 4 or 8 bytes)"),
                }
            },
            .boolean => |v| {
                if (width == 0) return bailDetail("comptime store of bool through raw pointer: zero-width destination");
                @memset(dst[0..width], 0);
                dst[0] = if (v) 1 else 0;
            },
            .null_val => {
                if (width == 0 or width > 8) return bailDetail("comptime store of null through raw pointer: unexpected declared width");
                @memset(dst[0..width], 0);
            },
            .aggregate => return bailDetail("comptime store of aggregate through raw pointer not supported (struct field layout not threaded into Store IR op)"),
            .heap_ptr => return bailDetail("comptime store of interp-heap pointer through raw pointer not supported"),
            .byte_ptr => return bailDetail("comptime store of byte pointer through raw pointer not supported"),
            .slot_ptr => return bailDetail("comptime store of slot pointer through raw pointer not supported (frame-local slot indices aren't meaningful as memory contents)"),
            .func_ref => return bailDetail("comptime store of func_ref through raw pointer not supported"),
            .closure => return bailDetail("comptime store of closure value through raw pointer not supported"),
            .string, .type_tag, .void_val, .undef => return bailDetail("comptime store: unsupported Value kind at raw destination"),
        }
    }

    // ── Implicit Context ──────────────────────────────────────────

    /// Build the default Context aggregate for top-level interp calls.
    /// Mirrors the static `__sx_default_context` LLVM global: a Context
    /// whose `allocator` field is the stateless CAllocator inline-protocol
    /// value (alloc/dealloc thunks bottom out at libc malloc/free).
    fn defaultContextValue(self: *Interpreter) Value {
        const tbl_ptr: *const @import("types.zig").TypeTable = &self.module.types;
        const tbl = @constCast(tbl_ptr);
        const alloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_alloc");
        const dealloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_dealloc");

        var alloc_fn: Value = .null_val;
        var dealloc_fn: Value = .null_val;
        for (self.module.functions.items, 0..) |func, i| {
            if (func.name == alloc_thunk_name) alloc_fn = .{ .func_ref = FuncId.fromIndex(@intCast(i)) };
            if (func.name == dealloc_thunk_name) dealloc_fn = .{ .func_ref = FuncId.fromIndex(@intCast(i)) };
        }

        const allocator_fields = self.alloc.alloc(Value, 3) catch unreachable;
        allocator_fields[0] = .null_val; // CAllocator receiver — stateless
        allocator_fields[1] = alloc_fn;
        allocator_fields[2] = dealloc_fn;
        const allocator_val: Value = .{ .aggregate = allocator_fields };

        const ctx_fields = self.alloc.alloc(Value, 2) catch unreachable;
        ctx_fields[0] = allocator_val;
        ctx_fields[1] = .null_val;
        return .{ .aggregate = ctx_fields };
    }

    // ── Heap operations ────────────────────────────────────────────

    fn heapAlloc(self: *Interpreter, size: usize) Value.HeapPtr {
        const mem = self.alloc.alloc(u8, size) catch unreachable;
        @memset(mem, 0);
        const id: u32 = @intCast(self.heap.items.len);
        self.heap.append(self.alloc, mem) catch unreachable;
        return .{ .id = id };
    }

    fn heapFree(self: *Interpreter, hp: Value.HeapPtr) void {
        if (hp.id < self.heap.items.len) {
            self.alloc.free(self.heap.items[hp.id]);
            self.heap.items[hp.id] = &.{};
        }
    }

    pub fn heapSlice(self: *const Interpreter, hp: Value.HeapPtr) ?[]u8 {
        if (hp.id >= self.heap.items.len) return null;
        const mem = self.heap.items[hp.id];
        if (hp.offset >= mem.len) return null;
        return mem[hp.offset..];
    }

    fn heapMemcpy(self: *Interpreter, dst: Value.HeapPtr, src_bytes: []const u8, len: usize) void {
        const dst_mem = self.heapSlice(dst) orelse return;
        const copy_len = @min(len, @min(dst_mem.len, src_bytes.len));
        @memcpy(dst_mem[0..copy_len], src_bytes[0..copy_len]);
    }

    fn heapMemset(self: *Interpreter, dst: Value.HeapPtr, val: u8, len: usize) void {
        const dst_mem = self.heapSlice(dst) orelse return;
        const set_len = @min(len, dst_mem.len);
        @memset(dst_mem[0..set_len], val);
    }

    fn heapStoreByte(self: *Interpreter, dst: Value.HeapPtr, val: u8) void {
        const mem = self.heapSlice(dst) orelse return;
        if (mem.len > 0) mem[0] = val;
    }

    /// Look up a global value, lazy-evaluating its comptime_func if needed.
    fn getGlobal(self: *Interpreter, gid: inst_mod.GlobalId) InterpError!Value {
        const idx = gid.index();
        // Check cache first
        if (self.global_values.get(idx)) |v| return v;

        // Not cached — evaluate from global definition
        const global = &self.module.globals.items[idx];
        if (global.comptime_func) |func_id| {
            const result = try self.call(func_id, &.{});
            self.global_values.put(idx, result) catch {};
            return result;
        }
        // Static init value
        if (global.init_val) |iv| {
            const val: Value = self.constToValue(iv);
            self.global_values.put(idx, val) catch {};
            return val;
        }
        return .undef;
    }

    /// Marshal a single sx Value into a `usize` slot for a cdecl host call.
    /// Strings are made null-terminated; pointer-like values pass their
    /// underlying address. The returned `usize` is only valid for the
    /// duration of this call — caller-allocated buffers are tracked in
    /// `tmp` so they get freed once the call returns.
    fn marshalForeignArg(self: *Interpreter, v: Value, tmp: *std.ArrayList([]u8)) !usize {
        return switch (v) {
            .int => |i| @bitCast(i),
            .boolean => |b| @intFromBool(b),
            .null_val => 0,
            .byte_ptr => |addr| addr,
            .heap_ptr => |hp| blk: {
                // `heapSlice` returns the slice already advanced by `hp.offset`,
                // so its `.ptr` IS the offset address. Adding `hp.offset` again
                // double-counts and lands the foreign call past the buffer end.
                _ = self.heapSlice(hp) orelse return error.TypeError;
                break :blk @intFromPtr(self.heap.items[hp.id].ptr) + hp.offset;
            },
            .string => |s| blk: {
                const buf = try self.alloc.alloc(u8, s.len + 1);
                @memcpy(buf[0..s.len], s);
                buf[s.len] = 0;
                tmp.append(self.alloc, buf) catch return error.TypeError;
                break :blk @intFromPtr(buf.ptr);
            },
            .aggregate => |fields| blk: {
                // Fat string pointer: { ptr, len }. Pass the raw bytes
                // null-terminated so libc string APIs work.
                if (fields.len == 2) {
                    const len: usize = @intCast(fields[1].asInt() orelse return error.TypeError);
                    switch (fields[0]) {
                        .heap_ptr => |hp| {
                            const mem = self.heapSlice(hp) orelse return error.TypeError;
                            const start = hp.offset;
                            const slice = mem[start .. start + len];
                            const buf = try self.alloc.alloc(u8, len + 1);
                            @memcpy(buf[0..len], slice);
                            buf[len] = 0;
                            tmp.append(self.alloc, buf) catch return error.TypeError;
                            break :blk @intFromPtr(buf.ptr);
                        },
                        .string => |s| {
                            const slice = if (len <= s.len) s[0..len] else s;
                            const buf = try self.alloc.alloc(u8, slice.len + 1);
                            @memcpy(buf[0..slice.len], slice);
                            buf[slice.len] = 0;
                            tmp.append(self.alloc, buf) catch return error.TypeError;
                            break :blk @intFromPtr(buf.ptr);
                        },
                        // Raw host pointer (from libc_malloc-backed
                        // cstring). Read bytes from real memory and copy
                        // into a null-terminated buffer the foreign call
                        // can consume.
                        .int => |addr| {
                            const src: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(addr)));
                            const buf = try self.alloc.alloc(u8, len + 1);
                            @memcpy(buf[0..len], src[0..len]);
                            buf[len] = 0;
                            tmp.append(self.alloc, buf) catch return error.TypeError;
                            break :blk @intFromPtr(buf.ptr);
                        },
                        else => return typeErrorDetail("comptime foreign call: unsupported aggregate data-field kind (expected heap_ptr/string/int)"),
                    }
                }
                return typeErrorDetail("comptime foreign call: aggregate arg must be a {ptr, len} fat-pointer pair");
            },
            else => return typeErrorDetail("comptime foreign call: unsupported arg Value kind"),
        };
    }

    /// Append the current sx call-frame chain to the interp output, most-recent
    /// last (ERR E4.1). The topmost frame is `print_interpreter_frames` itself
    /// (the dump site), so it's skipped. Frame source locations await IR-offset
    /// resolution (the comptime analog of DWARF), so only function names print.
    fn printInterpFrames(self: *Interpreter) void {
        const n = self.call_chain.items.len;
        if (n <= 1) return;
        self.output.appendSlice(self.alloc, "comptime call frames (most recent call last):\n") catch {};
        var i: usize = 0;
        while (i < n - 1) : (i += 1) {
            const fid = self.call_chain.items[i];
            const fname = self.module.types.getString(self.module.getFunction(fid).name);
            const line = std.fmt.allocPrint(self.alloc, "  at {s}\n", .{fname}) catch continue;
            defer self.alloc.free(line);
            self.output.appendSlice(self.alloc, line) catch {};
        }
    }

    fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value {
        const name = self.module.types.getString(func.name);

        // A foreign call may not return (e.g. `process.exit` → `_exit`), which
        // would discard the interpreter's buffered `print` output (otherwise
        // flushed only after `#run` completes). Flush it first so comptime
        // diagnostics emitted just before a terminating call survive.
        if (self.output.items.len > 0) {
            _ = std.c.write(1, self.output.items.ptr, self.output.items.len);
            self.output.clearRetainingCapacity();
        }
        const symbol = (host_ffi.lookupSymbol(self.alloc, name) catch return bailDetail("comptime foreign call: dlsym error looking up symbol")) orelse {
            if (last_bail_detail == null) last_bail_detail = "comptime foreign call: symbol not found via dlsym (target-specific binding called at compile time?)";
            return error.CannotEvalComptime;
        };

        var packed_args: [8]usize = undefined;
        if (args.len > packed_args.len) return bailDetail("comptime foreign call: more than 8 args (host_ffi trampolines max out at 8)");

        var tmp = std.ArrayList([]u8).empty;
        defer {
            for (tmp.items) |buf| self.alloc.free(buf);
            tmp.deinit(self.alloc);
        }
        for (args, 0..) |a, i| {
            packed_args[i] = self.marshalForeignArg(a, &tmp) catch return error.TypeError;
        }
        const argv = packed_args[0..args.len];

        // Variadic foreign functions (declared `args: ..T`) must be
        // dispatched through C-variadic trampolines so the trailing
        // args land in the right place per the target's variadic
        // ABI. The fixed-arity trampolines would put them in arg
        // registers, and the callee would read garbage from the
        // stack.
        const fixed = func.params.len;
        const variadic = func.is_variadic and args.len > fixed;

        const ret = func.ret;
        if (ret == .void) {
            if (variadic) {
                host_ffi.callVoidRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime;
            } else {
                host_ffi.callVoidRet(symbol, argv) catch return error.CannotEvalComptime;
            }
            return .void_val;
        }
        if (ret == .s8 or ret == .s16 or ret == .s32 or ret == .s64 or
            ret == .u8 or ret == .u16 or ret == .u32 or ret == .u64 or
            ret == .usize or ret == .isize)
        {
            const r = if (variadic)
                host_ffi.callIntRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime
            else
                host_ffi.callIntRet(symbol, argv) catch return error.CannotEvalComptime;
            return Value{ .int = r };
        }
        if (ret == .bool) {
            const r = if (variadic)
                host_ffi.callIntRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime
            else
                host_ffi.callIntRet(symbol, argv) catch return error.CannotEvalComptime;
            return Value{ .boolean = r != 0 };
        }
        const r = if (variadic)
            host_ffi.callPtrRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime
        else
            host_ffi.callPtrRet(symbol, argv) catch return error.CannotEvalComptime;
        return Value{ .int = @bitCast(@as(u64, r)) };
    }

    pub fn call(self: *Interpreter, func_id: FuncId, args: []const Value) InterpError!Value {
        if (self.call_depth >= self.max_call_depth) return error.StackOverflow;
        self.call_depth += 1;
        defer self.call_depth -= 1;

        const func = self.module.getFunction(func_id);
        if (func.is_extern or func.blocks.items.len == 0) {
            // Dispatch to host libc via dlsym. Lets `#run` (and the
            // post-link bundler) call ordinary foreign symbols like
            // `puts`, `getenv`, `posix_spawn`, etc.
            return self.callForeign(func, args);
        }

        // Track the sx call chain for `trace.print_interpreter_frames()`.
        self.call_chain.append(self.alloc, func_id) catch {};
        defer _ = self.call_chain.pop();

        // Compute total refs: params + all instructions across all blocks
        var total_refs: u32 = @intCast(func.params.len);
        for (func.blocks.items) |blk| {
            total_refs += @intCast(blk.insts.items.len);
        }

        var frame = Frame.initSized(self.alloc, total_refs);
        defer frame.deinit();

        // Implicit-context bootstrap: when an entry point with implicit
        // ctx is called without an explicit ctx arg, materialise the
        // default context in a fresh slot and bind slot_ptr(0) to ref 0.
        // This is the interp-side equivalent of FFI-inbound wrappers
        // installing `&__sx_default_context` at function entry.
        var skip_first: u32 = 0;
        if (func.has_implicit_ctx and args.len + 1 == func.params.len) {
            const ctx_val = self.defaultContextValue();
            const slot = frame.allocSlot(self.alloc);
            frame.storeSlot(slot, ctx_val);
            frame.setRef(0, .{ .slot_ptr = slot });
            skip_first = 1;
        }

        // Bind parameters as initial refs (indices skip_first..N-1)
        for (args, 0..) |arg, i| {
            frame.setRef(@intCast(i + skip_first), arg);
        }

        // Start at the entry block (index 0)
        var current_block: BlockId = BlockId.fromIndex(0);
        var block_args: []const Value = &.{};

        while (true) {
            const block_idx = current_block.index();
            const block = &func.blocks.items[block_idx];
            var ref_counter: u32 = block.first_ref;

            // Bind block params (block_param instructions handle this, but we
            // also need to pre-set the values for them)
            for (block_args) |_| {
                // block_param instructions will read from frame refs when executed
                // The block_param instruction itself produces the value
            }

            for (block.insts.items) |*instruction| {
                // Special handling for block_param: bind the arg value
                if (instruction.op == .block_param) {
                    const bp = instruction.op.block_param;
                    if (bp.param_index < block_args.len) {
                        frame.setRef(ref_counter, block_args[bp.param_index]);
                    }
                    ref_counter += 1;
                    continue;
                }

                const result = self.execInst(instruction, &frame, &current_block, &block_args) catch |err| {
                    if (last_bail_op == null) {
                        last_bail_op = @tagName(instruction.op);
                        last_bail_file = func.source_file;
                        last_bail_offset = instruction.span.start;
                    }
                    return err;
                };
                switch (result) {
                    .value => |val| {
                        frame.setRef(ref_counter, val);
                        ref_counter += 1;
                    },
                    .branch => {
                        ref_counter += 1; // terminator consumes a ref slot
                        break;
                    },
                    .ret_val => |val| return val,
                    .ret_nothing => return .void_val,
                }
            } else {
                // Fell through the block with no terminator — treat as implicit return void
                return .void_val;
            }
        }
    }

    const ExecResult = union(enum) {
        value: Value,
        branch,
        ret_val: Value,
        ret_nothing,
    };

    fn execInst(self: *Interpreter, instruction: *const Inst, frame: *Frame, current_block: *BlockId, block_args: *[]const Value) InterpError!ExecResult {
        const op = instruction.op;

        switch (op) {
            // ── Constants ───────────────────────────────────────
            .const_int => |v| return .{ .value = .{ .int = v } },
            .const_float => |v| return .{ .value = .{ .float = v } },
            .const_bool => |v| return .{ .value = .{ .boolean = v } },
            .const_string => |sid| return .{ .value = .{ .string = self.module.types.getString(sid) } },
            .const_null => return .{ .value = .null_val },
            .const_undef => return .{ .value = .undef },
            .is_comptime => return .{ .value = .{ .boolean = true } },
            .interp_print_frames => {
                self.printInterpFrames();
                return .{ .value = .void_val };
            },
            .trace_frame => {
                // Comptime frame: pack (func_id, span.start) so the slice-3b
                // resolver can recover file:line:col via the IR/source tables.
                // The interp never produces a `Frame*` — only the compiled
                // backend does — so this stays a packed id, never a pointer.
                const fid: u64 = if (self.call_chain.items.len > 0)
                    self.call_chain.items[self.call_chain.items.len - 1].index()
                else
                    0;
                const packed_frame: u64 = (fid << 32) | @as(u64, instruction.span.start);
                return .{ .value = .{ .int = @bitCast(packed_frame) } };
            },
            .trace_resolve => |u| {
                // Unpack the comptime frame `(func_id << 32 | span.start)` and
                // resolve it to a `Frame { file, line, col, func }` aggregate.
                const raw: u64 = @bitCast(frame.getRef(u.operand).asInt() orelse 0);
                const fid: u32 = @intCast(raw >> 32);
                const offset: u32 = @truncate(raw);
                const func = self.module.getFunction(FuncId.fromIndex(fid));
                const func_name = self.module.types.getString(func.name);
                const file_full = func.source_file orelse "";
                const file = std.fs.path.basename(file_full);
                var line: i64 = 1;
                var col: i64 = 1;
                var line_text: []const u8 = "";
                if (self.source_map) |sm| {
                    if (sm.get(file_full)) |src| {
                        const loc = errors.SourceLoc.compute(src, offset);
                        line = @intCast(loc.line);
                        col = @intCast(loc.col);
                        line_text = errors.lineAt(src, offset);
                    }
                }
                const fields = self.alloc.alloc(Value, 5) catch return .{ .value = .undef };
                fields[0] = .{ .string = file };
                fields[1] = .{ .int = line };
                fields[2] = .{ .int = col };
                fields[3] = .{ .string = func_name };
                fields[4] = .{ .string = line_text };
                return .{ .value = .{ .aggregate = fields } };
            },
            .const_type => |tid| return .{ .value = .{ .type_tag = tid } },

            // ── Arithmetic ──────────────────────────────────────
            .add => |b| return .{ .value = try self.evalArith(frame, b, .add) },
            .sub => |b| return .{ .value = try self.evalArith(frame, b, .sub) },
            .mul => |b| return .{ .value = try self.evalArith(frame, b, .mul) },
            .div => |b| return .{ .value = try self.evalArith(frame, b, .div) },
            .mod => |b| return .{ .value = try self.evalArith(frame, b, .mod) },
            .neg => |u| {
                const val = frame.getRef(u.operand);
                return .{ .value = switch (val) {
                    .int => |v| .{ .int = -v },
                    .float => |v| .{ .float = -v },
                    else => return typeErrorDetail("comptime unary `-`: operand is neither int nor float"),
                } };
            },

            // ── Comparison ──────────────────────────────────────
            .cmp_eq => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .eq) } },
            .cmp_ne => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .ne) } },
            .cmp_lt => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .lt) } },
            .cmp_le => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .le) } },
            .cmp_gt => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .gt) } },
            .cmp_ge => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .ge) } },
            .str_eq => |b| {
                const lhs = frame.getRef(b.lhs);
                const rhs = frame.getRef(b.rhs);
                const ls = if (lhs == .string) lhs.string else "";
                const rs = if (rhs == .string) rhs.string else "";
                return .{ .value = .{ .boolean = std.mem.eql(u8, ls, rs) } };
            },
            .str_ne => |b| {
                const lhs = frame.getRef(b.lhs);
                const rhs = frame.getRef(b.rhs);
                const ls = if (lhs == .string) lhs.string else "";
                const rs = if (rhs == .string) rhs.string else "";
                return .{ .value = .{ .boolean = !std.mem.eql(u8, ls, rs) } };
            },

            // ── Logical ─────────────────────────────────────────
            .bool_and => |b| {
                const lhs = frame.getRef(b.lhs).asBool() orelse return error.TypeError;
                if (!lhs) return .{ .value = .{ .boolean = false } };
                const rhs = frame.getRef(b.rhs).asBool() orelse return error.TypeError;
                return .{ .value = .{ .boolean = rhs } };
            },
            .bool_or => |b| {
                const lhs = frame.getRef(b.lhs).asBool() orelse return error.TypeError;
                if (lhs) return .{ .value = .{ .boolean = true } };
                const rhs = frame.getRef(b.rhs).asBool() orelse return error.TypeError;
                return .{ .value = .{ .boolean = rhs } };
            },
            .bool_not => |u| {
                const val = frame.getRef(u.operand).asBool() orelse return error.TypeError;
                return .{ .value = .{ .boolean = !val } };
            },

            // ── Conversions ─────────────────────────────────────
            .widen, .narrow => |c| {
                const val = frame.getRef(c.operand);
                return .{ .value = val }; // comptime values don't truncate
            },
            .bitcast => |c| {
                const val = frame.getRef(c.operand);
                // Loud-fail on `.type_tag → <runtime kind>` casts. A Type
                // value can flow through bitcast only to .any (Any-boxing)
                // or to itself; any other destination means the lowering
                // emitted a coercion that silently pretends the TypeId is
                // some other shape (e.g. an int, or a string). The most
                // likely site that would trip this: the `case type:` arm
                // of `any_to_string` in stdlib doing `xx val to string` —
                // which expects the value field to already be a string,
                // a leftover from the pre-`type_tag` era when Type values
                // were string-shaped.
                if (val == .type_tag) {
                    const allowed = c.to == .any or c.to == c.from;
                    if (!allowed) {
                        return bailDetail("comptime bitcast: Type value cast to a non-Type runtime kind — most likely a stale `xx val to string` from the pre-type_tag era; use `type_name(val)` instead");
                    }
                }
                return .{ .value = val };
            },
            .int_to_float => |c| {
                const val = frame.getRef(c.operand);
                const i = val.asInt() orelse return error.TypeError;
                return .{ .value = .{ .float = @floatFromInt(i) } };
            },
            .float_to_int => |c| {
                const val = frame.getRef(c.operand);
                const f = val.asFloat() orelse return error.TypeError;
                return .{ .value = .{ .int = @intFromFloat(f) } };
            },

            // ── Memory (stack simulation) ───────────────────────
            .alloca => {
                const slot = frame.allocSlot(self.alloc);
                return .{ .value = .{ .slot_ptr = slot } };
            },
            .load => |u| {
                const ptr = frame.getRef(u.operand);
                switch (ptr) {
                    .slot_ptr => |slot| {
                        const slot_val = frame.loadSlot(slot);
                        // Check if this is a field pointer (from struct_gep)
                        if (self.resolveFieldLoad(frame, slot_val)) |field_val| {
                            return .{ .value = field_val };
                        }
                        return .{ .value = slot_val };
                    },
                    // The implicit __sx_ctx arrives as an aggregate after
                    // materializeCtxArg dereferences the caller's slot_ptr.
                    // `load(ref_0)` then naturally yields the Context value.
                    .aggregate => return .{ .value = ptr },
                    // Comptime load through a raw host pointer needs the
                    // target IR type to know byte width — currently not
                    // threaded into the .load op. Add it when a comptime
                    // path hits this.
                    .int => return bailDetail("comptime load through raw host pointer not supported (IR type width not threaded)"),
                    .byte_ptr => return bailDetail("comptime load through raw byte pointer not supported"),
                    .heap_ptr => return bailDetail("comptime load through interp heap pointer not supported"),
                    else => return bailDetail("comptime load: unsupported pointer kind"),
                }
            },
            .store => |s| {
                const ptr = frame.getRef(s.ptr);
                const val = frame.getRef(s.val);
                switch (ptr) {
                    .slot_ptr => |slot| {
                        const slot_val = frame.loadSlot(slot);
                        // Check if this is a field pointer (from struct_gep)
                        if (self.resolveFieldStore(frame, slot_val, val)) {
                            // Field store handled
                        } else {
                            frame.storeSlot(slot, val);
                        }
                    },
                    .heap_ptr => |hp| {
                        // Store a byte into heap memory (from index_gep on string)
                        const byte: u8 = @intCast(@as(u64, @bitCast(val.asInt() orelse return error.TypeError)) & 0xFF);
                        self.heapStoreByte(hp, byte);
                    },
                    // Raw host pointer (from foreign call, e.g. libc_malloc).
                    // `val_ty` carries the declared destination width so we
                    // write exactly that many bytes — no neighbor clobber.
                    .int => |p| {
                        try storeAtRawPtr(self, p, val, s.val_ty);
                    },
                    // Byte-granular pointer (from index_gep on a string).
                    // Always a 1-byte store — matches the heap_ptr arm.
                    .byte_ptr => |addr| {
                        const byte: u8 = @intCast(@as(u64, @bitCast(val.asInt() orelse return error.TypeError)) & 0xFF);
                        const dst: [*]u8 = @ptrFromInt(addr);
                        dst[0] = byte;
                    },
                    else => return bailDetail("comptime store: unsupported pointer kind"),
                }
                return .{ .value = .void_val };
            },

            // ── Struct ops ──────────────────────────────────────
            .struct_init => |agg| {
                const fields = self.alloc.alloc(Value, agg.fields.len) catch return error.CannotEvalComptime;
                for (agg.fields, 0..) |ref, i| {
                    fields[i] = frame.getRef(ref);
                }
                return .{ .value = .{ .aggregate = fields } };
            },
            .struct_get => |fa| {
                var base = frame.getRef(fa.base);
                // Auto-deref slot_ptr → load the value
                if (base == .slot_ptr) {
                    const loaded = frame.loadSlot(base.slot_ptr);
                    if (self.resolveFieldLoad(frame, loaded)) |resolved| {
                        base = resolved;
                    } else {
                        base = loaded;
                    }
                }
                switch (base) {
                    .aggregate => |fields| {
                        if (fa.field_index >= fields.len) return error.OutOfBounds;
                        return .{ .value = fields[fa.field_index] };
                    },
                    .string => |s| {
                        // String as fat pointer: field 0 = ptr (string), field 1 = len
                        if (fa.field_index == 0) return .{ .value = .{ .string = s } };
                        if (fa.field_index == 1) return .{ .value = .{ .int = @intCast(s.len) } };
                        return error.OutOfBounds;
                    },
                    .int => |v| {
                        // Scalar boxed as "struct" — field 0 is the value itself
                        if (fa.field_index == 0) return .{ .value = .{ .int = v } };
                        return error.OutOfBounds;
                    },
                    else => return typeErrorDetail("comptime struct_get: base has no fields (not an aggregate/string/int)"),
                }
            },

            // ── Enum ops ────────────────────────────────────────
            .enum_init => |ei| {
                if (ei.payload.isNone()) {
                    return .{ .value = .{ .int = @intCast(ei.tag) } };
                } else {
                    const payload = frame.getRef(ei.payload);
                    const fields = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime;
                    fields[0] = .{ .int = @intCast(ei.tag) };
                    fields[1] = payload;
                    return .{ .value = .{ .aggregate = fields } };
                }
            },
            .enum_tag => |u| {
                const val = frame.getRef(u.operand);
                switch (val) {
                    .int => return .{ .value = val },
                    .aggregate => |fields| {
                        if (fields.len == 0) return typeErrorDetail("comptime enum_tag: aggregate operand has zero fields");
                        return .{ .value = fields[0] };
                    },
                    else => return typeErrorDetail("comptime enum_tag: operand is neither an int (untagged enum) nor an aggregate (tagged union)"),
                }
            },
            .enum_payload => |fa| {
                const base = frame.getRef(fa.base);
                switch (base) {
                    .aggregate => |fields| {
                        if (fa.field_index + 1 >= fields.len) return error.OutOfBounds;
                        return .{ .value = fields[fa.field_index + 1] };
                    },
                    else => return typeErrorDetail("comptime enum_payload: base is not a tagged-union aggregate"),
                }
            },

            // ── Optional ops ────────────────────────────────────
            .optional_wrap => |u| {
                const val = frame.getRef(u.operand);
                return .{ .value = val }; // wrapped value is just the value
            },
            .optional_unwrap => |u| {
                const val = frame.getRef(u.operand);
                if (val.isNull()) return error.TypeError; // unwrapping null
                return .{ .value = val };
            },
            .optional_has_value => |u| {
                const val = frame.getRef(u.operand);
                return .{ .value = .{ .boolean = !val.isNull() } };
            },
            .optional_coalesce => |b| {
                const lhs = frame.getRef(b.lhs);
                if (!lhs.isNull()) return .{ .value = lhs };
                return .{ .value = frame.getRef(b.rhs) };
            },

            // ── Calls ───────────────────────────────────────────
            .call => |c| {
                const args = self.alloc.alloc(Value, c.args.len) catch return error.CannotEvalComptime;
                defer self.alloc.free(args);
                for (c.args, 0..) |ref, i| {
                    // Inline any slot_ptr field-refs in the caller's frame before
                    // the value crosses the call boundary. slot_ptr indices are
                    // frame-local; if a slice/aggregate carrying one is passed to
                    // the callee, the callee would later resolve the index against
                    // its own slot table and read garbage.
                    args[i] = self.materializeForCall(frame, frame.getRef(ref));
                }
                // The implicit __sx_ctx is logically a `*Context` but the
                // interp can't dereference cross-frame slot_ptrs. Materialise
                // args[0] to the loaded Context aggregate so the callee can
                // treat its slot 0 as the value directly.
                const callee_func = self.module.getFunction(c.callee);
                if (callee_func.has_implicit_ctx and args.len >= 1) {
                    args[0] = self.materializeCtxArg(frame, args[0]);
                }
                const result = try self.call(c.callee, args);
                return .{ .value = result };
            },

            // The Obj-C runtime isn't available at comptime; any
            // `#objc_call` reached during `#run` execution can't
            // resolve. Fail fast so callers see a useful diagnostic.
            .objc_msg_send => return bailDetail("#objc_call not available at comptime (no Obj-C runtime)"),
            // Same story for JNI — no JVM at compile time.
            .jni_msg_send => return bailDetail("#jni_call not available at comptime (no JVM)"),

            // ── Block params ────────────────────────────────────
            .block_param => {
                // Block params are pushed at the start of block execution.
                // This instruction is a no-op; the value was already pushed
                // during block arg binding.
                return .{ .value = .void_val };
            },

            // ── Terminators ─────────────────────────────────────
            .br => |b| {
                const args = self.alloc.alloc(Value, b.args.len) catch return error.CannotEvalComptime;
                for (b.args, 0..) |ref, i| {
                    args[i] = frame.getRef(ref);
                }
                current_block.* = b.target;
                block_args.* = args;
                return .branch;
            },
            .cond_br => |cb| {
                const cond = frame.getRef(cb.cond).asBool() orelse return error.TypeError;
                if (cond) {
                    const args = self.alloc.alloc(Value, cb.then_args.len) catch return error.CannotEvalComptime;
                    for (cb.then_args, 0..) |ref, i| {
                        args[i] = frame.getRef(ref);
                    }
                    current_block.* = cb.then_target;
                    block_args.* = args;
                } else {
                    const args = self.alloc.alloc(Value, cb.else_args.len) catch return error.CannotEvalComptime;
                    for (cb.else_args, 0..) |ref, i| {
                        args[i] = frame.getRef(ref);
                    }
                    current_block.* = cb.else_target;
                    block_args.* = args;
                }
                return .branch;
            },
            .switch_br => |sb| {
                const operand = frame.getRef(sb.operand).asInt() orelse return error.TypeError;
                for (sb.cases) |case| {
                    if (operand == case.value) {
                        const args = self.alloc.alloc(Value, case.args.len) catch return error.CannotEvalComptime;
                        for (case.args, 0..) |ref, i| {
                            args[i] = frame.getRef(ref);
                        }
                        current_block.* = case.target;
                        block_args.* = args;
                        return .branch;
                    }
                }
                // Default
                const args = self.alloc.alloc(Value, sb.default_args.len) catch return error.CannotEvalComptime;
                for (sb.default_args, 0..) |ref, i| {
                    args[i] = frame.getRef(ref);
                }
                current_block.* = sb.default;
                block_args.* = args;
                return .branch;
            },
            .ret => |u| {
                return .{ .ret_val = frame.getRef(u.operand) };
            },
            .ret_void => return .ret_nothing,
            .@"unreachable" => return error.Unreachable,

            // ── Builtin calls ──────────────────────────────────
            .call_builtin => |bi| {
                return self.execBuiltin(bi, frame, instruction.ty);
            },

            // ── Compiler hook calls (#compiler methods) ────────
            .compiler_call => |cc| {
                const name = self.module.types.getString(@enumFromInt(cc.name));
                if (self.hooks.get(name)) |hook| {
                    // Resolve args from Ref to Value
                    var resolved_args = std.ArrayList(Value).empty;
                    defer resolved_args.deinit(self.alloc);
                    for (cc.args) |arg| {
                        resolved_args.append(self.alloc, frame.getRef(arg)) catch return error.CannotEvalComptime;
                    }
                    if (self.build_config) |bc| {
                        const result = hook(self, resolved_args.items, bc, self.alloc) catch return bailDetail("#compiler hook returned an error (see hook impl)");
                        return .{ .value = result };
                    }
                    return .{ .value = .void_val };
                }
                if (last_bail_detail == null) {
                    // Capture which hook name failed so the host diag
                    // surfaces "compiler_call: unknown hook 'X'" instead
                    // of a bare CannotEvalComptime.
                    last_bail_detail = "#compiler hook not registered (likely a target-specific BuildOptions setter)";
                }
                return error.CannotEvalComptime;
            },

            // ── Struct GEP (field pointer) ─────────────────────
            .struct_gep => |fa| {
                const base = frame.getRef(fa.base);
                switch (base) {
                    .slot_ptr => |slot| {
                        // Create a field-pointer: we encode as a slot_ptr with field info
                        // When loading, we extract the field; when storing, we modify the field
                        const field_slot = frame.allocSlot(self.alloc);
                        // Store a field reference: { parent_slot, field_index }
                        const field_ref = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime;
                        field_ref[0] = .{ .int = @intCast(slot) };
                        field_ref[1] = .{ .int = @intCast(fa.field_index) };
                        frame.storeSlot(field_slot, .{ .aggregate = field_ref });
                        return .{ .value = .{ .slot_ptr = field_slot } };
                    },
                    // struct_gep through a raw host pointer requires the
                    // struct's field-offset table — feasible via
                    // `fa.base_type` but not currently wired. Add when a
                    // comptime path hits this.
                    .int => return bailDetail("comptime struct_gep through raw host pointer not supported"),
                    .byte_ptr => return bailDetail("comptime struct_gep through raw byte pointer not supported"),
                    .heap_ptr => return bailDetail("comptime struct_gep through interp heap pointer not supported"),
                    else => return bailDetail("comptime struct_gep: unsupported pointer kind"),
                }
            },

            // ── String/slice operations ────────────────────────
            .index_get => |idx| {
                const base = frame.getRef(idx.lhs);
                const index_val = frame.getRef(idx.rhs);
                const i: usize = @intCast(index_val.asInt() orelse return error.TypeError);
                // Try as string value
                if (base.asString(self)) |s| {
                    if (i >= s.len) return error.OutOfBounds;
                    return .{ .value = .{ .int = s[i] } };
                }
                // Try as aggregate array or slice
                switch (base) {
                    .aggregate => |fields| {
                        // Check for slice-like: {data_ptr, len} where data_ptr is slot_ptr
                        if (fields.len == 2 and fields[1] == .int) {
                            const data = fields[0];
                            if (data == .slot_ptr) {
                                // The data field is a ptr — resolve through slots to get the array
                                const arr = self.resolveSlotChain(frame, data);
                                switch (arr) {
                                    .aggregate => |arr_fields| {
                                        if (i < arr_fields.len) return .{ .value = arr_fields[i] };
                                        return error.OutOfBounds;
                                    },
                                    else => {},
                                }
                            } else if (data == .aggregate) {
                                // Inline array data
                                const arr_fields = data.aggregate;
                                if (i < arr_fields.len) return .{ .value = arr_fields[i] };
                                return error.OutOfBounds;
                            }
                        }
                        // Plain aggregate indexing
                        if (i >= fields.len) return error.OutOfBounds;
                        return .{ .value = fields[i] };
                    },
                    // Raw host pointer base — `buf[i]` reads one byte at
                    // offset i. Matches the byte-addressed `index_gep`
                    // semantics for the same shape. Used by comptime sx
                    // code that walks libc-malloc'd buffers.
                    .int => |p| {
                        const src: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(p)));
                        return .{ .value = .{ .int = src[i] } };
                    },
                    .byte_ptr => |addr| {
                        const src: [*]const u8 = @ptrFromInt(addr);
                        return .{ .value = .{ .int = src[i] } };
                    },
                    else => return bailDetail("comptime index_get: unsupported base kind"),
                }
            },
            .length => |u| {
                const val = frame.getRef(u.operand);
                if (val.asString(self)) |s| {
                    return .{ .value = .{ .int = @intCast(s.len) } };
                }
                switch (val) {
                    .aggregate => |fields| {
                        // For fat pointers {ptr, len}, len is field[1]
                        if (fields.len == 2) {
                            return .{ .value = fields[1] };
                        }
                        return .{ .value = .{ .int = @intCast(fields.len) } };
                    },
                    else => return bailDetail("comptime .len: operand is neither a string nor an aggregate"),
                }
            },
            .data_ptr => |u| {
                const val = frame.getRef(u.operand);
                switch (val) {
                    .aggregate => |fields| {
                        if (fields.len >= 1) return .{ .value = fields[0] };
                        return error.OutOfBounds;
                    },
                    .string => return .{ .value = val },
                    else => return bailDetail("comptime .ptr: operand has no data field (not a string or slice aggregate)"),
                }
            },
            .subslice => |sub| {
                const base = frame.getRef(sub.base);
                const lo_val = frame.getRef(sub.lo);
                const hi_val = frame.getRef(sub.hi);
                const lo: usize = @intCast(lo_val.asInt() orelse return error.TypeError);
                const hi: usize = @intCast(hi_val.asInt() orelse return error.TypeError);
                if (base.asString(self)) |s| {
                    if (hi > s.len) return error.OutOfBounds;
                    return .{ .value = .{ .string = s[lo..hi] } };
                }
                return bailDetail("comptime subslice: base is not a string-backed value (slice over non-string aggregates not yet supported)");
            },

            // ── Addr/deref ─────────────────────────────────────
            .addr_of => |u| {
                const val = frame.getRef(u.operand);
                return .{ .value = val }; // pass through pointer-like values
            },
            .deref => |u| {
                const val = frame.getRef(u.operand);
                switch (val) {
                    .slot_ptr => |slot| return .{ .value = frame.loadSlot(slot) },
                    // Real raw-memory deref needs val's IR type for byte
                    // width — not yet threaded. Erroring is safer than
                    // returning the pointer-as-int unchanged, which
                    // silently looks like a successful deref.
                    .int => return bailDetail("comptime deref through raw host pointer not supported (IR type width not threaded)"),
                    .byte_ptr => return bailDetail("comptime deref through raw byte pointer not supported"),
                    .heap_ptr => return bailDetail("comptime deref through interp heap pointer not supported"),
                    // Pre-dereferenced values that flow through deref as a
                    // no-op: an aggregate/string already IS the loaded
                    // value (lowering sometimes emits `deref(struct_val)`
                    // where the struct was previously materialized in
                    // place rather than via a slot).
                    .aggregate, .string => return .{ .value = val },
                    // Null deref is UB at runtime; surface it at comptime
                    // instead of silently producing a null again.
                    .null_val => return bailDetail("comptime deref of null"),
                    // Scalars / handles / undef aren't pointer-shaped —
                    // dereffing them is a frontend bug. Bail rather than
                    // returning the bare value (which looked like a
                    // successful deref to callers).
                    .boolean, .float, .func_ref, .closure, .type_tag, .void_val, .undef => return bailDetail("comptime deref: operand is not a pointer"),
                }
            },

            // ── Bitwise operations ─────────────────────────────
            .bit_and => |b| {
                const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError;
                const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError;
                return .{ .value = .{ .int = lhs & rhs } };
            },
            .bit_or => |b| {
                const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError;
                const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError;
                return .{ .value = .{ .int = lhs | rhs } };
            },
            .bit_xor => |b| {
                const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError;
                const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError;
                return .{ .value = .{ .int = lhs ^ rhs } };
            },
            .bit_not => |u| {
                const val = frame.getRef(u.operand).asInt() orelse return error.TypeError;
                return .{ .value = .{ .int = ~val } };
            },
            .shl => |b| {
                const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError;
                const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError;
                const shift: u6 = @intCast(@min(rhs, 63));
                return .{ .value = .{ .int = lhs << shift } };
            },
            .shr => |b| {
                const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError;
                const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError;
                const shift: u6 = @intCast(@min(rhs, 63));
                return .{ .value = .{ .int = lhs >> shift } };
            },

            // ── Tuple ops (same as struct) ─────────────────────
            .tuple_init => |agg| {
                const fields = self.alloc.alloc(Value, agg.fields.len) catch return error.CannotEvalComptime;
                for (agg.fields, 0..) |ref, i| {
                    fields[i] = frame.getRef(ref);
                }
                return .{ .value = .{ .aggregate = fields } };
            },
            .tuple_get => |fa| {
                const base = frame.getRef(fa.base);
                switch (base) {
                    .aggregate => |fields| {
                        if (fa.field_index >= fields.len) return error.OutOfBounds;
                        return .{ .value = fields[fa.field_index] };
                    },
                    else => return error.TypeError,
                }
            },

            // ── Box/unbox (Any type) ───────────────────────────
            .box_any => |ba| {
                const val = frame.getRef(ba.operand);
                // Box as aggregate: { type_tag, value } — matches LLVM layout
                const fields = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime;
                fields[0] = .{ .int = @intFromEnum(ba.source_type) };
                fields[1] = val;
                return .{ .value = .{ .aggregate = fields } };
            },
            .unbox_any => |ua| {
                const val = frame.getRef(ua.operand);
                switch (val) {
                    .aggregate => |fields| {
                        // Value is at field 1 in { tag, value } layout
                        if (fields.len >= 2) return .{ .value = fields[1] };
                        if (fields.len >= 1) return .{ .value = fields[0] };
                        return error.OutOfBounds;
                    },
                    // Any-typed comptime values flow through box_any first,
                    // which always wraps as an aggregate. If we reach here
                    // with a scalar / undef / null, the IR shape upstream
                    // diverged from the box_any contract — bail loudly so
                    // the offending box_any site shows in the diagnostic.
                    .int, .float, .boolean, .string, .null_val, .undef => return bailDetail("comptime unbox_any: operand is a bare scalar (expected { tag, value } aggregate from box_any)"),
                    .void_val => return bailDetail("comptime unbox_any: operand is void_val"),
                    .slot_ptr, .heap_ptr, .byte_ptr, .func_ref, .closure, .type_tag => return bailDetail("comptime unbox_any: operand is a pointer/handle (expected { tag, value } aggregate)"),
                }
            },

            // ── Reflection ─────────────────────────────────────
            .field_name_get => |fr| {
                const idx_val = frame.getRef(fr.index);
                const idx: usize = @intCast(switch (idx_val) {
                    .int => |i| i,
                    else => return bailDetail("comptime field_name(T, i): index operand is not an int"),
                });
                const info = self.module.types.get(fr.struct_type);
                const fields = switch (info) {
                    .@"struct" => |s| s.fields,
                    .@"union" => |u| u.fields,
                    .tagged_union => |u| u.fields,
                    else => return bailDetail("comptime field_name(T, i): T is not a struct/union/tagged_union"),
                };
                if (idx >= fields.len) return error.OutOfBounds;
                const name = self.module.types.getString(fields[idx].name);
                return .{ .value = .{ .string = name } };
            },
            .error_tag_name_get => |u| {
                const tag_val = frame.getRef(u.operand);
                const id: u32 = @intCast(switch (tag_val) {
                    .int => |i| i,
                    else => return bailDetail("comptime error_tag_name(e): operand is not an integer tag id"),
                });
                return .{ .value = .{ .string = self.module.types.tags.getName(id) } };
            },
            .field_value_get => |fr| {
                const base_val = frame.getRef(fr.base);
                const idx_val = frame.getRef(fr.index);
                const idx: usize = @intCast(switch (idx_val) {
                    .int => |i| i,
                    else => return bailDetail("comptime field_value(s, i): index operand is not an int"),
                });
                switch (base_val) {
                    .aggregate => |agg| {
                        if (idx >= agg.len) return error.OutOfBounds;
                        // Box as Any: { value, type_tag }
                        const info = self.module.types.get(fr.struct_type);
                        const fields = switch (info) {
                            .@"struct" => |s| s.fields,
                            .@"union" => |u| u.fields,
                            .tagged_union => |u| u.fields,
                            else => return bailDetail("comptime field_value(s, i): s's type is not a struct/union/tagged_union"),
                        };
                        const field_ty_tag: i64 = if (idx < fields.len) @intFromEnum(fields[idx].ty) else 0;
                        const boxed = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime;
                        boxed[0] = agg[idx];
                        boxed[1] = .{ .int = field_ty_tag };
                        return .{ .value = .{ .aggregate = boxed } };
                    },
                    else => return bailDetail("comptime field_value(s, i): s is not an aggregate Value (struct values must be materialized as aggregates at comptime)"),
                }
            },

            // ── Global access ──────────────────────────────────
            .global_get => |gid| {
                const val = try self.getGlobal(gid);
                return .{ .value = val };
            },
            .global_addr => |gid| {
                // The implicit-context default global is the only global
                // whose address sees runtime use. Return the Context
                // aggregate directly so `load(args[0])` yields it via the
                // aggregate-passthrough branch of the `.load` handler.
                if (gid.index() < self.module.globals.items.len) {
                    const global = &self.module.globals.items[gid.index()];
                    const name = self.module.types.getString(global.name);
                    if (std.mem.eql(u8, name, "__sx_default_context")) {
                        return .{ .value = self.defaultContextValue() };
                    }
                }
                return bailDetail("comptime global_addr: only `&__sx_default_context` is currently materialised at comptime");
            },
            .func_ref => |fid| {
                return .{ .value = .{ .func_ref = fid } };
            },
            .global_set => |gs| {
                const val = frame.getRef(gs.value);
                self.global_values.put(gs.global.index(), val) catch {};
                return .{ .value = .void_val };
            },

            // ── Index GEP (array element pointer) ─────────────
            .index_gep => |b| {
                const base = frame.getRef(b.lhs);
                const idx = frame.getRef(b.rhs);
                switch (base) {
                    .slot_ptr => |slot| {
                        // Create an indexed element pointer: { parent_slot, index, is_index_gep=1 }
                        const field_slot = frame.allocSlot(self.alloc);
                        const ref = self.alloc.alloc(Value, 3) catch return error.CannotEvalComptime;
                        ref[0] = .{ .int = @intCast(slot) };
                        ref[1] = idx;
                        ref[2] = .{ .int = 1 }; // marker: this is index_gep, not struct_gep
                        frame.storeSlot(field_slot, .{ .aggregate = ref });
                        return .{ .value = .{ .slot_ptr = field_slot } };
                    },
                    .aggregate => |fields| {
                        // String/slice aggregate {data_ptr, len} — compute data_ptr + index
                        if (fields.len >= 2) {
                            const data_ptr = fields[0];
                            const offset = idx.asInt() orelse return error.TypeError;
                            switch (data_ptr) {
                                .heap_ptr => |hp| {
                                    return .{ .value = .{ .heap_ptr = .{
                                        .id = hp.id,
                                        .offset = hp.offset + @as(u32, @intCast(offset)),
                                    } } };
                                },
                                // Raw host pointer (from foreign call return,
                                // e.g. libc_malloc). Byte-addressed offset
                                // matches the heap_ptr branch above — both
                                // are u8-granular for sx's string/slice ops.
                                // Producing `.byte_ptr` makes store-through
                                // this address write a single byte.
                                .int => |p| {
                                    return .{ .value = .{ .byte_ptr = @intCast(p + offset) } };
                                },
                                else => {},
                            }
                        }
                        return bailDetail("comptime index_gep: unsupported aggregate-base shape (expected {data_ptr, len} with heap_ptr or int data field)");
                    },
                    .string => |s| {
                        // String literal — copy to heap and return heap_ptr at offset
                        const offset: usize = @intCast(@as(u64, @bitCast(idx.asInt() orelse return error.TypeError)));
                        const hp = self.heapAlloc(s.len);
                        self.heapMemcpy(hp, s, s.len);
                        return .{ .value = .{ .heap_ptr = .{
                            .id = hp.id,
                            .offset = @intCast(offset),
                        } } };
                    },
                    // Raw host pointer base — byte-addressed offset.
                    // Element size > 1 would silently mis-index; document
                    // the assumption. Callers stride past byte granularity
                    // must wrap the pointer in an aggregate so the
                    // {data_ptr, len} branch fires (which is also
                    // byte-addressed today — fix here when needed).
                    .int => |p| {
                        const offset = idx.asInt() orelse return error.TypeError;
                        return .{ .value = .{ .int = p + offset } };
                    },
                    else => return bailDetail("comptime index_gep: unsupported base kind"),
                }
            },

            // ── Array to slice ────────────────────────────────
            .array_to_slice => |u| {
                const val = frame.getRef(u.operand);
                switch (val) {
                    .aggregate => |fields| {
                        // Convert array aggregate to slice: { aggregate_ref, len }
                        const slice = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime;
                        slice[0] = val; // the array data
                        slice[1] = .{ .int = @intCast(fields.len) };
                        return .{ .value = .{ .aggregate = slice } };
                    },
                    .slot_ptr => |slot| {
                        const arr = frame.loadSlot(slot);
                        switch (arr) {
                            .aggregate => |fields| {
                                const slice = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime;
                                slice[0] = arr;
                                slice[1] = .{ .int = @intCast(fields.len) };
                                return .{ .value = .{ .aggregate = slice } };
                            },
                            else => return bailDetail("comptime array_to_slice: slot-backed value is not an aggregate"),
                        }
                    },
                    else => return bailDetail("comptime array_to_slice: operand is neither an aggregate nor a slot pointer"),
                }
            },

            // ── Call indirect (function pointer) ──────────────
            .call_indirect => |ci| {
                const callee = frame.getRef(ci.callee);
                switch (callee) {
                    .func_ref => |fid| {
                        const args = self.alloc.alloc(Value, ci.args.len) catch return error.CannotEvalComptime;
                        defer self.alloc.free(args);
                        for (ci.args, 0..) |ref, i| {
                            args[i] = self.materializeForCall(frame, frame.getRef(ref));
                        }
                        const target = self.module.getFunction(fid);
                        if (target.has_implicit_ctx and args.len >= 1) {
                            args[0] = self.materializeCtxArg(frame, args[0]);
                        }
                        const result = try self.call(fid, args);
                        return .{ .value = result };
                    },
                    else => return bailDetail("comptime call_indirect: callee is not a func_ref Value (raw fn-pointers from foreign calls aren't dispatchable in interp)"),
                }
            },

            // Type-as-value sentinel emitted for the type arg of
            // `cast(T) val`. Result is never read (the cast lowering
            // consumes the type from the AST, not the IR Ref), so an
            // undef value is sufficient — matches the LLVM emitter.
            .placeholder => return .{ .value = .undef },

            // ── Not yet evaluable at comptime ──────────────────
            .call_closure => return bailDetail("comptime call_closure not yet implemented (closure trampoline ABI threading required)"),
            .closure_create => return bailDetail("comptime closure_create not yet implemented"),
            .union_get => return bailDetail("comptime union_get not yet implemented"),
            .union_gep => return bailDetail("comptime union_gep not yet implemented"),
            .vec_splat => return bailDetail("comptime vec_splat not yet implemented"),
            .vec_extract => return bailDetail("comptime vec_extract not yet implemented"),
            .vec_insert => return bailDetail("comptime vec_insert not yet implemented"),
        }
    }

    // ── Arithmetic helpers ──────────────────────────────────────────

    const ArithOp = enum { add, sub, mul, div, mod };

    fn evalArith(self: *Interpreter, frame: *Frame, b: inst_mod.BinOp, comptime aop: ArithOp) InterpError!Value {
        _ = self;
        const lhs = frame.getRef(b.lhs);
        const rhs = frame.getRef(b.rhs);

        // Both int
        if (lhs.asInt()) |li| {
            if (rhs.asInt()) |ri| {
                return .{ .int = switch (aop) {
                    .add => li +% ri,
                    .sub => li -% ri,
                    .mul => li *% ri,
                    .div => if (ri == 0) return error.DivisionByZero else @divTrunc(li, ri),
                    .mod => if (ri == 0) return error.DivisionByZero else @mod(li, ri),
                } };
            }
        }

        // Both float (or int promoted to float)
        if (lhs.asFloat()) |lf| {
            if (rhs.asFloat()) |rf| {
                return .{ .float = switch (aop) {
                    .add => lf + rf,
                    .sub => lf - rf,
                    .mul => lf * rf,
                    .div => if (rf == 0.0) return error.DivisionByZero else lf / rf,
                    .mod => @mod(lf, rf),
                } };
            }
        }

        return typeErrorDetail("comptime arithmetic: operand pair is neither both-int nor both-float-coercible");
    }

    // ── Comparison helpers ──────────────────────────────────────────

    const CmpOp = enum { eq, ne, lt, le, gt, ge };

    fn evalCmp(self: *Interpreter, frame: *Frame, b: inst_mod.BinOp, comptime cop: CmpOp) InterpError!bool {
        _ = self;
        const lhs = frame.getRef(b.lhs);
        const rhs = frame.getRef(b.rhs);

        // Both int
        if (lhs.asInt()) |li| {
            if (rhs.asInt()) |ri| {
                return switch (cop) {
                    .eq => li == ri,
                    .ne => li != ri,
                    .lt => li < ri,
                    .le => li <= ri,
                    .gt => li > ri,
                    .ge => li >= ri,
                };
            }
        }

        // Both float
        if (lhs.asFloat()) |lf| {
            if (rhs.asFloat()) |rf| {
                return switch (cop) {
                    .eq => lf == rf,
                    .ne => lf != rf,
                    .lt => lf < rf,
                    .le => lf <= rf,
                    .gt => lf > rf,
                    .ge => lf >= rf,
                };
            }
        }

        // Bool equality
        if (lhs.asBool()) |lb| {
            if (rhs.asBool()) |rb| {
                return switch (cop) {
                    .eq => lb == rb,
                    .ne => lb != rb,
                    else => return error.TypeError,
                };
            }
        }

        // Type-as-value equality. Compares TypeIds structurally.
        // `.type_tag` vs `.int(N)` deliberately does NOT compare —
        // a Type is not an int even if the underlying enum value
        // matches; falls through to the typeErrorDetail below.
        if (lhs.asTypeId()) |la| {
            if (rhs.asTypeId()) |ra| {
                return switch (cop) {
                    .eq => la == ra,
                    .ne => la != ra,
                    else => return error.TypeError,
                };
            }
        }

        return typeErrorDetail("comptime comparison: operand pair has no shared comparable shape (int/float/bool/string/type)");
    }

    // ── Slot chain resolution ────────────────────────────────────

    /// Walk an aggregate Value and rewrite any embedded `slot_ptr` that points
    /// to a field-ref slot in `frame` (the marker shape `{parent_slot, idx, ..}`
    /// emitted by `struct_gep` / `index_gep`) into the resolved parent value.
    /// Slot indices are frame-local; a slice passed across a call would otherwise
    /// read its data_ptr out of the callee's slot table.
    /// Resolve the implicit __sx_ctx arg to its loaded Context value so
    /// callees can treat their own slot 0 as the aggregate directly
    /// (no cross-frame slot_ptr indirection).
    fn materializeCtxArg(self: *Interpreter, frame: *Frame, val: Value) Value {
        _ = self;
        return switch (val) {
            .slot_ptr => |slot| frame.loadSlot(slot),
            else => val,
        };
    }

    fn materializeForCall(self: *Interpreter, frame: *Frame, val: Value) Value {
        switch (val) {
            .aggregate => |fields| {
                const new_fields = self.alloc.alloc(Value, fields.len) catch return val;
                for (fields, 0..) |f, i| {
                    new_fields[i] = self.materializeForCall(frame, f);
                }
                return .{ .aggregate = new_fields };
            },
            .slot_ptr => |slot| {
                const stored = frame.loadSlot(slot);
                if (stored == .aggregate) {
                    const ref_fields = stored.aggregate;
                    if (ref_fields.len >= 2) {
                        const parent_slot_val = ref_fields[0].asInt() orelse return val;
                        if (ref_fields[1].asInt() == null) return val;
                        const parent_slot: u32 = @intCast(parent_slot_val);
                        const parent = frame.loadSlot(parent_slot);
                        return self.materializeForCall(frame, parent);
                    }
                }
                return val;
            },
            else => return val,
        }
    }

    /// Follow a slot_ptr through field-pointer / index-gep chains
    /// to get the underlying value. Handles nested dereferences.
    fn resolveSlotChain(self: *Interpreter, frame: *Frame, val: Value) Value {
        _ = self;
        var current = val;
        var depth: u32 = 0;
        while (depth < 16) : (depth += 1) {
            switch (current) {
                .slot_ptr => |slot| {
                    const stored = frame.loadSlot(slot);
                    switch (stored) {
                        .aggregate => |ref_fields| {
                            if (ref_fields.len >= 2) {
                                // Field-pointer or index-gep reference: {parent_slot, index, [marker]}
                                const parent_slot_val = ref_fields[0].asInt() orelse return stored;
                                const parent_slot: u32 = @intCast(parent_slot_val);
                                const parent = frame.loadSlot(parent_slot);
                                return parent; // Return the parent array/struct
                            }
                            return stored;
                        },
                        .slot_ptr => {
                            current = stored;
                            continue;
                        },
                        else => return stored,
                    }
                },
                else => return current,
            }
        }
        return current;
    }

    // ── Constant → Value conversion ─────────────────────────────

    fn constToValue(self: *Interpreter, cv: inst_mod.ConstantValue) Value {
        return switch (cv) {
            .int => |v| .{ .int = v },
            .float => |v| .{ .float = v },
            .boolean => |v| .{ .boolean = v },
            .string => |sid| .{ .string = self.module.types.getString(sid) },
            .null_val => .null_val,
            .undef, .zeroinit => .undef,
            .aggregate => |items| {
                const fields = self.alloc.alloc(Value, items.len) catch return .undef;
                for (items, 0..) |item, i| {
                    fields[i] = self.constToValue(item);
                }
                return .{ .aggregate = fields };
            },
            .vtable => |func_ids| {
                // Vtable is a struct of function refs — represent as aggregate of func_ref values
                const fields = self.alloc.alloc(Value, func_ids.len) catch return .undef;
                for (func_ids, 0..) |fid, i| {
                    fields[i] = .{ .func_ref = fid };
                }
                return .{ .aggregate = fields };
            },
            .func_ref => |fid| .{ .func_ref = fid },
        };
    }

    // ── Field pointer helpers (for struct_gep load/store) ─────────

    /// Check if a slot value is a field pointer { parent_slot, field_index [, is_index_gep] }.
    /// If so, load the parent aggregate and return the field value.
    fn resolveFieldLoad(self: *Interpreter, frame: *Frame, slot_val: Value) ?Value {
        _ = self;
        switch (slot_val) {
            .aggregate => |fields| {
                if (fields.len >= 2) {
                    const parent_slot_val = fields[0].asInt() orelse return null;
                    const field_idx_val = fields[1].asInt() orelse return null;
                    // A real field-pointer's parent_slot is a small frame
                    // index; a struct aggregate whose first field happens
                    // to be a wide integer (e.g. a stored pointer-as-int
                    // or a u64) would otherwise mis-trigger this branch.
                    if (parent_slot_val < 0 or parent_slot_val > std.math.maxInt(u32)) return null;
                    if (field_idx_val < 0 or field_idx_val > std.math.maxInt(u32)) return null;
                    const parent_slot: u32 = @intCast(parent_slot_val);
                    const field_idx: usize = @intCast(field_idx_val);
                    const parent = frame.loadSlot(parent_slot);
                    switch (parent) {
                        .aggregate => |parent_fields| {
                            if (field_idx < parent_fields.len) return parent_fields[field_idx];
                        },
                        .string => |s| {
                            // String fat pointer: field 0 = ptr (as string), field 1 = len
                            if (field_idx == 0) return .{ .string = s };
                            if (field_idx == 1) return .{ .int = @intCast(s.len) };
                        },
                        else => {},
                    }
                }
            },
            else => {},
        }
        return null;
    }

    /// Check if a slot value is a field pointer. If so, modify the field
    /// in the parent aggregate. Returns true if handled.
    fn resolveFieldStore(self: *Interpreter, frame: *Frame, slot_val: Value, new_val: Value) bool {
        switch (slot_val) {
            .aggregate => |fields| {
                if (fields.len >= 2) {
                    const parent_slot_val = fields[0].asInt() orelse return false;
                    const field_idx_val = fields[1].asInt() orelse return false;
                    // Same field-pointer-vs-real-struct disambiguation as
                    // resolveFieldLoad — a wide integer in fields[0] is a
                    // stored pointer, not a frame index.
                    if (parent_slot_val < 0 or parent_slot_val > std.math.maxInt(u32)) return false;
                    if (field_idx_val < 0 or field_idx_val > std.math.maxInt(u32)) return false;
                    const parent_slot: u32 = @intCast(parent_slot_val);
                    const field_idx: usize = @intCast(field_idx_val);
                    const parent = frame.loadSlot(parent_slot);
                    switch (parent) {
                        .aggregate => |parent_fields| {
                            const new_len = @max(field_idx + 1, parent_fields.len);
                            const new_fields = self.alloc.alloc(Value, new_len) catch return false;
                            @memcpy(new_fields[0..parent_fields.len], parent_fields);
                            for (new_fields[parent_fields.len..]) |*f| f.* = .undef;
                            new_fields[field_idx] = new_val;
                            frame.storeSlot(parent_slot, .{ .aggregate = new_fields });
                            return true;
                        },
                        .undef => {
                            // Initialize a new aggregate from undef
                            const num_fields: usize = @max(field_idx + 1, 2); // at least 2 for strings
                            const new_fields = self.alloc.alloc(Value, num_fields) catch return false;
                            for (new_fields) |*f| f.* = .undef;
                            new_fields[field_idx] = new_val;
                            frame.storeSlot(parent_slot, .{ .aggregate = new_fields });
                            return true;
                        },
                        else => {},
                    }
                }
            },
            else => {},
        }
        return false;
    }

    // ── Builtin call dispatch ──────────────────────────────────────

    fn execBuiltin(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame, _: TypeId) InterpError!ExecResult {
        const result = self.execBuiltinInner(bi, frame) catch |err| {
            if (last_bail_builtin == null) last_bail_builtin = @tagName(bi.builtin);
            return err;
        };
        return result;
    }

    fn execBuiltinInner(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame) InterpError!ExecResult {
        switch (bi.builtin) {
            .out => {
                const str_val = frame.getRef(bi.args[0]);
                if (str_val.asString(self)) |s| {
                    self.output.appendSlice(self.alloc, s) catch {};
                }
                return .{ .value = .void_val };
            },
            .size_of => {
                // Return a default size (8 bytes for most types)
                return .{ .value = .{ .int = 8 } };
            },
            .align_of => {
                return .{ .value = .{ .int = 8 } };
            },
            .sqrt => {
                const val = frame.getRef(bi.args[0]);
                const f = val.asFloat() orelse return error.TypeError;
                return .{ .value = .{ .float = @sqrt(f) } };
            },
            .sin => {
                const val = frame.getRef(bi.args[0]);
                const f = val.asFloat() orelse return error.TypeError;
                return .{ .value = .{ .float = @sin(f) } };
            },
            .cos => {
                const val = frame.getRef(bi.args[0]);
                const f = val.asFloat() orelse return error.TypeError;
                return .{ .value = .{ .float = @cos(f) } };
            },
            .floor => {
                const val = frame.getRef(bi.args[0]);
                const f = val.asFloat() orelse return error.TypeError;
                return .{ .value = .{ .float = @floor(f) } };
            },
            .cast => return bailDetail("comptime #builtin cast: handled at lowering, not the interp (you reached this if a #builtin cast leaked into IR)"),
            .type_of => return bailDetail("comptime #builtin type_of: handled at lowering, not the interp"),
            .alloc => return bailDetail("comptime #builtin alloc unused (use context.allocator.alloc)"),
            .dealloc => return bailDetail("comptime #builtin dealloc unused (use context.allocator.dealloc)"),

            // ── Comptime reflection (Type-as-Value path) ─────────
            // These are only reached when lower.zig emitted a real
            // builtin_call — i.e. the type argument was NOT statically
            // resolvable (e.g. inside a builder body where `args[i]` is
            // a `.type_tag(TypeId)` Value bound at interp time). Static
            // calls fold to `const_string` / `const_bool` at lower time
            // and never hit this dispatch.
            .type_name => {
                if (bi.args.len < 1) return bailDetail("comptime type_name: missing argument");
                const arg = frame.getRef(bi.args[0]);
                // Accept either a bare `.type_tag` Value (the
                // comptime-native form) or an Any-boxed Type
                // (`.aggregate { tag: int, value: .type_tag }`)
                // — the latter shape is what `box_any` produces
                // when const_type values flow through a `.any`-typed
                // slice or struct field.
                const tid = blk: {
                    if (arg.asTypeId()) |t| break :blk t;
                    if (arg == .aggregate) {
                        const fields = arg.aggregate;
                        if (fields.len >= 2) {
                            if (fields[1].asTypeId()) |t| break :blk t;
                        }
                    }
                    return bailDetail("comptime type_name: argument is not a Type value (expected `.type_tag` or Any-boxed Type)");
                };
                const name = self.module.types.typeName(tid);
                // Copy the slice into the interp's allocator so it
                // outlives any TypeTable churn during the rest of the
                // interp execution. The TypeTable's strings are stable
                // for now but copying is the safe pattern.
                const owned = self.alloc.dupe(u8, name) catch return error.CannotEvalComptime;
                return .{ .value = .{ .string = owned } };
            },
            .type_eq => {
                if (bi.args.len < 2) return bailDetail("comptime type_eq: needs two Type arguments");
                const a = frame.getRef(bi.args[0]).asTypeId() orelse return bailDetail("comptime type_eq: first argument is not a Type value");
                const b = frame.getRef(bi.args[1]).asTypeId() orelse return bailDetail("comptime type_eq: second argument is not a Type value");
                return .{ .value = .{ .boolean = a == b } };
            },
            .has_impl => {
                // has_impl at interp time needs access to the host's
                // protocol-registration maps (protocol_thunk_map +
                // param_impl_map). These live on `Lowering`, not on
                // the Interpreter. Plumbing a queryable snapshot is
                // its own slice — until then, bail loudly so the user
                // gets a clear "not yet wired" message instead of a
                // silent false. Static-arg has_impl still works via
                // `tryConstBoolCondition` in lower.zig.
                return bailDetail("comptime has_impl: interp-time evaluation not yet wired (use static type args for now — they fold at lower time)");
            },
        }
    }

};

// ── Frame ───────────────────────────────────────────────────────────────
// Holds SSA values (by Ref index) and local mutable slots (for alloca).

const Frame = struct {
    refs: []Value,
    ref_alloc: Allocator,
    slots: std.ArrayList(Value),

    /// Create a frame pre-allocated with `num_refs` slots (all undef).
    fn initSized(alloc: Allocator, num_refs: u32) Frame {
        const refs = alloc.alloc(Value, num_refs) catch unreachable;
        @memset(refs, .undef);
        return .{
            .refs = refs,
            .ref_alloc = alloc,
            .slots = std.ArrayList(Value).empty,
        };
    }

    fn deinit(self: *Frame) void {
        self.ref_alloc.free(self.refs);
    }

    fn setRef(self: *Frame, idx: u32, val: Value) void {
        if (idx < self.refs.len) {
            self.refs[idx] = val;
        }
    }

    fn getRef(self: *const Frame, ref: Ref) Value {
        if (ref.isNone()) return .void_val;
        const idx = ref.index();
        if (idx >= self.refs.len) return .undef;
        return self.refs[idx];
    }

    fn allocSlot(self: *Frame, alloc: Allocator) u32 {
        const idx: u32 = @intCast(self.slots.items.len);
        self.slots.append(alloc, .undef) catch unreachable;
        return idx;
    }

    fn loadSlot(self: *const Frame, slot: u32) Value {
        if (slot >= self.slots.items.len) return .undef;
        return self.slots.items[slot];
    }

    fn storeSlot(self: *Frame, slot: u32, val: Value) void {
        if (slot < self.slots.items.len) {
            self.slots.items[slot] = val;
        }
    }
};

