//! Django-compatible template filters

use chrono::{DateTime, Datelike, Timelike, Utc};
use djust_core::{DjangoRustError, Result, Value};
use once_cell::sync::Lazy;
use regex::Regex;

pub fn apply_filter(filter_name: &str, value: &Value, arg: Option<&str>) -> Result<Value> {
    match filter_name {
        "upper" => Ok(Value::String(value.to_string().to_uppercase())),
        "lower" => Ok(Value::String(value.to_string().to_lowercase())),
        "title" => Ok(Value::String(titlecase(&value.to_string()))),
        "length" => {
            let len = match value {
                Value::String(s) => s.len(),
                Value::List(l) => l.len(),
                _ => 0,
            };
            Ok(Value::Integer(len as i64))
        }
        "default" => {
            // default filter with argument
            if value.is_truthy() {
                Ok(value.clone())
            } else {
                Ok(Value::String(arg.unwrap_or("").to_string()))
            }
        }
        "escape" => Ok(value.clone()), // No-op: auto-escaping at render time handles this
        "safe" => Ok(value.clone()),   // No-op: renderer checks for |safe to skip auto-escaping
        "first" => match value {
            Value::List(l) => Ok(l.first().cloned().unwrap_or(Value::Null)),
            Value::String(s) => Ok(Value::String(
                s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
            )),
            _ => Ok(Value::Null),
        },
        "last" => match value {
            Value::List(l) => Ok(l.last().cloned().unwrap_or(Value::Null)),
            Value::String(s) => Ok(Value::String(
                s.chars().last().map(|c| c.to_string()).unwrap_or_default(),
            )),
            _ => Ok(Value::Null),
        },
        "join" => {
            // join with separator argument
            let separator = arg.unwrap_or(", ");
            match value {
                Value::List(items) => {
                    let strings: Vec<String> = items.iter().map(|v| v.to_string()).collect();
                    Ok(Value::String(strings.join(separator)))
                }
                _ => Ok(value.clone()),
            }
        }
        "truncatewords" => {
            let num_words = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
            let text = value.to_string();
            Ok(Value::String(truncate_words(&text, num_words)))
        }
        "truncatechars" => {
            let num_chars = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(20);
            let text = value.to_string();
            Ok(Value::String(truncate_chars(&text, num_chars)))
        }
        "slice" => {
            // slice filter supports Python slice syntax: ":5", "2:", "2:5"
            let slice_str = arg.unwrap_or(":");
            Ok(apply_slice(value, slice_str)?)
        }
        "timesince" => {
            // timesince filter: converts ISO datetime to "X minutes/hours/days ago" format
            let datetime_str = value.to_string();
            match format_timesince(&datetime_str) {
                Ok(formatted) => Ok(Value::String(formatted)),
                Err(_) => Ok(value.clone()), // If parsing fails, return original value
            }
        }
        "add" => {
            // add filter: adds argument to value (for numbers)
            let arg_val = arg.and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
            match value {
                Value::Integer(n) => Ok(Value::Integer(n + arg_val)),
                Value::Float(f) => Ok(Value::Float(f + arg_val as f64)),
                _ => Ok(value.clone()),
            }
        }
        "pluralize" => {
            // pluralize filter: returns plural suffix if value != 1
            let suffix = arg.unwrap_or("s");
            match value {
                Value::Integer(n) => {
                    if *n == 1 {
                        Ok(Value::String(String::new()))
                    } else {
                        Ok(Value::String(suffix.to_string()))
                    }
                }
                Value::List(l) => {
                    if l.len() == 1 {
                        Ok(Value::String(String::new()))
                    } else {
                        Ok(Value::String(suffix.to_string()))
                    }
                }
                _ => Ok(Value::String(suffix.to_string())),
            }
        }
        "slugify" => {
            // slugify filter: converts to URL-friendly slug
            Ok(Value::String(slugify(&value.to_string())))
        }
        "capfirst" => {
            // capfirst filter: capitalizes first character
            let s = value.to_string();
            let mut chars = s.chars();
            match chars.next() {
                None => Ok(Value::String(String::new())),
                Some(first) => Ok(Value::String(
                    first.to_uppercase().collect::<String>() + chars.as_str(),
                )),
            }
        }
        "yesno" => {
            // yesno filter: maps true/false/none to custom strings
            // Argument format: "yes,no,maybe" (maybe is optional)
            let parts: Vec<&str> = arg.unwrap_or("yes,no,maybe").split(',').collect();
            let yes_str = parts.first().unwrap_or(&"yes");
            let no_str = parts.get(1).unwrap_or(&"no");
            let maybe_str = parts.get(2).unwrap_or(&"maybe");

            let result = match value {
                Value::Bool(true) => yes_str,
                Value::Bool(false) => no_str,
                Value::Null => maybe_str,
                Value::String(s) if s.is_empty() => maybe_str,
                _ => {
                    if value.is_truthy() {
                        yes_str
                    } else {
                        maybe_str
                    }
                }
            };
            Ok(Value::String(result.to_string()))
        }
        "linebreaks" => {
            // linebreaks filter: converts newlines to <p> and <br> tags
            Ok(Value::String(linebreaks(&value.to_string())))
        }
        "linebreaksbr" => {
            // linebreaksbr filter: converts newlines to <br> tags
            Ok(Value::String(linebreaksbr(&value.to_string())))
        }
        "cut" => {
            // cut filter: removes all occurrences of arg from string
            let remove_str = arg.unwrap_or("");
            Ok(Value::String(value.to_string().replace(remove_str, "")))
        }
        "divisibleby" => {
            // divisibleby filter: returns true if value is divisible by arg
            let divisor = arg.and_then(|s| s.parse::<i64>().ok()).unwrap_or(1);
            match value {
                Value::Integer(n) => Ok(Value::Bool(divisor != 0 && n % divisor == 0)),
                _ => Ok(Value::Bool(false)),
            }
        }
        "floatformat" => {
            // floatformat filter: formats float to specified decimal places
            let decimals = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(1);
            match value {
                Value::Float(f) => Ok(Value::String(format!("{f:.decimals$}"))),
                Value::Integer(n) => Ok(Value::String(format!(
                    "{:.prec$}",
                    *n as f64,
                    prec = decimals
                ))),
                _ => Ok(value.clone()),
            }
        }
        "filesizeformat" => {
            // filesizeformat filter: formats bytes to human-readable size
            match value {
                Value::Integer(n) => Ok(Value::String(format_filesize(*n))),
                Value::Float(f) => Ok(Value::String(format_filesize(*f as i64))),
                _ => Ok(value.clone()),
            }
        }
        "random" => {
            // random filter: returns random item from list
            match value {
                Value::List(items) if !items.is_empty() => {
                    // Use simple pseudo-random selection based on list length
                    // For deterministic testing, we'll use first item
                    // In production, you'd want to use rand crate
                    use std::collections::hash_map::DefaultHasher;
                    use std::hash::{Hash, Hasher};
                    use std::time::{SystemTime, UNIX_EPOCH};

                    let mut hasher = DefaultHasher::new();
                    SystemTime::now()
                        .duration_since(UNIX_EPOCH)
                        .unwrap()
                        .as_nanos()
                        .hash(&mut hasher);
                    let random_index = (hasher.finish() as usize) % items.len();
                    Ok(items[random_index].clone())
                }
                Value::List(_) => Ok(Value::Null),
                _ => Ok(value.clone()),
            }
        }
        "timeuntil" => {
            // timeuntil filter: converts ISO datetime to "in X minutes/hours/days" format
            let datetime_str = value.to_string();
            match format_timeuntil(&datetime_str) {
                Ok(formatted) => Ok(Value::String(formatted)),
                Err(_) => Ok(value.clone()), // If parsing fails, return original value
            }
        }
        "date" => {
            // date filter: formats datetime with format string
            // Supports common Django/strftime format codes
            let format_str = arg.unwrap_or("N j, Y"); // Default: "Nov. 13, 2025"
            let datetime_str = value.to_string();
            match format_date(&datetime_str, format_str) {
                Ok(formatted) => Ok(Value::String(formatted)),
                Err(_) => Ok(value.clone()), // If parsing fails, return original value
            }
        }
        "time" => {
            // time filter: formats time with format string
            let format_str = arg.unwrap_or("P"); // Default: "2:30 p.m."
            let datetime_str = value.to_string();
            match format_time(&datetime_str, format_str) {
                Ok(formatted) => Ok(Value::String(formatted)),
                Err(_) => Ok(value.clone()),
            }
        }
        "dictsort" => {
            // dictsort filter: sorts list of dicts by key
            let sort_key = arg.unwrap_or("name");
            match value {
                Value::List(items) => Ok(Value::List(sort_dicts_by_key(items, sort_key))),
                _ => Ok(value.clone()),
            }
        }
        "dictsortreversed" => {
            // dictsortreversed filter: sorts list of dicts by key in reverse
            let sort_key = arg.unwrap_or("name");
            match value {
                Value::List(items) => {
                    let mut sorted = sort_dicts_by_key(items, sort_key);
                    sorted.reverse();
                    Ok(Value::List(sorted))
                }
                _ => Ok(value.clone()),
            }
        }
        "urlencode" => {
            // urlencode filter: URL-encodes the string
            // Matches Django behavior: spaces become %20, safe chars are preserved
            Ok(Value::String(urlencode(&value.to_string())))
        }
        "stringformat" => {
            // stringformat filter: formats value using Python %-style format spec
            // Usage: {{ value|stringformat:"s" }} → "%s" % value
            // The argument is the format spec WITHOUT the leading %
            let spec = arg.unwrap_or("s");
            Ok(Value::String(apply_stringformat(value, spec)))
        }
        "default_if_none" => {
            // default_if_none filter: fallback only when value is None/Null (not empty string)
            match value {
                Value::Null => Ok(Value::String(arg.unwrap_or("").to_string())),
                _ => Ok(value.clone()),
            }
        }
        "wordcount" => {
            // wordcount filter: count the number of words
            let count = value.to_string().split_whitespace().count();
            Ok(Value::Integer(count as i64))
        }
        "wordwrap" => {
            // wordwrap filter: wrap text at N characters (word boundary)
            let width = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(75);
            Ok(Value::String(word_wrap(&value.to_string(), width)))
        }
        "striptags" => {
            // striptags filter: strip HTML tags from string
            Ok(Value::String(strip_tags(&value.to_string())))
        }
        "addslashes" => {
            // addslashes filter: escape \, ', " with backslashes
            let s = value.to_string();
            let escaped = s
                .replace('\\', "\\\\")
                .replace('\'', "\\'")
                .replace('"', "\\\"");
            Ok(Value::String(escaped))
        }
        "ljust" => {
            // ljust filter: left-align string, pad to width with spaces
            let width = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
            let s = value.to_string();
            Ok(Value::String(format!("{s:<width$}")))
        }
        "rjust" => {
            // rjust filter: right-align string, pad to width with spaces
            let width = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
            let s = value.to_string();
            Ok(Value::String(format!("{s:>width$}")))
        }
        "center" => {
            // center filter: center string, pad to width with spaces
            let width = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
            let s = value.to_string();
            Ok(Value::String(format!("{s:^width$}")))
        }
        "make_list" => {
            // make_list filter: split string into list of characters
            let s = value.to_string();
            let chars: Vec<Value> = s.chars().map(|c| Value::String(c.to_string())).collect();
            Ok(Value::List(chars))
        }
        "json_script" => {
            // json_script filter: wrap value as JSON inside <script id="..."> tag
            let element_id = arg.unwrap_or("data");
            let json_str = value_to_json(value);
            let safe_json = json_escape_for_script(&json_str);
            let safe_id = html_escape(element_id);
            Ok(Value::String(format!(
                "<script id=\"{safe_id}\" type=\"application/json\">{safe_json}</script>"
            )))
        }
        "force_escape" => {
            // force_escape filter: always HTML-escape (unlike escape which is a no-op)
            Ok(Value::String(html_escape(&value.to_string())))
        }
        "escapejs" => {
            // escapejs filter: escape string for use in JavaScript
            Ok(Value::String(escape_js(&value.to_string())))
        }
        "linenumbers" => {
            // linenumbers filter: prepend line numbers to each line
            Ok(Value::String(add_linenumbers(&value.to_string())))
        }
        "get_digit" => {
            // get_digit filter: return Nth digit from right (1-indexed)
            let n = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
            Ok(Value::String(get_digit(&value.to_string(), n)))
        }
        "iriencode" => {
            // iriencode filter: like urlencode but preserves non-ASCII chars
            Ok(Value::String(iriencode(&value.to_string())))
        }
        "phone2numeric" => {
            // phone2numeric filter: convert phone letters to digits
            Ok(Value::String(phone2numeric(&value.to_string())))
        }
        "pprint" => {
            // pprint filter: Python-like repr of value
            Ok(Value::String(pprint_value(value)))
        }
        "safeseq" => {
            // safeseq filter: marks each item in a sequence as safe (no-op at filter level)
            Ok(value.clone())
        }
        "escapeseq" => {
            // escapeseq filter: apply HTML escaping to each item in a sequence
            match value {
                Value::List(items) => {
                    let escaped: Vec<Value> = items
                        .iter()
                        .map(|item| Value::String(html_escape(&item.to_string())))
                        .collect();
                    Ok(Value::List(escaped))
                }
                _ => Ok(Value::String(html_escape(&value.to_string()))),
            }
        }
        "urlize" => {
            // urlize filter: convert URLs and emails to clickable links
            Ok(Value::String(urlize(&value.to_string(), None)))
        }
        "urlizetrunc" => {
            // urlizetrunc filter: like urlize but truncates displayed URL
            let limit = arg.and_then(|s| s.parse::<usize>().ok());
            Ok(Value::String(urlize(&value.to_string(), limit)))
        }
        "unordered_list" => {
            // unordered_list filter: recursively render nested lists as <li>/<ul>
            match value {
                Value::List(items) => Ok(Value::String(unordered_list(items, 1))),
                _ => Ok(value.clone()),
            }
        }
        "truncatechars_html" => {
            // truncatechars_html filter: truncate by char count, preserving HTML tags
            let num_chars = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(20);
            Ok(Value::String(truncate_chars_html(
                &value.to_string(),
                num_chars,
            )))
        }
        "truncatewords_html" => {
            // truncatewords_html filter: truncate by word count, preserving HTML tags
            let num_words = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
            Ok(Value::String(truncate_words_html(
                &value.to_string(),
                num_words,
            )))
        }
        _ => Err(DjangoRustError::TemplateError(format!(
            "Unknown filter: {filter_name}"
        ))),
    }
}

fn titlecase(s: &str) -> String {
    s.split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => {
                    first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
                }
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

pub fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#x27;")
}

fn truncate_words(text: &str, num_words: usize) -> String {
    let words: Vec<&str> = text.split_whitespace().collect();
    if words.len() <= num_words {
        text.to_string()
    } else {
        words[..num_words].join(" ") + "..."
    }
}

fn truncate_chars(text: &str, num_chars: usize) -> String {
    if text.chars().count() <= num_chars {
        text.to_string()
    } else {
        text.chars()
            .take(num_chars.saturating_sub(3))
            .collect::<String>()
            + "..."
    }
}

fn apply_slice(value: &Value, slice_str: &str) -> Result<Value> {
    let parts: Vec<&str> = slice_str.split(':').collect();

    match value {
        Value::String(s) => {
            let chars: Vec<char> = s.chars().collect();
            let len = chars.len() as isize;

            let (start, end) = parse_slice_indices(&parts, len);
            let start = start.max(0) as usize;
            let end = end.min(len).max(0) as usize;

            if start < end && start < chars.len() {
                let sliced: String = chars[start..end.min(chars.len())].iter().collect();
                Ok(Value::String(sliced))
            } else {
                Ok(Value::String(String::new()))
            }
        }
        Value::List(items) => {
            let len = items.len() as isize;
            let (start, end) = parse_slice_indices(&parts, len);
            let start = start.max(0) as usize;
            let end = end.min(len).max(0) as usize;

            if start < end && start < items.len() {
                Ok(Value::List(items[start..end.min(items.len())].to_vec()))
            } else {
                Ok(Value::List(vec![]))
            }
        }
        _ => Ok(value.clone()),
    }
}

fn parse_slice_indices(parts: &[&str], len: isize) -> (isize, isize) {
    let start = if parts.is_empty() || parts[0].is_empty() {
        0
    } else {
        parts[0].parse::<isize>().unwrap_or(0)
    };

    let end = if parts.len() < 2 || parts[1].is_empty() {
        len
    } else {
        parts[1].parse::<isize>().unwrap_or(len)
    };

    (start, end)
}

fn format_timesince(datetime_str: &str) -> Result<String> {
    // Parse ISO datetime string
    let dt = DateTime::parse_from_rfc3339(datetime_str)
        .map_err(|e| DjangoRustError::TemplateError(format!("Invalid datetime format: {e}")))?;

    let now = Utc::now();
    let duration = now.signed_duration_since(dt.with_timezone(&Utc));

    let seconds = duration.num_seconds();

    // Format like Django's timesince
    let formatted = if seconds < 60 {
        format!("{} second{}", seconds, if seconds != 1 { "s" } else { "" })
    } else if seconds < 3600 {
        let minutes = seconds / 60;
        format!("{} minute{}", minutes, if minutes != 1 { "s" } else { "" })
    } else if seconds < 86400 {
        let hours = seconds / 3600;
        format!("{} hour{}", hours, if hours != 1 { "s" } else { "" })
    } else if seconds < 604800 {
        let days = seconds / 86400;
        format!("{} day{}", days, if days != 1 { "s" } else { "" })
    } else if seconds < 2629746 {
        let weeks = seconds / 604800;
        format!("{} week{}", weeks, if weeks != 1 { "s" } else { "" })
    } else if seconds < 31556952 {
        let months = seconds / 2629746;
        format!("{} month{}", months, if months != 1 { "s" } else { "" })
    } else {
        let years = seconds / 31556952;
        format!("{} year{}", years, if years != 1 { "s" } else { "" })
    };

    Ok(formatted)
}

fn format_timeuntil(datetime_str: &str) -> Result<String> {
    // Parse ISO datetime string
    let dt = DateTime::parse_from_rfc3339(datetime_str)
        .map_err(|e| DjangoRustError::TemplateError(format!("Invalid datetime format: {e}")))?;

    let now = Utc::now();
    let duration = dt.with_timezone(&Utc).signed_duration_since(now);

    let seconds = duration.num_seconds();

    // If datetime is in the past, return empty string like Django
    if seconds < 0 {
        return Ok("0 minutes".to_string());
    }

    // Format like Django's timeuntil
    let formatted = if seconds < 60 {
        format!("{} second{}", seconds, if seconds != 1 { "s" } else { "" })
    } else if seconds < 3600 {
        let minutes = seconds / 60;
        format!("{} minute{}", minutes, if minutes != 1 { "s" } else { "" })
    } else if seconds < 86400 {
        let hours = seconds / 3600;
        format!("{} hour{}", hours, if hours != 1 { "s" } else { "" })
    } else if seconds < 604800 {
        let days = seconds / 86400;
        format!("{} day{}", days, if days != 1 { "s" } else { "" })
    } else if seconds < 2629746 {
        let weeks = seconds / 604800;
        format!("{} week{}", weeks, if weeks != 1 { "s" } else { "" })
    } else if seconds < 31556952 {
        let months = seconds / 2629746;
        format!("{} month{}", months, if months != 1 { "s" } else { "" })
    } else {
        let years = seconds / 31556952;
        format!("{} year{}", years, if years != 1 { "s" } else { "" })
    };

    Ok(formatted)
}

fn format_filesize(bytes: i64) -> String {
    const KB: i64 = 1024;
    const MB: i64 = KB * 1024;
    const GB: i64 = MB * 1024;
    const TB: i64 = GB * 1024;
    const PB: i64 = TB * 1024;

    if bytes < KB {
        format!("{bytes} bytes")
    } else if bytes < MB {
        format!("{:.1} KB", bytes as f64 / KB as f64)
    } else if bytes < GB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes < TB {
        format!("{:.1} GB", bytes as f64 / GB as f64)
    } else if bytes < PB {
        format!("{:.1} TB", bytes as f64 / TB as f64)
    } else {
        format!("{:.1} PB", bytes as f64 / PB as f64)
    }
}

fn format_date(datetime_str: &str, format_str: &str) -> Result<String> {
    // Parse ISO datetime string
    let dt = DateTime::parse_from_rfc3339(datetime_str)
        .map_err(|e| DjangoRustError::TemplateError(format!("Invalid datetime format: {e}")))?;

    // Convert common Django format codes to output
    // This is a simplified implementation - Django has many more format codes
    let mut result = String::new();
    let mut chars = format_str.chars().peekable();

    while let Some(ch) = chars.next() {
        match ch {
            // Common date format codes
            'Y' => result.push_str(&dt.year().to_string()), // 2025
            'y' => result.push_str(&format!("{:02}", dt.year() % 100)), // 25
            'm' => result.push_str(&format!("{:02}", dt.month())), // 01-12
            'n' => result.push_str(&dt.month().to_string()), // 1-12
            'd' => result.push_str(&format!("{:02}", dt.day())), // 01-31
            'j' => result.push_str(&dt.day().to_string()),  // 1-31
            'D' => result.push_str(&dt.format("%a").to_string()), // Mon
            'l' => result.push_str(&dt.format("%A").to_string()), // Monday
            'F' => result.push_str(&dt.format("%B").to_string()), // January
            'M' => result.push_str(&dt.format("%b").to_string()), // Jan
            'N' => {
                // Django: "Jan.", "Feb.", etc.
                let month = dt.format("%b").to_string();
                result.push_str(&format!("{month}."));
            }
            // Time format codes
            'G' => result.push_str(&dt.hour().to_string()), // 0-23 (24-hour, no leading zero)
            'H' => result.push_str(&format!("{:02}", dt.hour())), // 00-23
            'g' => {
                // 1-12 (12-hour, no leading zero)
                let hour = dt.hour();
                let display_hour = if hour == 0 {
                    12
                } else if hour > 12 {
                    hour - 12
                } else {
                    hour
                };
                result.push_str(&display_hour.to_string());
            }
            'h' => {
                // 01-12 (12-hour, with leading zero)
                let hour = dt.hour();
                let display_hour = if hour == 0 {
                    12
                } else if hour > 12 {
                    hour - 12
                } else {
                    hour
                };
                result.push_str(&format!("{:02}", display_hour));
            }
            'i' => result.push_str(&format!("{:02}", dt.minute())), // 00-59
            's' => result.push_str(&format!("{:02}", dt.second())), // 00-59
            'A' => {
                // AM/PM
                if dt.hour() < 12 {
                    result.push_str("AM");
                } else {
                    result.push_str("PM");
                }
            }
            'a' => {
                // am/pm
                if dt.hour() < 12 {
                    result.push_str("am");
                } else {
                    result.push_str("pm");
                }
            }
            'P' => {
                // Django: "2:30 p.m.", "midnight", "noon"
                let hour = dt.hour();
                let minute = dt.minute();
                if hour == 0 && minute == 0 {
                    result.push_str("midnight");
                } else if hour == 12 && minute == 0 {
                    result.push_str("noon");
                } else {
                    let display_hour = if hour == 0 {
                        12
                    } else if hour > 12 {
                        hour - 12
                    } else {
                        hour
                    };
                    let ampm = if hour < 12 { "a.m." } else { "p.m." };
                    if minute == 0 {
                        result.push_str(&format!("{display_hour} {ampm}"));
                    } else {
                        result.push_str(&format!("{display_hour}:{minute:02} {ampm}"));
                    }
                }
            }
            // Literal characters
            '\\' => {
                // Escape next character
                if let Some(next) = chars.next() {
                    result.push(next);
                }
            }
            _ => result.push(ch),
        }
    }

    Ok(result)
}

fn format_time(datetime_str: &str, format_str: &str) -> Result<String> {
    // Reuse format_date but focused on time formatting
    format_date(datetime_str, format_str)
}

fn sort_dicts_by_key(items: &[Value], sort_key: &str) -> Vec<Value> {
    let mut sorted_items = items.to_vec();

    sorted_items.sort_by(|a, b| {
        let a_val = get_dict_value(a, sort_key);
        let b_val = get_dict_value(b, sort_key);

        // Compare values
        match (&a_val, &b_val) {
            (Value::String(a_str), Value::String(b_str)) => a_str.cmp(b_str),
            (Value::Integer(a_int), Value::Integer(b_int)) => a_int.cmp(b_int),
            (Value::Float(a_float), Value::Float(b_float)) => a_float
                .partial_cmp(b_float)
                .unwrap_or(std::cmp::Ordering::Equal),
            (Value::Bool(a_bool), Value::Bool(b_bool)) => a_bool.cmp(b_bool),
            _ => std::cmp::Ordering::Equal,
        }
    });

    sorted_items
}

fn get_dict_value(value: &Value, key: &str) -> Value {
    match value {
        Value::Object(map) => map.get(key).cloned().unwrap_or(Value::Null),
        _ => Value::Null,
    }
}

fn slugify(s: &str) -> String {
    // Convert to lowercase and replace non-alphanumeric characters with hyphens
    s.to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect::<String>()
        // Remove consecutive hyphens
        .split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

fn linebreaks(s: &str) -> String {
    // Convert double newlines to </p><p> and single newlines to <br>
    // Similar to Django's linebreaks filter
    let paragraphs: Vec<&str> = s.split("\n\n").collect();

    let formatted_paragraphs: Vec<String> = paragraphs
        .iter()
        .filter(|p| !p.trim().is_empty())
        .map(|p| {
            let lines_with_br = p.split('\n').collect::<Vec<_>>().join("<br>");
            format!("<p>{lines_with_br}</p>")
        })
        .collect();

    formatted_paragraphs.join("\n")
}

fn linebreaksbr(s: &str) -> String {
    // Simply replace newlines with <br> tags
    s.replace('\n', "<br>")
}

fn urlencode(s: &str) -> String {
    // URL-encode a string, matching Django's urlencode behavior
    // Safe characters (not encoded): alphanumeric, -, _, ., ~
    // Everything else is percent-encoded
    // Spaces become %20 (not +)
    let mut result = String::with_capacity(s.len() * 3); // Worst case: every char becomes %XX

    for c in s.chars() {
        if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
            result.push(c);
        } else {
            // Percent-encode the character
            // For multi-byte UTF-8 characters, encode each byte separately
            let mut buf = [0u8; 4];
            let encoded = c.encode_utf8(&mut buf);
            for byte in encoded.bytes() {
                result.push_str(&format!("%{:02X}", byte));
            }
        }
    }

    result
}

fn apply_stringformat(value: &Value, spec: &str) -> String {
    // Implements Django's stringformat filter.
    // The spec is a Python printf-style format specifier WITHOUT the leading %.
    // Common specifiers: "s" (string), "d" (integer), "f" (float),
    // "05d" (zero-padded int), ".2f" (2 decimal places).

    let last_char = spec.chars().last().unwrap_or('s');

    match last_char {
        's' => value.to_string(),
        'd' | 'i' => {
            let int_val = match value {
                Value::Integer(n) => *n,
                Value::Float(f) => *f as i64,
                Value::Bool(b) => {
                    if *b {
                        1
                    } else {
                        0
                    }
                }
                _ => value.to_string().parse::<i64>().unwrap_or(0),
            };

            let prefix = &spec[..spec.len() - 1];
            if prefix.is_empty() {
                format!("{int_val}")
            } else if let Some(stripped) = prefix.strip_prefix('0') {
                let width = if stripped.is_empty() {
                    prefix.parse::<usize>().unwrap_or(0)
                } else {
                    stripped.parse::<usize>().unwrap_or(0)
                };
                format!("{int_val:0>width$}")
            } else {
                let width = prefix.parse::<usize>().unwrap_or(0);
                format!("{int_val:>width$}")
            }
        }
        'f' | 'F' => {
            let float_val = match value {
                Value::Float(f) => *f,
                Value::Integer(n) => *n as f64,
                _ => value.to_string().parse::<f64>().unwrap_or(0.0),
            };

            let prefix = &spec[..spec.len() - 1];
            if let Some(dot_pos) = prefix.find('.') {
                let precision = prefix[dot_pos + 1..].parse::<usize>().unwrap_or(6);
                format!("{float_val:.precision$}")
            } else {
                format!("{float_val:.6}")
            }
        }
        'e' | 'E' => {
            let float_val = match value {
                Value::Float(f) => *f,
                Value::Integer(n) => *n as f64,
                _ => value.to_string().parse::<f64>().unwrap_or(0.0),
            };
            let prefix = &spec[..spec.len() - 1];
            let precision = if let Some(dot_pos) = prefix.find('.') {
                prefix[dot_pos + 1..].parse::<usize>().unwrap_or(6)
            } else {
                6
            };
            if last_char == 'E' {
                format!("{float_val:.precision$E}")
            } else {
                format!("{float_val:.precision$e}")
            }
        }
        _ => value.to_string(),
    }
}

fn word_wrap(text: &str, width: usize) -> String {
    if width == 0 {
        return text.to_string();
    }
    let mut result = String::new();
    let mut line_len = 0;

    for (i, word) in text.split_whitespace().enumerate() {
        let word_len = word.len();
        if i > 0 && line_len + 1 + word_len > width {
            result.push('\n');
            line_len = 0;
        } else if i > 0 {
            result.push(' ');
            line_len += 1;
        }
        result.push_str(word);
        line_len += word_len;
    }
    result
}

fn strip_tags(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut in_tag = false;
    for c in s.chars() {
        match c {
            '<' => in_tag = true,
            '>' => in_tag = false,
            _ if !in_tag => result.push(c),
            _ => {}
        }
    }
    result
}

fn json_escape_for_script(s: &str) -> String {
    // Escape characters that could break out of <script> tags
    // Matches Django's _json_script_escapes (django/utils/html.py)
    s.replace('&', "\\u0026")
        .replace('<', "\\u003C")
        .replace('>', "\\u003E")
        .replace('\u{2028}', "\\u2028")
        .replace('\u{2029}', "\\u2029")
}

fn value_to_json(value: &Value) -> String {
    match value {
        Value::Null => "null".to_string(),
        Value::Bool(b) => {
            if *b {
                "true".to_string()
            } else {
                "false".to_string()
            }
        }
        Value::Integer(n) => n.to_string(),
        Value::Float(f) => format!("{f}"),
        Value::String(s) => {
            // JSON string: escape special chars
            let escaped = s
                .replace('\\', "\\\\")
                .replace('"', "\\\"")
                .replace('\n', "\\n")
                .replace('\r', "\\r")
                .replace('\t', "\\t");
            format!("\"{escaped}\"")
        }
        Value::List(items) => {
            let parts: Vec<String> = items.iter().map(value_to_json).collect();
            format!("[{}]", parts.join(", "))
        }
        Value::Object(map) => {
            let mut parts: Vec<String> = map
                .iter()
                .map(|(k, v)| {
                    let key_json = format!("\"{}\"", k.replace('\\', "\\\\").replace('"', "\\\""));
                    format!("{}: {}", key_json, value_to_json(v))
                })
                .collect();
            parts.sort(); // Deterministic output
            format!("{{{}}}", parts.join(", "))
        }
    }
}

fn escape_js(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => result.push_str("\\u005C"),
            '\'' => result.push_str("\\u0027"),
            '"' => result.push_str("\\u0022"),
            '>' => result.push_str("\\u003E"),
            '<' => result.push_str("\\u003C"),
            '&' => result.push_str("\\u0026"),
            '=' => result.push_str("\\u003D"),
            '-' => result.push_str("\\u002D"),
            ';' => result.push_str("\\u003B"),
            '\n' => result.push_str("\\u000A"),
            '\r' => result.push_str("\\u000D"),
            '\t' => result.push_str("\\u0009"),
            '\0' => result.push_str("\\u0000"),
            '\u{2028}' => result.push_str("\\u2028"),
            '\u{2029}' => result.push_str("\\u2029"),
            _ => result.push(c),
        }
    }
    result
}

fn add_linenumbers(s: &str) -> String {
    let lines: Vec<&str> = s.split('\n').collect();
    let width = lines.len().to_string().len();
    lines
        .iter()
        .enumerate()
        .map(|(i, line)| format!("{:>width$}. {line}", i + 1))
        .collect::<Vec<_>>()
        .join("\n")
}

fn get_digit(s: &str, n: usize) -> String {
    if n == 0 {
        return s.to_string();
    }
    let digits: Vec<char> = s.chars().filter(|c| c.is_ascii_digit()).collect();
    if n > digits.len() {
        return s.to_string();
    }
    digits[digits.len() - n].to_string()
}

fn iriencode(s: &str) -> String {
    // Like urlencode but preserves non-ASCII characters (for IRIs).
    // Matches Django's iri_to_uri: preserves RFC 3986 unreserved + reserved chars.
    let mut result = String::with_capacity(s.len() * 3);
    for c in s.chars() {
        if c.is_ascii_alphanumeric()
            || matches!(c, '-' | '_' | '.' | '~')  // unreserved
            || matches!(c, '/' | ':' | '?' | '#' | '[' | ']' | '@')  // gen-delims
            || matches!(c, '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=')  // sub-delims
            || !c.is_ascii()
        // non-ASCII preserved for IRIs
        {
            result.push(c);
        } else {
            let mut buf = [0u8; 4];
            let encoded = c.encode_utf8(&mut buf);
            for byte in encoded.bytes() {
                result.push_str(&format!("%{:02X}", byte));
            }
        }
    }
    result
}

fn phone2numeric(s: &str) -> String {
    s.chars()
        .map(|c| match c.to_ascii_uppercase() {
            'A' | 'B' | 'C' => '2',
            'D' | 'E' | 'F' => '3',
            'G' | 'H' | 'I' => '4',
            'J' | 'K' | 'L' => '5',
            'M' | 'N' | 'O' => '6',
            'P' | 'Q' | 'R' | 'S' => '7',
            'T' | 'U' | 'V' => '8',
            'W' | 'X' | 'Y' | 'Z' => '9',
            other => other,
        })
        .collect()
}

fn pprint_value(value: &Value) -> String {
    match value {
        Value::Null => "None".to_string(),
        Value::Bool(true) => "True".to_string(),
        Value::Bool(false) => "False".to_string(),
        Value::Integer(n) => n.to_string(),
        Value::Float(f) => format!("{f}"),
        Value::String(s) => format!("'{s}'"),
        Value::List(items) => {
            let parts: Vec<String> = items.iter().map(pprint_value).collect();
            format!("[{}]", parts.join(", "))
        }
        Value::Object(map) => {
            let mut parts: Vec<String> = map
                .iter()
                .map(|(k, v)| format!("'{}': {}", k, pprint_value(v)))
                .collect();
            parts.sort();
            format!("{{{}}}", parts.join(", "))
        }
    }
}

static URLIZE_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(
        r#"(?x)
        (?:
            (?:https?://|ftp://|www\.)   # URL starting with http(s)://, ftp://, or www.
            [^\s<>"']+                   # URL body
        )
        |
        (?:
            [a-zA-Z0-9._%+\-]+          # email local part
            @
            [a-zA-Z0-9.\-]+             # email domain
            \.[a-zA-Z]{2,}              # TLD
        )
    "#,
    )
    .unwrap()
});

fn urlize(text: &str, trunc_limit: Option<usize>) -> String {
    let mut result = String::new();
    let mut last_end = 0;

    for m in URLIZE_RE.find_iter(text) {
        // Escape non-URL text between matches (prevents XSS via raw HTML injection)
        result.push_str(&html_escape(&text[last_end..m.start()]));

        let matched = m.as_str();

        // Determine if this is an email or a URL
        if matched.contains('@') && !matched.starts_with("http") {
            // Email — escape href and display text
            let safe_href = html_escape(matched);
            let display = html_escape(&truncate_url_display(matched, trunc_limit));
            result.push_str(&format!("<a href=\"mailto:{safe_href}\">{display}</a>"));
        } else {
            // URL
            let href = if matched.starts_with("www.") {
                format!("http://{matched}")
            } else {
                matched.to_string()
            };
            // Strip trailing punctuation from href/display that's not part of URL
            let (href_clean, display_raw, trailing) = strip_url_trailing(&href, matched);
            let safe_href = html_escape(&href_clean);
            let display = html_escape(&truncate_url_display(&display_raw, trunc_limit));
            let safe_trailing = html_escape(&trailing);
            result.push_str(&format!(
                "<a href=\"{safe_href}\" rel=\"nofollow\">{display}</a>{safe_trailing}"
            ));
        }

        last_end = m.end();
    }

    // Escape remaining text after last match
    result.push_str(&html_escape(&text[last_end..]));
    result
}

fn strip_url_trailing<'a>(href: &'a str, display: &'a str) -> (String, String, String) {
    // Strip trailing punctuation that's likely not part of the URL.
    // Only strip ')' if parentheses are unbalanced (preserves Wikipedia-style URLs).
    let trailing_chars: &[char] = &['.', ',', '!', '?', ';', ':'];
    let mut href_s = href.to_string();
    let mut display_s = display.to_string();
    let mut trailing = String::new();

    loop {
        if href_s.ends_with(trailing_chars) {
            let c = href_s.pop().unwrap();
            display_s.pop();
            trailing.insert(0, c);
        } else if href_s.ends_with(')') {
            // Only strip ')' if there are more closing than opening parens
            let open = href_s.chars().filter(|&c| c == '(').count();
            let close = href_s.chars().filter(|&c| c == ')').count();
            if close > open {
                href_s.pop();
                display_s.pop();
                trailing.insert(0, ')');
            } else {
                break;
            }
        } else {
            break;
        }
    }

    (href_s, display_s, trailing)
}

fn truncate_url_display(s: &str, limit: Option<usize>) -> String {
    match limit {
        Some(n) if s.chars().count() > n => {
            let truncated: String = s.chars().take(n.saturating_sub(3)).collect();
            format!("{truncated}...")
        }
        _ => s.to_string(),
    }
}

fn unordered_list(items: &[Value], depth: usize) -> String {
    let indent = "\t".repeat(depth);
    let mut result = Vec::new();

    let mut i = 0;
    while i < items.len() {
        let item = &items[i];

        // Check if the next item is a sublist
        let sublist = if i + 1 < items.len() {
            if let Value::List(sub) = &items[i + 1] {
                i += 1; // consume the sublist
                Some(sub)
            } else {
                None
            }
        } else {
            None
        };

        let escaped_item = html_escape(&item.to_string());
        match sublist {
            Some(sub) if !sub.is_empty() => {
                let sub_content = unordered_list(sub, depth + 1);
                let sub_indent = "\t".repeat(depth + 1);
                result.push(format!(
                    "{indent}<li>{escaped_item}\n{sub_indent}<ul>\n{sub_content}\n{sub_indent}</ul>\n{indent}</li>"
                ));
            }
            _ => {
                result.push(format!("{indent}<li>{escaped_item}</li>"));
            }
        }

        i += 1;
    }

    result.join("\n")
}

/// Tracks open HTML tags during truncation, providing shared logic for
/// `truncate_chars_html` and `truncate_words_html`.
struct HtmlTagTracker {
    open_tags: Vec<String>,
}

impl HtmlTagTracker {
    fn new() -> Self {
        Self {
            open_tags: Vec::new(),
        }
    }

    /// Read a full HTML tag from the char iterator and track open/close state.
    /// Returns the raw tag string (e.g. `<b>`, `</p>`).
    fn consume_tag(&mut self, chars: &mut impl Iterator<Item = char>) -> String {
        let mut tag = String::from('<');
        for tc in chars {
            tag.push(tc);
            if tc == '>' {
                break;
            }
        }
        let tag_inner = tag.trim_start_matches('<').trim_end_matches('>').trim();
        if let Some(stripped) = tag_inner.strip_prefix('/') {
            let name = stripped
                .split_whitespace()
                .next()
                .unwrap_or("")
                .to_lowercase();
            if let Some(pos) = self.open_tags.iter().rposition(|t| *t == name) {
                self.open_tags.remove(pos);
            }
        } else if !tag_inner.ends_with('/')
            && !tag_inner.starts_with('!')
            && !is_void_element(tag_inner.split_whitespace().next().unwrap_or(""))
        {
            let name = tag_inner
                .split_whitespace()
                .next()
                .unwrap_or("")
                .to_lowercase();
            if !name.is_empty() {
                self.open_tags.push(name);
            }
        }
        tag
    }

    /// Append closing tags for all still-open elements (in reverse order).
    fn close_open_tags(&self, result: &mut String) {
        for tag_name in self.open_tags.iter().rev() {
            result.push_str(&format!("</{tag_name}>"));
        }
    }
}

fn truncate_chars_html(text: &str, limit: usize) -> String {
    if limit == 0 {
        return String::new();
    }

    let ellipsis = "...";
    let mut visible_count = 0;
    let mut tracker = HtmlTagTracker::new();
    let mut result = String::new();
    let mut chars = text.chars().peekable();
    let target = limit.saturating_sub(ellipsis.len());

    while let Some(c) = chars.next() {
        if c == '<' {
            result.push_str(&tracker.consume_tag(&mut chars));
        } else {
            visible_count += 1;
            if visible_count > target {
                result.push_str(ellipsis);
                tracker.close_open_tags(&mut result);
                return result;
            }
            result.push(c);
        }
    }

    // Text was shorter than the limit, return as-is
    result
}

fn truncate_words_html(text: &str, limit: usize) -> String {
    if limit == 0 {
        return String::new();
    }

    let ellipsis = " ...";
    let mut word_count = 0;
    let mut in_word = false;
    let mut tracker = HtmlTagTracker::new();
    let mut result = String::new();
    let mut chars = text.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '<' {
            in_word = false;
            result.push_str(&tracker.consume_tag(&mut chars));
        } else if c.is_whitespace() {
            if in_word {
                in_word = false;
            }
            if word_count >= limit {
                result.push_str(ellipsis);
                tracker.close_open_tags(&mut result);
                return result;
            }
            result.push(c);
        } else {
            if !in_word {
                word_count += 1;
                in_word = true;
                if word_count > limit {
                    result.push_str(ellipsis);
                    tracker.close_open_tags(&mut result);
                    return result;
                }
            }
            result.push(c);
        }
    }

    result
}

fn is_void_element(tag: &str) -> bool {
    matches!(
        tag.to_lowercase().as_str(),
        "area"
            | "base"
            | "br"
            | "col"
            | "embed"
            | "hr"
            | "img"
            | "input"
            | "link"
            | "meta"
            | "param"
            | "source"
            | "track"
            | "wbr"
    )
}

pub mod tags {
    // Placeholder for custom tags
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_upper_filter() {
        let value = Value::String("hello".to_string());
        let result = apply_filter("upper", &value, None).unwrap();
        assert_eq!(result.to_string(), "HELLO");
    }

    #[test]
    fn test_length_filter() {
        let value = Value::List(vec![Value::Integer(1), Value::Integer(2)]);
        let result = apply_filter("length", &value, None).unwrap();
        assert!(matches!(result, Value::Integer(2)));
    }

    #[test]
    fn test_escape_filter_is_noop() {
        // |escape is a no-op at filter time; auto-escaping happens at render time
        let value = Value::String("<script>alert('xss')</script>".to_string());
        let result = apply_filter("escape", &value, None).unwrap();
        assert_eq!(result.to_string(), "<script>alert('xss')</script>");
    }

    #[test]
    fn test_html_escape_function() {
        assert_eq!(
            html_escape("<b>\"hello\"</b>"),
            "&lt;b&gt;&quot;hello&quot;&lt;/b&gt;"
        );
        assert_eq!(html_escape("safe text"), "safe text");
        assert_eq!(html_escape("a&b"), "a&amp;b");
        assert_eq!(html_escape("it's"), "it&#x27;s");
    }

    #[test]
    fn test_truncatewords_filter() {
        let value = Value::String("This is a long sentence with many words".to_string());
        let result = apply_filter("truncatewords", &value, Some("5")).unwrap();
        assert_eq!(result.to_string(), "This is a long sentence...");
    }

    #[test]
    fn test_truncatechars_filter() {
        let value = Value::String("This is a long string".to_string());
        let result = apply_filter("truncatechars", &value, Some("10")).unwrap();
        assert_eq!(result.to_string(), "This is...");
    }

    #[test]
    fn test_slice_filter() {
        let value = Value::String("hello world".to_string());
        let result = apply_filter("slice", &value, Some(":5")).unwrap();
        assert_eq!(result.to_string(), "hello");
    }

    #[test]
    fn test_add_filter() {
        let value = Value::Integer(5);
        let result = apply_filter("add", &value, Some("3")).unwrap();
        assert!(matches!(result, Value::Integer(8)));
    }

    #[test]
    fn test_pluralize_filter() {
        let value = Value::Integer(1);
        let result = apply_filter("pluralize", &value, None).unwrap();
        assert_eq!(result.to_string(), "");

        let value = Value::Integer(2);
        let result = apply_filter("pluralize", &value, None).unwrap();
        assert_eq!(result.to_string(), "s");

        let value = Value::Integer(0);
        let result = apply_filter("pluralize", &value, Some("es")).unwrap();
        assert_eq!(result.to_string(), "es");
    }

    #[test]
    fn test_slugify_filter() {
        let value = Value::String("Hello World Test!".to_string());
        let result = apply_filter("slugify", &value, None).unwrap();
        assert_eq!(result.to_string(), "hello-world-test");
    }

    #[test]
    fn test_capfirst_filter() {
        let value = Value::String("hello world".to_string());
        let result = apply_filter("capfirst", &value, None).unwrap();
        assert_eq!(result.to_string(), "Hello world");
    }

    #[test]
    fn test_yesno_filter() {
        let value = Value::Bool(true);
        let result = apply_filter("yesno", &value, Some("yeah,nope,dunno")).unwrap();
        assert_eq!(result.to_string(), "yeah");

        let value = Value::Bool(false);
        let result = apply_filter("yesno", &value, Some("yeah,nope,dunno")).unwrap();
        assert_eq!(result.to_string(), "nope");

        let value = Value::Null;
        let result = apply_filter("yesno", &value, Some("yeah,nope,dunno")).unwrap();
        assert_eq!(result.to_string(), "dunno");
    }

    #[test]
    fn test_linebreaks_filter() {
        let value = Value::String("Line 1\nLine 2\n\nParagraph 2".to_string());
        let result = apply_filter("linebreaks", &value, None).unwrap();
        assert!(result.to_string().contains("<p>"));
        assert!(result.to_string().contains("<br>"));
    }

    #[test]
    fn test_linebreaksbr_filter() {
        let value = Value::String("Line 1\nLine 2\nLine 3".to_string());
        let result = apply_filter("linebreaksbr", &value, None).unwrap();
        assert_eq!(result.to_string(), "Line 1<br>Line 2<br>Line 3");
    }

    #[test]
    fn test_cut_filter() {
        let value = Value::String("hello world".to_string());
        let result = apply_filter("cut", &value, Some(" ")).unwrap();
        assert_eq!(result.to_string(), "helloworld");
    }

    #[test]
    fn test_divisibleby_filter() {
        let value = Value::Integer(10);
        let result = apply_filter("divisibleby", &value, Some("2")).unwrap();
        assert!(matches!(result, Value::Bool(true)));

        let value = Value::Integer(10);
        let result = apply_filter("divisibleby", &value, Some("3")).unwrap();
        assert!(matches!(result, Value::Bool(false)));
    }

    #[test]
    fn test_floatformat_filter() {
        let value = Value::Float(std::f64::consts::PI);
        let result = apply_filter("floatformat", &value, Some("2")).unwrap();
        assert_eq!(result.to_string(), "3.14");

        let value = Value::Integer(42);
        let result = apply_filter("floatformat", &value, Some("2")).unwrap();
        assert_eq!(result.to_string(), "42.00");
    }

    #[test]
    fn test_filesizeformat_filter() {
        let value = Value::Integer(1024);
        let result = apply_filter("filesizeformat", &value, None).unwrap();
        assert_eq!(result.to_string(), "1.0 KB");

        let value = Value::Integer(1048576);
        let result = apply_filter("filesizeformat", &value, None).unwrap();
        assert_eq!(result.to_string(), "1.0 MB");

        let value = Value::Integer(500);
        let result = apply_filter("filesizeformat", &value, None).unwrap();
        assert_eq!(result.to_string(), "500 bytes");
    }

    #[test]
    fn test_random_filter() {
        let value = Value::List(vec![
            Value::String("a".to_string()),
            Value::String("b".to_string()),
            Value::String("c".to_string()),
        ]);
        let result = apply_filter("random", &value, None).unwrap();
        // Result should be one of the list items
        match result {
            Value::String(s) => assert!(s == "a" || s == "b" || s == "c"),
            _ => panic!("Expected string value"),
        }

        // Empty list should return Null
        let empty = Value::List(vec![]);
        let result = apply_filter("random", &empty, None).unwrap();
        assert!(matches!(result, Value::Null));
    }

    #[test]
    fn test_timeuntil_filter() {
        // Create a future datetime (1 day from now)
        use chrono::Duration;
        let future = Utc::now() + Duration::days(1);
        let future_str = future.to_rfc3339();
        let value = Value::String(future_str);
        let result = apply_filter("timeuntil", &value, None).unwrap();
        // Should contain "day" or "hour" (depending on exact timing)
        let result_str = result.to_string();
        assert!(
            result_str.contains("day") || result_str.contains("hour"),
            "Expected 'day' or 'hour' in result: {result_str}"
        );
    }

    #[test]
    fn test_date_filter() {
        use chrono::TimeZone;
        // Create a specific datetime for testing
        let dt = Utc.with_ymd_and_hms(2025, 11, 13, 14, 30, 0).unwrap();
        let dt_str = dt.to_rfc3339();
        let value = Value::String(dt_str);

        // Test Y-m-d format
        let result = apply_filter("date", &value, Some("Y-m-d")).unwrap();
        assert_eq!(result.to_string(), "2025-11-13");

        // Test Django default format
        let result = apply_filter("date", &value, Some("N j, Y")).unwrap();
        assert_eq!(result.to_string(), "Nov. 13, 2025");

        // Test with time
        let result = apply_filter("date", &value, Some("Y-m-d H:i")).unwrap();
        assert_eq!(result.to_string(), "2025-11-13 14:30");

        // Test 12-hour format codes (g, h) - afternoon time (14:30 = 2:30 PM)
        let result = apply_filter("date", &value, Some("g:i A")).unwrap();
        assert_eq!(result.to_string(), "2:30 PM");

        let result = apply_filter("date", &value, Some("h:i A")).unwrap();
        assert_eq!(result.to_string(), "02:30 PM");

        // Test 24-hour without leading zero (G)
        let result = apply_filter("date", &value, Some("G:i")).unwrap();
        assert_eq!(result.to_string(), "14:30");

        // Test morning time for 12-hour formats
        let morning = Utc.with_ymd_and_hms(2025, 11, 13, 9, 5, 0).unwrap();
        let morning_str = morning.to_rfc3339();
        let morning_value = Value::String(morning_str);

        let result = apply_filter("date", &morning_value, Some("g:i A")).unwrap();
        assert_eq!(result.to_string(), "9:05 AM");

        let result = apply_filter("date", &morning_value, Some("h:i A")).unwrap();
        assert_eq!(result.to_string(), "09:05 AM");

        // Test midnight (00:00 should be 12:xx AM)
        let midnight = Utc.with_ymd_and_hms(2025, 11, 13, 0, 30, 0).unwrap();
        let midnight_str = midnight.to_rfc3339();
        let midnight_value = Value::String(midnight_str);

        let result = apply_filter("date", &midnight_value, Some("g:i A")).unwrap();
        assert_eq!(result.to_string(), "12:30 AM");

        // Test noon (12:00 should be 12:xx PM)
        let noon = Utc.with_ymd_and_hms(2025, 11, 13, 12, 30, 0).unwrap();
        let noon_str = noon.to_rfc3339();
        let noon_value = Value::String(noon_str);

        let result = apply_filter("date", &noon_value, Some("g:i A")).unwrap();
        assert_eq!(result.to_string(), "12:30 PM");
    }

    #[test]
    fn test_time_filter() {
        use chrono::TimeZone;
        // Test afternoon time
        let dt = Utc.with_ymd_and_hms(2025, 11, 13, 14, 30, 0).unwrap();
        let dt_str = dt.to_rfc3339();
        let value = Value::String(dt_str);

        let result = apply_filter("time", &value, Some("H:i")).unwrap();
        assert_eq!(result.to_string(), "14:30");

        // Test P format (Django time format)
        let result = apply_filter("time", &value, Some("P")).unwrap();
        assert_eq!(result.to_string(), "2:30 p.m.");

        // Test midnight
        let midnight = Utc.with_ymd_and_hms(2025, 11, 13, 0, 0, 0).unwrap();
        let midnight_str = midnight.to_rfc3339();
        let value = Value::String(midnight_str);
        let result = apply_filter("time", &value, Some("P")).unwrap();
        assert_eq!(result.to_string(), "midnight");

        // Test noon
        let noon = Utc.with_ymd_and_hms(2025, 11, 13, 12, 0, 0).unwrap();
        let noon_str = noon.to_rfc3339();
        let value = Value::String(noon_str);
        let result = apply_filter("time", &value, Some("P")).unwrap();
        assert_eq!(result.to_string(), "noon");
    }

    #[test]
    fn test_dictsort_filter() {
        use std::collections::HashMap;

        // Create list of dicts
        let mut dict1 = HashMap::new();
        dict1.insert("name".to_string(), Value::String("Charlie".to_string()));
        dict1.insert("age".to_string(), Value::Integer(30));

        let mut dict2 = HashMap::new();
        dict2.insert("name".to_string(), Value::String("Alice".to_string()));
        dict2.insert("age".to_string(), Value::Integer(25));

        let mut dict3 = HashMap::new();
        dict3.insert("name".to_string(), Value::String("Bob".to_string()));
        dict3.insert("age".to_string(), Value::Integer(35));

        let value = Value::List(vec![
            Value::Object(dict1),
            Value::Object(dict2),
            Value::Object(dict3),
        ]);

        // Sort by name
        let result = apply_filter("dictsort", &value, Some("name")).unwrap();
        if let Value::List(sorted) = result {
            assert_eq!(sorted.len(), 3);
            // First should be Alice
            if let Value::Object(first) = &sorted[0] {
                assert_eq!(first.get("name").unwrap().to_string(), "Alice");
            }
        } else {
            panic!("Expected List value");
        }
    }

    #[test]
    fn test_dictsortreversed_filter() {
        use std::collections::HashMap;

        let mut dict1 = HashMap::new();
        dict1.insert("name".to_string(), Value::String("Alice".to_string()));

        let mut dict2 = HashMap::new();
        dict2.insert("name".to_string(), Value::String("Bob".to_string()));

        let value = Value::List(vec![Value::Object(dict1), Value::Object(dict2)]);

        let result = apply_filter("dictsortreversed", &value, Some("name")).unwrap();
        if let Value::List(sorted) = result {
            // First should be Bob (reversed)
            if let Value::Object(first) = &sorted[0] {
                assert_eq!(first.get("name").unwrap().to_string(), "Bob");
            }
        }
    }

    #[test]
    fn test_urlencode_filter() {
        // Basic text with spaces
        let value = Value::String("Hello World".to_string());
        let result = apply_filter("urlencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "Hello%20World");

        // Text with special characters
        let value = Value::String("Hello World & Friends".to_string());
        let result = apply_filter("urlencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "Hello%20World%20%26%20Friends");

        // Text with query string characters
        let value = Value::String("foo=bar&baz=qux".to_string());
        let result = apply_filter("urlencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "foo%3Dbar%26baz%3Dqux");

        // Safe characters should NOT be encoded
        let value = Value::String("hello-world_test.file~name".to_string());
        let result = apply_filter("urlencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "hello-world_test.file~name");

        // Empty string
        let value = Value::String("".to_string());
        let result = apply_filter("urlencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "");

        // Question mark and slash should be encoded
        let value = Value::String("path/to/file?query=1".to_string());
        let result = apply_filter("urlencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "path%2Fto%2Ffile%3Fquery%3D1");
    }

    #[test]
    fn test_stringformat_filter_string() {
        let value = Value::Integer(42);
        let result = apply_filter("stringformat", &value, Some("s")).unwrap();
        assert_eq!(result.to_string(), "42");

        let value = Value::String("hello".to_string());
        let result = apply_filter("stringformat", &value, Some("s")).unwrap();
        assert_eq!(result.to_string(), "hello");
    }

    #[test]
    fn test_stringformat_filter_integer() {
        let value = Value::Integer(42);
        let result = apply_filter("stringformat", &value, Some("d")).unwrap();
        assert_eq!(result.to_string(), "42");

        let value = Value::Integer(42);
        let result = apply_filter("stringformat", &value, Some("05d")).unwrap();
        assert_eq!(result.to_string(), "00042");
    }

    #[test]
    fn test_stringformat_filter_float() {
        let value = Value::Float(3.14259);
        let result = apply_filter("stringformat", &value, Some(".2f")).unwrap();
        assert_eq!(result.to_string(), "3.14");

        let value = Value::Integer(42);
        let result = apply_filter("stringformat", &value, Some(".1f")).unwrap();
        assert_eq!(result.to_string(), "42.0");
    }

    #[test]
    fn test_stringformat_filter_scientific() {
        let value = Value::Float(1234.5);
        let result = apply_filter("stringformat", &value, Some(".2e")).unwrap();
        assert_eq!(result.to_string(), "1.23e3");

        let result = apply_filter("stringformat", &value, Some(".2E")).unwrap();
        assert_eq!(result.to_string(), "1.23E3");
    }

    #[test]
    fn test_stringformat_filter_default() {
        let value = Value::Integer(42);
        let result = apply_filter("stringformat", &value, None).unwrap();
        assert_eq!(result.to_string(), "42");
    }

    #[test]
    fn test_default_if_none_with_null() {
        let value = Value::Null;
        let result = apply_filter("default_if_none", &value, Some("fallback")).unwrap();
        assert_eq!(result.to_string(), "fallback");
    }

    #[test]
    fn test_default_if_none_with_empty_string() {
        let value = Value::String("".to_string());
        let result = apply_filter("default_if_none", &value, Some("fallback")).unwrap();
        assert_eq!(result.to_string(), "");
    }

    #[test]
    fn test_default_if_none_with_value() {
        let value = Value::String("hello".to_string());
        let result = apply_filter("default_if_none", &value, Some("fallback")).unwrap();
        assert_eq!(result.to_string(), "hello");
    }

    #[test]
    fn test_wordcount_filter() {
        let value = Value::String("one two three four".to_string());
        let result = apply_filter("wordcount", &value, None).unwrap();
        assert!(matches!(result, Value::Integer(4)));
    }

    #[test]
    fn test_wordcount_filter_empty() {
        let value = Value::String("".to_string());
        let result = apply_filter("wordcount", &value, None).unwrap();
        assert!(matches!(result, Value::Integer(0)));
    }

    #[test]
    fn test_wordwrap_filter() {
        let value = Value::String("this is a long string that should wrap".to_string());
        let result = apply_filter("wordwrap", &value, Some("15")).unwrap();
        assert!(result.to_string().contains('\n'));
    }

    #[test]
    fn test_wordwrap_filter_default() {
        let value = Value::String("short".to_string());
        let result = apply_filter("wordwrap", &value, None).unwrap();
        assert_eq!(result.to_string(), "short");
    }

    #[test]
    fn test_striptags_filter() {
        let value = Value::String("<b>Hello</b> <i>world</i>".to_string());
        let result = apply_filter("striptags", &value, None).unwrap();
        assert_eq!(result.to_string(), "Hello world");
    }

    #[test]
    fn test_striptags_filter_nested() {
        let value = Value::String("<div><p>Text</p></div>".to_string());
        let result = apply_filter("striptags", &value, None).unwrap();
        assert_eq!(result.to_string(), "Text");
    }

    #[test]
    fn test_addslashes_filter() {
        let value = Value::String("it's a \"test\" with \\ backslash".to_string());
        let result = apply_filter("addslashes", &value, None).unwrap();
        assert_eq!(
            result.to_string(),
            "it\\'s a \\\"test\\\" with \\\\ backslash"
        );
    }

    #[test]
    fn test_ljust_filter() {
        let value = Value::String("hi".to_string());
        let result = apply_filter("ljust", &value, Some("10")).unwrap();
        assert_eq!(result.to_string(), "hi        ");
        assert_eq!(result.to_string().len(), 10);
    }

    #[test]
    fn test_ljust_filter_no_pad_needed() {
        let value = Value::String("hello".to_string());
        let result = apply_filter("ljust", &value, Some("3")).unwrap();
        assert_eq!(result.to_string(), "hello");
    }

    #[test]
    fn test_rjust_filter() {
        let value = Value::String("hi".to_string());
        let result = apply_filter("rjust", &value, Some("10")).unwrap();
        assert_eq!(result.to_string(), "        hi");
        assert_eq!(result.to_string().len(), 10);
    }

    #[test]
    fn test_center_filter() {
        let value = Value::String("hi".to_string());
        let result = apply_filter("center", &value, Some("10")).unwrap();
        assert_eq!(result.to_string(), "    hi    ");
        assert_eq!(result.to_string().len(), 10);
    }

    #[test]
    fn test_make_list_filter() {
        let value = Value::String("abc".to_string());
        let result = apply_filter("make_list", &value, None).unwrap();
        match result {
            Value::List(items) => {
                assert_eq!(items.len(), 3);
                assert_eq!(items[0].to_string(), "a");
                assert_eq!(items[1].to_string(), "b");
                assert_eq!(items[2].to_string(), "c");
            }
            _ => panic!("Expected List value"),
        }
    }

    #[test]
    fn test_json_script_filter() {
        let value = Value::String("hello".to_string());
        let result = apply_filter("json_script", &value, Some("my-data")).unwrap();
        let s = result.to_string();
        assert!(s.starts_with("<script id=\"my-data\" type=\"application/json\">"));
        assert!(s.ends_with("</script>"));
        assert!(s.contains("\"hello\""));
    }

    #[test]
    fn test_json_script_filter_escapes_script_tags() {
        let value = Value::String("</script><script>alert(1)".to_string());
        let result = apply_filter("json_script", &value, Some("data")).unwrap();
        let s = result.to_string();
        // Must not contain literal </script> inside the JSON
        assert!(!s[..s.len() - 9].contains("</script>"));
        assert!(s.contains("\\u003C"));
    }

    #[test]
    fn test_json_script_filter_escapes_line_separators() {
        let value = Value::String("line\u{2028}sep\u{2029}end".to_string());
        let result = apply_filter("json_script", &value, Some("data")).unwrap();
        let s = result.to_string();
        assert!(s.contains("\\u2028"));
        assert!(s.contains("\\u2029"));
        assert!(!s.contains('\u{2028}'));
        assert!(!s.contains('\u{2029}'));
    }

    #[test]
    fn test_force_escape_filter() {
        let value = Value::String("<b>hello</b>".to_string());
        let result = apply_filter("force_escape", &value, None).unwrap();
        assert_eq!(result.to_string(), "&lt;b&gt;hello&lt;/b&gt;");
    }

    #[test]
    fn test_force_escape_quotes() {
        let value = Value::String("it's \"quoted\"".to_string());
        let result = apply_filter("force_escape", &value, None).unwrap();
        assert!(result.to_string().contains("&#x27;"));
        assert!(result.to_string().contains("&quot;"));
    }

    #[test]
    fn test_escapejs_filter() {
        let value = Value::String("hello\\world".to_string());
        let result = apply_filter("escapejs", &value, None).unwrap();
        assert!(result.to_string().contains("\\u005C"));

        let value = Value::String("it's \"quoted\"".to_string());
        let result = apply_filter("escapejs", &value, None).unwrap();
        assert!(result.to_string().contains("\\u0027"));
        assert!(result.to_string().contains("\\u0022"));

        let value = Value::String("line1\nline2\ttab".to_string());
        let result = apply_filter("escapejs", &value, None).unwrap();
        assert!(result.to_string().contains("\\u000A"));
        assert!(result.to_string().contains("\\u0009"));

        // Test U+2028/U+2029 (line/paragraph separators)
        let value = Value::String("a\u{2028}b\u{2029}c".to_string());
        let result = apply_filter("escapejs", &value, None).unwrap();
        assert!(result.to_string().contains("\\u2028"));
        assert!(result.to_string().contains("\\u2029"));
    }

    #[test]
    fn test_linenumbers_filter() {
        let value = Value::String("first\nsecond\nthird".to_string());
        let result = apply_filter("linenumbers", &value, None).unwrap();
        assert_eq!(result.to_string(), "1. first\n2. second\n3. third");
    }

    #[test]
    fn test_linenumbers_filter_alignment() {
        // With 10+ lines, numbers should be right-aligned
        let lines: Vec<&str> = (0..12).map(|_| "line").collect();
        let value = Value::String(lines.join("\n"));
        let result = apply_filter("linenumbers", &value, None).unwrap();
        let output = result.to_string();
        assert!(output.starts_with(" 1. line"));
        assert!(output.contains("12. line"));
    }

    #[test]
    fn test_get_digit_filter() {
        // 1 = rightmost digit
        let value = Value::String("12345".to_string());
        let result = apply_filter("get_digit", &value, Some("1")).unwrap();
        assert_eq!(result.to_string(), "5");

        let result = apply_filter("get_digit", &value, Some("3")).unwrap();
        assert_eq!(result.to_string(), "3");

        // Out of range returns original
        let result = apply_filter("get_digit", &value, Some("10")).unwrap();
        assert_eq!(result.to_string(), "12345");

        // 0 returns original (Django behavior)
        let result = apply_filter("get_digit", &value, Some("0")).unwrap();
        assert_eq!(result.to_string(), "12345");
    }

    #[test]
    fn test_iriencode_filter() {
        // Preserves non-ASCII chars (unlike urlencode)
        let value = Value::String("café".to_string());
        let result = apply_filter("iriencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "café");

        // Encodes ASCII specials (spaces)
        let value = Value::String("hello world".to_string());
        let result = apply_filter("iriencode", &value, None).unwrap();
        assert!(result.to_string().contains("%20"));

        // Preserves / and :
        let value = Value::String("http://example.com/path".to_string());
        let result = apply_filter("iriencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "http://example.com/path");

        // Preserves # and ? (query/fragment)
        let value = Value::String("http://example.com/path?q=1&p=2#frag".to_string());
        let result = apply_filter("iriencode", &value, None).unwrap();
        assert_eq!(result.to_string(), "http://example.com/path?q=1&p=2#frag");
    }

    #[test]
    fn test_phone2numeric_filter() {
        let value = Value::String("1-800-COLLECT".to_string());
        let result = apply_filter("phone2numeric", &value, None).unwrap();
        assert_eq!(result.to_string(), "1-800-2655328");
    }

    #[test]
    fn test_pprint_filter() {
        // String value
        let value = Value::String("hello".to_string());
        let result = apply_filter("pprint", &value, None).unwrap();
        assert_eq!(result.to_string(), "'hello'");

        // Integer
        let value = Value::Integer(42);
        let result = apply_filter("pprint", &value, None).unwrap();
        assert_eq!(result.to_string(), "42");

        // List
        let value = Value::List(vec![Value::Integer(1), Value::Integer(2)]);
        let result = apply_filter("pprint", &value, None).unwrap();
        assert_eq!(result.to_string(), "[1, 2]");

        // Bool
        let value = Value::Bool(true);
        let result = apply_filter("pprint", &value, None).unwrap();
        assert_eq!(result.to_string(), "True");

        // Null
        let value = Value::Null;
        let result = apply_filter("pprint", &value, None).unwrap();
        assert_eq!(result.to_string(), "None");
    }

    #[test]
    fn test_safeseq_filter() {
        let value = Value::List(vec![
            Value::String("<b>bold</b>".to_string()),
            Value::String("plain".to_string()),
        ]);
        let result = apply_filter("safeseq", &value, None).unwrap();
        // safeseq is a no-op at filter level; returns the list unchanged
        if let Value::List(items) = result {
            assert_eq!(items.len(), 2);
            assert_eq!(items[0].to_string(), "<b>bold</b>");
        } else {
            panic!("Expected List value");
        }
    }

    #[test]
    fn test_escapeseq_filter() {
        let value = Value::List(vec![
            Value::String("<b>bold</b>".to_string()),
            Value::String("plain".to_string()),
        ]);
        let result = apply_filter("escapeseq", &value, None).unwrap();
        if let Value::List(items) = result {
            assert_eq!(items.len(), 2);
            assert_eq!(items[0].to_string(), "&lt;b&gt;bold&lt;/b&gt;");
            assert_eq!(items[1].to_string(), "plain");
        } else {
            panic!("Expected List value");
        }
    }

    #[test]
    fn test_urlize_filter() {
        // URL detection
        let value = Value::String("Visit https://example.com for info".to_string());
        let result = apply_filter("urlize", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("<a href=\"https://example.com\""));
        assert!(s.contains("rel=\"nofollow\""));

        // Email detection
        let value = Value::String("Email user@example.com for help".to_string());
        let result = apply_filter("urlize", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("<a href=\"mailto:user@example.com\">"));

        // www prefix
        let value = Value::String("Go to www.example.com today".to_string());
        let result = apply_filter("urlize", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("<a href=\"http://www.example.com\""));

        // ftp:// support
        let value = Value::String("Download from ftp://files.example.com/pub".to_string());
        let result = apply_filter("urlize", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("<a href=\"ftp://files.example.com/pub\""));

        // Balanced parentheses preserved (Wikipedia-style URLs)
        let value =
            Value::String("See https://en.wikipedia.org/wiki/Foo_(bar) for details".to_string());
        let result = apply_filter("urlize", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("href=\"https://en.wikipedia.org/wiki/Foo_(bar)\""));

        // Unbalanced trailing paren still stripped
        let value = Value::String("(visit https://example.com) for info".to_string());
        let result = apply_filter("urlize", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("href=\"https://example.com\""));
        assert!(s.contains("</a>)"));
    }

    #[test]
    fn test_urlizetrunc_filter() {
        let value = Value::String("Visit https://example.com/very/long/path for info".to_string());
        let result = apply_filter("urlizetrunc", &value, Some("15")).unwrap();
        let s = result.to_string();
        assert!(s.contains("..."));
        assert!(s.contains("href=\"https://example.com/very/long/path\""));
    }

    #[test]
    fn test_unordered_list_filter() {
        // Flat list
        let value = Value::List(vec![
            Value::String("one".to_string()),
            Value::String("two".to_string()),
        ]);
        let result = apply_filter("unordered_list", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("<li>one</li>"));
        assert!(s.contains("<li>two</li>"));

        // Nested list: ["item", ["sub1", "sub2"]]
        let value = Value::List(vec![
            Value::String("parent".to_string()),
            Value::List(vec![
                Value::String("child1".to_string()),
                Value::String("child2".to_string()),
            ]),
        ]);
        let result = apply_filter("unordered_list", &value, None).unwrap();
        let s = result.to_string();
        assert!(s.contains("<li>parent"));
        assert!(s.contains("<ul>"));
        assert!(s.contains("<li>child1</li>"));
        assert!(s.contains("<li>child2</li>"));
        assert!(s.contains("</ul>"));
    }

    #[test]
    fn test_truncatechars_html_filter() {
        // Truncate and close tags
        let value = Value::String("<p>Hello <b>world</b> this is long</p>".to_string());
        let result = apply_filter("truncatechars_html", &value, Some("11")).unwrap();
        let s = result.to_string();
        // Should preserve tags, count only visible chars, and close open tags
        assert!(s.contains("<p>"));
        assert!(s.contains("..."));
        // Open tags should be closed
        assert!(s.ends_with("</p>") || s.ends_with("</b></p>"));

        // Short text unchanged
        let value = Value::String("<b>Hi</b>".to_string());
        let result = apply_filter("truncatechars_html", &value, Some("20")).unwrap();
        assert_eq!(result.to_string(), "<b>Hi</b>");
    }

    #[test]
    fn test_truncatewords_html_filter() {
        // Truncate by words, preserving tags
        let value = Value::String("<p>one two <b>three four</b> five six</p>".to_string());
        let result = apply_filter("truncatewords_html", &value, Some("3")).unwrap();
        let s = result.to_string();
        assert!(s.contains("one"));
        assert!(s.contains("two"));
        assert!(s.contains("three"));
        assert!(s.contains("..."));

        // Short text unchanged
        let value = Value::String("<b>one two</b>".to_string());
        let result = apply_filter("truncatewords_html", &value, Some("10")).unwrap();
        assert_eq!(result.to_string(), "<b>one two</b>");
    }
}
