use std::collections::HashMap;

use crate::diag::{Diag, DiagCollector, DiagConfig, Severity};
use crate::ir::error::{
    lower_arg_count, lower_div_by_zero, lower_float_precision_loss, lower_implicit_promotion,
    lower_implicit_promotion_with_operands, lower_int_out_of_range, lower_redundant_cast_chain,
    lower_tuple_arity, lower_type_mismatch, lower_undefined_var, lower_unknown_function,
    lower_unsafe_uint_to_sint,
};
use crate::ir::{
    BinaryOp, Builtin, Const, DType, DTypeKind, FloatEncoding, Node, NodeId, ParamId, TypedDag,
    TypedDagError, UnaryOp, promote,
};
use crate::parse::ast::{Expr, FuncDef, Span, Spanned};

// ── Error-handling strategy ─────────────────────────────────────────────────
//
// Two modes share a single lowering implementation (Rule of Separation).
// `FailFast` stops at the first error; `Collect` gathers all errors and
// inserts placeholder nodes so lowering can continue.

enum DiagMode {
    /// Stop on the first error-severity diagnostic.
    FailFast(DiagCollector),
    /// Accumulate all diagnostics; never short-circuit.
    Collect {
        config: DiagConfig,
        diags: Vec<Diag>,
        errors: Vec<Diag>,
    },
}

// ── Lowering context ────────────────────────────────────────────────────────

struct Ctx {
    nodes: Vec<Node>,
    dtypes: Vec<DType>,
    symbols: HashMap<String, NodeId>,
    mode: DiagMode,
}

impl Ctx {
    fn new_fail_fast(config: DiagConfig) -> Self {
        Self {
            nodes: Vec::new(),
            dtypes: Vec::new(),
            symbols: HashMap::new(),
            mode: DiagMode::FailFast(DiagCollector::new(config)),
        }
    }

    fn new_collect(config: DiagConfig) -> Self {
        Self {
            nodes: Vec::new(),
            dtypes: Vec::new(),
            symbols: HashMap::new(),
            mode: DiagMode::Collect {
                config,
                diags: Vec::new(),
                errors: Vec::new(),
            },
        }
    }

    // ── Node operations ──────────────────────────────────────────────────

    fn push(&mut self, node: Node, dtype: DType) -> NodeId {
        let id = NodeId(self.nodes.len());
        self.nodes.push(node);
        self.dtypes.push(dtype);
        id
    }

    fn dtype_of(&self, id: NodeId) -> DType {
        self.dtypes[id.0]
    }

    fn node_at(&self, id: NodeId) -> &Node {
        &self.nodes[id.0]
    }

    /// Insert an implicit `Cast` node only when the source type differs from `target`.
    fn cast_if_needed(&mut self, id: NodeId, target: DType) -> NodeId {
        if self.dtype_of(id) == target {
            return id;
        }
        self.push(Node::Cast(target, id, false), target)
    }

    fn placeholder(&mut self, dtype: DType) -> NodeId {
        let c = match dtype.kind {
            DTypeKind::Bool => Const::Bool(false),
            DTypeKind::Float(_) | DTypeKind::Complex(_) => Const::Float(0.0),
            _ => Const::Int(0),
        };
        self.push(Node::Const(c), dtype)
    }

    fn finish(self, params: Vec<(String, DType)>, outputs: Vec<NodeId>) -> (TypedDag, DiagMode) {
        let dag = TypedDag::try_new(self.nodes, self.dtypes, params, outputs)
            .unwrap_or_else(|e: TypedDagError| panic!("lower produced invalid TypedDag: {e}"));
        (dag, self.mode)
    }

    // ── Diagnostics ──────────────────────────────────────────────────────

    /// Emit a diagnostic.  Returns `Err` only in fail-fast mode on error-severity.
    fn emit(&mut self, diag: Diag) -> Result<(), Diag> {
        match &mut self.mode {
            DiagMode::FailFast(collector) => collector.emit(diag),
            DiagMode::Collect {
                config,
                diags,
                errors,
            } => {
                match config.severity(diag.code) {
                    Severity::Allow => {}
                    Severity::Info | Severity::Warning => diags.push(diag),
                    Severity::Error | Severity::Fatal => errors.push(diag),
                }
                Ok(())
            }
        }
    }

    /// Called when an expression cannot be lowered.  In collect mode,
    /// returns a placeholder; in fail-fast mode, propagates the error.
    fn on_error(&mut self, diag: Diag, placeholder_dtype: DType) -> Result<NodeId, Diag> {
        match &mut self.mode {
            DiagMode::FailFast(_) => Err(diag),
            DiagMode::Collect { .. } => {
                self.emit(diag)?;
                Ok(self.placeholder(placeholder_dtype))
            }
        }
    }

    /// Lower a sub-expression and extract the first (typically only) output.
    /// On error in collect mode, inserts a placeholder.
    fn lower_single(
        &mut self,
        expr: &Spanned<Expr>,
        placeholder_dtype: DType,
    ) -> Result<NodeId, Diag> {
        match lower_expr(self, expr) {
            Ok(ids) => match ids.as_slice() {
                [id] => Ok(*id),
                many => self.on_error(
                    lower_tuple_arity(1, many.len(), span_of(expr.span)),
                    placeholder_dtype,
                ),
            },
            Err(diag) => self.on_error(diag, placeholder_dtype),
        }
    }
}

// ── Shared utilities ────────────────────────────────────────────────────────

fn span_of(s: Span) -> (usize, usize) {
    (s.start, s.end)
}

fn surface_builtin(name: &str) -> Option<Builtin> {
    let b: Builtin = name.parse().ok()?;
    b.is_surface().then_some(b)
}

/// Smallest signed dtype that can hold the value.
fn default_int_type(value: i64) -> DType {
    if value >= i8::MIN as i64 && value <= i8::MAX as i64 {
        DType::I8
    } else if value >= i16::MIN as i64 && value <= i16::MAX as i64 {
        DType::I16
    } else if value >= i32::MIN as i64 && value <= i32::MAX as i64 {
        DType::I32
    } else {
        DType::I64
    }
}

fn check_int_range(value: i64, dtype: DType) -> bool {
    match (dtype.kind, dtype.bits) {
        (DTypeKind::UInt, 8) => value >= 0 && value <= u8::MAX as i64,
        (DTypeKind::UInt, 16) => value >= 0 && value <= u16::MAX as i64,
        (DTypeKind::UInt, 32) => value >= 0 && value <= u32::MAX as i64,
        (DTypeKind::UInt, 64) => value >= 0,
        (DTypeKind::SInt, 8) => value >= i8::MIN as i64 && value <= i8::MAX as i64,
        (DTypeKind::SInt, 16) => value >= i16::MIN as i64 && value <= i16::MAX as i64,
        (DTypeKind::SInt, 32) => value >= i32::MIN as i64 && value <= i32::MAX as i64,
        (DTypeKind::SInt, 64) => true,
        _ => true,
    }
}

/// Returns true if `v` is exactly representable in the given float dtype.
fn float_representable_in(v: f64, dtype: DType) -> bool {
    if v.is_nan() {
        return true;
    }
    if v.is_infinite() {
        return match (dtype.kind, dtype.bits) {
            (DTypeKind::Float(_), 16) => half::f16::from_f64(v).is_infinite(),
            (DTypeKind::Float(_), 32) => (v as f32).is_infinite(),
            _ => true,
        };
    }
    match (dtype.kind, dtype.bits) {
        (DTypeKind::Float(FloatEncoding::Ieee), 16) => half::f16::from_f64(v).to_f64() == v,
        (DTypeKind::Float(FloatEncoding::Brain), 16) => half::bf16::from_f64(v).to_f64() == v,
        (DTypeKind::Float(FloatEncoding::Ieee), 32) => (v as f32) as f64 == v,
        _ => true,
    }
}

/// Narrowest float dtype that can represent `v` without precision loss.
fn narrowest_float_for(v: f64) -> DType {
    if float_representable_in(v, DType::F16) {
        DType::F16
    } else if float_representable_in(v, DType::F32) {
        DType::F32
    } else {
        DType::F64
    }
}

/// Narrowest complex dtype whose underlying float can represent both components.
fn narrowest_complex_for(re: f64, im: f64) -> DType {
    if float_representable_in(re, DType::F32) && float_representable_in(im, DType::F32) {
        DType::C64
    } else {
        DType::C128
    }
}

fn check_float_precision(ctx: &mut Ctx, value: f64, dtype: DType, span: Span) -> Result<(), Diag> {
    if !float_representable_in(value, dtype) {
        ctx.emit(lower_float_precision_loss(value, dtype, span_of(span)))?;
    }
    Ok(())
}

fn promote_with_diags(
    ctx: &mut Ctx,
    op: &str,
    lhs: DType,
    rhs: DType,
    span: Span,
    operand_spans: Option<(Span, Span)>,
) -> Result<DType, Diag> {
    let ir_span = span_of(span);
    let out = match promote(lhs, rhs) {
        Ok(t) => t,
        Err(_) => {
            return ctx
                .on_error(lower_type_mismatch(op, lhs, rhs, ir_span), DType::F64)
                .map(|id| ctx.dtype_of(id));
        }
    };

    // Warn when mixing u64 with signed types (potential overflow).
    let is_u64 = |t: DType| matches!((t.kind, t.bits), (DTypeKind::UInt, 64));
    if (is_u64(lhs) && matches!(rhs.kind, DTypeKind::SInt))
        || (is_u64(rhs) && matches!(lhs.kind, DTypeKind::SInt))
    {
        ctx.emit(lower_unsafe_uint_to_sint(op, lhs, rhs, out, ir_span))?;
    }

    if lhs != out || rhs != out {
        let diag = match operand_spans {
            Some((l, r)) => {
                lower_implicit_promotion_with_operands(op, lhs, rhs, out, span_of(l), span_of(r))
            }
            None => lower_implicit_promotion(op, lhs, rhs, out, ir_span),
        };
        ctx.emit(diag)?;
    }
    Ok(out)
}

// ── Literal helpers ─────────────────────────────────────────────────────────

fn lower_int_literal(
    ctx: &mut Ctx,
    value: i64,
    ann: &Option<DType>,
    span: Span,
) -> Result<NodeId, Diag> {
    let dtype = ann.unwrap_or_else(|| default_int_type(value));
    if !check_int_range(value, dtype) {
        return ctx.on_error(
            lower_int_out_of_range(value, dtype, span_of(span)),
            DType::I64,
        );
    }
    Ok(ctx.push(Node::Const(Const::Int(value)), dtype))
}

fn lower_float_literal(
    ctx: &mut Ctx,
    value: f64,
    ann: &Option<DType>,
    span: Span,
) -> Result<NodeId, Diag> {
    let dtype = if let Some(d) = ann {
        if matches!(d.kind, DTypeKind::Float(_)) && d.bits < 64 {
            check_float_precision(ctx, value, *d, span)?;
        }
        *d
    } else {
        narrowest_float_for(value)
    };
    Ok(ctx.push(Node::Const(Const::Float(value)), dtype))
}

fn lower_complex_literal(
    ctx: &mut Ctx,
    re: f64,
    im: f64,
    ann: &Option<DType>,
    span: Span,
) -> Result<NodeId, Diag> {
    let dtype = if let Some(d) = ann {
        if matches!(d.kind, DTypeKind::Complex(_)) && d.bits == 64 {
            check_float_precision(ctx, re, DType::F32, span)?;
            check_float_precision(ctx, im, DType::F32, span)?;
        }
        *d
    } else {
        narrowest_complex_for(re, im)
    };
    Ok(ctx.push(Node::Const(Const::Complex(re, im)), dtype))
}

/// Try to fold `-LitInt`, `-LitFloat`, `-LitComplex` into a single constant.
/// Returns `None` if the child is not a foldable literal.
fn try_fold_neg_literal(ctx: &mut Ctx, child: &Spanned<Expr>) -> Result<Option<NodeId>, Diag> {
    match &child.node {
        Expr::LitInt(v, ann) => {
            let neg = match v.checked_neg() {
                Some(n) => n,
                None => {
                    let id = ctx.on_error(
                        lower_int_out_of_range(*v, DType::I64, span_of(child.span)),
                        DType::I64,
                    )?;
                    return Ok(Some(id));
                }
            };
            lower_int_literal(ctx, neg, ann, child.span).map(Some)
        }
        Expr::LitFloat(v, ann) => lower_float_literal(ctx, -*v, ann, child.span).map(Some),
        Expr::LitComplex(re, im, ann) => {
            lower_complex_literal(ctx, -*re, -*im, ann, child.span).map(Some)
        }
        _ => Ok(None),
    }
}

// ── Core lowering ───────────────────────────────────────────────────────────

fn lower_expr(ctx: &mut Ctx, expr: &Spanned<Expr>) -> Result<Vec<NodeId>, Diag> {
    match &expr.node {
        Expr::LitBool(v) => Ok(vec![ctx.push(Node::Const(Const::Bool(*v)), DType::BOOL)]),

        Expr::LitInt(v, ann) => lower_int_literal(ctx, *v, ann, expr.span).map(|id| vec![id]),

        Expr::LitFloat(v, ann) => lower_float_literal(ctx, *v, ann, expr.span).map(|id| vec![id]),

        Expr::LitComplex(re, im, ann) => {
            lower_complex_literal(ctx, *re, *im, ann, expr.span).map(|id| vec![id])
        }

        Expr::ConstE => {
            Ok(vec![ctx.push(
                Node::Const(Const::Float(std::f64::consts::E)),
                DType::F64,
            )])
        }

        Expr::ConstPi => {
            Ok(vec![ctx.push(
                Node::Const(Const::Float(std::f64::consts::PI)),
                DType::F64,
            )])
        }

        Expr::Var(name) => match ctx.symbols.get(name).copied() {
            Some(id) => Ok(vec![id]),
            None => ctx
                .on_error(lower_undefined_var(name, span_of(expr.span)), DType::F64)
                .map(|id| vec![id]),
        },

        // ── Unary ────────────────────────────────────────────────────────
        Expr::Unary(op, child) => {
            // Fold negation of numeric literals into a single Const node.
            if *op == UnaryOp::Neg
                && let Some(id) = try_fold_neg_literal(ctx, child)?
            {
                return Ok(vec![id]);
            }

            let child_id = ctx.lower_single(child, DType::F64)?;
            let child_ty = ctx.dtype_of(child_id);

            let out_ty = match op {
                UnaryOp::Neg => {
                    if child_ty == DType::BOOL {
                        return ctx
                            .on_error(
                                lower_type_mismatch("neg", child_ty, child_ty, span_of(expr.span)),
                                DType::F64,
                            )
                            .map(|id| vec![id]);
                    }
                    child_ty
                }
                UnaryOp::Not => {
                    if child_ty != DType::BOOL {
                        return ctx
                            .on_error(
                                lower_type_mismatch(
                                    "not",
                                    child_ty,
                                    DType::BOOL,
                                    span_of(expr.span),
                                ),
                                DType::BOOL,
                            )
                            .map(|id| vec![id]);
                    }
                    DType::BOOL
                }
            };
            Ok(vec![ctx.push(Node::Unary(*op, child_id), out_ty)])
        }

        // ── Binary ───────────────────────────────────────────────────────
        Expr::Binary(op, lhs, rhs) => {
            let lhs_id = ctx.lower_single(lhs, DType::F64)?;
            let rhs_id = ctx.lower_single(rhs, DType::F64)?;
            let lhs_ty = ctx.dtype_of(lhs_id);
            let rhs_ty = ctx.dtype_of(rhs_id);

            // Check for literal division by zero.
            if matches!(op, BinaryOp::FloorDiv | BinaryOp::Rem)
                && let Node::Const(Const::Int(0)) = ctx.node_at(rhs_id)
            {
                ctx.emit(lower_div_by_zero(op.as_str(), span_of(expr.span)))?;
            }

            let op_name = op.as_str();
            let operand_spans = Some((lhs.span, rhs.span));

            let (out_ty, cast_ty) = match op {
                BinaryOp::And | BinaryOp::Or => {
                    if lhs_ty != DType::BOOL || rhs_ty != DType::BOOL {
                        return ctx
                            .on_error(
                                lower_type_mismatch(op_name, lhs_ty, rhs_ty, span_of(expr.span)),
                                DType::BOOL,
                            )
                            .map(|id| vec![id]);
                    }
                    (DType::BOOL, None)
                }

                BinaryOp::Eq
                | BinaryOp::Ne
                | BinaryOp::Lt
                | BinaryOp::Le
                | BinaryOp::Gt
                | BinaryOp::Ge => {
                    let cmp_ty =
                        promote_with_diags(ctx, op_name, lhs_ty, rhs_ty, expr.span, operand_spans)?;
                    (DType::BOOL, Some(cmp_ty))
                }

                BinaryOp::Div => {
                    let is_non_int =
                        |t: DType| matches!(t.kind, DTypeKind::Float(_) | DTypeKind::Complex(_));
                    if !is_non_int(lhs_ty) || !is_non_int(rhs_ty) {
                        return ctx
                            .on_error(
                                lower_type_mismatch(op_name, lhs_ty, rhs_ty, span_of(expr.span)),
                                DType::F64,
                            )
                            .map(|id| vec![id]);
                    }
                    let out =
                        promote_with_diags(ctx, op_name, lhs_ty, rhs_ty, expr.span, operand_spans)?;
                    (out, Some(out))
                }

                BinaryOp::FloorDiv => {
                    let is_int = |t: DType| matches!(t.kind, DTypeKind::UInt | DTypeKind::SInt);
                    if !is_int(lhs_ty) || !is_int(rhs_ty) {
                        return ctx
                            .on_error(
                                lower_type_mismatch(op_name, lhs_ty, rhs_ty, span_of(expr.span)),
                                DType::F64,
                            )
                            .map(|id| vec![id]);
                    }
                    let out =
                        promote_with_diags(ctx, op_name, lhs_ty, rhs_ty, expr.span, operand_spans)?;
                    (out, Some(out))
                }

                _ => {
                    let out =
                        promote_with_diags(ctx, op_name, lhs_ty, rhs_ty, expr.span, operand_spans)?;
                    (out, Some(out))
                }
            };

            let (cast_lhs, cast_rhs) = match cast_ty {
                Some(target) => (
                    ctx.cast_if_needed(lhs_id, target),
                    ctx.cast_if_needed(rhs_id, target),
                ),
                None => (lhs_id, rhs_id),
            };
            Ok(vec![
                ctx.push(Node::Binary(*op, cast_lhs, cast_rhs), out_ty),
            ])
        }

        // ── IfElse ───────────────────────────────────────────────────────
        Expr::IfElse(cond, then_expr, else_expr) => {
            let cond_id = ctx.lower_single(cond, DType::BOOL)?;
            let then_id = ctx.lower_single(then_expr, DType::F64)?;
            let else_id = ctx.lower_single(else_expr, DType::F64)?;

            let cond_ty = ctx.dtype_of(cond_id);
            if cond_ty != DType::BOOL {
                ctx.emit(lower_type_mismatch(
                    "if_cond",
                    cond_ty,
                    DType::BOOL,
                    span_of(cond.span),
                ))?;
            }

            let then_ty = ctx.dtype_of(then_id);
            let else_ty = ctx.dtype_of(else_id);
            let out_ty = promote_with_diags(ctx, "if_branch", then_ty, else_ty, expr.span, None)?;

            let cast_then = ctx.cast_if_needed(then_id, out_ty);
            let cast_else = ctx.cast_if_needed(else_id, out_ty);
            Ok(vec![
                ctx.push(Node::IfElse(cond_id, cast_then, cast_else), out_ty),
            ])
        }

        // ── Call ─────────────────────────────────────────────────────────
        Expr::Call(name, args) => {
            let builtin = match surface_builtin(name) {
                Some(b) => b,
                None => {
                    return ctx
                        .on_error(lower_unknown_function(name, span_of(expr.span)), DType::F64)
                        .map(|id| vec![id]);
                }
            };
            if args.len() != builtin.arity() {
                return ctx
                    .on_error(
                        lower_arg_count(name, builtin.arity(), args.len(), span_of(expr.span)),
                        DType::F64,
                    )
                    .map(|id| vec![id]);
            }
            let mut lowered = Vec::with_capacity(args.len());
            for arg in args {
                lowered.push(ctx.lower_single(arg, DType::F64)?);
            }
            let out_ty = ctx.dtype_of(lowered[0]);
            Ok(vec![ctx.push(Node::Call(builtin, lowered), out_ty)])
        }

        // ── Cast ─────────────────────────────────────────────────────────
        Expr::Cast(dtype, child) => {
            if let Expr::Cast(inner_dtype, _) = &child.node {
                ctx.emit(lower_redundant_cast_chain(
                    *inner_dtype,
                    *dtype,
                    span_of(expr.span),
                ))?;
            }
            let child_id = ctx.lower_single(child, DType::F64)?;
            Ok(vec![ctx.push(Node::Cast(*dtype, child_id, true), *dtype)])
        }

        // ── Tuple ────────────────────────────────────────────────────────
        Expr::Tuple(items) => {
            let mut out = Vec::with_capacity(items.len());
            for item in items {
                out.push(ctx.lower_single(item, DType::F64)?);
            }
            Ok(out)
        }

        // ── Let ──────────────────────────────────────────────────────────
        Expr::Let(name, value, body) => {
            let value_id = ctx.lower_single(value, DType::F64)?;
            let prev = ctx.symbols.insert(name.clone(), value_id);
            let result = lower_expr(ctx, body);
            match prev {
                Some(old) => {
                    ctx.symbols.insert(name.clone(), old);
                }
                None => {
                    ctx.symbols.remove(name);
                }
            }
            result
        }
    }
}

// ── Public API ──────────────────────────────────────────────────────────────

/// Result of lowering: DAG plus any non-fatal diagnostics.
#[derive(Debug)]
pub struct LowerResult {
    pub dag: TypedDag,
    pub diags: Vec<Diag>,
}

/// Lowering result that includes all error-severity diagnostics
/// (used for best-effort reporting of as many issues as possible).
#[derive(Debug)]
pub struct LowerResultWithErrors {
    pub dag: TypedDag,
    pub diags: Vec<Diag>,
    pub errors: Vec<Diag>,
}

/// Lower with default [`DiagConfig`].
pub fn lower(func: &FuncDef) -> Result<LowerResult, Diag> {
    lower_with_config(func, DiagConfig::default())
}

/// Lower with a custom [`DiagConfig`] so callers can control diagnostic severity.
pub fn lower_with_config(func: &FuncDef, config: DiagConfig) -> Result<LowerResult, Diag> {
    let mut ctx = Ctx::new_fail_fast(config);

    for (idx, (name, dtype)) in func.params.iter().enumerate() {
        let id = ctx.push(Node::Param(ParamId(idx)), *dtype);
        ctx.symbols.insert(name.clone(), id);
    }

    let outputs = lower_expr(&mut ctx, &func.body)?;
    let (dag, mode) = ctx.finish(func.params.clone(), outputs);
    let diags = match mode {
        DiagMode::FailFast(collector) => collector.into_diags(),
        _ => unreachable!(),
    };
    Ok(LowerResult { dag, diags })
}

/// Lower while collecting as many error-severity diagnostics as possible.
/// Never short-circuits; returns a best-effort DAG.
pub fn lower_with_config_collect(func: &FuncDef, config: DiagConfig) -> LowerResultWithErrors {
    let mut ctx = Ctx::new_collect(config);

    for (idx, (name, dtype)) in func.params.iter().enumerate() {
        let id = ctx.push(Node::Param(ParamId(idx)), *dtype);
        ctx.symbols.insert(name.clone(), id);
    }

    let outputs = lower_expr(&mut ctx, &func.body)
        .unwrap_or_else(|_| unreachable!("Collect mode never returns Err"));
    let (dag, mode) = ctx.finish(func.params.clone(), outputs);
    let (diags, errors) = match mode {
        DiagMode::Collect { diags, errors, .. } => (diags, errors),
        _ => unreachable!(),
    };
    LowerResultWithErrors { dag, diags, errors }
}

#[cfg(test)]
mod tests {
    use super::lower;
    use crate::ir::{BinaryOp, Node};
    use crate::parse::parse_dsl;

    #[test]
    fn lower_inserts_casts_for_comparison() {
        // 1@i8 == 2@i16 → lhs (i8) needs cast to i16, rhs (i16) does not.
        let func = parse_dsl("||\n1@i8 == 2@i16").expect("parse");
        let result = lower(&func).expect("lower");
        let dag = result.dag;
        let cmp_id = *dag.outputs.first().expect("output");
        let (lhs, rhs) = match dag.nodes[cmp_id.0] {
            Node::Binary(BinaryOp::Eq, lhs, rhs) => (lhs, rhs),
            _ => panic!("expected comparison node"),
        };
        // Only the narrower operand gets a cast node.
        assert!(matches!(dag.nodes[lhs.0], Node::Cast(..)));
        assert!(!matches!(dag.nodes[rhs.0], Node::Cast(..)));
    }

    #[test]
    fn lower_inserts_casts_for_if_merge() {
        // if true then 1@i8 else 2@i16 → then-branch (i8) needs cast, else (i16) does not.
        let func = parse_dsl("||\nif true then 1@i8 else 2@i16").expect("parse");
        let result = lower(&func).expect("lower");
        let dag = result.dag;
        let if_id = *dag.outputs.first().expect("output");
        let (then_id, else_id) = match dag.nodes[if_id.0] {
            Node::IfElse(_, then_id, else_id) => (then_id, else_id),
            _ => panic!("expected if node"),
        };
        // Only the narrower branch gets a cast node.
        assert!(matches!(dag.nodes[then_id.0], Node::Cast(..)));
        assert!(!matches!(dag.nodes[else_id.0], Node::Cast(..)));
    }

    #[test]
    fn lower_rejects_tuple_in_scalar_context() {
        let func = parse_dsl("||\n(1, 2) + 3").expect("parse");
        let err = lower(&func).expect_err("tuple in scalar context should fail");
        assert!(err.to_string().contains("expected 1 value"));
    }
}
