const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("../../ast.zig");
const Node = ast.Node;
const types = @import("../types.zig");
const inst_mod = @import("../inst.zig");
const program_index_mod = @import("../program_index.zig");
const ProtocolDeclInfo = program_index_mod.ProtocolDeclInfo;
const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo;
const ProtocolResolver = @import("../protocols.zig").ProtocolResolver;

const TypeId = types.TypeId;
const Ref = inst_mod.Ref;
const FuncId = inst_mod.FuncId;
const Function = inst_mod.Function;

const lower = @import("../lower.zig");
const Lowering = lower.Lowering;

/// Shared implementation for the `has_impl(P, T)` builtin and its
/// `tryConstBoolCondition` arm. The protocol expression is either:
/// - Plain `Hash` (identifier / type_expr) → walks
///   `protocol_thunk_map["Hash\x00<T>"]`.
/// - Parameterised `Into(Block)` (call) → walks `param_impl_map`
///   keyed by `"<P>\x00<arg_mangled>\x00<T_mangled>"`.
/// Returns false on any malformed protocol-arg shape (caller
/// reports a diagnostic if it wants).
pub fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool {
    switch (proto_node.data) {
        .identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty),
        .type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty),
        .call => |c| {
            const p_name: []const u8 = switch (c.callee.data) {
                .identifier => |id| id.name,
                .type_expr => |te| te.name,
                else => return false,
            };
            // Resolve protocol type args. Each goes through
            // `resolveTypeArg` so type aliases / generics / pack-
            // indexed types all work as protocol args.
            var arg_mangles = std.ArrayList(u8).empty;
            defer arg_mangles.deinit(self.alloc);
            for (c.args, 0..) |a, i| {
                if (i > 0) arg_mangles.append(self.alloc, 0) catch return false;
                const aty = self.resolveTypeArg(a);
                arg_mangles.appendSlice(self.alloc, self.mangleTypeName(aty)) catch return false;
            }
            const ty_mangled = self.mangleTypeName(ty);
            const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}\x00{s}", .{
                p_name, arg_mangles.items, ty_mangled,
            }) catch return false;
            return self.param_impl_map.contains(key);
        },
        else => return false,
    }
}

/// Register a protocol declaration as a struct type in the IR type table.
/// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... }
/// Non-inline protocols: { ctx: *void, __vtable: *void }
/// Also stores protocol info for dispatch and vtable struct type for vtable protocols.
/// Register a protocol declaration. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` as a `pub`
/// entry point because the scan pass + several unit tests reach it here.
pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void {
    return self.protocolResolver().registerProtocolDecl(pd);
}

/// Instantiate a parameterized protocol as a runtime VALUE type:
/// `VL(i64)` → a 16-byte `{ctx, __vtable}` protocol value (`is_protocol`),
/// with method infos resolved under the type-arg binding (so `get -> T`
/// becomes `get -> i64`) and the binding recorded for projection. Cached by
/// the mangled name `VL__i64`. Mirrors the non-parameterized path in
/// `registerProtocolDecl`.
pub fn instantiateParamProtocol(self: *Lowering, pd: *const ast.ProtocolDecl, args: []const *const Node) TypeId {
    const table = &self.module.types;
    const void_ptr_ty = table.ptrTo(.void);

    var np = std.ArrayList(u8).empty;
    np.appendSlice(self.alloc, pd.name) catch {};
    var tb = std.StringHashMap(TypeId).init(self.alloc);
    for (pd.type_params, 0..) |tp, i| {
        if (i >= args.len) break;
        const ty = self.resolveTypeWithBindings(args[i]);
        tb.put(tp.name, ty) catch {};
        np.appendSlice(self.alloc, "__") catch {};
        np.appendSlice(self.alloc, self.formatTypeName(ty)) catch {};
    }
    const mangled = np.items;
    const name_id = table.internString(mangled);
    if (table.findByName(name_id)) |existing| {
        const info = table.get(existing);
        if (info == .@"struct" and info.@"struct".is_protocol) return existing;
    }

    // Value struct: {ctx, __vtable} (or ctx + fn-ptrs for an inline protocol).
    var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
    fields.append(self.alloc, .{ .name = table.internString("ctx"), .ty = void_ptr_ty }) catch unreachable;
    if (pd.is_inline) {
        for (pd.methods) |m| fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
    } else {
        fields.append(self.alloc, .{ .name = table.internString("__vtable"), .ty = void_ptr_ty }) catch unreachable;
    }
    const struct_info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items, .is_protocol = true } };
    const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info);
    table.updatePreservingKey(id, struct_info);

    // Method infos resolved with the type-arg binding (T → i64), pinned to
    // the protocol's OWN module (E4) so a method-signature type visible only
    // there resolves correctly when instantiated cross-module. `Self` and the
    // bound type-args short-circuit before the leaf; a concrete library type
    // in a signature is the case this pin protects.
    const saved_tb = self.type_bindings;
    self.type_bindings = tb;
    const saved_pp_src = self.current_source_file;
    defer self.setCurrentSourceFile(saved_pp_src);
    if (pd.source_file) |src| self.setCurrentSourceFile(src);
    var method_infos = std.ArrayList(ProtocolMethodInfo).empty;
    for (pd.methods) |method| {
        var ptypes = std.ArrayList(TypeId).empty;
        for (method.params) |p| {
            const pty = blk: {
                if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) break :blk void_ptr_ty;
                break :blk self.resolveTypeWithBindings(p);
            };
            ptypes.append(self.alloc, pty) catch unreachable;
        }
        var ret_is_self = false;
        const ret = if (method.return_type) |rt| blk: {
            if (rt.data == .type_expr and std.mem.eql(u8, rt.data.type_expr.name, "Self")) {
                ret_is_self = true;
                break :blk void_ptr_ty;
            }
            break :blk self.resolveTypeWithBindings(rt);
        } else .void;
        method_infos.append(self.alloc, .{
            .name = method.name,
            .param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable,
            .ret_type = ret,
            .ret_is_self = ret_is_self,
        }) catch unreachable;
    }
    self.type_bindings = saved_tb;

    const owned = self.alloc.dupe(u8, mangled) catch return id;
    self.program_index.protocol_decl_map.put(owned, .{
        .name = owned,
        .is_inline = pd.is_inline,
        .methods = self.alloc.dupe(ProtocolMethodInfo, method_infos.items) catch unreachable,
    }) catch {};
    // Record the type-arg binding so projection (`xs.T`, `.value`) and
    // method-arg resolution on this instance can recover it.
    self.struct_instance_bindings.put(owned, tb) catch {};

    if (!pd.is_inline) {
        var vtable_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
        for (pd.methods) |m| vtable_fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
        var vtable_name_buf: [192]u8 = undefined;
        const vtable_name = std.fmt.bufPrint(&vtable_name_buf, "__{s}__Vtable", .{mangled}) catch "__Vtable";
        const vtable_ty = table.intern(.{ .@"struct" = .{ .name = table.internString(vtable_name), .fields = vtable_fields.items } });
        self.protocol_vtable_type_map.put(owned, vtable_ty) catch {};
    }
    return id;
}

// ── Protocol namespace lookups (pack projection, Feature 1, Decision 4) ──
// (The position-driven orchestrator `resolvePackProjection` lives in
// lower/pack.zig; these two lookups are its per-namespace halves.)
//
// A `..pack.<name>` projection can target two protocol namespaces:
//   - type-arg namespace: the `protocol($T, ...)` params.
//   - runtime-accessor namespace: the protocol's methods (protocols have
//     no fields; a zero-arg method like `value` is the accessor).
// Resolution is POSITION-driven, not precedence-driven: type position
// consults type-args, value position consults methods, with NO
// cross-namespace fallback.


/// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`).
/// Returns the `type_params` index, or null (also for unknown protocols).
pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
    const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
    for (pd.type_params, 0..) |tp, i| {
        if (std.mem.eql(u8, tp.name, name)) return @intCast(i);
    }
    return null;
}

/// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods
/// — protocols have no fields). Returns the `methods` index, or null.
pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
    const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
    for (pd.methods, 0..) |m, i| {
        if (std.mem.eql(u8, m.name, name)) return @intCast(i);
    }
    return null;
}

/// Check if a type name is a registered protocol.
pub fn isProtocolType(self: *Lowering, type_name: []const u8) bool {
    return self.program_index.protocol_decl_map.contains(type_name);
}

/// Get protocol info for a TypeId (if it's a protocol type).
/// Protocol lookup. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9
/// callers (dispatch sites here + `calls.zig`) reach it.
pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo {
    return self.protocolResolver().getProtocolInfo(ty);
}

/// Get or create thunks for a (protocol, concrete_type) pair.
/// Returns a slice of FuncIds, one per protocol method.
pub fn getOrCreateThunks(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) []const FuncId {
    // Key: "Proto\x00Type"
    const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch return &.{};
    if (self.protocol_thunk_map.get(key)) |thunks| return thunks;

    // PLANNING: which methods need a thunk (owned by the registry).
    const methods = self.protocolResolver().protocolMethodInfos(proto_name) orelse return &.{};
    var thunk_ids = std.ArrayList(FuncId).empty;
    defer thunk_ids.deinit(self.alloc);

    // EMISSION: materialize one thunk per method (stays in Lowering).
    for (methods) |method| {
        const thunk_id = self.createProtocolThunk(proto_name, concrete_type_name, method);
        thunk_ids.append(self.alloc, thunk_id) catch unreachable;
    }

    const owned = self.alloc.dupe(FuncId, thunk_ids.items) catch unreachable;
    self.protocol_thunk_map.put(key, owned) catch {};
    return owned;
}

/// Emit the process-wide default Context as an LLVM static constant.
///
///   @__sx_default_context = internal constant %Context {
///     %Allocator { ptr null,
///                  ptr @__thunk_CAllocator_Allocator_alloc_bytes,
///                  ptr @__thunk_CAllocator_Allocator_dealloc_bytes },
///     ptr null
///   }
///
/// Used by FFI inbound wrappers (Step 4) and the interp's default-
/// context call entry (Step 7). Only emitted when the program imports
/// `std.sx` — without that, Context / Allocator / CAllocator aren't
/// registered and the global has no purpose.
pub fn emitDefaultContextGlobal(self: *Lowering) void {
    const saved_edc = self.emitting_default_context;
    self.emitting_default_context = true;
    defer self.emitting_default_context = saved_edc;
    const tbl = &self.module.types;
    const ctx_name_id = tbl.internString("Context");
    const ctx_ty = tbl.findByName(ctx_name_id) orelse return;
    if (tbl.findByName(tbl.internString("Allocator")) == null) return;
    if (tbl.findByName(tbl.internString("CAllocator")) == null) return;

    // Force the CAllocator → Allocator thunks to exist so we can
    // reference them by FuncId in the static initializer.
    const thunks = self.getOrCreateThunks("Allocator", "CAllocator");
    if (thunks.len < 2) return;

    // Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void }
    // CAllocator is stateless, so ctx is null.
    const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return;
    alloc_fields[0] = .null_val;
    alloc_fields[1] = .{ .func_ref = thunks[0] };
    alloc_fields[2] = .{ .func_ref = thunks[1] };

    // Context value: { allocator: Allocator, data: *void }
    const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return;
    ctx_fields[0] = .{ .aggregate = alloc_fields };
    ctx_fields[1] = .null_val;

    const global_name = "__sx_default_context";
    const global_name_id = tbl.internString(global_name);
    const gid = self.module.addGlobal(.{
        .name = global_name_id,
        .ty = ctx_ty,
        .init_val = .{ .aggregate = ctx_fields },
        .is_const = true,
    });
    self.putGlobal(self.current_source_file, global_name, .{ .id = gid, .ty = ctx_ty });
}

/// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret
/// The thunk calls ConcreteType.method(ctx, args...).
pub fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId {
    // Build params: [__sx_ctx]? + ctx: *void + method params.
    // Thunks are sx-side functions, so they get the implicit __sx_ctx
    // at slot 0 when it's enabled program-wide. The concrete protocol
    // receiver (ctx) follows at slot 1; user method args at slot 2+.
    var params = std.ArrayList(inst_mod.Function.Param).empty;
    defer params.deinit(self.alloc);
    const void_ptr = self.module.types.ptrTo(.void);
    const thunk_has_ctx = self.implicit_ctx_enabled;
    if (thunk_has_ctx) {
        params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr }) catch unreachable;
    }
    params.append(self.alloc, .{ .name = self.module.types.internString("ctx"), .ty = void_ptr }) catch unreachable;
    for (method.param_types, 0..) |pty, i| {
        var buf: [32]u8 = undefined;
        const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg";
        params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable;
    }

    // Generate unique name
    var name_buf: [192]u8 = undefined;
    const thunk_name = std.fmt.bufPrint(&name_buf, "__thunk_{s}_{s}_{s}", .{ concrete_type_name, proto_name, method.name }) catch "__thunk";
    const thunk_name_id = self.module.types.internString(thunk_name);

    // Save builder state
    const saved_func = self.builder.func;
    const saved_block = self.builder.current_block;
    const saved_counter = self.builder.inst_counter;
    const saved_ctx_ref_thunk = self.current_ctx_ref;
    defer self.current_ctx_ref = saved_ctx_ref_thunk;

    const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable;
    var func = inst_mod.Function.init(thunk_name_id, owned_params, method.ret_type);
    func.has_implicit_ctx = thunk_has_ctx;
    const func_id = self.module.addFunction(func);
    self.builder.func = func_id;
    self.builder.inst_counter = @intCast(owned_params.len);
    if (thunk_has_ctx) self.current_ctx_ref = Ref.fromIndex(0);
    const entry_block = self.builder.appendBlock(self.module.types.internString("entry"), &.{});
    self.builder.switchToBlock(entry_block);

    // Ensure the concrete method is lowered
    const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, method.name }) catch method.name;
    if (!self.lowered_functions.contains(qualified)) {
        if (self.program_index.fn_ast_map.contains(qualified)) {
            self.lazyLowerFunction(qualified);
        } else if (self.genericInstanceMethod(concrete_type_name, method.name)) |gm| {
            // Generic-struct instance (`Combined__i64_i64`): the impl method is
            // authored on the instance's STAMPED decl (CP-4). Monomorphize it
            // for this instance's bindings so the thunk has a concrete
            // `Combined__i64_i64.get` to call.
            self.monomorphizeFunction(gm.fd, qualified, gm.bindings);
        }
    }

    // Call the concrete method: ConcreteType.method(__sx_ctx?, ctx, args...).
    // The concrete method is itself an sx function that takes the
    // implicit __sx_ctx at slot 0 (when implicit_ctx is enabled); we
    // forward the thunk's own __sx_ctx.
    if (self.resolveFuncByName(qualified)) |concrete_fid| {
        const concrete_func = &self.module.functions.items[@intFromEnum(concrete_fid)];
        var call_args = std.ArrayList(Ref).empty;
        defer call_args.deinit(self.alloc);

        // Slot offsets inside the thunk: __sx_ctx at 0 (if present),
        // protocol receiver (ctx) at slot user_base, user args at +1, +2...
        const user_base: u32 = if (thunk_has_ctx) 1 else 0;

        // Forward our __sx_ctx to the concrete method's __sx_ctx slot.
        if (concrete_func.has_implicit_ctx) {
            call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
        }

        // Pass ctx as the next arg (it's the concrete *Type disguised as *void).
        // If the concrete method expects a value (e.g., f32) not a pointer, load from ctx.
        const ctx_ref = Ref.fromIndex(user_base);
        const concrete_receiver_idx: usize = if (concrete_func.has_implicit_ctx) 1 else 0;
        if (concrete_receiver_idx < concrete_func.params.len) {
            const first_concrete_ty = concrete_func.params[concrete_receiver_idx].ty;
            const first_info = self.module.types.get(first_concrete_ty);
            if (first_info != .pointer) {
                // Concrete expects value — load from ctx pointer
                call_args.append(self.alloc, self.builder.load(ctx_ref, first_concrete_ty)) catch unreachable;
            } else {
                call_args.append(self.alloc, ctx_ref) catch unreachable;
            }
        } else {
            call_args.append(self.alloc, ctx_ref) catch unreachable;
        }
        for (method.param_types, 0..) |proto_pty, i| {
            var arg_ref = Ref.fromIndex(@intCast(user_base + 1 + i));
            // If protocol param is a pointer (Self→*void) but concrete method
            // expects a value type, load the value from the pointer.
            const concrete_idx = concrete_receiver_idx + 1 + i;
            if (concrete_idx < concrete_func.params.len) {
                const concrete_pty = concrete_func.params[concrete_idx].ty;
                const proto_info = self.module.types.get(proto_pty);
                const concrete_info = self.module.types.get(concrete_pty);
                if (proto_info == .pointer and concrete_info != .pointer) {
                    arg_ref = self.builder.load(arg_ref, concrete_pty);
                }
            }
            call_args.append(self.alloc, arg_ref) catch unreachable;
        }
        const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable;
        const concrete_ret = concrete_func.ret;
        const result = self.builder.call(concrete_fid, owned_args, concrete_ret);
        if (method.ret_type != .void) {
            // If protocol returns *void (Self) but concrete returns a value type,
            // box the value: alloca+store and return the pointer
            const ret_info = self.module.types.get(method.ret_type);
            const concrete_ret_info = self.module.types.get(concrete_ret);
            if (ret_info == .pointer and concrete_ret_info != .pointer) {
                const slot = self.builder.alloca(concrete_ret);
                self.builder.store(slot, result);
                self.builder.ret(slot, method.ret_type);
            } else {
                self.builder.ret(result, method.ret_type);
            }
        } else {
            self.builder.retVoid();
        }
    } else {
        // Can't resolve concrete method — emit unreachable
        _ = self.builder.emit(.{ .@"unreachable" = {} }, .void);
    }
    self.builder.finalize();

    // Restore builder state
    self.builder.func = saved_func;
    self.builder.current_block = saved_block;
    self.builder.inst_counter = saved_counter;

    return func_id;
}

/// Build a protocol value from a concrete pointer.
/// For inline protocols: struct_init { ctx, thunk1, thunk2, ... }
/// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated
/// When `heap_copy` is true, the concrete data is heap-copied so the protocol value
/// outlives the current stack frame (used when source is a value, not an explicit pointer).
/// When false, the pointer is used directly (user manages the pointee's lifetime).
pub fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []const u8, concrete_type_name: []const u8, proto_ty: TypeId, concrete_ty: TypeId, heap_copy: bool) Ref {
    const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return concrete_ptr;
    const thunks = self.getOrCreateThunks(proto_name, concrete_type_name);
    if (thunks.len != pd.methods.len) return concrete_ptr;

    const void_ptr_ty = self.module.types.ptrTo(.void);

    // When source is a value (not an explicit pointer), heap-allocate
    // so the protocol value outlives the current stack frame.
    // When source is an explicit pointer (xx @obj), use it directly —
    // the user is responsible for the pointee's lifetime.
    var ctx_ptr = concrete_ptr;
    if (heap_copy) {
        const concrete_size = self.module.types.typeSizeBytes(concrete_ty);
        const size_ref = self.builder.constInt(@intCast(concrete_size), .i64);
        const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty);
        _ = self.callExtern("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty);
        ctx_ptr = heap_ptr;
    }

    if (pd.is_inline) {
        // Inline: { ctx, fn1, fn2, ... }
        var field_vals = std.ArrayList(Ref).empty;
        defer field_vals.deinit(self.alloc);
        field_vals.append(self.alloc, ctx_ptr) catch unreachable;
        for (thunks) |thunk_id| {
            const fn_ref = self.builder.emit(.{ .func_ref = thunk_id }, void_ptr_ty);
            field_vals.append(self.alloc, fn_ref) catch unreachable;
        }
        const owned = self.alloc.dupe(Ref, field_vals.items) catch unreachable;
        return self.builder.emit(.{ .struct_init = .{ .fields = owned } }, proto_ty);
    } else {
        // Vtable: { ctx, vtable_ptr }
        // Vtable is a global constant (same function pointers for every instance
        // of the same Protocol+ConcreteType pair). Cached per pair.
        const vtable_ty = self.protocol_vtable_type_map.get(proto_name) orelse return concrete_ptr;

        // Build cache key: "Proto\x00Type"
        const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch unreachable;

        const vtable_global_id = self.protocol_vtable_global_map.get(key) orelse blk: {
            // Create vtable global with function pointer initializer
            const global_name = std.fmt.allocPrint(self.alloc, "__{s}__{s}__vtable", .{ proto_name, concrete_type_name }) catch unreachable;
            const global_name_id = self.module.types.strings.intern(self.alloc, global_name);
            const thunk_ids = self.alloc.dupe(FuncId, thunks) catch unreachable;
            const gid = self.module.addGlobal(.{
                .name = global_name_id,
                .ty = vtable_ty,
                .init_val = .{ .vtable = thunk_ids },
                .is_const = true,
            });
            self.protocol_vtable_global_map.put(key, gid) catch {};
            break :blk gid;
        };

        // Reference the vtable global's address
        const vtable_ptr_ty = self.module.types.ptrTo(vtable_ty);
        const vtable_addr = self.builder.emit(.{ .global_addr = vtable_global_id }, vtable_ptr_ty);

        // Build protocol struct: { ctx, &vtable }
        var proto_fields = std.ArrayList(Ref).empty;
        defer proto_fields.deinit(self.alloc);
        proto_fields.append(self.alloc, ctx_ptr) catch unreachable;
        proto_fields.append(self.alloc, vtable_addr) catch unreachable;
        const proto_owned = self.alloc.dupe(Ref, proto_fields.items) catch unreachable;
        return self.builder.emit(.{ .struct_init = .{ .fields = proto_owned } }, proto_ty);
    }
}

/// Emit protocol method dispatch for a protocol-typed receiver.
/// Returns the call result ref.
pub fn emitProtocolDispatch(self: *Lowering, receiver: Ref, proto_info: ProtocolDeclInfo, method_name: []const u8, args: []const Ref, proto_ty: TypeId, span: ast.Span) Ref {
    // Find method index
    var method_idx: ?usize = null;
    var method_info: ?ProtocolMethodInfo = null;
    for (proto_info.methods, 0..) |m, i| {
        if (std.mem.eql(u8, m.name, method_name)) {
            method_idx = i;
            method_info = m;
            break;
        }
    }
    const mi = method_info orelse return self.emitError(method_name, null);
    const midx = method_idx orelse 0;

    // Arity is exact: a protocol signature has no defaults, packs, or
    // variadics, so the user-arg count must equal its parameter list
    // (issue 0131: extra args were silently dropped here; missing args
    // left the thunk reading garbage).
    if (args.len != mi.param_types.len) {
        if (self.diagnostics) |d| {
            const s: []const u8 = if (mi.param_types.len == 1) "" else "s";
            const got_verb: []const u8 = if (args.len == 1) "was" else "were";
            d.addFmt(.err, span, "'{s}' expects {d} argument{s}, but {d} {s} given", .{ method_name, mi.param_types.len, s, args.len, got_verb });
        }
        return Ref.none;
    }

    // Extract ctx from protocol struct (field 0)
    const void_ptr = self.module.types.ptrTo(.void);
    const ctx = self.builder.structGet(receiver, 0, void_ptr);

    // Extract fn_ptr
    const fn_ptr = if (proto_info.is_inline) blk: {
        // Inline: fn_ptr at field 1+method_idx
        break :blk self.builder.structGet(receiver, @intCast(1 + midx), void_ptr);
    } else blk: {
        // Vtable: load vtable struct, extract fn_ptr at method_idx
        const vtable_ptr = self.builder.structGet(receiver, 1, void_ptr);
        const vtable_ty = self.protocol_vtable_type_map.get(proto_info.name) orelse return self.emitError("vtable", null);
        const vtable = self.builder.emit(.{ .deref = .{ .operand = vtable_ptr } }, vtable_ty);
        break :blk self.builder.structGet(vtable, @intCast(midx), void_ptr);
    };
    _ = proto_ty;

    // Build call args: [__sx_ctx]? + receiver_ctx + user args.
    // Protocol thunks are sx-side, so they carry the implicit __sx_ctx
    // at slot 0 when the program uses Context — forward our caller's
    // ctx so the thunk's body (and the concrete method it forwards to)
    // sees the same Context as the dispatching code.
    var call_args = std.ArrayList(Ref).empty;
    defer call_args.deinit(self.alloc);
    if (self.implicit_ctx_enabled) {
        call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
    }
    call_args.append(self.alloc, ctx) catch unreachable;
    for (args, 0..) |a, i| {
        const expected_ty = if (i < mi.param_types.len) mi.param_types[i] else void_ptr;
        const arg_ty = self.builder.getRefType(a);

        // Untargeted `null` lowers as const_null with type .void. Re-emit it
        // as a null of the expected pointer type instead of alloca'ing void.
        if (arg_ty == .void and expected_ty == void_ptr) {
            call_args.append(self.alloc, self.builder.constNull(void_ptr)) catch unreachable;
            continue;
        }
        // A protocol method that expects `*void` accepts any single-pointer
        // value directly (`*T`, `[*]T`). Only wrap non-pointer values in an
        // alloca-slot — wrapping a pointer would pass the stack slot's
        // address instead of the actual pointer, and the callee would read
        // 8 bytes of pointer plus garbage from beyond the stack.
        const is_pointer_ty = if (!arg_ty.isBuiltin()) blk: {
            const info = self.module.types.get(arg_ty);
            break :blk info == .pointer or info == .many_pointer;
        } else false;
        if (expected_ty == void_ptr and arg_ty != void_ptr and !is_pointer_ty) {
            const slot = self.builder.alloca(arg_ty);
            self.builder.store(slot, a);
            call_args.append(self.alloc, slot) catch unreachable;
        } else {
            // Coerce to match declared parameter type (critical for WASM strict signatures)
            const coerced = self.coerceToType(a, arg_ty, expected_ty);
            call_args.append(self.alloc, coerced) catch unreachable;
        }
    }
    const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable;
    const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type);

    // If the protocol method was declared `-> Self` (encoded here as *void)
    // and the caller expects a value type, unbox: load the concrete value
    // from the returned pointer. A literal `-> *void` return is NOT
    // auto-loaded — it's a real pointer whose pointee size we don't know.
    if (mi.ret_is_self) {
        if (self.target_type) |target| {
            const target_info = self.module.types.get(target);
            if (target_info != .pointer) {
                return self.builder.load(raw_result, target);
            }
        }
    }
    return raw_result;
}

/// Resolve the concrete type name for protocol erasure.
/// Handles both direct types and pointer-to-types.
pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
    if (ty.isBuiltin()) {
        // Primitive types like i64 — check if they have toName()
        return self.module.types.typeName(ty);
    }
    const info = self.module.types.get(ty);
    if (info == .pointer) {
        // *ConcreteType → resolve pointee
        const pointee = info.pointer.pointee;
        if (pointee.isBuiltin()) return self.module.types.typeName(pointee);
        const pi = self.module.types.get(pointee);
        if (pi == .@"struct") return self.module.types.getString(pi.@"struct".name);
        return null;
    }
    if (info == .@"struct") return self.module.types.getString(info.@"struct".name);
    return null;
}
