//! expr builtin - evaluate expressions
//!
//! Supports arithmetic, string, and comparison operations.

use async_trait::async_trait;

use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;

/// The expr builtin - evaluate expressions.
///
/// Usage: expr EXPRESSION
///
/// Arithmetic: expr ARG1 + ARG2, - , \* , / , %
/// Comparison: expr ARG1 = ARG2, != , < , > , <= , >=
/// String: expr length STRING, expr substr STRING POS LEN, expr match STRING REGEX
/// Pattern: expr STRING : REGEX
pub struct Expr;

#[async_trait]
impl Builtin for Expr {
    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
        if let Some(r) = super::check_help_version(
            ctx.args,
            "Usage: expr EXPRESSION\n       expr OPTION\n\nPrint the value of EXPRESSION to standard output.\n\n  ARG1 + ARG2\tarithmetic sum\n  ARG1 - ARG2\tarithmetic difference\n  ARG1 * ARG2\tarithmetic product\n  ARG1 / ARG2\tarithmetic quotient\n  ARG1 % ARG2\tarithmetic remainder\n  ARG1 = ARG2\tcomparison equal\n  ARG1 != ARG2\tcomparison not equal\n  ARG1 < ARG2\tcomparison less than\n  ARG1 > ARG2\tcomparison greater than\n  ARG1 <= ARG2\tcomparison less or equal\n  ARG1 >= ARG2\tcomparison greater or equal\n  ARG1 | ARG2\tlogical or\n  ARG1 & ARG2\tlogical and\n  length STRING\tlength of STRING\n  substr STRING POS LEN\tsubstring of STRING\n  index STRING CHARS\tindex of first CHAR in STRING\n  match STRING REGEX\tanchored pattern match\n  STRING : REGEX\tanchored pattern match\n  --help\tdisplay this help and exit\n  --version\toutput version information and exit\n",
            Some("expr (bashkit) 0.1"),
        ) {
            return Ok(r);
        }
        if ctx.args.is_empty() {
            return Ok(ExecResult::err("expr: missing operand\n".to_string(), 2));
        }

        let args: Vec<&str> = ctx.args.iter().map(|s| s.as_str()).collect();
        match evaluate(&args) {
            Ok(val) => {
                let exit_code = if val == "0" || val.is_empty() { 1 } else { 0 };
                Ok(ExecResult::with_code(format!("{}\n", val), exit_code))
            }
            Err(msg) => Ok(ExecResult::err(format!("expr: {}\n", msg), 2)),
        }
    }
}

fn evaluate(args: &[&str]) -> std::result::Result<String, String> {
    if args.is_empty() {
        return Err("missing operand".to_string());
    }

    // Handle keyword operations first
    if args.len() >= 2 && args[0] == "length" {
        // TM-UNI-015: Use char count, not byte count
        return Ok(args[1].chars().count().to_string());
    }

    if args.len() >= 4 && args[0] == "substr" {
        let s = args[1];
        let pos: usize = args[2]
            .parse()
            .map_err(|_| "non-integer argument".to_string())?;
        let len: usize = args[3]
            .parse()
            .map_err(|_| "non-integer argument".to_string())?;
        let char_count = s.chars().count();
        if pos == 0 || pos > char_count {
            return Ok(String::new());
        }
        // TM-UNI-015: Use char-based slicing, not byte-based
        let result: String = s.chars().skip(pos - 1).take(len).collect();
        return Ok(result);
    }

    if args.len() >= 3 && args[0] == "index" {
        let s = args[1];
        let chars = args[2];
        for (i, c) in s.chars().enumerate() {
            if chars.contains(c) {
                return Ok((i + 1).to_string());
            }
        }
        return Ok("0".to_string());
    }

    if args.len() >= 3 && args[0] == "match" {
        return match_pattern(args[1], args[2]);
    }

    // Single value
    if args.len() == 1 {
        return Ok(args[0].to_string());
    }

    // Binary operations: ARG1 OP ARG2
    if args.len() == 3 {
        let left = args[0];
        let op = args[1];
        let right = args[2];

        // Pattern match: STRING : REGEX
        if op == ":" {
            return match_pattern(left, right);
        }

        // Try arithmetic
        let left_num = left.parse::<i64>();
        let right_num = right.parse::<i64>();

        match op {
            "+" | "-" | "*" | "/" | "%" => {
                let a = left_num.map_err(|_| "non-integer argument".to_string())?;
                let b = right_num.map_err(|_| "non-integer argument".to_string())?;
                let result = match op {
                    "+" => a.checked_add(b).ok_or("integer overflow")?,
                    "-" => a.checked_sub(b).ok_or("integer overflow")?,
                    "*" => a.checked_mul(b).ok_or("integer overflow")?,
                    "/" => {
                        if b == 0 {
                            return Err("division by zero".to_string());
                        }
                        a / b
                    }
                    "%" => {
                        if b == 0 {
                            return Err("division by zero".to_string());
                        }
                        a % b
                    }
                    _ => unreachable!(),
                };
                return Ok(result.to_string());
            }
            "=" => {
                return Ok(if left == right { "1" } else { "0" }.to_string());
            }
            "!=" => {
                return Ok(if left != right { "1" } else { "0" }.to_string());
            }
            "<" | ">" | "<=" | ">=" => {
                // Compare as integers if both are numbers, otherwise as strings
                let result = if let (Ok(a), Ok(b)) = (left_num, right_num) {
                    match op {
                        "<" => a < b,
                        ">" => a > b,
                        "<=" => a <= b,
                        ">=" => a >= b,
                        _ => unreachable!(),
                    }
                } else {
                    match op {
                        "<" => left < right,
                        ">" => left > right,
                        "<=" => left <= right,
                        ">=" => left >= right,
                        _ => unreachable!(),
                    }
                };
                return Ok(if result { "1" } else { "0" }.to_string());
            }
            "|" => {
                // OR: return left if non-zero/non-empty, else right
                if !left.is_empty() && left != "0" {
                    return Ok(left.to_string());
                }
                return Ok(right.to_string());
            }
            "&" => {
                // AND: return left if both are non-zero/non-empty, else 0
                let l_true = !left.is_empty() && left != "0";
                let r_true = !right.is_empty() && right != "0";
                if l_true && r_true {
                    return Ok(left.to_string());
                }
                return Ok("0".to_string());
            }
            _ => {}
        }
    }

    // Fallback: return first arg
    Ok(args[0].to_string())
}

/// Match a string against a pattern (anchored at start, like expr : behavior)
fn match_pattern(s: &str, pattern: &str) -> std::result::Result<String, String> {
    // Simple pattern matching - expr patterns are anchored at start
    // For now, support basic patterns: . (any char), .* (any), literal
    // Check if pattern has capturing group \(...\)
    let has_group = pattern.contains("\\(") && pattern.contains("\\)");

    if has_group {
        // Extract the group pattern
        // For simplicity, handle common case: prefix\(.*\)suffix
        if let Some(start) = pattern.find("\\(")
            && let Some(end) = pattern.find("\\)")
        {
            let before = &pattern[..start];
            let inner = &pattern[start + 2..end];
            let _after = &pattern[end + 2..];

            // Simple: if before matches start, capture inner
            if let Some(rest) = s.strip_prefix(before) {
                let matched = simple_match(rest, inner);
                return Ok(matched);
            }
        }
        Ok(String::new())
    } else {
        // No group: return number of matched characters
        let count = count_match(s, pattern);
        Ok(count.to_string())
    }
}

/// Count how many characters from start of s match the pattern
fn count_match(s: &str, pattern: &str) -> usize {
    // Build a simple matcher
    let mut si = 0;
    let mut pi = 0;
    let s_chars: Vec<char> = s.chars().collect();
    let p_chars: Vec<char> = pattern.chars().collect();

    while pi < p_chars.len() && si < s_chars.len() {
        if pi + 1 < p_chars.len() && p_chars[pi + 1] == '*' {
            // X* - match zero or more of X
            let match_char = p_chars[pi];
            pi += 2;
            // Greedy: match as many as possible
            while si < s_chars.len() && char_matches(s_chars[si], match_char) {
                si += 1;
            }
        } else if p_chars[pi] == '.' {
            // . matches any character
            si += 1;
            pi += 1;
        } else if s_chars[si] == p_chars[pi] {
            si += 1;
            pi += 1;
        } else {
            break;
        }
    }

    // Check if we consumed the entire pattern
    // Handle trailing X* patterns (they can match zero)
    while pi + 1 < p_chars.len() && p_chars[pi + 1] == '*' {
        pi += 2;
    }

    if pi >= p_chars.len() { si } else { 0 }
}

/// Simple match returning the matched portion
fn simple_match(s: &str, pattern: &str) -> String {
    let count = count_match(s, pattern);
    s.chars().take(count).collect()
}

fn char_matches(c: char, pattern: char) -> bool {
    pattern == '.' || c == pattern
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::sync::Arc;

    use crate::fs::{FileSystem, InMemoryFs};

    async fn setup() -> (Arc<InMemoryFs>, PathBuf, HashMap<String, String>) {
        let fs = Arc::new(InMemoryFs::new());
        let cwd = PathBuf::from("/home/user");
        let variables = HashMap::new();
        fs.mkdir(&cwd, true).await.unwrap();
        (fs, cwd, variables)
    }

    // ==================== missing operand ====================

    #[tokio::test]
    async fn expr_missing_operand() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args: Vec<String> = vec![];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 2);
        assert!(result.stderr.contains("missing operand"));
    }

    // ==================== arithmetic ====================

    #[tokio::test]
    async fn expr_addition() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["3".to_string(), "+".to_string(), "4".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stdout.trim(), "7");
    }

    #[tokio::test]
    async fn expr_subtraction() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["10".to_string(), "-".to_string(), "3".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stdout.trim(), "7");
    }

    #[tokio::test]
    async fn expr_multiplication() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["6".to_string(), "*".to_string(), "7".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stdout.trim(), "42");
    }

    #[tokio::test]
    async fn expr_division() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["15".to_string(), "/".to_string(), "3".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stdout.trim(), "5");
    }

    #[tokio::test]
    async fn expr_modulo() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["17".to_string(), "%".to_string(), "5".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stdout.trim(), "2");
    }

    #[tokio::test]
    async fn expr_division_by_zero() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["5".to_string(), "/".to_string(), "0".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 2);
        assert!(result.stderr.contains("division by zero"));
    }

    #[tokio::test]
    async fn expr_modulo_by_zero() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["5".to_string(), "%".to_string(), "0".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 2);
        assert!(result.stderr.contains("division by zero"));
    }

    #[tokio::test]
    async fn expr_non_integer_arithmetic() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["abc".to_string(), "+".to_string(), "1".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 2);
        assert!(result.stderr.contains("non-integer"));
    }

    // ==================== zero result gives exit code 1 ====================

    #[tokio::test]
    async fn expr_zero_result_exit_code_1() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["5".to_string(), "-".to_string(), "5".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.exit_code, 1); // "0" result => exit code 1
        assert_eq!(result.stdout.trim(), "0");
    }

    // ==================== string comparison ====================

    #[tokio::test]
    async fn expr_string_equal() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["hello".to_string(), "=".to_string(), "hello".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "1");
        assert_eq!(result.exit_code, 0);
    }

    #[tokio::test]
    async fn expr_string_not_equal() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["hello".to_string(), "!=".to_string(), "world".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "1");
        assert_eq!(result.exit_code, 0);
    }

    #[tokio::test]
    async fn expr_string_equal_returns_zero_for_mismatch() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["a".to_string(), "=".to_string(), "b".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "0");
        assert_eq!(result.exit_code, 1);
    }

    // ==================== comparison operators ====================

    #[tokio::test]
    async fn expr_less_than_numeric() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["3".to_string(), "<".to_string(), "10".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "1");
    }

    #[tokio::test]
    async fn expr_greater_than_numeric() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["10".to_string(), ">".to_string(), "3".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "1");
    }

    #[tokio::test]
    async fn expr_le_numeric() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["5".to_string(), "<=".to_string(), "5".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "1");
    }

    #[tokio::test]
    async fn expr_ge_numeric() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["5".to_string(), ">=".to_string(), "3".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "1");
    }

    // ==================== string functions ====================

    #[tokio::test]
    async fn expr_length() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["length".to_string(), "hello".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "5");
    }

    #[tokio::test]
    async fn expr_length_empty() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["length".to_string(), "".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "0");
        assert_eq!(result.exit_code, 1); // "0" => exit 1
    }

    #[tokio::test]
    async fn expr_substr() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec![
            "substr".to_string(),
            "hello".to_string(),
            "2".to_string(),
            "3".to_string(),
        ];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "ell");
    }

    #[tokio::test]
    async fn expr_substr_out_of_range() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec![
            "substr".to_string(),
            "hi".to_string(),
            "0".to_string(),
            "1".to_string(),
        ];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        // pos=0 is out of range (1-based)
        assert!(result.stdout.trim().is_empty());
    }

    #[tokio::test]
    async fn expr_index() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["index".to_string(), "hello".to_string(), "lo".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        // First occurrence of any char in "lo" in "hello" is 'l' at position 3 (1-based)
        assert_eq!(result.stdout.trim(), "3");
    }

    #[tokio::test]
    async fn expr_index_not_found() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["index".to_string(), "hello".to_string(), "xyz".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "0");
    }

    // ==================== pattern matching ====================

    #[tokio::test]
    async fn expr_match_literal() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["match".to_string(), "hello".to_string(), "hel".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "3"); // 3 chars matched
    }

    #[tokio::test]
    async fn expr_colon_pattern() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["hello".to_string(), ":".to_string(), ".*".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "5"); // .* matches all 5 chars
    }

    #[tokio::test]
    async fn expr_colon_no_match() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["hello".to_string(), ":".to_string(), "xyz".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "0");
    }

    // ==================== logical operators ====================

    #[tokio::test]
    async fn expr_or_left_nonzero() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["hello".to_string(), "|".to_string(), "world".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "hello");
    }

    #[tokio::test]
    async fn expr_or_left_zero() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["0".to_string(), "|".to_string(), "fallback".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "fallback");
    }

    #[tokio::test]
    async fn expr_and_both_nonzero() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["hello".to_string(), "&".to_string(), "world".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "hello");
    }

    #[tokio::test]
    async fn expr_and_one_zero() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["0".to_string(), "&".to_string(), "world".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "0");
    }

    // ==================== single value ====================

    #[tokio::test]
    async fn expr_single_value() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["42".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "42");
        assert_eq!(result.exit_code, 0);
    }

    #[tokio::test]
    async fn expr_single_zero_value() {
        let (fs, mut cwd, mut variables) = setup().await;
        let env = HashMap::new();
        let args = vec!["0".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "0");
        assert_eq!(result.exit_code, 1); // "0" => falsy
    }

    // Issue #434: length should count chars, not bytes
    #[tokio::test]
    async fn test_length_multibyte_utf8() {
        let fs = Arc::new(crate::fs::InMemoryFs::new());
        let mut variables = HashMap::new();
        let mut cwd = std::path::PathBuf::from("/");
        let env = HashMap::new();
        // "café" = 4 chars but 5 bytes (é is 2 bytes)
        let args = vec!["length".to_string(), "café".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "4", "should count chars, not bytes");
    }

    // Issue #434: substr should use char-based slicing
    #[tokio::test]
    async fn test_substr_multibyte_utf8() {
        let fs = Arc::new(crate::fs::InMemoryFs::new());
        let mut variables = HashMap::new();
        let mut cwd = std::path::PathBuf::from("/");
        let env = HashMap::new();
        // "日本語" - extract first 2 chars
        let args = vec![
            "substr".to_string(),
            "日本語".to_string(),
            "1".to_string(),
            "2".to_string(),
        ];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(
            result.stdout.trim(),
            "日本",
            "should extract chars, not bytes"
        );
    }

    // Issue #434: match with capture groups should not slice on byte offsets
    #[tokio::test]
    async fn test_match_capture_group_multibyte_utf8() {
        let fs = Arc::new(crate::fs::InMemoryFs::new());
        let mut variables = HashMap::new();
        let mut cwd = std::path::PathBuf::from("/");
        let env = HashMap::new();
        let args = vec!["match".to_string(), "é".to_string(), "\\(.*\\)".to_string()];
        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
        let result = Expr.execute(ctx).await.unwrap();
        assert_eq!(result.stdout.trim(), "é");
        assert_eq!(result.exit_code, 0);
    }

    // TM-INF-022: garbage expression stderr must not leak parser Debug shapes.
    #[tokio::test]
    async fn no_leak_garbage_expression() {
        let r = crate::builtins::debug_leak_check::run(r#"expr garbage / 0"#).await;
        crate::builtins::debug_leak_check::assert_no_leak(
            &r,
            "expr_garbage",
            &["ParseFloatError", "ParseIntError"],
        );
    }
}
