const std = @import("std");
const ast = @import("../../ast.zig");
const Node = ast.Node;
const types = @import("../types.zig");
const inst_mod = @import("../inst.zig");
const errors = @import("../../errors.zig");
const program_index_mod = @import("../program_index.zig");

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


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

/// Lower the `xx` operator (type coercion).
/// Uses self.target_type for context when available. Handles:
/// - Any → concrete type: unbox_any
/// - int → int: widen/narrow
/// - int ↔ float: int_to_float/float_to_int
pub fn lowerXX(self: *Lowering, operand: Ref, operand_node: *const Node) Ref {
    // Use the operand's *actual* lowered Ref type rather than reaching
    // back through inferExprType — the latter doesn't cover every
    // expression shape (notably lambdas), and a wrong src_ty here can
    // route the cast through coerceToType (e.g. a bogus i64→ptr bitcast)
    // and silently skip the user-space Into fallback.
    const src_ty = self.builder.getRefType(operand);
    const target_explicit = self.target_type != null;
    const dst_ty = self.target_type orelse .unresolved;

    // PLANNING: the `xx`-head decision (conversions.zig). `.coerce` falls
    // through to the built-in ladder + the user-`Into` fallback below.
    switch (self.coercionResolver().classifyXX(src_ty, dst_ty)) {
        // Any → concrete type: unbox.
        .unbox_any => {
            // When inside a float match arm covering both f32 and f64,
            // and target is f64, we need a mini-dispatch to unbox correctly.
            // f32 values are stored as zext(bitcast(f32→i32), i64) in Any,
            // so bitcasting i64→f64 directly gives wrong results for f32.
            if (dst_ty == .f64) {
                if (self.current_match_tags) |tags| {
                    var has_f32 = false;
                    var has_f64 = false;
                    for (tags) |t| {
                        const tid = TypeId.fromIndex(@intCast(t));
                        if (tid == .f32) has_f32 = true;
                        if (tid == .f64) has_f64 = true;
                    }
                    if (has_f32 and has_f64) {
                        return self.lowerAnyToF64Dispatch(operand);
                    }
                    if (has_f32 and !has_f64) {
                        // Only f32 values: unbox as f32, then widen
                        const f32_val = self.builder.emit(.{ .unbox_any = .{
                            .operand = operand,
                        } }, .f32);
                        return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
                    }
                }
            }
            return self.builder.emit(.{ .unbox_any = .{
                .operand = operand,
            } }, dst_ty);
        },
        // Same type: no-op.
        .no_op => return operand,
        // Concrete → Protocol: build protocol value.
        .erase_protocol => return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty),
        // Protocol → pointer: recover the typed ctx pointer (field 0).
        // The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
        // `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
        .protocol_to_pointer => {
            const void_ptr_ty = self.module.types.ptrTo(.void);
            const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
            if (dst_ty == void_ptr_ty) return ctx_ref;
            return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
        },
        .coerce => {},
    }

    const result = self.coerceExplicit(operand, src_ty, dst_ty);

    // User-space fallback via `impl Into(Target) for Source`. Only fires
    // when the target was explicitly named (not the .i64 default), src and
    // dst differ, and the built-in ladder made no progress. Built-ins
    // always win.
    if (target_explicit and src_ty != dst_ty and result == operand) {
        if (self.tryUserConversion(operand, operand_node, src_ty, dst_ty)) |converted| {
            return converted;
        }
        // Pointer-target fallback: `xx <expr>` whose surrounding context
        // expects `*T` (a fn arg slot, a var typed as a pointer-to-aggregate)
        // can be satisfied by `impl Into(T) for src` plus an implicit
        // alloca+store on the result. Lets users write
        // `fn(xx () => { ... })` instead of materialising a named Block local
        // just to take its address.
        if (!dst_ty.isBuiltin()) {
            const dst_info = self.module.types.get(dst_ty);
            if (dst_info == .pointer) {
                const pointee = dst_info.pointer.pointee;
                if (pointee != src_ty) {
                    if (self.tryUserConversion(operand, operand_node, src_ty, pointee)) |converted| {
                        const slot = self.builder.alloca(pointee);
                        self.builder.store(slot, converted);
                        return slot;
                    }
                }
            }
        }
    }
    return result;
}

/// Detect the `xx closure : Block` cast pattern so `tryUserConversion`
/// can emit a focused diagnostic when no `Into(Block) for Closure(...)`
/// impl is reachable. Replaces what was briefly a compiler-synthesised
/// trampoline path with a "declare an impl" requirement — the stdlib
/// covers common signatures (see modules/ffi/objc_block.sx), users
/// add their own for unusual ones.
pub fn isClosureToBlockCast(self: *Lowering, src_ty: TypeId, dst_ty: TypeId) bool {
    if (src_ty.isBuiltin()) return false;
    const src_info = self.module.types.get(src_ty);
    if (src_info != .closure) return false;
    if (dst_ty.isBuiltin()) return false;
    const dst_info = self.module.types.get(dst_ty);
    if (dst_info != .@"struct") return false;
    const block_name = self.module.types.internString("Block");
    return dst_info.@"struct".name == block_name;
}

/// Pack-variadic impl matching. Walks `param_impl_pack_map[pack_key]`
/// and returns a call ref when a single pack impl matches `src_ty`'s
/// shape (concrete src closure / fn with the same fixed prefix as
/// the impl's source pack closure). Binds the pack-var to the source's
/// tail param types and the return-var (when generic) to the source's
/// return type, then monomorphises the convert method.
/// Returns null if no pack impls registered for this (proto, dst) or
/// none of them match `src_ty`'s shape.
pub fn tryPackImplMatch(
    self: *Lowering,
    operand: Ref,
    operand_node: *const Node,
    src_ty: TypeId,
    dst_ty: TypeId,
    proto_name: []const u8,
    pack_key: []const u8,
    guard_key: u64,
) ?Ref {
    _ = operand_node;
    // PLANNING: select the matching pack impl + its `convert` (registry).
    const match = self.protocolResolver().matchPackImpl(src_ty, pack_key) orelse return null;
    const entry = match.entry;
    const fd = match.convert_fd;
    const src_params = match.src_params;
    const src_ret = match.src_ret;
    const table = &self.module.types;
    // EMISSION: bind the pack tail + ret-var, monomorphise, call (Lowering).

    // Build bindings. Target → dst_ty (already in the protocol's type
    // params), pack-var → src tail TypeIds, ret-var (when generic) →
    // src ret.
    const ent_pack_start = table.get(entry.source_pack_ty).closure.pack_start.?;
    const tail = src_params[ent_pack_start..];
    const tail_owned = self.alloc.dupe(TypeId, tail) catch return null;

    var bindings = std.StringHashMap(TypeId).init(self.alloc);
    defer bindings.deinit();
    const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null;
    bindings.put(pd.type_params[0].name, dst_ty) catch return null;
    if (entry.ret_var_name) |rv| bindings.put(rv, src_ret) catch return null;

    var pack_bindings = std.StringHashMap([]const TypeId).init(self.alloc);
    defer pack_bindings.deinit();
    pack_bindings.put(entry.pack_var_name, tail_owned) catch return null;

    // Mangled name keyed on the CONCRETE source so distinct shapes
    // monomorphise separately. Same scheme as the concrete path:
    // "<src>.convert__<dst>".
    const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
        self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
    }) catch return null;

    self.xx_reentrancy.put(guard_key, {}) catch {};
    defer _ = self.xx_reentrancy.remove(guard_key);

    if (!self.lowered_functions.contains(mangled)) {
        const saved_pack = self.pack_bindings;
        self.pack_bindings = pack_bindings;
        defer self.pack_bindings = saved_pack;
        self.monomorphizeFunction(fd, mangled, &bindings);
    }

    const fid = self.resolveFuncByName(mangled) orelse return null;
    const func = &self.module.functions.items[@intFromEnum(fid)];
    const ret_ty = func.ret;
    const params = func.params;
    var single = [_]Ref{operand};
    const final_args = self.prependCtxIfNeeded(func, single[0..]);
    self.coerceCallArgs(final_args, params);
    return self.builder.call(fid, final_args, ret_ty);
}

/// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise
/// the impl's `convert` method and emit a direct call. Returns null when
/// no impl matches (caller falls back to the built-in result, which is
/// the unchanged operand — Phase 3 emits no diagnostic for v0).
pub fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref {
    // Reentrancy guard — pack (src, dst) into a u64.
    const guard_key: u64 = (@as(u64, src_ty.index()) << 32) | @as(u64, dst_ty.index());
    if (self.xx_reentrancy.contains(guard_key)) {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, operand_node.span, "recursive xx conversion from '{s}' to '{s}'", .{
                self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
            });
        }
        return operand;
    }

    // Build lookup key: "Into\x00<dst_mangled>\x00<src_mangled>".
    // Hardcoded to the "Into" protocol for v1. Generalising to other
    // parameterised protocols would walk protocol_decl_map looking for
    // protocols that take a single type-param and have a `convert` method.
    const proto_name = "Into";
    const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null;
    if (pd.type_params.len != 1) return null;

    var key_buf = std.ArrayList(u8).empty;
    key_buf.appendSlice(self.alloc, proto_name) catch return null;
    key_buf.append(self.alloc, 0) catch return null;
    key_buf.appendSlice(self.alloc, self.mangleTypeName(dst_ty)) catch return null;
    key_buf.append(self.alloc, 0) catch return null;
    key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null;
    const key = key_buf.items;

    // Pack-only key (proto + dst) — used if the concrete lookup misses.
    // Same prefix as the concrete key, minus the `\x00<src_mangled>` tail.
    const dst_mangled_len = self.mangleTypeName(dst_ty).len;
    const pack_key = key_buf.items[0 .. proto_name.len + 1 + dst_mangled_len];

    const entries_opt = self.param_impl_map.get(key);
    const has_concrete = entries_opt != null and entries_opt.?.items.len > 0;
    if (!has_concrete) {
        // Concrete miss — try the pack map before emitting a diagnostic.
        if (self.tryPackImplMatch(operand, operand_node, src_ty, dst_ty, proto_name, pack_key, guard_key)) |result| {
            return result;
        }
        if (self.isClosureToBlockCast(src_ty, dst_ty)) {
            if (self.diagnostics) |diags| {
                const saved = diags.current_source_file;
                diags.current_source_file = operand_node.source_file orelse self.current_source_file;
                defer diags.current_source_file = saved;
                diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/ffi/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
            }
            return operand;
        }
        return null;
    }
    const entries = entries_opt.?;

    // Filter by import visibility: only impls in modules that the current
    // file transitively imports (or the current file itself) are reachable.
    // Falls open when import_graph isn't wired (e.g. comptime callers).
    var visible_impls = std.ArrayList(ParamImplEntry).empty;
    defer visible_impls.deinit(self.alloc);
    self.protocolResolver().findVisibleImpls(entries.items, &visible_impls);

    if (visible_impls.items.len == 0) {
        if (self.diagnostics) |diags| {
            const saved = diags.current_source_file;
            diags.current_source_file = operand_node.source_file orelse self.current_source_file;
            defer diags.current_source_file = saved;
            diags.addFmt(.err, operand_node.span, "no visible xx conversion from '{s}' to '{s}' — impl exists in another module but is not imported", .{
                self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
            });
        }
        return operand;
    }
    if (visible_impls.items.len > 1) {
        if (self.diagnostics) |diags| {
            const saved = diags.current_source_file;
            diags.current_source_file = operand_node.source_file orelse self.current_source_file;
            defer diags.current_source_file = saved;
            diags.addFmt(.err, operand_node.span, "duplicate xx conversion from '{s}' to '{s}': impls in {s} and {s}", .{
                self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
                visible_impls.items[0].defining_module, visible_impls.items[1].defining_module,
            });
        }
        return operand;
    }
    const entry = visible_impls.items[0];

    // Find the `convert` method on this impl.
    var convert_fd: ?*const ast.FnDecl = null;
    for (entry.methods) |m| {
        if (std.mem.eql(u8, m.name, "convert")) {
            convert_fd = m;
            break;
        }
    }
    const fd = convert_fd orelse return null;

    // Bind Target → dst_ty.
    var bindings = std.StringHashMap(TypeId).init(self.alloc);
    defer bindings.deinit();
    bindings.put(pd.type_params[0].name, dst_ty) catch return null;

    // Mangled name: "<src>.convert__<dst>".
    const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
        self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
    }) catch return null;

    self.xx_reentrancy.put(guard_key, {}) catch {};
    defer _ = self.xx_reentrancy.remove(guard_key);

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

    const fid = self.resolveFuncByName(mangled) orelse return null;
    const func = &self.module.functions.items[@intFromEnum(fid)];
    const ret_ty = func.ret;
    const params = func.params;
    var single = [_]Ref{operand};
    const final_args = self.prependCtxIfNeeded(func, single[0..]);
    self.coerceCallArgs(final_args, params);
    return self.builder.call(fid, final_args, ret_ty);
}

/// True for expression shapes that name an addressable storage location
/// (variables, fields, array elements, dereferenced pointers). Used by
/// `xx <struct-typed expr>` to decide between borrow (lvalue → take the
/// address) and heap-copy (rvalue → allocate a fresh copy).
pub fn isLvalueExpr(self: *Lowering, node: *const Node) bool {
    return switch (node.data) {
        .identifier, .field_access, .deref_expr => true,
        // A comptime pack index (`pack[i]`) is NOT an lvalue: a pack is
        // comptime-only with no runtime storage — `pack[i]` resolves to the
        // call-site arg node, which only acquires storage when lowered as a
        // value. Taking its address via `lowerExprAsPtr` would lower the bare
        // pack as a value and trip the pack-as-value error (issue 0135).
        // Reporting it as an rvalue routes `buildProtocolErasure` into its
        // heap-copy branch, which copies the already-materialized element.
        // A non-pack index (array/slice element) is a genuine lvalue.
        //
        // Decide pack-ness with the SAME predicate the value path uses —
        // `packArgNodeAt` (the `pack_arg_nodes` substitution map) — NOT
        // `isPackName` (the `pack_param_count` map). The two maps are set
        // together in the pack-fn path but the comptime-call path
        // (comptime.zig) installs only `pack_arg_nodes`; using `isPackName`
        // there would disagree with the value substitution and mis-route the
        // erasure. Sharing one predicate keeps the value/lvalue paths from
        // diverging on what counts as a pack element.
        .index_expr => self.packArgNodeAt(&node.data.index_expr) == null,
        else => false,
    };
}

/// Build a protocol value from a concrete value via xx conversion.
/// Coerce `val` (type `src`) to `dst`: if `dst` is a protocol, `xx`-erase
/// the concrete value into it; otherwise fall back to numeric/struct
/// coercion. Used to materialize a pack into a protocol-typed tuple field.
pub fn coerceOrErase(self: *Lowering, val: Ref, src: TypeId, dst: TypeId, node: *const Node) Ref {
    if (src == dst) return val;
    if (!dst.isBuiltin()) {
        const di = self.module.types.get(dst);
        if (di == .@"struct" and di.@"struct".is_protocol) {
            return self.buildProtocolErasure(val, node, src, dst);
        }
    }
    return self.coerceToType(val, src, dst);
}

pub fn buildProtocolErasure(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) Ref {
    const dst_info = self.module.types.get(dst_ty);
    if (dst_info != .@"struct") return operand;
    const proto_name = self.module.types.getString(dst_info.@"struct".name);

    // Determine concrete type name and type — resolve through pointer if needed
    var concrete_ptr = operand;
    var concrete_type_name: ?[]const u8 = null;
    var concrete_ty: TypeId = src_ty;
    var heap_copy = false;

    if (!src_ty.isBuiltin()) {
        const src_info = self.module.types.get(src_ty);
        if (src_info == .pointer) {
            // xx @acc — operand is already a pointer (user manages lifetime)
            const pointee = src_info.pointer.pointee;
            concrete_type_name = self.resolveConcreteTypeName(pointee);
            concrete_ty = pointee;
            heap_copy = false;
        } else if (src_info == .@"struct") {
            // Struct-typed operand. Split on lvalue-ness:
            //   - lvalue (identifier, field, index, deref): borrow the
            //     storage the operand already names. No heap copy; the
            //     protocol value's ctx points at the caller's slot, and
            //     mutations through the protocol are visible to the
            //     original. Lifetime is the caller's responsibility.
            //   - rvalue (struct literal, call result, etc.): heap-copy
            //     into a fresh allocation so the protocol value is
            //     self-contained and outlives this expression.
            concrete_type_name = self.module.types.getString(src_info.@"struct".name);
            concrete_ty = src_ty;
            if (self.isLvalueExpr(operand_node)) {
                concrete_ptr = self.lowerExprAsPtr(operand_node);
                heap_copy = false;
            } else {
                heap_copy = true;
                const slot = self.builder.alloca(src_ty);
                self.builder.store(slot, operand);
                concrete_ptr = slot;
            }
        }
    }

    // Also try from the operand node for struct literals: xx Accumulator.{ total = 0 }
    if (concrete_type_name == null) {
        concrete_type_name = self.inferConcreteTypeName(operand_node);
        if (concrete_type_name != null) heap_copy = true;
    }

    if (concrete_type_name) |ctn| {
        return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
    }
    return operand;
}

/// Try to infer the concrete type name from an AST node (for struct literals etc.)
pub fn inferConcreteTypeName(self: *Lowering, node: *const Node) ?[]const u8 {
    return switch (node.data) {
        .struct_literal => |sl| if (sl.struct_name) |n| n else null,
        .unary_op => |uop| if (uop.op == .address_of) self.inferConcreteTypeName(uop.operand) else null,
        .identifier => |id| blk: {
            // Check if identifier's type resolves to a struct
            if (self.scope) |scope| {
                if (scope.lookup(id.name)) |binding| {
                    if (!binding.ty.isBuiltin()) {
                        const bi = self.module.types.get(binding.ty);
                        if (bi == .@"struct") break :blk self.module.types.getString(bi.@"struct".name);
                        if (bi == .pointer) {
                            const pointee = bi.pointer.pointee;
                            if (!pointee.isBuiltin()) {
                                const pi = self.module.types.get(pointee);
                                if (pi == .@"struct") break :blk self.module.types.getString(pi.@"struct".name);
                            }
                        }
                    }
                }
            }
            break :blk null;
        },
        else => null,
    };
}

/// Generate a mini-dispatch for unboxing Any to f64 when the value might be f32 or f64.
/// Uses alloca-based merge: create result slot, branch, store in each arm, load after merge.
pub fn lowerAnyToF64Dispatch(self: *Lowering, any_val: Ref) Ref {
    // Create result alloca BEFORE the branch
    const result_slot = self.builder.alloca(.f64);

    // Extract type tag from Any
    const tag = self.builder.structGet(any_val, 0, .i64);

    const f32_bb = self.freshBlock("f32.unbox");
    const f64_bb = self.freshBlock("f64.unbox");
    const merge_bb = self.freshBlock("float.merge");

    // Branch: tag == f32_tag ? f32_bb : f64_bb
    const f32_tag = self.builder.constInt(TypeId.f32.index(), .i64);
    const cond = self.builder.emit(.{ .cmp_eq = .{ .lhs = tag, .rhs = f32_tag } }, .bool);
    self.builder.condBr(cond, f32_bb, &.{}, f64_bb, &.{});

    // f32 block: unbox as f32, fpext to f64, store
    self.builder.switchToBlock(f32_bb);
    const f32_val = self.builder.emit(.{ .unbox_any = .{
        .operand = any_val,
    } }, .f32);
    const f64_from_f32 = self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
    self.builder.store(result_slot, f64_from_f32);
    self.builder.br(merge_bb, &.{});

    // f64 block: unbox as f64 directly, store
    self.builder.switchToBlock(f64_bb);
    const f64_val = self.builder.emit(.{ .unbox_any = .{
        .operand = any_val,
    } }, .f64);
    self.builder.store(result_slot, f64_val);
    self.builder.br(merge_bb, &.{});

    // Merge block: load result
    self.builder.switchToBlock(merge_bb);
    return self.builder.load(result_slot, .f64);
}

/// Produce a default value for a type, applying struct field defaults.
/// For structs with defaults (e.g., `b: i32 = 99`), creates a struct_literal with defaults applied.
/// For other types, returns a zero value.
pub fn buildDefaultValue(self: *Lowering, ty: TypeId) Ref {
    if (ty.isBuiltin()) return self.builder.constInt(0, ty);
    const info = self.module.types.get(ty);
    if (info != .@"struct" and info != .tuple) return self.zeroValue(ty);
    // For tuples, build a zero-initialized tuple
    if (info == .tuple) {
        var field_vals = std.ArrayList(Ref).empty;
        defer field_vals.deinit(self.alloc);
        for (info.tuple.fields) |f| {
            field_vals.append(self.alloc, self.zeroValue(f)) catch unreachable;
        }
        return self.builder.emit(.{
            .tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
        }, ty);
    }
    // Check for struct defaults
    const struct_name_str = self.module.types.getString(info.@"struct".name);
    const field_defaults = self.struct_defaults_map.get(struct_name_str) orelse
        return self.builder.constUndef(ty);
    const fields = info.@"struct".fields;
    var field_vals = std.ArrayList(Ref).empty;
    defer field_vals.deinit(self.alloc);
    for (fields, 0..) |f, i| {
        if (i < field_defaults.len) {
            if (field_defaults[i]) |default_expr| {
                field_vals.append(self.alloc, self.lowerCoercedDefault(default_expr, f.ty)) catch unreachable;
            } else {
                field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
            }
        } else {
            field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
        }
    }
    return self.builder.emit(.{
        .struct_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
    }, ty);
}

/// Wrap ty in ?ty, but flatten: if ty is already ?U, return ?U (not ??U)
pub fn optionalOfFlattened(self: *Lowering, ty: TypeId) TypeId {
    if (!ty.isBuiltin()) {
        const info = self.module.types.get(ty);
        if (info == .optional) return ty;
    }
    return self.module.types.optionalOf(ty);
}

/// Produce a zero/default value for any type — constInt(0) for integers,
/// constNull for pointers, constUndef for structs/complex types.
pub fn zeroValue(self: *Lowering, ty: TypeId) Ref {
    if (ty.isBuiltin()) return self.builder.constInt(0, ty);
    const info = self.module.types.get(ty);
    return switch (info) {
        // Arbitrary-width integer types (u1, u2, i4, ...) interned as
        // `.signed`/`.unsigned` variants — fall through `isBuiltin()`.
        .signed, .unsigned => self.builder.constInt(0, ty),
        .pointer, .tuple, .optional => self.builder.constNull(ty),
        .@"struct", .array, .slice, .many_pointer => self.builder.constNull(ty),
        else => self.builder.constUndef(ty),
    };
}

/// Emit the unified non-integral float→int narrowing diagnostic (F0.11).
/// ONE wording, ONE place: every site that rejects an implicit
/// narrowing of a non-integral compile-time float to an integer type calls
/// this, so the message + fix-it stay identical across the typed-binding
/// coerce arm, the field/param-default sites, the typed-const path, and the
/// global-initializer path.
pub fn diagNonIntegralNarrow(self: *Lowering, span: ast.Span, value: f64, dst_ty: TypeId) void {
    if (self.diagnostics) |d|
        d.addFmt(.err, span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ value, self.formatTypeName(dst_ty) });
}

/// Lower a struct field default `default_expr`, coerced to the field type
/// `field_ty`. A compile-time float default narrowing into an integer field
/// follows the unified rule via `foldComptimeFloatInit`; everything else
/// lowers under the field type as target and coerces at the IR level.
pub fn lowerCoercedDefault(self: *Lowering, default_expr: *const Node, field_ty: TypeId) Ref {
    if (self.foldComptimeFloatInit(default_expr, field_ty)) |folded| return folded;
    const saved_tt = self.target_type;
    self.target_type = field_ty;
    const raw = self.lowerExpr(default_expr);
    self.target_type = saved_tt;
    return self.coerceToType(raw, self.builder.getRefType(raw), field_ty);
}

/// How a float→int conversion is treated. An IMPLICIT coercion (a typed
/// binding initializer) folds an integral compile-time float to its int and
/// REJECTS a non-integral one; an EXPLICIT `xx` / `cast` always truncates.
const CoerceMode = enum { implicit, explicit };

/// Insert a conversion if src_ty and dst_ty differ.
/// Handles int widening/narrowing, float widening/narrowing, and int↔float.
/// IMPLICIT coercion — the typed-binding initializer path. A compile-time
/// float narrowing to an integer folds when integral, errors when not.
pub fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref {
    return self.coerceMode(val, src_ty, dst_ty, .implicit);
}

/// EXPLICIT coercion — the `xx` / `cast(T)` escape hatch. A float→int here
/// always truncates, bypassing the integral-fold / non-integral-error rule.
pub fn coerceExplicit(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref {
    return self.coerceMode(val, src_ty, dst_ty, .explicit);
}

pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mode: CoerceMode) Ref {
    // PLANNING: classify the built-in coercion (conversions.zig).
    // EMISSION: each arm below reproduces the original lowering.
    switch (self.coercionResolver().classify(src_ty, dst_ty)) {
        .no_op, .none => return val,
        // Unbox Any → concrete type
        .unbox_any => return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty),
        // Box concrete → Any
        .box_any => return self.builder.boxAny(val, src_ty),
        // Closure VALUE → bare function-pointer slot: not soundly representable.
        // A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env
        // arg, but a closure's underlying fn takes an env slot — so passing a
        // closure value's fn_ptr drops the env and shifts the args (UB for a
        // matching ABI, a wrong-tuple read for ∅-widening, a segfault when the
        // closure captures). Only a closure LITERAL can cross this boundary,
        // via the static adapter `lowerLambda` emits (so a literal arrives here
        // already typed `.function`). Reject the variable case loudly.
        .closure_to_fn_reject => {
            if (self.diagnostics) |d| {
                const cs = self.builder.current_span;
                d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a closure value cannot be passed as a bare function-pointer `(...) -> ...` — its environment can't be carried across the bare ABI; pass the closure literal directly at the call site, or declare the parameter type as `Closure(...)`", .{});
            }
            return val;
        },
        // Tuple → Tuple element-wise coercion (e.g. a `(i64, i64)` literal
        // flowing into a `(i32, i32)` slot — the multi-value failable success
        // tuple). Same arity: extract each slot, coerce it, rebuild.
        .tuple_elementwise => {
            const si = self.module.types.get(src_ty);
            const di = self.module.types.get(dst_ty);
            var elems = std.ArrayList(Ref).empty;
            defer elems.deinit(self.alloc);
            for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| {
                const fv = self.builder.emit(.{ .tuple_get = .{ .base = val, .field_index = @intCast(i), .base_type = src_ty } }, sf);
                elems.append(self.alloc, self.coerceMode(fv, sf, df, mode)) catch unreachable;
            }
            return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty);
        },
        // Optional → Concrete unwrapping (flow-sensitive narrowing coercion)
        .optional_unwrap => {
            const child_ty = self.module.types.get(src_ty).optional.child;
            const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
            return self.coerceMode(unwrapped, child_ty, dst_ty, mode);
        },
        // string → cstring: ONLY a string LITERAL coerces implicitly — its
        // bytes are a terminated constant (Odin's literal blessing). Any
        // other string may be an unterminated view, so it must materialize
        // through `to_cstring`.
        .string_to_cstring => {
            if (self.builder.isConstString(val)) {
                return self.builder.emit(.{ .data_ptr = .{ .operand = val } }, .cstring);
            }
            if (self.diagnostics) |d| {
                const cs = self.builder.current_span;
                d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "only a string LITERAL coerces to 'cstring' implicitly; an arbitrary string may be an unterminated view — materialize it with to_cstring(s)", .{});
            }
            return val;
        },
        // cstring → string: the length is implicit (strlen), so the
        // conversion is never silent — `from_cstring(c)` is the zero-copy
        // view, `substr(from_cstring(c), 0, ...)` the owned copy.
        .cstring_to_string_reject => {
            if (self.diagnostics) |d| {
                const cs = self.builder.current_span;
                d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "'cstring' does not coerce to 'string' implicitly (the length is implicit); convert with from_cstring(c)", .{});
            }
            return val;
        },
        // void → Optional: produce null (void is the type of null_literal)
        .void_to_optional => return self.builder.constNull(dst_ty),
        // Concrete → Optional wrapping (coerce to the inner type first)
        .optional_wrap => {
            const child_ty = self.module.types.get(dst_ty).optional.child;
            const coerced = self.coerceMode(val, src_ty, child_ty, mode);
            return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty);
        },
        // Concrete → Protocol (auto type erasure)
        .erase_protocol => {
            const proto_name = self.module.types.getString(self.module.types.get(dst_ty).@"struct".name);
            const ctn = self.resolveConcreteTypeName(src_ty).?;
            // If src is a pointer, use directly; otherwise alloca+store + heap-copy
            var concrete_ptr = val;
            var concrete_ty = src_ty;
            var heap_copy = false;
            if (!src_ty.isBuiltin()) {
                const si = self.module.types.get(src_ty);
                if (si == .pointer) {
                    concrete_ty = si.pointer.pointee;
                    heap_copy = false;
                } else {
                    const slot = self.builder.alloca(src_ty);
                    self.builder.store(slot, val);
                    concrete_ptr = slot;
                    heap_copy = true;
                }
            }
            return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
        },
        .int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
        .float_to_int => {
            // Implicit float→int narrowing follows the unified rule (the
            // same `floatToIntExact` the array-dim / `$K: Count` paths use):
            // a compile-time INTEGRAL float folds to its int, a NON-integral
            // one is a compile error. Explicit `xx` / `cast` (mode
            // `.explicit`) skips this and truncates. A runtime float has no
            // compile-time value to fold — it truncates as before.
            if (mode == .implicit) {
                if (self.builder.constFloatInfo(val)) |info| {
                    if (program_index_mod.floatToIntExact(info.value)) |iv| {
                        return self.builder.constInt(iv, dst_ty);
                    }
                    // Non-integral: diagnose, then fall through to the
                    // truncating op below so lowering finishes and
                    // `hasErrors()` aborts the build.
                    self.diagNonIntegralNarrow(.{ .start = info.span.start, .end = info.span.end }, info.value, dst_ty);
                }
            }
            return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
        },
        // Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot.
        // Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches
        // to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level
        // since LLVMBuildBitCast itself doesn't accept ptr↔int.
        .ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
        .narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
        .widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
        .array_to_slice => return self.builder.emit(.{ .array_to_slice = .{ .operand = val } }, dst_ty),
        // `[*]T → []T`: a many-pointer has no length, so it can't form a slice
        // header implicitly. Diagnose and tell the user to slice with a length.
        .many_to_slice_reject => {
            if (self.diagnostics) |d| {
                const cs = self.builder.current_span;
                d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a many-pointer '[*]T' does not coerce to a slice '[]T' implicitly (it carries no length) — slice it with a length: ptr[0..len]", .{});
            }
            return val;
        },
    }
}

/// Apply C default argument promotion to variadic-tail args. These rules
/// (bool/i8/i16/u8/u16 → i32, f32 → f64) match the C calling convention's
/// implicit promotions when an argument is passed through `...`.
pub fn promoteCVariadicArgs(self: *Lowering, args: []Ref, fixed_count: usize) void {
    if (args.len <= fixed_count) return;
    for (args[fixed_count..]) |*arg| {
        const src_ty = self.builder.getRefType(arg.*);
        const promoted: TypeId = switch (src_ty) {
            .bool, .i8, .i16, .u8, .u16 => .i32,
            .f32 => .f64,
            else => continue,
        };
        arg.* = self.coerceToType(arg.*, src_ty, promoted);
    }
}

/// Coerce call arguments in-place to match function parameter types.
pub fn coerceCallArgs(self: *Lowering, args: []Ref, params: []const Function.Param) void {
    for (0..@min(args.len, params.len)) |i| {
        const src_ty = self.builder.getRefType(args[i]);
        const dst_ty = params[i].ty;
        if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) {
            const src_info = self.module.types.get(src_ty);
            const dst_info = self.module.types.get(dst_ty);
            // Array → many_pointer decay: alloca the array, GEP to first element
            if (src_info == .array and dst_info == .many_pointer) {
                const slot = self.builder.alloca(src_ty);
                self.builder.store(slot, args[i]);
                const zero = self.builder.constInt(0, .i64);
                args[i] = self.builder.emit(.{ .index_gep = .{ .lhs = slot, .rhs = zero } }, dst_ty);
                continue;
            }
            // Implicit address-of: passing T value where *T is expected → alloca + store
            // Only when the pointee type matches the source type.
            if (dst_info == .pointer and src_info != .pointer and dst_info.pointer.pointee == src_ty) {
                const slot = self.builder.alloca(src_ty);
                self.builder.store(slot, args[i]);
                args[i] = slot;
                continue;
            }
        }
        args[i] = self.coerceToType(args[i], src_ty, dst_ty);
    }
}
