//! Expression and statement lowering — the `LowerCtx`-driven engine that
//! turns Bynk expressions, statements, matches, and mocks into TypeScript
//! source. Split out of `emitter.rs` (ADR 0060); `LowerCtx` and the `ts_*`
//! type renderers stay in the parent and are reached via `use super::*`.

use std::collections::HashSet;

use crate::ast::*;
use crate::builtin_names::types::*;
use crate::checker::{NamedKind, Ty, TypedCommons};

use super::*;

/// Lower a block to a sequence of TypeScript statements suitable for use as
/// an async function body. Used by v0.7 mock-operation emission.
pub fn lower_block_to_async_body(
    block: &Block,
    return_type: &TypeRef,
    typed: &mut TypedCommons,
    cross_context: &crate::resolver::CrossContextInfo,
) -> String {
    let mut out = String::new();
    let mut cx = LowerCtx::new(typed, cross_context);
    let async_tail = is_effectful_return(return_type);
    emit_block_as_function_body(&mut out, block, &mut cx, 0, async_tail);
    out
}

/// Lower a test-case body: statements followed by a discarded tail expression
/// (the runner records success via the assertion mechanism, not via a return
/// value). Used by v0.7 test emission.
pub fn lower_test_case_body(
    block: &Block,
    typed: &mut TypedCommons,
    cross_context: &crate::resolver::CrossContextInfo,
    test_services: HashSet<String>,
    test_agents: HashSet<String>,
) -> String {
    let mut out = String::new();
    let mut cx = LowerCtx::new(typed, cross_context);
    cx.test_services = test_services;
    cx.local_agents = test_agents.clone();
    cx.test_agents = test_agents;
    for stmt in &block.statements {
        emit_statement(&mut out, stmt, &mut cx, 0);
    }
    // Evaluate the tail expression but discard its value; assertions inside
    // it still take effect via thrown AssertionErrors.
    let mut stmts = Vec::new();
    let tail = lower_expr(&block.tail, &mut stmts, &mut cx);
    for s in &stmts {
        write_line(&mut out, 0, s);
    }
    if !tail.is_empty() && tail != "undefined" {
        write_line(&mut out, 0, &format!("void ({tail});"));
    }
    out
}

/// v0.16: lower an integration test case body. Like [`lower_test_case_body`],
/// but in **workers** mode and from a synthetic harness root: entry calls
/// (`ctx.service(args)`) are cross-context calls that lower to `callService(
/// deps.env.<BINDING>, …)` over the real wire. The harness root declares no
/// local services/agents, so those scoped sets stay empty; `cross_context`
/// carries every participant's service surface.
pub fn lower_integration_case_body(
    block: &Block,
    typed: &mut TypedCommons,
    cross_context: &crate::resolver::CrossContextInfo,
) -> String {
    let mut out = String::new();
    let mut cx = LowerCtx::new(typed, cross_context);
    cx.target = BuildTarget::Workers;
    for stmt in &block.statements {
        emit_statement(&mut out, stmt, &mut cx, 0);
    }
    let mut stmts = Vec::new();
    let tail = lower_expr(&block.tail, &mut stmts, &mut cx);
    for s in &stmts {
        write_line(&mut out, 0, s);
    }
    if !tail.is_empty() && tail != "undefined" {
        write_line(&mut out, 0, &format!("void ({tail});"));
    }
    out
}

pub(crate) fn emit_block_as_function_body(
    out: &mut String,
    block: &Block,
    cx: &mut LowerCtx,
    indent: usize,
    async_tail: bool,
) {
    for stmt in &block.statements {
        emit_statement(out, stmt, cx, indent);
    }
    // Tail position: match → inline switch, if → inline if, otherwise return expr.
    match &block.tail.kind {
        ExprKind::Match { discriminant, arms } => {
            emit_match_tail(out, discriminant, arms, cx, indent, async_tail);
        }
        ExprKind::If {
            cond,
            then_block,
            else_block,
        } if !both_simple(then_block, else_block) || cond_has_is_bindings(cond, cx) => {
            emit_if_tail(out, cond, then_block, else_block, cx, indent, async_tail);
        }
        _ => {
            let mut stmts = Vec::new();
            let tail = lower_tail_expr(&block.tail, &mut stmts, cx, async_tail);
            for s in &stmts {
                write_line(out, indent, s);
            }
            write_line(out, indent, &format!("return {tail};"));
        }
    }
}

/// Lower an expression that's in the tail position of a returning context.
///
/// In async-tail position (v0.7.1), an `async function` wraps its return value
/// as a Promise automatically, so `Effect.pure(...)` is redundant and should
/// emit as a bare value. Recurse through control-flow forms whose result is
/// the surrounding function's return value:
/// - `Effect.pure(x)` → lower `x` directly.
/// - A ternary-form `if`/`else` (simple branches) where each branch's tail is
///   itself an async-tail position.
/// - A pure-tail block (no statements) where the inner tail is the actual
///   returned expression.
/// - Parens (transparent).
///
/// In non-async-tail position, defer to `lower_expr` unchanged.
fn lower_tail_expr(
    e: &Expr,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
    async_tail: bool,
) -> String {
    if !async_tail {
        return lower_expr(e, stmts, cx);
    }
    match &e.kind {
        ExprKind::EffectPure(inner) => lower_expr(inner, stmts, cx),
        ExprKind::Paren(inner) => lower_tail_expr(inner, stmts, cx, true),
        ExprKind::Block(b) if b.statements.is_empty() => lower_tail_expr(&b.tail, stmts, cx, true),
        ExprKind::If {
            cond,
            then_block,
            else_block,
        } if both_simple(then_block, else_block) && !cond_has_is_bindings(cond, cx) => {
            let cond_expr = lower_expr(cond, stmts, cx);
            let mut tstmts = Vec::new();
            let testr = lower_tail_expr(&then_block.tail, &mut tstmts, cx, true);
            debug_assert!(tstmts.is_empty());
            let mut estmts = Vec::new();
            let eestr = lower_tail_expr(&else_block.tail, &mut estmts, cx, true);
            debug_assert!(estmts.is_empty());
            format!("({cond_expr} ? {testr} : {eestr})")
        }
        _ => lower_expr(e, stmts, cx),
    }
}

fn emit_statement(out: &mut String, stmt: &Statement, cx: &mut LowerCtx, indent: usize) {
    match stmt {
        Statement::Let(l) => {
            // Track `let x = AgentName(key)` so subsequent `x.method(args)`
            // calls can dispatch through the agent class.
            if l.name.name != "_"
                && let ExprKind::Call {
                    name,
                    args: ctor_args,
                    ..
                } = &l.value.kind
                && cx.local_agents.contains(&name.name)
                && ctor_args.len() == 1
            {
                cx.local_agent_vars
                    .insert(l.name.name.clone(), name.name.clone());
            }
            let mut stmts = Vec::new();
            let value = lower_expr(&l.value, &mut stmts, cx);
            for s in &stmts {
                write_line(out, indent, s);
            }
            let bind_name = if l.name.name == "_" {
                // Emit a unique throwaway local; TS allows `const _ = ...` only
                // once per scope, so use a fresh name to be safe.
                cx.fresh()
            } else {
                l.name.name.clone()
            };
            match &l.type_annot {
                Some(annot) => write_line(
                    out,
                    indent,
                    &format!(
                        "const {bind_name}: {ty} = {value};",
                        ty = ts_type_ref(annot),
                    ),
                ),
                None => write_line(out, indent, &format!("const {bind_name} = {value};")),
            }
        }
        Statement::EffectLet(l) => {
            // `let x <- expr` → `const x = await expr;`
            let mut stmts = Vec::new();
            let value = lower_expr(&l.value, &mut stmts, cx);
            for s in &stmts {
                write_line(out, indent, s);
            }
            let bind_name = if l.name.name == "_" {
                cx.fresh()
            } else {
                l.name.name.clone()
            };
            match &l.type_annot {
                Some(annot) => write_line(
                    out,
                    indent,
                    &format!(
                        "const {bind_name}: {ty} = await {value};",
                        ty = ts_type_ref(annot),
                    ),
                ),
                None => write_line(out, indent, &format!("const {bind_name} = await {value};")),
            }
        }
        Statement::Commit(c) => {
            // Inside an agent handler, `commit expr` → `await this.commitState(expr);`
            let mut stmts = Vec::new();
            let value = lower_expr(&c.value, &mut stmts, cx);
            for s in &stmts {
                write_line(out, indent, s);
            }
            write_line(out, indent, &format!("await this.commitState({value});"));
        }
        Statement::Assert(a) => {
            // Inside a test case body, `assert expr` lowers to a runtime check
            // that throws an AssertionError so the surrounding test-case
            // runner catches it and records the failure.
            let mut stmts = Vec::new();
            let value = lower_expr(&a.value, &mut stmts, cx);
            for s in &stmts {
                write_line(out, indent, s);
            }
            let display = a.value.span.start.to_string();
            let span_start = a.value.span.start;
            let span_end = a.value.span.end;
            write_line(
                out,
                indent,
                &format!(
                    "if (!({value})) {{ throw __bynkAssertionFailure(\"offset {display}\", {span_start}, {span_end}); }}",
                ),
            );
        }
    }
}

fn write_line(out: &mut String, indent: usize, line: &str) {
    for _ in 0..indent {
        out.push(' ');
    }
    out.push_str(line);
    out.push('\n');
}

/// True when a bare `Call` whose result type is the sum `sum_name` is actually a
/// variant constructor of that sum (e.g. `Won(prize)` for `Outcome`), as opposed
/// to an ordinary function that merely *returns* the sum (e.g.
/// `classify(n) -> Outcome`). Only the former is qualified to `Sum.Variant(...)`.
fn call_is_sum_variant(cx: &LowerCtx, sum_name: &str, call_name: &str) -> bool {
    if let Some(decl) = cx.commons.types.get(sum_name)
        && let TypeBody::Sum(s) = &decl.body
    {
        s.variants.iter().any(|v| v.name.name == call_name)
    } else {
        false
    }
}

/// The raw TypeScript form of a compile-time literal that v0.9.4 may admit as a
/// refined type (int or string). `None` for anything else.
fn lower_const_literal_raw(e: &Expr) -> Option<String> {
    match &e.kind {
        ExprKind::IntLit(n) => Some(n.to_string()),
        // v0.21: the stored lexeme verbatim.
        ExprKind::FloatLit { lexeme, .. } => Some(lexeme.clone()),
        ExprKind::StrLit(s) => Some(format!("\"{}\"", escape_ts_string(s))),
        // A negated numeric literal — admissible at compile time (the
        // checker folds the sign in `const_literal`), so the lowering must
        // route it through `unsafe` like any other admitted literal.
        ExprKind::UnaryOp(UnaryOp::Neg, inner) => match &inner.kind {
            ExprKind::IntLit(n) => Some(format!("-{n}")),
            ExprKind::FloatLit { lexeme, .. } => Some(format!("-{lexeme}")),
            _ => None,
        },
        _ => None,
    }
}

/// Lower an interpolated string (v0.43, ADR 0075) to a TS template literal.
/// Chunks become escaped literal text; each hole becomes `${String(<expr>)}`.
/// `String(…)` is identity for a `String` hole and the display form for
/// `Int`/`Float`/`Bool` — and the checker guarantees only base scalars reach
/// here, so no `[object Object]` can be emitted.
fn lower_interp_str(parts: &[InterpPart], stmts: &mut Vec<String>, cx: &mut LowerCtx) -> String {
    let mut out = String::from("`");
    for part in parts {
        match part {
            InterpPart::Chunk(text) => out.push_str(&escape_ts_template(text)),
            InterpPart::Hole(hole) => {
                let lowered = lower_expr(hole, stmts, cx);
                out.push_str(&format!("${{String({lowered})}}"));
            }
        }
    }
    out.push('`');
    out
}

/// Escape a literal chunk for a TS template-literal context: backslash,
/// backtick, and `$` (to neutralise `${`), plus the control-char escapes
/// [`escape_ts_string`] applies. (v0.43.)
fn escape_ts_template(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '`' => out.push_str("\\`"),
            '$' => out.push_str("\\$"),
            '\n' => out.push_str("\\n"),
            '\t' => out.push_str("\\t"),
            '\r' => out.push_str("\\r"),
            c => out.push(c),
        }
    }
    out
}

pub(crate) fn lower_expr(e: &Expr, stmts: &mut Vec<String>, cx: &mut LowerCtx) -> String {
    // v0.9.4: a literal the checker admitted as a refined type (expected-type-
    // directed construction) is emitted through the unchecked `unsafe`
    // constructor — the refinement was already verified at compile time, so
    // there is no runtime check and no `Result`.
    if let Some(Ty::Named {
        name,
        kind: NamedKind::Refined(_),
    }) = cx.commons.expr_types.get(&e.span)
        && let Some(raw) = lower_const_literal_raw(e)
    {
        return format!("{name}.unsafe({raw})");
    }
    match &e.kind {
        ExprKind::IntLit(n) => n.to_string(),
        // v0.21: the stored lexeme verbatim — `1e10` must not normalise.
        ExprKind::FloatLit { lexeme, .. } => lexeme.clone(),
        ExprKind::StrLit(s) => format!("\"{}\"", escape_ts_string(s)),
        // v0.43 (ADR 0075): an interpolated string lowers to a TS template
        // literal — chunks as escaped literal text, holes as `${String(…)}`.
        ExprKind::InterpStr(parts) => lower_interp_str(parts, stmts, cx),
        ExprKind::BoolLit(b) => b.to_string(),
        // v0.20b: a list literal lowers to a TS array literal; `readonly` is
        // a type-level property and the checker owns the element typing.
        ExprKind::ListLit(elems) => {
            let lowered: Vec<String> = elems.iter().map(|el| lower_expr(el, stmts, cx)).collect();
            format!("[{}]", lowered.join(", "))
        }
        ExprKind::Ident(id) => lower_ident(e, id, cx),
        ExprKind::Call { name, args, .. } => lower_call(e, name, args, stmts, cx),
        ExprKind::UnaryOp(op, inner) => {
            let inner = lower_expr(inner, stmts, cx);
            let sym = match op {
                UnaryOp::Neg => "-",
                UnaryOp::Not => "!",
            };
            format!("{sym}{inner}")
        }
        ExprKind::BinOp(op, lhs, rhs) => lower_bin_op(*op, lhs, rhs, stmts, cx),
        ExprKind::Paren(inner) => {
            let s = lower_expr(inner, stmts, cx);
            format!("({s})")
        }
        ExprKind::Ok(inner) => {
            let s = lower_expr(inner, stmts, cx);
            // v0.9: `Ok` is overloaded — use the checker's recorded type to
            // decide between `Result.Ok` and `HttpResult.Ok`.
            if matches!(cx.commons.expr_types.get(&e.span), Some(Ty::HttpResult(_))) {
                format!("HttpResult.Ok({s})")
            } else {
                format!("Ok({s})")
            }
        }
        ExprKind::Err(inner) => {
            let s = lower_expr(inner, stmts, cx);
            format!("Err({s})")
        }
        ExprKind::Some(inner) => {
            let s = lower_expr(inner, stmts, cx);
            format!("Some({s})")
        }
        ExprKind::None => "None".to_string(),
        ExprKind::Question(inner) => {
            let inner_expr = lower_expr(inner, stmts, cx);
            let tmp = cx.fresh();
            stmts.push(format!("const {tmp} = {inner_expr};"));
            stmts.push(format!("if ({tmp}.tag === \"Err\") return {tmp};"));
            format!("{tmp}.value")
        }
        ExprKind::ConstructorCall {
            type_name,
            method,
            args,
        } => lower_constructor_call(type_name, method, args, stmts, cx),
        ExprKind::RecordConstruction { type_name, fields } => {
            lower_record_construction(type_name, fields, stmts, cx)
        }
        ExprKind::FieldAccess { receiver, field } => lower_field_access(receiver, field, stmts, cx),
        ExprKind::MethodCall {
            receiver,
            method,
            args,
            ..
        } => lower_method_call(e, receiver, method, args, stmts, cx),
        ExprKind::If {
            cond,
            then_block,
            else_block,
        } => lower_if(cond, then_block, else_block, stmts, cx),
        // v0.20a: a lambda lowers to a TS arrow; `async` iff its checked type
        // is an effectful function. Expression bodies that need hoisted
        // statements (match-as-IIFE etc.) keep them local to the arrow.
        ExprKind::Lambda(lambda) => lower_lambda(e, lambda, cx),
        ExprKind::Block(b) => lower_block_as_expr(b, cx),
        ExprKind::Match { discriminant, arms } => lower_match_as_iife(discriminant, arms, cx),
        ExprKind::Is { value, pattern } => lower_is(value, pattern, stmts, cx),
        ExprKind::UnitLit => "undefined".to_string(),
        ExprKind::EffectPure(inner) => {
            let inner_expr = lower_expr(inner, stmts, cx);
            format!("Promise.resolve({inner_expr})")
        }
        ExprKind::RecordSpread {
            type_name: _,
            base,
            overrides,
        } => lower_record_spread(base, overrides, stmts, cx),
        ExprKind::Assert(inner) => {
            // v0.9.1: assert as an expression. Emit a runtime helper call
            // that returns void (i.e., evaluates to `undefined` at runtime
            // and is treated as the unit value `()` in Bynk terms).
            let value = lower_expr(inner, stmts, cx);
            let display = inner.span.start;
            let span_start = inner.span.start;
            let span_end = inner.span.end;
            format!("__bynkAssert(({value}), \"offset {display}\", {span_start}, {span_end})")
        }
        ExprKind::Mock { type_ref, args } => lower_mock(type_ref, args, stmts, cx),
    }
}

/// A default base-type literal (as TypeScript source) that satisfies a refined
/// type's predicates, for bare `Mock[T]`. `None` when no default can be derived
/// (a `Matches` refinement — the checker rejects bare `Mock` for those).
fn refined_default(decl: &TypeDecl) -> Option<String> {
    let (base, refinement) = match &decl.body {
        TypeBody::Refined {
            base, refinement, ..
        } => (*base, refinement.as_ref()),
        _ => return None,
    };
    match base {
        BaseType::Int => {
            let mut lo: i64 = 0;
            if let Some(r) = refinement {
                for p in &r.predicates {
                    match &p.kind {
                        PredKind::Positive => lo = lo.max(1),
                        PredKind::NonNegative => lo = lo.max(0),
                        PredKind::InRange(a, _) => lo = lo.max(a.value),
                        _ => {}
                    }
                }
            }
            Some(lo.to_string())
        }
        BaseType::String => {
            let mut len: i64 = 0;
            if let Some(r) = refinement {
                for p in &r.predicates {
                    match p.kind {
                        PredKind::NonEmpty => len = len.max(1),
                        PredKind::MinLength(k) | PredKind::Length(k) => len = len.max(k),
                        PredKind::Matches(_) => return None,
                        _ => {}
                    }
                }
            }
            if len < 1 {
                len = 1;
            }
            Some(format!("\"{}\"", "x".repeat(len as usize)))
        }
        BaseType::Bool => Some("true".to_string()),
        BaseType::Float => {
            let mut lo: f64 = 0.0;
            let mut hi = f64::INFINITY;
            if let Some(r) = refinement {
                for p in &r.predicates {
                    match &p.kind {
                        PredKind::Positive => lo = lo.max(1.0),
                        PredKind::NonNegative => lo = lo.max(0.0),
                        PredKind::InRangeF(a, b) => {
                            lo = lo.max(a.value);
                            hi = hi.min(b.value);
                        }
                        _ => {}
                    }
                }
            }
            // The `Positive` floor of 1.0 can overshoot a tight fractional
            // range (`InRange(0.0, 0.5)`); fall back to the upper bound.
            if lo > hi {
                lo = hi;
            }
            Some(lo.to_string())
        }
    }
}

/// v0.9.4 Part B (slice 1): lower a refined-type `Mock[T]` / `Mock[T](lit)` to
/// the branded `unsafe` constructor. The checker has already validated this is a
/// refined type in a test body, and recorded the refined type at `span`.
/// v0.9.4 slice 2 recursion cap for bare `Mock` generation (mirrors the
/// checker's `MOCK_DEPTH`).
const MOCK_DEPTH: u32 = 12;

/// A TypeScript base-literal default for an opaque type's underlying base. Not
/// distinct per call in this increment — per-call distinctness via a runtime
/// counter is a follow-up.
fn base_default_ts(base: BaseType) -> String {
    match base {
        BaseType::Int => "0".to_string(),
        BaseType::String => "\"mock\"".to_string(),
        BaseType::Bool => "true".to_string(),
        BaseType::Float => "0".to_string(),
    }
}

/// Generate a TypeScript expression for a bare `Mock` of `ty` (v0.9.4 Part B,
/// slice 2). Recurses through sum payloads and record fields; refined types use
/// `refined_default`, opaque types wrap a base default, bare bases use 0/""/true.
fn mock_value(ty: &Ty, cx: &LowerCtx, depth: u32) -> String {
    if depth == 0 {
        return "undefined".to_string();
    }
    match ty {
        Ty::Base(BaseType::Int) => "0".to_string(),
        Ty::Base(BaseType::String) => "\"\"".to_string(),
        Ty::Base(BaseType::Bool) => "true".to_string(),
        Ty::Base(BaseType::Float) => "0".to_string(),
        Ty::Named { name, .. } => {
            let Some(decl) = cx.commons.types.get(name) else {
                return "undefined".to_string();
            };
            match &decl.body {
                TypeBody::Refined { .. } => {
                    let d = refined_default(decl).unwrap_or_else(|| "0".to_string());
                    format!("{name}.unsafe({d})")
                }
                TypeBody::Opaque { base, .. } => {
                    format!("{name}.unsafe({})", base_default_ts(*base))
                }
                TypeBody::Sum(s) => match s.variants.first() {
                    None => "undefined".to_string(),
                    Some(v) if v.payload.is_empty() => format!("{name}.{}", v.name.name),
                    Some(v) => {
                        let args: Vec<String> = v
                            .payload
                            .iter()
                            .map(|f| {
                                crate::checker::resolve_type_ref(&f.type_ref, &cx.commons.types)
                                    .map(|t| mock_value(&t, cx, depth - 1))
                                    .unwrap_or_else(|| "undefined".to_string())
                            })
                            .collect();
                        format!("{name}.{}({})", v.name.name, args.join(", "))
                    }
                },
                TypeBody::Record(r) => {
                    let parts: Vec<String> = r
                        .fields
                        .iter()
                        .map(|f| {
                            let fv =
                                crate::checker::resolve_type_ref(&f.type_ref, &cx.commons.types)
                                    .map(|t| mock_value(&t, cx, depth - 1))
                                    .unwrap_or_else(|| "undefined".to_string());
                            format!("{}: {}", f.name.name, fv)
                        })
                        .collect();
                    format!("{{ {} }}", parts.join(", "))
                }
            }
        }
        _ => "undefined".to_string(),
    }
}

/// Lower an `ExprKind::MethodCall`. This is a dispatcher: a sequence of
/// independent guard-and-`return` branches, tried in order (the order is
/// load-bearing — earlier guards take precedence), falling through to the
/// UFCS instance-call tail. The collection/numeric/string/option/result
/// kernels and the typed JSON codec delegate to dedicated helpers that
/// return `Option<String>`.
fn lower_method_call(
    e: &Expr,
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    // v0.9: explicit `HttpResult.Variant(args)` construction. The
    // checker has already recorded the expression's type — emit it
    // directly through the runtime's HttpResult namespace.
    if let ExprKind::Ident(id) = &receiver.kind
        && id.name == HTTP_RESULT
        && http_variant(&method.name).is_some()
    {
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        return format!("HttpResult.{}({})", method.name, args_lowered.join(", "));
    }
    // v0.20b: built-in collection statics — `List.empty()` /
    // `Map.empty()`. The checker recorded the instantiated type;
    // emit it explicitly so the TS value doesn't infer as `never[]`
    // / `Map<unknown, unknown>` outside contextually-typed positions.
    if let ExprKind::Ident(id) = &receiver.kind
        && (id.name == LIST || id.name == MAP)
        && method.name == "empty"
        && args.is_empty()
        && !cx.commons.types.contains_key(&id.name)
    {
        match cx.commons.expr_types.get(&e.span) {
            Some(Ty::List(t)) => return format!("([] as readonly {}[])", ts_ty(t)),
            Some(Ty::Map(k, v)) => {
                return format!("new Map<{}, {}>()", ts_ty(k), ts_ty(v));
            }
            _ => {}
        }
    }
    // v0.22b: the typed JSON codec (ADR 0045).
    if let Some(s) = lower_json_codec_call(e, receiver, method, args, stmts, cx) {
        return s;
    }
    // v0.22a: the numeric parse statics — `Int.parse(s)` /
    // `Float.parse(s)` (ADR 0048). Full-string parse via `Number(…)`
    // (which, unlike `parseFloat`, rejects trailing garbage); an
    // empty/whitespace-only string would coerce to `0`, so it is
    // rejected first. `Int` requires a safe integer (the honest
    // runtime "overflow → None"); `Float` requires finite (the 0040
    // posture).
    if let ExprKind::Ident(id) = &receiver.kind
        && (id.name == INT || id.name == FLOAT)
        && method.name == "parse"
        && args.len() == 1
    {
        let s = lower_expr(&args[0], stmts, cx);
        let guard = if id.name == INT {
            "Number.isSafeInteger(__n)"
        } else {
            "Number.isFinite(__n)"
        };
        return format!(
            "((__s: string) => {{ const __n = __s.trim() === \"\" ? Number.NaN : Number(__s); return {guard} ? Some(__n) : None; }})({s})"
        );
    }
    // v0.15 cross-context capability call: `B.Cap.op(args)` /
    // `Alias.Cap.op(args)`. The provider is instantiated locally in
    // the composition root, so this lowers to an in-process
    // `<deps>.<Cap>.op(args)` exactly like a local capability call —
    // the consumed-context prefix is resolved away.
    if let Some(chain) = flatten_emit_ident_chain(receiver)
        && let Some((_consumed, cap)) = cx.cross_context.resolve_cross_capability(&chain)
    {
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        return format!(
            "{}.{}.{}({})",
            cx.cap_deps_expr,
            cap,
            method.name,
            args_lowered.join(", ")
        );
    }
    // v0.6 cross-context service call: receiver is an alias or the
    // dotted name of a consumed context.
    if let Some(s) = lower_cross_context_service_call(receiver, method, args, stmts, cx) {
        return s;
    }
    // Capability call: receiver is a bare ident naming a declared
    // capability in `given`. Lower to `<deps>.Capability.op(args)`,
    // where `<deps>` is `deps` in a handler body and `this.deps` in a
    // provider body (v0.12 provider composition).
    if let ExprKind::Ident(id) = &receiver.kind
        && cx.capabilities.contains(&id.name)
    {
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        return format!(
            "{}.{}.{}({})",
            cx.cap_deps_expr,
            id.name,
            method.name,
            args_lowered.join(", ")
        );
    }
    // Static call: receiver is a bare ident naming a declared type.
    if let ExprKind::Ident(id) = &receiver.kind
        && cx.commons.types.contains_key(&id.name)
    {
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        return format!("{}.{}({})", id.name, method.name, args_lowered.join(", "));
    }
    // v0.7: local service call inside a test case body.
    // `serviceName.method(args)` → `serviceName.method(args, deps)`.
    if let ExprKind::Ident(id) = &receiver.kind
        && cx.test_services.contains(&id.name)
    {
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        let mut all = args_lowered;
        all.push("deps".to_string());
        return format!("{}.{}({})", id.name, method.name, all.join(", "));
    }
    // v0.9.2: inline agent invocation. Source form is
    // `Agent(<key>).method(args)`; receiver parses as
    // `Call(Agent, [<key>])`. Lower to
    // `__makeAgent(<key>).method(args, deps)`. Works in service and
    // agent-handler bodies (deps is the handler's deps parameter) and
    // test bodies (deps is the locally-built makeTestDeps record).
    if let ExprKind::Call {
        name,
        args: ctor_args,
        ..
    } = &receiver.kind
        && cx.local_agents.contains(&name.name)
    {
        let key_arg = ctor_args
            .first()
            .map(|a| lower_expr(a, stmts, cx))
            .unwrap_or_else(|| "\"default\"".to_string());
        let instance = cx.agent_construct(&name.name, &key_arg);
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        let mut all = args_lowered;
        all.push("deps".to_string());
        return format!(
            "{instance}.{method}({args})",
            method = method.name,
            args = all.join(", ")
        );
    }
    // Let-bound agent invocation. `let x = Agent(key); x.method(args)`
    // — the statement emitter recorded `x` as an agent variable when
    // it lowered the let. Method calls on `x` go straight to the
    // class instance with `deps` threaded through.
    if let ExprKind::Ident(id) = &receiver.kind
        && cx.local_agent_vars.contains_key(&id.name)
    {
        let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
        let mut all = args_lowered;
        all.push("deps".to_string());
        return format!("{}.{}({})", id.name, method.name, all.join(", "));
    }
    // v0.20b: built-in kernel methods on the collection types,
    // dispatched on the receiver's checked type. Emitted inline
    // (typed IIFEs / spreads) — no runtime imports, so files that
    // never touch collections emit byte-identically to v0.20a.
    if let Some(recv_ty) = cx.commons.expr_types.get(&receiver.span).cloned() {
        match &recv_ty {
            Ty::List(elem) => {
                if let Some(s) = lower_list_kernel(e, receiver, method, args, elem, stmts, cx) {
                    return s;
                }
            }
            Ty::Map(key, val) => {
                if let Some(s) = lower_map_kernel(receiver, method, args, key, val, stmts, cx) {
                    return s;
                }
            }
            // v0.21: the numeric kernel. `toFloat` is the identity
            // at runtime (the Int/Float distinction is erased);
            // the four `Float -> Int` roundings map onto `Math.*`.
            // v0.22a extends it (abs/min/max/clamp, isNaN/isFinite).
            Ty::Base(BaseType::Int | BaseType::Float) => {
                if let Some(s) = lower_numeric_kernel(receiver, method, args, stmts, cx) {
                    return s;
                }
            }
            // v0.22a: the string kernel (ADR 0046).
            Ty::Base(BaseType::String) => {
                if let Some(s) = lower_string_kernel(receiver, method, args, stmts, cx) {
                    return s;
                }
            }
            // v0.22a: Option/Result combinators (ADR 0048).
            Ty::Option(inner) => {
                if let Some(s) = lower_option_kernel(e, receiver, method, args, inner, stmts, cx) {
                    return s;
                }
            }
            Ty::Result(ok, err) => {
                if let Some(s) = lower_result_kernel(e, receiver, method, args, ok, err, stmts, cx)
                {
                    return s;
                }
            }
            _ => {}
        }
    }
    // Instance call: UFCS lowering with the receiver as first arg.
    let ns = cx
        .receiver_namespace(receiver)
        .unwrap_or_else(|| "/* unknown */".to_string());
    let recv = lower_expr(receiver, stmts, cx);
    let mut all = vec![recv];
    for a in args {
        all.push(lower_expr(a, stmts, cx));
    }
    format!("{ns}.{}({})", method.name, all.join(", "))
}

/// v0.22b: the typed JSON codec (ADR 0045). `encode` dispatches to the
/// module-local `serialise_*` helpers + `JSON.stringify`; `decode[T]` to
/// `JSON.parse` + `deserialise_*`, mapping a parse failure to a `Malformed`
/// JsonError and a BoundaryError to the uniform `kind`/`path`/`message`
/// record (ADR 0047). Returns `None` when the receiver is not `Json` or the
/// shape does not match, so the dispatcher falls through.
fn lower_json_codec_call(
    e: &Expr,
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    if let ExprKind::Ident(id) = &receiver.kind
        && id.name == JSON
        && args.len() == 1
    {
        if method.name == "encode"
            && let Some(arg_ty) = cx.commons.expr_types.get(&args[0].span).cloned()
            && let Some(tref) = ty_to_type_ref(&arg_ty)
        {
            let v = lower_expr(&args[0], stmts, cx);
            let ser = serialisation::serialise_expr(&tref, &v);
            return Some(format!("JSON.stringify({ser})"));
        }
        if method.name == "decode"
            && let Some(Ty::Result(t, _)) = cx.commons.expr_types.get(&e.span).cloned()
            && let Some(tref) = ty_to_type_ref(&t)
        {
            let ts = ts_ty(&t);
            let des = serialisation::deserialise_expr(&tref, "__j", "$");
            let arg = lower_expr(&args[0], stmts, cx);
            return Some(format!(
                "((__s: string): Result<{ts}, JsonError> => {{ \
                 let __j: JsonValue; \
                 try {{ __j = JSON.parse(__s) as JsonValue; }} \
                 catch (__e) {{ return Err({{ kind: \"Malformed\", path: \"$\", message: String(__e) }}); }} \
                 const __r = {des}; \
                 if (__r.tag === \"Ok\") return Ok(__r.value as {ts}); \
                 const __be = __r.error; \
                 return Err({{ kind: __be.kind, \
                 path: (__be.kind === \"StructuralMismatch\" || __be.kind === \"RefinementViolation\") ? __be.path : \"$\", \
                 message: __be.kind === \"StructuralMismatch\" ? `expected ${{__be.expected}}, got ${{String(__be.actual)}}` : __be.kind === \"RefinementViolation\" ? __be.violation.message : __be.details }}); }})({arg})"
            ));
        }
    }
    None
}

/// v0.6 cross-context service call: receiver is an alias or the dotted name
/// of a consumed context. In bundle mode, lower to
/// `deps.surface.<key>.<method>(args as <consumed_ns>.<T>)`; in workers mode
/// (v0.8), lower to
/// `callService(deps.env.<BINDING>, "<method>", serialise...(args), deserialise_<R>)`.
/// Returns `None` when the receiver is not a consumed-context prefix.
fn lower_cross_context_service_call(
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    if let Some((consumed, key)) = cross_context_lowering_prefix(receiver, cx) {
        cx.cross_context_used = true;
        match cx.target {
            BuildTarget::Bundle => {
                let args_lowered: Vec<String> = args
                    .iter()
                    .enumerate()
                    .map(|(i, a)| {
                        let lowered = lower_expr(a, stmts, cx);
                        param_cast(&consumed, cx.cross_context, method, i, lowered)
                    })
                    .collect();
                Some(format!(
                    "deps.surface.{key}.{}({})",
                    method.name,
                    args_lowered.join(", ")
                ))
            }
            BuildTarget::Workers => {
                let _ = key;
                Some(lower_workers_cross_context_call(
                    &consumed, method, args, stmts, cx,
                ))
            }
        }
    } else {
        None
    }
}

fn lower_mock(
    type_ref: &TypeRef,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    // Resolve the mocked type straight from the AST node rather than the
    // checker's `expr_types` side-table — the static type table is always
    // populated, whereas a test body's per-expression types may not be visible
    // to the emitter.
    let ty = match crate::checker::resolve_type_ref(type_ref, &cx.commons.types) {
        Some(t) => t,
        None => return "undefined /* mock: unresolved type */".to_string(),
    };
    // Refined literal pin → `T.unsafe(<literal>)`.
    if let (
        Some(arg),
        Ty::Named {
            name,
            kind: NamedKind::Refined(_),
        },
    ) = (args.first(), &ty)
    {
        let raw = lower_const_literal_raw(arg).unwrap_or_else(|| lower_expr(arg, stmts, cx));
        return format!("{name}.unsafe({raw})");
    }
    // Bare mock (refined / opaque / sum / record).
    mock_value(&ty, cx, MOCK_DEPTH)
}

/// When we encounter `lhs && rhs`, see if lhs is an `is` (possibly wrapped
/// in parens or nested `&&`) and if so collect the bindings to inject into
/// rhs. Returns `(binding_const_decls, lowered_lhs, lowered_rhs)` if
/// special handling is appropriate; otherwise returns None.
fn lower_and_with_is(
    lhs: &Expr,
    rhs: &Expr,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<(Vec<String>, String, String)> {
    // Probe structurally (no lowering) so a `&&` without an `is` falls through
    // to the caller's ordinary lowering untouched. This mirrors exactly the
    // shapes `gather_is_bindings_for_emit` walks (`&&` and parens), preserving
    // the original "Some iff lhs contains an `is`" behaviour.
    if !cond_contains_is(lhs) {
        return None;
    }
    // Lower lhs *before* gathering its bindings. Lowering lifts any complex
    // `is` receiver (e.g. a call) into a shared temp recorded on `cx`; the
    // binding gatherer below then references that temp via `is_receiver_text`
    // instead of re-emitting the receiver. For simple receivers nothing is
    // cached and the output is byte-identical to before.
    let lhs_expr = lower_expr(lhs, stmts, cx);
    let mut bindings = Vec::new();
    let mut found = false;
    gather_is_bindings_for_emit(lhs, cx, &mut bindings, &mut found);
    let _ = found; // guaranteed true by the `cond_contains_is` guard above
    // We lower rhs ourselves (not into the outer stmts), so that any
    // statement-style prefix from rhs is folded into the IIFE properly.
    let mut rhs_stmts = Vec::new();
    let rhs_expr = lower_expr(rhs, &mut rhs_stmts, cx);
    let mut rhs_full = String::new();
    for s in &rhs_stmts {
        rhs_full.push_str(s);
        rhs_full.push(' ');
    }
    rhs_full.push_str(&rhs_expr);
    Some((bindings, lhs_expr, rhs_full))
}

/// Walk an expression collecting `const name = expr.field;` strings for
/// any `is`-pattern bindings on the truthy path. `found` indicates whether
/// at least one `is` was seen.
fn gather_is_bindings_for_emit(e: &Expr, cx: &LowerCtx, out: &mut Vec<String>, found: &mut bool) {
    match &e.kind {
        ExprKind::Is { value, pattern } => {
            *found = true;
            let value_text = cx.is_receiver_text(value);
            let disc_ty = cx.commons.expr_types.get(&value.span).cloned();
            if let Pattern::Variant {
                variant, bindings, ..
            } = pattern
            {
                // v0.13: refinement narrowing re-binds the value's name to the
                // branded refined type, read from the forced receiver temp.
                if bindings.is_empty()
                    && cx.is_refined_is_check(value, &variant.name)
                    && let ExprKind::Ident(id) = &value.kind
                {
                    out.push(format!(
                        "const {name} = {value_text} as {refined};",
                        name = id.name,
                        refined = variant.name,
                    ));
                    return;
                }
                for (i, b) in bindings.iter().enumerate() {
                    if b.is_wildcard() {
                        continue;
                    }
                    match &b.kind {
                        PatternBindingKind::Named { field, name } => {
                            out.push(format!(
                                "const {name} = {value}.{field};",
                                name = name.name,
                                value = value_text,
                                field = field.name
                            ));
                        }
                        PatternBindingKind::Positional { name } => {
                            let field =
                                cx.positional_field_name(disc_ty.as_ref(), &variant.name, i);
                            out.push(format!(
                                "const {name} = {value}.{field};",
                                name = name.name,
                                value = value_text,
                                field = field
                            ));
                        }
                    }
                }
            }
        }
        ExprKind::BinOp(BinOp::And, l, r) => {
            gather_is_bindings_for_emit(l, cx, out, found);
            gather_is_bindings_for_emit(r, cx, out, found);
        }
        ExprKind::Paren(inner) => gather_is_bindings_for_emit(inner, cx, out, found),
        _ => {}
    }
}

/// True when an `is` receiver is a simple, side-effect-free, repeatable
/// lvalue — an identifier or a field-access chain ending at one (optionally
/// parenthesised). Such receivers can be referenced textually as many times
/// as there are pattern bindings without re-evaluation. Anything else (calls,
/// matches, arithmetic, …) must be lifted to a temp before use; see
/// `LowerCtx::is_receiver_ref`.
pub(crate) fn is_simple_is_receiver(value: &Expr) -> bool {
    match &value.kind {
        ExprKind::Ident(_) => true,
        ExprKind::FieldAccess { receiver, .. } => is_simple_is_receiver(receiver),
        ExprKind::Paren(inner) => is_simple_is_receiver(inner),
        _ => false,
    }
}

/// Render a *simple* `is` receiver (see `is_simple_is_receiver`) as a textual
/// reference for binding lookups. Complex receivers never reach this function
/// — they are lifted to a temp and resolved via the span cache in
/// `LowerCtx::is_receiver_text` — so the final arm is a defensive backstop the
/// `no_unknown_placeholder_in_emitted_output` test also guards against.
pub(crate) fn value_text_for_is(value: &Expr) -> String {
    match &value.kind {
        ExprKind::Ident(id) => id.name.clone(),
        ExprKind::FieldAccess { receiver, field } => {
            format!("{}.{}", value_text_for_is(receiver), field.name)
        }
        ExprKind::Paren(inner) => value_text_for_is(inner),
        _ => "(/* TODO: complex is-receiver */ )".to_string(),
    }
}

/// v0.20b: lower a built-in `List` kernel method. Returns None for a method
/// name the kernel doesn't own (the checker has already rejected it; this
/// keeps the dispatch defensive). All forms are pure expressions; `foldEff`
/// returns a Promise that the surrounding `<-` bind awaits.
fn lower_list_kernel(
    e: &Expr,
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    elem: &Ty,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    let elem_ts = ts_ty(elem);
    match (method.name.as_str(), args) {
        ("length", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("({recv}).length"))
        }
        ("get", [index]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let idx = lower_expr(index, stmts, cx);
            Some(format!(
                "((__xs: readonly {elem_ts}[], __i: number) => __i >= 0 && __i < __xs.length ? Some(__xs[__i] as {elem_ts}) : None)({recv}, {idx})"
            ))
        }
        ("prepend", [head]) => {
            let head = lower_expr(head, stmts, cx);
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("[{head}, ...{recv}]"))
        }
        ("fold", [init, f]) => {
            // The call's checked type is the accumulator type.
            let acc_ts = cx
                .commons
                .expr_types
                .get(&e.span)
                .map(ts_ty)
                .unwrap_or_else(|| "unknown".to_string());
            let recv = lower_expr(receiver, stmts, cx);
            let init = lower_expr(init, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "((__xs: readonly {elem_ts}[], __acc: {acc_ts}, __f: (acc: {acc_ts}, x: {elem_ts}) => {acc_ts}) => {{ for (const __x of __xs) __acc = __f(__acc, __x); return __acc; }})({recv}, {init}, {f})"
            ))
        }
        (FOLD_EFF, [init, f]) => {
            // The call's checked type is `Effect[Acc]` — peel for the TS
            // accumulator annotation.
            let acc_ts = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Effect(acc)) => ts_ty(acc),
                Some(other) => ts_ty(other),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let init = lower_expr(init, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "(async (__xs: readonly {elem_ts}[], __acc: {acc_ts}, __f: (acc: {acc_ts}, x: {elem_ts}) => Promise<{acc_ts}>) => {{ for (const __x of __xs) __acc = await __f(__acc, __x); return __acc; }})({recv}, {init}, {f})"
            ))
        }
        _ => None,
    }
}

/// v0.21/v0.22a: lower a built-in numeric kernel method. `toFloat` is the
/// identity at runtime (the Int/Float distinction is erased); everything
/// else maps onto `Math.*` / `Number.*`.
fn lower_numeric_kernel(
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    match (method.name.as_str(), args) {
        ("toFloat", []) => Some(lower_expr(receiver, stmts, cx)),
        ("round" | "floor" | "ceil" | "abs", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("Math.{}({recv})", method.name))
        }
        ("truncate", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("Math.trunc({recv})"))
        }
        ("min" | "max", [other]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let other = lower_expr(other, stmts, cx);
            Some(format!("Math.{}({recv}, {other})", method.name))
        }
        ("clamp", [lo, hi]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let lo = lower_expr(lo, stmts, cx);
            let hi = lower_expr(hi, stmts, cx);
            Some(format!("Math.min(Math.max({recv}, {lo}), {hi})"))
        }
        ("isNaN" | "isFinite", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("Number.{}({recv})", method.name))
        }
        // v0.42 (ADR 0074): host number→string — `String(n)` is ECMAScript's
        // Number::toString (shortest round-trip; `1e21`/`Infinity`/`NaN` as the
        // host renders them). The normative contract is the platform's.
        ("toString", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("String({recv})"))
        }
        _ => None,
    }
}

/// v0.22a: lower a built-in `String` kernel method (ADR 0046). Pinned
/// semantics: `replace` is replace-**all** (`replaceAll`); `chars()` is
/// code **points** (`[...s]`), not code units; `slice` clamps negative
/// indices to `0` (no TS wrap-around); `indexOf` turns `-1` into `None`.
fn lower_string_kernel(
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    match (method.name.as_str(), args) {
        ("length", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("({recv}).length"))
        }
        ("trim", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("{recv}.trim()"))
        }
        ("toUpper", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("{recv}.toUpperCase()"))
        }
        ("toLower", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("{recv}.toLowerCase()"))
        }
        ("chars", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("[...{recv}]"))
        }
        ("split", [sep]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let sep = lower_expr(sep, stmts, cx);
            Some(format!("{recv}.split({sep})"))
        }
        ("contains", [sub]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let sub = lower_expr(sub, stmts, cx);
            Some(format!("{recv}.includes({sub})"))
        }
        ("startsWith" | "endsWith", [sub]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let sub = lower_expr(sub, stmts, cx);
            Some(format!("{recv}.{}({sub})", method.name))
        }
        ("concat", [other]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let other = lower_expr(other, stmts, cx);
            Some(format!("{recv}.concat({other})"))
        }
        ("replace", [from, to]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let from = lower_expr(from, stmts, cx);
            let to = lower_expr(to, stmts, cx);
            Some(format!("{recv}.replaceAll({from}, {to})"))
        }
        ("slice", [lo, hi]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let lo = lower_expr(lo, stmts, cx);
            let hi = lower_expr(hi, stmts, cx);
            Some(format!(
                "{recv}.slice(Math.max(0, {lo}), Math.max(0, {hi}))"
            ))
        }
        ("indexOf", [sub]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let sub = lower_expr(sub, stmts, cx);
            Some(format!(
                "((__i: number) => __i < 0 ? None : Some(__i))({recv}.indexOf({sub}))"
            ))
        }
        _ => None,
    }
}

/// v0.22a: lower a built-in `Option[T]` kernel method (ADR 0048). Typed
/// IIFEs in the v0.20b posture — no runtime imports beyond the
/// `Some`/`None`/`Ok`/`Err` constructors every module already has.
#[allow(clippy::too_many_arguments)]
fn lower_option_kernel(
    e: &Expr,
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    inner: &Ty,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    let t = ts_ty(inner);
    match (method.name.as_str(), args) {
        ("map", [f]) => {
            // The call's checked type is `Option[B]` — peel for B.
            let b = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Option(b)) => ts_ty(b),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "((__o: Option<{t}>, __f: (x: {t}) => {b}) => __o.tag === \"Some\" ? Some(__f(__o.value)) : None)({recv}, {f})"
            ))
        }
        ("andThen", [f]) => {
            let b = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Option(b)) => ts_ty(b),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "((__o: Option<{t}>, __f: (x: {t}) => Option<{b}>) => __o.tag === \"Some\" ? __f(__o.value) : None)({recv}, {f})"
            ))
        }
        ("getOrElse", [fallback]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let fallback = lower_expr(fallback, stmts, cx);
            Some(format!(
                "((__o: Option<{t}>, __d: {t}) => __o.tag === \"Some\" ? __o.value : __d)({recv}, {fallback})"
            ))
        }
        ("isSome", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("({recv}.tag === \"Some\")"))
        }
        ("okOr", [error]) => {
            // The call's checked type is `Result[T, E]` — peel for E.
            let err = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Result(_, err)) => ts_ty(err),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let error = lower_expr(error, stmts, cx);
            Some(format!(
                "((__o: Option<{t}>, __e: {err}) => __o.tag === \"Some\" ? Ok(__o.value) : Err(__e))({recv}, {error})"
            ))
        }
        _ => None,
    }
}

/// v0.22a: lower a built-in `Result[T, E]` kernel method (ADR 0048). The
/// miss branches return the narrowed receiver — TS's discriminated-union
/// narrowing makes the `Err` arm assignable to `Result<B, E>` directly.
#[allow(clippy::too_many_arguments)]
fn lower_result_kernel(
    e: &Expr,
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    ok: &Ty,
    err: &Ty,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    let t = ts_ty(ok);
    let et = ts_ty(err);
    match (method.name.as_str(), args) {
        ("map", [f]) => {
            // The call's checked type is `Result[B, E]` — peel for B.
            let b = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Result(b, _)) => ts_ty(b),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "((__r: Result<{t}, {et}>, __f: (x: {t}) => {b}) => __r.tag === \"Ok\" ? Ok(__f(__r.value)) : __r)({recv}, {f})"
            ))
        }
        ("andThen", [f]) => {
            let b = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Result(b, _)) => ts_ty(b),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "((__r: Result<{t}, {et}>, __f: (x: {t}) => Result<{b}, {et}>) => __r.tag === \"Ok\" ? __f(__r.value) : __r)({recv}, {f})"
            ))
        }
        ("mapErr", [f]) => {
            // The call's checked type is `Result[T, F]` — peel for F.
            let fts = match cx.commons.expr_types.get(&e.span) {
                Some(Ty::Result(_, f)) => ts_ty(f),
                _ => "unknown".to_string(),
            };
            let recv = lower_expr(receiver, stmts, cx);
            let f = lower_expr(f, stmts, cx);
            Some(format!(
                "((__r: Result<{t}, {et}>, __f: (e: {et}) => {fts}) => __r.tag === \"Err\" ? Err(__f(__r.error)) : __r)({recv}, {f})"
            ))
        }
        ("getOrElse", [fallback]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let fallback = lower_expr(fallback, stmts, cx);
            Some(format!(
                "((__r: Result<{t}, {et}>, __d: {t}) => __r.tag === \"Ok\" ? __r.value : __d)({recv}, {fallback})"
            ))
        }
        ("isOk", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("({recv}.tag === \"Ok\")"))
        }
        _ => None,
    }
}

/// v0.20b: lower a built-in `Map` kernel method. `insert` copies — the
/// emitted `ReadonlyMap` is never mutated in place; updating an existing key
/// keeps its insertion position (JS `Map` semantics, normative in §7).
fn lower_map_kernel(
    receiver: &Expr,
    method: &Ident,
    args: &[Expr],
    key: &Ty,
    val: &Ty,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> Option<String> {
    let key_ts = ts_ty(key);
    let val_ts = ts_ty(val);
    match (method.name.as_str(), args) {
        ("length", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("({recv}).size"))
        }
        ("keys", []) => {
            let recv = lower_expr(receiver, stmts, cx);
            Some(format!("[...({recv}).keys()]"))
        }
        ("get", [k]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let k = lower_expr(k, stmts, cx);
            Some(format!(
                "((__m: ReadonlyMap<{key_ts}, {val_ts}>, __k: {key_ts}) => __m.has(__k) ? Some(__m.get(__k) as {val_ts}) : None)({recv}, {k})"
            ))
        }
        ("insert", [k, v]) => {
            let recv = lower_expr(receiver, stmts, cx);
            let k = lower_expr(k, stmts, cx);
            let v = lower_expr(v, stmts, cx);
            Some(format!("new Map({recv}).set({k}, {v})"))
        }
        _ => None,
    }
}

fn lower_if(
    cond: &Expr,
    then_block: &Block,
    else_block: &Block,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    let cond_expr = lower_expr(cond, stmts, cx);
    // If the cond contains `is`-bindings, the then-branch needs a place
    // for the `const name = receiver.field;` declarations — a ternary
    // has no such place. Force the IIFE form.
    if both_simple(then_block, else_block) && !cond_has_is_bindings(cond, cx) {
        let mut tstmts = Vec::new();
        let testr = lower_expr(&then_block.tail, &mut tstmts, cx);
        debug_assert!(tstmts.is_empty());
        let mut estmts = Vec::new();
        let eestr = lower_expr(&else_block.tail, &mut estmts, cx);
        debug_assert!(estmts.is_empty());
        format!("({cond_expr} ? {testr} : {eestr})")
    } else {
        let mut iife = String::new();
        iife.push_str("(() => {\n");
        iife.push_str("    if (");
        iife.push_str(&cond_expr);
        iife.push_str(") {\n");
        // Inject is-binding declarations on the truthy side.
        let mut is_bindings = Vec::new();
        let mut found = false;
        gather_is_bindings_for_emit(cond, cx, &mut is_bindings, &mut found);
        for b in &is_bindings {
            for _ in 0..(INDENT_STEP * 3) {
                iife.push(' ');
            }
            iife.push_str(b);
            iife.push('\n');
        }
        emit_block_as_function_body(&mut iife, then_block, cx, INDENT_STEP * 3, false);
        for _ in 0..(INDENT_STEP * 2) {
            iife.push(' ');
        }
        iife.push_str("} else {\n");
        emit_block_as_function_body(&mut iife, else_block, cx, INDENT_STEP * 3, false);
        for _ in 0..(INDENT_STEP * 2) {
            iife.push(' ');
        }
        iife.push_str("}\n");
        for _ in 0..INDENT_STEP {
            iife.push(' ');
        }
        iife.push_str("})()");
        iife
    }
}

/// True if `e` contains an `is` test reachable through `&&` and parentheses —
/// matching exactly the shapes `gather_is_bindings_for_emit` walks (note: it
/// does *not* descend into `||`). Used by `lower_and_with_is` to decide whether
/// the `is`-binding flow applies before doing any lowering.
fn cond_contains_is(e: &Expr) -> bool {
    match &e.kind {
        ExprKind::Is { .. } => true,
        ExprKind::BinOp(BinOp::And, l, r) => cond_contains_is(l) || cond_contains_is(r),
        ExprKind::Paren(inner) => cond_contains_is(inner),
        _ => false,
    }
}

/// True if the expression contains an `is` test with at least one
/// non-wildcard binding. Walks through `&&`, `||`, and parens.
fn cond_has_is_bindings(e: &Expr, cx: &LowerCtx) -> bool {
    match &e.kind {
        ExprKind::Is {
            value,
            pattern: Pattern::Variant {
                variant, bindings, ..
            },
        } => {
            bindings.iter().any(|b| !b.is_wildcard())
                // v0.13: a refined `is`-narrowing introduces a (shadow) binding
                // even though the pattern carries none.
                || (bindings.is_empty() && cx.is_refined_is_check(value, &variant.name))
        }
        ExprKind::BinOp(BinOp::And, l, r) | ExprKind::BinOp(BinOp::Or, l, r) => {
            cond_has_is_bindings(l, cx) || cond_has_is_bindings(r, cx)
        }
        ExprKind::Paren(inner) => cond_has_is_bindings(inner, cx),
        _ => false,
    }
}

fn emit_if_tail(
    out: &mut String,
    cond: &Expr,
    then_block: &Block,
    else_block: &Block,
    cx: &mut LowerCtx,
    indent: usize,
    async_tail: bool,
) {
    let mut pre = Vec::new();
    let cond_expr = lower_expr(cond, &mut pre, cx);
    for s in &pre {
        write_line(out, indent, s);
    }
    write_line(out, indent, &format!("if ({cond_expr}) {{"));
    // is-binding declarations on the truthy path.
    let mut is_bindings = Vec::new();
    let mut found = false;
    gather_is_bindings_for_emit(cond, cx, &mut is_bindings, &mut found);
    for b in &is_bindings {
        write_line(out, indent + INDENT_STEP, b);
    }
    emit_block_as_function_body(out, then_block, cx, indent + INDENT_STEP, async_tail);
    write_line(out, indent, "} else {");
    emit_block_as_function_body(out, else_block, cx, indent + INDENT_STEP, async_tail);
    write_line(out, indent, "}");
}

fn both_simple(a: &Block, b: &Block) -> bool {
    a.statements.is_empty()
        && b.statements.is_empty()
        && simple_expr(&a.tail)
        && simple_expr(&b.tail)
}

fn simple_expr(e: &Expr) -> bool {
    match &e.kind {
        ExprKind::Question(_) => false,
        ExprKind::Match { .. } => false,
        ExprKind::Block(b) => b.statements.is_empty() && simple_expr(&b.tail),
        ExprKind::If {
            then_block,
            else_block,
            cond,
        } => simple_expr(cond) && both_simple(then_block, else_block),
        ExprKind::Ok(i) | ExprKind::Err(i) | ExprKind::Some(i) => simple_expr(i),
        ExprKind::Paren(i) | ExprKind::UnaryOp(_, i) => simple_expr(i),
        ExprKind::BinOp(_, l, r) => simple_expr(l) && simple_expr(r),
        ExprKind::Call { args, .. } | ExprKind::ConstructorCall { args, .. } => {
            args.iter().all(simple_expr)
        }
        ExprKind::MethodCall { receiver, args, .. } => {
            simple_expr(receiver) && args.iter().all(simple_expr)
        }
        ExprKind::FieldAccess { receiver, .. } => simple_expr(receiver),
        ExprKind::RecordConstruction { fields, .. } => fields.iter().all(|f| match &f.value {
            Some(v) => simple_expr(v),
            None => true,
        }),
        ExprKind::Is { value, .. } => simple_expr(value),
        _ => true,
    }
}

fn lower_ident(e: &Expr, id: &Ident, cx: &mut LowerCtx) -> String {
    // v0.9: a nullary HttpResult variant (whose checker type is
    // `HttpResult[_]`) constructs an HttpResult.<Variant>.
    if matches!(cx.commons.expr_types.get(&e.span), Some(Ty::HttpResult(_)))
        && http_variant(&id.name).is_some()
    {
        return format!("HttpResult.{}", id.name);
    }
    // v0.44: a nullary QueueResult variant (`Ack`) constructs `QueueResult.Ack`.
    if matches!(cx.commons.expr_types.get(&e.span), Some(Ty::QueueResult))
        && crate::ast::queue_variant(&id.name).is_some()
    {
        return format!("QueueResult.{}", id.name);
    }
    // A bare ident whose name matches a declared variant of a sum
    // type (and whose checker type is that sum) is a nullary
    // variant constructor reference. Qualify it as `Type.Variant`.
    // Otherwise (locals, params, `self`) emit the identifier as-is.
    if let Some(Ty::Named {
        kind: NamedKind::Sum,
        name: type_name,
    }) = cx.commons.expr_types.get(&e.span)
        && let Some(decl) = cx.commons.types.get(type_name)
        && let TypeBody::Sum(s) = &decl.body
        && s.variants.iter().any(|v| v.name.name == id.name)
    {
        return format!("{}.{}", type_name, id.name);
    }
    // v0.52: the multi-actor sum binder is not a runtime local — the resolved
    // actor is threaded through `deps.who` at the boundary wrapper, so the
    // binder ident lowers to it (the tagged union the body `match`es).
    if cx.actor_sum_binder.as_deref() == Some(id.name.as_str())
        && matches!(cx.commons.expr_types.get(&e.span), Some(Ty::ActorSum(_)))
    {
        return "deps.who".to_string();
    }
    id.name.clone()
}

fn lower_call(
    e: &Expr,
    name: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    // Bare variant constructor with payload → qualify.
    let args_lowered: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
    // v0.9: HttpResult variant call.
    if matches!(cx.commons.expr_types.get(&e.span), Some(Ty::HttpResult(_)))
        && http_variant(&name.name).is_some()
    {
        return format!("HttpResult.{}({})", name.name, args_lowered.join(", "));
    }
    // v0.44: a QueueResult variant call (`Retry(reason)`) → `QueueResult.Retry(...)`.
    if matches!(cx.commons.expr_types.get(&e.span), Some(Ty::QueueResult))
        && crate::ast::queue_variant(&name.name).is_some()
    {
        return format!("QueueResult.{}({})", name.name, args_lowered.join(", "));
    }
    // v0.9.2: agent instantiation `AgentName(key)` lowers to the
    // generated `__makeAgentName(key)` factory, which obtains the
    // instance for this key (lookup-or-create against the registry in
    // bundle mode, or a typed DO proxy in workers mode). Skipped when
    // this Call is the receiver of a MethodCall — that path folds
    // construction and the method invocation together.
    if cx.local_agents.contains(&name.name) && args_lowered.len() == 1 {
        return cx.agent_construct(&name.name, &args_lowered[0]);
    }
    if let Some(Ty::Named {
        kind: NamedKind::Sum,
        name: type_name,
    }) = cx.commons.expr_types.get(&e.span)
        && type_name != &name.name
        && call_is_sum_variant(cx, type_name, &name.name)
    {
        return format!("{}.{}({})", type_name, name.name, args_lowered.join(", "));
    }
    format!("{}({})", name.name, args_lowered.join(", "))
}

fn lower_bin_op(
    op: BinOp,
    lhs: &Expr,
    rhs: &Expr,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    // For `&&` we need to lower `is` bindings into the rhs scope.
    // We handle that here by collecting bindings from lhs, emitting
    // them as `const` declarations before evaluating rhs — but
    // `&&` short-circuits, so simply emitting them inline is wrong.
    // We compile `lhs && (...is binding flow...) rhs` to a function
    // expression: `(lhs && ((bindings) => rhs)())`. Simpler: rely
    // on TypeScript's narrowing for the value-from-is part of
    // `is` patterns. For now, for the special pattern `x is Ok(n)`
    // we lower the rhs assuming the binding `n = x.value` was
    // captured. We use a parenthesised IIFE to scope the binding.
    if op == BinOp::And
        && let Some((bindings, lhs_expr, rhs_expr)) = lower_and_with_is(lhs, rhs, stmts, cx)
    {
        if bindings.is_empty() {
            return format!("{lhs_expr} && {rhs_expr}");
        }
        // Emit:  lhs && (() => { const n = ...; return rhs; })()
        let mut wrap = String::new();
        wrap.push_str(&lhs_expr);
        wrap.push_str(" && ((() => { ");
        for b in &bindings {
            wrap.push_str(b);
            wrap.push(' ');
        }
        wrap.push_str(&format!("return {rhs_expr}; }})())"));
        return wrap;
    }
    let l = lower_expr(lhs, stmts, cx);
    let r = lower_expr(rhs, stmts, cx);
    if op == BinOp::Div {
        // v0.21: division is operand-typed (ADR 0042) — `Float`
        // true-divides; `Int` keeps truncating. The checker rejects
        // mixed operands, so the left operand decides; a missing
        // type entry falls back to the `Int` (truncating) lowering.
        let lhs_is_float =
            cx.commons.expr_types.get(&lhs.span).and_then(|t| t.base()) == Some(BaseType::Float);
        if lhs_is_float {
            format!("{l} / {r}")
        } else {
            format!("Math.trunc({l} / {r})")
        }
    } else {
        format!("{l} {} {r}", ts_binop(op))
    }
}

fn lower_constructor_call(
    type_name: &Ident,
    method: &Ident,
    args: &[Expr],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    let args: Vec<String> = args.iter().map(|a| lower_expr(a, stmts, cx)).collect();
    // Nullary variant qualified construction: `T.V` (no parens) at the
    // source level wouldn't reach here, so `T.V()` always means call.
    format!("{}.{}({})", type_name.name, method.name, args.join(", "))
}

fn lower_record_construction(
    type_name: &Ident,
    fields: &[FieldInit],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    let mut parts = Vec::new();
    for f in fields {
        match &f.value {
            Some(v) => {
                let val = lower_expr(v, stmts, cx);
                parts.push(format!("{}: {}", f.name.name, val));
            }
            None => parts.push(f.name.name.clone()),
        }
    }
    let _ = type_name;
    format!("{{ {} }}", parts.join(", "))
}

fn lower_field_access(
    receiver: &Expr,
    field: &Ident,
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    // v0.9: `HttpResult.Variant` (nullary).
    if let ExprKind::Ident(id) = &receiver.kind
        && id.name == HTTP_RESULT
        && http_variant(&field.name).is_some()
    {
        return format!("HttpResult.{}", field.name);
    }
    // Agent-handler `self.state` and `self.<key>` rewrites.
    if cx.in_agent_handler
        && let ExprKind::Ident(id) = &receiver.kind
        && id.name == "self"
    {
        if field.name == "state"
            && let Some(s) = &cx.agent_state_var
        {
            return s.clone();
        }
        if let Some(k) = &cx.agent_key_field
            && field.name == *k
        {
            return format!("(this.state.id.toString() as {})", k);
        }
    }
    // v0.45: `<binder>.identity` on a verified actor binding. The binder is not
    // a runtime value; the identity is minted at the verification seam. For the
    // zero-crypto schemes the sealed identity carries no payload, so it lowers
    // to the unit value (`undefined`). Authenticated identities (Bearer/
    // Signature) and the calling-context value arrive with their later slices.
    //
    // v0.47: a Bearer handler's identity is minted at the verification seam and
    // threaded through `deps`, so `<binder>.identity` reads `deps.identity`.
    // Other (unit) identities — `None`/`Internal` actors — carry no payload and
    // stay the unit value `undefined`.
    if field.name == "identity"
        && matches!(
            cx.commons.expr_types.get(&receiver.span),
            Some(Ty::Actor(_))
        )
    {
        if let (Some(binder), ExprKind::Ident(id)) = (&cx.deps_identity_binder, &receiver.kind)
            && &id.name == binder
        {
            return "deps.identity".to_string();
        }
        return "undefined".to_string();
    }
    let r = lower_expr(receiver, stmts, cx);
    // `.raw` on an opaque value compiles to a TypeScript type
    // assertion back to the base type. The checker has already
    // verified that the receiver is opaque and the call site is
    // inside the defining commons.
    if field.name == RAW
        && let Some(Ty::Named {
            kind: NamedKind::Opaque(base),
            ..
        }) = cx.commons.expr_types.get(&receiver.span)
    {
        return format!("({r} as {})", ts_base(*base));
    }
    format!("{r}.{}", field.name)
}

fn lower_lambda(e: &Expr, lambda: &LambdaExpr, cx: &mut LowerCtx) -> String {
    let is_async = matches!(
        cx.commons.expr_types.get(&e.span),
        Some(crate::checker::Ty::Fn { ret, .. }) if ret.is_effect()
    );
    let prefix = if is_async { "async " } else { "" };
    let params: Vec<String> = lambda
        .params
        .iter()
        .map(|p| match &p.type_ref {
            Some(tr) => format!("{}: {}", p.name.name, ts_type_ref(tr)),
            None => p.name.name.clone(),
        })
        .collect();
    let params = params.join(", ");
    match &lambda.body.kind {
        ExprKind::Block(b) => {
            let mut out = format!("{prefix}({params}) => {{\n");
            emit_block_as_function_body(&mut out, b, cx, INDENT_STEP * 2, is_async);
            for _ in 0..INDENT_STEP {
                out.push(' ');
            }
            out.push('}');
            out
        }
        _ => {
            let mut body_stmts: Vec<String> = Vec::new();
            let body = lower_expr(&lambda.body, &mut body_stmts, cx);
            if body_stmts.is_empty() {
                format!("{prefix}({params}) => {body}")
            } else {
                let mut out = format!("{prefix}({params}) => {{\n");
                for s in &body_stmts {
                    out.push_str(s);
                    out.push('\n');
                }
                out.push_str(&format!("  return {body};\n}}"));
                out
            }
        }
    }
}

fn lower_record_spread(
    base: &Expr,
    overrides: &[FieldInit],
    stmts: &mut Vec<String>,
    cx: &mut LowerCtx,
) -> String {
    let base_expr = lower_expr(base, stmts, cx);
    let mut parts = vec![format!("...{base_expr}")];
    for f in overrides {
        match &f.value {
            Some(v) => {
                let val = lower_expr(v, stmts, cx);
                parts.push(format!("{}: {}", f.name.name, val));
            }
            None => parts.push(f.name.name.clone()),
        }
    }
    format!("{{ {} }}", parts.join(", "))
}

fn lower_block_as_expr(b: &Block, cx: &mut LowerCtx) -> String {
    let mut iife = String::new();
    iife.push_str("(() => {\n");
    // IIFE is a synchronous arrow function; the surrounding expression context
    // expects a concrete value, so `Effect.pure(...)` must still wrap as
    // `Promise.resolve(...)`.
    emit_block_as_function_body(&mut iife, b, cx, INDENT_STEP * 2, false);
    for _ in 0..INDENT_STEP {
        iife.push(' ');
    }
    iife.push_str("})()");
    iife
}

fn lower_match_as_iife(discriminant: &Expr, arms: &[MatchArm], cx: &mut LowerCtx) -> String {
    let disc_ty = cx.commons.expr_types.get(&discriminant.span).cloned();
    let mut stmts = Vec::new();
    let disc = lower_expr(discriminant, &mut stmts, cx);
    let mut iife = String::new();
    // Pre-statements need to be evaluated before the IIFE; lift them into
    // a sequence: `(prestmt1, prestmt2, iife)`. Since TS doesn't let us
    // evaluate statements inline, we wrap in another arrow.
    let inner_iife = maybe_async_iife(build_match_iife(&disc, &disc_ty, arms, cx));
    if stmts.is_empty() {
        iife.push_str(&inner_iife);
    } else {
        iife.push_str("(() => {\n");
        for s in &stmts {
            for _ in 0..(INDENT_STEP * 2) {
                iife.push(' ');
            }
            iife.push_str(s);
            iife.push('\n');
        }
        for _ in 0..(INDENT_STEP * 2) {
            iife.push(' ');
        }
        iife.push_str("return ");
        iife.push_str(&inner_iife);
        iife.push_str(";\n");
        for _ in 0..INDENT_STEP {
            iife.push(' ');
        }
        iife.push_str("})()");
        iife = maybe_async_iife(iife);
    }
    iife
}

/// v0.9.2: a match lowered to an IIFE in *expression* position may have arms
/// that `await` (an effectful `let x <- …` or an effectful tail). A synchronous
/// arrow can't host `await`, so when the lowered body contains one, make the
/// outermost arrow `async` and `await` its call. Nested matches that already
/// did this surface their own `await`, which propagates the transform outward.
fn maybe_async_iife(iife: String) -> String {
    if !iife.contains("await ") {
        return iife;
    }
    let async_iife = if let Some(rest) = iife.strip_prefix("((__d) =>") {
        format!("(async (__d) =>{rest}")
    } else if let Some(rest) = iife.strip_prefix("(() => {") {
        format!("(async () => {{{rest}")
    } else {
        return iife;
    };
    format!("await {async_iife}")
}

fn build_match_iife(
    disc_expr: &str,
    disc_ty: &Option<Ty>,
    arms: &[MatchArm],
    cx: &mut LowerCtx,
) -> String {
    let mut out = String::new();
    out.push_str("((__d) => {\n");
    for _ in 0..(INDENT_STEP * 2) {
        out.push(' ');
    }
    out.push_str("switch (__d.tag) {\n");
    for arm in arms {
        // IIFE form (non-tail match expression): `Effect.pure(...)` must keep
        // its `Promise.resolve` wrapper because the IIFE is a synchronous arrow.
        emit_match_case(&mut out, "__d", disc_ty, arm, cx, INDENT_STEP * 3, false);
    }
    for _ in 0..(INDENT_STEP * 2) {
        out.push(' ');
    }
    out.push_str("}\n");
    for _ in 0..(INDENT_STEP * 2) {
        out.push(' ');
    }
    out.push_str("throw new Error(\"non-exhaustive match\");\n");
    for _ in 0..INDENT_STEP {
        out.push(' ');
    }
    out.push_str(&format!("}})({disc_expr})"));
    out
}

/// Whether an expression lowers to a stable reference TypeScript can narrow
/// across a `switch` (a variable or a property path rooted in one). Calls and
/// other computed expressions are not narrowable and must be bound to a temp.
fn is_narrowable_path(e: &Expr) -> bool {
    match &e.kind {
        ExprKind::Ident(_) => true,
        ExprKind::FieldAccess { receiver, .. } => is_narrowable_path(receiver),
        ExprKind::Paren(inner) => is_narrowable_path(inner),
        _ => false,
    }
}

fn emit_match_tail(
    out: &mut String,
    discriminant: &Expr,
    arms: &[MatchArm],
    cx: &mut LowerCtx,
    indent: usize,
    async_tail: bool,
) {
    let mut pre = Vec::new();
    let mut disc = lower_expr(discriminant, &mut pre, cx);
    let disc_ty = cx.commons.expr_types.get(&discriminant.span).cloned();
    for s in &pre {
        write_line(out, indent, s);
    }
    // v0.9.2: a statement-position `switch` narrows the scrutinee only when it
    // is a stable reference (a variable or property path). A call discriminant
    // such as `ShortCode.of(raw)` is re-evaluated per arm and TypeScript cannot
    // narrow it (and re-evaluation could repeat side effects), so bind it to a
    // fresh temp once and switch on that.
    if !is_narrowable_path(discriminant) {
        let tmp = cx.fresh();
        write_line(out, indent, &format!("const {tmp} = {disc};"));
        disc = tmp;
    }
    write_line(out, indent, &format!("switch ({disc}.tag) {{"));
    for arm in arms {
        emit_match_case(
            out,
            &disc,
            &disc_ty,
            arm,
            cx,
            indent + INDENT_STEP,
            async_tail,
        );
    }
    write_line(out, indent, "}");
    write_line(out, indent, "throw new Error(\"non-exhaustive match\");");
}

fn emit_match_case(
    out: &mut String,
    disc_var: &str,
    disc_ty: &Option<Ty>,
    arm: &MatchArm,
    cx: &mut LowerCtx,
    indent: usize,
    async_tail: bool,
) {
    match &arm.pattern {
        Pattern::Wildcard(_) => {
            write_line(out, indent, "default: {");
            emit_match_body(out, &arm.body, cx, indent + INDENT_STEP, async_tail);
            write_line(out, indent, "}");
        }
        Pattern::Variant {
            variant, bindings, ..
        } => {
            write_line(
                out,
                indent,
                &format!("case \"{tag}\": {{", tag = variant.name),
            );
            for (i, b) in bindings.iter().enumerate() {
                if b.is_wildcard() {
                    continue;
                }
                let field = match &b.kind {
                    PatternBindingKind::Named { field, .. } => field.name.clone(),
                    PatternBindingKind::Positional { .. } => {
                        cx.positional_field_name(disc_ty.as_ref(), &variant.name, i)
                    }
                };
                let local = b.local_name().name.clone();
                write_line(
                    out,
                    indent + INDENT_STEP,
                    &format!("const {local} = {disc_var}.{field};"),
                );
            }
            emit_match_body(out, &arm.body, cx, indent + INDENT_STEP, async_tail);
            write_line(out, indent, "}");
        }
    }
}

fn emit_match_body(
    out: &mut String,
    body: &MatchBody,
    cx: &mut LowerCtx,
    indent: usize,
    async_tail: bool,
) {
    match body {
        MatchBody::Expr(e) => {
            let mut stmts = Vec::new();
            let v = lower_tail_expr(e, &mut stmts, cx, async_tail);
            for s in &stmts {
                write_line(out, indent, s);
            }
            write_line(out, indent, &format!("return {v};"));
        }
        MatchBody::Block(b) => emit_block_as_function_body(out, b, cx, indent, async_tail),
    }
}

fn lower_is(value: &Expr, pattern: &Pattern, stmts: &mut Vec<String>, cx: &mut LowerCtx) -> String {
    // v0.13: refinement check — `value is RefinedType` lowers to the refined
    // type's predicates as a boolean expression. The receiver is forced to a
    // temp so the narrowing binding (`const n = <temp> as Quantity`) can shadow
    // the value's name without a TDZ.
    if let Pattern::Variant {
        variant, bindings, ..
    } = pattern
        && bindings.is_empty()
        && cx.is_refined_is_check(value, &variant.name)
        && let Some(TypeBody::Refined {
            base, refinement, ..
        }) = cx.commons.types.get(&variant.name).map(|d| d.body.clone())
    {
        let recv = cx.is_receiver_ref_forced(value, stmts);
        return refined_check_as_bool(&recv, base, refinement.as_ref());
    }
    let v = cx.is_receiver_ref(value, stmts);
    match pattern {
        Pattern::Wildcard(_) => "true".to_string(),
        Pattern::Variant { variant, .. } => {
            format!("{v}.tag === \"{}\"", variant.name)
        }
    }
}

/// v0.13: render a refined type's predicates as a single boolean expression over
/// `recv`, for `value is RefinedType`. Mirrors `emit_pred_check`'s per-predicate
/// logic but as `&&`-joined terms instead of `Result`-returning statements.
fn refined_check_as_bool(recv: &str, base: BaseType, refinement: Option<&Refinement>) -> String {
    let mut terms: Vec<String> = Vec::new();
    if base == BaseType::Int {
        terms.push(format!("Number.isInteger({recv})"));
    }
    // v0.21: validated `Float` values are finite (ADR 0040).
    if base == BaseType::Float {
        terms.push(format!("Number.isFinite({recv})"));
    }
    if let Some(r) = refinement {
        for p in &r.predicates {
            terms.push(match &p.kind {
                PredKind::NonNegative => format!("{recv} >= 0"),
                PredKind::Positive => format!("{recv} > 0"),
                PredKind::InRange(a, b) => {
                    format!("({recv} >= {} && {recv} <= {})", a.value, b.value)
                }
                PredKind::InRangeF(a, b) => {
                    format!("({recv} >= {} && {recv} <= {})", a.lexeme, b.lexeme)
                }
                PredKind::NonEmpty => format!("{recv}.length > 0"),
                PredKind::MinLength(n) => format!("{recv}.length >= {n}"),
                PredKind::MaxLength(n) => format!("{recv}.length <= {n}"),
                PredKind::Length(n) => format!("{recv}.length === {n}"),
                PredKind::Matches(pat) => {
                    let escaped = escape_ts_string(pat);
                    format!("new RegExp(\"^\" + \"{escaped}\" + \"$\").test({recv})")
                }
            });
        }
    }
    if terms.is_empty() {
        "true".to_string()
    } else {
        format!("({})", terms.join(" && "))
    }
}
