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 ErrorAnalysis = @import("../error_analysis.zig").ErrorAnalysis;

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

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

/// Lazily declare the `sx_trace_push(u64)` / `sx_trace_clear()` runtime
/// externs (ERR E3.1). Storage is a `_Thread_local` ring buffer in
/// `library/vendors/sx_trace_runtime/sx_trace.c` — kept OUT of the user's IR
/// module (same JIT-TLS reason as the JNI env slot). Setting
/// `needs_trace_runtime` signals Compilation to auto-link the .c for AOT.
/// Wired into the `raise` / `try` push sites and the absorbing clear sites
/// at ERR E3.2.
pub fn getTraceFids(self: *Lowering) struct { push: FuncId, clear: FuncId } {
    self.needs_trace_runtime = true;
    if (self.trace_push_fid == null) {
        const name = self.module.types.internString("sx_trace_push");
        const frame_param = self.module.types.internString("frame");
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = frame_param, .ty = .u64 }) catch unreachable;
        const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void);
        self.module.getFunctionMut(fid).call_conv = .c;
        self.trace_push_fid = fid;
    }
    if (self.trace_clear_fid == null) {
        const name = self.module.types.internString("sx_trace_clear");
        const fid = self.builder.declareExtern(name, &.{}, .void);
        self.module.getFunctionMut(fid).call_conv = .c;
        self.trace_clear_fid = fid;
    }
    return .{ .push = self.trace_push_fid.?, .clear = self.trace_clear_fid.? };
}

/// Error return-traces are emitted in debug-ish builds and skipped in
/// release (ERR E3.2 build-mode gating). `sx run` defaults to `-O0`
/// (`.none`), the common dev path; `.default`/`.aggressive` are release.
/// The spec's `--release-traces` opt-in + a `BuildOptions.error_traces`
/// accessor are a later refinement; for now the opt level is the gate.
pub fn tracesEnabled(self: *Lowering) bool {
    const tc = self.target_config orelse return true; // no target → treat as debug
    return tc.opt_level == .none or tc.opt_level == .less;
}

/// Emit a trace-buffer push of `frame` (an opaque u64) at a failure site.
/// No-op when traces are disabled (release). `frame` is a placeholder until
/// DWARF (E3.0) supplies real return-address PCs and E3.3 resolves them.
pub fn emitTracePush(self: *Lowering, frame: Ref) void {
    if (!self.tracesEnabled()) return;
    const fids = self.getTraceFids();
    const coerced = self.coerceToType(frame, self.builder.getRefType(frame), .u64);
    const args = self.alloc.dupe(Ref, &.{coerced}) catch return;
    _ = self.builder.emit(.{ .call = .{ .callee = fids.push, .args = args } }, .void);
}

/// Emit a trace-buffer clear at an absorbing site (`catch` / `or value` /
/// destructure). No-op when traces are disabled.
pub fn emitTraceClear(self: *Lowering) void {
    if (!self.tracesEnabled()) return;
    const fids = self.getTraceFids();
    _ = self.builder.emit(.{ .call = .{ .callee = fids.clear, .args = &.{} } }, .void);
}

/// The trace frame value for a failure site (ERR E3.0 slice 3a). Emits the
/// niladic `.trace_frame` op (span-stamped via `Builder.current_span`); each
/// backend resolves it to a real frame — `emit_llvm` to a `Frame*`, `interp`
/// to a packed `(func_id, offset)`. The result feeds `sx_trace_push`.
pub fn placeholderTraceFrame(self: *Lowering) Ref {
    return self.builder.emit(.{ .trace_frame = {} }, .u64);
}

/// The named error-set TypeId of `node`'s type, or null if not an
/// error-set-typed expression.
pub fn errorSetTypeOf(self: *Lowering, node: *const Node) ?TypeId {
    const t = self.inferExprType(node);
    if (t.isBuiltin()) return null;
    return if (self.module.types.get(t) == .error_set) t else null;
}

/// True when `node` is an `error.X` tag literal (`field_access` whose
/// object is the `error` keyword, parsed as identifier "error").
pub fn isErrorTagLiteralNode(node: *const Node) bool {
    if (node.data != .field_access) return false;
    const obj = node.data.field_access.object;
    return obj.data == .identifier and std.mem.eql(u8, obj.data.identifier.name, "error");
}

/// Lower `==` / `!=` when an error-set value or `error.X` tag is involved.
/// Returns null when neither operand is error-related (general path runs).
/// Both operands must be a tag (an `error.X` literal or an error-set value);
/// otherwise it's a type error (e.g. comparing a tag to a raw integer).
pub fn tryLowerErrorSetEquality(self: *Lowering, bop: *const ast.BinaryOp) ?Ref {
    const l_set = self.errorSetTypeOf(bop.lhs);
    const r_set = self.errorSetTypeOf(bop.rhs);
    const l_tag = isErrorTagLiteralNode(bop.lhs);
    const r_tag = isErrorTagLiteralNode(bop.rhs);
    if (l_set == null and r_set == null and !l_tag and !r_tag) return null;

    const l_ok = l_set != null or l_tag;
    const r_ok = r_set != null or r_tag;
    if (!l_ok or !r_ok) {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, bop.lhs.span, "an error-set value compares only with an `error.X` tag or another error-set value; coerce with `xx` to compare the raw id", .{});
        }
        return self.builder.constBool(false);
    }

    // Lower both sides with the set type as context so an `error.X` literal
    // resolves to it (and validates membership). Two bare tag literals with
    // no set context lower to global u32 ids (cross-set comparison is OK).
    const set_ty = l_set orelse r_set;
    const saved = self.target_type;
    if (set_ty) |st| self.target_type = st;
    const lv = self.lowerExpr(bop.lhs);
    const rv = self.lowerExpr(bop.rhs);
    self.target_type = saved;
    return if (bop.op == .eq)
        self.builder.cmpEq(lv, rv)
    else
        self.builder.emit(.{ .cmp_ne = .{ .lhs = lv, .rhs = rv } }, .bool);
}

/// The declared return type of the function currently being lowered (the
/// inlined body's type wins while inlining a comptime call), or null when
/// there is no enclosing function.
pub fn effectiveReturnType(self: *Lowering) ?TypeId {
    if (self.inline_return_target) |iri| return iri.ret_ty;
    if (self.builder.func) |fid| return self.module.functions.items[@intFromEnum(fid)].ret;
    return null;
}

/// If `ret_ty` belongs to a failable function, the TypeId of its error
/// channel; else null. `-> !Named` / `-> !` resolve the error set directly;
/// `-> (T..., !)` carries it as the last tuple field (the locked ABI).
pub fn errorChannelOf(self: *Lowering, ret_ty: TypeId) ?TypeId {
    if (ret_ty.isBuiltin()) return null;
    switch (self.module.types.get(ret_ty)) {
        .error_set => return ret_ty,
        .tuple => |t| {
            if (t.fields.len == 0) return null;
            const last = t.fields[t.fields.len - 1];
            if (last.isBuiltin()) return null;
            return if (self.module.types.get(last) == .error_set) last else null;
        },
        else => return null,
    }
}

/// True for the bare-`!` inferred placeholder error set (reserved name "!").
pub fn isInferredErrorSet(self: *Lowering, set: TypeId) bool {
    if (set.isBuiltin()) return false;
    const info = self.module.types.get(set);
    if (info != .error_set) return false;
    return std.mem.eql(u8, self.module.types.getString(info.error_set.name), "!");
}

/// Diagnose every tag of `src` that is not also a member of `dst` (the
/// enclosing function's named error set). Both must be `.error_set` types.
pub fn checkErrorSetSubset(self: *Lowering, src: TypeId, dst: TypeId, span: ast.Span) void {
    if (src.isBuiltin()) return;
    const src_info = self.module.types.get(src);
    if (src_info != .error_set) return;
    self.diagTagsNotInSet(src_info.error_set.tags, dst, span);
}

/// Diagnose every tag id in `src_tags` that is not a member of the named
/// error set `dst`. Shared by the named-set subset check and E1.4b's
/// inferred-callee widening (where the callee's tags come from the SCC,
/// not a `.error_set` TypeId).
pub fn diagTagsNotInSet(self: *Lowering, src_tags: []const u32, dst: TypeId, span: ast.Span) void {
    if (dst.isBuiltin()) return;
    const dst_info = self.module.types.get(dst);
    if (dst_info != .error_set) return;
    for (src_tags) |tag| {
        var found = false;
        for (dst_info.error_set.tags) |d| {
            if (d == tag) {
                found = true;
                break;
            }
        }
        if (!found) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, span, "error tag 'error.{s}' is not in caller's error set '{s}'", .{ self.module.types.getTagName(tag), self.module.types.getString(dst_info.error_set.name) });
            }
        }
    }
}

/// `raise EXPR;` — terminate the enclosing failable function via the error
/// channel. E1.3 lowers the **pure-failable** shape (`-> !` / `-> !Named`,
/// whose return type IS the error set): emit `ret(EXPR)`. The value-carrying
/// shape (`-> (T..., !)`) needs the value slots set to `undef` alongside the
/// error slot — that tuple ABI lands in E2.1/E2.2, so we bail loudly here
/// rather than ship a half-built return that silently corrupts value slots.
pub fn lowerRaise(self: *Lowering, rs: *const ast.RaiseStmt, span: ast.Span) void {
    // (1) `raise` is legal only inside a failable function.
    const ret_ty = self.effectiveReturnType() orelse {
        self.diagRaiseNotFailable(span);
        return;
    };
    const err_set = self.errorChannelOf(ret_ty) orelse {
        self.diagRaiseNotFailable(span);
        return;
    };
    const inferred = self.isInferredErrorSet(err_set);

    // (2) Set check. Lowering EXPR with the function's error set as the
    //     target type makes a literal `raise error.X` validate `X ∈ set`
    //     inside lowerErrorTagLiteral (the inferred placeholder accepts any
    //     tag). The variable form `raise e` is subset-checked below.
    const saved_target = self.target_type;
    self.target_type = err_set;
    const tag_ref = self.lowerExpr(rs.tag);
    self.target_type = saved_target;

    if (!inferred and !isErrorTagLiteralNode(rs.tag)) {
        if (self.errorSetTypeOf(rs.tag)) |src_set| {
            self.checkErrorSetSubset(src_set, err_set, span);
        }
    }

    // (3) Push a trace frame: `raise` always escapes the function (ERR E3.2).
    //     Before cleanup, so the frame records the raise site itself.
    self.emitTracePush(self.placeholderTraceFrame());

    // (4) Emit the failure return. Pure-failable: the return type IS the
    //     error set, so return the tag value directly.
    if (ret_ty == err_set) {
        const tag_ty = self.builder.getRefType(tag_ref);
        const coerced = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
        self.emitErrorCleanup(self.func_defer_base, coerced);
        if (self.inline_return_target) |iri| {
            self.builder.store(iri.slot, coerced);
            self.builder.br(iri.done_bb, &.{});
        } else {
            self.builder.ret(coerced, err_set);
        }
    } else {
        // Value-carrying `-> (T..., !)`: the error path leaves the value
        // slots undefined and carries the tag in the error slot (ERR E2.1).
        const tag_ty = self.builder.getRefType(tag_ref);
        const coerced_tag = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
        self.emitErrorCleanup(self.func_defer_base, coerced_tag);
        const fields = self.module.types.get(ret_ty).tuple.fields;
        var slots = std.ArrayList(Ref).empty;
        defer slots.deinit(self.alloc);
        for (fields[0 .. fields.len - 1]) |vty| {
            slots.append(self.alloc, self.builder.constUndef(vty)) catch unreachable;
        }
        const tup = self.buildFailableTuple(ret_ty, slots.items, coerced_tag);
        self.emitTupleRet(ret_ty, tup);
    }
}

/// Return a value-carrying failable function's success tuple
/// `{value(s)..., 0}` from `ref` (the user-returned value part). Forwarding
/// a full failable tuple (`return other_failable()` / explicit `return
/// (v, e)`) returns it as-is. Single-value `-> (T, !)` takes `ref` as the
/// lone value; multi-value `-> (T1, ..., !)` takes `ref` as a value-tuple
/// `(T1, ...)` and re-assembles its slots alongside the success error slot.
pub fn lowerFailableSuccessReturn(self: *Lowering, ref: Ref, ret_ty: TypeId, span: ast.Span) void {
    const fields = self.module.types.get(ret_ty).tuple.fields;
    const err_ty = fields[fields.len - 1];
    const val_ty = self.builder.getRefType(ref);
    if (val_ty == ret_ty) {
        // The expression already IS the full failable tuple (forwarding).
        self.emitTupleRet(ret_ty, ref);
        return;
    }
    const n_vals = fields.len - 1;
    if (n_vals == 1) {
        const cv = self.coerceToType(ref, val_ty, fields[0]);
        const tup = self.buildFailableTuple(ret_ty, &.{cv}, self.builder.constInt(0, err_ty));
        self.emitTupleRet(ret_ty, tup);
        return;
    }
    // Multi-value: `ref` must be a value-tuple `(T1, ..., Tn)`. Extract
    // each value slot, coerce to the declared field type, and re-assemble
    // with the success error slot (0).
    if (val_ty.isBuiltin() or self.module.types.get(val_ty) != .tuple or self.module.types.get(val_ty).tuple.fields.len != n_vals) {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, span, "a multi-value failable function (`-> (T1, ..., !)`) must `return` a {d}-tuple of its value types", .{n_vals});
        }
        return;
    }
    const vfields = self.module.types.get(val_ty).tuple.fields;
    var vals = std.ArrayList(Ref).empty;
    defer vals.deinit(self.alloc);
    for (0..n_vals) |i| {
        const fv = self.builder.emit(.{ .tuple_get = .{ .base = ref, .field_index = @intCast(i), .base_type = val_ty } }, vfields[i]);
        vals.append(self.alloc, self.coerceToType(fv, vfields[i], fields[i])) catch unreachable;
    }
    const tup = self.buildFailableTuple(ret_ty, vals.items, self.builder.constInt(0, err_ty));
    self.emitTupleRet(ret_ty, tup);
}

/// Build a failable return tuple `{value_refs..., tag}` typed `ret_ty`.
pub fn buildFailableTuple(self: *Lowering, ret_ty: TypeId, value_refs: []const Ref, tag: Ref) Ref {
    var fields = std.ArrayList(Ref).empty;
    defer fields.deinit(self.alloc);
    fields.appendSlice(self.alloc, value_refs) catch unreachable;
    fields.append(self.alloc, tag) catch unreachable;
    return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, fields.items) catch unreachable } }, ret_ty);
}

/// The success (value-part) type of a value-carrying failable tuple
/// `op_ty` (`-> (T..., !)`): the lone value type for a single-value
/// failable, or a synthesized value-tuple `(T1, ..., Tn)` (error slot
/// dropped) for a multi-value one. Callers must pass a value-carrying
/// tuple — a pure `-> !`'s success type is `void`, handled separately.
pub fn failableSuccessType(self: *Lowering, op_ty: TypeId) TypeId {
    const fields = self.module.types.get(op_ty).tuple.fields;
    const n_vals = fields.len - 1;
    if (n_vals == 1) return fields[0];
    return self.module.types.intern(.{ .tuple = .{
        .fields = self.alloc.dupe(TypeId, fields[0..n_vals]) catch unreachable,
        .names = null,
    } });
}

/// The `target_type` to lower a returned expression against. For a
/// value-carrying failable (`-> (T..., !)`) a BARE returned value resolves
/// against the success value type (so a bare enum literal gets its real
/// ordinal); an EXPLICIT full failable tuple literal (`return (v..., e)`,
/// arity == full-tuple field count) keeps the failable-tuple target so its
/// trailing error element resolves against the error set and is forwarded
/// as-is. Every other return type passes through unchanged.
pub fn failableReturnTarget(self: *Lowering, ret_ty: TypeId, value_node: ?*const Node) TypeId {
    if (ret_ty.isBuiltin()) return ret_ty;
    if (self.module.types.get(ret_ty) != .tuple) return ret_ty;
    if (self.errorChannelOf(ret_ty) == null) return ret_ty;
    if (value_node) |vn| {
        if (vn.data == .tuple_literal and
            vn.data.tuple_literal.elements.len == self.module.types.get(ret_ty).tuple.fields.len)
            return ret_ty;
    }
    return self.failableSuccessType(ret_ty);
}

/// Extract the success value from an evaluated value-carrying failable
/// tuple `result` (type `op_ty`): the lone value slot for single-value,
/// or an assembled value-tuple (typed `succ_ty`) for multi-value.
pub fn extractSuccessValue(self: *Lowering, result: Ref, op_ty: TypeId, succ_ty: TypeId) Ref {
    const fields = self.module.types.get(op_ty).tuple.fields;
    const n_vals = fields.len - 1;
    if (n_vals == 1) {
        return self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = 0, .base_type = op_ty } }, fields[0]);
    }
    var vals = std.ArrayList(Ref).empty;
    defer vals.deinit(self.alloc);
    for (0..n_vals) |i| {
        vals.append(self.alloc, self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = @intCast(i), .base_type = op_ty } }, fields[i])) catch unreachable;
    }
    return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, vals.items) catch unreachable } }, succ_ty);
}

/// Extract the error slot (always the last field) of an evaluated
/// value-carrying failable tuple `result`, typed as `err_set`.
pub fn extractErrorSlot(self: *Lowering, result: Ref, op_ty: TypeId, err_set: TypeId) Ref {
    const fields = self.module.types.get(op_ty).tuple.fields;
    return self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = @intCast(fields.len - 1), .base_type = op_ty } }, err_set);
}

/// Emit a return of an already-assembled tuple, honoring inline-comptime
/// return targets (store + branch) vs a real function return.
pub fn emitTupleRet(self: *Lowering, ret_ty: TypeId, tup: Ref) void {
    if (self.inline_return_target) |iri| {
        self.builder.store(iri.slot, tup);
        self.builder.br(iri.done_bb, &.{});
    } else {
        self.builder.ret(tup, ret_ty);
    }
}

pub fn diagRaiseNotFailable(self: *Lowering, span: ast.Span) void {
    if (self.diagnostics) |diags| {
        if (self.in_lambda_body) {
            diags.addFmt(.err, span, "lambda body raises; declare its return type explicitly with `-> (T, !)` or `-> (T, !Named)`", .{});
        } else {
            diags.addFmt(.err, span, "`raise` is only valid inside a failable function (a return type with `!` or `!Named`)", .{});
        }
    }
}

/// True if `node`'s value is failable — a `try` (the result is its
/// operand's success value, but the expression itself routes an error) or
/// any expression whose type carries an error channel (a bare failable
/// call). Used to detect failable `or` chains (deferred to E1.4b).
pub fn exprIsFailable(self: *Lowering, node: *const Node) bool {
    if (node.data == .try_expr) return true;
    return self.errorChannelOf(self.inferExprType(node)) != null;
}

/// `try X` — a fallible attempt (ERR step E1.4a: the STANDALONE form, whose
/// failure target is function-propagation). Evaluates X; on failure, runs
/// the function's defers and returns the error to the caller; on success,
/// continues with X's value. E1.4a lowers the pure-failable shape (callee
/// `-> !` / `-> !Named`, caller likewise pure-failable). Value-carrying
/// callees, propagation from a value-carrying caller, and `try` inside an
/// `or` chain need the error-channel tuple ABI / fallback routing — those
/// land in E1.4b/E2, so we bail loudly here.
/// Synthesize a `Source_Location` value for a `#caller_location` marker
/// (ERR E4.1b). The node's `span`/`source_file` are the CALL site (rewritten
/// by `expandCallDefaults`); resolve them to file / line:col against the
/// source text and stamp the enclosing (caller) function name.
pub fn lowerCallerLocation(self: *Lowering, node: *const Node) Ref {
    const sl_tid = self.module.types.findByName(self.module.types.internString("Source_Location")) orelse {
        if (self.diagnostics) |d| d.addFmt(.err, node.span, "`#caller_location` needs `Source_Location` (from std.sx) in scope", .{});
        return self.builder.constInt(0, .void);
    };
    const file = node.source_file orelse self.current_source_file orelse (self.main_file orelse "");
    const src = self.sourceForFile(file);
    const loc = errors.SourceLoc.compute(src, node.span.start);
    const func_name = self.currentFunctionName();
    var fields = [_]Ref{
        self.builder.constString(self.module.types.internString(file)),
        self.builder.constInt(@intCast(loc.line), .i32),
        self.builder.constInt(@intCast(loc.col), .i32),
        self.builder.constString(self.module.types.internString(func_name)),
    };
    return self.builder.emit(.{ .struct_init = .{ .fields = self.alloc.dupe(Ref, &fields) catch unreachable } }, sl_tid);
}

/// The source text for `file`, via the diagnostics' file→source map (which
/// includes the main file). Empty if unavailable — line:col then degrade to
/// 1:1 rather than crash.
pub fn sourceForFile(self: *Lowering, file: []const u8) []const u8 {
    const diags = self.diagnostics orelse return "";
    if (diags.import_sources) |is| {
        if (is.get(file)) |s| return s;
    }
    return diags.source;
}

/// Name of the function currently being lowered (the caller, at a
/// `#caller_location` site), or "" outside any function.
pub fn currentFunctionName(self: *Lowering) []const u8 {
    const fid = self.builder.func orelse return "";
    return self.module.types.getString(self.module.functions.items[@intFromEnum(fid)].name);
}

pub fn lowerTry(self: *Lowering, operand: *const Node, span: ast.Span) Ref {
    // (1) `try` is legal only inside a failable function.
    const caller_ret = self.effectiveReturnType() orelse {
        self.diagTryNotFailable(span);
        return self.builder.constInt(0, .void);
    };
    const caller_set = self.errorChannelOf(caller_ret) orelse {
        self.diagTryNotFailable(span);
        return self.builder.constInt(0, .void);
    };

    // (2) The operand must be failable. This is the sole failable-operand
    //     check (the parser imposes none — see E0.2).
    const op_ty = self.inferExprType(operand);
    const callee_set = self.errorChannelOf(op_ty) orelse {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, span, "`try` requires a failable expression; operand has type '{s}'", .{self.formatTypeName(op_ty)});
        }
        return self.builder.constInt(0, .void);
    };

    // A value-carrying callee (`-> (T..., !)`) returns a tuple
    // `{v..., err}`; a pure-failable callee (`-> !`) returns the bare
    // error tag.
    const callee_value_carrying = op_ty != callee_set;

    // (3) Widening: the callee's escape set must be ⊆ the caller's named
    //     set. For an inferred caller (`!`) the absorption happens in the
    //     whole-program SCC (E1.4b) — no check here.
    self.checkEscapeWidening(operand, callee_set, caller_set, span);

    // (4) Lower: evaluate the operand, then branch on its error tag (which
    //     is the bare result for a pure callee, or the last tuple slot for
    //     a value-carrying one).
    const result = self.lowerExpr(operand);
    const err_val = if (callee_value_carrying)
        self.extractErrorSlot(result, op_ty, callee_set)
    else
        result;
    const err_ty = self.builder.getRefType(err_val);
    const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);

    const prop_bb = self.freshBlock("try.prop");
    const ok_bb = self.freshBlock("try.ok");
    self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{});

    // Propagation: push a trace frame (this `try` failure escapes to the
    // caller — ERR E3.2), run the function's cleanups (defers + onfails,
    // since this is an error exit), then return the caller's failure
    // carrying this tag (pure caller → `ret(tag)`; value-carrying →
    // `ret {undef…, tag}`).
    self.builder.switchToBlock(prop_bb);
    self.emitTracePush(self.placeholderTraceFrame());
    self.emitErrorCleanup(self.func_defer_base, err_val);
    self.emitErrorReturn(caller_ret, caller_set, err_val);

    // Success: a value-carrying callee yields its value part (the lone
    // value, or a value-tuple); a pure-failable callee has no value (void).
    self.builder.switchToBlock(ok_bb);
    if (callee_value_carrying) {
        const succ_ty = self.failableSuccessType(op_ty);
        return self.extractSuccessValue(result, op_ty, succ_ty);
    }
    return self.builder.constInt(0, .void);
}

/// Return the enclosing function's failure carrying error tag `err`. A
/// pure-failable caller (`-> !`) returns the tag directly; a value-carrying
/// caller (`-> (T..., !)`) returns `{undef value slots..., tag}`. Honors
/// inline-comptime return targets. The caller emits defers first.
pub fn emitErrorReturn(self: *Lowering, caller_ret: TypeId, caller_set: TypeId, err: Ref) void {
    const ety = self.builder.getRefType(err);
    const coerced = if (ety != caller_set) self.coerceToType(err, ety, caller_set) else err;
    if (caller_ret == caller_set) {
        if (self.inline_return_target) |iri| {
            self.builder.store(iri.slot, coerced);
            self.builder.br(iri.done_bb, &.{});
        } else {
            self.builder.ret(coerced, caller_set);
        }
    } else {
        const fields = self.module.types.get(caller_ret).tuple.fields;
        var undefs = std.ArrayList(Ref).empty;
        defer undefs.deinit(self.alloc);
        for (fields[0 .. fields.len - 1]) |vty| {
            undefs.append(self.alloc, self.builder.constUndef(vty)) catch unreachable;
        }
        const tup = self.buildFailableTuple(caller_ret, undefs.items, coerced);
        self.emitTupleRet(caller_ret, tup);
    }
}

pub fn diagTryNotFailable(self: *Lowering, span: ast.Span) void {
    if (self.diagnostics) |diags| {
        diags.addFmt(.err, span, "`try` is only valid inside a failable function (a return type with `!` or `!Named`)", .{});
    }
}

/// `expr catch [e] BODY` — inline failure handler (ERR step E1.5,
/// pure-failable slice). Evaluates `expr`; on failure, binds the tag to
/// `e` (if present) and runs BODY; on success, the value is `void` (a
/// pure-failable LHS has no success value). BODY either diverges (via
/// `noreturn` — E1.4c) or falls through. `catch` consumes the error
/// locally, so — unlike `try` / `raise` — it needs no failable *enclosing*
/// function. Value-carrying LHS (binding the success value / a
/// value-producing body unifying with the success tuple) needs the
/// error-channel tuple ABI and lands in E2 — bail loudly here.
pub fn lowerCatch(self: *Lowering, ce: *const ast.CatchExpr, span: ast.Span) Ref {
    // A failable `or` chain operand (`(try a or try b) catch e …`) routes
    // its total failure to the catch handler — not the function — via the
    // chain-fail target (ERR E2.4). A chain's value type is non-failable
    // `T`, so it wouldn't pass the `errorChannelOf` check below.
    if (ce.operand.data == .binary_op and ce.operand.data.binary_op.op == .or_op and
        self.orIsFailableChain(&ce.operand.data.binary_op))
    {
        return self.lowerCatchOverChain(ce, span);
    }

    const op_ty = self.inferExprType(ce.operand);
    const err_set = self.errorChannelOf(op_ty) orelse {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, span, "`catch` requires a failable expression; operand has type '{s}'", .{self.formatTypeName(op_ty)});
        }
        return self.builder.constInt(0, .void);
    };
    // Pure-failable LHS (`-> !`): no success value. Run the body on the
    // error path; both paths fall through to a value-less merge.
    if (op_ty == err_set) {
        const err_val = self.lowerExpr(ce.operand);
        const err_ty = self.builder.getRefType(err_val);
        const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);
        const handle_bb = self.freshBlock("catch.handle");
        const merge_bb = self.freshBlock("catch.merge");
        self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{});
        self.builder.switchToBlock(handle_bb);
        _ = self.runCatchBody(ce, err_val, err_set, null);
        // The handler can inspect the trace (`trace.print_current()`); the
        // absorption clear fires once it completes WITHOUT re-raising (a
        // fall-through). A diverging body (`raise` / `return`) keeps /
        // discards the buffer on its own path (ERR E3.2; reconciles
        // PLAN-ERR §clear-points "cleared before body" with §catch-over-or
        // "frames still in the buffer when the body runs").
        if (!self.currentBlockHasTerminator()) {
            self.emitTraceClear();
            self.builder.br(merge_bb, &.{});
        }
        self.builder.switchToBlock(merge_bb);
        return self.builder.constInt(0, .void);
    }

    // Value-carrying LHS (`-> (T..., !)`): on success the catch yields the
    // value part (the lone value, or a value-tuple); on error it yields
    // the handler body's value. The paths merge through a block-parameter
    // (phi).
    const succ_ty = self.failableSuccessType(op_ty);
    const result = self.lowerExpr(ce.operand);
    const err_val = self.extractErrorSlot(result, op_ty, err_set);
    const succ_val = self.extractSuccessValue(result, op_ty, succ_ty);
    const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_set) } }, .bool);

    const handle_bb = self.freshBlock("catch.handle");
    const merge_bb = self.freshBlockWithParams("catch.merge", &.{succ_ty});
    // Success → merge with the value slot; error → run the handler.
    self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{succ_val});

    self.builder.switchToBlock(handle_bb);
    const body_val = self.runCatchBody(ce, err_val, err_set, succ_ty);
    if (!self.currentBlockHasTerminator()) {
        self.finishCatchHandler(body_val, succ_ty, merge_bb, span);
    }

    self.builder.switchToBlock(merge_bb);
    return self.builder.blockParam(merge_bb, 0, succ_ty);
}

/// `(failable or-chain) catch [e] BODY` (ERR E2.4). The chain's operands
/// route per the chain rules; its TOTAL failure (the final operand failing)
/// is redirected to the catch handler via `chain_fail_target` rather than
/// propagating to the function. `e` binds the final error tag; the handler's
/// value (or divergence) joins the chain's success value at the merge.
pub fn lowerCatchOverChain(self: *Lowering, ce: *const ast.CatchExpr, span: ast.Span) Ref {
    const chain = &ce.operand.data.binary_op;

    // The error tag reaching the handler is the final operand's (left-assoc
    // chain → the top-level rhs). A value-terminator last operand means the
    // chain can't fail — nothing for `catch` to absorb.
    const last = unwrapTryNode(chain.rhs);
    const last_ty = self.inferExprType(last);
    const err_set = self.errorChannelOf(last_ty) orelse {
        if (self.diagnostics) |d| d.addFmt(.err, span, "`catch` here is redundant — the `or` chain already absorbs every failure via its value terminator", .{});
        return self.builder.constInt(0, .void);
    };

    const succ_ty = self.orChainSuccessType(chain);
    const has_value = succ_ty != .void;

    const handle_bb = self.freshBlockWithParams("catch.handle", &.{err_set});
    const merge_bb = if (has_value)
        self.freshBlockWithParams("catch.merge", &.{succ_ty})
    else
        self.freshBlock("catch.merge");

    // Lower the chain with its total failure routed to the handler.
    const saved = self.chain_fail_target;
    self.chain_fail_target = .{ .bb = handle_bb, .set = err_set };
    const chain_val = self.lowerExpr(ce.operand);
    self.chain_fail_target = saved;
    // Chain success → merge with its value (the buffer was already cleared
    // at the succeeding operand inside the chain).
    if (has_value) {
        const cv = self.coerceToType(chain_val, self.builder.getRefType(chain_val), succ_ty);
        self.builder.br(merge_bb, &.{cv});
    } else {
        self.builder.br(merge_bb, &.{});
    }

    // Handler: bind the final tag, run the body. The buffer still holds the
    // chain's frames (handler may inspect them); absorb on non-diverging exit.
    self.builder.switchToBlock(handle_bb);
    const tag = self.builder.blockParam(handle_bb, 0, err_set);
    const body_val = self.runCatchBody(ce, tag, err_set, if (has_value) succ_ty else null);
    if (!self.currentBlockHasTerminator()) {
        self.finishCatchHandler(body_val, succ_ty, merge_bb, span);
    }

    self.builder.switchToBlock(merge_bb);
    return if (has_value) self.builder.blockParam(merge_bb, 0, succ_ty) else self.builder.constInt(0, .void);
}

/// Close a non-terminated `catch` handler block. `succ_ty` is the catch's
/// result type (`.void` for a pure-failable / void-chain catch — the merge
/// block then has no parameter). A `body_val` typed `noreturn` (e.g. a
/// `process.exit` / other noreturn call, which is NOT an IR terminator)
/// diverges: close with `unreachable` and skip the merge edge so its
/// "value" never reaches a phi. Otherwise clear the absorbed trace and
/// branch to the merge (coercing the body value, or diagnosing a missing /
/// void value for a value-carrying catch).
pub fn finishCatchHandler(self: *Lowering, body_val: ?Ref, succ_ty: TypeId, merge_bb: BlockId, span: ast.Span) void {
    if (body_val) |v| {
        if (self.builder.getRefType(v) == .noreturn) {
            self.builder.emitUnreachable();
            return;
        }
    }
    self.emitTraceClear();
    if (succ_ty == .void) {
        self.builder.br(merge_bb, &.{});
        return;
    }
    const bv: Ref = blk: {
        if (body_val) |v| {
            const vty = self.builder.getRefType(v);
            if (vty != .void) break :blk self.coerceToType(v, vty, succ_ty);
        }
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, span, "`catch` body must produce a value of type '{s}' (or diverge with `return` / `raise`)", .{self.formatTypeName(succ_ty)});
        }
        break :blk self.builder.constUndef(succ_ty);
    };
    self.builder.br(merge_bb, &.{bv});
}

/// Lower a `catch` body in a child scope that binds the error tag to the
/// catch binding (if any). When `want_ty` is non-null (value-carrying
/// catch), returns the body's value (or null if the body diverged); when
/// null (pure-failable catch), runs the body for effect and returns null.
pub fn runCatchBody(self: *Lowering, ce: *const ast.CatchExpr, err_val: Ref, err_set: TypeId, want_ty: ?TypeId) ?Ref {
    var handle_scope = Scope.init(self.alloc, self.scope);
    const saved_scope = self.scope;
    self.scope = &handle_scope;
    defer {
        self.scope = saved_scope;
        handle_scope.deinit();
    }
    if (ce.binding) |name| {
        handle_scope.put(name, .{ .ref = err_val, .ty = err_set, .is_alloca = false });
    }
    if (want_ty == null) {
        if (ce.body.data == .block) self.lowerBlock(ce.body) else _ = self.lowerExpr(ce.body);
        return null;
    }
    const saved_fbv = self.force_block_value;
    self.force_block_value = true;
    defer self.force_block_value = saved_fbv;
    return if (ce.body.data == .block) self.lowerBlockValue(ce.body) else self.lowerExpr(ce.body);
}

/// `lhs or rhs` with a failable LHS (ERR step E2.4a — the value-terminator
/// form). On LHS success the result is its value part (the lone value, or a
/// value-tuple); on failure the LHS error is discarded and the result is
/// `rhs` (a plain value of the success type), so the whole expression is
/// non-failable. The CHAIN form (`... or try ...` / a failable RHS) needs
/// the fallback-target routing deferred from E1.4 — bail.
/// Widening at an escape (function-propagation) site: the escaping set must
/// be ⊆ the caller's named set. An inferred caller (`!`) absorbs everything
/// via the whole-program SCC (E1.4b) — no check. A bare-`!` callee carries
/// no tags on its placeholder TypeId, so check its SCC-converged set.
/// Shared by `try` propagation and a failable `or` chain's final operand.
pub fn checkEscapeWidening(self: *Lowering, callee_node: *const Node, callee_set: TypeId, caller_set: TypeId, span: ast.Span) void {
    if (self.isInferredErrorSet(caller_set)) return;
    if (!self.isInferredErrorSet(callee_set)) {
        self.checkErrorSetSubset(callee_set, caller_set, span);
        return;
    }
    // Bare-`!` callee: either a named top-level function (its converged set
    // is name-keyed) or a closure/fn-type SLOT (its set is shape-keyed,
    // shared program-wide by value-signature).
    if (callTargetName(callee_node)) |nm| {
        if (self.inferred_error_sets.get(nm)) |tags| {
            self.diagTagsNotInSet(tags, caller_set, span);
            return;
        }
    }
    if (self.shapeKeyOfCallee(callee_node)) |key| {
        if (self.shape_inferred_sets.get(key)) |tags| {
            self.diagTagsNotInSet(tags, caller_set, span);
        }
        // Empty union (no closure of this shape ever raises) → silently
        // allowed: the slot's `!` resolves to ∅ (ERR E5.1 sub-feature 6).
    }
}

/// Structural test: is this `or` a *failable* construct (value-terminator or
/// chain), rather than a boolean / optional-unwrap `or`? True when either
/// operand is failable-like — a `try`, an error-channel-typed expression, or
/// itself a nested failable `or` chain. Kept separate from `inferExprType`:
/// a `try`-chain's *value* type is its success type `T` (non-failable), so
/// the chain-ness is structural, not type-derived.
pub fn orIsFailableChain(self: *Lowering, bop: *const ast.BinaryOp) bool {
    return self.operandIsFailableLike(bop.lhs) or self.operandIsFailableLike(bop.rhs);
}

pub fn operandIsFailableLike(self: *Lowering, node: *const Node) bool {
    if (node.data == .try_expr) return true;
    if (node.data == .binary_op and node.data.binary_op.op == .or_op) {
        return self.orIsFailableChain(&node.data.binary_op);
    }
    return self.errorChannelOf(self.inferExprType(node)) != null;
}

/// The success (value) type of a failable `or` chain: descend to the
/// leftmost operand, unwrap any `try`, and take its failable success type
/// (`void` for a pure-`-> !` chain). All operands share this type.
pub fn orChainSuccessType(self: *Lowering, bop: *const ast.BinaryOp) TypeId {
    var lhs = bop.lhs;
    while (lhs.data == .binary_op and lhs.data.binary_op.op == .or_op and
        self.orIsFailableChain(&lhs.data.binary_op))
    {
        lhs = lhs.data.binary_op.lhs;
    }
    const ft = self.inferExprType(unwrapTryNode(lhs));
    const fset = self.errorChannelOf(ft) orelse return .unresolved;
    return if (ft == fset) .void else self.failableSuccessType(ft);
}

/// `try X` → `X` (the underlying failable); any other node unchanged. In an
/// `or` chain the `try` marker's routing IS the chain, so the chain lowers
/// the underlying failable directly rather than re-entering `lowerTry`.
pub fn unwrapTryNode(node: *const Node) *const Node {
    return if (node.data == .try_expr) node.data.try_expr.operand else node;
}

/// Flatten a left-associative failable `or` chain into its operands,
/// left-to-right. `a or b or c` parses as `(a or b) or c`; this collects
/// `[a, b, c]`. Walks the left spine only while it stays a failable
/// `or` chain (a parenthesized non-chain `or` on the left stops the walk).
pub fn flattenOrChain(self: *Lowering, bop: *const ast.BinaryOp, list: *std.ArrayList(*const Node)) void {
    if (bop.lhs.data == .binary_op and bop.lhs.data.binary_op.op == .or_op and
        self.orIsFailableChain(&bop.lhs.data.binary_op))
    {
        self.flattenOrChain(&bop.lhs.data.binary_op, list);
    } else {
        list.append(self.alloc, bop.lhs) catch unreachable;
    }
    list.append(self.alloc, bop.rhs) catch unreachable;
}

/// Lower a failable `or` (ERR E2.4): a value-terminator (`lhs or value`) or
/// a chain (`try a or try b or …`, possibly with a trailing value
/// terminator). Left-to-right, short-circuit: each failable operand's
/// failure routes to the next operand; the final operand either absorbs
/// (value terminator) or propagates to the enclosing function. Each failed
/// attempt pushes a trace frame; an absorbing resolution (any operand
/// succeeding, or the value terminator) clears the buffer; total failure
/// preserves the frames for the caller.
pub fn lowerFailableOr(self: *Lowering, bop: *const ast.BinaryOp) Ref {
    const span = bop.lhs.span;

    var operands = std.ArrayList(*const Node).empty;
    defer operands.deinit(self.alloc);
    self.flattenOrChain(bop, &operands);
    const last_idx = operands.items.len - 1;
    const last_is_value = !self.operandIsFailableLike(operands.items[last_idx]);

    // The chain's total-failure routing. An absorbing consumer (`catch`)
    // sets this so the final operand's failure reaches the handler; cleared
    // while lowering operands so a nested operand doesn't inherit it.
    const fail_target = self.chain_fail_target;
    self.chain_fail_target = null;
    defer self.chain_fail_target = fail_target;

    // Success type from the first operand (a failable; unwrap any `try`).
    const first_ty = self.inferExprType(unwrapTryNode(operands.items[0]));
    const first_set = self.errorChannelOf(first_ty) orelse {
        if (self.diagnostics) |d| d.addFmt(.err, span, "the left operand of a failable `or` must be failable; got '{s}'", .{self.formatTypeName(first_ty)});
        return self.builder.constInt(0, .void);
    };
    const has_value = first_ty != first_set;
    const succ_ty = if (has_value) self.failableSuccessType(first_ty) else TypeId.void;

    // Pure-failable LHS (`-> !`) with a value terminator: nothing to fall
    // back to.
    if (!has_value and last_is_value) {
        if (self.diagnostics) |d| d.addFmt(.err, span, "`or value` requires a value-carrying failable (`-> (T, !)`) — a `-> !` has no success value to fall back to; use `catch` to absorb the error", .{});
        return self.builder.constInt(0, .void);
    }

    // Caller failability — only needed when the chain can propagate to the
    // function (final operand is failable AND no absorbing consumer target).
    var caller_ret: TypeId = .void;
    var caller_set: TypeId = .void;
    if (!last_is_value and fail_target == null) {
        const cret = self.effectiveReturnType();
        const cset = if (cret) |r| self.errorChannelOf(r) else null;
        if (cset == null) {
            if (self.diagnostics) |d| d.addFmt(.err, span, "a failable `or` chain propagates on total failure, so it is only valid inside a failable function — add a value terminator (`… or value`) or wrap with `catch`", .{});
            return self.builder.constInt(0, .void);
        }
        caller_ret = cret.?;
        caller_set = cset.?;
    }

    const merge_bb = if (has_value)
        self.freshBlockWithParams("orc.merge", &.{succ_ty})
    else
        self.freshBlock("orc.merge");

    for (operands.items, 0..) |operand, i| {
        const is_last = i == last_idx;

        if (is_last and last_is_value) {
            // Value terminator: absorbs every prior failure.
            self.emitTraceClear();
            const saved = self.target_type;
            self.target_type = succ_ty;
            const v = self.lowerExpr(operand);
            self.target_type = saved;
            const vc = self.coerceToType(v, self.builder.getRefType(v), succ_ty);
            self.builder.br(merge_bb, &.{vc});
            break;
        }

        // Failable operand (`try X` marker or a bare failable). Lower the
        // underlying failable; the `try` marker's routing IS the chain.
        const underlying = unwrapTryNode(operand);
        const op_ty = self.inferExprType(underlying);
        const op_set = self.errorChannelOf(op_ty) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, operand.span, "operand of a failable `or` chain must be failable; got '{s}'", .{self.formatTypeName(op_ty)});
            return self.builder.constInt(0, .void);
        };
        const op_value_carrying = op_ty != op_set;

        // Widening applies only when the final failure escapes to the
        // function (no absorbing consumer); a `catch` target absorbs it.
        if (is_last and fail_target == null) self.checkEscapeWidening(underlying, op_set, caller_set, operand.span);

        const result = self.lowerExpr(underlying);
        const err_val = if (op_value_carrying) self.extractErrorSlot(result, op_ty, op_set) else result;
        const err_ty = self.builder.getRefType(err_val);
        const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);

        const ok_bb = self.freshBlock("orc.ok");
        const fail_bb = self.freshBlock(if (is_last) "orc.prop" else "orc.next");
        self.builder.condBr(is_err, fail_bb, &.{}, ok_bb, &.{});

        // Success: the chain resolved here — clear the buffer, merge value.
        self.builder.switchToBlock(ok_bb);
        self.emitTraceClear();
        if (has_value) {
            const sv = self.extractSuccessValue(result, op_ty, succ_ty);
            const svc = self.coerceToType(sv, self.builder.getRefType(sv), succ_ty);
            self.builder.br(merge_bb, &.{svc});
        } else {
            self.builder.br(merge_bb, &.{});
        }

        // Failure: push a trace frame, then either route to the next
        // operand (same block — no function exit, so `onfail` does not
        // fire) or, for the final operand, resolve the total failure: to an
        // absorbing consumer (`catch`) if one set a target, else propagate
        // to the caller.
        self.builder.switchToBlock(fail_bb);
        self.emitTracePush(self.placeholderTraceFrame());
        if (is_last) {
            if (fail_target) |t| {
                const ec = self.coerceToType(err_val, self.builder.getRefType(err_val), t.set);
                self.builder.br(t.bb, &.{ec});
            } else {
                self.emitErrorCleanup(self.func_defer_base, err_val);
                self.emitErrorReturn(caller_ret, caller_set, err_val);
            }
        }
        // else: fall through — the next operand is lowered in fail_bb.
    }

    self.builder.switchToBlock(merge_bb);
    return if (has_value) self.builder.blockParam(merge_bb, 0, succ_ty) else self.builder.constInt(0, .void);
}

// ── ERR E1.4b: whole-program inferred-error-set convergence ──────────

/// The bare callee name of a call expression (`g(...)` → "g"), or null if
/// the node isn't a direct call to a named function. E1.4b resolves only
/// the bare identifier (top-level functions); UFCS / mangled-local callees
/// aren't tracked by the SCC.
pub fn callTargetName(node: *const Node) ?[]const u8 {
    if (node.data != .call) return null;
    const callee = node.data.call.callee;
    return if (callee.data == .identifier) callee.data.identifier.name else null;
}

/// True when `rt` is a pure bare-`!` failable return (`-> !`, the inferred
/// set) — NOT `!Named` and NOT a value-carrying `-> (T..., !)` tuple.
pub fn astIsPureBareInferred(rt: ?*const Node) bool {
    const n = rt orelse return false;
    return n.data == .error_type_expr and n.data.error_type_expr.name == null;
}

/// The named-set name of a pure `-> !Named` return (`"Named"`), or null for
/// bare-`!`, value-carrying, or non-failable returns.
pub fn astPureNamedSet(rt: ?*const Node) ?[]const u8 {
    const n = rt orelse return null;
    if (n.data != .error_type_expr) return null;
    return n.data.error_type_expr.name;
}

/// The declared tags of a named error set, by name; null if not a
/// registered error set.
pub fn namedSetTags(self: *Lowering, name: []const u8) ?[]const u32 {
    const sid = self.module.types.internString(name);
    const tid = self.module.types.findByName(sid) orelse return null;
    if (tid.isBuiltin()) return null;
    const info = self.module.types.get(tid);
    return if (info == .error_set) info.error_set.tags else null;
}

/// Whole-program inferred-error-set convergence. Thin delegation to the
/// canonical owner (`ErrorAnalysis`, `error_analysis.zig`); kept on
/// `Lowering` as a `pub` entry point because the lowering pipeline + the
/// E1.4b unit test call it.
pub fn convergeInferredErrorSets(self: *Lowering) void {
    self.errorAnalysis().convergeInferredErrorSets();
}

pub fn containsTag(tags: []const u32, t: u32) bool {
    for (tags) |x| if (x == t) return true;
    return false;
}

/// Whole-program closure-shape error-set convergence. Thin delegation to the
/// canonical owner (`ErrorAnalysis`, `error_analysis.zig`); kept on
/// `Lowering` as a `pub` entry point because the lowering pipeline calls it.
pub fn convergeClosureShapeSets(self: *Lowering) void {
    self.errorAnalysis().convergeClosureShapeSets();
}

/// Record one closure literal's contribution to its value-signature shape's
/// inferred-`!` union. No-op unless the literal is a CONCRETE (non-generic)
/// bare-`!` failable closure; named-set / non-failable literals add no tags.
pub fn recordClosureShape(self: *Lowering, lam: *const ast.Lambda) void {
    if (lam.type_params.len > 0) return; // generic shapes out of scope (sub-feature 8)
    const rt_node = lam.return_type orelse return; // no annotation → non-failable infer
    const ret = self.resolveType(rt_node);
    const es = self.errorChannelOf(ret) orelse return; // not failable
    if (!self.isInferredErrorSet(es)) return; // `!Named` → its own set, not the inferred union

    var ptys = std.ArrayList(TypeId).empty;
    defer ptys.deinit(self.alloc);
    for (lam.params) |p| {
        if (p.is_variadic or p.is_pack or p.is_comptime) return; // not a plain fn-type slot
        ptys.append(self.alloc, self.resolveType(p.type_expr)) catch return;
    }
    const key = self.closureShapeKey(ptys.items, self.returnValuePart(ret));

    var tags = std.ArrayList(u32).empty;
    defer tags.deinit(self.alloc);
    var edges = std.ArrayList([]const u8).empty;
    defer edges.deinit(self.alloc);
    self.errorAnalysis().collectErrorSites(lam.body, &tags, &edges);
    for (edges.items) |callee| {
        for (self.calleeEscapeTags(callee)) |t| {
            if (!containsTag(tags.items, t)) tags.append(self.alloc, t) catch {};
        }
    }
    self.unionShapeTags(key, tags.items);
}

/// The escape tags of a callee referenced by name from a `try g()` edge:
/// a bare-`!` callee's converged set, or a `-> !Named` callee's declared set.
pub fn calleeEscapeTags(self: *Lowering, callee: []const u8) []const u32 {
    if (self.inferred_error_sets.get(callee)) |t| return t;
    if (self.program_index.fn_ast_map.get(callee)) |cfd| {
        if (astPureNamedSet(cfd.return_type)) |nm| return self.namedSetTags(nm) orelse &.{};
    }
    return &.{};
}

/// Merge `new_tags` into the shape node `key` (sorted, deduped). The map is
/// content-keyed (StringHashMap), so re-`put` with a fresh equal key string
/// overwrites the existing node's value in place.
pub fn unionShapeTags(self: *Lowering, key: []const u8, new_tags: []const u32) void {
    var list = std.ArrayList(u32).empty;
    defer list.deinit(self.alloc);
    if (self.shape_inferred_sets.get(key)) |existing| list.appendSlice(self.alloc, existing) catch {};
    for (new_tags) |t| {
        if (!containsTag(list.items, t)) list.append(self.alloc, t) catch {};
    }
    const sorted = self.alloc.dupe(u32, list.items) catch return;
    std.mem.sort(u32, sorted, {}, std.sort.asc(u32));
    self.shape_inferred_sets.put(key, sorted) catch {};
}

/// Canonical key for a callable VALUE-signature: param types + the value
/// part of the return (error slot excluded). Bare-`!` and non-failable
/// shapes of the same value-sig — and `.function` vs `.closure` of that
/// sig — collapse to one key, so all occurrences share one inferred node.
pub fn closureShapeKey(self: *Lowering, params: []const TypeId, value_ret: TypeId) []const u8 {
    var buf = std.ArrayList(u8).empty;
    buf.appendSlice(self.alloc, "shape") catch return "shape";
    for (params) |p| {
        buf.append(self.alloc, '_') catch return "shape";
        buf.appendSlice(self.alloc, self.mangleTypeName(p)) catch return "shape";
    }
    buf.appendSlice(self.alloc, "__") catch return "shape";
    buf.appendSlice(self.alloc, self.mangleTypeName(value_ret)) catch return "shape";
    return buf.items;
}

/// The value part of a (possibly failable) return type, error slot dropped:
/// `(T, !)` → T (or a value-tuple); pure `-> !` → void; non-failable → self.
pub fn returnValuePart(self: *Lowering, ret: TypeId) TypeId {
    const es = self.errorChannelOf(ret) orelse return ret;
    if (ret == es) return .void;
    return self.failableSuccessType(ret);
}

/// Shape key of a call's callee expression when it's a closure/fn-type slot
/// (variable, field, index — anything with a `.closure`/`.function` type),
/// for the program-wide shape-union widening lookup. Null for non-callables.
pub fn shapeKeyOfCallee(self: *Lowering, node: *const Node) ?[]const u8 {
    if (node.data != .call) return null;
    const fty = self.inferExprType(node.data.call.callee);
    if (fty.isBuiltin()) return null;
    const info = self.module.types.get(fty);
    const params: []const TypeId = switch (info) {
        .closure => |c| c.params,
        .function => |f| f.params,
        else => return null,
    };
    const ret: TypeId = switch (info) {
        .closure => |c| c.ret,
        .function => |f| f.ret,
        else => return null,
    };
    return self.closureShapeKey(params, self.returnValuePart(ret));
}
