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 type_bridge = @import("../type_bridge.zig");
const unescape = @import("../../unescape.zig");
const errors = @import("../../errors.zig");
const program_index_mod = @import("../program_index.zig");
const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo;
const GlobalInfo = program_index_mod.GlobalInfo;
const CallResolver = @import("../calls.zig").CallResolver;

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

const lower = @import("../lower.zig");
const Lowering = lower.Lowering;
const SelectedFunc = Lowering.SelectedFunc;
const isTypeParamDecl = Lowering.isTypeParamDecl;
const isPackFn = Lowering.isPackFn;
const headNameOfCallee = Lowering.headNameOfCallee;
const hasComptimeParams = Lowering.hasComptimeParams;

pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
    var c = c_in;
    // A bare reserved-type-name spelling in call position parses as a
    // `.type_expr` (e.g. `i2(4)`), but if a function of that name is in
    // scope — a backtick-declared sx fn or a `#import c` extern fn whose C
    // name collides with a reserved type spelling — it is a CALL to that
    // function. `TypeName(val)` is not a cast (casts are `cast(T, val)`), so
    // there is no ambiguity. Rewrite the callee to an identifier so the
    // normal call machinery resolves it, symmetric to the bare-value
    // reference that already resolves via scope/globals.
    //
    // Scoped to RAW provenance: only a backtick (`is_raw`) or `#import c`
    // extern fn declaration may legally carry a reserved-name spelling
    // (the decl check rejects every bare reserved-name sx fn). Refusing the
    // rewrite for a non-raw match keeps a genuine reserved type spelling a
    // type — belt-and-suspenders should any future path ever reintroduce a
    // non-raw reserved-name callee.
    if (c.callee.data == .type_expr) {
        const tname = c.callee.data.type_expr.name;
        const eff = if (self.scope) |scope| scope.lookupFn(tname) orelse tname else tname;
        const fd: ?*const ast.FnDecl = self.program_index.fn_ast_map.get(eff) orelse
            self.program_index.fn_ast_map.get(tname);
        if (fd) |decl| if (decl.is_raw) {
            const id_node = self.alloc.create(Node) catch unreachable;
            id_node.* = .{ .span = c.callee.span, .data = .{ .identifier = .{ .name = tname, .is_raw = true } } };
            const rewritten = self.alloc.create(ast.Call) catch unreachable;
            rewritten.* = .{ .callee = id_node, .args = c.args };
            c = rewritten;
        };
    }
    // R5 §C: select the bare / value-UFCS same-name call author
    // ONCE, via `CallResolver.selectedFreeAuthor` — the SINGLE producer of
    // this verdict, the exact same one `CallResolver.plan` consumes for typing.
    // The call-path consumers (default expansion, param typing, dispatch) all
    // read THIS one author object, so plan-typing and lowering-dispatch can no
    // longer disagree about which same-name function the call names, and the
    // shadow's FuncId is materialized at most once (into `author_verdict`).
    // `selectedFreeAuthor` is side-effect-free (it only runs the author
    // selector — no return-type inference / type-arg resolution), so computing
    // it eagerly here can't emit a premature diagnostic the way the full plan
    // would.
    var author_verdict = self.callResolver().selectedFreeAuthor(c);
    const sel_author: ?*SelectedFunc = switch (author_verdict) {
        .func => |*sf| sf,
        else => null,
    };
    const author_ambiguous = author_verdict == .ambiguous;
    // Expand default parameter values for bare identifier callees:
    // when the caller omits trailing positional args, fill them in
    // from the callee's `param: T = expr` declarations.
    if (self.expandCallDefaults(c, sel_author, author_ambiguous)) |expanded| c = expanded;
    // Check reflection builtins first (before lowering args — some args are type names, not values)
    if (c.callee.data == .identifier) {
        if (self.tryLowerReflectionCall(c.callee.data.identifier.name, c)) |ref| return ref;
        // Atomic intrinsics (atomic_load/atomic_store): a type arg + value args,
        // so lower them here (before generic arg lowering) like reflection calls.
        if (self.tryLowerAtomicIntrinsic(c.callee.data.identifier.name, c)) |ref| return ref;
    }

    // Check for runtime dispatch pattern BEFORE lowering args.
    // lowerRuntimeDispatchCall handles its own arg lowering, and pre-lowering
    // cast(type) val would produce a dead `call_builtin cast : void`.
    if (c.callee.data == .identifier) {
        const id_name = c.callee.data.identifier.name;
        const eff_name = blk: {
            const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
            if (self.program_index.ufcs_alias_map.get(id_name)) |target| {
                break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
            }
            break :blk scoped;
        };
        // C-import visibility: deny calls to C fn_decls not in the caller's module scope
        if (!self.isCImportVisible(eff_name)) {
            if (self.diagnostics) |d|
                d.addFmt(.err, c.callee.span, "C function '{s}' not visible; add #import for the module that declares it", .{eff_name});
            return Ref.none;
        }
        // Non-transitive `#import` visibility check. Apply only when the
        // user-typed name resolved as-is to a top-level fn — local-scope
        // mangling (eff_name != id_name) and UFCS alias rewriting are
        // compiler indirections and stay exempt.
        if (std.mem.eql(u8, eff_name, id_name) and
            self.program_index.ufcs_alias_map.get(id_name) == null and
            self.program_index.fn_ast_map.contains(eff_name) and
            !self.isNameVisible(eff_name))
        {
            if (self.diagnostics) |d|
                d.addFmt(.err, c.callee.span, "'{s}' is not visible; #import the module that declares it", .{eff_name});
            return Ref.none;
        }
        if (self.program_index.fn_ast_map.get(eff_name)) |fd| {
            if (self.current_match_tags) |tags| {
                if (tags.len > 0 and self.hasCastWithRuntimeType(c)) {
                    return self.lowerRuntimeDispatchCall(fd, eff_name, c, tags);
                }
            }
        }
    }

    // Handle closure(fn_or_lambda) — wrap bare functions into closures
    if (c.callee.data == .identifier and std.mem.eql(u8, c.callee.data.identifier.name, "closure")) {
        if (c.args.len >= 1) {
            const arg = c.args[0];
            // If argument is a bare function name, create a proper closure from it
            if (arg.data == .identifier) {
                const fn_name = arg.data.identifier.name;
                // `closure(fn)` over a genuine flat same-name
                // collision must capture the RESOLVED author's FuncId, not the
                // first-wins winner's. Plain bare name only; `.ambiguous`
                // → loud diagnostic; `.none` → existing first-wins path.
                const closure_fid: ?FuncId = blk_cl: {
                    if (self.program_index.ufcs_alias_map.get(fn_name) == null and
                        (if (self.scope) |scope| scope.lookup(fn_name) == null else true))
                    {
                        if (self.current_source_file) |caller_file| {
                            switch (self.selectPlainCallableAuthor(fn_name, caller_file)) {
                                .func => |sf| {
                                    var selected = sf;
                                    break :blk_cl self.selectedFuncId(&selected, fn_name);
                                },
                                .ambiguous => {
                                    if (self.diagnostics) |d|
                                        d.addFmt(.err, arg.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fn_name});
                                    return Ref.none;
                                },
                                .none => {},
                            }
                        }
                    }
                    if (!self.lowered_functions.contains(fn_name)) {
                        self.lazyLowerFunction(fn_name);
                    }
                    break :blk_cl self.resolveFuncByName(fn_name);
                };
                if (closure_fid) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    // Build closure type from user-visible params only —
                    // skip the implicit __sx_ctx param.
                    var param_types_list = std.ArrayList(TypeId).empty;
                    defer param_types_list.deinit(self.alloc);
                    const skip: usize = if (func.has_implicit_ctx) 1 else 0;
                    for (func.params[skip..]) |p| {
                        param_types_list.append(self.alloc, p.ty) catch unreachable;
                    }
                    const closure_ty = self.module.types.closureType(param_types_list.items, func.ret);
                    const closure_info = self.module.types.get(closure_ty).closure;
                    const tramp_id = self.createBareFnTrampoline(fid, closure_info);
                    return self.builder.closureCreate(tramp_id, Ref.none, closure_ty);
                }
            }
            // Lambda or other expression — already produces closure_create
            return self.lowerExpr(arg);
        }
    }

    // Early detection of comptime-expanded calls (e.g. print) — skip arg evaluation
    // since lowerComptimeCall re-evaluates args from AST (avoiding double evaluation)
    if (c.callee.data == .identifier) {
        const early_name = blk: {
            const id_name = c.callee.data.identifier.name;
            const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
            if (self.program_index.ufcs_alias_map.get(id_name)) |target| {
                break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
            }
            break :blk scoped;
        };
        // R5 §C: the early pack/comptime/generic dispatch reads
        // the SAME author the call resolver SELECTED — not the first-wins
        // winner — whenever a genuine flat same-name collision rerouted the
        // call (`sel_author != null`). The selector only ever returns a plain
        // free fn (`isPlainFreeFn` rejects type-params / comptime / pack), so
        // `sel_author.decl` matches none of the arms below and the early path
        // falls through to the main dispatch, which CONSUMES `sel_author` and
        // binds that author. Without this the early path would dispatch the
        // first-wins winner (e.g. a pack `(..$args)`) and disagree with the
        // main dispatch — the selected plain author's bare call would invoke
        // the wrong function. On the common path (`sel_author == null`) this
        // reads the winner exactly as before — byte-identical, since the
        // selector reroutes nothing there.
        const early_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else self.program_index.fn_ast_map.get(early_name);
        if (early_fd) |fd| {
            if (isPackFn(fd)) {
                // Protocol packs (`..xs: P`) and comptime type-packs
                // (`..$args`) both monomorphize per call shape.
                return self.lowerPackFnCall(fd, c);
            }
            if (hasComptimeParams(fd)) {
                return self.lowerComptimeCall(fd, c);
            }
            // Early detection of generic function calls — skip arg lowering for type params
            // because lowerGenericCall resolves type params from AST nodes, not lowered refs.
            // Only if the name is NOT shadowed by a local variable (closure, fn ptr, etc.).
            // A selected author is never generic (`isPlainFreeFn` excludes
            // `type_params > 0`), so this branch fires only on the winner.
            const shadowed = if (self.scope) |scope| scope.lookup(c.callee.data.identifier.name) != null else false;
            if (fd.type_params.len > 0 and !shadowed) {
                // Types are explicit when call args match param count (e.g., are_equal(Point, p1, p2))
                // Types are inferred when call args < param count (e.g., are_equal(p1, p2))
                const types_explicit = c.args.len == fd.params.len;
                var lowered_args = std.ArrayList(Ref).empty;
                defer lowered_args.deinit(self.alloc);
                for (c.args, 0..) |arg, ai| {
                    // Skip type param args only when types are passed explicitly
                    if (types_explicit and ai < fd.params.len and isTypeParamDecl(&fd.params[ai], fd.type_params)) {
                        lowered_args.append(self.alloc, Ref.none) catch unreachable;
                    } else {
                        const saved_target = self.target_type;
                        lowered_args.append(self.alloc, self.lowerExpr(arg)) catch unreachable;
                        self.target_type = saved_target;
                    }
                }
                return self.lowerGenericCall(fd, early_name, c, lowered_args.items);
            }
        }
    }

    // Lower args (with target type propagation for xx conversions)
    var args = std.ArrayList(Ref).empty;
    defer args.deinit(self.alloc);
    // Try to resolve param types for target_type context
    const param_types = self.resolveCallParamTypes(c, sel_author);
    // For enum_literal callees (.Variant(payload)), resolve the payload target type
    // from the union field type so struct literal fields get proper coercion
    var enum_payload_ty: ?TypeId = null;
    if (c.callee.data == .enum_literal) {
        const target = self.target_type orelse .unresolved;
        if (!target.isBuiltin()) {
            const info = self.module.types.get(target);
            if (info == .tagged_union) {
                const tag = self.resolveVariantIndex(target, c.callee.data.enum_literal.name);
                if (tag < info.tagged_union.fields.len) {
                    enum_payload_ty = info.tagged_union.fields[tag].ty;
                }
            }
        }
    }
    for (c.args, 0..) |arg, ai| {
        if (arg.data == .spread_expr) {
            // Pack spread `..xs` / `..xs.method` → expand to N positional
            // args here. A runtime-slice spread (`..arr`) is left as a
            // placeholder for the slice-variadic path (packVariadicCallArgs).
            if (self.packSpreadRefs(arg.data.spread_expr.operand, arg.span)) |elems| {
                defer self.alloc.free(elems);
                for (elems) |e| args.append(self.alloc, e) catch unreachable;
                continue;
            }
            args.append(self.alloc, Ref.none) catch unreachable;
            continue;
        }
        const saved_target = self.target_type;
        if (ai < param_types.len) {
            self.target_type = param_types[ai];
        }
        if (enum_payload_ty) |ept| {
            if (ai == 0) self.target_type = ept;
        }
        // Implicit float→int narrowing of a compile-time float argument
        // (incl. an expanded `param: T = expr` default) follows the unified
        // rule: an integral comptime float folds, a non-integral one errors.
        // A runtime float / `xx` cast is unaffected and coerces as before.
        if (ai < param_types.len) {
            if (self.foldComptimeFloatInit(arg, param_types[ai])) |folded| {
                args.append(self.alloc, folded) catch unreachable;
                self.target_type = saved_target;
                continue;
            }
        }
        // Implicit address-of: when param expects *T and arg is an identifier
        // with an alloca of type T, pass the alloca pointer directly (reference
        // semantics, so mutations through the pointer are visible to the caller).
        if (ai < param_types.len and arg.data == .identifier) {
            const pt = param_types[ai];
            if (!pt.isBuiltin()) {
                const pti = self.module.types.get(pt);
                if (pti == .pointer) {
                    if (self.scope) |scope| {
                        if (scope.lookup(arg.data.identifier.name)) |binding| {
                            // Only apply when the binding type matches the pointee type
                            if (binding.is_alloca and binding.ty == pti.pointer.pointee) {
                                const ptr_ty = self.module.types.ptrTo(binding.ty);
                                args.append(self.alloc, self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, ptr_ty)) catch unreachable;
                                self.target_type = saved_target;
                                continue;
                            }
                        }
                    }
                }
            }
        }
        // Implicit address-of for compound lvalues (field access / index /
        // deref): when the param expects `*T` and the arg is an addressable
        // lvalue of type `T`, pass the lvalue's real address (GEP) — same
        // reference semantics as the identifier case above. Without this the
        // arg would be loaded into a temporary and the callee would mutate a
        // throwaway copy (silent data loss — e.g. `make_move(self.board, m)`).
        if (ai < param_types.len and (arg.data == .field_access or arg.data == .index_expr or arg.data == .deref_expr)) {
            const pt = param_types[ai];
            if (!pt.isBuiltin()) {
                const pti = self.module.types.get(pt);
                if (pti == .pointer and self.inferExprType(arg) == pti.pointer.pointee) {
                    // `lowerExprAsPtr` yields the lvalue's address, typed
                    // either as `*T` already (index/deref) or as the pointee
                    // `T` (a field "place" ref); normalize to `*T` — exactly
                    // what `@field_access` does.
                    const place = self.lowerExprAsPtr(arg);
                    const place_ty = self.builder.getRefType(place);
                    const ref: ?Ref = if (place_ty == pt)
                        place
                    else if (place_ty == pti.pointer.pointee)
                        self.builder.emit(.{ .addr_of = .{ .operand = place } }, pt)
                    else
                        null;
                    if (ref) |r| {
                        args.append(self.alloc, r) catch unreachable;
                        self.target_type = saved_target;
                        continue;
                    }
                }
            }
        }
        const val = self.lowerExpr(arg);
        self.target_type = saved_target;
        // Passing a `*T` where a `T` value is expected — a by-reference loop
        // capture (`for xs: (*m)`), a `*T` parameter, or any pointer local —
        // otherwise slips through to LLVM as an opaque "call parameter type
        // does not match function signature" verifier error. Flag it at the
        // call site with a `.*` fix-it.
        if (ai < param_types.len) {
            const vt = self.builder.getRefType(val);
            const vti = self.module.types.get(vt);
            if (vti == .pointer and vti.pointer.pointee == param_types[ai]) {
                if (self.diagnostics) |d| {
                    const tn = self.formatTypeName(param_types[ai]);
                    if (arg.data == .identifier) {
                        const nm = arg.data.identifier.name;
                        const lead: []const u8 = if (self.refCapturePointee(arg) != null) "by-reference loop capture" else "argument";
                        const fix = std.fmt.allocPrint(self.alloc, "{s}.*", .{nm}) catch nm;
                        const pid = d.addFmtId(.err, arg.span, "{s} '{s}' has type '*{s}', but '{s}' is expected here", .{ lead, nm, tn, tn });
                        d.addHelpFmt(pid, arg.span, fix, "dereference it to pass the value: `{s}`", .{fix});
                    } else {
                        const pid = d.addFmtId(.err, arg.span, "this argument has type '*{s}', but '{s}' is expected here", .{ tn, tn });
                        d.addHelpFmt(pid, arg.span, null, "dereference it with `.*` to pass the value", .{});
                    }
                }
            }
        }
        args.append(self.alloc, val) catch unreachable;
    }

    switch (c.callee.data) {
        .identifier => |id| {
            // Resolve local function name (bare → mangled) and UFCS aliases
            const func_name = blk: {
                // First try scope lookup for mangled local fn names
                const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
                // Then try UFCS alias on bare name
                if (self.program_index.ufcs_alias_map.get(id.name)) |target| {
                    // Resolve the alias target through scope too (target may be mangled)
                    break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
                }
                break :blk scoped;
            };

            // Handle cast(TargetType, val) — emit conversion instructions
            // for any compile-time-resolvable type argument. The gate is the
            // canonical `isStaticTypeArg` (the one `type_name`/`type_eq` use),
            // so compound shapes (`*T`, `[]T`, `?T`, `[*]T`, `[N]T`) resolve
            // statically instead of falling into the runtime-dispatch path
            // and dying unresolved (issue 0118). An identifier bound in scope
            // as a runtime `Type` value (the `cast(type) val` category-arm
            // form) still classifies as non-static and falls through.
            if (std.mem.eql(u8, id.name, "cast") and c.args.len >= 2) {
                const type_arg = c.args[0];
                if (self.isStaticTypeArg(type_arg)) {
                    const dst_ty = self.resolveTypeArg(c.args[0]);
                    const val = args.items[1]; // already lowered
                    const src_ty = self.inferExprType(c.args[1]);
                    // Unbox Any → concrete type
                    if (src_ty == .any) {
                        return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty);
                    }
                    return self.coerceExplicit(val, src_ty, dst_ty);
                }
                // Runtime cast — fall through to builtin handling
            }
            // Check builtins first (these are handled natively by interpreter and emitter)
            if (resolveBuiltin(id.name)) |bid| {
                const ret_ty: TypeId = switch (bid) {
                    .size_of, .align_of => .i64,
                    .sqrt, .sin, .cos, .floor => blk: {
                        // Math builtins: return type matches argument type ($T -> T)
                        if (c.args.len > 0) {
                            const arg_ty = self.inferExprType(c.args[0]);
                            if (arg_ty == .f32) break :blk TypeId.f32;
                        }
                        break :blk TypeId.f64;
                    },
                    else => .void,
                };
                return self.builder.callBuiltin(bid, args.items, ret_ty);
            }
            // Check scope first: local variables (closures, fn ptrs) shadow global functions
            if (self.scope) |scope| {
                if (scope.lookup(id.name)) |binding| {
                    if (!binding.ty.isBuiltin()) {
                        const ty_info = self.module.types.get(binding.ty);
                        if (ty_info == .closure) {
                            const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
                            // Closure trampolines carry `__sx_ctx` at
                            // slot 0; emit_llvm's `call_closure` builds
                            // the call as [ctx, env, user_args], so we
                            // prepend ctx here. args[0] becomes ctx.
                            const owned = if (self.implicit_ctx_enabled) blk: {
                                const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
                                arr[0] = self.current_ctx_ref;
                                @memcpy(arr[1..], args.items);
                                break :blk arr;
                            } else self.alloc.dupe(Ref, args.items) catch unreachable;
                            const ret_ty = ty_info.closure.ret;
                            return self.builder.emit(.{ .call_closure = .{ .callee = callee_ref, .args = owned } }, ret_ty);
                        }
                    }
                }
            }
            // R5 §C: a genuine flat same-name collision — bind the
            // author the call resolver selected (own-author-wins, or the single
            // flat-reachable author), or reject a bare call to a name ≥2
            // imported modules author. `selectedFreeAuthor` (computed once
            // above, and the exact verdict `plan` consumes for typing) is the
            // single producer; lowering CONSUMES it rather than re-resolving
            // the name, so typing and dispatch read the SAME author and can't
            // disagree. Reached only for an identifier callee, so
            // `sel_author` / `author_ambiguous` here are the bare verdict.
            if (author_ambiguous) {
                if (self.diagnostics) |d|
                    d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name});
                return Ref.none;
            }
            // `args.items` is the post-expansion count: trailing defaults
            // were filled by `expandCallDefaults`, comptime-pack spreads
            // expanded element-wise above.
            {
                const arity_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else self.program_index.fn_ast_map.get(func_name);
                if (arity_fd) |fd| {
                    if (self.checkCallArity(fd, id.name, args.items.len, false, c.callee.span)) return Ref.none;
                }
            }
            if (sel_author) |sf| {
                const fid = self.selectedFuncId(sf, func_name);
                const func = &self.module.functions.items[@intFromEnum(fid)];
                const ret_ty = func.ret;
                const params = func.params;
                // The RESOLVED author's decl drives variadic packing — not a
                // first-wins re-lookup by name, whose variadic shape may
                // differ.
                self.packVariadicCallArgs(sf.decl, c, &args);
                const final_args = self.prependCtxIfNeeded(func, args.items);
                self.coerceCallArgs(final_args, params);
                if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
                return self.builder.call(fid, final_args, ret_ty);
            }
            // Check for comptime-expanded or generic functions
            if (self.program_index.fn_ast_map.get(func_name)) |fd| {
                if (hasComptimeParams(fd)) {
                    return self.lowerComptimeCall(fd, c);
                }
                if (fd.type_params.len > 0) {
                    // Runtime dispatch already handled above (before arg lowering)
                    return self.lowerGenericCall(fd, func_name, c, args.items);
                }
            }
            // Look up declared/extern function — try lazy lowering if not yet lowered
            {
                // First attempt: function may already be declared (from scanDecls)
                // but not yet lowered. Try lazy lowering if needed.
                if (self.program_index.fn_ast_map.contains(func_name) and !self.lowered_functions.contains(func_name)) {
                    self.lazyLowerFunction(func_name);
                }
                if (self.resolveFuncByName(func_name)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    const ret_ty = func.ret;
                    const params = func.params;
                    // Pack variadic args into a slice if the function has a variadic param
                    if (self.program_index.fn_ast_map.get(func_name)) |fd| {
                        self.packVariadicCallArgs(fd, c, &args);
                    }
                    const final_args = self.prependCtxIfNeeded(func, args.items);
                    // Coerce arguments to match parameter types
                    self.coerceCallArgs(final_args, params);
                    if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
                    return self.builder.call(fid, final_args, ret_ty);
                }
            }
            // May be a variable holding a function pointer (non-closure)
            if (self.scope) |scope| {
                if (scope.lookup(id.name)) |binding| {
                    const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
                    const ret_ty = if (!binding.ty.isBuiltin()) blk: {
                        const bti = self.module.types.get(binding.ty);
                        break :blk if (bti == .function) bti.function.ret else .i64;
                    } else .i64;
                    var final_args = std.ArrayList(Ref).empty;
                    defer final_args.deinit(self.alloc);
                    if (self.fnPtrTypeWantsCtx(binding.ty)) {
                        final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
                    }
                    final_args.appendSlice(self.alloc, args.items) catch unreachable;
                    const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
                    return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, ret_ty);
                }
            }
            // May be a global variable holding a function pointer
            if (self.resolveGlobalRef(id.name, c.callee.span)) |gi| {
                if (!gi.ty.isBuiltin()) {
                    const gti = self.module.types.get(gi.ty);
                    if (gti == .function) {
                        const callee_ref = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                        // Coerce args to match fn-ptr param types (including implicit address-of)
                        for (args.items, 0..) |*arg, ai| {
                            if (ai < gti.function.params.len) {
                                const dst_ty = gti.function.params[ai];
                                const src_ty = self.inferExprType(c.args[ai]);
                                // Implicit address-of: passing T where *T expected
                                if (!dst_ty.isBuiltin()) {
                                    const dti = self.module.types.get(dst_ty);
                                    if (dti == .pointer and dti.pointer.pointee == src_ty and src_ty != .void) {
                                        // For identifier args, pass the alloca directly (reference semantics)
                                        if (c.args[ai].data == .identifier) {
                                            if (self.scope) |scope| {
                                                if (scope.lookup(c.args[ai].data.identifier.name)) |binding| {
                                                    if (binding.is_alloca) {
                                                        arg.* = self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, dst_ty);
                                                        continue;
                                                    }
                                                }
                                            }
                                        }
                                        // For other expressions, copy semantics
                                        const slot = self.builder.alloca(src_ty);
                                        self.builder.store(slot, arg.*);
                                        arg.* = slot;
                                        continue;
                                    }
                                }
                                arg.* = self.coerceToType(arg.*, src_ty, dst_ty);
                            }
                        }
                        var final_args = std.ArrayList(Ref).empty;
                        defer final_args.deinit(self.alloc);
                        if (self.fnPtrTypeWantsCtx(gi.ty)) {
                            final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
                        }
                        final_args.appendSlice(self.alloc, args.items) catch unreachable;
                        const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
                        return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, gti.function.ret);
                    }
                }
            }
            // Unresolved function call
            return self.emitError(id.name, c.callee.span);
        },
        .field_access => |fa| {
            // `super.method(args)` from inside a `#jni_main` (or any
            // sx-defined `#jni_class`) bodied method. Dispatch via
            // CallNonvirtual<T>Method against the parent class
            // resolved from the enclosing fcd's `#extends` clause.
            if (fa.object.data == .identifier and
                std.mem.eql(u8, fa.object.data.identifier.name, "super"))
            {
                return self.lowerSuperCall(fa.field, args.items, c.callee.span);
            }

            // `Alias.method(args)` where Alias is a runtime-class
            // identifier and `method` is a `static` member — JNI
            // dispatch via FindClass + GetStaticMethodID + CallStatic*,
            // OR (for `new`) via FindClass + GetMethodID("<init>") +
            // NewObject. Falls through to existing paths when no match.
            if (fa.object.data == .identifier) {
                const alias = fa.object.data.identifier.name;
                if (self.program_index.runtime_class_map.get(alias)) |fcd| {
                    for (fcd.members) |m| switch (m) {
                        .method => |md| if (md.is_static and std.mem.eql(u8, md.name, fa.field)) {
                            return self.lowerRuntimeStaticCall(fcd, md, args.items, c.callee.span);
                        },
                        else => {},
                    };
                }
            }

            // Type constructor call: Sx(f32).user(0.5) — obj is a call that returns a type
            if (fa.object.data == .call) {
                const inner_call = &fa.object.data.call;
                // Generic struct STATIC-METHOD head (`Box(i64).make(..)` or the
                // qualified `a.Box(i64).make(..)`): the layout author is chosen
                // by the single head choke-point (CP-1) and the method body by
                // the instance's STAMPED author (CP-4), so layout-author ≡
                // body-author for BOTH bare and qualified heads (E4 #1 / #2).
                if (headNameOfCallee(inner_call.callee)) |hn| {
                    switch (self.selectGenericStructHead(hn.name, hn.alias, hn.is_qualified, inner_call.callee.span)) {
                        .poisoned => return Ref.none,
                        .template => |t| {
                            const inst_ty = self.instantiateGenericStruct(&t, inner_call.args);
                            const inst_name = self.formatTypeName(inst_ty);
                            if (self.genericInstanceMethod(inst_name, fa.field)) |gm| {
                                if (self.ensureGenericInstanceMethodLowered(gm)) |fid| {
                                    const func = &self.module.functions.items[@intFromEnum(fid)];
                                    const final_args = self.prependCtxIfNeeded(func, args.items);
                                    self.coerceCallArgs(final_args, func.params);
                                    return self.builder.call(fid, final_args, func.ret);
                                }
                            }
                        },
                        .not_generic => {},
                    }
                }

                if (inner_call.callee.data == .identifier) {
                    const inner_name = inner_call.callee.data.identifier.name;
                    const resolved = if (self.scope) |scope| (scope.lookupFn(inner_name) orelse inner_name) else inner_name;

                    if (self.program_index.fn_ast_map.get(resolved)) |fd| {
                        if (fd.type_params.len > 0) {
                            if (self.headFnLeak(inner_name, inner_call.callee.span)) return Ref.none;
                            // Try instantiate as type function
                            if (self.instantiateTypeFunction(inner_name, inner_name, fd, inner_call.args)) |result_ty| {
                                const type_info = self.module.types.get(result_ty);
                                if (type_info == .tagged_union) {
                                    // Qualified enum construction: Type.variant(payload)
                                    const tag = self.resolveVariantIndex(result_ty, fa.field);
                                    var payload = if (args.items.len > 0) args.items[0] else Ref.none;
                                    if (!payload.isNone()) {
                                        const fields = type_info.tagged_union.fields;
                                        if (tag < fields.len) {
                                            const field_ty = fields[tag].ty;
                                            if (field_ty != .void) {
                                                const payload_ty = self.inferExprType(c.args[0]);
                                                if (field_ty != payload_ty) {
                                                    payload = self.coerceToType(payload, payload_ty, field_ty);
                                                }
                                            }
                                        }
                                    }
                                    return self.builder.enumInit(tag, payload, result_ty);
                                }
                                if (type_info == .@"enum") {
                                    const tag = self.resolveVariantIndex(result_ty, fa.field);
                                    return self.builder.enumInit(tag, Ref.none, result_ty);
                                }
                            }
                        }
                    }
                }
            }

            // Namespace-qualified call (e.g. `std.print`) vs method / UFCS
            // call on a value (`recv.method`). This boundary decides whether
            // the receiver is prepended, so it MUST agree with the call
            // plan's `free_fn_ufcs` (prepends) vs `namespace_fn` (does not)
            // classification — source it from the single definition in
            // `CallResolver` rather than re-deriving it here.
            const is_namespace = !self.callResolver().objectIsValue(fa.object);

            if (is_namespace) {
                // Namespace call: module.func(args) — don't prepend object
                const func_name = fa.field;
                // Also try qualified name: Namespace.method (for struct methods)
                const ns_name: ?[]const u8 = switch (fa.object.data) {
                    .identifier => |id| id.name,
                    .type_expr => |te| te.name,
                    // `alias.Type.method()` — strip the alias so the existing
                    // `Type.method` qualified machinery resolves the static.
                    .field_access => self.namespaceRootedMember(fa.object),
                    else => null,
                };
                const qualified_name = if (ns_name) |n|
                    std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ n, fa.field }) catch func_name
                else
                    func_name;
                // The carry gate (issue 0114): a plain-identifier root that is
                // a namespace ALIAS (not a type / fn global name — those are
                // the `Type.method` paths below) must be visible under the
                // carry rule, and its fn members dispatch pinned to the
                // alias's TARGET module — never the global first-wins
                // qualified registration, never the last-wins bare fallback.
                gate: {
                    if (fa.object.data != .identifier) break :gate;
                    const oname = fa.object.data.identifier.name;
                    if (self.program_index.global_names.contains(oname)) break :gate;
                    switch (self.namespaceAliasVerdict(oname)) {
                        .target => |target| {
                            const fd = Lowering.namespaceFnMember(&target, fa.field) orelse break :gate;
                            // Extern / builtin / #compiler bodies keep their
                            // literal global symbol — the existing bare-name
                            // machinery below resolves them.
                            switch (fd.body.data) {
                                .builtin_expr => break :gate,
                                else => {},
                            }
                            if (hasComptimeParams(fd)) return self.lowerComptimeCall(fd, c);
                            if (fd.type_params.len > 0) return self.lowerGenericCall(fd, fa.field, c, args.items);
                            if (self.checkCallArity(fd, fa.field, args.items.len, false, c.callee.span)) return Ref.none;
                            var sf = SelectedFunc{ .decl = fd, .source = target.target_module_path };
                            const fid = self.selectedFuncId(&sf, fa.field);
                            const func = &self.module.functions.items[@intFromEnum(fid)];
                            self.packVariadicCallArgs(fd, c, &args);
                            const final_args = self.prependCtxIfNeeded(func, args.items);
                            self.coerceCallArgs(final_args, func.params);
                            if (func.is_variadic) self.promoteCVariadicArgs(final_args, func.params.len);
                            return self.builder.call(fid, final_args, func.ret);
                        },
                        .ambiguous => {
                            if (self.diagnostics) |d|
                                d.addFmt(.err, fa.object.span, "namespace '{s}' is ambiguous: aliases from multiple flat-imported modules point at different targets; declare the alias locally", .{oname});
                            return Ref.none;
                        },
                        .none => {
                            if (self.aliasDeclaredAnywhere(oname)) {
                                if (self.diagnostics) |d|
                                    d.addFmt(.err, fa.object.span, "namespace '{s}' is not visible; #import the module that declares it", .{oname});
                                return Ref.none;
                            }
                        },
                    }
                }
                // Check for comptime-expanded or generic functions (try both names)
                const effective_name = if (self.program_index.fn_ast_map.get(qualified_name) != null) qualified_name else func_name;
                if (self.program_index.fn_ast_map.get(effective_name)) |fd| {
                    if (hasComptimeParams(fd)) {
                        return self.lowerComptimeCall(fd, c);
                    }
                    if (fd.type_params.len > 0) {
                        return self.lowerGenericCall(fd, effective_name, c, args.items);
                    }
                }
                if (self.program_index.fn_ast_map.contains(effective_name) and !self.lowered_functions.contains(effective_name)) {
                    self.lazyLowerFunction(effective_name);
                }
                if (self.resolveFuncByName(effective_name)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    const ret_ty = func.ret;
                    const params = func.params;
                    if (self.program_index.fn_ast_map.get(effective_name)) |fd| {
                        if (self.checkCallArity(fd, effective_name, args.items.len, false, c.callee.span)) return Ref.none;
                        self.packVariadicCallArgs(fd, c, &args);
                    }
                    const final_args = self.prependCtxIfNeeded(func, args.items);
                    self.coerceCallArgs(final_args, params);
                    if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
                    return self.builder.call(fid, final_args, ret_ty);
                }
                // Check if this is Type.variant(payload) — qualified enum construction
                if (ns_name) |type_name| {
                    const type_name_id = self.module.types.internString(type_name);
                    if (self.module.types.findByName(type_name_id)) |union_ty| {
                        const type_info = self.module.types.get(union_ty);
                        if (type_info == .tagged_union) {
                            const tag = self.resolveVariantIndex(union_ty, func_name);
                            var payload = if (args.items.len > 0) args.items[0] else Ref.none;
                            // Coerce payload to match field type
                            if (!payload.isNone()) {
                                const fields = type_info.tagged_union.fields;
                                if (tag < fields.len) {
                                    const field_ty = fields[tag].ty;
                                    const payload_ty = self.inferExprType(c.args[0]);
                                    if (field_ty != payload_ty) {
                                        payload = self.coerceToType(payload, payload_ty, field_ty);
                                    }
                                }
                            }
                            return self.builder.enumInit(tag, payload, union_ty);
                        }
                        if (type_info == .@"enum") {
                            const tag = self.resolveVariantIndex(union_ty, func_name);
                            return self.builder.enumInit(tag, Ref.none, union_ty);
                        }
                    }
                }
                return self.emitError(func_name, c.callee.span);
            }

            // Method call: obj.method(args) → prepend obj (or &obj for *Self receivers)
            // For ptr.*.method(): pass the pointer directly instead of loading + re-addressing.
            // This ensures mutations through self: *T are visible after the call.
            var obj_ty: TypeId = undefined;
            var obj: Ref = undefined;
            var effective_obj_node: *const Node = fa.object;
            if (fa.object.data == .deref_expr) {
                effective_obj_node = fa.object.data.deref_expr.operand;
                obj_ty = self.inferExprType(effective_obj_node);
                obj = self.lowerExpr(effective_obj_node);
            } else {
                obj_ty = self.inferExprType(fa.object);
                obj = self.lowerExpr(fa.object);
            }

            // Check if field is a closure type — call as closure, not method
            if (!obj_ty.isBuiltin()) {
                const field_name_id = self.module.types.internString(fa.field);
                const struct_fields = self.getStructFields(obj_ty);
                for (struct_fields, 0..) |f, fi| {
                    if (f.name == field_name_id and !f.ty.isBuiltin()) {
                        const fti = self.module.types.get(f.ty);
                        if (fti == .closure) {
                            // structGet requires an aggregate value; if obj is *T, load through it first.
                            var agg = obj;
                            const oi = self.module.types.get(obj_ty);
                            if (oi == .pointer) {
                                agg = self.builder.load(obj, oi.pointer.pointee);
                            }
                            const closure_val = self.builder.structGet(agg, @intCast(fi), f.ty);
                            // Prepend ctx for sx-side closure call ABI.
                            const owned = if (self.implicit_ctx_enabled) blk: {
                                const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
                                arr[0] = self.current_ctx_ref;
                                @memcpy(arr[1..], args.items);
                                break :blk arr;
                            } else self.alloc.dupe(Ref, args.items) catch unreachable;
                            return self.builder.emit(.{ .call_closure = .{ .callee = closure_val, .args = owned } }, fti.closure.ret);
                        }
                    }
                }
            }

            // Check if receiver is a protocol type → dispatch through
            // vtable/fn_ptrs — but only for the protocol's OWN methods. A
            // non-member field falls through to the free-fn ufcs machinery
            // (`context.allocator.create(Session)` — a ufcs fn taking the
            // protocol value as its first param).
            if (self.getProtocolInfo(obj_ty)) |proto_info| {
                if (protocolHasMethod(proto_info, fa.field)) {
                    return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, obj_ty, c.callee.span);
                }
            }

            // Check if receiver is `?Protocol` — for sentinel-shaped
            // optionals (Protocol has ctx as first ptr field, and a
            // null ctx is the "none" state) the unwrap is a no-op
            // structurally. Treat the optional value as the protocol
            // value and dispatch. Calling a method on a null protocol
            // is undefined (same as derefing a null pointer); user
            // guards with `if x != null` first.
            if (!obj_ty.isBuiltin()) {
                const opt_info = self.module.types.get(obj_ty);
                if (opt_info == .optional) {
                    const pay_ty = opt_info.optional.child;
                    if (self.getProtocolInfo(pay_ty)) |proto_info| {
                        if (protocolHasMethod(proto_info, fa.field)) {
                            return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, pay_ty, c.callee.span);
                        }
                    }
                }
            }

            var method_args = std.ArrayList(Ref).empty;
            defer method_args.deinit(self.alloc);
            method_args.append(self.alloc, obj) catch unreachable;
            for (args.items) |a| {
                method_args.append(self.alloc, a) catch unreachable;
            }

            // Runtime-class DSL: `inst.method(args)` where `inst`'s
            // type is an alias declared by `#jni_class("...") { ... }`
            // (or its parallel forms). Routes to the JNI dispatch
            // shape, descriptor derived from the sx signature.
            const struct_name = self.getStructTypeName(obj_ty);
            if (struct_name) |sname_for_runtime| {
                if (self.program_index.runtime_class_map.get(sname_for_runtime)) |fcd| {
                    return self.lowerRuntimeMethodCall(fcd, fa.field, obj, args.items, c.callee.span);
                }
            }

            // Try to resolve the method by struct type name
            if (struct_name) |sname| {
                // Try direct qualified name: StructName.method
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch fa.field;

                // Generic-struct instance method: select the body via the
                // instance's STAMPED author (CP-4), so the dispatched method is
                // the one authored alongside this instance's layout — never the
                // global last-wins `fn_ast_map["Template.method"]`.
                if (self.genericInstanceMethod(sname, fa.field)) |gm| {
                    // A comptime method (`pick :: (self: *Box(T), $o: Ord)`) must
                    // INLINE so its `$o` binds — a plain `call` to a monomorphized
                    // FuncId would leave `o` unresolved. The struct is already
                    // monomorphized for `T`; this composes that with the
                    // comptime-value-param binding (`bindComptimeValueParams`).
                    if (hasComptimeParams(gm.fd)) {
                        return self.lowerComptimeGenericInstanceMethod(gm, effective_obj_node, obj, obj_ty, c.args);
                    }
                    if (self.ensureGenericInstanceMethodLowered(gm)) |fid| {
                        const func = &self.module.functions.items[@intFromEnum(fid)];
                        const ret_ty = func.ret;
                        const params = func.params;
                        self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
                        self.appendDefaultArgs(gm.fd, &method_args);
                        const final_args = self.prependCtxIfNeeded(func, method_args.items);
                        self.coerceCallArgs(final_args, params);
                        return self.builder.call(fid, final_args, ret_ty);
                    }
                }

                // Generic method on a non-template struct: `obj.method($T, ...)`
                // or inferred form `obj.method(val)` where val's type pins $T.
                if (self.program_index.fn_ast_map.get(qualified)) |gen_fd| {
                    if (gen_fd.type_params.len > 0) {
                        // Effective AST args: prepend receiver so positions
                        // line up with fd.params (which has self at index 0).
                        var eff_args = std.ArrayList(*const Node).empty;
                        defer eff_args.deinit(self.alloc);
                        eff_args.append(self.alloc, effective_obj_node) catch unreachable;
                        for (c.args) |a| eff_args.append(self.alloc, a) catch unreachable;

                        var gbindings = self.genericResolver().buildTypeBindings(gen_fd, eff_args.items);
                        defer gbindings.deinit();

                        const gmangled = self.genericResolver().mangleGenericName(qualified, gen_fd, &gbindings);
                        if (!self.lowered_functions.contains(gmangled)) {
                            self.monomorphizeFunction(gen_fd, gmangled, &gbindings);
                        }
                        if (self.resolveFuncByName(gmangled)) |gfid| {
                            const gfunc = &self.module.functions.items[@intFromEnum(gfid)];
                            const gret_ty = gfunc.ret;
                            const gparams = gfunc.params;
                            // Strip type-decl slots from method_args. method_args[0] is the
                            // receiver (corresponds to fd.params[0] = self, never a type decl).
                            // Walk fd.params[1..], advance arg_idx through method_args[1..].
                            var gvalue_args = std.ArrayList(Ref).empty;
                            defer gvalue_args.deinit(self.alloc);
                            gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable;
                            const types_explicit = method_args.items.len == gen_fd.params.len;
                            var arg_idx: usize = 1;
                            for (gen_fd.params[1..]) |p| {
                                if (isTypeParamDecl(&p, gen_fd.type_params)) {
                                    if (types_explicit) arg_idx += 1;
                                    continue;
                                }
                                if (arg_idx < method_args.items.len) {
                                    gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable;
                                }
                                arg_idx += 1;
                            }
                            self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty);
                            const final_args = self.prependCtxIfNeeded(gfunc, gvalue_args.items);
                            self.coerceCallArgs(final_args, gparams);
                            return self.builder.call(gfid, final_args, gret_ty);
                        }
                    }
                }

                // Try non-generic qualified method
                const plain_method_fd = self.program_index.fn_ast_map.get(qualified);
                if (plain_method_fd) |fd| {
                    if (self.checkCallArity(fd, qualified, method_args.items.len, true, c.callee.span)) return Ref.none;
                    if (!self.lowered_functions.contains(qualified)) {
                        self.lazyLowerFunction(qualified);
                    }
                }
                if (self.resolveFuncByName(qualified)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    const ret_ty = func.ret;
                    const params = func.params;
                    const has_ctx = func.has_implicit_ctx;
                    self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
                    if (plain_method_fd) |fd| self.appendDefaultArgs(fd, &method_args);
                    // Note: coerceCallArgs can trigger protocol thunk creation
                    // (module.addFunction), invalidating func pointer.
                    // Use pre-extracted params/ret_ty (+ has_ctx) instead of
                    // func.* after this.
                    const final_args = blk: {
                        if (!has_ctx) break :blk method_args.items;
                        const new_args = self.alloc.alloc(Ref, method_args.items.len + 1) catch break :blk method_args.items;
                        new_args[0] = self.current_ctx_ref;
                        @memcpy(new_args[1..], method_args.items);
                        break :blk new_args;
                    };
                    self.coerceCallArgs(final_args, params);
                    return self.builder.call(fid, final_args, ret_ty);
                }
            }

            // Free-function dot-call (`recv.fn(args)` → `fn(recv, args)`)
            // is OPT-IN: only a fn declared `name :: ufcs (...) {...}` or a
            // `name :: ufcs target;` alias dispatches. A plain fn is
            // callable directly or via `|>` only — a dot-call on one gets a
            // tailored diagnostic rather than silently becoming a method.
            //
            // R5 §C: a free-function UFCS target with a
            // genuine flat same-name collision dispatches to the author the
            // call PLAN selected for the receiver's source — the SAME author
            // plan typed the call's result as, so dispatch and typing can't
            // disagree (without this, a string-typed winner over
            // an i64 shadow boxes a raw int as a string pointer → segfault).
            // The plan is the single producer; lowering consumes its verdict
            // (`sel_author` / `cplan.ambiguous_collision`, computed once above)
            // rather than re-resolving the field name. `.ambiguous` → loud
            // diagnostic; otherwise the existing first-wins lazy path.
            const alias_target = self.program_index.ufcs_alias_map.get(fa.field);
            const eff_field = alias_target orelse fa.field;
            const ufcs_fd = self.program_index.fn_ast_map.get(eff_field);
            const ufcs_opted_in = alias_target != null or (ufcs_fd != null and ufcs_fd.?.is_ufcs);

            if (ufcs_opted_in) {
                if (author_ambiguous) {
                    if (self.diagnostics) |d|
                        d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field});
                    return Ref.none;
                }
                // Generic ufcs target: monomorphize with the receiver's AST
                // node prepended so bindings align with fd.params[0].
                if (ufcs_fd) |fd| {
                    if (fd.type_params.len > 0) {
                        var eff_args = std.ArrayList(*const Node).empty;
                        defer eff_args.deinit(self.alloc);
                        eff_args.append(self.alloc, effective_obj_node) catch unreachable;
                        for (c.args) |arg| eff_args.append(self.alloc, arg) catch unreachable;
                        var gbindings = self.genericResolver().buildTypeBindings(fd, eff_args.items);
                        defer gbindings.deinit();
                        const gmangled = self.genericResolver().mangleGenericName(eff_field, fd, &gbindings);
                        if (!self.lowered_functions.contains(gmangled)) {
                            self.monomorphizeFunction(fd, gmangled, &gbindings);
                        }
                        if (self.resolveFuncByName(gmangled)) |gfid| {
                            const gfunc = &self.module.functions.items[@intFromEnum(gfid)];
                            const gret_ty = gfunc.ret;
                            const gparams = gfunc.params;
                            // Strip type-decl slots. method_args[0] is the
                            // receiver (a VALUE — a type-expr receiver
                            // classifies as a namespace call, never here),
                            // so fd.params[0] is a value param.
                            var gvalue_args = std.ArrayList(Ref).empty;
                            defer gvalue_args.deinit(self.alloc);
                            gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable;
                            const types_explicit = method_args.items.len == fd.params.len;
                            var arg_idx: usize = 1;
                            for (fd.params[1..]) |p| {
                                if (isTypeParamDecl(&p, fd.type_params)) {
                                    if (types_explicit) arg_idx += 1;
                                    continue;
                                }
                                if (arg_idx < method_args.items.len) {
                                    gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable;
                                }
                                arg_idx += 1;
                            }
                            self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty);
                            const final_args = self.prependCtxIfNeeded(gfunc, gvalue_args.items);
                            self.coerceCallArgs(final_args, gparams);
                            return self.builder.call(gfid, final_args, gret_ty);
                        }
                        return self.emitError(eff_field, c.callee.span);
                    }
                }
                const ufcs_arity_fd: ?*const ast.FnDecl = if (sel_author) |sf| sf.decl else ufcs_fd;
                if (ufcs_arity_fd) |fd| {
                    if (self.checkCallArity(fd, fa.field, method_args.items.len, true, c.callee.span)) return Ref.none;
                }
                const ufcs_fid: ?FuncId = blk_uf: {
                    if (sel_author) |sf| {
                        break :blk_uf self.selectedFuncId(sf, eff_field);
                    }
                    if (ufcs_fd != null) {
                        if (!self.lowered_functions.contains(eff_field)) {
                            self.lazyLowerFunction(eff_field);
                        }
                    }
                    break :blk_uf self.resolveFuncByName(eff_field);
                };
                if (ufcs_fid) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    const ret_ty = func.ret;
                    const params = func.params;
                    // Same implicit address-of as a struct-defined method: if the
                    // free function's first param is `*T` and the receiver is a
                    // value `T`, pass its address instead of a by-value copy
                    self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
                    if (ufcs_arity_fd) |fd| self.appendDefaultArgs(fd, &method_args);
                    const final_args = self.prependCtxIfNeeded(func, method_args.items);
                    self.coerceCallArgs(final_args, params);
                    return self.builder.call(fid, final_args, ret_ty);
                }
                return self.emitError(eff_field, c.callee.span);
            }

            // A fn by this name exists but is not dot-callable: tailored help.
            if (ufcs_fd != null or self.resolveFuncByName(fa.field) != null) {
                if (self.diagnostics) |d| {
                    const id = d.addFmtId(.err, c.callee.span, "'{s}' is not a ufcs function — a plain function does not dispatch via dot-call", .{fa.field});
                    d.addHelpFmt(id, c.callee.span, null, "call it directly (`{s}(receiver, ...)`), pipe it (`receiver |> {s}(...)`), or declare it `{s} :: ufcs (...) {{ ... }}`", .{ fa.field, fa.field, fa.field });
                }
                return Ref.none;
            }
            return self.emitError(fa.field, c.callee.span);
        },
        .enum_literal => |el| {
            const target_opt: ?TypeId = self.target_type;

            // Try struct-method dispatch first: .{...}.method() where target is a struct
            if (target_opt) |tgt| {
                if (!tgt.isBuiltin()) {
                    const target_info = self.module.types.get(tgt);
                    if (target_info == .@"struct") {
                        const struct_name = self.module.types.typeName(tgt);
                        const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ struct_name, el.name }) catch el.name;
                        if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                            if (fd.type_params.len > 0) {
                                return self.lowerGenericCall(fd, qualified, c, args.items);
                            }
                            if (!self.lowered_functions.contains(qualified)) {
                                self.lazyLowerFunction(qualified);
                            }
                        }
                        if (self.resolveFuncByName(qualified)) |fid| {
                            const func = &self.module.functions.items[@intFromEnum(fid)];
                            const ret_ty = func.ret;
                            const params = func.params;
                            const final_args = self.prependCtxIfNeeded(func, args.items);
                            self.coerceCallArgs(final_args, params);
                            return self.builder.call(fid, final_args, ret_ty);
                        }
                    }
                }
            }

            // .Variant(payload) — tagged enum construction. Requires target to be a tagged union.
            const target = blk: {
                if (target_opt) |tgt| {
                    if (!tgt.isBuiltin() and self.module.types.get(tgt) == .tagged_union) break :blk tgt;
                }
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, c.callee.span, "cannot infer enum type for '.{s}' \u{2014} use an explicit type or assign to a typed variable", .{el.name});
                }
                return self.emitPlaceholder(el.name);
            };
            const tag = self.resolveVariantIndex(target, el.name);
            var payload = if (args.items.len > 0) args.items[0] else Ref.none;
            // Coerce payload to match the field type
            if (!payload.isNone() and !target.isBuiltin()) {
                const info = self.module.types.get(target);
                if (info == .tagged_union) {
                    const fields = info.tagged_union.fields;
                    if (tag < fields.len) {
                        const field_ty = fields[tag].ty;
                        const payload_ty = self.inferExprType(c.args[0]);
                        if (field_ty != payload_ty) {
                            payload = self.coerceToType(payload, payload_ty, field_ty);
                        }
                    }
                }
            }
            return self.builder.enumInit(tag, payload, target);
        },
        else => {
            // Indirect call through expression
            const callee_ref = self.lowerExpr(c.callee);
            const owned = self.alloc.dupe(Ref, args.items) catch unreachable;
            return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, .i64);
        },
    }
}

/// Emit a diagnostic for code that needs `Context` (allocator
/// protocol, `push Context.{...}`, the `context` identifier) when
/// the program hasn't registered the type — i.e. doesn't transitively
/// import `modules/std.sx`. Returns a placeholder Ref so the lowering
/// can keep going and surface any additional errors.
pub fn diagnoseMissingContext(self: *Lowering, what: []const u8) Ref {
    if (self.diagnostics) |d| {
        const span = ast.Span{ .start = 0, .end = 0 };
        d.addFmt(.err, span, "{s} requires the Context type — add `#import \"modules/std.sx\";` (or a module that imports it)", .{what});
    }
    return self.emitPlaceholder("missing-context");
}

/// Emit `context.allocator.alloc(size)` dispatch — used by internal
/// compiler-driven heap copies (e.g. the `xx value` protocol-erasure
/// path in `buildProtocolValue`). Routes through whatever allocator is
/// currently installed in `context`, so a surrounding
/// `push Context.{ allocator = my_alloc, ... }` actually backs every
/// allocation including the ones the compiler inserts.
///
/// If `Context` isn't registered (the program doesn't import std.sx),
/// emits a diagnostic and returns a placeholder. We deliberately do
/// NOT fall back to a direct libc malloc — that was the silent escape
/// hatch that bit us through the implicit-context refactor (see the
/// "Silent unimplemented arms" REJECTED PATTERN in CLAUDE.md).
pub fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref {
    if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) {
        return self.diagnoseMissingContext("heap allocation");
    }
    const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
        return self.diagnoseMissingContext("heap allocation");
    };
    const ctx_ty_info = self.module.types.get(ctx_ty);
    if (ctx_ty_info != .@"struct" or ctx_ty_info.@"struct".fields.len < 1) {
        return self.diagnoseMissingContext("heap allocation");
    }
    const allocator_ty = ctx_ty_info.@"struct".fields[0].ty;
    const ctx = self.builder.load(self.current_ctx_ref, ctx_ty);
    const allocator = self.builder.structGet(ctx, 0, allocator_ty);
    // #inline Allocator protocol layout: { ctx, alloc_fn_ptr, dealloc_fn_ptr }.
    // field 0 = receiver ctx, field 1 = alloc fn-ptr.
    const alloc_ctx = self.builder.structGet(allocator, 0, void_ptr_ty);
    const fn_ptr = self.builder.structGet(allocator, 1, void_ptr_ty);
    // Allocator thunks are sx-side and carry the implicit __sx_ctx at
    // slot 0. Forward our caller's current_ctx_ref so the thunk's body
    // (and the concrete alloc method it forwards to) has a real
    // Context to thread on.
    const args = if (self.implicit_ctx_enabled)
        self.alloc.dupe(Ref, &.{ self.current_ctx_ref, alloc_ctx, size_ref }) catch unreachable
    else
        self.alloc.dupe(Ref, &.{ alloc_ctx, size_ref }) catch unreachable;
    return self.builder.emit(.{ .call_indirect = .{
        .callee = fn_ptr,
        .args = args,
    } }, void_ptr_ty);
}

/// Emit a call to a extern-declared function looked up by name.
/// Used for the compiler-internal byte-copy in the protocol-erasure
/// heap path and the closure env-copy path, both of which need
/// libc `memcpy` after the `#builtin` form was dropped.
pub fn callExtern(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref {
    const fid = self.resolveFuncByName(name) orelse @panic("extern symbol missing — std.sx not imported?");
    return self.builder.call(fid, args, ret_ty);
}

/// Prepend the caller's current `__sx_ctx` to `args` when the callee
/// has the implicit context param. Returns either the original `args`
/// (when no prepend is needed) or a newly-allocated slice with ctx at
/// slot 0. The returned slice is mutable so callers can pass it
/// straight into `coerceCallArgs`. Direct callers that built the args
/// themselves with __sx_ctx already prepended (protocol thunks, FFI
/// wrappers in Step 4) should NOT call this — they already manage
/// slot 0.
pub fn prependCtxIfNeeded(self: *Lowering, callee: *const Function, args: []Ref) []Ref {
    if (!callee.has_implicit_ctx) return args;
    const new_args = self.alloc.alloc(Ref, args.len + 1) catch return args;
    new_args[0] = self.current_ctx_ref;
    @memcpy(new_args[1..], args);
    return new_args;
}


fn protocolHasMethod(proto_info: anytype, name: []const u8) bool {
    for (proto_info.methods) |m| {
        if (std.mem.eql(u8, m.name, name)) return true;
    }
    return false;
}

pub fn resolveFuncByName(self: *Lowering, name: []const u8) ?FuncId {
    // Check extern name map first (e.g., "c_abs" → "abs")
    const effective_name = self.extern_name_map.get(name) orelse name;
    const name_id = self.module.types.internString(effective_name);
    for (self.module.functions.items, 0..) |func, i| {
        if (func.name == name_id) return FuncId.fromIndex(@intCast(i));
    }
    return null;
}

pub fn resolveBuiltin(name: []const u8) ?inst_mod.BuiltinId {
    const builtins = .{
        // Note: "print" is NOT here — it's a comptime-expanded function, not a simple builtin.
        // "out" is NOT here either — it's a plain sx function (libc `write`), not a builtin.
        .{ "sqrt", inst_mod.BuiltinId.sqrt },
        .{ "sin", inst_mod.BuiltinId.sin },
        .{ "cos", inst_mod.BuiltinId.cos },
        .{ "floor", inst_mod.BuiltinId.floor },
        .{ "size_of", inst_mod.BuiltinId.size_of },
        .{ "align_of", inst_mod.BuiltinId.align_of },
        .{ "cast", inst_mod.BuiltinId.cast },
    };
    inline for (builtins) |entry| {
        if (std.mem.eql(u8, name, entry[0])) return entry[1];
    }
    return null;
}

// ── Generic calls ─────────────────────────────────────────────

/// Build `tp.name -> TypeId` bindings for a generic call.
/// `args_ast` must be parallel to `fd.params`; for dot-calls the caller
/// prepends the receiver's AST node so positions align with `fd.params[0] = self`.
/// Caller owns the returned map and must call `.deinit()`.
/// Lower a call to a generic function by monomorphizing it with inferred type arguments.
pub fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
    var bindings = self.genericResolver().buildTypeBindings(fd, call_node.args);
    defer bindings.deinit();

    // An uninferrable TYPE param must diagnose here: monomorphizing with
    // it unbound stamps `.unresolved` through the body and trips the
    // emitter's sentinel panic instead of surfacing a source error.
    // Comptime VALUE params (`$N: u32`) and `..$Ts` packs bind through
    // their own dispatch and are exempt.
    for (fd.type_params) |tp| {
        if (tp.is_variadic) continue;
        if (tp.constraint.data != .type_expr) continue;
        const cname = tp.constraint.data.type_expr.name;
        const is_type_param = std.mem.eql(u8, cname, "Type") or
            self.program_index.protocol_decl_map.contains(cname) or
            self.program_index.protocol_ast_map.contains(cname);
        if (is_type_param and !bindings.contains(tp.name)) {
            if (self.diagnostics) |d|
                d.addFmt(.err, call_node.callee.span, "cannot infer generic type parameter '{s}' for '{s}' from this call's arguments", .{ tp.name, base_name });
            return Ref.none;
        }
    }

    const types_passed_explicitly = call_node.args.len == fd.params.len;
    const mangled_name = self.genericResolver().mangleGenericName(base_name, fd, &bindings);

    if (!self.lowered_functions.contains(mangled_name)) {
        self.monomorphizeFunction(fd, mangled_name, &bindings);
    }

    if (self.resolveFuncByName(mangled_name)) |fid| {
        const func = &self.module.functions.items[@intFromEnum(fid)];
        const ret_ty = func.ret;
        const params = func.params;
        // Build value-only args (skip type param declaration args)
        var value_args = std.ArrayList(Ref).empty;
        defer value_args.deinit(self.alloc);
        var arg_idx: usize = 0;
        for (fd.params) |p| {
            if (isTypeParamDecl(&p, fd.type_params)) {
                if (types_passed_explicitly) arg_idx += 1;
                continue;
            }
            if (arg_idx < lowered_args.len) {
                value_args.append(self.alloc, lowered_args[arg_idx]) catch unreachable;
            }
            arg_idx += 1;
        }
        const final_args = self.prependCtxIfNeeded(func, value_args.items);
        self.coerceCallArgs(final_args, params);
        return self.builder.call(fid, final_args, ret_ty);
    }

    return self.emitError(base_name, call_node.callee.span);
}

/// Create a monomorphized instance of a generic function.
/// Check if a call has a `cast(runtime_var, val)` argument (runtime type dispatch pattern).
pub fn hasCastWithRuntimeType(self: *Lowering, c: *const ast.Call) bool {
    for (c.args) |arg| {
        if (arg.data == .call) {
            if (arg.data.call.callee.data == .identifier) {
                const name = arg.data.call.callee.data.identifier.name;
                if (std.mem.eql(u8, name, "cast") and arg.data.call.args.len == 2) {
                    const type_arg = arg.data.call.args[0];
                    if (type_arg.data == .identifier) {
                        // It's a runtime type if it's in scope as a variable
                        if (self.scope) |scope| {
                            if (scope.lookup(type_arg.data.identifier.name) != null) return true;
                        }
                    }
                }
            }
        }
    }
    return false;
}

/// Generate runtime dispatch for a generic call inside a type-match arm.
/// For each type tag in match_tags, monomorphizes the generic function and calls it.
pub fn lowerRuntimeDispatchCall(
    self: *Lowering,
    fd: *const ast.FnDecl,
    base_name: []const u8,
    call_node: *const ast.Call,
    match_tags: []const u64,
) Ref {
    // Find the cast arg: cast(type_var, any_val)
    var cast_arg_idx: usize = 0;
    var type_tag_node: ?*const Node = null;
    var any_val_node: ?*const Node = null;
    for (call_node.args, 0..) |arg, i| {
        if (arg.data == .call and arg.data.call.callee.data == .identifier) {
            const name = arg.data.call.callee.data.identifier.name;
            if (std.mem.eql(u8, name, "cast") and arg.data.call.args.len == 2) {
                cast_arg_idx = i;
                type_tag_node = arg.data.call.args[0];
                any_val_node = arg.data.call.args[1];
                break;
            }
        }
    }

    // Lower the type tag (runtime value) and Any value BEFORE the switch
    const type_tag_raw = self.lowerExpr(type_tag_node orelse return self.emitError("dispatch", call_node.callee.span));
    const type_tag_node_ty = self.inferExprType(type_tag_node.?);
    const type_tag = if (type_tag_node_ty == .any)
        self.builder.emit(.{ .unbox_any = .{ .operand = type_tag_raw } }, .i64)
    else
        type_tag_raw;
    const any_val = self.lowerExpr(any_val_node orelse return self.emitError("dispatch", call_node.callee.span));

    // Lower non-cast arguments once (before the switch)
    var other_args = std.ArrayList(?Ref).empty;
    defer other_args.deinit(self.alloc);
    for (call_node.args, 0..) |arg, i| {
        if (i == cast_arg_idx) {
            other_args.append(self.alloc, null) catch unreachable; // placeholder
        } else {
            other_args.append(self.alloc, self.lowerExpr(arg)) catch unreachable;
        }
    }

    // Resolve return type (using first available binding)
    const ret_ty: TypeId = blk: {
        if (fd.return_type) |rt| {
            if (rt.data == .type_expr) {
                if (type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map) != .unresolved) {
                    break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map);
                }
            }
        }
        break :blk .string; // default for to_string functions
    };

    const merge_bb = self.freshBlock("dispatch.merge");
    const default_bb = self.freshBlock("dispatch.default");

    // Build switch cases
    var cases = std.ArrayList(inst_mod.SwitchBranch.Case).empty;
    defer cases.deinit(self.alloc);

    // For each type tag, create a case block
    var case_blocks = std.ArrayList(BlockId).empty;
    defer case_blocks.deinit(self.alloc);

    for (match_tags) |tag| {
        const case_bb = self.freshBlock("dispatch.case");
        case_blocks.append(self.alloc, case_bb) catch unreachable;
        cases.append(self.alloc, .{
            .value = @intCast(tag),
            .target = case_bb,
            .args = &.{},
        }) catch unreachable;
    }

    // Create a result alloca BEFORE the switch (must be before terminator)
    var result_slot: ?Ref = null;
    if (ret_ty != .void) {
        result_slot = self.builder.alloca(ret_ty);
    }

    self.builder.switchBr(type_tag, cases.items, default_bb, &.{});

    // Whether the cast-arg parameter is a SLICE (`[]$T`). When it is and the tag
    // is an ARRAY, we pass a slice VIEW of the array's storage rather than loading
    // the whole array as a value (issue 0125 — the giant load + per-element
    // SelectionDAG scalarization). The Any payload for an array IS a pointer to its
    // storage, so `unbox_any → [*]elem` (int-to-ptr, no load) + the array length
    // gives a `{ptr,len}` slice for free.
    const cast_param_is_slice = cast_arg_idx < fd.params.len and
        fd.params[cast_arg_idx].type_expr.data == .slice_type_expr;

    for (match_tags, 0..) |tag, ti| {
        self.builder.switchToBlock(case_blocks.items[ti]);

        const ty_id = TypeId.fromIndex(@intCast(tag));

        // Unbox the Any value to the concrete type — except an ARRAY tag bound to a
        // SLICE param, which becomes a no-load slice view of the array storage.
        const tag_is_array = !ty_id.isBuiltin() and self.module.types.get(ty_id) == .array;
        const unboxed = if (cast_param_is_slice and tag_is_array) blk: {
            const elem_ty = self.getElementType(ty_id);
            const arr_len = self.module.types.get(ty_id).array.length;
            const slice_ty = self.module.types.sliceOf(elem_ty);
            const ptr_ty = self.module.types.manyPtrTo(elem_ty);
            // The Any payload (the array's storage address) → `[*]elem` (no load).
            const ptr = self.builder.emit(.{ .unbox_any = .{ .operand = any_val } }, ptr_ty);
            const len = self.builder.constInt(@intCast(arr_len), .i64);
            break :blk self.builder.structInit(&.{ ptr, len }, slice_ty);
        } else self.builder.emit(.{ .unbox_any = .{
            .operand = any_val,
        } }, ty_id);

        if (fd.type_params.len > 0) {
            // Generic function: build type bindings + monomorphize
            var bindings = std.StringHashMap(TypeId).init(self.alloc);
            defer bindings.deinit();

            // Find which type param the cast arg corresponds to
            if (cast_arg_idx < fd.params.len) {
                const param_te = fd.params[cast_arg_idx].type_expr;
                if (param_te.data == .type_expr) {
                    // Direct: `param: $T` → T = ty_id
                    const tp_name = param_te.data.type_expr.name;
                    for (fd.type_params) |tp| {
                        if (std.mem.eql(u8, tp.name, tp_name)) {
                            bindings.put(tp.name, ty_id) catch {};
                            break;
                        }
                    }
                } else if (param_te.data == .slice_type_expr) {
                    // Compound: `param: []$T` → T = element type of ty_id
                    const elem_te = param_te.data.slice_type_expr.element_type;
                    if (elem_te.data == .type_expr) {
                        const tp_name = elem_te.data.type_expr.name;
                        for (fd.type_params) |tp| {
                            if (std.mem.eql(u8, tp.name, tp_name)) {
                                const elem_ty = self.getElementType(ty_id);
                                bindings.put(tp.name, if (elem_ty != .void) elem_ty else ty_id) catch {};
                                break;
                            }
                        }
                    }
                } else if (param_te.data == .pointer_type_expr) {
                    // Compound: `param: *$T` → T = pointee type of ty_id
                    const pointee_te = param_te.data.pointer_type_expr.pointee_type;
                    if (pointee_te.data == .type_expr) {
                        const tp_name = pointee_te.data.type_expr.name;
                        for (fd.type_params) |tp| {
                            if (std.mem.eql(u8, tp.name, tp_name)) {
                                if (!ty_id.isBuiltin()) {
                                    const pinfo = self.module.types.get(ty_id);
                                    if (pinfo == .pointer) {
                                        bindings.put(tp.name, pinfo.pointer.pointee) catch {};
                                        break;
                                    }
                                }
                                bindings.put(tp.name, ty_id) catch {};
                                break;
                            }
                        }
                    }
                }
            }

            // Build mangled name
            var mangled_buf: [256]u8 = undefined;
            var mangled_len: usize = 0;
            for (base_name) |ch| {
                if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
            }
            for (fd.type_params) |tp| {
                for ("__") |ch| {
                    if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
                }
                const bound_ty = bindings.get(tp.name) orelse ty_id;
                const type_name_str = self.mangleTypeName(bound_ty);
                for (type_name_str) |ch| {
                    if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
                }
            }
            const mangled_name = mangled_buf[0..mangled_len];

            // Monomorphize if not already done
            if (!self.lowered_functions.contains(mangled_name)) {
                self.monomorphizeFunction(fd, mangled_name, &bindings);
            }

            // Build call args (replace cast arg with unboxed value, skip type param decl args)
            if (self.resolveFuncByName(mangled_name)) |fid| {
                const func = &self.module.functions.items[@intFromEnum(fid)];
                const callee_ret = func.ret;
                const callee_params = func.params;
                var call_args = std.ArrayList(Ref).empty;
                defer call_args.deinit(self.alloc);
                for (fd.params, 0..) |p, pi| {
                    if (isTypeParamDecl(&p, fd.type_params)) continue;
                    if (pi == cast_arg_idx) {
                        call_args.append(self.alloc, unboxed) catch unreachable;
                    } else if (pi < other_args.items.len) {
                        if (other_args.items[pi]) |ref| {
                            call_args.append(self.alloc, ref) catch unreachable;
                        }
                    }
                }
                const final_args = self.prependCtxIfNeeded(func, call_args.items);
                self.coerceCallArgs(final_args, callee_params);
                const result = self.builder.call(fid, final_args, callee_ret);
                if (result_slot) |slot| {
                    self.builder.store(slot, result);
                }
            }
        } else {
            // Non-generic function: call directly with per-tag unboxing + coercion
            const resolve_name = base_name;
            if (!self.lowered_functions.contains(resolve_name)) {
                self.lazyLowerFunction(resolve_name);
            }
            if (self.resolveFuncByName(resolve_name)) |fid| {
                const callee_func = &self.module.functions.items[@intFromEnum(fid)];
                const callee_ret = callee_func.ret;
                const callee_params = callee_func.params;
                const callee_has_ctx = callee_func.has_implicit_ctx;
                var call_args = std.ArrayList(Ref).empty;
                defer call_args.deinit(self.alloc);
                for (fd.params, 0..) |_, pi| {
                    if (pi == cast_arg_idx) {
                        // Coerce unboxed value (typed as ty_id) to param type
                        var arg = unboxed;
                        // callee param index shifts by +1 if it carries __sx_ctx
                        const callee_pi = pi + @as(usize, if (callee_has_ctx) 1 else 0);
                        if (callee_pi < callee_params.len) {
                            arg = self.coerceToType(arg, ty_id, callee_params[callee_pi].ty);
                        }
                        call_args.append(self.alloc, arg) catch unreachable;
                    } else if (pi < other_args.items.len) {
                        if (other_args.items[pi]) |ref| {
                            call_args.append(self.alloc, ref) catch unreachable;
                        }
                    }
                }
                // Prepend __sx_ctx if needed BEFORE coercion so indices line up.
                var final_call_args: []Ref = call_args.items;
                if (callee_has_ctx) {
                    final_call_args = self.alloc.alloc(Ref, call_args.items.len + 1) catch call_args.items;
                    if (final_call_args.len == call_args.items.len + 1) {
                        final_call_args[0] = self.current_ctx_ref;
                        @memcpy(final_call_args[1..], call_args.items);
                    }
                }
                // Coerce non-cast args (source type unknown, use i64 default).
                // cast_arg_idx is in user-space (skips __sx_ctx); offset by ctx_slots.
                const ctx_slots: usize = if (callee_has_ctx) 1 else 0;
                for (0..@min(final_call_args.len, callee_params.len)) |ci| {
                    if (ci < ctx_slots) continue; // skip __sx_ctx slot
                    if ((ci - ctx_slots) != cast_arg_idx) {
                        final_call_args[ci] = self.coerceToType(final_call_args[ci], .i64, callee_params[ci].ty);
                    }
                }
                const result = self.builder.call(fid, final_call_args, callee_ret);
                if (result_slot) |slot| {
                    self.builder.store(slot, result);
                }
            }
        }

        self.builder.br(merge_bb, &.{});
    }

    // Default block: store a default value and branch to merge
    self.builder.switchToBlock(default_bb);
    if (result_slot) |slot| {
        const empty_id = self.module.types.internString("");
        const default_val = if (ret_ty == .string) self.builder.constString(empty_id) else self.zeroValue(ret_ty);
        self.builder.store(slot, default_val);
    }
    self.builder.br(merge_bb, &.{});

    // Merge block: load result
    self.builder.switchToBlock(merge_bb);
    if (result_slot) |slot| {
        return self.builder.load(slot, ret_ty);
    }
    return self.builder.constInt(0, .void);
}

/// The five `Ordering` variants by declaration-order tag. INVARIANT: the sx
/// `Ordering` enum (library/modules/std/atomic.sx) and the IR `AtomicOrdering`
/// enum (inst.zig) declare these variants in the SAME order, so a comptime-bound
/// ordering's tag indexes this list. Keep all three in sync.
fn atomicOrderingFromTag(tag: i64) ?inst_mod.AtomicOrdering {
    return switch (tag) {
        0 => .relaxed,
        1 => .acquire,
        2 => .release,
        3 => .acq_rel,
        4 => .seq_cst,
        else => null,
    };
}

/// Resolve an ordering argument to the IR `AtomicOrdering`. Accepts a bare enum
/// literal (`.seq_cst`) OR a comptime-bound identifier (a `$o: Ordering` param
/// forwarded into the intrinsic — read its bound variant tag via
/// `comptimeIntNamed`). Returns null for a non-constant ordering — the caller
/// turns that into a loud diagnostic (never a silent default).
fn atomicOrderingFromNode(self: *Lowering, node: *const Node) ?inst_mod.AtomicOrdering {
    if (node.data == .enum_literal) {
        const n = node.data.enum_literal.name;
        if (std.mem.eql(u8, n, "relaxed")) return .relaxed;
        if (std.mem.eql(u8, n, "acquire")) return .acquire;
        if (std.mem.eql(u8, n, "release")) return .release;
        if (std.mem.eql(u8, n, "acq_rel")) return .acq_rel;
        if (std.mem.eql(u8, n, "seq_cst")) return .seq_cst;
        return null;
    }
    if (node.data == .identifier) {
        if (self.comptimeIntNamed(node.data.identifier.name)) |tag| return atomicOrderingFromTag(tag);
    }
    return null;
}

/// Recognize the atomic `#builtin` intrinsics and lower them to dedicated atomic
/// IR ops:
///   atomic_load($T, ptr: *T, o: Ordering) -> T
///   atomic_store($T, ptr: *T, v: T, o: Ordering)
/// The `Ordering` arg MUST be a constant enum literal — read statically here and
/// baked into the op (the op carries no runtime ordering operand). `T` must be a
/// scalar of size 1/2/4/8/16. Both constraints are loud diagnostics, never silent
/// defaults. Returns null if `name` is not an atomic intrinsic.
pub fn tryLowerAtomicIntrinsic(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
    // Fence is a standalone op — ordering only, no `$T`/ptr (different shape).
    if (std.mem.eql(u8, name, "atomic_fence")) {
        if (c.args.len != 1) {
            if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "atomic_fence expects 1 argument", .{});
            return Ref.none;
        }
        const ordering = atomicOrderingFromNode(self, c.args[0]) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, c.args[0].span, "fence ordering must be a constant ordering literal", .{});
            return Ref.none;
        };
        // LLVM has no monotonic/unordered fence — `.relaxed` is invalid.
        if (ordering == .relaxed) {
            if (self.diagnostics) |d| d.addFmt(.err, c.args[0].span, "fence ordering cannot be .relaxed (use .acquire / .release / .acq_rel / .seq_cst)", .{});
            return Ref.none;
        }
        self.builder.emitVoid(.{ .atomic_fence = .{ .ordering = ordering } }, .void);
        return Ref.none; // fence has a void result
    }

    const is_load = std.mem.eql(u8, name, "atomic_load");
    const is_store = std.mem.eql(u8, name, "atomic_store");
    const rmw_kind = rmwKindFromName(name); // atomic_fetch_add/sub/and/or/xor/min/max
    const is_cmpxchg = std.mem.eql(u8, name, "atomic_cmpxchg");
    const is_cmpxchg_weak = std.mem.eql(u8, name, "atomic_cmpxchg_weak");
    const is_cas = is_cmpxchg or is_cmpxchg_weak;
    if (!is_load and !is_store and rmw_kind == null and !is_cas) return null;

    // ($T, ptr[, operand/val], ordering): load=3, store/rmw=4.
    // cmpxchg ($T, ptr, expected, desired, success, failure): 6.
    const expected: usize = if (is_load) 3 else if (is_cas) 6 else 4;
    if (c.args.len != expected) {
        if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "{s} expects {d} arguments", .{ name, expected });
        return Ref.none;
    }

    const elem_ty = self.resolveTypeArg(c.args[0]);
    // Atomic-eligible T = a SCALAR that LLVM can load/store atomically: integer,
    // float, bool, pointer, enum (integer-backed), or vector. Aggregates
    // (struct/array/slice/string/tuple/…) are rejected LOUDLY here — without the
    // kind check a same-sized aggregate (`[8]u8`, an 8-byte struct) slips through
    // and the user gets a raw LLVM verifier error instead of a clean diagnostic.
    const scalar_ok = switch (self.module.types.get(elem_ty)) {
        .signed, .unsigned, .f32, .f64, .bool, .pointer, .many_pointer, .cstring, .@"enum", .vector => true,
        else => false,
    };
    const size = self.typeSizeBytes(elem_ty);
    if (!scalar_ok or (size != 1 and size != 2 and size != 4 and size != 8 and size != 16)) {
        if (self.diagnostics) |d| d.addFmt(.err, c.args[0].span, "atomic ops require a scalar type (integer/float/bool/pointer/enum/vector) of size 1/2/4/8/16 bytes — '{s}' is not eligible", .{self.formatTypeName(elem_ty)});
        return Ref.none;
    }
    // RMW (A.1) is restricted to INTEGER types: arithmetic/bitwise/min-max on
    // floats (fadd/fsub) and pointers is out of scope — reject loudly rather
    // than emit invalid LLVM.
    if (rmw_kind != null) {
        const int_ok = switch (self.module.types.get(elem_ty)) {
            .signed, .unsigned => true,
            else => false,
        };
        if (!int_ok) {
            if (self.diagnostics) |d| d.addFmt(.err, c.args[0].span, "atomic read-modify-write requires an integer type — '{s}' is not eligible", .{self.formatTypeName(elem_ty)});
            return Ref.none;
        }
    }
    // CAS (A.2) is likewise restricted to INTEGER types: the `?T` result is laid
    // out as `{ T, i1 }` (null = success); pointer/niche optionals are out of
    // scope, so a non-integer T is rejected LOUDLY here.
    if (is_cas) {
        const int_ok = switch (self.module.types.get(elem_ty)) {
            .signed, .unsigned => true,
            else => false,
        };
        if (!int_ok) {
            if (self.diagnostics) |d| d.addFmt(.err, c.args[0].span, "atomic compare-exchange requires an integer type — '{s}' is not eligible", .{self.formatTypeName(elem_ty)});
            return Ref.none;
        }
    }

    // CAS resolves TWO orderings (success, failure) and validates the LLVM rule
    // that the failure ordering may not be .release / .acq_rel and may not be
    // stronger than the success ordering. Handled in its own branch (different
    // arity + dual-ordering shape) before the single-ordering path below.
    if (is_cas) {
        const succ_node = c.args[4];
        const fail_node = c.args[5];
        const success_ordering = atomicOrderingFromNode(self, succ_node) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, succ_node.span, "atomic ordering must be a constant ordering literal (.relaxed / .acquire / .release / .acq_rel / .seq_cst)", .{});
            return Ref.none;
        };
        const failure_ordering = atomicOrderingFromNode(self, fail_node) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, fail_node.span, "atomic ordering must be a constant ordering literal (.relaxed / .acquire / .release / .acq_rel / .seq_cst)", .{});
            return Ref.none;
        };
        // The FAILURE ordering describes a load that does NOT write, so LLVM
        // forbids .release / .acq_rel there, and forbids it being stronger than
        // the SUCCESS ordering. Strength rank: relaxed=0 < acquire=release=1 <
        // acq_rel=2 < seq_cst=3.
        if (failure_ordering == .release or failure_ordering == .acq_rel) {
            if (self.diagnostics) |d| d.addFmt(.err, fail_node.span, "atomic compare-exchange failure ordering cannot be .release or .acq_rel (use .relaxed / .acquire / .seq_cst)", .{});
            return Ref.none;
        }
        if (atomicOrderingRank(failure_ordering) > atomicOrderingRank(success_ordering)) {
            if (self.diagnostics) |d| d.addFmt(.err, fail_node.span, "atomic compare-exchange failure ordering ('.{s}') cannot be stronger than the success ordering ('.{s}')", .{ @tagName(failure_ordering), @tagName(success_ordering) });
            return Ref.none;
        }

        const cas_ptr = self.lowerExpr(c.args[1]);
        const cmp = self.lowerExpr(c.args[2]);
        const new = self.lowerExpr(c.args[3]);
        // Result type is `?T` (null = success; failure carries the actual value).
        const opt_ty = self.module.types.optionalOf(elem_ty);
        return self.builder.emit(.{ .atomic_cmpxchg = .{
            .ptr = cas_ptr,
            .cmp = cmp,
            .new = new,
            .val_ty = elem_ty,
            .success_ordering = success_ordering,
            .failure_ordering = failure_ordering,
            .weak = is_cmpxchg_weak,
        } }, opt_ty);
    }

    const ord_node = c.args[expected - 1];
    const ordering = atomicOrderingFromNode(self, ord_node) orelse {
        if (self.diagnostics) |d| d.addFmt(.err, ord_node.span, "atomic ordering must be a constant ordering literal (.relaxed / .acquire / .release / .acq_rel / .seq_cst)", .{});
        return Ref.none;
    };
    // Per-op ordering validity (LLVM rejects these). A load can't release; a
    // store can't acquire; neither can acq_rel. (RMW accepts all orderings.)
    if (is_load and (ordering == .release or ordering == .acq_rel)) {
        if (self.diagnostics) |d| d.addFmt(.err, ord_node.span, "atomic load ordering cannot be .release or .acq_rel (use .relaxed / .acquire / .seq_cst)", .{});
        return Ref.none;
    }
    if (is_store and (ordering == .acquire or ordering == .acq_rel)) {
        if (self.diagnostics) |d| d.addFmt(.err, ord_node.span, "atomic store ordering cannot be .acquire or .acq_rel (use .relaxed / .release / .seq_cst)", .{});
        return Ref.none;
    }

    const ptr = self.lowerExpr(c.args[1]);
    if (is_load) {
        return self.builder.emit(.{ .atomic_load = .{ .ptr = ptr, .ordering = ordering } }, elem_ty);
    }
    const val = self.lowerExpr(c.args[2]);
    if (rmw_kind) |kind| {
        // RMW returns the OLD value (result type = T).
        return self.builder.emit(.{ .atomic_rmw = .{ .ptr = ptr, .operand = val, .val_ty = elem_ty, .ordering = ordering, .kind = kind } }, elem_ty);
    }
    self.builder.emitVoid(.{ .atomic_store = .{ .ptr = ptr, .val = val, .val_ty = elem_ty, .ordering = ordering } }, .void);
    return Ref.none; // store has a void result
}

/// Strength rank of an atomic ordering, for the compare-exchange rule that the
/// failure ordering may not be stronger than the success ordering.
/// relaxed=0 < acquire=release=1 < acq_rel=2 < seq_cst=3.
fn atomicOrderingRank(o: inst_mod.AtomicOrdering) u8 {
    return switch (o) {
        .relaxed => 0,
        .acquire, .release => 1,
        .acq_rel => 2,
        .seq_cst => 3,
    };
}

/// Map an `atomic_fetch_*` intrinsic name to its RMW kind (null if not one).
fn rmwKindFromName(name: []const u8) ?inst_mod.RmwKind {
    if (std.mem.eql(u8, name, "atomic_fetch_add")) return .add;
    if (std.mem.eql(u8, name, "atomic_fetch_sub")) return .sub;
    if (std.mem.eql(u8, name, "atomic_fetch_and")) return .@"and";
    if (std.mem.eql(u8, name, "atomic_fetch_or")) return .@"or";
    if (std.mem.eql(u8, name, "atomic_fetch_xor")) return .xor;
    if (std.mem.eql(u8, name, "atomic_fetch_min")) return .min;
    if (std.mem.eql(u8, name, "atomic_fetch_max")) return .max;
    if (std.mem.eql(u8, name, "atomic_swap")) return .xchg; // swap = exchange RMW
    return null;
}

/// Try to lower a call as a reflection builtin (expanded inline during lowering).
/// Returns null if the call is not a recognized reflection builtin.
pub fn tryLowerReflectionCall(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
    // Strict `$T: Type` guard for the type-introspection builtins. A
    // value argument (`6`, `true`, `5.2`, a struct) is rejected with a
    // diagnostic instead of being silently reinterpreted as a TypeId
    // index / sized via its `typeof`. One shared
    // classification covers all 7; it runs before dispatch.
    if (self.reflectionTypeArgGuard(name, c)) |sentinel| return sentinel;

    // `declare(name)` and `define(handle, info)` are now ordinary sx functions
    // (`modules/std/meta.sx`) written over the `abi(.compiler)` primitives
    // (`declare_type` / `register_type`) — no longer intercepted here.
    // (`preregisterForwardTypes` still scans for the literal `declare("Name")`
    // spelling so a `*Name` self-reference forward-registers before the body
    // lowers; the sx `declare` calls `declare_type`, which returns that slot.
    // The `.enum(…)` arg to `define` now infers `TypeInfo` from the sx fn's
    // declared param type via the ordinary call path's target-type threading.)
    if (std.mem.eql(u8, name, "type_info")) {
        // Comptime reflection-into-data: reflect a type INTO a `TypeInfo`
        // value (the inverse of `define`'s decode). Resolve `$T` at lower
        // time, then emit a `callBuiltin(.type_info, [const_type])` the
        // interp executes against its type table — it reads the variants
        // (name + payload) and constructs the same `.enum(EnumInfo{ … })`
        // value `define` decodes, so the two round-trip. Result type is
        // `TypeInfo`; the whole `define(declare(), type_info(T))` expr is
        // comptime-evaluated, so this builtin never reaches codegen.
        if (c.args.len != 1) {
            if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "type_info($T) takes one type argument", .{});
            return Ref.none;
        }
        const ti_ty = self.module.types.findByName(self.module.types.internString("TypeInfo")) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "type_info needs `TypeInfo` in scope — import `modules/std/meta.sx`", .{});
            return Ref.none;
        };
        const t = self.resolveTypeArg(c.args[0]);
        // Enum / tagged-union and struct reflection ship today (the symmetric
        // inverse of `define`, which builds those kinds). A loud, well-spanned
        // reject here beats a deferred interp bail; tuple widening lands later.
        const reflectable = !t.isBuiltin() and switch (self.module.types.get(t)) {
            .@"enum", .tagged_union, .@"struct", .tuple => true,
            else => false,
        };
        if (!reflectable) {
            if (self.diagnostics) |d| d.addFmt(.err, c.args[0].span, "type_info: '{s}' is not reflectable — only enum / tagged-union / struct / tuple types reflect today", .{self.formatTypeName(t)});
            return Ref.none;
        }
        const type_ref = self.builder.constType(t);
        const args_owned = self.alloc.dupe(Ref, &.{type_ref}) catch return Ref.none;
        return self.builder.callBuiltin(.type_info, args_owned, ti_ty);
    }
    if (std.mem.eql(u8, name, "size_of")) {
        // size_of(T) → const_int(sizeof(T))
        const ty = self.resolveTypeArg(c.args[0]);
        const size: i64 = @intCast(self.typeSizeBytes(ty));
        return self.builder.constInt(size, .i64);
    }
    if (std.mem.eql(u8, name, "align_of")) {
        const ty = self.resolveTypeArg(c.args[0]);
        const a: i64 = @intCast(self.module.types.typeAlignBytes(ty));
        return self.builder.constInt(a, .i64);
    }
    if (std.mem.eql(u8, name, "field_count")) {
        // field_count(T) → const_int(N)
        const ty = self.resolveTypeArg(c.args[0]);
        const info = self.module.types.get(ty);
        const count: i64 = switch (info) {
            .@"struct" => |s| @intCast(s.fields.len),
            .@"union" => |u| @intCast(u.fields.len),
            .tagged_union => |u| @intCast(u.fields.len),
            .@"enum" => |e| @intCast(e.variants.len),
            .array => |a| @intCast(a.length),
            .vector => |v| @intCast(v.length),
            else => 0,
        };
        return self.builder.constInt(count, .i64);
    }
    if (std.mem.eql(u8, name, "type_name")) {
        // type_name(T):
        //   - Statically resolvable arg (type expression, pack
        //     index, generic binding, etc.) → fold to const_string
        //     at lower time.
        //   - Dynamic arg (e.g. `list[i]` indexing into a
        //     `$args`-derived []Type slice) → emit a
        //     `callBuiltin(.type_name, [arg_ref])`. The interp's
        //     arm (commit 9600ba5) reads the runtime `.type_tag`
        //     and returns the per-position name. Without this
        //     split, the catch-all `else => .i64` in
        //     `resolveTypeArg` silently returns "i64" for every
        //     dynamic call — exactly the silent-arm pattern the
        //     project's REJECTED PATTERNS forbid.
        if (self.isStaticTypeArg(c.args[0])) {
            const ty = self.resolveTypeArg(c.args[0]);
            const tn_str = self.formatTypeName(ty);
            const sid = self.module.types.internString(tn_str);
            return self.builder.constString(sid);
        }
        const arg_ref = self.lowerExpr(c.args[0]);
        const args_owned = self.alloc.dupe(Ref, &.{arg_ref}) catch return self.builder.constString(self.module.types.internString(""));
        return self.builder.callBuiltin(.type_name, args_owned, .string);
    }
    if (std.mem.eql(u8, name, "type_eq")) {
        // type_eq(T1, T2) → const_bool — comptime TypeId equality.
        // TypeIds are interned per structural shape so equality on
        // them matches the user's intuition: `type_eq(i64, i64)` is
        // true, `type_eq(*i64, *i64)` is true, distinct shapes are
        // false. Pack-indexed types (`$args[0]`) resolve through
        // `resolveTypeArg` → `resolveTypeWithBindings`.
        if (c.args.len < 2) return self.builder.constBool(false);
        const a = self.resolveTypeArg(c.args[0]);
        const b = self.resolveTypeArg(c.args[1]);
        return self.builder.constBool(a == b);
    }
    if (std.mem.eql(u8, name, "type_is_unsigned")) {
        // type_is_unsigned(T) → bool. Static arg (a spelled type or
        // generic binding) folds to const_bool at lower time. A
        // dynamic arg — the runtime `type_of(x)` value queried by
        // `any_to_string` — emits a `callBuiltin`: the interp reads
        // the boxed TypeId, LLVM GEPs a per-type signedness table.
        // Mirrors `type_name`'s static/dynamic split; the same split
        // avoids `resolveTypeArg`'s silent `.i64` default lying about
        // a runtime Type value.
        if (c.args.len < 1) return self.builder.constBool(false);
        if (self.isStaticTypeArg(c.args[0])) {
            const ty = self.resolveTypeArg(c.args[0]);
            return self.builder.constBool(self.module.types.isUnsignedInt(ty));
        }
        const arg_ref = self.lowerExpr(c.args[0]);
        const args_owned = self.alloc.dupe(Ref, &.{arg_ref}) catch return self.builder.constBool(false);
        return self.builder.callBuiltin(.type_is_unsigned, args_owned, .bool);
    }
    if (std.mem.eql(u8, name, "has_impl")) {
        // has_impl(P, T) → const_bool. Returns true when type T has
        // a reachable impl for protocol P. P is either:
        // - plain protocol name (`Hash`, `Eq`) for unary protocols;
        // - parameterised call like `Into(Block)` — for protocols
        //   with type args, the args must be fully spelled.
        // Delegates to `computeHasImpl` (shared with the
        // `tryConstBoolCondition` arm so `inline if has_impl(...)`
        // folds at compile time).
        if (c.args.len < 2) return self.builder.constBool(false);
        const ty = self.resolveTypeArg(c.args[1]);
        return self.builder.constBool(self.computeHasImpl(c.args[0], ty));
    }
    if (std.mem.eql(u8, name, "is_flags")) {
        const ty = self.resolveTypeArg(c.args[0]);
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            if (info == .@"enum") return self.builder.constBool(info.@"enum".is_flags);
        }
        return self.builder.constBool(false);
    }
    if (std.mem.eql(u8, name, "compile_error")) {
        // compile_error(msg) — raise a build-time diagnostic at
        // the call site. The argument must be a string literal so
        // the message text is available at lower time. Returns a
        // void-typed const (the call site is consumed for its
        // side effect, not its value).
        if (self.diagnostics) |diags| {
            if (c.args.len < 1) {
                diags.addFmt(.err, c.callee.span, "compile_error requires a string argument", .{});
            } else if (c.args[0].data == .string_literal) {
                const lit = c.args[0].data.string_literal;
                const msg = if (lit.is_raw)
                    lit.raw
                else
                    unescape.unescapeString(self.alloc, lit.raw) catch lit.raw;
                diags.addFmt(.err, c.callee.span, "{s}", .{msg});
            } else {
                diags.addFmt(.err, c.callee.span, "compile_error argument must be a string literal", .{});
            }
        }
        return self.builder.constInt(0, .void);
    }
    if (std.mem.eql(u8, name, "field_name")) {
        // field_name(T, i) → field_name_get instruction
        if (c.args.len < 2) return self.builder.constString(self.module.types.internString(""));
        const ty = self.resolveTypeArg(c.args[0]);
        const idx = self.lowerExpr(c.args[1]);
        return self.builder.emit(.{ .field_name_get = .{
            .base = .none,
            .index = idx,
            .struct_type = ty,
        } }, .string);
    }
    if (std.mem.eql(u8, name, "is_comptime")) {
        // True under the comptime interpreter, false in compiled code — the
        // op decides per backend (it can't fold here, since the same IR
        // serves both). Lets stdlib gate a comptime-only diagnostic branch.
        return self.builder.emit(.{ .is_comptime = {} }, .bool);
    }
    if (std.mem.eql(u8, name, "__interp_print_frames")) {
        // Backs `trace.print_interpreter_frames()`: dumps the interp call
        // chain at comptime, no-op in compiled code (ERR E4.1).
        return self.builder.emit(.{ .interp_print_frames = {} }, .void);
    }
    if (std.mem.eql(u8, name, "__trace_resolve_frame")) {
        // Backs `trace.sx`'s formatter: a raw trace-buffer u64 → a `TraceFrame`.
        // Compiled code reinterprets the operand as `*TraceFrame` and loads it;
        // the interp unpacks (func_id, span.start) and resolves (ERR E3.0
        // slice 3b). Result type is the `TraceFrame` struct from trace.sx.
        const frame_ty = self.module.types.findByName(self.module.types.internString("TraceFrame")) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, null, "`__trace_resolve_frame` needs `TraceFrame` (from trace.sx) in scope", .{});
            return self.builder.constInt(0, .void);
        };
        const arg = self.lowerExpr(c.args[0]);
        return self.builder.emit(.{ .trace_resolve = .{ .operand = arg } }, frame_ty);
    }
    if (std.mem.eql(u8, name, "error_tag_name")) {
        // error_tag_name(e) → look the error-set value's runtime tag id up
        // in the always-linked tag-name table. The value IS its u32 tag id.
        if (c.args.len < 1) return self.builder.constString(self.module.types.internString(""));
        const e = self.lowerExpr(c.args[0]);
        return self.builder.emit(.{ .error_tag_name_get = .{ .operand = e } }, .string);
    }
    if (std.mem.eql(u8, name, "field_value")) {
        // field_value(s, i) → field_value_get instruction (structs/unions)
        //                   → index_get + box_any (slices/arrays)
        if (c.args.len < 2) return self.builder.constInt(0, .any);
        const base = self.lowerExpr(c.args[0]);
        const idx = self.lowerExpr(c.args[1]);
        const struct_ty = self.inferExprType(c.args[0]);

        // For slices, arrays, and vectors, use index_get to access elements
        if (!struct_ty.isBuiltin()) {
            const ti = self.module.types.get(struct_ty);
            if (ti == .slice or ti == .array or ti == .vector) {
                const elem_ty = self.getElementType(struct_ty);
                const elem = self.builder.emit(.{ .index_get = .{ .lhs = base, .rhs = idx } }, elem_ty);
                return self.builder.boxAny(elem, elem_ty);
            }
        }

        return self.builder.emit(.{ .field_value_get = .{
            .base = base,
            .index = idx,
            .struct_type = struct_ty,
        } }, .any);
    }
    if (std.mem.eql(u8, name, "type_of")) {
        // type_of(val) — produce a Type value (`.type_value`, a bare i64 handle).
        if (c.args.len < 1) return self.builder.constType(.void);
        const arg_ty = self.inferExprType(c.args[0]);
        if (arg_ty == .any) {
            // Runtime: the held value's type is the Any's tag (field 0). Read it
            // out AS the 8-byte `.type_value` handle — type_of of a runtime Any
            // names the held value's type, so the tag IS the type id.
            const val = self.lowerExpr(c.args[0]);
            return self.builder.structGet(val, 0, .type_value);
        } else {
            return self.builder.constType(arg_ty);
        }
    }
    if (std.mem.eql(u8, name, "field_index")) {
        // field_index(T, val) → extract tag from tagged union
        if (c.args.len < 2) return self.builder.constInt(0, .i64);
        const val = self.lowerExpr(c.args[1]);
        // For tagged unions: extract field 0 (the tag)
        return self.builder.emit(.{ .enum_tag = .{ .operand = val } }, .i64);
    }
    if (std.mem.eql(u8, name, "field_value_int")) {
        // field_value_int(T, i) → lookup enum variant value by index
        if (c.args.len < 2) return self.builder.constInt(0, .i64);
        const ty = self.resolveTypeArg(c.args[0]);
        const idx = self.lowerExpr(c.args[1]);
        // For enums with explicit values, build a global value array and index into it
        if (!ty.isBuiltin()) {
            const ti = self.module.types.get(ty);
            if (ti == .@"enum") {
                if (ti.@"enum".explicit_values) |vals| {
                    // Build inline switch: for each index, return the explicit value
                    // Simple approach: build an array of constants and use index_get
                    var elems = std.ArrayList(Ref).empty;
                    defer elems.deinit(self.alloc);
                    for (vals) |v| {
                        elems.append(self.alloc, self.builder.constInt(v, .i64)) catch unreachable;
                    }
                    const arr_ty = self.module.types.arrayOf(.i64, @intCast(vals.len));
                    const arr = self.builder.structInit(elems.items, arr_ty);
                    return self.builder.emit(.{ .index_get = .{ .lhs = arr, .rhs = idx } }, .i64);
                }
            }
        }
        // Default: return the index itself (regular enums)
        return idx;
    }
    return null;
}

/// Strict `$T: Type` classification shared by the 7 type-introspection
/// builtins. An argument denotes a type iff it is a spelled /
/// compile-time type or generic type parameter (the `isStaticTypeArg`
/// shapes), or a runtime `Type` value — which is `.type_value`-typed at
/// runtime (`type_of(x)`, a `[]Type` element `list[i]`, a `Type`-typed
/// local / field / param). Any other expression — a value of type
/// i64 / f64 / bool / a struct — is NOT a type.
pub fn reflectionArgIsType(self: *Lowering, arg: *const Node) bool {
    if (self.isStaticTypeArg(arg)) return true;
    // Either a bare `Type` value (`.type_value`) or an `Any` that may hold a Type
    // — the boxed reflection path (`case type: type_name(val)` where `val: Any`,
    // the runtime tag deciding). Both are valid reflection arguments.
    const ty = self.inferExprType(arg);
    return ty == .type_value or ty == .any;
}

/// Guard for the type-introspection builtins (`size_of`, `align_of`,
/// `field_count`, `type_name`, `type_eq`, `type_is_unsigned`,
/// `is_flags`): every argument must denote a type. A value argument is
/// rejected with a diagnostic rather than silently reinterpreted as a
/// TypeId index or sized via its `typeof`.
///
/// Returns null when `name` is not a guarded builtin OR every argument
/// is a type (→ fall through to normal dispatch). Returns a harmless
/// result-typed sentinel Ref when a violation was diagnosed; the
/// emitted `.err` gates the build so the value is never observed.
pub fn reflectionTypeArgGuard(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
    const arity: usize = if (std.mem.eql(u8, name, "type_eq"))
        2
    else if (std.mem.eql(u8, name, "size_of") or
        std.mem.eql(u8, name, "align_of") or
        std.mem.eql(u8, name, "field_count") or
        std.mem.eql(u8, name, "type_name") or
        std.mem.eql(u8, name, "type_is_unsigned") or
        std.mem.eql(u8, name, "is_flags"))
        1
    else
        return null;

    var ok = true;
    if (c.args.len != arity) {
        if (self.diagnostics) |d| {
            d.addFmt(.err, c.callee.span, "{s} expects {d} type argument{s}, got {d}", .{
                name, arity, if (arity == 1) @as([]const u8, "") else "s", c.args.len,
            });
        }
        ok = false;
    } else {
        for (c.args) |a| {
            if (self.reflectionArgIsType(a)) continue;
            if (self.diagnostics) |d| {
                d.addFmt(.err, a.span, "{s} expects a type, got '{s}'", .{
                    name, self.formatTypeName(self.inferExprType(a)),
                });
            }
            ok = false;
        }
    }
    if (ok) return null;
    return self.reflectionErrorSentinel(name);
}

/// Result-typed placeholder returned after `reflectionTypeArgGuard`
/// diagnoses a non-type argument: a string for `type_name`, a bool for
/// the predicate builtins, an int for the size / count builtins. Never
/// observed at runtime — the diagnostic already fails the build — but
/// keeps the IR well-typed so lowering can finish and report every
/// error in one pass.
pub fn reflectionErrorSentinel(self: *Lowering, name: []const u8) Ref {
    if (std.mem.eql(u8, name, "type_name"))
        return self.builder.constString(self.module.types.internString(""));
    if (std.mem.eql(u8, name, "type_eq") or
        std.mem.eql(u8, name, "type_is_unsigned") or
        std.mem.eql(u8, name, "is_flags"))
        return self.builder.constBool(false);
    return self.builder.constInt(0, .i64);
}

/// After args have been lowered, append the lowered values of any
/// `param: T = default_expr` defaults for positions past `args.items.len`.
/// Stops at the first param without a default. Used at method-dispatch
/// sites whose callee is a field_access (so `expandCallDefaults` can't
/// handle them up front). The default expression is lowered in the
/// caller's current scope, so identifiers like `context.allocator`
/// resolve to the caller's runtime context.
pub fn appendDefaultArgs(self: *Lowering, fd: *const ast.FnDecl, args: *std.ArrayList(Ref)) void {
    if (args.items.len >= fd.params.len) return;
    var i: usize = args.items.len;
    while (i < fd.params.len) : (i += 1) {
        const dflt = fd.params[i].default_expr orelse break;
        const v = self.lowerExpr(dflt);
        args.append(self.alloc, v) catch unreachable;
    }
}

/// Reject a direct call whose argument count cannot bind to the callee's
/// declared parameter list. `supplied` counts the args as they bind to
/// params — receiver included for dot-dispatch, defaults not yet
/// appended. Returns true when a diagnostic was emitted (the call must
/// not lower). Pack / comptime / generic / `#compiler` / `#builtin`
/// callees bind args through their own dispatch and are exempt.
pub fn checkCallArity(self: *Lowering, fd: *const ast.FnDecl, callee_name: []const u8, supplied: usize, has_receiver: bool, span: ast.Span) bool {

    if (fd.type_params.len > 0 or hasComptimeParams(fd) or isPackFn(fd)) return false;
    switch (fd.body.data) {
        .builtin_expr => return false,
        else => {},
    }
    var min: usize = 0;
    var max: ?usize = fd.params.len;
    for (fd.params, 0..) |p, i| {
        if (p.is_variadic) {
            max = null;
            break;
        }
        if (p.default_expr == null) min = i + 1;
    }
    if (supplied >= min and (max == null or supplied <= max.?)) return false;
    if (self.diagnostics) |d| {
        // Dot-dispatch report counts the user-visible args: the receiver
        // slot is implicit at the call site, so it is elided from both
        // the expected and the supplied counts.
        const recv: usize = @intFromBool(has_receiver);
        const got = supplied -| recv;
        const lo = min -| recv;
        const got_verb: []const u8 = if (got == 1) "was" else "were";
        if (max == null) {
            const s: []const u8 = if (lo == 1) "" else "s";
            d.addFmt(.err, span, "'{s}' expects at least {d} argument{s}, but {d} {s} given", .{ callee_name, lo, s, got, got_verb });
        } else if (max.? -| recv == lo) {
            const s: []const u8 = if (lo == 1) "" else "s";
            d.addFmt(.err, span, "'{s}' expects {d} argument{s}, but {d} {s} given", .{ callee_name, lo, s, got, got_verb });
        } else {
            d.addFmt(.err, span, "'{s}' expects between {d} and {d} arguments, but {d} {s} given", .{ callee_name, lo, max.? -| recv, got, got_verb });
        }
    }
    return true;
}

/// When a bare-identifier call omits trailing positional args and the
/// callee's signature provides defaults for them, return a fresh Call
/// node with the defaults filled in. Returns null when no expansion is
/// needed (callee unknown, all args provided, or no defaults available).
pub fn expandCallDefaults(self: *Lowering, c: *const ast.Call, sel_author: ?*const SelectedFunc, author_ambiguous: bool) ?*ast.Call {
    const fd = blk: {
        switch (c.callee.data) {
            .identifier => |id| {
                const eff_name = blk2: {
                    const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
                    if (self.program_index.ufcs_alias_map.get(id.name)) |target| {
                        break :blk2 if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
                    }
                    break :blk2 scoped;
                };
                // R5 §C: for a genuine flat same-name
                // collision the omitted trailing args are filled from the
                // author the call resolver selected — its `*FnDecl` defaults —
                // not the first-wins winner's. lowering consumes the ONE author
                // verdict (`selectedFreeAuthor`, computed once in `lowerCall`)
                // rather than re-resolving the name, so default expansion and
                // dispatch agree on the author. `.ambiguous` declines to expand
                // (the call path emits the single diagnostic); a non-collision
                // call keeps the existing first-wins winner, byte-for-byte.
                // Reading `.decl` only keeps `materialized` null — inspecting
                // defaults must not lower the author (0102d).
                if (author_ambiguous) return null;
                if (sel_author) |sf| break :blk sf.decl;
                break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null;
            },
            // Namespace call `mod.fn(args)` — args map directly to params
            // (no `self` prepend), so default expansion is the same shape as
            // a bare call. A METHOD call `value.method(args)` prepends `self`
            // (arg/param counts are offset), so it's excluded: only treat the
            // receiver as a namespace when it isn't a value in scope.
            .field_access => |fa| {
                const obj_name: ?[]const u8 = switch (fa.object.data) {
                    .identifier => |id| id.name,
                    .type_expr => |te| te.name,
                    else => null,
                };
                const name = obj_name orelse return null;
                if (self.scope) |scope| {
                    if (scope.lookup(name) != null) return null; // method call on a value
                }
                if (self.program_index.global_names.contains(name)) return null;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ name, fa.field }) catch fa.field;
                break :blk self.program_index.fn_ast_map.get(qualified) orelse self.program_index.fn_ast_map.get(fa.field) orelse return null;
            },
            else => return null,
        }
    };
    if (c.args.len >= fd.params.len) return null;
    var end: usize = c.args.len;
    while (end < fd.params.len) : (end += 1) {
        if (fd.params[end].default_expr == null) break;
    }
    if (end == c.args.len) return null;

    var new_args = self.alloc.alloc(*ast.Node, end) catch return null;
    for (c.args, 0..) |arg, i| new_args[i] = arg;
    var i: usize = c.args.len;
    while (i < end) : (i += 1) {
        const def = fd.params[i].default_expr.?;
        // `#caller_location` resolves at the CALL site, not the callee's
        // signature: emit a fresh marker carrying the call's span + file so
        // lowering synthesizes the caller's `Source_Location` (ERR E4.1b).
        if (def.data == .caller_location) {
            const n = self.alloc.create(ast.Node) catch return null;
            n.* = .{ .span = c.callee.span, .data = .{ .caller_location = {} }, .source_file = c.callee.source_file };
            new_args[i] = n;
        } else {
            new_args[i] = def;
        }
    }
    const new_call = self.alloc.create(ast.Call) catch return null;
    new_call.* = .{ .callee = c.callee, .args = new_args };
    return new_call;
}

/// Resolve parameter types for a call expression (for target_type context).
/// Returns empty slice if the function can't be resolved.
/// Return the param types of a Function from the caller's POV — i.e.
/// skipping the synthetic `__sx_ctx` slot when present. lowerCall's
/// arg-lowering uses these to set `target_type` per arg, and user
/// args don't include `__sx_ctx`, so the slot must be elided.
pub fn userParamTypes(self: *Lowering, func: *const Function) []TypeId {
    const start: usize = if (func.has_implicit_ctx) 1 else 0;
    var types_list = std.ArrayList(TypeId).empty;
    if (func.params.len > start) {
        for (func.params[start..]) |p| {
            types_list.append(self.alloc, p.ty) catch unreachable;
        }
    }
    return types_list.items;
}

/// Param types of a not-yet-lowered AST callee for arg target-typing,
/// resolved in the callee's own module context (the E4 source pin — see
/// `resolveParamTypeInSource`). A generic callee's bare `T` leaves mean
/// nothing as nominal names in that module: without this call's inferred
/// `$T → concrete` bindings the pin would resolve `T` as an undeclared
/// type in a non-main module and diagnose it unknown.
fn astCalleeParamTypes(self: *Lowering, fd: *const ast.FnDecl, args: []const *const Node) []const TypeId {
    const saved_bindings = self.type_bindings;
    defer self.type_bindings = saved_bindings;
    var gbindings: ?std.StringHashMap(TypeId) = null;
    defer if (gbindings) |*gb| gb.deinit();
    if (fd.type_params.len > 0) {
        gbindings = self.genericResolver().buildTypeBindings(fd, args);
        self.type_bindings = gbindings.?;
    }
    var types_list = std.ArrayList(TypeId).empty;
    for (fd.params) |p| {
        types_list.append(self.alloc, self.resolveParamTypeInSource(fd.body.source_file, &p)) catch unreachable;
    }
    return types_list.items;
}

pub fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call, sel_author: ?*SelectedFunc) []const TypeId {
    // Method calls: obj.method(args) — resolve param types from the method signature,
    // skipping the first param (self) since it's prepended later.
    if (c.callee.data == .field_access) {
        const fa = c.callee.data.field_access;

        // Namespace/static call: `Type.method(args)` where `Type` is a type
        // identifier (not a value in scope). Args correspond to ALL params
        // — no self prepend — so target_type for arg lowering must include
        // the leading param. Skipping it would lose the protocol context
        // for `xx ptr` inline-cast args.
        if (fa.object.data == .identifier) {
            const obj_name = fa.object.data.identifier.name;
            const is_value = blk: {
                if (self.scope) |scope| {
                    if (scope.lookup(obj_name) != null) break :blk true;
                }
                if (self.program_index.global_names.contains(obj_name)) break :blk true;
                break :blk false;
            };
            if (!is_value) {
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ obj_name, fa.field }) catch return &.{};
                if (self.resolveFuncByName(qualified)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    return self.userParamTypes(func);
                }
                if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                    return astCalleeParamTypes(self, fd, c.args);
                }
            }
        }

        const obj_ty = self.inferExprType(fa.object);
        // Protocol-typed receiver: look up the method on the protocol decl. The
        // protocol's ProtocolMethodInfo.param_types already excludes self.
        if (self.getProtocolInfo(obj_ty)) |proto_info| {
            for (proto_info.methods) |m| {
                if (std.mem.eql(u8, m.name, fa.field)) return m.param_types;
            }
        }
        // Optional-protocol receiver (`?GPU`): same as above but the
        // protocol type sits inside the optional's payload.
        if (!obj_ty.isBuiltin()) {
            const opt_info = self.module.types.get(obj_ty);
            if (opt_info == .optional) {
                if (self.getProtocolInfo(opt_info.optional.child)) |proto_info| {
                    for (proto_info.methods) |m| {
                        if (std.mem.eql(u8, m.name, fa.field)) return m.param_types;
                    }
                }
            }
        }
        // Closure-typed struct field: `c.on(args)` lowers to call_closure on
        // the field value. Pick up the callee's param types from the closure
        // type so each arg gets the right target_type during lowering.
        if (!obj_ty.isBuiltin()) {
            const field_name_id = self.module.types.internString(fa.field);
            const struct_fields = self.getStructFields(obj_ty);
            for (struct_fields) |f| {
                if (f.name == field_name_id and !f.ty.isBuiltin()) {
                    const fti = self.module.types.get(f.ty);
                    if (fti == .closure) return fti.closure.params;
                    if (fti == .function) return fti.function.params;
                }
            }
        }
        if (self.getStructTypeName(obj_ty)) |sname| {
            // Runtime-class receiver (`#objc_class` / `#jni_class` / etc.):
            // resolve the method from `runtime_class_map` walking `#extends`.
            // Without this path, `target_type` for each arg falls back to
            // whatever `self.target_type` was on entry — typically the
            // enclosing fn's return type — which silently truncates `xx ptr`
            // casts inside e.g. a `BOOL`-returning method body.
            if (self.program_index.runtime_class_map.get(sname)) |fcd| {
                if (self.findRuntimeMethodInChain(fcd, fa.field)) |found| {
                    const md = found.method;
                    const saved_fc = self.current_runtime_class;
                    defer self.current_runtime_class = saved_fc;
                    self.current_runtime_class = found.fcd;
                    const user_param_start: usize = if (md.is_static) 0 else 1;
                    if (md.params.len > user_param_start) {
                        var types_list = std.ArrayList(TypeId).empty;
                        for (md.params[user_param_start..]) |p_node| {
                            types_list.append(self.alloc, self.resolveType(p_node)) catch unreachable;
                        }
                        return types_list.items;
                    }
                    return &.{};
                }
            }

            const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch return &.{};
            // Try already-lowered functions first
            if (self.resolveFuncByName(qualified)) |fid| {
                const func = &self.module.functions.items[@intFromEnum(fid)];
                // Skip both `__sx_ctx` (if present) AND `self` param;
                // caller args include neither.
                const skip: usize = (if (func.has_implicit_ctx) @as(usize, 1) else 0) + 1;
                if (func.params.len > skip) {
                    var types_list = std.ArrayList(TypeId).empty;
                    for (func.params[skip..]) |p| {
                        types_list.append(self.alloc, p.ty) catch unreachable;
                    }
                    return types_list.items;
                }
            }
            // Try AST map (not yet lowered)
            if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                if (fd.params.len > 0) {
                    var types_list = std.ArrayList(TypeId).empty;
                    for (fd.params[1..]) |p| {
                        types_list.append(self.alloc, self.resolveParamTypeInSource(fd.body.source_file, &p)) catch unreachable;
                    }
                    return types_list.items;
                }
            }
            // Generic-struct instance method param types: select the method
            // body via the instance's STAMPED author (CP-4), substituting the
            // instance's bindings so `T → concrete`. The param source-pin
            // follows the selected `fd` (its own `body.source_file`).
            if (self.genericInstanceMethod(sname, fa.field)) |gm| {
                if (gm.fd.params.len > 0) {
                    const saved_bindings = self.type_bindings;
                    self.type_bindings = gm.bindings.*;
                    var types_list = std.ArrayList(TypeId).empty;
                    for (gm.fd.params[1..]) |p| {
                        types_list.append(self.alloc, self.resolveParamTypeInSource(gm.fd.body.source_file, &p)) catch unreachable;
                    }
                    self.type_bindings = saved_bindings;
                    return types_list.items;
                }
            }
        }
        return &.{};
    }
    if (c.callee.data != .identifier) return &.{};
    const bare_name = c.callee.data.identifier.name;
    const name = blk: {
        const scoped = if (self.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name;
        if (self.program_index.ufcs_alias_map.get(bare_name)) |target| {
            break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
        }
        break :blk scoped;
    };

    // R5 §C: a genuine flat same-name collision must type this
    // call's args against the author the call resolver selected, not the
    // first-wins winner's params. lowering consumes the ONE author verdict
    // (`selectedFreeAuthor`, computed once in `lowerCall`) rather than
    // re-resolving the name, so arg lowering (implicit address-of, coercion)
    // matches the author actually dispatched — otherwise a `*T`-param shadow
    // gets a `T` value arg that is later bit-cast to a pointer (segfault). The
    // FuncId materializes into the SHARED verdict (once), so dispatch reuses
    // it. A non-collision call falls to the existing first-wins path below,
    // byte-for-byte.
    if (sel_author) |sf| {
        const fid = self.selectedFuncId(sf, bare_name);
        const func = &self.module.functions.items[@intFromEnum(fid)];
        return self.userParamTypes(func);
    }

    // Check declared functions
    if (self.resolveFuncByName(name)) |fid| {
        const func = &self.module.functions.items[@intFromEnum(fid)];
        return self.userParamTypes(func);
    }

    // Check AST map for function signatures
    if (self.program_index.fn_ast_map.get(name)) |fd| {
        return astCalleeParamTypes(self, fd, c.args);
    }

    // Check global function pointer variables (quiet author-aware lookup —
    // param typing only; the call site diagnoses ambiguity / visibility)
    if (self.program_index.global_names.get(bare_name)) |gi_global| {
        const gi: ?GlobalInfo = switch (self.selectGlobalAuthor(bare_name)) {
            .resolved => |g| g,
            .untracked => gi_global,
            else => null,
        };
        if (gi) |g| {
            if (!g.ty.isBuiltin()) {
                const ti = self.module.types.get(g.ty);
                if (ti == .function) {
                    return ti.function.params;
                }
            }
        }
    }

    // Check local scope for function pointer variables
    if (self.scope) |scope| {
        if (scope.lookup(bare_name)) |binding| {
            if (!binding.ty.isBuiltin()) {
                const ti = self.module.types.get(binding.ty);
                if (ti == .function) {
                    return ti.function.params;
                }
            }
        }
    }

    return &.{};
}
