// Tests for lower.zig

const std = @import("std");
const ast = @import("../ast.zig");
const Node = ast.Node;

const ir_mod = @import("ir.zig");
const TypeId = ir_mod.TypeId;
const Ref = ir_mod.Ref;
const FuncId = ir_mod.FuncId;
const Lowering = ir_mod.Lowering;

test "lower: simple function with arithmetic" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();

    // Build a minimal AST: add :: (a: s64, b: s64) -> s64 { return a + b; }
    const a_type = alloc.create(Node) catch unreachable;
    a_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
    const b_type = alloc.create(Node) catch unreachable;
    b_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
    const ret_type = alloc.create(Node) catch unreachable;
    ret_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };

    const a_ident = alloc.create(Node) catch unreachable;
    a_ident.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "a" } } };
    const b_ident = alloc.create(Node) catch unreachable;
    b_ident.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "b" } } };

    const add_expr = alloc.create(Node) catch unreachable;
    add_expr.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .binary_op = .{
        .op = .add,
        .lhs = a_ident,
        .rhs = b_ident,
    } } };

    const ret_stmt = alloc.create(Node) catch unreachable;
    ret_stmt.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .return_stmt = .{ .value = add_expr } } };

    const body = alloc.create(Node) catch unreachable;
    const stmts: []const *Node = &.{ret_stmt};
    body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = stmts } } };

    defer alloc.destroy(a_type);
    defer alloc.destroy(b_type);
    defer alloc.destroy(ret_type);
    defer alloc.destroy(a_ident);
    defer alloc.destroy(b_ident);
    defer alloc.destroy(add_expr);
    defer alloc.destroy(ret_stmt);
    defer alloc.destroy(body);

    const params: []const ast.Param = &.{
        .{ .name = "a", .name_span = .{ .start = 0, .end = 0 }, .type_expr = a_type },
        .{ .name = "b", .name_span = .{ .start = 0, .end = 0 }, .type_expr = b_type },
    };

    const fn_decl = ast.FnDecl{
        .name = "add",
        .params = params,
        .return_type = ret_type,
        .body = body,
    };

    var lowering = Lowering.init(&module);
    lowering.lowerFunction(&fn_decl, "add", false);

    // Verify
    try std.testing.expectEqual(@as(usize, 1), module.functions.items.len);
    const func = module.getFunction(FuncId.fromIndex(0));
    try std.testing.expectEqual(@as(usize, 2), func.params.len);
    try std.testing.expectEqual(TypeId.s64, func.ret);
    try std.testing.expect(func.blocks.items.len > 0);

    // Print the IR to verify it looks reasonable
    const print_mod = @import("print.zig");
    var aw = std.Io.Writer.Allocating.init(alloc);
    try print_mod.printModule(&module, &aw.writer);
    var result = aw.writer.toArrayList();
    defer result.deinit(alloc);

    const output = result.items;
    try std.testing.expect(std.mem.indexOf(u8, output, "func @add") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "entry:") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "add %") != null or std.mem.indexOf(u8, output, "ret %") != null);
}

test "lower: instructions carry their AST node's source span (ERR E3.0)" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();

    // probe :: (a: s64, b: s64) -> s64 { return a + b; } — the `a + b` node
    // gets a distinctive span so we can find the emitted `add` instruction and
    // assert it was stamped (not left at the empty {0,0} default).
    const a_type = alloc.create(Node) catch unreachable;
    a_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
    const b_type = alloc.create(Node) catch unreachable;
    b_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
    const ret_type = alloc.create(Node) catch unreachable;
    ret_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
    const a_ident = alloc.create(Node) catch unreachable;
    a_ident.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "a" } } };
    const b_ident = alloc.create(Node) catch unreachable;
    b_ident.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "b" } } };
    const add_expr = alloc.create(Node) catch unreachable;
    add_expr.* = .{ .span = .{ .start = 42, .end = 47 }, .data = .{ .binary_op = .{ .op = .add, .lhs = a_ident, .rhs = b_ident } } };
    const ret_stmt = alloc.create(Node) catch unreachable;
    ret_stmt.* = .{ .span = .{ .start = 30, .end = 50 }, .data = .{ .return_stmt = .{ .value = add_expr } } };
    const body = alloc.create(Node) catch unreachable;
    const stmts: []const *Node = &.{ret_stmt};
    body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = stmts } } };
    defer {
        alloc.destroy(a_type);
        alloc.destroy(b_type);
        alloc.destroy(ret_type);
        alloc.destroy(a_ident);
        alloc.destroy(b_ident);
        alloc.destroy(add_expr);
        alloc.destroy(ret_stmt);
        alloc.destroy(body);
    }

    const params: []const ast.Param = &.{
        .{ .name = "a", .name_span = .{ .start = 0, .end = 0 }, .type_expr = a_type },
        .{ .name = "b", .name_span = .{ .start = 0, .end = 0 }, .type_expr = b_type },
    };
    const fn_decl = ast.FnDecl{ .name = "probe", .params = params, .return_type = ret_type, .body = body };

    var lowering = Lowering.init(&module);
    lowering.lowerFunction(&fn_decl, "probe", false);

    // Find the `add` instruction and assert it carries the `a + b` span.
    const func = module.getFunction(FuncId.fromIndex(0));
    var found = false;
    for (func.blocks.items) |blk| {
        for (blk.insts.items) |inst| {
            if (inst.op == .add) {
                try std.testing.expectEqual(@as(u32, 42), inst.span.start);
                try std.testing.expectEqual(@as(u32, 47), inst.span.end);
                found = true;
            }
        }
    }
    try std.testing.expect(found);
}

test "lower: if/else generates basic blocks" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();

    // Build AST: test :: (c: bool) -> s64 { if c { return 1; } else { return 2; } }
    // The condition must be a runtime value (a param) — a constant `if true`
    // is folded by lowering to a single block, defeating the branch test.
    const cond_node = alloc.create(Node) catch unreachable;
    defer alloc.destroy(cond_node);
    cond_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "c" } } };

    const cond_ty = alloc.create(Node) catch unreachable;
    defer alloc.destroy(cond_ty);
    cond_ty.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "bool", .is_generic = false } } };

    const ret1_val = alloc.create(Node) catch unreachable;
    defer alloc.destroy(ret1_val);
    ret1_val.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .int_literal = .{ .value = 1 } } };

    const ret2_val = alloc.create(Node) catch unreachable;
    defer alloc.destroy(ret2_val);
    ret2_val.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .int_literal = .{ .value = 2 } } };

    const then_ret = alloc.create(Node) catch unreachable;
    defer alloc.destroy(then_ret);
    then_ret.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .return_stmt = .{ .value = ret1_val } } };

    const else_ret = alloc.create(Node) catch unreachable;
    defer alloc.destroy(else_ret);
    else_ret.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .return_stmt = .{ .value = ret2_val } } };

    const then_body = alloc.create(Node) catch unreachable;
    defer alloc.destroy(then_body);
    then_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = &.{then_ret} } } };

    const else_body = alloc.create(Node) catch unreachable;
    defer alloc.destroy(else_body);
    else_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = &.{else_ret} } } };

    const if_node = alloc.create(Node) catch unreachable;
    defer alloc.destroy(if_node);
    if_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .if_expr = .{
        .condition = cond_node,
        .then_branch = then_body,
        .else_branch = else_body,
        .is_inline = false,
    } } };

    const fn_body = alloc.create(Node) catch unreachable;
    defer alloc.destroy(fn_body);
    fn_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = &.{if_node} } } };

    const ret_type = alloc.create(Node) catch unreachable;
    defer alloc.destroy(ret_type);
    ret_type.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };

    const fn_decl = ast.FnDecl{
        .name = "test_if",
        .params = &.{.{ .name = "c", .name_span = .{ .start = 0, .end = 0 }, .type_expr = cond_ty }},
        .return_type = ret_type,
        .body = fn_body,
    };

    var lowering = Lowering.init(&module);
    lowering.lowerFunction(&fn_decl, "test_if", false);

    // Verify: should have 4 blocks (entry, if.then, if.else, if.merge)
    const func = module.getFunction(FuncId.fromIndex(0));
    try std.testing.expectEqual(@as(usize, 4), func.blocks.items.len);

    // Print and verify structure
    const print_mod = @import("print.zig");
    var aw = std.Io.Writer.Allocating.init(alloc);
    try print_mod.printModule(&module, &aw.writer);
    var result = aw.writer.toArrayList();
    defer result.deinit(alloc);
    const output = result.items;

    try std.testing.expect(std.mem.indexOf(u8, output, "cond_br") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "if.then") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "if.else") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "if.merge") != null);
}

test "lower: while loop generates header/body/exit blocks" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();

    // Build AST: loop :: () { while true { break; } }
    const cond_node = alloc.create(Node) catch unreachable;
    defer alloc.destroy(cond_node);
    cond_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .bool_literal = .{ .value = true } } };

    const break_node = alloc.create(Node) catch unreachable;
    defer alloc.destroy(break_node);
    break_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .break_expr };

    const while_body = alloc.create(Node) catch unreachable;
    defer alloc.destroy(while_body);
    while_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = &.{break_node} } } };

    const while_node = alloc.create(Node) catch unreachable;
    defer alloc.destroy(while_node);
    while_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .while_expr = .{
        .condition = cond_node,
        .body = while_body,
    } } };

    const fn_body = alloc.create(Node) catch unreachable;
    defer alloc.destroy(fn_body);
    fn_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = &.{while_node} } } };

    const fn_decl = ast.FnDecl{
        .name = "loop_test",
        .params = &.{},
        .return_type = null,
        .body = fn_body,
    };

    var lowering = Lowering.init(&module);
    lowering.lowerFunction(&fn_decl, "loop_test", false);

    // Verify: should have 4 blocks (entry, while.hdr, while.body, while.exit)
    const func = module.getFunction(FuncId.fromIndex(0));
    try std.testing.expectEqual(@as(usize, 4), func.blocks.items.len);

    // Print and verify structure
    const print_mod = @import("print.zig");
    var aw = std.Io.Writer.Allocating.init(alloc);
    try print_mod.printModule(&module, &aw.writer);
    var result = aw.writer.toArrayList();
    defer result.deinit(alloc);
    const output = result.items;

    try std.testing.expect(std.mem.indexOf(u8, output, "while.hdr") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "while.body") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "while.exit") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "cond_br") != null);
}

// M1.2 A.1 — Obj-C type-encoding helper.
test "lower: objcTypeEncodingFromSignature emits primitive shapes" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Niladic void method: -(void)greet  →  "v@:"
    const e1 = try lowering.objcTypeEncodingFromSignature(.void, &.{}, null);
    defer alloc.free(e1);
    try std.testing.expectEqualStrings("v@:", e1);

    // Returns s32, takes s32: -(int)add:(int)x  →  "i@:i"
    const e2 = try lowering.objcTypeEncodingFromSignature(.s32, &.{.s32}, null);
    defer alloc.free(e2);
    try std.testing.expectEqualStrings("i@:i", e2);

    // s64 return, two s64 args: "q@:qq"
    const e3 = try lowering.objcTypeEncodingFromSignature(.s64, &.{ .s64, .s64 }, null);
    defer alloc.free(e3);
    try std.testing.expectEqualStrings("q@:qq", e3);

    // BOOL return (s8): "c@:"
    const e4 = try lowering.objcTypeEncodingFromSignature(.s8, &.{}, null);
    defer alloc.free(e4);
    try std.testing.expectEqualStrings("c@:", e4);

    // Float/double: "f@:d"
    const e5 = try lowering.objcTypeEncodingFromSignature(.f32, &.{.f64}, null);
    defer alloc.free(e5);
    try std.testing.expectEqualStrings("f@:d", e5);

    // bool (i1) is `B` — distinct from BOOL (`c`).
    const e6 = try lowering.objcTypeEncodingFromSignature(.bool, &.{.bool}, null);
    defer alloc.free(e6);
    try std.testing.expectEqualStrings("B@:B", e6);

    // usize / isize on the 64-bit target.
    const e7 = try lowering.objcTypeEncodingFromSignature(.usize, &.{.isize}, null);
    defer alloc.free(e7);
    try std.testing.expectEqualStrings("Q@:q", e7);

    // Unsigned variants u8/u16/u32/u64.
    const e8 = try lowering.objcTypeEncodingFromSignature(.u32, &.{ .u8, .u16, .u64 }, null);
    defer alloc.free(e8);
    try std.testing.expectEqualStrings("I@:CSQ", e8);
}

test "lower: objcTypeEncodingFromSignature emits pointer shapes" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Generic `*void` → `^v`.
    const void_ptr = module.types.ptrTo(.void);
    const e1 = try lowering.objcTypeEncodingFromSignature(void_ptr, &.{void_ptr}, null);
    defer alloc.free(e1);
    try std.testing.expectEqualStrings("^v@:^v", e1);

    // `[*]u8` C-string carrier → `*`.
    const u8_many = module.types.intern(.{ .many_pointer = .{ .element = .u8 } });
    const e2 = try lowering.objcTypeEncodingFromSignature(.void, &.{u8_many}, null);
    defer alloc.free(e2);
    try std.testing.expectEqualStrings("v@:*", e2);

    // `[*]s32` (non-u8 many-pointer) → `^v`.
    const s32_many = module.types.intern(.{ .many_pointer = .{ .element = .s32 } });
    const e3 = try lowering.objcTypeEncodingFromSignature(.void, &.{s32_many}, null);
    defer alloc.free(e3);
    try std.testing.expectEqualStrings("v@:^v", e3);
}

// M1.2 A.2 — sx-defined #objc_class state struct construction.
test "lower: objcDefinedStateStructType collects user-declared fields" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Synthesize a #objc_class("SxFoo") { counter: s32; ticks: s64; } AST.
    const span = ast.Span{ .start = 0, .end = 0 };
    const counter_type = try alloc.create(Node);
    defer alloc.destroy(counter_type);
    counter_type.* = .{ .span = span, .data = .{ .type_expr = .{ .name = "s32", .is_generic = false } } };
    const ticks_type = try alloc.create(Node);
    defer alloc.destroy(ticks_type);
    ticks_type.* = .{ .span = span, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };

    const members = [_]ast.ForeignClassMember{
        .{ .field = .{ .name = "counter", .field_type = counter_type } },
        .{ .field = .{ .name = "ticks", .field_type = ticks_type } },
    };
    const fcd = ast.ForeignClassDecl{
        .name = "SxFoo",
        .foreign_path = "SxFoo",
        .runtime = .objc_class,
        .members = &members,
        .is_foreign = false,
        .is_main = false,
    };

    const state_ty = lowering.objcDefinedStateStructType(&fcd);
    const info = module.types.get(state_ty);
    try std.testing.expectEqual(@as(std.meta.Tag(@TypeOf(info)), .@"struct"), std.meta.activeTag(info));

    const s = info.@"struct";
    try std.testing.expectEqualStrings("__SxFooState", module.types.getString(s.name));
    try std.testing.expectEqual(@as(usize, 2), s.fields.len);
    try std.testing.expectEqualStrings("counter", module.types.getString(s.fields[0].name));
    try std.testing.expectEqual(TypeId.s32, s.fields[0].ty);
    try std.testing.expectEqualStrings("ticks", module.types.getString(s.fields[1].name));
    try std.testing.expectEqual(TypeId.s64, s.fields[1].ty);

    // Idempotency: a second call returns the same TypeId (cache hit on name).
    const state_ty2 = lowering.objcDefinedStateStructType(&fcd);
    try std.testing.expectEqual(state_ty, state_ty2);
}

test "lower: objcDefinedStateStructType handles empty field set" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    const fcd = ast.ForeignClassDecl{
        .name = "SxEmpty",
        .foreign_path = "SxEmpty",
        .runtime = .objc_class,
        .members = &.{},
        .is_foreign = false,
        .is_main = false,
    };

    const state_ty = lowering.objcDefinedStateStructType(&fcd);
    const info = module.types.get(state_ty);
    try std.testing.expectEqualStrings("__SxEmptyState", module.types.getString(info.@"struct".name));
    try std.testing.expectEqual(@as(usize, 0), info.@"struct".fields.len);
}

test "lower: objcDefinedStateStructType skips non-field members" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Mix in #extends and method members — only `.field` contributes.
    const span = ast.Span{ .start = 0, .end = 0 };
    const counter_type = try alloc.create(Node);
    defer alloc.destroy(counter_type);
    counter_type.* = .{ .span = span, .data = .{ .type_expr = .{ .name = "s32", .is_generic = false } } };

    const members = [_]ast.ForeignClassMember{
        .{ .extends = "NSObject" },
        .{ .field = .{ .name = "counter", .field_type = counter_type } },
        .{ .implements = "UIApplicationDelegate" },
    };
    const fcd = ast.ForeignClassDecl{
        .name = "SxMixed",
        .foreign_path = "SxMixed",
        .runtime = .objc_class,
        .members = &members,
        .is_foreign = false,
        .is_main = false,
    };

    const state_ty = lowering.objcDefinedStateStructType(&fcd);
    const info = module.types.get(state_ty);
    try std.testing.expectEqual(@as(usize, 1), info.@"struct".fields.len);
    try std.testing.expectEqualStrings("counter", module.types.getString(info.@"struct".fields[0].name));
}

test "lower: objcTypeEncodingFromSignature emits @ for Obj-C class pointers" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Synthesize a foreign Obj-C class entry so the encoder recognises
    // `*NSString` as an object pointer.
    const ns_name = module.types.internString("NSString");
    const ns_struct = module.types.intern(.{ .@"struct" = .{ .name = ns_name, .fields = &.{} } });
    const ns_ptr = module.types.ptrTo(ns_struct);
    var ns_fcd = ast.ForeignClassDecl{
        .name = "NSString",
        .foreign_path = "NSString",
        .runtime = .objc_class,
        .members = &.{},
        .is_foreign = true,
        .is_main = false,
    };
    try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd);

    // Return *NSString, no args: "@@:"
    const e1 = try lowering.objcTypeEncodingFromSignature(ns_ptr, &.{}, null);
    defer alloc.free(e1);
    try std.testing.expectEqualStrings("@@:", e1);

    // Return *NSString, take *NSString: "@@:@"
    const e2 = try lowering.objcTypeEncodingFromSignature(ns_ptr, &.{ns_ptr}, null);
    defer alloc.free(e2);
    try std.testing.expectEqualStrings("@@:@", e2);
}

test "lower: objcTypeEncodingFromSignature unwraps optional to wire type" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Foreign `*NSString` so the encoder recognises it as `@`.
    const ns_name = module.types.internString("NSString");
    const ns_struct = module.types.intern(.{ .@"struct" = .{ .name = ns_name, .fields = &.{} } });
    const ns_ptr = module.types.ptrTo(ns_struct);
    var ns_fcd = ast.ForeignClassDecl{
        .name = "NSString",
        .foreign_path = "NSString",
        .runtime = .objc_class,
        .members = &.{},
        .is_foreign = true,
        .is_main = false,
    };
    try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd);

    // `?s64 -> ?*NSString` collapses to `q -> @` at the Obj-C boundary.
    const opt_s64 = module.types.optionalOf(.s64);
    const opt_ns = module.types.optionalOf(ns_ptr);
    const e1 = try lowering.objcTypeEncodingFromSignature(opt_ns, &.{opt_s64}, null);
    defer alloc.free(e1);
    try std.testing.expectEqualStrings("@@:q", e1);

    // Nested optional unwrap (`??f64`) — same as `f64` at the wire.
    const opt_f64 = module.types.optionalOf(.f64);
    const opt_opt_f64 = module.types.optionalOf(opt_f64);
    const e2 = try lowering.objcTypeEncodingFromSignature(.void, &.{opt_opt_f64}, null);
    defer alloc.free(e2);
    try std.testing.expectEqualStrings("v@:d", e2);
}

test "lower: objcTypeEncodingFromSignature emits structs as {Name=fields...}" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // CGPoint :: struct { x: f64; y: f64 } → {CGPoint=dd}
    const cgpoint_name = module.types.internString("CGPoint");
    const cgpoint_x_name = module.types.internString("x");
    const cgpoint_y_name = module.types.internString("y");
    const cgpoint_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
        .{ .name = cgpoint_x_name, .ty = .f64 },
        .{ .name = cgpoint_y_name, .ty = .f64 },
    };
    const cgpoint = module.types.intern(.{ .@"struct" = .{ .name = cgpoint_name, .fields = &cgpoint_fields } });

    // `-(void)setOrigin:(CGPoint)p` → `v@:{CGPoint=dd}`
    const e1 = try lowering.objcTypeEncodingFromSignature(.void, &.{cgpoint}, null);
    defer alloc.free(e1);
    try std.testing.expectEqualStrings("v@:{CGPoint=dd}", e1);

    // `-(CGPoint)origin` → `{CGPoint=dd}@:`
    const e2 = try lowering.objcTypeEncodingFromSignature(cgpoint, &.{}, null);
    defer alloc.free(e2);
    try std.testing.expectEqualStrings("{CGPoint=dd}@:", e2);

    // NSRange ({u64 location; u64 length}) → {_NSRange=QQ} (Apple uses
    // the underscore-prefixed internal name in practice, but we faithfully
    // emit whatever the struct is registered as).
    const nsrange_name = module.types.internString("_NSRange");
    const loc_name = module.types.internString("location");
    const len_name = module.types.internString("length");
    const nsrange_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
        .{ .name = loc_name, .ty = .u64 },
        .{ .name = len_name, .ty = .u64 },
    };
    const nsrange = module.types.intern(.{ .@"struct" = .{ .name = nsrange_name, .fields = &nsrange_fields } });
    const e3 = try lowering.objcTypeEncodingFromSignature(nsrange, &.{ nsrange, .s64 }, null);
    defer alloc.free(e3);
    try std.testing.expectEqualStrings("{_NSRange=QQ}@:{_NSRange=QQ}q", e3);
}

test "lower: objcTypeEncodingFromSignature emits nested structs (CGRect)" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // CGPoint and CGSize, both {f64, f64}.
    const cgpoint_name = module.types.internString("CGPoint");
    const cgsize_name = module.types.internString("CGSize");
    const x_name = module.types.internString("x");
    const y_name = module.types.internString("y");
    const w_name = module.types.internString("width");
    const h_name = module.types.internString("height");

    const cgpoint_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
        .{ .name = x_name, .ty = .f64 },
        .{ .name = y_name, .ty = .f64 },
    };
    const cgsize_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
        .{ .name = w_name, .ty = .f64 },
        .{ .name = h_name, .ty = .f64 },
    };
    const cgpoint = module.types.intern(.{ .@"struct" = .{ .name = cgpoint_name, .fields = &cgpoint_fields } });
    const cgsize = module.types.intern(.{ .@"struct" = .{ .name = cgsize_name, .fields = &cgsize_fields } });

    // CGRect :: struct { origin: CGPoint; size: CGSize } →
    // {CGRect={CGPoint=dd}{CGSize=dd}}
    const cgrect_name = module.types.internString("CGRect");
    const origin_name = module.types.internString("origin");
    const size_name = module.types.internString("size");
    const cgrect_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
        .{ .name = origin_name, .ty = cgpoint },
        .{ .name = size_name, .ty = cgsize },
    };
    const cgrect = module.types.intern(.{ .@"struct" = .{ .name = cgrect_name, .fields = &cgrect_fields } });

    // `-(CGRect)frame` → `{CGRect={CGPoint=dd}{CGSize=dd}}@:`
    const e1 = try lowering.objcTypeEncodingFromSignature(cgrect, &.{}, null);
    defer alloc.free(e1);
    try std.testing.expectEqualStrings("{CGRect={CGPoint=dd}{CGSize=dd}}@:", e1);

    // `-(void)setFrame:(CGRect)f` round-trip.
    const e2 = try lowering.objcTypeEncodingFromSignature(.void, &.{cgrect}, null);
    defer alloc.free(e2);
    try std.testing.expectEqualStrings("v@:{CGRect={CGPoint=dd}{CGSize=dd}}", e2);
}

// ── Pack projection name resolution (Feature 1, Step 2.2) ────────────

const errors = @import("../errors.zig");

fn typeKeyword(alloc: std.mem.Allocator, name: []const u8) *Node {
    const n = alloc.create(Node) catch unreachable;
    n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = name, .is_generic = false } } };
    return n;
}

fn protoMethod(name: []const u8) ast.ProtocolMethodDecl {
    return .{ .name = name, .params = &.{}, .param_names = &.{}, .return_type = null, .default_body = null };
}

test "pack projection: type-arg vs method namespace lookups" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Wrap :: protocol(Target: Type) { wrap :: () -> Target; value :: () -> Target; }
    const type_kw = typeKeyword(alloc, "Type");
    defer alloc.destroy(type_kw);
    const type_params = [_]ast.StructTypeParam{.{ .name = "Target", .constraint = type_kw }};
    const methods = [_]ast.ProtocolMethodDecl{ protoMethod("wrap"), protoMethod("value") };
    const pd = ast.ProtocolDecl{ .name = "Wrap", .methods = &methods, .type_params = &type_params };
    lowering.registerProtocolDecl(&pd);

    // type-arg namespace
    try std.testing.expectEqual(@as(?u32, 0), lowering.lookupProtocolArg("Wrap", "Target"));
    try std.testing.expectEqual(@as(?u32, null), lowering.lookupProtocolArg("Wrap", "wrap"));
    try std.testing.expectEqual(@as(?u32, null), lowering.lookupProtocolArg("Nope", "Target"));

    // method (runtime-accessor) namespace
    try std.testing.expectEqual(@as(?u32, 0), lowering.lookupProtocolField("Wrap", "wrap"));
    try std.testing.expectEqual(@as(?u32, 1), lowering.lookupProtocolField("Wrap", "value"));
    try std.testing.expectEqual(@as(?u32, null), lowering.lookupProtocolField("Wrap", "Target"));
}

test "pack projection: position-driven resolution (Decision 4)" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    const type_kw = typeKeyword(alloc, "Type");
    defer alloc.destroy(type_kw);
    const type_params = [_]ast.StructTypeParam{.{ .name = "Target", .constraint = type_kw }};
    const methods = [_]ast.ProtocolMethodDecl{protoMethod("wrap")};
    const pd = ast.ProtocolDecl{ .name = "Wrap", .methods = &methods, .type_params = &type_params };
    lowering.registerProtocolDecl(&pd);

    // type position consults type-args only
    try std.testing.expectEqual(Lowering.PackProjection{ .type_arg = 0 }, lowering.resolvePackProjection("Wrap", "Target", .type_position));
    try std.testing.expectEqual(Lowering.PackProjection.not_found, lowering.resolvePackProjection("Wrap", "wrap", .type_position));

    // value position consults methods only — no cross-namespace fallback
    try std.testing.expectEqual(Lowering.PackProjection{ .method = 0 }, lowering.resolvePackProjection("Wrap", "wrap", .value_position));
    try std.testing.expectEqual(Lowering.PackProjection.not_found, lowering.resolvePackProjection("Wrap", "Target", .value_position));
}

test "pack projection: same-name type-arg + method warns (Decision 4)" {
    // Arena: DiagnosticList.addFmt allocates messages it never frees in deinit
    // (mixed ownership with borrowed literals) — an arena keeps the leak
    // checker clean without changing diagnostic semantics.
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const alloc = arena.allocator();
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var diags = errors.DiagnosticList.init(alloc, "", "test.sx");
    defer diags.deinit();

    var lowering = Lowering.init(&module);
    lowering.diagnostics = &diags;

    // A protocol whose type-arg and method share the name `value`.
    const type_kw = typeKeyword(alloc, "Type");
    defer alloc.destroy(type_kw);
    const type_params = [_]ast.StructTypeParam{.{ .name = "value", .constraint = type_kw }};
    const methods = [_]ast.ProtocolMethodDecl{protoMethod("value")};
    const pd = ast.ProtocolDecl{ .name = "Shadowy", .methods = &methods, .type_params = &type_params };
    lowering.registerProtocolDecl(&pd);

    var warned = false;
    for (diags.items.items) |d| {
        if (d.level == .warn and std.mem.indexOf(u8, d.message, "type-arg and method both named 'value'") != null) warned = true;
    }
    try std.testing.expect(warned);

    // Position still resolves deterministically despite the shadow.
    try std.testing.expectEqual(Lowering.PackProjection{ .type_arg = 0 }, lowering.resolvePackProjection("Shadowy", "value", .type_position));
    try std.testing.expectEqual(Lowering.PackProjection{ .method = 0 }, lowering.resolvePackProjection("Shadowy", "value", .value_position));
}

test "E1.4b converge inferred error sets: empty -> warning, raising -> converged set" {
    // The empty-inferred warning isn't user-visible yet (the compile driver
    // only renders diagnostics on failure — a LANG follow-up), so validate the
    // SCC's emission + set computation directly on the DiagnosticList.
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const alloc = arena.allocator();
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var diags = errors.DiagnosticList.init(alloc, "", "test.sx");
    defer diags.deinit();

    var lowering = Lowering.init(&module);
    lowering.diagnostics = &diags;

    // stub :: () -> ! { return; }   — bare `!`, never raises.
    const stub_rt = alloc.create(Node) catch unreachable;
    stub_rt.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .error_type_expr = .{ .name = null } } };
    const stub_ret = alloc.create(Node) catch unreachable;
    stub_ret.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .return_stmt = .{ .value = null } } };
    const stub_body = alloc.create(Node) catch unreachable;
    const stub_stmts: []const *Node = &.{stub_ret};
    stub_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = stub_stmts } } };
    const stub_fd = ast.FnDecl{ .name = "stub", .params = &.{}, .return_type = stub_rt, .body = stub_body };

    // raiser :: () -> ! { raise error.Foo; }   — bare `!`, raises Foo.
    const r_rt = alloc.create(Node) catch unreachable;
    r_rt.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .error_type_expr = .{ .name = null } } };
    const r_err = alloc.create(Node) catch unreachable;
    r_err.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "error" } } };
    const r_fa = alloc.create(Node) catch unreachable;
    r_fa.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .field_access = .{ .object = r_err, .field = "Foo" } } };
    const r_raise = alloc.create(Node) catch unreachable;
    r_raise.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .raise_stmt = .{ .tag = r_fa } } };
    const r_body = alloc.create(Node) catch unreachable;
    const r_stmts: []const *Node = &.{r_raise};
    r_body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = r_stmts } } };
    const raiser_fd = ast.FnDecl{ .name = "raiser", .params = &.{}, .return_type = r_rt, .body = r_body };

    lowering.program_index.fn_ast_map.put("stub", &stub_fd) catch unreachable;
    lowering.program_index.fn_ast_map.put("raiser", &raiser_fd) catch unreachable;

    lowering.convergeInferredErrorSets();

    // raiser converges to {Foo} (non-empty); stub to ∅.
    try std.testing.expectEqual(@as(usize, 1), (lowering.inferred_error_sets.get("raiser") orelse unreachable).len);
    try std.testing.expectEqual(@as(usize, 0), (lowering.inferred_error_sets.get("stub") orelse unreachable).len);

    // The empty-set (stub) warns; the raising one does not.
    var stub_warned = false;
    var raiser_warned = false;
    for (diags.items.items) |d| {
        if (d.level != .warn) continue;
        if (std.mem.indexOf(u8, d.message, "stub") != null) stub_warned = true;
        if (std.mem.indexOf(u8, d.message, "raiser") != null) raiser_warned = true;
    }
    try std.testing.expect(stub_warned);
    try std.testing.expect(!raiser_warned);
}

test "E1.4c noreturn typing: divergence shapes + if-else unification + block propagation" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    const mk = struct {
        fn node(a: std.mem.Allocator, data: ast.Node.Data) *Node {
            const n = a.create(Node) catch unreachable;
            n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = data };
            return n;
        }
    };

    // return; / break; / continue; / raise error.X  → noreturn
    const ret = mk.node(alloc, .{ .return_stmt = .{ .value = null } });
    defer alloc.destroy(ret);
    const brk = mk.node(alloc, .{ .break_expr = {} });
    defer alloc.destroy(brk);
    const cont = mk.node(alloc, .{ .continue_expr = {} });
    defer alloc.destroy(cont);
    const err_id = mk.node(alloc, .{ .identifier = .{ .name = "error" } });
    defer alloc.destroy(err_id);
    const fa = mk.node(alloc, .{ .field_access = .{ .object = err_id, .field = "X" } });
    defer alloc.destroy(fa);
    const raise = mk.node(alloc, .{ .raise_stmt = .{ .tag = fa } });
    defer alloc.destroy(raise);

    try std.testing.expectEqual(TypeId.noreturn, lowering.inferExprType(ret));
    try std.testing.expectEqual(TypeId.noreturn, lowering.inferExprType(brk));
    try std.testing.expectEqual(TypeId.noreturn, lowering.inferExprType(cont));
    try std.testing.expectEqual(TypeId.noreturn, lowering.inferExprType(raise));

    // Block whose last statement diverges → noreturn.
    const five = mk.node(alloc, .{ .int_literal = .{ .value = 5 } });
    defer alloc.destroy(five);
    const blk_stmts: []const *Node = &.{ five, ret };
    const blk = mk.node(alloc, .{ .block = .{ .stmts = blk_stmts, .produces_value = true } });
    defer alloc.destroy(blk);
    try std.testing.expectEqual(TypeId.noreturn, lowering.inferExprType(blk));

    // if-else with one diverging branch unifies to the other branch's type;
    // both diverging → noreturn.
    const lit = mk.node(alloc, .{ .int_literal = .{ .value = 1 } });
    defer alloc.destroy(lit);
    const then_div = mk.node(alloc, .{ .if_expr = .{ .condition = lit, .then_branch = ret, .else_branch = lit, .is_inline = false } });
    defer alloc.destroy(then_div);
    try std.testing.expectEqual(TypeId.s64, lowering.inferExprType(then_div)); // then diverges → else (s64)

    const else_div = mk.node(alloc, .{ .if_expr = .{ .condition = lit, .then_branch = lit, .else_branch = ret, .is_inline = false } });
    defer alloc.destroy(else_div);
    try std.testing.expectEqual(TypeId.s64, lowering.inferExprType(else_div)); // then is s64

    const both_div = mk.node(alloc, .{ .if_expr = .{ .condition = lit, .then_branch = ret, .else_branch = brk, .is_inline = false } });
    defer alloc.destroy(both_div);
    try std.testing.expectEqual(TypeId.noreturn, lowering.inferExprType(both_div));
}

// ── A4.2 test-first scaffolding: protocol-decl registration ──────────
// Lock `registerProtocolDecl`'s method-table output (consumed by protocol
// dispatch + impl planning) before the protocol/impl lookup moves to
// `src/ir/protocols.zig`. Public surface only (registerProtocolDecl +
// getProtocolInfo are pub) — the impl-lookup / conversion plan tests land
// with the registry in sub-step 2 (as A4.1's internal tests landed with
// GenericResolver). Arena: a non-parameterized protocol dupes its method
// infos via the module allocator and never frees them.

test "protocols: registerProtocolDecl builds the dispatch method table" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const alloc = arena.allocator();
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var lowering = Lowering.init(&module);

    // Shape :: protocol { area :: () -> f64; scaled :: (factor: f64) -> Self; }
    const methods = [_]ast.ProtocolMethodDecl{
        .{ .name = "area", .params = &.{}, .param_names = &.{}, .return_type = typeKeyword(alloc, "f64"), .default_body = null },
        .{
            .name = "scaled",
            .params = &[_]*Node{typeKeyword(alloc, "f64")},
            .param_names = &[_][]const u8{"factor"},
            .return_type = typeKeyword(alloc, "Self"),
            .default_body = null,
        },
    };
    const pd = ast.ProtocolDecl{ .name = "Shape", .methods = &methods };
    lowering.registerProtocolDecl(&pd);

    // getProtocolInfo resolves the registered protocol struct by type.
    const shape_ty = module.types.findByName(module.types.internString("Shape")).?;
    const info = lowering.getProtocolInfo(shape_ty).?;
    try std.testing.expectEqual(@as(usize, 2), info.methods.len);

    // area :: () -> f64 — no params (self excluded), concrete f64 return.
    try std.testing.expectEqualStrings("area", info.methods[0].name);
    try std.testing.expectEqual(@as(usize, 0), info.methods[0].param_types.len);
    try std.testing.expectEqual(TypeId.f64, info.methods[0].ret_type);
    try std.testing.expect(!info.methods[0].ret_is_self);

    // scaled :: (factor: f64) -> Self — one f64 param; `Self` return is
    // flagged and encoded as `*void` (the dispatcher auto-unboxes it).
    try std.testing.expectEqualStrings("scaled", info.methods[1].name);
    try std.testing.expectEqual(@as(usize, 1), info.methods[1].param_types.len);
    try std.testing.expectEqual(TypeId.f64, info.methods[1].param_types[0]);
    try std.testing.expect(info.methods[1].ret_is_self);
    try std.testing.expectEqual(module.types.ptrTo(.void), info.methods[1].ret_type);
}

// ── A4.3 test-first scaffolding: coercion planning ───────────────────
// Lock the one coercion-plan decision reachable via the existing public
// surface — the optional wrap/flatten rule — before coercion planning moves to
// `src/ir/conversions.zig`. The lowerXX / coerceToType / coerceOrErase /
// buildProtocolErasure decisions are private + emission-bound, so their
// CoercionPlan unit tests land with the extracted module in sub-step 2 (as the
// generics/protocols plan tests landed with their modules); behavior is locked
// here by the new `.ir` snapshots.

test "conversions: optionalOfFlattened wraps once, flattening a nested optional" {
    const alloc = std.testing.allocator;
    var module = ir_mod.Module.init(alloc);
    defer module.deinit();
    var l = Lowering.init(&module);

    const opt_s64 = module.types.optionalOf(.s64);
    // Wrap a non-optional: T -> ?T.
    try std.testing.expectEqual(opt_s64, l.optionalOfFlattened(.s64));
    // Wrap an already-optional FLATTENS: ?T -> ?T (the coercion never builds ??T).
    try std.testing.expectEqual(opt_s64, l.optionalOfFlattened(opt_s64));
    // Contrast: the plain wrap does NOT flatten — ?T -> ??T (distinct type).
    try std.testing.expect(module.types.optionalOf(opt_s64) != opt_s64);
}
