// codegen.v — bytecode code generator for VuurRaaf.
//
// Compiles a parsed program into a VROBJ object file: flat bytecode plus a
// symbol per function and a relocation per call site. Call targets are left as
// relocations and resolved by the linker, so functions may live in other files.
module compiler

import obj

struct Fixup {
	name string
	off  u32
}

// LoopCtx records where `break` and `continue` should jump while generating
// the body of a loop. For `for` loops `continue` targets the increment, not
// the condition check, so the loop variable still advances.
struct LoopCtx {
	break_l    string
	continue_l string
}

struct Gen {
mut:
	code      []u8
	strings   []string
	str_map   map[string]int
	symbols   []obj.Symbol
	relocs    []obj.Reloc
	locals    map[string]int
	types     map[string]string      // local name -> declared struct type ('' = unknown)
	structs   map[string][]string    // declared struct name -> field list
	enums     map[string][]string    // enum name -> variant list
	lam_counter int // anonymous function counter
	enum_vals map[string]int         // 'Enum.variant' -> integer value
	consts    map[string]i64         // constant name -> integer value
	lines     []obj.LineInfo         // code offset -> source line (debug info)
	local_cnt int
	argc      int
	cur_fn    string
	labels    map[string]int
	fixups    []Fixup
	loops     []LoopCtx
	enter_off u32
	next_lbl  int
	modules   map[string]bool // imported module names (bare `import os`)
	captures  []string        // enclosing locals captured by the closure being compiled
}

fn gen(prog Program) !obj.Obj {
	mut g := Gen{}
	// register enums first so their values are available everywhere
	for ed in prog.enums {
		if ed.name in g.enums {
			return error('duplicate enum declaration "${ed.name}"')
		}
		g.enums[ed.name] = ed.variants
		for i, v in ed.variants {
			g.enum_vals['${ed.name}.${v}'] = i
		}
	}
	// register constants
	for cd in prog.consts {
		if cd.name in g.consts {
			return error('duplicate constant declaration "${cd.name}"')
		}
		// constants must be compile-time integer expressions
		if cd.value.kind == .int_lit {
			g.consts[cd.name] = cd.value.int_v
		} else if cd.value.kind == .bool_lit {
			g.consts[cd.name] = cd.value.int_v
		} else {
			return error('constant "${cd.name}" must be an integer or boolean literal (line ${cd.line})')
		}
	}
	// register struct declarations
	for sd in prog.structs {
		if sd.name in g.structs {
			return error('duplicate struct declaration "${sd.name}"')
		}
		g.structs[sd.name] = sd.fields
	}
	// compile imported files and merge their objects. Bare module imports
	// (`import os`) prefix the module's function symbols and internal call
	// relocations with "os.", so programs call os.exists(...) and modules can
	// never collide with each other or with the program's own functions.
	for imp in prog.imports {
		mod_name := imp.name
		prefix := if mod_name.len > 0 { mod_name + '.' } else { '' }
		imported := compile_file(resolve_import(imp.path)!)!
		if mod_name.len > 0 {
			g.modules[mod_name] = true
		}
		// the imported object's own symbol names (for rewriting call sites)
		mut own := map[string]bool{}
		for s in imported.symbols {
			own[s.name] = true
		}
		// append imported bytecode first so symbol entries can be rebased
		code_off := g.code.len
		g.code << imported.code
		// merge symbols from the imported object (prefixed and rebased: entries
		// are relative to the imported code, which now sits at code_off)
		for s in imported.symbols {
			g.symbols << obj.Symbol{ name: prefix + s.name, entry: code_off + s.entry }
		}
		// merge strings
		for s in imported.strings {
			g.strings << s
		}
		// adjust relocations (and prefix module-internal call targets)
		for r in imported.relocs {
			mut rname := r.name
			if r.kind == 0 && prefix.len > 0 && r.name in own {
				rname = prefix + r.name
			}
			g.relocs << obj.Reloc{ offset: u32(code_off) + r.offset, name: rname, kind: r.kind }
		}
		// merge debug info, rebasing offsets into this object's code space
		for l in imported.lines {
			g.lines << obj.LineInfo{ off: u32(code_off) + l.off, line: l.line }
		}
	}
	for fd in prog.fns {
		g.captures = []string{} // top-level functions capture nothing
		g.gen_fn(fd)!
	}
	return obj.Obj{
		symbols: g.symbols
		strings: g.strings
		code:    g.code
		relocs:  g.relocs
		lines:   g.lines
	}
}

fn (mut g Gen) gen_fn(fd FnDecl) ! {
	// methods compile to functions named `Type.method`; the receiver is the
	// implicit first argument, so `p.dist(x)` becomes `call Point.dist p, x`
	sym := if fd.recv_type.len > 0 { '${fd.recv_type}.${fd.name}' } else { fd.name }
	g.cur_fn = sym
	g.symbols << obj.Symbol{ name: sym, entry: g.code.len }
	g.lines << obj.LineInfo{ off: u32(g.code.len), line: fd.line }
	g.locals.clear()
	g.types.clear()
	g.local_cnt = 0
	// closure captures occupy the leading local slots (filled by the caller's
	// op_call_closure), then the receiver (methods), then the parameters
	g.argc = fd.params.len + g.captures.len + if fd.recv_type.len > 0 { 1 } else { 0 }
	mut next := 0
	if g.captures.len > 0 {
		for i, c in g.captures {
			g.locals[c] = i
		}
		next = g.captures.len
	} else if fd.recv_type.len > 0 {
		g.locals[fd.recv_name] = 0
		g.types[fd.recv_name] = fd.recv_type
		next = 1
	}
	// a variadic parameter does not occupy an argument slot; it gets a fresh
	// local that the prologue fills with the collected vararg array
	if fd.variadic {
		g.argc--
	}
	for i, p in fd.params {
		if fd.variadic && i == fd.params.len - 1 {
			continue
		}
		g.locals[p] = i + next
	}
	g.local_cnt = g.argc
	if fd.variadic {
		vidx := g.local_cnt
		g.local_cnt++
		g.locals[fd.params[fd.params.len - 1]] = vidx
	}
	// `enter n` reserves the non-parameter locals; n is patched once the body
	// has been scanned.
	g.code << op_enter
	g.enter_off = u32(g.code.len)
	g.code << obj.encode_i64(0)
	// default parameter values: if the caller passed fewer args than this
	// param's slot, evaluate the default and store it
	for i, p in fd.params {
		if fd.variadic && i == fd.params.len - 1 {
			continue
		}
		if i >= fd.has_defs.len || !fd.has_defs[i] {
			continue
		}
		slot := i + next
		skip_l := g.new_label()
		g.code << op_argc
		g.code << op_push_i
		g.code << obj.encode_i64(i64(slot))
		g.code << op_le
		g.code << op_jz
		g.code << obj.encode_i64(0)
		g.fixups << Fixup{ name: skip_l, off: u32(g.code.len) - 8 }
		g.gen_expr(fd.defaults[i])!
		g.emit_store(slot)
		g.emit_label(skip_l)
	}
	// variadic collection: build an array from args[argc..actual-1]
	if fd.variadic {
		vidx := g.locals[fd.params[fd.params.len - 1]] or {
			return error('internal: variadic param missing')
		}
		g.code << op_varargs
		g.code << obj.encode_i64(i64(g.argc))
		g.code << obj.encode_i64(i64(vidx))
	}
	for st in fd.body {
		g.gen_stmt(st)!
	}
	g.code << op_ret // trailing return for fall-through
	// reserve all local slots: the callee may be called with fewer arguments
	// than declared (default parameters) or more (variadic), so the frame must
	// always cover slots 0..local_cnt-1
	obj.patch_i64(mut g.code, g.enter_off, i64(g.local_cnt))
	// resolve intra-function jump targets. Targets are encoded PC-relative
	// (delta from the end of the 8-byte operand), so bytecode stays
	// position-independent when module objects are merged or linked.
	for f in g.fixups {
		target := g.labels[f.name] or {
			return error('internal error: unresolved label ${f.name} in fn ${fd.name}')
		}
		obj.patch_i64(mut g.code, f.off, i64(target - (int(f.off) + 8)))
	}
	g.fixups.clear()
	g.labels.clear()
	g.cur_fn = ''
}

fn (mut g Gen) gen_stmt(st Stmt) ! {
	g.lines << obj.LineInfo{ off: u32(g.code.len), line: st.line }
	match st.kind {
		.expr_stmt {
			g.gen_expr(st.expr)!
			// print/println already consume their value; everything else
			// leaves one on the stack that must be discarded
			if st.expr.kind == .call && (st.expr.name == 'print' || st.expr.name == 'println') {
				// nothing to discard
			} else {
				g.code << op_pop
			}
		}
		.let_stmt {
			g.gen_expr(st.expr)!
			idx := g.local_cnt
			g.local_cnt++
			g.locals[st.target] = idx
			g.types[st.target] = g.expr_type(st.expr)
			g.code << op_store
			g.code << obj.encode_i64(i64(idx))
		}
		.destruct_stmt {
			// let { a, b } = e  →  tmp := e; a := tmp.a; b := tmp.b
			// let [a, b] = e   →  tmp := e; a := tmp[0]; b := tmp[1]
			tmp_idx := g.new_local()
			g.gen_expr(st.expr)!
			g.emit_store(tmp_idx)
			for i, name in st.destruct_targets {
				g.emit_load(tmp_idx)
				if st.destruct_field {
					g.emit_field_name(name)
					g.code << op_sget
				} else {
					g.code << op_push_i
					g.code << obj.encode_i64(i64(i))
					g.code << op_aget
				}
				idx := g.new_local()
				g.locals[name] = idx
				g.types.delete(name)
				g.emit_store(idx)
			}
		}
		.assign_stmt {
			idx := g.locals[st.target] or {
				return error('unknown variable "${st.target}" at line ${st.line}')
			}
			g.gen_expr(st.expr)!
			g.types[st.target] = g.expr_type(st.expr)
			g.code << op_store
			g.code << obj.encode_i64(i64(idx))
		}
		.index_assign {
			// if the index is a string literal, use struct field set (map style)
			if st.idx.kind == .str_lit {
				g.gen_expr(st.base)!
				g.gen_expr(st.expr)!
				g.emit_field_name(st.idx.str_v)
				g.code << op_sset
			} else {
				g.gen_expr(st.base)!
				g.gen_expr(st.idx)!
				g.gen_expr(st.expr)!
				g.code << op_aset
			}
		}
		.field_assign {
			// a.b = v  →  a, v, "b"  sset   (field name on top of the stack)
			g.gen_expr(st.base)!
			g.gen_expr(st.expr)!
			g.emit_field_name(st.target)
			g.code << op_sset
		}
		.if_stmt {
			else_l := g.new_label()
			end_l := g.new_label()
			g.gen_expr(st.cond)!
			g.code << op_jz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: else_l, off: u32(g.code.len) - 8 }
			for s in st.body {
				g.gen_stmt(s)!
			}
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.emit_label(else_l)
			for s in st.els {
				g.gen_stmt(s)!
			}
			g.emit_label(end_l)
		}
		.match_stmt {
			// match x { v1 {..} v2 {..} else {..} }  →  subject := x; a chain of
			// equality tests jumping to the matching arm; else falls through.
			subj_idx := g.new_local()
			end_l := g.new_label()
			g.gen_expr(st.expr)!
			g.emit_store(subj_idx)
			for i, arm in st.arms {
				next_l := g.new_label()
				g.emit_load(subj_idx)
				g.gen_expr(arm.val)!
				g.code << op_eq
				g.code << op_jz
				g.code << obj.encode_i64(0)
				g.fixups << Fixup{ name: next_l, off: u32(g.code.len) - 8 }
				for s in arm.body {
					g.gen_stmt(s)!
				}
				g.code << op_jmp
				g.code << obj.encode_i64(0)
				g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
				g.emit_label(next_l)
				if i == st.arms.len - 1 && !st.has_else {
					// no else: fall through to the end label
					g.emit_label(end_l)
				}
			}
			if st.has_else {
				for s in st.els_body {
					g.gen_stmt(s)!
				}
				g.emit_label(end_l)
			}
		}
		.while_stmt {
			loop_l := g.new_label()
			end_l := g.new_label()
			g.emit_label(loop_l)
			g.gen_expr(st.cond)!
			g.code << op_jz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.loops << LoopCtx{ break_l: end_l, continue_l: loop_l }
			for s in st.body {
				g.gen_stmt(s)!
			}
			g.loops.delete_last()
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: loop_l, off: u32(g.code.len) - 8 }
			g.emit_label(end_l)
		}
		.for_range_stmt {
			// for i in a..b / for i in a...b  →  i := a; while i <(<=) b { body; i++ }
			var_idx := g.new_local()
			bound_idx := g.new_local()
			loop_l := g.new_label()
			inc_l := g.new_label()
			end_l := g.new_label()
			g.gen_expr(st.expr)!
			g.gen_expr(st.cond)!
			g.emit_store(bound_idx)
			g.emit_store(var_idx)
			g.emit_label(loop_l)
			g.emit_load(var_idx)
			g.emit_load(bound_idx)
			g.code << if st.inclusive { op_le } else { op_lt }
			g.code << op_jz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.loops << LoopCtx{ break_l: end_l, continue_l: inc_l }
			prev := g.locals[st.target] or { -1 }
			prev_t := g.types[st.target] or { '' }
			g.locals[st.target] = var_idx
			g.types.delete(st.target)
			for s in st.body {
				g.gen_stmt(s)!
			}
			if prev >= 0 {
				g.locals[st.target] = prev
			} else {
				g.locals.delete(st.target)
			}
			if prev_t.len > 0 {
				g.types[st.target] = prev_t
			}
			g.loops.delete_last()
			g.emit_label(inc_l)
			g.emit_load(var_idx)
			g.code << op_push_i
			g.code << obj.encode_i64(1)
			g.code << op_add
			g.emit_store(var_idx)
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: loop_l, off: u32(g.code.len) - 8 }
			g.emit_label(end_l)
		}
		.for_in_stmt {
			// for x in EnumType { ... }  →  iterate over enum variants as integers
			if st.expr.kind == .ident && st.expr.name in g.enums {
				g.gen_for_enum(st.target, st.expr.name, st.body, st.line)!
				return
			}
			// for x in arr  →  idx := 0; while idx < len(arr) { x := arr[idx]; body; idx++ }
			arr_idx := g.new_local()
			idx_idx := g.new_local()
			elem_idx := g.new_local()
			loop_l := g.new_label()
			inc_l := g.new_label()
			end_l := g.new_label()
			g.gen_expr(st.expr)!
			g.emit_store(arr_idx)
			g.code << op_push_i
			g.code << obj.encode_i64(0)
			g.emit_store(idx_idx)
			g.emit_label(loop_l)
			g.emit_load(idx_idx)
			g.emit_load(arr_idx)
			g.code << op_alen
			g.code << op_lt
			g.code << op_jz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.loops << LoopCtx{ break_l: end_l, continue_l: inc_l }
			g.emit_load(arr_idx)
			g.emit_load(idx_idx)
			g.code << op_aget
			g.emit_store(elem_idx)
			prev := g.locals[st.target] or { -1 }
			prev_t := g.types[st.target] or { '' }
			g.locals[st.target] = elem_idx
			g.types.delete(st.target)
			// bind the index variable if present (for i, v in arr)
			prev_idx := if st.idx_target.len > 0 { g.locals[st.idx_target] or { -1 } } else { -1 }
			prev_idx_t := if st.idx_target.len > 0 { g.types[st.idx_target] or { '' } } else { '' }
			if st.idx_target.len > 0 {
				g.locals[st.idx_target] = idx_idx
				g.types.delete(st.idx_target)
			}
			for s in st.body {
				g.gen_stmt(s)!
			}
			if st.idx_target.len > 0 {
				if prev_idx >= 0 {
					g.locals[st.idx_target] = prev_idx
				} else {
					g.locals.delete(st.idx_target)
				}
				if prev_idx_t.len > 0 {
					g.types[st.idx_target] = prev_idx_t
				}
			}
			if prev >= 0 {
				g.locals[st.target] = prev
			} else {
				g.locals.delete(st.target)
			}
			if prev_t.len > 0 {
				g.types[st.target] = prev_t
			}
			g.loops.delete_last()
			g.emit_label(inc_l)
			g.emit_load(idx_idx)
			g.code << op_push_i
			g.code << obj.encode_i64(1)
			g.code << op_add
			g.emit_store(idx_idx)
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: loop_l, off: u32(g.code.len) - 8 }
			g.emit_label(end_l)
		}
		.ret_stmt {
			if st.has_val {
				g.gen_expr(st.expr)!
				g.code << op_retv
			} else {
				g.code << op_ret
			}
		}
		.assert_stmt {
			g.gen_expr(st.expr)!
			g.code << op_assert
		}
		.break_stmt {
			if g.loops.len == 0 {
				return error('break outside of a loop (line ${st.line})')
			}
			ctx := g.loops[g.loops.len - 1]
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: ctx.break_l, off: u32(g.code.len) - 8 }
		}
		.continue_stmt {
			if g.loops.len == 0 {
				return error('continue outside of a loop (line ${st.line})')
			}
			ctx := g.loops[g.loops.len - 1]
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: ctx.continue_l, off: u32(g.code.len) - 8 }
		}
		.throw_stmt {
			g.gen_expr(st.expr)!
			g.code << op_throw
		}
		.try_stmt {
			catch_l := g.new_label()
			end_l := g.new_label()
			err_idx := g.new_local()
			g.code << op_try
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: catch_l, off: u32(g.code.len) - 8 }
			for s in st.body {
				g.gen_stmt(s)!
			}
			g.code << op_catch_done
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.emit_label(catch_l)
			g.code << op_store
			g.code << obj.encode_i64(i64(err_idx))
			prev := g.locals[st.target] or { -1 }
			prev_t := g.types[st.target] or { '' }
			g.locals[st.target] = err_idx
			g.types.delete(st.target)
			for s in st.els {
				g.gen_stmt(s)!
			}
			if prev >= 0 {
				g.locals[st.target] = prev
			} else {
				g.locals.delete(st.target)
			}
			if prev_t.len > 0 {
				g.types[st.target] = prev_t
			}
			g.emit_label(end_l)
		}
	}
}

fn (mut g Gen) gen_expr(e Expr) ! {
	match e.kind {
		.int_lit {
			g.code << op_push_i
			g.code << obj.encode_i64(e.int_v)
		}
		.float_lit {
			g.code << op_push_f
			g.code << obj.encode_f64(e.float_v)
		}
		.str_lit {
			// the index is a placeholder; the linker rebases it via a string
			// relocation so multi-file links keep working
			g.code << op_push_s
			g.code << obj.encode_i64(0)
			g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: e.str_v, kind: 1 }
		}
		.array_lit {
			for el in e.elems {
				g.gen_expr(el)!
			}
			g.code << op_mkarray
			g.code << obj.encode_i64(i64(e.elems.len))
		}
		.struct_lit {
			// typed literals validate their fields against the declaration
			// (an undeclared type name is allowed — it may live in another
			// file, where the same validation applies)
			if e.name.len > 0 && e.name in g.structs {
				decl_fields := g.structs[e.name]
				mut seen := map[string]bool{}
				for f in e.fields {
					if f.name !in decl_fields {
						return error('unknown field "${f.name}" for struct ${e.name} (line ${e.line})')
					}
					if f.name in seen {
						return error('duplicate field "${f.name}" in struct literal (line ${e.line})')
					}
					seen[f.name] = true
				}
			}
			// for each field: push the name string then the value; mkstruct n
			// pops the (name, value) pairs and builds the record
			for f in e.fields {
				g.emit_field_name(f.name)
				g.gen_expr(f.val)!
			}
			g.code << op_mkstruct
			g.code << obj.encode_i64(i64(e.fields.len))
		}
		.field {
			// check if it's an enum variant (e.g., Color.red)
			if e.left.kind == .ident {
				key := '${e.left.name}.${e.name}'
				if key in g.enum_vals {
					g.code << op_push_i
					g.code << obj.encode_i64(i64(g.enum_vals[key]))
					return
				}
			}
			g.gen_expr(*e.left)!
			g.emit_field_name(e.name)
			g.code << op_sget
		}
	.method_call {
		// module call: os.exists(x) — the receiver is an imported module name
		if e.left.kind == .ident && e.left.name in g.modules {
			for a in e.args {
				g.gen_expr(a)!
			}
			g.code << op_call
			g.code << obj.encode_i64(0) // placeholder — patched by the linker
			g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: '${e.left.name}.${e.name}', kind: 0 }
			g.code << obj.encode_i64(i64(e.args.len))
			return
		}
		// p.dist(x)  →  call <Type>.dist  p, x
		recv_t := g.method_receiver_type(e)
		// string methods: s.len(), s.to_upper(), s.contains(x), ... —
		// the receiver type is known when it is a literal or a local that
		// was assigned a string literal
		if recv_t == 'string' || e.left.kind == .str_lit {
				g.gen_expr(*e.left)!
				for a in e.args {
					g.gen_expr(a)!
				}
				g.code << op_str_method
				g.code << obj.encode_i64(0) // name placeholder — rebased by the linker
				g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: e.name, kind: 1 }
				g.code << obj.encode_i64(i64(e.args.len))
				return
			}
			// built-in: enum.to_string() generates a match on the integer value
			if e.name == 'to_string' && recv_t in g.enums && e.args.len == 0 {
				g.gen_enum_to_string(recv_t, *e.left, e.line)!
				return
			}
			// built-in: enum.count() returns the number of variants
			if e.name == 'count' && recv_t in g.enums && e.args.len == 0 {
				g.gen_expr(*e.left)!
				g.code << op_pop
				variants := g.enums[recv_t]
				g.code << op_push_i
				g.code << obj.encode_i64(i64(variants.len))
				return
			}
			g.gen_expr(*e.left)!
			for a in e.args {
				g.gen_expr(a)!
			}
			// if receiver type is known, emit a static method call
			if recv_t.len > 0 {
				g.code << op_call
				g.code << obj.encode_i64(0) // placeholder — patched by the linker
				g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: '${recv_t}.${e.name}', kind: 0 }
				g.code << obj.encode_i64(i64(e.args.len + 1)) // receiver + args
		} else {
			// unknown type: treat as closure call on a struct field
			g.emit_field_name(e.name)
			g.code << op_sget
			for a in e.args {
				g.gen_expr(a)!
			}
			g.code << op_call_closure
			g.code << obj.encode_i64(i64(e.args.len))
		}
		return
	}
		.index {
			// if the index is a string literal, use struct field access (map style)
			if e.right.kind == .str_lit {
				g.gen_expr(*e.left)!
				g.emit_field_name(e.right.str_v)
				g.code << op_sget
			} else {
				g.gen_expr(*e.left)!
				g.gen_expr(*e.right)!
				g.code << op_aget
			}
		}
		.slice {
			// arr[start..end]  →  push value, start, end; slice
			g.gen_expr(*e.left)!
			g.gen_expr(*e.right)!
			g.gen_expr(*e.extra)!
			g.code << op_slice
		}
		.anon_fn {
			g.lam_counter++
			name := '__lam_${g.lam_counter}'
			// find the enclosing locals the body references (its free
			// variables); they become this closure's captures
			caps := g.scan_captures(e.fn_body, e.fparams)
			// jump over the lambda body so callers don't fall through
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			skip_fix_off := u32(g.code.len) - 8
			fd := FnDecl{
				name: name
				params: e.fparams
				defaults: e.fdefaults
				has_defs: e.fhas_defs
				variadic: e.fvariadic
				body: e.fn_body
				line: e.line
			}
			// Save enclosing fixup/label/locals/type state; gen_fn clears them.
			// enter_off and argc are also per-function, so they must be restored
			// or the enclosing function's `enter n` patch is lost (locals would
			// then collide with the stack top).
			saved_fixups := g.fixups.clone()
			saved_labels := g.labels.clone()
			saved_locals := g.locals.clone()
			saved_types := g.types.clone()
			saved_local_cnt := g.local_cnt
			saved_enter_off := g.enter_off
			saved_argc := g.argc
			saved_captures := g.captures
			g.labels.clear()
			g.fixups = []Fixup{}
			g.captures = caps
			g.gen_fn(fd)!
			// Restore the enclosing state.
			g.fixups = saved_fixups
			g.labels = saved_labels.clone()
			g.locals = saved_locals.clone()
			g.types = saved_types.clone()
			g.local_cnt = saved_local_cnt
			g.enter_off = saved_enter_off
			g.argc = saved_argc
			g.captures = saved_captures
			// Patch the skip jump to land at the closure opcode we emit next
			// (PC-relative, like all other jump targets).
			obj.patch_i64(mut g.code, skip_fix_off, i64(g.code.len - (int(skip_fix_off) + 8)))
			// capture the enclosing locals' current values (capture by value)
			for cname in caps {
				g.emit_load(g.locals[cname])
			}
			g.code << op_closure
			g.code << obj.encode_i64(0)
			g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: name, kind: 0 }
			g.code << obj.encode_i64(i64(caps.len))
		}
		.bool_lit {
			g.code << op_push_i
			g.code << obj.encode_i64(e.int_v)
		}
		.none_lit {
			g.code << op_push_none
		}
		.ident {
			// check if it's a constant
			if e.name in g.consts {
				g.code << op_push_i
				g.code << obj.encode_i64(g.consts[e.name])
			} else if e.name in g.enum_vals {
				// check if it's an enum variant (e.g., Color.red)
				g.code << op_push_i
				g.code << obj.encode_i64(i64(g.enum_vals[e.name]))
			} else {
				idx := g.locals[e.name] or {
					return error('unknown variable "${e.name}" at line ${e.line}')
				}
				g.code << op_load
				g.code << obj.encode_i64(i64(idx))
			}
		}
		.unary {
			// constant-fold unary ops on literals: -5, -2.5, not true, ~7
			if e.right.kind == .int_lit && (e.op == .minus || e.op == .tilde) {
				v := e.right.int_v
				res := if e.op == .minus { -v } else { ~v }
				g.code << op_push_i
				g.code << obj.encode_i64(res)
				return
			}
			if e.right.kind == .float_lit && e.op == .minus {
				g.code << op_push_f
				g.code << obj.encode_f64(-e.right.float_v)
				return
			}
			if e.right.kind == .bool_lit && e.op == .kw_not {
				g.code << op_push_i
				g.code << obj.encode_i64(if e.right.int_v == 0 { 1 } else { 0 })
				return
			}
			g.gen_expr(*e.right)!
			match e.op {
				.kw_not { g.code << op_not }
				.tilde { g.code << op_not_b }
				else { g.code << op_neg }
			}
		}
		.binary {
			g.gen_binary(e)!
		}
		.call {
			g.gen_call(e)!
		}
	}
}

fn (mut g Gen) gen_call(e Expr) ! {
	if e.name == 'print' || e.name == 'println' {
		if e.args.len != 1 {
			return error('${e.name}() takes exactly one argument (line ${e.line})')
		}
		g.gen_expr(e.args[0])!
		g.code << if e.name == 'print' { op_print } else { op_println }
		return
	}
	if e.name == 'len' {
		if e.args.len != 1 {
			return error('len() takes exactly one argument (line ${e.line})')
		}
		g.gen_expr(e.args[0])!
		g.code << op_alen
		return
	}
	if e.name == 'push' {
		if e.args.len != 2 {
			return error('push() takes exactly two arguments (line ${e.line})')
		}
		g.gen_expr(e.args[0])!
		g.gen_expr(e.args[1])!
		g.code << op_apush
		return
	}
	if e.name == 'has' {
		if e.args.len != 2 {
			return error('has() takes exactly two arguments (line ${e.line})')
		}
		g.gen_expr(e.args[0])!
		g.gen_expr(e.args[1])!
		g.code << op_shas
		return
	}
	if e.name == 'delete' {
		if e.args.len != 2 {
			return error('delete() takes exactly two arguments (line ${e.line})')
		}
		g.gen_expr(e.args[0])!
		g.gen_expr(e.args[1])!
		g.code << op_sdel
		return
	}
	if e.name == 'keys' {
		if e.args.len != 1 {
			return error('keys() takes exactly one argument (line ${e.line})')
		}
		g.gen_expr(e.args[0])!
		g.code << op_skeys
		return
	}
	// closure call: ident(args) where ident is a local holding a closure.
	// The local's value is pushed as the call sequence's first slot;
	// op_call_closure consumes it along with the args, leaving only the
	// result on the stack.
	if e.name in g.locals {
		g.gen_expr(Expr{ kind: .ident, name: e.name, line: e.line })!
		for a in e.args {
			g.gen_expr(a)!
		}
		g.code << op_call_closure
		g.code << obj.encode_i64(i64(e.args.len))
		return
	}
	// host builtins (file I/O, OS, math, collections) go through op_native
	bid, bargc := builtin_spec(e.name)
	if bid >= 0 {
		if e.args.len != bargc {
			return error('${e.name}() takes exactly ${bargc} argument(s) (line ${e.line})')
		}
		for a in e.args {
			g.gen_expr(a)!
		}
		g.code << op_native
		g.code << obj.encode_i64(i64(bid))
		g.code << obj.encode_i64(i64(bargc))
		return
	}
	for a in e.args {
		g.gen_expr(a)!
	}
	g.code << op_call
	g.code << obj.encode_i64(0) // placeholder — patched by the linker
	g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: e.name, kind: 0 }
	g.code << obj.encode_i64(i64(e.args.len)) // argc
}

// builtin_spec maps a builtin function name to its (native id, arg count).
// A negative id means the name is not a builtin (it is a user function).
fn builtin_spec(name string) (int, int) {
	return match name {
		'abs' { native_abs, 1 }
		'min' { native_min, 2 }
		'max' { native_max, 2 }
		'pow' { native_pow, 2 }
		'sqrt' { native_sqrt, 1 }
		'floor' { native_floor, 1 }
		'ceil' { native_ceil, 1 }
		'round' { native_round, 1 }
		'rand' { native_rand, 0 }
		'rand_int' { native_rand_int, 1 }
		'int' { native_int, 1 }
		'str' { native_str, 1 }
		'float' { native_float, 1 }
		'type' { native_type, 1 }
		'split' { native_split, 2 }
		'join' { native_join, 2 }
		'contains' { native_contains, 2 }
		'starts_with' { native_starts_with, 2 }
		'ends_with' { native_ends_with, 2 }
		'trim' { native_trim, 1 }
		'lower' { native_lower, 1 }
		'upper' { native_upper, 1 }
		'pop' { native_pop, 1 }
		'insert' { native_insert, 3 }
		'remove' { native_remove, 2 }
		'sort' { native_sort, 1 }
		'clone' { native_clone, 1 }
		'reverse' { native_reverse, 1 }
		'index_of' { native_index_of, 2 }
		'args' { native_args, 0 }
		'getenv' { native_getenv, 1 }
		'setenv' { native_setenv, 2 }
		'exit' { native_exit, 1 }
		'time' { native_time, 0 }
		'sleep' { native_sleep, 1 }
		'read_file' { native_read_file, 1 }
		'write_file' { native_write_file, 2 }
		'eprint' { native_eprint, 1 }
		// build-module builtins (.vrmm) — see vm/native.v
		'build_compile' { native_build_compile, 2 }
		'build_assemble' { native_build_assemble, 2 }
		'build_link' { native_build_link, 2 }
		'build_run' { native_build_run, 1 }
		'build_test' { native_build_test, 1 }
		'build_bench' { native_build_bench, 2 }
		'build_clean' { native_build_clean, 0 }
		'build_exec' { native_build_exec, 1 }
		'build_exec_status' { native_build_exec_status, 1 }
		'build_exists' { native_build_exists, 1 }
		'build_mkdir' { native_build_mkdir, 1 }
		'build_rm' { native_build_rm, 1 }
		'build_copy' { native_build_copy, 2 }
		'build_glob' { native_build_glob, 1 }
		'build_ls' { native_build_ls, 1 }
		'build_base' { native_build_base, 1 }
		'build_dir' { native_build_dir, 1 }
		'build_join' { native_build_join, 2 }
		'build_root' { native_build_root, 0 }
		// stdlib: JSON + string formatting
		'json_encode' { native_json_encode, 1 }
		'json_decode' { native_json_decode, 1 }
		'format' { native_format, 2 }
		'replace' { native_replace, 3 }
		'split_lines' { native_split_lines, 1 }
		'pad' { native_pad, 2 }
		'pad_left' { native_pad_left, 2 }
		'repeat' { native_repeat, 2 }
		'build_is_dir' { native_build_is_dir, 1 }
		'cwd' { native_cwd, 0 }
		'json_pretty' { native_json_pretty, 1 }
		// HTTP client
		'http_get' { native_http_get, 1 }
		'http_post' { native_http_post, 2 }
		// date/time
		'now' { native_now, 0 }
		'time_ms' { native_time_ms, 0 }
		'format_time' { native_format_time, 2 }
		'parse_time' { native_parse_time, 1 }
		'weekday' { native_weekday, 1 }
		// regex
		'regex_match' { native_regex_match, 2 }
		'regex_find_all' { native_regex_find_all, 2 }
		'regex_replace' { native_regex_replace, 3 }
		'regex_split' { native_regex_split, 2 }
		// crypto/encoding
		'base64_encode' { native_base64_encode, 1 }
		'base64_decode' { native_base64_decode, 1 }
		'sha256' { native_sha256, 1 }
		'md5' { native_md5, 1 }
		'csv_parse' { native_csv_parse, 1 }
		// extended HTTP + path/process helpers
		'http_req' { native_http_req, 5 }
		'path_ext' { native_path_ext, 1 }
		'path_abs' { native_path_abs, 1 }
		'path_rel' { native_path_rel, 2 }
		'exec_full' { native_exec_full, 1 }
		else { -1, 0 }
	}
}

// fold_binary constant-folds binary expressions whose operands are both
// literals, emitting the precomputed constant. Returns false when the
// expression cannot be folded (leaving it to the runtime). Division/modulo by
// zero and out-of-range shifts are deliberately not folded so the runtime
// still reports them.
fn (mut g Gen) fold_binary(e Expr) bool {
	// integer folding
	if e.left.kind == .int_lit && e.right.kind == .int_lit {
		l := e.left.int_v
		r := e.right.int_v
		mut res := i64(0)
		match e.op {
			.plus { res = l + r }
			.minus { res = l - r }
			.star { res = l * r }
			.slash {
				if r == 0 {
					return false
				}
				res = l / r
			}
			.percent {
				if r == 0 {
					return false
				}
				res = l % r
			}
			.amp { res = l & r }
			.pipe { res = l | r }
			.caret { res = l ^ r }
			.lt_lt {
				if r < 0 || r > 63 {
					return false
				}
				res = l << u32(r)
			}
			.gt_gt {
				if r < 0 || r > 63 {
					return false
				}
				res = l >> u32(r)
			}
			.eq_eq { res = if l == r { 1 } else { 0 } }
			.not_eq { res = if l != r { 1 } else { 0 } }
			.lt { res = if l < r { 1 } else { 0 } }
			.le { res = if l <= r { 1 } else { 0 } }
			.gt { res = if l > r { 1 } else { 0 } }
			.ge { res = if l >= r { 1 } else { 0 } }
			else { return false }
		}
		g.code << op_push_i
		g.code << obj.encode_i64(res)
		return true
	}
	// float folding
	if e.left.kind == .float_lit && e.right.kind == .float_lit {
		l := e.left.float_v
		r := e.right.float_v
		mut res := 0.0
		mut is_bool := false
		mut bres := false
		match e.op {
			.plus { res = l + r }
			.minus { res = l - r }
			.star { res = l * r }
			.slash {
				if r == 0.0 {
					return false
				}
				res = l / r
			}
			.eq_eq { is_bool = true; bres = l == r }
			.not_eq { is_bool = true; bres = l != r }
			.lt { is_bool = true; bres = l < r }
			.le { is_bool = true; bres = l <= r }
			.gt { is_bool = true; bres = l > r }
			.ge { is_bool = true; bres = l >= r }
			else { return false }
		}
		if is_bool {
			g.code << op_push_i
			g.code << obj.encode_i64(if bres { 1 } else { 0 })
		} else {
			g.code << op_push_f
			g.code << obj.encode_f64(res)
		}
		return true
	}
	// string concatenation folding: "a" + "b" → one interned constant.
	// The string is emitted as a relocation so the linker interns it in the
	// final table, exactly like a plain string literal.
	if e.left.kind == .str_lit && e.right.kind == .str_lit && e.op == .plus {
		g.code << op_push_s
		g.code << obj.encode_i64(0) // placeholder — rebased by the linker
		g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: e.left.str_v + e.right.str_v, kind: 1 }
		return true
	}
	// boolean short-circuit folding: only when both sides are bool literals
	if e.left.kind == .bool_lit && e.right.kind == .bool_lit {
		if e.op == .kw_and {
			g.code << op_push_i
			g.code << obj.encode_i64(if e.left.int_v != 0 && e.right.int_v != 0 { 1 } else { 0 })
			return true
		}
		if e.op == .kw_or {
			g.code << op_push_i
			g.code << obj.encode_i64(if e.left.int_v != 0 || e.right.int_v != 0 { 1 } else { 0 })
			return true
		}
	}
	return false
}

fn (mut g Gen) gen_binary(e Expr) ! {
	if g.fold_binary(e) {
		return
	}
	match e.op {
		.kw_and {
			// a and b  →  short-circuit: if !a or !b then 0 else 1
			false_l := g.new_label()
			end_l := g.new_label()
			g.gen_expr(*e.left)!
			g.code << op_jz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: false_l, off: u32(g.code.len) - 8 }
			g.gen_expr(*e.right)!
			g.code << op_jz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: false_l, off: u32(g.code.len) - 8 }
			g.code << op_push_i
			g.code << obj.encode_i64(1)
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.emit_label(false_l)
			g.code << op_push_i
			g.code << obj.encode_i64(0)
			g.emit_label(end_l)
		}
		.kw_or {
			// a or b  →  short-circuit: if a or b then 1 else 0
			true_l := g.new_label()
			end_l := g.new_label()
			g.gen_expr(*e.left)!
			g.code << op_jnz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: true_l, off: u32(g.code.len) - 8 }
			g.gen_expr(*e.right)!
			g.code << op_jnz
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: true_l, off: u32(g.code.len) - 8 }
			g.code << op_push_i
			g.code << obj.encode_i64(0)
			g.code << op_jmp
			g.code << obj.encode_i64(0)
			g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
			g.emit_label(true_l)
			g.code << op_push_i
			g.code << obj.encode_i64(1)
			g.emit_label(end_l)
		}
		else {
			g.gen_expr(*e.left)!
			g.gen_expr(*e.right)!
			op := match e.op {
				.plus { op_add }
				.minus { op_sub }
				.star { op_mul }
				.slash { op_div }
				.percent { op_mod }
				.eq_eq { op_eq }
				.not_eq { op_ne }
				.lt { op_lt }
				.le { op_le }
				.gt { op_gt }
				.ge { op_ge }
				.amp { op_and_b }
				.pipe { op_or_b }
				.caret { op_xor }
				.lt_lt { op_shl }
				.gt_gt { op_shr }
				else {
					return error('unsupported binary operator at line ${e.line}')
				}
			}
			g.code << op
		}
	}
}

// expr_type returns the declared struct type of an expression when it is
// statically knowable: a typed literal `Point{...}`, a copy of a typed
// variable, or an enum variant `Enum.variant`. Everything else has no
// known type ('').
fn (mut g Gen) expr_type(e Expr) string {
	if e.kind == .str_lit {
		return 'string'
	}
	if e.kind == .struct_lit {
		return e.name
	}
	if e.kind == .ident {
		return g.types[e.name] or { '' }
	}
	// enum variant: Color.red  →  type is "Color"
	if e.kind == .field && e.left.kind == .ident {
		key := '${e.left.name}.${e.name}'
		if key in g.enum_vals {
			return e.left.name
		}
	}
	// slicing or indexing a known string yields a string
	if (e.kind == .slice || e.kind == .index) && g.expr_type(*e.left) == 'string' {
		return 'string'
	}
	// string concatenation: "a" + "b" (or anything + a string literal)
	if e.kind == .binary && e.op == .plus && (e.left.kind == .str_lit || e.right.kind == .str_lit) {
		return 'string'
	}
	// string-producing builtins typed as strings so method chains keep working
	if e.kind == .call {
		return match e.name {
			'upper', 'lower', 'trim', 'str', 'getenv', 'read_file', 'join' { 'string' }
			'build_compile', 'build_assemble', 'build_link', 'build_exec', 'build_base',
			'build_dir', 'build_join', 'build_root' { 'string' }
			'json_encode', 'format', 'replace', 'pad', 'pad_left', 'repeat' { 'string' }
			'cwd', 'json_pretty' { 'string' }
			else { '' }
		}
	}
	return ''
}

// method_receiver_type resolves the struct type a method call is made on.
// Returns '' when the type is statically unknown (at which point the
// call becomes a dynamic closure invocation via field access).
fn (mut g Gen) method_receiver_type(e Expr) string {
	recv := e.left
	if recv.kind == .ident {
		t := g.types[recv.name] or { '' }
		if t.len > 0 {
			return t
		}
	}
	// enum variant: Color.red  ->  type is "Color"
	if recv.kind == .field && recv.left.kind == .ident {
		key := '${recv.left.name}.${recv.name}'
		if key in g.enum_vals {
			return recv.left.name
		}
	}
	return ''
}

// gen_enum_to_string generates bytecode for `e.to_string()` on an enum value.
// It emits a match statement that maps each integer variant to its string name.
fn (mut g Gen) gen_enum_to_string(enum_name string, recv Expr, line int) ! {
	variants := g.enums[enum_name] or {
		return error('unknown enum "${enum_name}" at line ${line}')
	}
	// store the receiver in a temp local
	subj_idx := g.new_local()
	g.gen_expr(recv)!
	g.emit_store(subj_idx)
	// end label for the match
	end_l := g.new_label()
	for i, v in variants {
		next_l := g.new_label()
		// load subject, push variant integer, compare
		g.emit_load(subj_idx)
		g.code << op_push_i
		g.code << obj.encode_i64(i64(i))
		g.code << op_eq
		g.code << op_jz
		g.code << obj.encode_i64(0)
		g.fixups << Fixup{ name: next_l, off: u32(g.code.len) - 8 }
		// push the variant name as a string
		g.code << op_push_s
		g.code << obj.encode_i64(0)
		g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: v, kind: 1 }
		// jump to end
		g.code << op_jmp
		g.code << obj.encode_i64(0)
		g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
		g.emit_label(next_l)
	}
	// else: push "unknown"
	g.code << op_push_s
	g.code << obj.encode_i64(0)
	g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: 'unknown', kind: 1 }
	g.emit_label(end_l)
}

// gen_for_enum generates a for loop that iterates over all variants of an enum.
// for x in Color { ... }  →  for i in 0..count { x = i; ... }  (x typed as Color)
fn (mut g Gen) gen_for_enum(var_name string, enum_name string, body []Stmt, line int) ! {
	variants := g.enums[enum_name] or {
		return error('unknown enum "${enum_name}" at line ${line}')
	}
	count := variants.len
	// i := 0
	var_idx := g.new_local()
	bound_idx := g.new_local()
	g.code << op_push_i
	g.code << obj.encode_i64(0)
	g.emit_store(var_idx)
	g.code << op_push_i
	g.code << obj.encode_i64(i64(count))
	g.emit_store(bound_idx)
	loop_l := g.new_label()
	inc_l := g.new_label()
	end_l := g.new_label()
	g.emit_label(loop_l)
	g.emit_load(var_idx)
	g.emit_load(bound_idx)
	g.code << op_lt
	g.code << op_jz
	g.code << obj.encode_i64(0)
	g.fixups << Fixup{ name: end_l, off: u32(g.code.len) - 8 }
	g.loops << LoopCtx{ break_l: end_l, continue_l: inc_l }
	prev := g.locals[var_name] or { -1 }
	prev_t := g.types[var_name] or { '' }
	g.locals[var_name] = var_idx
	g.types[var_name] = enum_name // type the loop variable as the enum
	for s in body {
		g.gen_stmt(s)!
	}
	if prev >= 0 {
		g.locals[var_name] = prev
	} else {
		g.locals.delete(var_name)
	}
	if prev_t.len > 0 {
		g.types[var_name] = prev_t
	}
	g.loops.delete_last()
	g.emit_label(inc_l)
	g.emit_load(var_idx)
	g.code << op_push_i
	g.code << obj.encode_i64(1)
	g.code << op_add
	g.emit_store(var_idx)
	g.code << op_jmp
	g.code << obj.encode_i64(0)
	g.fixups << Fixup{ name: loop_l, off: u32(g.code.len) - 8 }
	g.emit_label(end_l)
}

// emit_field_name pushes a field name as a string constant. Like string
// literals it goes through a kind-1 relocation so multi-file links rebase it.
fn (mut g Gen) emit_field_name(name string) {
	g.code << op_push_s
	g.code << obj.encode_i64(0)
	g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: name, kind: 1 }
}

fn (mut g Gen) intern(s string) int {
	if s in g.str_map {
		return g.str_map[s]
	}
	idx := g.strings.len
	g.strings << s
	g.str_map[s] = idx
	return idx
}

fn (mut g Gen) new_local() int {
	idx := g.local_cnt
	g.local_cnt++
	return idx
}

fn (mut g Gen) emit_load(idx int) {
	g.code << op_load
	g.code << obj.encode_i64(i64(idx))
}

fn (mut g Gen) emit_store(idx int) {
	g.code << op_store
	g.code << obj.encode_i64(i64(idx))
}

fn (mut g Gen) new_label() string {
	g.next_lbl++
	return 'L${g.next_lbl}'
}

fn (mut g Gen) emit_label(name string) {
	g.labels[name] = g.code.len
}

// ---------------------------------------------------------------------------
// closure capture analysis
//
// A closure (anonymous `fn`) may reference the enclosing function's locals.
// Since the VM compiles each function with its own frame, those references
// are resolved by capturing the values at closure-creation time: the compiler
// scans the body for free variables, registers them as the lambda's leading
// local slots, and emits loads of their current values before op_closure.

// scan_captures returns the enclosing locals a closure body references, in
// first-reference order (stable and deterministic for codegen).
fn (mut g Gen) scan_captures(body []Stmt, fparams []string) []string {
	mut bound := map[string]bool{}
	for p in fparams {
		bound[p] = true
	}
	mut caps := []string{}
	mut seen := map[string]bool{}
	for st in body {
		g.scan_stmt(st, mut bound, mut caps, mut seen)
	}
	return caps
}

fn (mut g Gen) maybe_capture(name string, bound map[string]bool, mut caps []string, mut seen map[string]bool) {
	if name in bound {
		return // bound inside the closure — a plain local
	}
	if name !in g.locals {
		return // not an enclosing local (global fn/const/enum — resolved elsewhere)
	}
	if name !in seen {
		seen[name] = true
		caps << name
	}
}

fn (mut g Gen) scan_stmt(st Stmt, mut bound map[string]bool, mut caps []string, mut seen map[string]bool) {
	match st.kind {
		.expr_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
		}
		.let_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
			bound[st.target] = true
		}
		.destruct_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
			for t in st.destruct_targets {
				bound[t] = true
			}
		}
		.assign_stmt {
			// assignment to a name that is not a closure-local references the
			// enclosing local's captured copy
			g.maybe_capture(st.target, bound, mut caps, mut seen)
			bound[st.target] = true
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
		}
		.index_assign {
			g.scan_expr(st.base, mut bound, mut caps, mut seen)
			g.scan_expr(st.idx, mut bound, mut caps, mut seen)
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
		}
		.field_assign {
			g.scan_expr(st.base, mut bound, mut caps, mut seen)
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
		}
		.if_stmt {
			g.scan_expr(st.cond, mut bound, mut caps, mut seen)
			for s in st.body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
			for s in st.els {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
		}
		.match_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
			for arm in st.arms {
				g.scan_expr(arm.val, mut bound, mut caps, mut seen)
				for s in arm.body {
					g.scan_stmt(s, mut bound, mut caps, mut seen)
				}
			}
			for s in st.els_body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
		}
		.while_stmt {
			g.scan_expr(st.cond, mut bound, mut caps, mut seen)
			for s in st.body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
		}
		.for_range_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
			g.scan_expr(st.cond, mut bound, mut caps, mut seen)
			had := st.target in bound
			bound[st.target] = true
			for s in st.body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
			if !had {
				bound.delete(st.target)
			}
		}
		.for_in_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
			had := st.target in bound
			bound[st.target] = true
			mut had_idx := false
			if st.idx_target.len > 0 {
				had_idx = st.idx_target in bound
				bound[st.idx_target] = true
			}
			for s in st.body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
			if !had {
				bound.delete(st.target)
			}
			if st.idx_target.len > 0 && !had_idx {
				bound.delete(st.idx_target)
			}
		}
		.ret_stmt {
			if st.has_val {
				g.scan_expr(st.expr, mut bound, mut caps, mut seen)
			}
		}
		.assert_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
		}
		.try_stmt {
			for s in st.body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
			had := st.target in bound
			bound[st.target] = true
			for s in st.els {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
			if !had {
				bound.delete(st.target)
			}
		}
		.throw_stmt {
			g.scan_expr(st.expr, mut bound, mut caps, mut seen)
		}
		.break_stmt, .continue_stmt {}
	}
}

fn (mut g Gen) scan_expr(e Expr, mut bound map[string]bool, mut caps []string, mut seen map[string]bool) {
	match e.kind {
		.ident {
			g.maybe_capture(e.name, bound, mut caps, mut seen)
		}
		.call {
			// a call to an enclosing local holding a closure must capture it too
			g.maybe_capture(e.name, bound, mut caps, mut seen)
			for a in e.args {
				g.scan_expr(a, mut bound, mut caps, mut seen)
			}
		}
		.field {
			g.scan_expr(*e.left, mut bound, mut caps, mut seen)
		}
		.method_call {
			g.scan_expr(*e.left, mut bound, mut caps, mut seen)
			for a in e.args {
				g.scan_expr(a, mut bound, mut caps, mut seen)
			}
		}
		.index {
			g.scan_expr(*e.left, mut bound, mut caps, mut seen)
			g.scan_expr(*e.right, mut bound, mut caps, mut seen)
		}
		.slice {
			g.scan_expr(*e.left, mut bound, mut caps, mut seen)
			g.scan_expr(*e.right, mut bound, mut caps, mut seen)
			g.scan_expr(*e.extra, mut bound, mut caps, mut seen)
		}
		.unary {
			g.scan_expr(*e.right, mut bound, mut caps, mut seen)
		}
		.binary {
			g.scan_expr(*e.left, mut bound, mut caps, mut seen)
			g.scan_expr(*e.right, mut bound, mut caps, mut seen)
		}
		.array_lit {
			for el in e.elems {
				g.scan_expr(el, mut bound, mut caps, mut seen)
			}
		}
		.struct_lit {
			for f in e.fields {
				g.scan_expr(f.val, mut bound, mut caps, mut seen)
			}
		}
		.anon_fn {
			// a nested closure: its parameters bind inside it, but references to
			// enclosing locals still belong to this closure's capture set
			mut saved := map[string]bool{}
			for p in e.fparams {
				saved[p] = p in bound
				bound[p] = true
			}
			for s in e.fn_body {
				g.scan_stmt(s, mut bound, mut caps, mut seen)
			}
			for p in e.fparams {
				if !saved[p] {
					bound.delete(p)
				}
			}
		}
		else {}
	}
}
