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 mod_mod = @import("../module.zig");
const errors = @import("../../errors.zig");

const TypeId = types.TypeId;
const Ref = inst_mod.Ref;
const Module = mod_mod.Module;

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

pub fn lowerBlock(self: *Lowering, node: *const Node) void {
    switch (node.data) {
        .block => |blk| {
            // Create a child scope for block-level variable shadowing
            var block_scope = Scope.init(self.alloc, self.scope);
            const saved_scope = self.scope;
            self.scope = &block_scope;
            const saved_defer_len = self.defer_stack.items.len;
            defer {
                self.emitBlockDefers(saved_defer_len);
                self.scope = saved_scope;
                block_scope.deinit();
            }
            for (blk.stmts) |stmt| {
                if (self.block_terminated) break;
                self.lowerStmt(stmt);
                // A bare `return`/`raise` mid-block terminates the current
                // basic block but deliberately does NOT set `block_terminated`
                // (that flag would leak past an `if cond { return }` merge
                // block, skipping its trailing statements — see lowerReturn).
                // Stop here so dead statements after the terminator aren't
                // emitted into an already-closed block (invalid LLVM IR).
                if (self.currentBlockHasTerminator()) break;
            }
        },
        else => {
            // Single expression as body (arrow functions)
            self.lowerStmt(node);
        },
    }
}

/// Lower an `inline if` branch — block body emits statements, expression returns value.
pub fn lowerInlineBranch(self: *Lowering, node: *const Node) Ref {
    if (node.data == .block) {
        self.lowerBlock(node);
        // A `return` inside the branch terminates the current LLVM block; propagate
        // that up so the enclosing block lowering stops emitting fall-through.
        if (self.currentBlockHasTerminator()) {
            self.block_terminated = true;
            return .none;
        }
        return self.builder.constInt(0, .void);
    }
    return self.lowerExpr(node);
}

/// Lower a block and return the last expression's value (for implicit returns).
pub fn lowerBlockValue(self: *Lowering, node: *const Node) ?Ref {
    // Set force_block_value so nested if-else expressions produce values
    const saved = self.force_block_value;
    self.force_block_value = true;
    defer self.force_block_value = saved;

    switch (node.data) {
        .block => |blk| {
            if (blk.stmts.len == 0) return null;
            // Create a child scope for block-level variable shadowing
            var block_scope = Scope.init(self.alloc, self.scope);
            const saved_scope = self.scope;
            self.scope = &block_scope;
            const saved_defer_len = self.defer_stack.items.len;
            defer {
                self.emitBlockDefers(saved_defer_len);
                self.scope = saved_scope;
                block_scope.deinit();
            }
            // A block whose last statement is `;`-terminated (or not an
            // expression) discards its value: lower every statement as a
            // statement and yield nothing.
            if (!blk.produces_value) {
                self.force_block_value = false;
                for (blk.stmts) |stmt| {
                    if (self.block_terminated) return null;
                    self.lowerStmt(stmt);
                    if (self.currentBlockHasTerminator()) return null;
                }
                return null;
            }
            // Lower all statements except the last normally
            self.force_block_value = false; // don't force for non-last statements
            for (blk.stmts[0 .. blk.stmts.len - 1]) |stmt| {
                if (self.block_terminated) return null;
                self.lowerStmt(stmt);
                // A bare `return`/`raise` mid-block closes the current basic
                // block (without setting `block_terminated`); the remaining
                // statements — including the value-expr — are dead.
                if (self.currentBlockHasTerminator()) return null;
            }
            if (self.block_terminated) return null;
            // Last statement (no trailing `;`): its value is the block's.
            self.force_block_value = true;
            const last = blk.stmts[blk.stmts.len - 1];
            return self.tryLowerAsExpr(last);
        },
        else => {
            // Single expression as body (arrow functions)
            return self.tryLowerAsExpr(node);
        },
    }
}

/// Lower a value-returning function body and emit the implicit return.
/// Emits a hard error when the body yields no value — its last statement is
/// `;`-terminated (value discarded) or void — and the body doesn't already
/// terminate via `return`/`raise`. Replaces the old silent default-return.
pub fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
    const body_val = self.lowerBlockValue(body);
    if (self.currentBlockHasTerminator()) return;
    if (body_val) |val| {
        const val_ty = self.builder.getRefType(val);
        if (val_ty != .void) {
            const coerced = self.coerceToType(val, val_ty, ret_ty);
            self.builder.ret(coerced, ret_ty);
            return;
        }
    }
    // A PURE-failable function (`-> !` / `-> !Named`, whose entire return IS
    // the error channel) carries no success value — a void body is a normal
    // success exit, not a missing value. `ensureTerminator` emits the
    // error-slot-zero success return.
    if (self.errorChannelOf(ret_ty)) |chan| {
        if (chan == ret_ty) {
            self.ensureTerminator(ret_ty);
            return;
        }
    }
    if (self.diagnostics) |diags| {
        if (body.data == .block and body.data.block.discarded_semi != null) {
            diags.addFmt(.err, body.data.block.discarded_semi.?, "function returns '{s}' but the last expression's value is discarded by this `;` — drop the `;` to return it (or use an explicit `return`)", .{self.formatTypeName(ret_ty)});
        } else {
            const span = blk: {
                if (body.data == .block) {
                    const stmts = body.data.block.stmts;
                    if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
                }
                break :blk body.span;
            };
            diags.addFmt(.err, span, "function returns '{s}' but its body produces no value — end it with a trailing expression (no `;`) or an explicit `return`", .{self.formatTypeName(ret_ty)});
        }
    }
    self.ensureTerminator(ret_ty);
}

/// Try to lower a node as an expression, returning its value.
/// Statement nodes are lowered as statements (returning null).
pub fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref {
    return switch (node.data) {
        .var_decl, .const_decl, .fn_decl, .return_stmt, .raise_stmt, .assignment, .defer_stmt, .push_stmt, .multi_assign, .destructure_decl => {
            self.lowerStmt(node);
            return null;
        },
        else => self.lowerExpr(node),
    };
}

pub fn lowerStmt(self: *Lowering, node: *const Node) void {
    // Stamp this statement's span onto its instructions (ERR E3.0); see
    // `lowerExpr`.
    const saved_span = self.builder.current_span;
    defer self.builder.current_span = saved_span;
    if (node.span.start != 0 or node.span.end != 0) self.builder.current_span = .{ .start = node.span.start, .end = node.span.end };
    switch (node.data) {
        .var_decl => |vd| self.lowerVarDecl(&vd),
        .const_decl => |cd| self.lowerConstDecl(&cd),
        // Pointer capture, not by-value: `lowerLocalFnDecl` registers the
        // decl pointer in `fn_ast_map`, so it must point into the AST node,
        // not at a stack temporary that the next statement reuses.
        .fn_decl => |*fd| self.lowerLocalFnDecl(fd),
        .return_stmt => |rs| self.lowerReturn(&rs),
        .raise_stmt => |rs| self.lowerRaise(&rs, node.span),
        .assignment => |asgn| self.lowerAssignment(&asgn),
        .defer_stmt => |ds| self.lowerDefer(&ds),
        .onfail_stmt => |ofs| self.lowerOnFail(&ofs, node.span),
        .push_stmt => |ps| self.lowerPush(&ps),
        .multi_assign => |ma| self.lowerMultiAssign(&ma),
        .destructure_decl => |dd| self.lowerDestructureDecl(&dd),
        .insert_expr => |ins| self.lowerInsertExpr(ins.expr),
        .block => self.lowerBlock(node),
        .jni_env_block => |eb| {
            // Compile-time stack push for lexical-direct env resolution
            // (2.16b — `#jni_call` in the same fn picks up env from
            // jni_env_stack directly, no TL read).
            //
            // Runtime TL save/set/restore (2.16c) for cross-function
            // helpers: callees in OTHER fns invoked from inside the
            // body read the slot via `sx_jni_env_tl_get`. Storage
            // lives in a separately-linked C helper (see
            // library/vendors/sx_jni_runtime/sx_jni_env_tl.c) so the
            // JIT doesn't need orc_rt for TLS.
            const env_ref = self.lowerExpr(eb.env);
            const fids = self.getJniEnvTlFids();
            const ptr_ty = self.module.types.ptrTo(.void);
            const saved_tl = self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
            const set_args = self.alloc.dupe(Ref, &.{env_ref}) catch unreachable;
            _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = set_args } }, .void);
            self.jni_env_stack.append(self.alloc, env_ref) catch unreachable;
            self.lowerBlock(eb.body);
            _ = self.jni_env_stack.pop();
            const restore_args = self.alloc.dupe(Ref, &.{saved_tl}) catch unreachable;
            _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
        },
        // Block-local type declarations
        .struct_decl => |sd| {
            self.recordLocalTypeName(sd.name);
            self.registerStructDecl(&node.data.struct_decl, node.source_file orelse self.current_source_file);
        },
        .enum_decl => {
            if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
            self.registerEnumDecl(&node.data.enum_decl);
        },
        .union_decl => {
            if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
            self.registerUnionDecl(&node.data.union_decl);
        },
        .error_set_decl => {
            if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
            self.registerErrorSetDecl(node);
        },
        .ufcs_alias => |ua| {
            self.program_index.ufcs_alias_map.put(ua.name, ua.target) catch {};
        },
        // Expression statement
        else => {
            _ = self.lowerExpr(node);
        },
    }
}

pub fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void {
    if (vd.value) |val| {
        if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
            const ph = self.diagPackAsValue(val.data.identifier.name, val.span, .storage);
            // Bind the name to the placeholder so later uses don't cascade
            // into a second "unresolved" error after this one.
            if (self.scope) |scope| {
                scope.put(vd.name, .{ .ref = ph, .ty = .unresolved, .is_alloca = false });
            }
            return;
        }
    }
    if (vd.type_annotation) |ta| {
        // Explicit type annotation — resolve type first, then lower value
        const ty = self.resolveType(ta);
        const slot = self.builder.alloca(ty);
        if (vd.value) |val| {
            if (val.data == .undef_literal and !ty.isBuiltin()) {
                const ti = self.module.types.get(ty);
                // = --- (undef_literal) on tuple types: zero-initialize
                if (ti == .tuple) {
                    var field_vals = std.ArrayList(Ref).empty;
                    defer field_vals.deinit(self.alloc);
                    for (ti.tuple.fields) |f| {
                        field_vals.append(self.alloc, self.builder.constInt(0, f)) catch unreachable;
                    }
                    const zero = self.builder.emit(.{
                        .tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
                    }, ty);
                    self.builder.store(slot, zero);
                    if (self.scope) |scope| {
                        scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
                    }
                    return;
                }
                // `---` on an array: explicitly uninitialized — no store.
                // A whole-array undef store is a store of nothing that
                // LLVM's legalizer scalarizes into one DAG node per
                // element (issue 0124: SelectionDAG segfault at ~64K).
                if (ti == .array) {
                    if (self.scope) |scope| {
                        scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
                    }
                    return;
                }
            }
            // A compile-time float initializer narrowing into an integer
            // local follows the unified rule (integral folds, non-integral
            // errors); a runtime float / `xx` cast falls through to the
            // normal lower+coerce below.
            if (self.foldComptimeFloatInit(val, ty)) |folded| {
                self.builder.store(slot, folded);
                if (self.scope) |scope| {
                    scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
                }
                return;
            }
            const saved_target = self.target_type;
            const saved_fbv = self.force_block_value;
            self.target_type = ty;
            self.force_block_value = true;
            var ref = self.lowerExpr(val);
            self.target_type = saved_target;
            self.force_block_value = saved_fbv;
            // If target is optional and value isn't null, wrap with optional_wrap
            if (!ty.isBuiltin()) {
                const ty_info = self.module.types.get(ty);
                if (ty_info == .optional and val.data != .null_literal) {
                    ref = self.builder.optionalWrap(ref, ty);
                } else if (ty_info == .slice) {
                    // Array → slice promotion: if value is an array, convert to slice
                    const ref_ty = self.builder.getRefType(ref);
                    if (!ref_ty.isBuiltin()) {
                        const ref_info = self.module.types.get(ref_ty);
                        if (ref_info == .array) {
                            ref = self.builder.emit(.{ .array_to_slice = .{ .operand = ref } }, ty);
                        }
                    }
                } else if (self.getProtocolInfo(ty) != null) {
                    // Auto type erasure: concrete → protocol
                    const ref_ty = self.builder.getRefType(ref);
                    if (ref_ty != ty) {
                        ref = self.buildProtocolErasure(ref, val, ref_ty, ty);
                    }
                }
            }
            // Coerce value to match target type (e.g. u8 → i64 widening)
            {
                const ref_ty = self.builder.getRefType(ref);
                if (ref_ty != ty and ref_ty != .void and ty != .void) {
                    ref = self.coerceToType(ref, ref_ty, ty);
                }
            }
            self.builder.store(slot, ref);
        } else {
            // No value: zero-initialize or apply struct defaults
            const zero = self.buildDefaultValue(ty);
            self.builder.store(slot, zero);
        }
        if (self.scope) |scope| {
            scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
        }
    } else if (vd.value) |val| {
        // No type annotation — lower expr first, then get type from result.
        // This is critical for generic calls where the return type is only
        // known after monomorphization.
        const saved_fbv = self.force_block_value;
        const saved_target = self.target_type;
        self.force_block_value = true;
        // An unannotated decl provides no target type: clear the ambient one
        // (the enclosing fn's implicit-return target) so literal initializers
        // take their spec defaults (i64/f64) instead of adopting it.
        self.target_type = null;
        const ref = self.lowerExpr(val);
        self.force_block_value = saved_fbv;
        self.target_type = saved_target;
        const ty = self.builder.getRefType(ref);
        const slot = self.builder.alloca(ty);
        self.builder.store(slot, ref);
        if (self.scope) |scope| {
            scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
        }
    } else {
        const ty = TypeId.i64;
        const slot = self.builder.alloca(ty);
        self.builder.store(slot, self.zeroValue(ty));
        if (self.scope) |scope| {
            scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
        }
    }
}

/// Handle a bare fn_decl node as a local function declaration.
/// The parser produces `fn_decl` (not `const_decl`) for `name :: (params) -> T { body }`.
pub fn lowerLocalFnDecl(self: *Lowering, fd: *const ast.FnDecl) void {
    // Use mangled name for local functions to support block-scoped shadowing
    const name = if (self.scope) |scope| blk: {
        const mangled = std.fmt.allocPrint(self.alloc, "{s}__{d}", .{ fd.name, self.local_fn_counter }) catch fd.name;
        self.local_fn_counter += 1;
        scope.fn_names.put(fd.name, mangled) catch {};
        break :blk mangled;
    } else fd.name;
    self.program_index.fn_ast_map.put(name, fd) catch {};
    self.lazyLowerFunction(name);
}

pub fn lowerConstDecl(self: *Lowering, cd: *const ast.ConstDecl) void {
    // Handle local function declarations: fx :: (s:i3) -> i3 { ... }
    if (cd.value.data == .fn_decl) {
        const fd = &cd.value.data.fn_decl;
        // Use mangled name for local functions to support block-scoped shadowing
        const name = if (self.scope != null) blk: {
            const mangled = std.fmt.allocPrint(self.alloc, "{s}__{d}", .{ cd.name, self.local_fn_counter }) catch cd.name;
            self.local_fn_counter += 1;
            // Register the bare→mangled mapping in the current scope
            if (self.scope) |scope| {
                scope.fn_names.put(cd.name, mangled) catch {};
            }
            break :blk mangled;
        } else cd.name;
        // Register in fn_ast_map so it can be resolved by lowerCall
        self.program_index.fn_ast_map.put(name, fd) catch {};
        // Lower the function body (saves/restores builder state)
        self.lazyLowerFunction(name);
        return;
    }

    // Handle local type declarations: MyType :: struct/union/enum { ... }
    if (cd.value.data == .struct_decl) {
        self.recordLocalTypeName(cd.name);
        self.registerStructDecl(&cd.value.data.struct_decl, self.current_source_file);
        return;
    }
    if (cd.value.data == .enum_decl) {
        self.recordLocalTypeName(cd.name);
        self.registerEnumDecl(&cd.value.data.enum_decl);
        return;
    }
    if (cd.value.data == .union_decl) {
        self.recordLocalTypeName(cd.name);
        self.registerUnionDecl(&cd.value.data.union_decl);
        return;
    }

    const ref = self.lowerExpr(cd.value);
    // If there's an explicit type annotation, use it. Otherwise, infer from the expression.
    const ty = if (cd.type_annotation) |ta|
        self.resolveType(ta)
    else
        self.builder.getRefType(ref);

    if (self.scope) |scope| {
        scope.put(cd.name, .{ .ref = ref, .ty = ty, .is_alloca = false });
    }
}

pub fn lowerReturn(self: *Lowering, rs: *const ast.ReturnStmt) void {
    if (rs.value) |val| {
        if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
            _ = self.diagPackAsValue(val.data.identifier.name, val.span, .return_value);
            return;
        }
    }
    // Set target_type to function return type so null_literal etc. get the right type.
    // When inlining a comptime body, the *inlined* fn's declared return type wins
    // over the caller's — otherwise `return 42` inside a `-> i64` body lowered into
    // a `-> i32` caller would coerce 42 to i32 before storing into the i64 slot.
    const old_target = self.target_type;
    const ret_ty_for_target: TypeId = if (self.inline_return_target) |iri|
        iri.ret_ty
    else if (self.builder.func) |fid|
        self.module.functions.items[@intFromEnum(fid)].ret
    else
        TypeId.i64;
    // A value-carrying failable (`-> (T..., !)`) returns its VALUE part and
    // the success error slot (0) is appended by lowerFailableSuccessReturn.
    // Resolve a BARE returned value against that value type, NOT the failable
    // tuple: a bare enum literal `.variant` resolves its tag against
    // `target_type`, and against the tuple it matches no variant (tag 0) and
    // is stamped with the tuple type — which the success-return path then
    // mistakes for a forwarded full tuple, dropping the appended `0` slot.
    // An explicit full failable tuple return (`return (v..., e)`) keeps the
    // full-tuple target so its trailing error element resolves against the
    // error set; it is then forwarded as-is. Applies to the inlined
    // comptime-body return path too (iri.ret_ty is the failable tuple there).
    const target_for_value = self.failableReturnTarget(ret_ty_for_target, rs.value);
    if (target_for_value != .void) self.target_type = target_for_value;
    // Evaluate return value first (before defers)
    const ret_val = if (rs.value) |val| self.lowerExpr(val) else null;
    self.target_type = old_target;

    // Inlined-comptime-body return: store into the slot the inliner
    // gave us and branch to the inliner's "return-done" basic block.
    // The branch is the basic block's terminator — so subsequent
    // dead code in the same block trips the LLVM verifier (the
    // SAME behaviour as a regular `return X;` followed by code).
    //
    // We DO NOT set `block_terminated = true`: that flag would
    // leak past structured control flow (e.g. an `if cond { return
    // X; }` whose merge block continues to subsequent statements)
    // and incorrectly skip the trailing statements. CFG-level
    // termination is what we actually want — let the basic-block
    // terminator do its job.
    if (self.inline_return_target) |iri| {
        if (ret_val) |ref| {
            // Value-carrying failable inlined body: append the success error
            // slot (0) exactly like the real-return path below.
            // lowerFailableSuccessReturn routes through emitTupleRet, which
            // stores into iri.slot and branches to iri.done_bb for an inline
            // target. Defers first, so the returned SSA value is materialized
            // before they run (matching the real-return ordering).
            if (!iri.ret_ty.isBuiltin() and
                self.module.types.get(iri.ret_ty) == .tuple and
                self.errorChannelOf(iri.ret_ty) != null)
            {
                self.emitBlockDefers(self.func_defer_base);
                self.lowerFailableSuccessReturn(ref, iri.ret_ty, rs.value.?.span);
                return;
            }
            const val_ty = self.builder.getRefType(ref);
            const coerced = if (val_ty != iri.ret_ty)
                self.coerceToType(ref, val_ty, iri.ret_ty)
            else
                ref;
            self.builder.store(iri.slot, coerced);
        }
        // Drain block-scoped defers up to the inlined-body base so
        // they fire on this return path the same as a real fn return.
        self.emitBlockDefers(self.func_defer_base);
        self.builder.br(iri.done_bb, &.{});
        return;
    }

    // Emit ALL pending defers for THIS function in LIFO order before the return
    self.emitBlockDefers(self.func_defer_base);

    if (ret_val) |ref| {
        const ret_ty = if (self.builder.func) |fid|
            self.module.functions.items[@intFromEnum(fid)].ret
        else
            TypeId.i64;
        if (ret_ty == .void) {
            // Void function — just return void (the value expression was evaluated for side effects)
            self.builder.retVoid();
        } else if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) {
            // Value-carrying failable `-> (T..., !)`: the user returns the
            // value part; the compiler appends the success error slot (0).
            self.lowerFailableSuccessReturn(ref, ret_ty, rs.value.?.span);
        } else {
            // Coerce return value to match function return type (e.g., ?i32 → i32)
            const val_ty = self.builder.getRefType(ref);
            const coerced = self.coerceToType(ref, val_ty, ret_ty);
            self.builder.ret(coerced, ret_ty);
        }
    } else {
        // A bare `return;` in a pure failable function (`-> !` / `-> !Named`,
        // whose return type IS the error set) is the success exit — the
        // error slot carries 0 ("no error"). Everything else is a void return.
        const ret_ty = if (self.builder.func) |fid|
            self.module.functions.items[@intFromEnum(fid)].ret
        else
            TypeId.void;
        if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .error_set) {
            self.builder.ret(self.builder.constInt(0, ret_ty), ret_ty);
        } else {
            self.builder.retVoid();
        }
    }
}

/// The ROOT identifier of an assignment-target chain (`K[0].x` → "K",
/// `WHITE.r` → "WHITE"). A deref along the chain (`p.*`, `p.*[i]`) breaks
/// it — writing through a pointer VALUE is not a write to the named root.
fn assignmentRootIdent(target: *const Node) ?[]const u8 {
    var n = target;
    while (true) {
        switch (n.data) {
            .identifier => |id| return id.name,
            .index_expr => |ie| n = ie.object,
            .field_access => |fa| n = fa.object,
            else => return null,
        }
    }
}

/// True when `root` names a module CONSTANT from the current source —
/// a const-flagged global (array/struct consts, #run consts) or a
/// module value const. Locals shadow (caller checks scope first).
fn rootIsConstant(self: *Lowering, root: []const u8) bool {
    switch (self.selectGlobalAuthor(root)) {
        .resolved => |g| if (self.module.globals.items[g.id.index()].is_const) return true,
        else => {},
    }
    return switch (self.selectModuleConst(root)) {
        .resolved, .own_opaque => true,
        .ambiguous, .none => false,
    };
}

pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
    // Writes through a constant are rejected at compile time (issue 0116):
    // the target chain's root naming a const global (array/struct consts,
    // #run consts) or a module value const cannot be stored to — for a
    // struct const the store previously compiled and bus-errored at
    // runtime; for scalars it silently misfired.
    if (assignmentRootIdent(asgn.target)) |root| {
        const shadowed = if (self.scope) |s| s.lookup(root) != null else false;
        if (!shadowed and rootIsConstant(self, root)) {
            if (self.diagnostics) |d|
                d.addFmt(.err, asgn.target.span, "cannot assign through constant '{s}' — constants are immutable (use a '=' global or a local copy for mutable data)", .{root});
            return;
        }
    }
    // Set target_type from LHS for RHS lowering (enum literals, struct literals, etc.)
    const old_target = self.target_type;
    if (asgn.target.data == .identifier) {
        var found_local = false;
        if (self.scope) |scope| {
            if (scope.lookup(asgn.target.data.identifier.name)) |binding| {
                self.target_type = binding.ty;
                found_local = true;
            }
        }
        if (!found_local) {
            // Quiet author-aware lookup (type inference only; the store
            // site diagnoses ambiguity / visibility).
            if (self.program_index.global_names.get(asgn.target.data.identifier.name)) |gi| {
                switch (self.selectGlobalAuthor(asgn.target.data.identifier.name)) {
                    .resolved => |g| self.target_type = g.ty,
                    .untracked => self.target_type = gi.ty,
                    else => {},
                }
            }
        }
    } else if (asgn.target.data == .index_expr) {
        // For array[i] = val, set target_type to the element type
        const tgt_obj_ty = self.inferExprType(asgn.target.data.index_expr.object);
        const elem_ty = self.ptrToArrayElem(tgt_obj_ty) orelse self.getElementType(tgt_obj_ty);
        if (elem_ty != .void) self.target_type = elem_ty;
    } else if (asgn.target.data == .field_access) {
        // For obj.field = val, set target_type to the field's type so RHS
        // sub-expressions (enum/struct literals, branch arms, xx casts) can
        // resolve against it. Skipped for forms that would forward the type
        // unchanged into method-call arg slots (`resolveCallParamTypes` can't
        // override target_type per-arg).
        const needs_target = switch (asgn.value.data) {
            .enum_literal, .struct_literal, .tuple_literal, .if_expr, .match_expr, .block, .unary_op, .binary_op => true,
            .call => |vc| vc.callee.data == .enum_literal,
            else => false,
        };
        if (needs_target) {
            const fa = asgn.target.data.field_access;
            const obj_ty_raw = self.inferExprType(fa.object);
            const obj_ty = if (!obj_ty_raw.isBuiltin()) blk: {
                const pinfo = self.module.types.get(obj_ty_raw);
                break :blk if (pinfo == .pointer) pinfo.pointer.pointee else obj_ty_raw;
            } else obj_ty_raw;
            // Resolve the LHS member's type via the SAME resolver the lvalue-
            // pointer path uses (fieldLvalueResolve), so the RHS target type
            // and the store slot can't diverge. Covers union/tagged-union
            // direct + promoted members, tuple/vector lanes, and structs —
            // not just structs (a plain getStructFields loop returned nothing
            // for a union member, leaving a struct-literal RHS untyped →
            // struct_init.ty == .unresolved → LLVM-emission panic; issue 0133).
            if (self.fieldLvalueResolve(obj_ty, fa.field)) |res| {
                self.target_type = res.valueType();
            }
        }
    }
    const val = self.lowerExpr(asgn.value);
    self.target_type = old_target;

    switch (asgn.target.data) {
        .identifier => |id| {
            var handled = false;
            if (self.scope) |scope| {
                if (scope.lookup(id.name)) |binding| {
                    if (binding.is_alloca) {
                        handled = true;
                        if (asgn.op == .assign) {
                            // Coerce value to match binding type (e.g., f32 → ?f32, concrete → protocol)
                            var store_val = val;
                            const val_ty = self.builder.getRefType(val);
                            if (val_ty != binding.ty and val_ty != .void and binding.ty != .void) {
                                store_val = self.coerceToType(val, val_ty, binding.ty);
                            }
                            self.builder.store(binding.ref, store_val);
                        } else {
                            // Compound assignment: load, op, store
                            const loaded = self.builder.load(binding.ref, binding.ty);
                            const result = self.emitCompoundOp(loaded, val, asgn.op, binding.ty);
                            self.builder.store(binding.ref, result);
                        }
                    }
                }
            }
            // Fallback: global variable assignment — source-aware (issue
            // 0115): write the AUTHOR's global, never an unrelated module's
            // same-named one.
            if (!handled) {
                if (self.resolveGlobalRef(id.name, asgn.target.span)) |gi| {
                    if (asgn.op == .assign) {
                        const val_ty = self.builder.getRefType(val);
                        const store_val = if (val_ty != gi.ty and val_ty != .void and gi.ty != .void)
                            self.coerceToType(val, val_ty, gi.ty)
                        else
                            val;
                        self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = store_val } }, .void);
                    } else {
                        // Compound assignment: load current value, apply op, store back
                        const loaded = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                        const result = self.emitCompoundOp(loaded, val, asgn.op, gi.ty);
                        self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = result } }, .void);
                    }
                }
            }
        },
        .field_access => |fa| {
            // M2.2 — `obj.field = val` for an Obj-C `#property` field
            // dispatches via objc_msgSend `setField:`. Skip struct-
            // pointer / GEP entirely; receivers are opaque Obj-C ids.
            // Compound ops on properties are deferred (need load-via-
            // getter + op + store-via-setter — Month 4 ARC territory).
            if (asgn.op == .assign) {
                if (self.lookupObjcPropertyOnPointer(fa.object, fa.field)) |prop| {
                    self.lowerObjcPropertySetter(fa.object, prop, val);
                    return;
                }
            }
            // M1.2 A.3 — `self.field [op]= val` on a sx-defined Obj-C
            // class instance field (NOT a #property): write through
            // the __sx_state ivar. Handles plain assignment AND
            // compound ops (+=, -=, etc.) via storeOrCompound.
            if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
                const obj_ref = self.lowerExpr(fa.object);
                const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return;
                const ptr_void = self.module.types.ptrTo(.void);
                const field_addr = self.builder.emit(.{ .struct_gep = .{
                    .base = state_ptr,
                    .field_index = info.field_idx,
                    .base_type = info.state_ty,
                } }, ptr_void);
                self.storeOrCompound(field_addr, val, asgn.op, info.field_ty);
                return;
            }

            var obj_ptr = self.lowerExprAsPtr(fa.object);
            var obj_ty = self.inferExprType(fa.object);
            // Auto-deref: if the object is a pointer field from a non-identifier
            // (i.e., result of structGep on a pointer slot), load the pointer value.
            if (fa.object.data != .identifier and !obj_ty.isBuiltin()) {
                const pinfo = self.module.types.get(obj_ty);
                if (pinfo == .pointer) {
                    obj_ptr = self.builder.load(obj_ptr, obj_ty);
                    obj_ty = pinfo.pointer.pointee;
                }
            }

            // Reject a direct write to a tagged-union variant (issue 0136): it
            // sets the payload but not the tag. Construct via `x = .variant(...)`.
            if (self.diagTaggedUnionVariantWrite(obj_ty, fa.field, asgn.target.span)) return;

            // Special .len/.ptr handling only for slices, strings, arrays — NOT structs
            const is_special_container = obj_ty == .string or (if (!obj_ty.isBuiltin()) blk: {
                const obj_info = self.module.types.get(obj_ty);
                break :blk obj_info == .slice or obj_info == .array or obj_info == .vector;
            } else false);

            if (is_special_container and std.mem.eql(u8, fa.field, "len")) {
                const gep = self.builder.structGepTyped(obj_ptr, 1, .i64, obj_ty);
                self.storeOrCompound(gep, val, asgn.op, .i64);
            } else if (is_special_container and std.mem.eql(u8, fa.field, "ptr")) {
                const gep = self.builder.structGepTyped(obj_ptr, 0, .i64, obj_ty);
                self.storeOrCompound(gep, val, asgn.op, .i64);
            } else if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |fl| {
                // Resolve the target field (struct / union direct / promoted
                // anonymous-struct member / tuple element / vector lane) via
                // the shared lvalue resolver — the same one the address-of
                // and multi-target store paths use — so the three never
                // resolve a field to a different slot or default field 0
                // (two-resolver defect class). fl.ptr is
                // *field_ty (the store handler unwraps one pointer level);
                // fl.ty is the value type to coerce the rhs to.
                const src_ty = self.builder.getRefType(val);
                const coerced = self.coerceToType(val, src_ty, fl.ty);
                self.storeOrCompound(fl.ptr, coerced, asgn.op, fl.ty);
            } else {
                // No struct / union / tuple / vector field matches the
                // assignment target. Emit the same field-not-found
                // diagnostic the read path uses (emitFieldError) and bail;
                // building a pointer with field_ty = .unresolved would
                // otherwise store through a pointer-to-.unresolved that
                // panics at LLVM emission.
                _ = self.emitFieldError(obj_ty, fa.field, asgn.target.span);
            }
        },
        .index_expr => |ie| {
            const idx = self.lowerExpr(ie.index);
            const obj_ty = self.inferExprType(ie.object);
            const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty);
            const ptr_ty = self.module.types.ptrTo(elem_ty);
            // For fixed-size array assignment targets, use the alloca pointer directly
            // so that the store modifies the original variable (not a loaded copy).
            const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
            const obj_alloca = if (is_array) self.getExprAlloca(ie.object) else null;
            if (obj_alloca) |alloca_ref| {
                // Array alloca: single-index GEP with element stride
                const gep = self.builder.emit(.{ .index_gep = .{ .lhs = alloca_ref, .rhs = idx } }, ptr_ty);
                self.storeOrCompound(gep, val, asgn.op, elem_ty);
            } else if (is_array) {
                // Array in a struct field or other composite: get pointer to array in-place
                const obj_ptr = self.lowerExprAsPtr(ie.object);
                const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj_ptr, .rhs = idx } }, ptr_ty);
                self.storeOrCompound(gep, val, asgn.op, elem_ty);
            } else {
                // Pointer/slice: load the pointer value and GEP
                const obj = self.lowerExpr(ie.object);
                const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj, .rhs = idx } }, ptr_ty);
                self.storeOrCompound(gep, val, asgn.op, elem_ty);
            }
        },
        .deref_expr => |de| {
            const ptr = self.lowerExpr(de.operand);
            if (asgn.op == .assign) {
                const pointee_ty = blk: {
                    const ptr_ty = self.inferExprType(de.operand);
                    if (!ptr_ty.isBuiltin()) {
                        const info = self.module.types.get(ptr_ty);
                        if (info == .pointer) break :blk info.pointer.pointee;
                    }
                    break :blk ptr_ty;
                };
                const val_ty = self.builder.getRefType(val);
                const store_val = if (val_ty != pointee_ty and val_ty != .void and pointee_ty != .void)
                    self.coerceToType(val, val_ty, pointee_ty)
                else
                    val;
                self.builder.store(ptr, store_val);
            } else {
                const pointee_ty = self.inferExprType(de.operand);
                const elem_ty = blk: {
                    if (!pointee_ty.isBuiltin()) {
                        const info = self.module.types.get(pointee_ty);
                        if (info == .pointer) break :blk info.pointer.pointee;
                    }
                    break :blk pointee_ty;
                };
                self.storeOrCompound(ptr, val, asgn.op, elem_ty);
            }
        },
        else => {
            _ = self.emitError("assignment_target", asgn.target.span);
        },
    }
}

const FieldLvalue = struct { ptr: Ref, ty: TypeId };

/// Pure description of which slot `obj.field` resolves to — the GEP path plus
/// the field's value type — computed WITHOUT emitting any IR. The single
/// field-matching resolver for the LVALUE/WRITE paths: `fieldLvaluePtr` builds
/// GEPs from it, and the assignment target-type path reads `.valueType()` from
/// it, so the lvalue-pointer path and the RHS target-type path can never
/// disagree on which field (or what type) a name resolves to — the two-resolver
/// defect class this codebase keeps burning on. To handle a new aggregate
/// shape, add an arm here and a matching GEP arm in `fieldLvaluePtr`; both fail
/// to compile until the union is exhaustive, forcing the two to stay in lockstep.
///
/// NOTE: the READ path (`lowerFieldAccess`, expr.zig) and the TYPE-INFER path
/// (`ExprTyper.inferType`, expr_typer.zig) still carry their OWN parallel field
/// matchers (emitting `union_get`/`enum_payload`/`struct_get` value reads, and
/// returning a bare `TypeId`, respectively). They are not yet routed through
/// here, so a new aggregate shape must currently be taught to all three. Folding
/// read + infer onto this resolver (switching the descriptor to value-read ops /
/// `.valueType()`) would make it the genuine compiler-wide single matcher.
const FieldResolution = union(enum) {
    /// Direct union/tagged-union member: union_gep(index) into the aggregate.
    union_direct: struct { index: u32, ty: TypeId },
    /// Promoted member of an anonymous-struct union variant: union_gep into
    /// the variant struct `variant_ty`, then struct_gep into the member.
    union_promoted: struct { variant_index: u32, variant_ty: TypeId, member_index: u32, ty: TypeId },
    /// Tuple element / vector lane / plain struct field: a single
    /// struct_gep(index) into the aggregate.
    indexed: struct { index: u32, ty: TypeId },

    /// The field's value type — what the caller coerces the rhs to / sets as
    /// the RHS target type. Identical regardless of the GEP path taken.
    fn valueType(self: FieldResolution) TypeId {
        return switch (self) {
            .union_direct => |u| u.ty,
            .union_promoted => |u| u.ty,
            .indexed => |s| s.ty,
        };
    }
};

/// Match `obj.field` against the aggregate `obj_ty` and return the resolution
/// descriptor, or null when no field matches (the caller emits the
/// field-not-found diagnostic). Emits NO IR — see `FieldResolution`.
///
/// Handles union direct fields, promoted anonymous-struct union members,
/// tuple elements (numeric or named), vector lanes (`.x`/`.y`/`.z`/`.w` and
/// the colour aliases), and plain struct fields.
pub fn fieldLvalueResolve(self: *Lowering, obj_ty: TypeId, field: []const u8) ?FieldResolution {
    if (obj_ty.isBuiltin()) return null;
    const field_name_id = self.module.types.internString(field);
    const type_info = self.module.types.get(obj_ty);

    // Union / tagged-union: variants overlay at offset 0. A direct field is a
    // union_gep; a promoted anonymous-struct member is a union_gep into the
    // variant followed by a struct_gep into the member.
    const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) {
        .@"union" => |u| u.fields,
        .tagged_union => |u| u.fields,
        else => null,
    };
    if (union_fields) |fields| {
        for (fields, 0..) |f, i| {
            if (f.name == field_name_id) {
                return .{ .union_direct = .{ .index = @intCast(i), .ty = f.ty } };
            }
            if (!f.ty.isBuiltin()) {
                const fi = self.module.types.get(f.ty);
                if (fi == .@"struct") {
                    for (fi.@"struct".fields, 0..) |sf, si| {
                        if (sf.name == field_name_id) {
                            return .{ .union_promoted = .{ .variant_index = @intCast(i), .variant_ty = f.ty, .member_index = @intCast(si), .ty = sf.ty } };
                        }
                    }
                }
            }
        }
        return null;
    }

    // Tuple element: `.0` (numeric) or `.name`.
    if (type_info == .tuple) {
        const tup = type_info.tuple;
        var elem_idx: ?usize = null;
        if (std.fmt.parseInt(usize, field, 10)) |n| {
            if (n < tup.fields.len) elem_idx = n;
        } else |_| {
            if (tup.names) |names| {
                for (names, 0..) |nm, i| {
                    if (nm == field_name_id and i < tup.fields.len) {
                        elem_idx = i;
                        break;
                    }
                }
            }
        }
        if (elem_idx) |idx| {
            return .{ .indexed = .{ .index = @intCast(idx), .ty = tup.fields[idx] } };
        }
        return null;
    }

    // Vector lane: `.x`/`.y`/`.z`/`.w` (or colour aliases `.r`/`.g`/`.b`/`.a`)
    // → lane 0/1/2/3 via the same vectorLaneIndex the read path uses. A
    // non-lane field on a vector is a genuine miss (caller diagnoses).
    if (type_info == .vector) {
        const vidx = Lowering.vectorLaneIndex(field) orelse return null;
        return .{ .indexed = .{ .index = vidx, .ty = type_info.vector.element } };
    }

    // Plain struct field.
    const struct_fields = self.getStructFields(obj_ty);
    for (struct_fields, 0..) |f, i| {
        if (f.name == field_name_id) {
            return .{ .indexed = .{ .index = @intCast(i), .ty = f.ty } };
        }
    }
    return null;
}

/// Resolve `obj.field` — where `obj_ptr` already points at the aggregate —
/// to a typed pointer into the field's storage plus the field's value type.
/// Delegates the field MATCH to `fieldLvalueResolve` (shared with the RHS
/// target-type path) and only builds the GEP(s) here. Returns null when no
/// field matches; the caller emits the field-not-found diagnostic.
///
/// `ptr`'s IR type is `*field_ty` (a pointer to the field), NOT the field
/// value type: `emitStore` reads the store-target pointer's IR type and
/// unwraps one `.pointer` level to find the stored value's type. Labelling
/// the GEP with the bare field type instead would make a field whose own
/// type is a pointer-to-aggregate (`*Pair`) coerce the stored pointer into
/// the aggregate (closure auto-promotion in `coerceArg`), storing an
/// oversized struct that clobbers the neighbouring field. `.ty` carries the
/// field's value type for the caller's coercion.
///
/// Single source of lvalue field GEP-building shared by all three store/
/// address-of sites — lowerAssignment (single-target store), lowerExprAsPtr
/// (address-of), and lowerMultiAssign (multi-target store); the field MATCH
/// itself is delegated to `fieldLvalueResolve` (above), so they never resolve
/// a field to a different slot or default field 0.
pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
    const res = self.fieldLvalueResolve(obj_ty, field) orelse return null;
    switch (res) {
        .union_direct => |u| {
            const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.index, .base_type = obj_ty } }, self.module.types.ptrTo(u.ty));
            return .{ .ptr = ptr, .ty = u.ty };
        },
        .union_promoted => |u| {
            const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.variant_index, .base_type = obj_ty } }, self.module.types.ptrTo(u.variant_ty));
            const ptr = self.builder.structGepTyped(ug, u.member_index, self.module.types.ptrTo(u.ty), u.variant_ty);
            return .{ .ptr = ptr, .ty = u.ty };
        },
        .indexed => |s| {
            const ptr = self.builder.structGepTyped(obj_ptr, s.index, self.module.types.ptrTo(s.ty), obj_ty);
            return .{ .ptr = ptr, .ty = s.ty };
        },
    }
}

/// True (and emits the diagnostic) when `obj.field` names a DIRECT variant of a
/// tagged union — a store target that would set the payload but NOT the tag
/// (issue 0136): a tagged union is laid out `{ tag, payload }`, the write path
/// emits a `union_gep` into the payload only, so the discriminant goes stale and
/// a later `match`/`==` takes the wrong arm. The variant is set via construction
/// (`x = .variant(...)`, which writes both), so a direct member write is rejected.
///
/// Returns false (keeps working) for: plain `union` (no tag); promoted / nested
/// sub-field writes (`s.rect.w = ...`, where the immediate object is the payload
/// struct, resolving to `.indexed`/`.union_promoted`, not `.union_direct`); and
/// non-aggregates. Derefs one pointer level so a `*TaggedUnion` receiver is
/// caught too. Uses the shared `fieldLvalueResolve` matcher, so the guard can't
/// drift from the store path's notion of which member a name resolves to.
pub fn diagTaggedUnionVariantWrite(self: *Lowering, obj_ty: TypeId, field: []const u8, span: ast.Span) bool {
    var ty = obj_ty;
    if (!ty.isBuiltin()) {
        const info = self.module.types.get(ty);
        if (info == .pointer) ty = info.pointer.pointee;
    }
    if (ty.isBuiltin() or self.module.types.get(ty) != .tagged_union) return false;
    const res = self.fieldLvalueResolve(ty, field) orelse return false;
    if (res != .union_direct) return false;
    if (self.diagnostics) |d|
        d.addFmt(.err, span, "cannot assign to tagged-union variant '{s}' directly — a member write sets the payload but leaves the tag stale; construct the variant instead (e.g. `x = .{s}(...)`)", .{ field, field });
    return true;
}

/// Get the pointer (alloca ref) for an lvalue expression, without loading.
pub fn lowerExprAsPtr(self: *Lowering, node: *const Node) Ref {
    switch (node.data) {
        .identifier => |id| {
            const local = if (self.scope) |scope| scope.lookup(id.name) else null;
            if (local) |binding| {
                if (binding.is_alloca) {
                    // If the variable IS a pointer (e.g., p: *Vec2), load it
                    // to get the actual pointer value for GEP/store operations
                    if (!binding.ty.isBuiltin()) {
                        const info = self.module.types.get(binding.ty);
                        if (info == .pointer) {
                            return self.builder.load(binding.ref, binding.ty);
                        }
                    }
                    return binding.ref;
                }
            } else if (self.resolveGlobalRef(id.name, null)) |gi| {
                // Module-global lvalue: address into the global's live storage
                // so a downstream GEP/store targets the global itself, not a
                // loaded copy. A pointer-typed global is loaded first to get
                // the pointer value to GEP through (mirrors the local pointer
                // case above); any other global yields its storage address.
                if (!gi.ty.isBuiltin() and self.module.types.get(gi.ty) == .pointer) {
                    return self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                }
                return self.builder.emit(.{ .global_addr = gi.id }, self.module.types.ptrTo(gi.ty));
            }
        },
        .field_access => |fa| {
            var obj_ptr = self.lowerExprAsPtr(fa.object);
            var obj_ty = self.inferExprType(fa.object);
            // Auto-deref for chained pointer field access:
            // When fa.object is a field_access or index_expr, lowerExprAsPtr returns
            // a structGep/pointer to the slot. If the slot holds a pointer type,
            // we need to load the pointer value before GEPing into the pointee struct.
            // (Identifiers are already loaded by the identifier handler in lowerExprAsPtr.)
            if (fa.object.data != .identifier and !obj_ty.isBuiltin()) {
                const info = self.module.types.get(obj_ty);
                if (info == .pointer) {
                    obj_ptr = self.builder.load(obj_ptr, obj_ty);
                    obj_ty = info.pointer.pointee;
                }
            }
            // Resolve the field lvalue (struct / union direct / promoted
            // anonymous-struct member / tuple element) via the shared
            // resolver so address-of and the multi-target store path never
            // disagree on the slot. No match → emit the read path's
            // field-not-found diagnostic (lowerFieldAccessOnType →
            // emitFieldError) instead of silently GEPing field 0 as .i64;
            // that bogus pointer reaches LLVM emission as ptrTo(.unresolved)
            // and panics.
            if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |r| return r.ptr;
            return self.emitFieldError(obj_ty, fa.field, node.span);
        },
        .index_expr => |ie| {
            const idx = self.lowerExpr(ie.index);
            const obj_ty = self.inferExprType(ie.object);
            const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty);
            const ptr_ty = self.module.types.ptrTo(elem_ty);
            // For fixed-size arrays, use the alloca so GEP addresses the original memory
            const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
            const base = if (is_array)
                (self.getExprAlloca(ie.object) orelse self.lowerExprAsPtr(ie.object))
            else
                self.lowerExpr(ie.object);
            return self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = idx } }, ptr_ty);
        },
        .deref_expr => |de| {
            return self.lowerExpr(de.operand);
        },
        else => {},
    }
    // Fallback: lower as expression (may produce a value, not pointer)
    return self.lowerExpr(node);
}

/// Store a value to a GEP, handling both plain and compound assignment.
pub fn storeOrCompound(self: *Lowering, gep: Ref, val: Ref, op: ast.Assignment.Op, ty: TypeId) void {
    if (op == .assign) {
        const val_ty = self.builder.getRefType(val);
        const store_val = if (val_ty != ty and val_ty != .void and ty != .void)
            self.coerceToType(val, val_ty, ty)
        else
            val;
        self.builder.store(gep, store_val);
    } else {
        const loaded = self.builder.load(gep, ty);
        const result = self.emitCompoundOp(loaded, val, op, ty);
        self.builder.store(gep, result);
    }
}

pub fn emitCompoundOp(self: *Lowering, lhs: Ref, rhs: Ref, op: ast.Assignment.Op, ty: TypeId) Ref {
    return switch (op) {
        .add_assign => self.builder.add(lhs, rhs, ty),
        .sub_assign => self.builder.sub(lhs, rhs, ty),
        .mul_assign => self.builder.mul(lhs, rhs, ty),
        .div_assign => self.builder.div(lhs, rhs, ty),
        .mod_assign => self.builder.emit(.{ .mod = .{ .lhs = lhs, .rhs = rhs } }, ty),
        .and_assign => self.builder.emit(.{ .bit_and = .{ .lhs = lhs, .rhs = rhs } }, ty),
        .or_assign => self.builder.emit(.{ .bit_or = .{ .lhs = lhs, .rhs = rhs } }, ty),
        .xor_assign => self.builder.emit(.{ .bit_xor = .{ .lhs = lhs, .rhs = rhs } }, ty),
        .shl_assign => self.builder.emit(.{ .shl = .{ .lhs = lhs, .rhs = rhs } }, ty),
        .shr_assign => self.builder.emit(.{ .shr = .{ .lhs = lhs, .rhs = rhs } }, ty),
        else => self.emitError("compound_assign", null),
    };
}

// ── Defer / cleanup ─────────────────────────────────────────────

pub fn lowerDefer(self: *Lowering, ds: *const ast.DeferStmt) void {
    // Push deferred expression onto the stack — emitted at every block exit, LIFO.
    self.defer_stack.append(self.alloc, .{ .body = ds.expr, .is_onfail = false }) catch {};
}

/// `onfail [e] BODY` (ERR E1.7) — cleanup that runs only when an error
/// leaves the enclosing block. Recorded on the shared cleanup stack;
/// emitted (interleaved with defers, reverse) at error exits by
/// `emitErrorCleanup`, and discarded — never run — on a success exit.
pub fn lowerOnFail(self: *Lowering, ofs: *const ast.OnFailStmt, span: ast.Span) void {
    // `onfail` is only meaningful inside a failable function — a
    // non-failable function never error-exits, so it could never fire.
    const ret_ty = self.effectiveReturnType() orelse {
        self.diagOnFailNotFailable(span);
        return;
    };
    if (self.errorChannelOf(ret_ty) == null) {
        self.diagOnFailNotFailable(span);
        return;
    }
    self.defer_stack.append(self.alloc, .{ .body = ofs.body, .is_onfail = true, .binding = ofs.binding }) catch {};
}

pub fn diagOnFailNotFailable(self: *Lowering, span: ast.Span) void {
    if (self.diagnostics) |diags| {
        diags.addFmt(.err, span, "`onfail` is only valid inside a failable function (a return type with `!` or `!Named`) — use `defer` for unconditional cleanup", .{});
    }
}

/// Emit cleanups from saved_len..current in reverse (LIFO) order on a
/// SUCCESS exit: only `defer` entries run; `onfail` entries are skipped
/// (and discarded by the truncation). Truncates the stack to saved_len.
pub fn emitBlockDefers(self: *Lowering, saved_len: usize) void {
    // Guard: if stack was already drained (e.g., by a return that emitted all defers)
    if (saved_len > self.defer_stack.items.len) return;
    if (self.currentBlockHasTerminator()) {
        // Block already terminated (e.g., by return) — cleanups were already emitted
        self.defer_stack.shrinkRetainingCapacity(saved_len);
        return;
    }
    const stack = self.defer_stack.items;
    var i = stack.len;
    while (i > saved_len) {
        i -= 1;
        if (!stack[i].is_onfail) self.lowerCleanupBody(stack[i].body);
    }
    self.defer_stack.shrinkRetainingCapacity(saved_len);
}

/// Emit pending `defer` cleanups for a `break`/`continue` exit: everything
/// registered since the innermost loop's body began, in LIFO order. `onfail`
/// entries are skipped (a break is a success exit). The stack is NOT
/// truncated — the same entries still belong to the fall-through lowering
/// path after the branch that contains the break; the enclosing block scopes
/// truncate as usual.
pub fn emitLoopExitDefers(self: *Lowering) void {
    const stack = self.defer_stack.items;
    var i = stack.len;
    while (i > self.loop_defer_base) {
        i -= 1;
        if (!stack[i].is_onfail) self.lowerCleanupBody(stack[i].body);
    }
}

/// Run a `defer`/`onfail` cleanup body for its side effects (void context).
/// A braced body lowers as statements (NOT as a value) so a trailing-`;`
/// last expression is fine here — cleanup bodies never yield a value.
pub fn lowerCleanupBody(self: *Lowering, body: *const Node) void {
    if (body.data == .block) self.lowerBlock(body) else _ = self.lowerExpr(body);
}

/// Emit cleanups from `base`..current in reverse order on an ERROR exit
/// (raise / try-propagation): BOTH `defer` and `onfail` entries run,
/// interleaved in reverse declaration order. `err_tag` is the in-flight
/// error tag, bound to each `onfail e`'s binding. Does not truncate — the
/// terminating `ret` + the unwinding block-scope `emitBlockDefers` (which
/// then see the terminator and skip) leave the stack consistent.
pub fn emitErrorCleanup(self: *Lowering, base: usize, err_tag: Ref) void {
    if (base > self.defer_stack.items.len) return;
    const tag_ty = self.builder.getRefType(err_tag);
    const stack = self.defer_stack.items;
    var i = stack.len;
    while (i > base) {
        i -= 1;
        const entry = stack[i];
        if (entry.is_onfail) {
            if (entry.binding) |name| {
                var ofscope = Scope.init(self.alloc, self.scope);
                const saved = self.scope;
                self.scope = &ofscope;
                ofscope.put(name, .{ .ref = err_tag, .ty = tag_ty, .is_alloca = false });
                self.lowerCleanupBody(entry.body);
                self.scope = saved;
                ofscope.deinit();
            } else {
                self.lowerCleanupBody(entry.body);
            }
        } else {
            self.lowerCleanupBody(entry.body);
        }
    }
}

pub fn lowerPush(self: *Lowering, ps: *const ast.PushStmt) void {
    // push Context.{...} { body } — allocates a fresh Context on the
    // stack frame, rebinds the lowering's `current_ctx_ref` to it for
    // the body's lexical scope, then restores. No global, no walk.
    if (!self.implicit_ctx_enabled) {
        _ = self.diagnoseMissingContext("`push Context.{...}`");
        self.lowerBlock(ps.body);
        return;
    }
    const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
        _ = self.diagnoseMissingContext("`push Context.{...}`");
        self.lowerBlock(ps.body);
        return;
    };
    const saved_ctx_ref = self.current_ctx_ref;
    defer self.current_ctx_ref = saved_ctx_ref;

    const saved_target = self.target_type;
    self.target_type = ctx_ty;
    const ctx_val = self.lowerExpr(ps.context_expr);
    self.target_type = saved_target;

    const slot = self.builder.alloca(ctx_ty);
    self.builder.store(slot, ctx_val);
    self.current_ctx_ref = slot;

    self.lowerBlock(ps.body);
}

pub fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
    // Evaluate all RHS values first, then assign to LHS targets
    var vals = std.ArrayList(Ref).empty;
    defer vals.deinit(self.alloc);
    for (ma.values) |v| {
        vals.append(self.alloc, self.lowerExpr(v)) catch unreachable;
    }

    for (ma.targets, 0..) |target, i| {
        if (i >= vals.items.len) break;
        const val = vals.items[i];
        switch (target.data) {
            .identifier => |id| {
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        if (binding.is_alloca) {
                            const val_ty = self.builder.getRefType(val);
                            const store_val = if (val_ty != binding.ty and val_ty != .void and binding.ty != .void)
                                self.coerceToType(val, val_ty, binding.ty)
                            else
                                val;
                            self.builder.store(binding.ref, store_val);
                        }
                    }
                }
            },
            .index_expr => |ie| {
                const idx = self.lowerExpr(ie.index);
                const obj_ty = self.inferExprType(ie.object);
                const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty);
                const ptr_ty = self.module.types.ptrTo(elem_ty);
                const val_ty = self.builder.getRefType(val);
                const store_val = if (val_ty != elem_ty and val_ty != .void and elem_ty != .void)
                    self.coerceToType(val, val_ty, elem_ty)
                else
                    val;
                // For fixed-size arrays, use the alloca pointer directly
                const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
                const obj_alloca = if (is_array) self.getExprAlloca(ie.object) else null;
                if (obj_alloca) |alloca_ref| {
                    const gep = self.builder.emit(.{ .index_gep = .{ .lhs = alloca_ref, .rhs = idx } }, ptr_ty);
                    self.builder.store(gep, store_val);
                } else {
                    const obj = self.lowerExpr(ie.object);
                    const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj, .rhs = idx } }, ptr_ty);
                    self.builder.store(gep, store_val);
                }
            },
            .field_access => |fa| {
                const obj_ptr = self.lowerExprAsPtr(fa.object);
                const obj_ty = self.inferExprType(fa.object);
                // Reject a direct write to a tagged-union variant (issue 0136).
                if (self.diagTaggedUnionVariantWrite(obj_ty, fa.field, target.span)) continue;
                // Resolve the target field via the shared lvalue resolver —
                // the same one address-of uses — so a missing field emits a
                // diagnostic instead of defaulting to field 0 / field_ty
                // .unresolved, which silently corrupted a neighbouring field
                // (or panicked at LLVM emission).
                if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |r| {
                    const val_ty = self.builder.getRefType(val);
                    const store_val = if (val_ty != r.ty and val_ty != .void and r.ty != .void)
                        self.coerceToType(val, val_ty, r.ty)
                    else
                        val;
                    self.builder.store(r.ptr, store_val);
                } else {
                    _ = self.emitFieldError(obj_ty, fa.field, target.span);
                }
            },
            .deref_expr => |de| {
                const ptr = self.lowerExpr(de.operand);
                const pointee_ty = blk: {
                    const ptr_ty = self.inferExprType(de.operand);
                    if (!ptr_ty.isBuiltin()) {
                        const info = self.module.types.get(ptr_ty);
                        if (info == .pointer) break :blk info.pointer.pointee;
                    }
                    break :blk ptr_ty;
                };
                const val_ty = self.builder.getRefType(val);
                const store_val = if (val_ty != pointee_ty and val_ty != .void and pointee_ty != .void)
                    self.coerceToType(val, val_ty, pointee_ty)
                else
                    val;
                self.builder.store(ptr, store_val);
            },
            else => {
                _ = self.emitError("multi_assign_target", target.span);
            },
        }
    }
}

pub fn lowerDestructureDecl(self: *Lowering, dd: *const ast.DestructureDecl) void {
    // Lower the RHS expression (must produce a tuple)
    const saved_fbv = self.force_block_value;
    const saved_target = self.target_type;
    self.force_block_value = true;
    // Same as the unannotated var-decl path: the destructure declares new
    // bindings, so the ambient target type must not type the RHS literals.
    self.target_type = null;
    const ref = self.lowerExpr(dd.value);
    self.force_block_value = saved_fbv;
    self.target_type = saved_target;
    const ty = self.builder.getRefType(ref);

    // Get tuple field info
    if (ty.isBuiltin()) return;
    const ti = self.module.types.get(ty);
    if (ti != .tuple) return;
    const tuple = ti.tuple;
    if (dd.names.len > tuple.fields.len) return;

    // E1.8 (discard rejection): when the RHS is a value-carrying failable,
    // the error slot (always the LAST tuple field) cannot be dropped. It is
    // dropped when the destructure omits it (fewer names than fields, so the
    // trailing error slot is never reached) or binds it to `_`. The `try` /
    // `catch` / `or value` consumer forms all strip the error channel (their
    // result type is non-failable), so this fires only on a BARE failable
    // destructure — exactly the case that would let an error vanish silently.
    if (self.errorChannelOf(ty) != null) {
        const err_dropped = dd.names.len < tuple.fields.len or
            std.mem.eql(u8, dd.names[dd.names.len - 1], "_");
        if (err_dropped) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, dd.value.span, "the error slot of a failable cannot be dropped — bind it (`v, err := …`) and handle it, or use `try` / `catch`", .{});
            }
        }
    }

    // Extract each field and bind to a new variable
    for (dd.names, 0..) |name, i| {
        if (std.mem.eql(u8, name, "_")) continue; // discard
        const field_ty = tuple.fields[i];
        const field_val = self.builder.emit(.{ .tuple_get = .{
            .base = ref,
            .field_index = @intCast(i),
            .base_type = ty,
        } }, field_ty);
        const slot = self.builder.alloca(field_ty);
        self.builder.store(slot, field_val);
        if (self.scope) |scope| {
            scope.put(name, .{ .ref = slot, .ty = field_ty, .is_alloca = true });
        }
    }

    // Destructuring a failable's result binds the error slot to a variable:
    // the user now owns the error explicitly, so the trace is absorbed
    // (ERR E3.2). A plain (non-failable) tuple destructure clears nothing.
    if (self.errorChannelOf(ty) != null) self.emitTraceClear();
}
