use std::sync::LazyLock;

use rustc_hash::FxHashSet;

use crate::template_usage::TemplateUsage;

use super::scanners::{scan_curly_section, scan_html_tag};
use super::shared::{
    HTML_COMMENT_RE, extract_pattern_binding_names, merge_component_tag_usage,
    merge_expression_usage_allow_dollar_refs, merge_statement_usage_allow_dollar_refs,
    parse_tag_attrs,
};

static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r#"(?is)<style\b(?:[^>"']|"[^"]*"|'[^']*')*>(?P<body>[\s\S]*?)</style>"#)
        .expect("valid regex")
});

static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r#"(?is)<script\b(?:[^>"']|"[^"]*"|'[^']*')*>(?P<body>[\s\S]*?)</script>"#)
        .expect("valid regex")
});

static SVELTE_EACH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(
        r"(?is)^#each\s+(?P<iterable>.+?)\s+as\s+(?P<bindings>.+?)(?:\s*\((?P<key>.+)\))?$",
    )
    .expect("valid regex")
});

static SVELTE_AWAIT_RE: LazyLock<regex::Regex> =
    LazyLock::new(|| regex::Regex::new(r"(?is)^#await\s+(?P<expr>.+)$").expect("valid regex"));

static SVELTE_THEN_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"(?is)^:then(?:\s+(?P<binding>.+))?$").expect("valid regex")
});

static SVELTE_CATCH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"(?is)^:catch(?:\s+(?P<binding>.+))?$").expect("valid regex")
});

static SVELTE_SNIPPET_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"(?is)^#snippet\s+[A-Za-z_$][\w$]*\s*\((?P<params>.*)\)\s*$")
        .expect("valid regex")
});

#[derive(Debug, Clone, PartialEq, Eq)]
enum SvelteBlockKind {
    Root,
    If,
    Each,
    Await,
    Key,
    Snippet,
    Element,
}

const VOID_HTML_TAGS: &[&str] = &[
    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
    "track", "wbr",
];

#[derive(Debug, Clone)]
struct SvelteScopeFrame {
    kind: SvelteBlockKind,
    locals: Vec<String>,
}

pub(super) fn collect_template_usage(
    source: &str,
    imported_bindings: &FxHashSet<String>,
) -> TemplateUsage {
    if imported_bindings.is_empty() {
        return TemplateUsage::default();
    }

    let markup = strip_non_template_content(source);
    if markup.is_empty() {
        return TemplateUsage::default();
    }

    let mut usage = TemplateUsage::default();
    let mut scopes = vec![SvelteScopeFrame {
        kind: SvelteBlockKind::Root,
        locals: Vec::new(),
    }];

    let bytes = markup.as_bytes();
    let mut index = 0;
    while index < bytes.len() {
        match bytes[index] {
            b'{' => {
                let Some((tag, next_index)) = scan_curly_section(&markup, index, 1, 1) else {
                    break;
                };
                apply_tag(tag.trim(), imported_bindings, &mut scopes, &mut usage);
                index = next_index;
            }
            b'<' => {
                let Some((tag, next_index)) = scan_html_tag(&markup, index) else {
                    break;
                };
                apply_markup_tag(tag, imported_bindings, &mut scopes, &mut usage);
                index = next_index;
            }
            _ => index += 1,
        }
    }

    usage
}

fn strip_non_template_content(source: &str) -> String {
    let mut hidden_ranges: Vec<(usize, usize)> = Vec::new();
    hidden_ranges.extend(
        HTML_COMMENT_RE
            .find_iter(source)
            .map(|m| (m.start(), m.end())),
    );
    hidden_ranges.extend(
        SCRIPT_BLOCK_RE
            .find_iter(source)
            .map(|m| (m.start(), m.end())),
    );
    hidden_ranges.extend(
        STYLE_BLOCK_RE
            .find_iter(source)
            .map(|m| (m.start(), m.end())),
    );
    hidden_ranges.sort_unstable_by_key(|range| range.0);

    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(hidden_ranges.len());
    for (start, end) in hidden_ranges {
        if let Some((_, last_end)) = merged.last_mut()
            && start <= *last_end
        {
            *last_end = (*last_end).max(end);
            continue;
        }
        merged.push((start, end));
    }

    let mut visible = String::new();
    let mut cursor = 0;
    for (start, end) in merged {
        if cursor < start {
            visible.push_str(&source[cursor..start]);
        }
        cursor = end;
    }
    if cursor < source.len() {
        visible.push_str(&source[cursor..]);
    }
    visible
}

#[expect(
    clippy::too_many_lines,
    reason = "Svelte tag dispatch is inherently branchy; split candidate for sig-audit-loop"
)]
fn apply_tag(
    tag: &str,
    imported_bindings: &FxHashSet<String>,
    scopes: &mut Vec<SvelteScopeFrame>,
    usage: &mut TemplateUsage,
) {
    if tag.is_empty() {
        return;
    }

    if let Some(rest) = tag.strip_prefix('/') {
        pop_scope(scopes, rest.trim());
        return;
    }

    if let Some(expr) = tag.strip_prefix("#if") {
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr.trim(),
            imported_bindings,
            &current_locals(scopes),
        );
        scopes.push(SvelteScopeFrame {
            kind: SvelteBlockKind::If,
            locals: Vec::new(),
        });
        return;
    }

    if let Some(captures) = SVELTE_EACH_RE.captures(tag) {
        let iterable = captures.name("iterable").map_or("", |m| m.as_str()).trim();
        let bindings = captures.name("bindings").map_or("", |m| m.as_str()).trim();
        let each_locals = extract_pattern_binding_names(bindings);
        let current = current_locals(scopes);
        merge_expression_usage_allow_dollar_refs(usage, iterable, imported_bindings, &current);
        if let Some(key) = captures.name("key").map(|m| m.as_str().trim())
            && !key.is_empty()
        {
            let mut key_locals = current;
            key_locals.extend(each_locals.iter().cloned());
            merge_expression_usage_allow_dollar_refs(usage, key, imported_bindings, &key_locals);
        }
        scopes.push(SvelteScopeFrame {
            kind: SvelteBlockKind::Each,
            locals: each_locals,
        });
        return;
    }

    if let Some(captures) = SVELTE_AWAIT_RE.captures(tag) {
        let expr = captures.name("expr").map_or("", |m| m.as_str()).trim();
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr,
            imported_bindings,
            &current_locals(scopes),
        );
        scopes.push(SvelteScopeFrame {
            kind: SvelteBlockKind::Await,
            locals: Vec::new(),
        });
        return;
    }

    if let Some(captures) = SVELTE_THEN_RE.captures(tag) {
        if let Some(frame) = scopes
            .iter_mut()
            .rev()
            .find(|frame| matches!(frame.kind, SvelteBlockKind::Await))
        {
            frame.locals = captures
                .name("binding")
                .map(|m| extract_pattern_binding_names(m.as_str()))
                .unwrap_or_default();
        }
        return;
    }

    if let Some(captures) = SVELTE_CATCH_RE.captures(tag) {
        if let Some(frame) = scopes
            .iter_mut()
            .rev()
            .find(|frame| matches!(frame.kind, SvelteBlockKind::Await))
        {
            frame.locals = captures
                .name("binding")
                .map(|m| extract_pattern_binding_names(m.as_str()))
                .unwrap_or_default();
        }
        return;
    }

    if let Some(expr) = tag.strip_prefix("#key") {
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr.trim(),
            imported_bindings,
            &current_locals(scopes),
        );
        scopes.push(SvelteScopeFrame {
            kind: SvelteBlockKind::Key,
            locals: Vec::new(),
        });
        return;
    }

    if let Some(captures) = SVELTE_SNIPPET_RE.captures(tag) {
        let params = captures.name("params").map_or("", |m| m.as_str());
        scopes.push(SvelteScopeFrame {
            kind: SvelteBlockKind::Snippet,
            locals: extract_pattern_binding_names(params),
        });
        return;
    }

    if let Some(expr) = tag.strip_prefix("@html") {
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr.trim(),
            imported_bindings,
            &current_locals(scopes),
        );
        return;
    }

    if let Some(expr) = tag.strip_prefix("@render") {
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr.trim(),
            imported_bindings,
            &current_locals(scopes),
        );
        return;
    }

    if let Some(stmt) = tag.strip_prefix("@const") {
        let locals = current_locals(scopes);
        merge_statement_usage_allow_dollar_refs(usage, stmt.trim(), imported_bindings, &locals);
        if let Some(lhs) = stmt.split_once('=').map(|(lhs, _)| lhs.trim()) {
            let new_bindings = extract_pattern_binding_names(lhs);
            if let Some(frame) = scopes.last_mut() {
                frame.locals.extend(new_bindings);
            }
        }
        return;
    }

    if let Some(expr) = tag.strip_prefix("@debug") {
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr.trim(),
            imported_bindings,
            &current_locals(scopes),
        );
        return;
    }

    if let Some(expr) = tag.strip_prefix(":else if") {
        merge_expression_usage_allow_dollar_refs(
            usage,
            expr.trim(),
            imported_bindings,
            &current_locals(scopes),
        );
        return;
    }

    if tag.starts_with(":else") {
        return;
    }

    merge_expression_usage_allow_dollar_refs(
        usage,
        tag,
        imported_bindings,
        &current_locals(scopes),
    );
}

fn apply_markup_tag(
    tag: &str,
    imported_bindings: &FxHashSet<String>,
    scopes: &mut Vec<SvelteScopeFrame>,
    usage: &mut TemplateUsage,
) {
    let trimmed = tag.trim();
    if trimmed.starts_with("</") {
        if let Some(frame) = scopes.last()
            && frame.kind == SvelteBlockKind::Element
        {
            scopes.pop();
        }
        return;
    }

    if trimmed.starts_with("<!") || trimmed.starts_with("<?") {
        return;
    }

    let parsed = parse_tag_attrs(trimmed, true);
    if parsed.name.is_empty() {
        return;
    }

    let current = current_locals(scopes);
    if parsed.name.contains('.')
        || parsed
            .name
            .chars()
            .next()
            .is_some_and(|ch| ch.is_ascii_uppercase())
    {
        merge_component_tag_usage(usage, &parsed.name, imported_bindings, &current, false);
    }

    let mut element_locals = Vec::new();
    for attr in &parsed.attrs {
        if let Some(binding) = directive_binding_name(&attr.name) {
            merge_expression_usage_allow_dollar_refs(usage, binding, imported_bindings, &current);
        }
        if let Some(local) = attr.name.strip_prefix("let:")
            && !local.is_empty()
        {
            element_locals.extend(extract_pattern_binding_names(local));
        }
        if let Some(expr) = shorthand_attribute_expression(&attr.name) {
            merge_expression_usage_allow_dollar_refs(usage, expr, imported_bindings, &current);
        }
        if let Some(value) = attr.value.as_deref() {
            merge_attribute_value_usage(usage, value, imported_bindings, &current);
        }
    }

    if !parsed.self_closing && !is_void_html_tag(&parsed.name) {
        scopes.push(SvelteScopeFrame {
            kind: SvelteBlockKind::Element,
            locals: element_locals,
        });
    }
}

fn directive_binding_name(attr_name: &str) -> Option<&str> {
    for prefix in ["use:", "animate:", "in:", "out:", "transition:"] {
        if let Some(rest) = attr_name.strip_prefix(prefix) {
            let binding = rest
                .split('|')
                .next()
                .map(str::trim)
                .filter(|name| !name.is_empty());
            if binding.is_some() {
                return binding;
            }
        }
    }
    None
}

fn shorthand_attribute_expression(attr_name: &str) -> Option<&str> {
    attr_name
        .strip_prefix('{')
        .and_then(|rest| rest.strip_suffix('}'))
        .map(str::trim)
        .filter(|expr| !expr.is_empty())
}

fn merge_attribute_value_usage(
    usage: &mut TemplateUsage,
    value: &str,
    imported_bindings: &FxHashSet<String>,
    locals: &[String],
) {
    let mut index = 0;
    let mut found_expression = false;
    let bytes = value.as_bytes();

    while index < bytes.len() {
        if bytes[index] == b'{' {
            let Some((expr, next_index)) = scan_curly_section(value, index, 1, 1) else {
                break;
            };
            merge_expression_usage_allow_dollar_refs(usage, expr, imported_bindings, locals);
            found_expression = true;
            index = next_index;
            continue;
        }
        index += 1;
    }

    if !found_expression && value.starts_with('{') && value.ends_with('}') && value.len() >= 2 {
        merge_expression_usage_allow_dollar_refs(
            usage,
            &value[1..value.len() - 1],
            imported_bindings,
            locals,
        );
    }
}

fn is_void_html_tag(tag_name: &str) -> bool {
    VOID_HTML_TAGS.contains(&tag_name)
}

fn pop_scope(scopes: &mut Vec<SvelteScopeFrame>, closing: &str) {
    let kind = match closing {
        "if" => Some(SvelteBlockKind::If),
        "each" => Some(SvelteBlockKind::Each),
        "await" => Some(SvelteBlockKind::Await),
        "key" => Some(SvelteBlockKind::Key),
        "snippet" => Some(SvelteBlockKind::Snippet),
        _ => None,
    };

    let Some(kind) = kind else {
        return;
    };

    if let Some(index) = scopes.iter().rposition(|frame| frame.kind == kind)
        && index > 0
    {
        scopes.truncate(index);
    }
}

fn current_locals(scopes: &[SvelteScopeFrame]) -> Vec<String> {
    scopes
        .iter()
        .flat_map(|frame| frame.locals.iter().cloned())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::collect_template_usage;
    use rustc_hash::FxHashSet;

    fn imported(names: &[&str]) -> FxHashSet<String> {
        names.iter().map(|name| (*name).to_string()).collect()
    }

    #[test]
    fn plain_expression_marks_binding_used() {
        let usage = collect_template_usage(
            "<script>import { formatDate } from './utils';</script><p>{formatDate(value)}</p>",
            &imported(&["formatDate"]),
        );

        assert!(usage.used_bindings.contains("formatDate"));
    }

    #[test]
    fn each_alias_shadows_import_name() {
        let usage = collect_template_usage(
            "<script>import { item } from './utils';</script>{#each items as item}<p>{item}</p>{/each}",
            &imported(&["item"]),
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn await_then_alias_shadows_import_name() {
        let usage = collect_template_usage(
            "<script>import { value } from './utils';</script>{#await promise}{:then value}<p>{value}</p>{/await}",
            &imported(&["value"]),
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn namespace_member_accesses_are_retained() {
        let usage = collect_template_usage(
            "<script>import * as utils from './utils';</script><p>{utils.formatDate(value)}</p>",
            &imported(&["utils"]),
        );

        assert!(usage.used_bindings.contains("utils"));
        assert_eq!(usage.member_accesses.len(), 1);
        assert_eq!(usage.member_accesses[0].object, "utils");
        assert_eq!(usage.member_accesses[0].member, "formatDate");
    }

    #[test]
    fn styles_are_ignored() {
        let usage = collect_template_usage(
            "<style>.button { color: red; }</style><script>import { button } from './utils';</script>",
            &imported(&["button"]),
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn component_tags_mark_imported_components_used() {
        let usage = collect_template_usage(
            "<script>import FancyButton from './FancyButton.svelte';</script><FancyButton />",
            &imported(&["FancyButton"]),
        );

        assert!(usage.used_bindings.contains("FancyButton"));
    }

    #[test]
    fn namespaced_component_tags_record_member_usage() {
        let usage = collect_template_usage(
            "<script>import * as Icons from './icons';</script><Icons.Alert />",
            &imported(&["Icons"]),
        );

        assert!(usage.used_bindings.contains("Icons"));
        assert_eq!(usage.member_accesses.len(), 1);
        assert_eq!(usage.member_accesses[0].object, "Icons");
        assert_eq!(usage.member_accesses[0].member, "Alert");
    }

    #[test]
    fn directive_names_mark_imported_actions_used() {
        let usage = collect_template_usage(
            "<script>import { tooltip } from './actions';</script><button use:tooltip>Hi</button>",
            &imported(&["tooltip"]),
        );

        assert!(usage.used_bindings.contains("tooltip"));
    }

    #[test]
    fn attribute_value_expressions_mark_imported_bindings_used() {
        let usage = collect_template_usage(
            "<script>import { isActive } from './state';</script><button class:active={isActive}>Hi</button>",
            &imported(&["isActive"]),
        );

        assert!(usage.used_bindings.contains("isActive"));
    }

    #[test]
    fn shorthand_attributes_mark_imported_bindings_used() {
        let usage = collect_template_usage(
            "<script>import { page } from './stores';</script><Component {page} />",
            &imported(&["page"]),
        );

        assert!(usage.used_bindings.contains("page"));
    }

    #[test]
    fn dollar_store_refs_mark_imported_store_used() {
        let usage = collect_template_usage(
            "<script>import { page } from './stores';</script><p>{$page.url.pathname}</p>",
            &imported(&["page"]),
        );

        assert!(usage.used_bindings.contains("page"));
    }

    #[test]
    fn let_directives_shadow_imported_names() {
        let usage = collect_template_usage(
            "<script>import { item } from './utils';</script><Slot let:item><p>{item}</p></Slot>",
            &imported(&["item"]),
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn local_let_bindings_shadow_imported_component_tags() {
        let usage = collect_template_usage(
            "<script>import Item from './Item.svelte';</script><Slot let:Item><Item /></Slot>",
            &imported(&["Item"]),
        );

        assert!(usage.is_empty());
    }

    // --- Early returns ---

    #[test]
    fn empty_imported_bindings_returns_empty_usage() {
        let usage = collect_template_usage("<p>{formatDate(value)}</p>", &imported(&[]));

        assert!(usage.is_empty());
    }

    #[test]
    fn only_script_and_style_returns_empty_markup() {
        let usage = collect_template_usage(
            "<script>import { x } from './x';</script><style>p { color: red; }</style>",
            &imported(&["x"]),
        );

        assert!(usage.is_empty());
    }

    // --- strip_non_template_content ---

    #[test]
    fn html_comments_are_stripped() {
        let usage = collect_template_usage(
            "<!-- {hidden(value)} --><p>{visible(value)}</p>",
            &imported(&["hidden", "visible"]),
        );

        assert!(usage.used_bindings.contains("visible"));
        assert!(!usage.used_bindings.contains("hidden"));
    }

    #[test]
    fn overlapping_ranges_are_merged_during_stripping() {
        // The script and style blocks overlap conceptually with a comment
        // that spans across them, testing the merge logic in strip_non_template_content
        let usage = collect_template_usage(
            "<script>let x;</script><!-- comment --><style>p{}</style><p>{fmt(v)}</p>",
            &imported(&["fmt"]),
        );

        assert!(usage.used_bindings.contains("fmt"));
    }

    // --- #key block ---

    #[test]
    fn key_block_marks_key_expression_used() {
        let usage = collect_template_usage(
            "<p>{#key selectedId}<Child />{/key}</p>",
            &imported(&["selectedId", "Child"]),
        );

        assert!(usage.used_bindings.contains("selectedId"));
        assert!(usage.used_bindings.contains("Child"));
    }

    // --- #snippet block ---

    #[test]
    fn snippet_params_shadow_imported_names() {
        let usage = collect_template_usage(
            "{#snippet row(item)}<p>{item}</p>{/snippet}",
            &imported(&["item"]),
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn snippet_body_uses_outer_imported_bindings() {
        let usage = collect_template_usage(
            "{#snippet row(local)}<p>{format(local)}</p>{/snippet}",
            &imported(&["format"]),
        );

        assert!(usage.used_bindings.contains("format"));
    }

    #[test]
    fn snippet_typed_params_do_not_stack_overflow() {
        // Regression: `{ href, content }: Props` caused infinite recursion in
        // `extract_pattern_binding_names` because the type annotation prevented
        // `strip_wrapping` from matching, sending the input into a self-recursive
        // comma-split path that never progressed.
        let usage = collect_template_usage(
            "{#snippet Link({ href, content }: Props)}<a {href}>{content}</a>{/snippet}",
            &imported(&["href", "content"]),
        );

        // href and content are snippet-local bindings, so they shadow imports
        assert!(usage.is_empty());
    }

    // --- @html ---

    #[test]
    fn at_html_marks_expression_used() {
        let usage = collect_template_usage("{@html sanitize(content)}", &imported(&["sanitize"]));

        assert!(usage.used_bindings.contains("sanitize"));
    }

    // --- @render ---

    #[test]
    fn at_render_marks_expression_used() {
        let usage = collect_template_usage("{@render header()}", &imported(&["header"]));

        assert!(usage.used_bindings.contains("header"));
    }

    // --- @const ---

    #[test]
    fn at_const_marks_rhs_expression_used() {
        let usage = collect_template_usage(
            "{#each items as item}{@const label = format(item)}<p>{label}</p>{/each}",
            &imported(&["format"]),
        );

        assert!(usage.used_bindings.contains("format"));
    }

    #[test]
    fn at_const_shadows_subsequent_usages() {
        // `myVal` is imported but @const declares a local `myVal`, shadowing
        // subsequent references to it in the same scope
        let usage = collect_template_usage(
            "{#each items as item}{@const myVal = item.name}<p>{myVal}</p>{/each}",
            &imported(&["myVal"]),
        );

        // The @const statement itself references `myVal` as the LHS assignment target,
        // so it's marked as used, but subsequent {myVal} references are shadowed
        assert!(usage.used_bindings.contains("myVal"));
    }

    // --- @debug ---

    #[test]
    fn at_debug_marks_expression_used() {
        let usage = collect_template_usage("{@debug count}", &imported(&["count"]));

        assert!(usage.used_bindings.contains("count"));
    }

    // --- :else if ---

    #[test]
    fn else_if_marks_condition_used() {
        let usage = collect_template_usage(
            "{#if a}<p>a</p>{:else if isReady}<p>b</p>{/if}",
            &imported(&["isReady"]),
        );

        assert!(usage.used_bindings.contains("isReady"));
    }

    // --- :else ---

    #[test]
    fn else_branch_does_not_generate_usage() {
        let usage = collect_template_usage(
            "{#if cond}<p>a</p>{:else}<p>{fallback(x)}</p>{/if}",
            &imported(&["fallback"]),
        );

        assert!(usage.used_bindings.contains("fallback"));
    }

    // --- #if block ---

    #[test]
    fn if_block_marks_condition_expression_used() {
        let usage = collect_template_usage(
            "{#if isVisible}<p>Hello</p>{/if}",
            &imported(&["isVisible"]),
        );

        assert!(usage.used_bindings.contains("isVisible"));
    }

    // --- closing block with unknown kind ---

    #[test]
    fn closing_unknown_block_kind_is_no_op() {
        // {/unknownblock} should not crash or affect scopes
        let usage = collect_template_usage("{/unknownblock}<p>{fmt(x)}</p>", &imported(&["fmt"]));

        assert!(usage.used_bindings.contains("fmt"));
    }

    // --- #each with key expression ---

    #[test]
    fn each_key_expression_marks_binding_used() {
        let usage = collect_template_usage(
            "{#each items as item (getId(item))}<p>{item}</p>{/each}",
            &imported(&["getId"]),
        );

        assert!(usage.used_bindings.contains("getId"));
    }

    #[test]
    fn each_key_expression_has_access_to_each_locals() {
        // The key expression can reference the `item` alias
        // so `item` should be shadowed in the key context
        let usage = collect_template_usage(
            "{#each items as item (item.id)}<p>{item}</p>{/each}",
            &imported(&["item"]),
        );

        assert!(usage.is_empty());
    }

    // --- await with :catch ---

    #[test]
    fn catch_binding_shadows_import_name() {
        let usage = collect_template_usage(
            "{#await promise}{:catch error}<p>{error}</p>{/await}",
            &imported(&["error"]),
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn catch_without_binding_does_not_crash() {
        let usage = collect_template_usage(
            "{#await loadData()}{:catch}<p>Error</p>{/await}",
            &imported(&["loadData"]),
        );

        assert!(usage.used_bindings.contains("loadData"));
    }

    #[test]
    fn then_without_binding_does_not_crash() {
        let usage = collect_template_usage(
            "{#await loadData()}{:then}<p>Done</p>{/await}",
            &imported(&["loadData"]),
        );

        assert!(usage.used_bindings.contains("loadData"));
    }

    // --- Markup tag branches ---

    #[test]
    fn html_doctype_and_processing_instructions_are_ignored() {
        let usage = collect_template_usage(
            "<!DOCTYPE html><?xml version=\"1.0\"?><p>{fmt(x)}</p>",
            &imported(&["fmt"]),
        );

        assert!(usage.used_bindings.contains("fmt"));
    }

    #[test]
    fn void_html_tags_do_not_push_element_scope() {
        // <input> is void; the closing </div> should pop the outer element scope, not fail
        let usage = collect_template_usage(
            "<div><input value={val} /><p>{handler(x)}</p></div>",
            &imported(&["val", "handler"]),
        );

        assert!(usage.used_bindings.contains("val"));
        assert!(usage.used_bindings.contains("handler"));
    }

    #[test]
    fn closing_markup_tag_pops_element_scope() {
        let usage = collect_template_usage(
            "<div let:item><p>{item}</p></div><p>{helper(x)}</p>",
            &imported(&["item", "helper"]),
        );

        // item is shadowed inside <div> by let:item, but helper is used outside
        assert!(usage.used_bindings.contains("helper"));
    }

    // --- Directive directives ---

    #[test]
    fn animate_directive_marks_binding_used() {
        let usage = collect_template_usage("<div animate:flip>content</div>", &imported(&["flip"]));

        assert!(usage.used_bindings.contains("flip"));
    }

    #[test]
    fn transition_directive_marks_binding_used() {
        let usage =
            collect_template_usage("<div transition:fade>content</div>", &imported(&["fade"]));

        assert!(usage.used_bindings.contains("fade"));
    }

    #[test]
    fn in_directive_marks_binding_used() {
        let usage = collect_template_usage("<div in:fly>content</div>", &imported(&["fly"]));

        assert!(usage.used_bindings.contains("fly"));
    }

    #[test]
    fn out_directive_marks_binding_used() {
        let usage = collect_template_usage("<div out:slide>content</div>", &imported(&["slide"]));

        assert!(usage.used_bindings.contains("slide"));
    }

    #[test]
    fn directive_with_modifier_strips_pipe() {
        let usage = collect_template_usage(
            "<div transition:fade|local>content</div>",
            &imported(&["fade"]),
        );

        assert!(usage.used_bindings.contains("fade"));
    }

    // --- Attribute value parsing ---

    #[test]
    fn unquoted_attribute_value_is_parsed() {
        let usage =
            collect_template_usage("<div data-value=hello>content</div>", &imported(&["hello"]));

        // Unquoted attribute values are plain strings, not expressions
        assert!(usage.is_empty());
    }

    #[test]
    fn curly_brace_attribute_value_is_parsed() {
        let usage = collect_template_usage(
            "<div class={getClass()}>content</div>",
            &imported(&["getClass"]),
        );

        assert!(usage.used_bindings.contains("getClass"));
    }

    #[test]
    fn attribute_without_value_is_handled() {
        let usage = collect_template_usage(
            "<button disabled><p>{action(x)}</p></button>",
            &imported(&["action"]),
        );

        assert!(usage.used_bindings.contains("action"));
    }

    // --- Expression in attribute value with surrounding text ---

    #[test]
    fn interpolated_attribute_value_marks_binding_used() {
        let usage = collect_template_usage(
            "<div class=\"prefix-{cls}-suffix\">content</div>",
            &imported(&["cls"]),
        );

        assert!(usage.used_bindings.contains("cls"));
    }

    // --- Multiple expressions in a single text node ---

    #[test]
    fn multiple_curly_expressions_all_tracked() {
        let usage = collect_template_usage(
            "<p>{first(x)} and {second(y)}</p>",
            &imported(&["first", "second"]),
        );

        assert!(usage.used_bindings.contains("first"));
        assert!(usage.used_bindings.contains("second"));
    }

    // --- Empty tag in curly braces ---

    #[test]
    fn empty_curly_braces_produce_no_usage() {
        let usage = collect_template_usage("{ }<p>{fmt(x)}</p>", &imported(&["fmt"]));

        assert!(usage.used_bindings.contains("fmt"));
    }

    // --- Self-closing void tags ---

    #[test]
    fn self_closing_tag_does_not_push_scope() {
        let usage = collect_template_usage("<br /><p>{fmt(x)}</p>", &imported(&["fmt"]));

        assert!(usage.used_bindings.contains("fmt"));
    }

    // --- Deeply nested scoping ---

    #[test]
    fn nested_each_and_if_scoping_works_correctly() {
        let usage = collect_template_usage(
            "{#each rows as row}{#if row.visible}<p>{format(row.name)}</p>{/if}{/each}",
            &imported(&["format", "row"]),
        );

        assert!(usage.used_bindings.contains("format"));
        // row is shadowed by the each binding
        assert!(!usage.used_bindings.contains("row"));
    }

    // --- @const without equals (edge case) ---

    #[test]
    fn at_const_rhs_references_are_tracked() {
        let usage = collect_template_usage(
            "{#each items as item}{@const x = compute(item)}<p>{x}</p>{/each}",
            &imported(&["compute"]),
        );

        assert!(usage.used_bindings.contains("compute"));
    }

    // --- Closing tag without matching Element scope ---

    #[test]
    fn closing_tag_without_element_scope_is_safe() {
        // </div> with no matching open scope should not crash
        let usage = collect_template_usage("</div><p>{fmt(x)}</p>", &imported(&["fmt"]));

        assert!(usage.used_bindings.contains("fmt"));
    }

    // --- Snippet closing ---

    #[test]
    fn snippet_closing_pops_scope_correctly() {
        let usage = collect_template_usage(
            "{#snippet cell(item)}<p>{item}</p>{/snippet}<p>{outer(x)}</p>",
            &imported(&["item", "outer"]),
        );

        // item is shadowed inside snippet, outer is used outside
        assert!(!usage.used_bindings.contains("item"));
        assert!(usage.used_bindings.contains("outer"));
    }

    // --- Key block closing ---

    #[test]
    fn key_block_closing_pops_scope() {
        let usage = collect_template_usage(
            "{#key id}<Child />{/key}<p>{helper(x)}</p>",
            &imported(&["id", "Child", "helper"]),
        );

        assert!(usage.used_bindings.contains("id"));
        assert!(usage.used_bindings.contains("Child"));
        assert!(usage.used_bindings.contains("helper"));
    }

    // --- Plain expression fallthrough (no special prefix) ---

    #[test]
    fn plain_expression_without_prefix_is_tracked() {
        let usage = collect_template_usage("{count + 1}", &imported(&["count"]));

        assert!(usage.used_bindings.contains("count"));
    }

    // --- Attribute with single-quoted value ---

    #[test]
    fn single_quoted_attribute_value_expressions_are_parsed() {
        let usage = collect_template_usage(
            "<div title='{getName()}'>content</div>",
            &imported(&["getName"]),
        );

        assert!(usage.used_bindings.contains("getName"));
    }
}
