//! Emit SwiftUI `View` source from a `.crepus` template (AST — preserves `@if` / `for`).

use crate::swiftgen_style;
use std::collections::HashMap;
use std::path::Path;

use anyhow::{anyhow, Context as _};
use crepuscularity_core::ast::{Element, ForBlock, IfBlock, MatchBlock, Node, TextPart};

/// Generate a SwiftUI view struct and write `<view_name>.swift` into `out_dir`.
pub fn run(
    view_path: &Path,
    out_dir: &Path,
    view_name: &str,
    context_type: &str,
) -> anyhow::Result<()> {
    let source = std::fs::read_to_string(view_path)
        .with_context(|| format!("read {}", view_path.display()))?;
    let nodes = crepuscularity_core::parser::parse_template(&source)
        .map_err(|e| anyhow!("parse {}: {e}", view_path.display()))?;
    let swift = emit_view(&nodes, view_name, context_type)?;
    std::fs::create_dir_all(out_dir)?;
    let out = out_dir.join(format!("{}.swift", view_name));
    std::fs::write(&out, swift)?;
    eprintln!("  {}  →  {}", view_path.display(), out.display());
    Ok(())
}

#[derive(Clone)]
struct GenCtx {
    /// Names introduced by `for` (innermost last).
    loop_vars: Vec<String>,
    /// `$: let` bindings → Swift expression string.
    lets: HashMap<String, String>,
}

impl GenCtx {
    fn new() -> Self {
        Self {
            lets: HashMap::new(),
            loop_vars: Vec::new(),
        }
    }

    fn with_let(&self, name: String, expr_swift: String) -> Self {
        let mut s = self.clone();
        s.lets.insert(name, expr_swift);
        s
    }

    fn with_loop_var(&self, name: String) -> Self {
        let mut s = self.clone();
        if !name.is_empty() && name != "_" {
            s.loop_vars.push(name);
        }
        s
    }

    fn resolve_ident(&self, head: &str) -> String {
        if self.lets.contains_key(head) {
            return head.to_string();
        }
        for v in self.loop_vars.iter().rev() {
            if *v == head {
                return head.to_string();
            }
        }
        format!("context.{}", head)
    }
}

fn emit_view(nodes: &[Node], view_name: &str, context_type: &str) -> anyhow::Result<String> {
    let mut out = String::new();
    out.push_str("// Generated by `aurorality swiftgen` — do not edit by hand.\n");
    out.push_str("import SwiftUI\n");
    let (menubar, dock_bind, body_nodes) = partition_root(nodes);
    if dock_bind.is_some() {
        out.push_str("#if os(macOS)\nimport AppKit\n#endif\n");
    }
    out.push('\n');
    if dock_bind.is_some() {
        out.push_str(
            r#"private enum AurorDockBadge {
    static func apply(count: Int) {
        #if os(macOS)
        NSApplication.shared.dockTile.badgeLabel = count > 0 ? "\(count)" : nil
        #endif
    }
}

"#,
        );
    }
    out.push_str(&format!(
        "struct {}: View {{\n    let context: {}\n    let eventSink: (String) -> Void\n\n",
        view_name, context_type
    ));
    out.push_str("    var body: some View {\n");
    let cx = GenCtx::new();
    let mut body = emit_nodes_block(&body_nodes, &cx, 8)?;
    if let Some(bind) = &dock_bind {
        let expr = field_accessor(bind, &cx)?;
        body = format!(
            "{}Group {{\n{}\n{}}}\n{}.onAppear {{ AurorDockBadge.apply(count: Int({})) }}\n{}.onChange(of: {}) {{ _, v in AurorDockBadge.apply(count: Int(v)) }}",
            indent(8),
            body,
            indent(8),
            indent(8),
            expr,
            indent(8),
            expr
        );
    }
    out.push_str(&body);
    out.push_str(swiftgen_style::root_frame());
    out.push_str("\n    }\n}\n");

    if let Some(mb) = menubar {
        out.push_str(&emit_commands_struct(&mb, view_name, context_type, &cx)?);
    }
    Ok(out)
}

/// Pull `menubar`, root `dockbadge`, and `notification` nodes out of the template root.
fn partition_root(nodes: &[Node]) -> (Option<Element>, Option<String>, Vec<Node>) {
    let mut menubar: Option<Element> = None;
    let mut dock_bind: Option<String> = None;
    let mut rest: Vec<Node> = Vec::new();

    for n in nodes {
        match n {
            Node::Element(el) if el.tag.eq_ignore_ascii_case("menubar") => {
                if menubar.is_none() {
                    menubar = Some(el.clone());
                } else {
                    rest.push(n.clone());
                }
            }
            Node::Element(el) if el.tag.eq_ignore_ascii_case("dockbadge") => {
                if dock_bind.is_none() {
                    dock_bind = binding_value(el, "bind");
                }
            }
            Node::Element(el) if el.tag.eq_ignore_ascii_case("notification") => {
                // Declarative hook only — consume at root (handled via menubar / model).
            }
            _ => rest.push(n.clone()),
        }
    }

    (menubar, dock_bind, rest)
}

fn emit_commands_struct(
    menubar: &Element,
    view_name: &str,
    context_type: &str,
    cx: &GenCtx,
) -> anyhow::Result<String> {
    let cmds_name = format!("{view_name}Commands");
    let mut inner = String::new();
    for child in &menubar.children {
        inner.push_str(&emit_menu_top(child, cx, 8)?);
    }
    Ok(format!(
        r#"struct {cmds_name}: Commands {{
    let context: {ctx}
    let eventSink: (String) -> Void

    var body: some Commands {{
{inner}
    }}
}}

"#,
        cmds_name = cmds_name,
        ctx = context_type,
        inner = inner
    ))
}

fn emit_menu_top(n: &Node, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    match n {
        Node::Element(el) if el.tag.eq_ignore_ascii_case("menu") => {
            let title = binding_value(el, "title").unwrap_or_else(|| "Menu".to_string());
            let body = emit_menu_items(&el.children, cx, ind + 4)?;
            Ok(format!(
                "{}CommandMenu(\"{}\") {{\n{}\n{}}}\n",
                indent(ind),
                escape_swift_string(&title),
                body,
                indent(ind)
            ))
        }
        _ => Ok(String::new()),
    }
}

fn emit_menu_items(nodes: &[Node], cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let mut lines = Vec::new();
    for n in nodes {
        match n {
            Node::Element(el) if el.tag.eq_ignore_ascii_case("menuitem") => {
                lines.push(emit_menu_item(el, cx, ind)?);
            }
            Node::Element(el) if el.tag.eq_ignore_ascii_case("menuseparator") => {
                lines.push(format!("{}Divider()", indent(ind)));
            }
            Node::Element(el) if el.tag.eq_ignore_ascii_case("menu") => {
                // Nested menus become buttons that open submenus where possible — keep flat for now.
                lines.push(emit_menu_top(n, cx, ind)?);
            }
            _ => {}
        }
    }
    Ok(lines.join("\n"))
}

fn emit_menu_item(el: &Element, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let click = el
        .event_handlers
        .iter()
        .find(|h| h.event == "click")
        .map(|h| h.handler.trim_matches('"').to_string())
        .unwrap_or_default();
    let title = binding_value(el, "title")
        .or_else(|| collect_inline_text(&el.children, cx).ok())
        .unwrap_or_default();
    let title = title.trim().to_string();
    let event_expr = event_handler_expr(&click, cx)?;
    let shortcut = binding_value(el, "shortcut");
    let btn = format!(
        "{}Button({}) {{\n{}eventSink({})\n{}}}",
        indent(ind),
        swift_string_literal(&title),
        indent(ind + 4),
        event_expr,
        indent(ind)
    );
    if let Some(sh) = shortcut {
        if let Some(chain) = keyboard_shortcut_chain(&sh) {
            return Ok(format!("{}{}", btn, chain));
        }
    }
    Ok(btn)
}

fn swift_string_literal(s: &str) -> String {
    format!("\"{}\"", escape_swift_string(s))
}

fn keyboard_shortcut_chain(spec: &str) -> Option<String> {
    let tokens: Vec<String> = spec
        .split('+')
        .map(|s| s.trim().to_ascii_lowercase())
        .filter(|s| !s.is_empty())
        .collect();
    if tokens.is_empty() {
        return None;
    }
    let mut mods: Vec<&str> = Vec::new();
    let mut key_parts: Vec<String> = Vec::new();
    for t in tokens {
        match t.as_str() {
            "cmd" | "command" => mods.push("command"),
            "shift" => mods.push("shift"),
            "opt" | "option" | "alt" => mods.push("option"),
            "ctrl" | "control" => mods.push("control"),
            other => key_parts.push(other.to_string()),
        }
    }
    if key_parts.is_empty() {
        return None;
    }
    let key = key_parts.join("+");
    let mod_arg = match mods.len() {
        0 => ".command".to_string(),
        1 => format!(".{}", mods[0]),
        _ => format!(
            "[{}]",
            mods.iter()
                .map(|m| format!(".{}", m))
                .collect::<Vec<_>>()
                .join(", ")
        ),
    };
    let shortcut = if key == "," {
        format!(
            "\n            .keyboardShortcut(\",\", modifiers: {mod_arg})",
            mod_arg = mod_arg
        )
    } else {
        format!(
            "\n            .keyboardShortcut(\"{}\", modifiers: {mod_arg})",
            escape_swift_string(&key),
            mod_arg = mod_arg
        )
    };
    Some(shortcut)
}

fn indent(n: usize) -> String {
    " ".repeat(n)
}

fn emit_nodes_block(nodes: &[Node], cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let mut cx = cx.clone();
    for n in nodes {
        if let Node::LetDecl(d) = n {
            let ex = translate_expr(&d.expr, &cx)?;
            cx = cx.with_let(d.name.clone(), ex);
        }
    }
    let mut lines = Vec::new();
    for n in nodes {
        if matches!(n, Node::LetDecl(_)) {
            continue;
        }
        lines.push(emit_node(n, &cx, ind)?);
    }
    if lines.is_empty() {
        return Ok(format!("{}EmptyView()", indent(ind)));
    }
    if lines.len() == 1 {
        return Ok(lines[0].clone());
    }
    Ok(lines.join("\n"))
}

fn emit_node(n: &Node, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    match n {
        Node::LetDecl(_) => Ok(format!("{}EmptyView()", indent(ind))),
        Node::Include(_) => Err(anyhow!(
            "`include` is not supported in swiftgen yet; expand or remove includes"
        )),
        Node::If(b) => emit_if(b, cx, ind),
        Node::For(b) => emit_for(b, cx, ind),
        Node::Match(b) => emit_match(b, cx, ind),
        Node::Text(parts) => Ok(emit_text(parts, cx, ind)?),
        Node::RawText(expr) => {
            let e = translate_expr(expr.trim(), cx)?;
            Ok(format!("{}Text(String(describing: {}))", indent(ind), e))
        }
        Node::Element(el) => emit_element(el, cx, ind),
        Node::Embed(_) => Err(anyhow!(
            "`embed` is not supported in swiftgen yet; use IR rendering instead"
        )),
        Node::RawHtml(_) => Err(anyhow!(
            "`raw_html` is not supported in swiftgen yet; use IR rendering instead"
        )),
    }
}

fn emit_if(b: &IfBlock, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let cond = translate_expr(&b.condition, cx)?;
    let then_part = emit_nodes_block(&b.then_children, cx, ind + 4)?;
    let else_part = if let Some(els) = &b.else_children {
        emit_nodes_block(els, cx, ind + 4)?
    } else {
        format!("{}EmptyView()", indent(ind + 4))
    };
    let ind_str = indent(ind);
    let mut out = String::new();
    out.push_str(&format!("{}if {} {{\n", ind_str, cond));
    out.push_str(&then_part);
    out.push('\n');
    out.push_str(&ind_str);
    out.push_str("} else {\n");
    out.push_str(&else_part);
    out.push('\n');
    out.push_str(&ind_str);
    out.push_str("}\n");
    Ok(out)
}

fn emit_for(b: &ForBlock, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let iter_expr = translate_expr(&b.iterator, cx)?;
    let pattern = b.pattern.trim();
    let inner_cx = if pattern.is_empty() || pattern == "_" {
        cx.clone()
    } else {
        cx.with_loop_var(pattern.to_string())
    };
    let inner = emit_nodes_block(&b.body, &inner_cx, ind + 8)?;
    if pattern.is_empty() || pattern == "_" {
        return Ok(format!(
            "{}ForEach(Array({}.enumerated()), id: \\.offset) {{ _, __row in\n{}\n{}}}",
            indent(ind),
            iter_expr,
            inner,
            indent(ind + 4)
        ));
    }
    Ok(format!(
        "{}ForEach(Array({}.enumerated()), id: \\.offset) {{ _, {} in\n{}\n{}}}",
        indent(ind),
        iter_expr,
        pattern,
        inner,
        indent(ind + 4)
    ))
}

fn emit_match(b: &MatchBlock, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let e = translate_expr(&b.expr, cx)?;
    let mut arms = String::new();
    let mut has_wild = false;
    for arm in &b.arms {
        let p = arm.pattern.trim();
        if p == "_" {
            has_wild = true;
            let body = emit_nodes_block(&arm.body, cx, ind + 12)?;
            arms.push_str(&format!(
                "{}default: {}\n",
                indent(ind + 8),
                wrap_in_block(&body, ind + 12)
            ));
            continue;
        }
        let pat_swift = if let Some(st) = p.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
            format!("\"{}\"", st.replace('\\', "\\\\").replace('"', "\\\""))
        } else {
            format!("\"{}\"", p)
        };
        let body = emit_nodes_block(&arm.body, cx, ind + 12)?;
        arms.push_str(&format!(
            "{}case {}: {}\n",
            indent(ind + 8),
            pat_swift,
            wrap_in_block(&body, ind + 12)
        ));
    }
    if !has_wild {
        arms.push_str(&format!("{}default: EmptyView()\n", indent(ind + 8)));
    }
    Ok(format!(
        "{}VStack(spacing: 0) {{\n{}switch String(describing: {}) {{\n{}\n{}}}\n{}}}",
        indent(ind),
        indent(ind + 4),
        e,
        arms,
        indent(ind + 4),
        indent(ind),
    ))
}

fn wrap_in_block(body: &str, ind: usize) -> String {
    if body.trim_start().starts_with("VStack") || body.trim_start().starts_with("Group") {
        body.to_string()
    } else {
        format!("VStack(spacing: 0) {{\n{}\n{}}}", body, indent(ind))
    }
}

fn emit_text(parts: &[TextPart], cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    if parts.len() == 1 {
        match &parts[0] {
            TextPart::Literal(s) => Ok(format!(
                "{}Text(\"{}\")",
                indent(ind),
                escape_swift_string(s)
            )),
            TextPart::Expr(e) => Ok(format!(
                "{}Text(String(describing: {}))",
                indent(ind),
                translate_expr(e, cx)?
            )),
        }
    } else {
        let mut swift_txt = String::new();
        for p in parts {
            match p {
                TextPart::Literal(s) => {
                    swift_txt.push_str(&escape_for_swift_interpolation_literal(s))
                }
                TextPart::Expr(e) => {
                    swift_txt.push_str(&format!("\\({})", translate_expr(e, cx)?));
                }
            }
        }
        Ok(format!("{}Text(\"{}\")", indent(ind), swift_txt))
    }
}

fn escape_for_swift_interpolation_literal(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace("\"", "\\\"")
        .replace("\n", "\\n")
}

fn escape_swift_string(s: &str) -> String {
    escape_for_swift_interpolation_literal(s)
}

fn sanitize_field_key(s: &str) -> String {
    s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()
}

fn binding_value(el: &Element, key: &str) -> Option<String> {
    el.bindings
        .iter()
        .find(|b| b.prop == key)
        .map(|b| {
            b.value
                .trim()
                .trim_matches(|c| c == '"' || c == '\'')
                .to_string()
        })
        .filter(|s| !s.is_empty())
}

/// Template binding value as a Swift expression (`{foo}`, `{!foo}`, or bare identifier).
fn binding_expression_optional(
    el: &Element,
    key: &str,
    cx: &GenCtx,
) -> anyhow::Result<Option<String>> {
    let Some(raw) = binding_value(el, key) else {
        return Ok(None);
    };
    let mut inner = raw.trim().to_string();
    inner = inner.trim_matches(|c| c == '"' || c == '\'').to_string();
    if inner.starts_with('{') && inner.ends_with('}') {
        inner = inner[1..inner.len() - 1].trim().to_string();
    }
    let expr = if let Some(rest) = inner.strip_prefix('!') {
        format!("!({})", translate_expr(rest.trim(), cx)?)
    } else {
        translate_expr(inner.trim(), cx)?
    };
    Ok(Some(expr))
}

fn first_text_parts(children: &[Node]) -> Option<&[TextPart]> {
    for c in children {
        if let Node::Text(parts) = c {
            return Some(parts.as_slice());
        }
    }
    None
}

fn emit_custom_element(
    tag: &str,
    el: &Element,
    cx: &GenCtx,
    ind: usize,
) -> anyhow::Result<Option<String>> {
    if tag == "notification" {
        let click = el
            .event_handlers
            .iter()
            .find(|h| h.event == "click" || h.event == "notify")
            .map(|h| h.handler.trim_matches('"').to_string())
            .or_else(|| binding_value(el, "event"))
            .unwrap_or_default();
        let event_expr = event_handler_expr(&click, cx)?;
        return Ok(Some(format!(
            "{}EmptyView().hidden()\n{}.onAppear {{ eventSink({}) }}",
            indent(ind),
            indent(ind),
            event_expr
        )));
    }

    if tag == "dockbadge" {
        return Ok(Some(format!("{}EmptyView()", indent(ind))));
    }

    if tag == "avatar" {
        let bind =
            sanitize_field_key(&binding_value(el, "bind").unwrap_or_else(|| "id".to_string()));
        let field = field_accessor(&bind, cx)?;
        let size = binding_value(el, "size").unwrap_or_else(|| "36".into());
        let sz: f64 = size.parse().unwrap_or(36.0);
        let font = (sz * 0.42).max(10.0);
        return Ok(Some(format!(
            r#"{}Text(String(String(describing: {}).prefix(1)).uppercased())
            .font(.system(size: {}, weight: .semibold))
            .foregroundStyle(Color.accentColor)
            .frame(width: {}, height: {})
            .background(Color.accentColor.opacity(0.12))
            .clipShape(Circle())"#,
            indent(ind),
            field,
            font,
            sz,
            sz
        )));
    }

    if tag == "badge" {
        let parts = first_text_parts(&el.children)
            .ok_or_else(|| anyhow!("<badge> needs inline text (swiftgen limitation)"))?;
        let txt = emit_text(parts, cx, ind)?;
        return Ok(Some(format!(
            "{}\n            .font(.caption2.weight(.semibold))\n            .padding(.horizontal, 6)\n            .padding(.vertical, 2)\n            .background(Color.accentColor)\n            .foregroundStyle(Color.white)\n            .clipShape(Capsule())",
            txt.trim_end()
        )));
    }

    if tag == "messagebubble" {
        let bind = sanitize_field_key(&binding_value(el, "bind").unwrap_or_else(|| "m".into()));
        let b = field_accessor(&bind, cx)?;
        return Ok(Some(format!(
            r#"{}Group {{
            if {b}.isOutgoing {{
                HStack {{
                    Spacer()
                    VStack(alignment: .leading, spacing: 4) {{
                        Text(String(describing: {b}.body))
                            .font(.callout)
                            .foregroundStyle(Color.white)
                        Text(String(describing: {b}.metaLine))
                            .font(.caption)
                            .foregroundStyle(Color.white.opacity(0.85))
                    }}
                    .padding(12)
                    .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color.accentColor))
                }}
            }} else {{
                HStack {{
                    VStack(alignment: .leading, spacing: 4) {{
                        Text(String(describing: {b}.body))
                            .font(.callout)
                        Text(String(describing: {b}.metaLine))
                            .font(.caption)
                            .foregroundStyle(Color.secondary)
                    }}
                    .padding(12)
                    .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color(nsColor: .underPageBackgroundColor)))
                    Spacer()
                }}
            }}
        }}"#,
            indent(ind),
            b = b
        )));
    }

    if tag == "typingindicator" {
        let raw_label = binding_value(el, "label").unwrap_or_default();
        let stripped = raw_label.trim();
        let text_content = if stripped.is_empty() {
            swift_string_literal("Typing…")
        } else if stripped.starts_with('{') && stripped.ends_with('}') {
            let inner = stripped[1..stripped.len() - 1].trim();
            format!("String(describing: {})", translate_expr(inner, cx)?)
        } else {
            swift_string_literal(stripped.trim_matches(|c| c == '"' || c == '\''))
        };
        return Ok(Some(format!(
            r#"{}HStack(spacing: 6) {{
                ProgressView()
                    .scaleEffect(0.65)
                Text({})
                    .font(.caption)
                    .foregroundStyle(Color.secondary)
            }}"#,
            indent(ind),
            text_content
        )));
    }

    Ok(None)
}

fn emit_container_element(
    tag: &str,
    el: &Element,
    cx: &GenCtx,
    ind: usize,
) -> anyhow::Result<Option<String>> {
    if tag == "navigationsplitview" {
        let mut sidebar_nodes: Vec<Node> = Vec::new();
        let mut detail_nodes: Vec<Node> = Vec::new();
        for child in &el.children {
            if let Node::Element(inner) = child {
                let child_tag = inner.tag.to_ascii_lowercase();
                if child_tag == "sidebar" {
                    sidebar_nodes.push(child.clone());
                    continue;
                }
                if child_tag == "detail" || child_tag == "content" {
                    detail_nodes.extend(inner.children.clone());
                    continue;
                }
            }
            detail_nodes.push(child.clone());
        }
        if sidebar_nodes.is_empty() {
            sidebar_nodes.push(Node::Text(vec![TextPart::Literal("Sidebar".to_string())]));
        }
        if detail_nodes.is_empty() {
            detail_nodes.push(Node::Text(vec![TextPart::Literal("Detail".to_string())]));
        }
        let sidebar = emit_nodes_block(&sidebar_nodes, cx, ind + 4)?;
        let detail = emit_nodes_block(&detail_nodes, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}NavigationSplitView {{\n{}\n{}}} detail: {{\n{}\n{}}}{}",
            indent(ind),
            sidebar,
            indent(ind),
            detail,
            indent(ind),
            mods
        )));
    }

    if tag == "navigationstack" {
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}NavigationStack {{\n{}\n{}}}{}",
            indent(ind),
            children,
            indent(ind),
            mods
        )));
    }

    if tag == "list" {
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        let list_extras = swiftgen_style::list_extras(&el.classes);
        return Ok(Some(format!(
            "{}List {{\n{}\n{}}}{}{}",
            indent(ind),
            children,
            indent(ind),
            list_extras,
            mods
        )));
    }

    if tag == "sidebar" {
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}List {{\n{}\n{}}}\n{}{}.listStyle(.sidebar)",
            indent(ind),
            children,
            indent(ind),
            indent(ind),
            mods
        )));
    }

    if tag == "item" {
        let click = el
            .event_handlers
            .iter()
            .find(|h| h.event == "click")
            .map(|h| h.handler.trim_matches('"').to_string())
            .unwrap_or_default();
        let row_body = emit_nodes_block(&el.children, cx, ind + 8)?;
        let layout = swiftgen_style::container_modifiers(&el.classes, false);
        if click.is_empty() {
            return Ok(Some(format!(
                "{}HStack(alignment: .top, spacing: 8) {{\n{}\n{}}}{}",
                indent(ind),
                row_body,
                indent(ind),
                layout
            )));
        }
        let event_expr = event_handler_expr(&click, cx)?;
        return Ok(Some(format!(
            "{}Button {{\n{}eventSink({})\n{}}} label: {{\n{}\n{}}}\n{}.buttonStyle(.plain){}",
            indent(ind),
            indent(ind + 4),
            event_expr,
            indent(ind),
            row_body,
            indent(ind),
            indent(ind),
            layout
        )));
    }

    if tag == "form" {
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}Form {{\n{}\n{}}}{}",
            indent(ind),
            children,
            indent(ind),
            mods
        )));
    }

    if tag == "group" {
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}Group {{\n{}\n{}}}{}",
            indent(ind),
            children,
            indent(ind),
            mods
        )));
    }

    if tag == "section" {
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let header = el
            .bindings
            .iter()
            .find(|b| b.prop == "header")
            .map(|b| {
                b.value
                    .trim()
                    .trim_matches(|c| c == '"' || c == '\'')
                    .to_string()
            })
            .filter(|s| !s.is_empty());
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        if let Some(header_text) = header {
            return Ok(Some(format!(
                "{}Section {{\n{}\n{}}} header: {{\n{}Text(\"{}\")\n{}}}{}",
                indent(ind),
                children,
                indent(ind),
                indent(ind + 4),
                escape_swift_string(&header_text),
                indent(ind),
                mods
            )));
        }
        return Ok(Some(format!(
            "{}Section {{\n{}\n{}}}{}",
            indent(ind),
            children,
            indent(ind),
            mods
        )));
    }

    if tag == "vstack" || tag == "hstack" || tag == "zstack" {
        let spacing = gap_points(&el.classes)
            .map(|n| n.to_string())
            .unwrap_or_else(|| "0".to_string());
        let children = emit_nodes_block(&el.children, cx, ind + 4)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        let ctor = match tag {
            "vstack" => format!("VStack(alignment: .leading, spacing: {spacing})"),
            "hstack" => format!("HStack(alignment: .top, spacing: {spacing})"),
            _ => "ZStack(alignment: .topLeading)".to_string(),
        };
        return Ok(Some(format!(
            "{}{} {{\n{}\n{}}}{}",
            indent(ind),
            ctor,
            children,
            indent(ind),
            mods
        )));
    }

    Ok(None)
}

fn emit_control_element(
    tag: &str,
    el: &Element,
    cx: &GenCtx,
    ind: usize,
) -> anyhow::Result<Option<String>> {
    if tag == "menu" {
        let title = binding_value(el, "title").unwrap_or_else(|| "Menu".to_string());
        let children = emit_nodes_block(&el.children, cx, ind + 8)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}Menu {{\n{}\n{}}} label: {{\n{}Text(\"{}\")\n{}}}{}",
            indent(ind),
            children,
            indent(ind),
            indent(ind + 4),
            escape_swift_string(&title),
            indent(ind),
            mods
        )));
    }

    if tag == "toggle" {
        let bind = sanitize_field_key(
            &el.bindings
                .iter()
                .find(|b| b.prop == "bind")
                .map(|b| b.value.clone())
                .unwrap_or_default(),
        );
        let label = binding_value(el, "label")
            .or_else(|| {
                collect_inline_text(&el.children, cx)
                    .ok()
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
            })
            .unwrap_or_else(|| "Toggle".to_string());
        let field_get = field_accessor(&bind, cx)?;
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}Toggle(\"{}\", isOn: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }})){}",
            indent(ind),
            escape_swift_string(&label),
            field_get,
            bind,
            mods
        )));
    }

    if tag == "button" {
        let click = el
            .event_handlers
            .iter()
            .find(|h| h.event == "click")
            .map(|h| h.handler.trim_matches('"').to_string())
            .unwrap_or_default();
        let event_expr = event_handler_expr(&click, cx)?;
        let label_body = emit_nodes_block(&el.children, cx, ind + 8)?;
        let base = format!(
            "{}Button {{\n{}eventSink({})\n{}}} label: {{\n{}\n{}}}",
            indent(ind),
            indent(ind + 4),
            event_expr,
            indent(ind),
            label_body,
            indent(ind)
        );
        let extras = swiftgen_style::button_extras(&el.classes);
        let layout = swiftgen_style::container_modifiers(&el.classes, false);
        let disabled = binding_expression_optional(el, "disabled", cx)?
            .map(|e| format!("\n            .disabled({e})"))
            .unwrap_or_default();
        return Ok(Some(format!("{base}{extras}{layout}{disabled}")));
    }

    if tag == "input" || tag == "textfield" {
        let bind = sanitize_field_key(
            &el.bindings
                .iter()
                .find(|b| b.prop == "bind")
                .map(|b| b.value.clone())
                .unwrap_or_default(),
        );
        let placeholder = el
            .bindings
            .iter()
            .find(|b| b.prop == "placeholder")
            .map(|b| {
                b.value
                    .trim()
                    .trim_matches(|c| c == '"' || c == '\'')
                    .to_string()
            })
            .unwrap_or_default();
        let multiline = el.classes.iter().any(|c| c == "multiline");
        let field_get = field_accessor(&bind, cx)?;
        let ph = format!("\"{}\"", escape_swift_string(&placeholder));
        if multiline {
            return Ok(Some(format!(
                "{}TextEditor(text: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }}))",
                indent(ind), field_get, bind
            )));
        }
        let base = format!(
            "{}TextField({}, text: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }}))",
            indent(ind), ph, field_get, bind
        );
        let extras = swiftgen_style::text_field_extras(&el.classes);
        return Ok(Some(format!("{base}{extras}")));
    }

    if tag == "securefield" {
        let bind = sanitize_field_key(
            &el.bindings
                .iter()
                .find(|b| b.prop == "bind")
                .map(|b| b.value.clone())
                .unwrap_or_default(),
        );
        let placeholder = el
            .bindings
            .iter()
            .find(|b| b.prop == "placeholder")
            .map(|b| {
                b.value
                    .trim()
                    .trim_matches(|c| c == '"' || c == '\'')
                    .to_string()
            })
            .unwrap_or_default();
        let field_get = field_accessor(&bind, cx)?;
        let ph = format!("\"{}\"", escape_swift_string(&placeholder));
        return Ok(Some(format!(
            "{}SecureField({}, text: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }}))\n{}.textFieldStyle(.roundedBorder)",
            indent(ind),
            ph,
            field_get,
            bind,
            indent(ind)
        )));
    }

    if tag == "picker" {
        let bind = sanitize_field_key(
            &el.bindings
                .iter()
                .find(|b| b.prop == "bind")
                .map(|b| b.value.clone())
                .unwrap_or_default(),
        );
        let sel = field_accessor(&bind, cx)?;
        let opts = collect_picker_options(el, cx)?;
        let mut body = String::new();
        for o in &opts {
            body.push_str(&format!(
                "{}Text(\"{}\").tag(\"{}\")\n",
                indent(ind + 4),
                escape_swift_string(&o.1),
                escape_swift_string(&o.0),
            ));
        }
        return Ok(Some(format!(
            "{}Picker(\"\", selection: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }})) {{\n{}}}\n{}.pickerStyle(.segmented)\n            .padding(.bottom, 4)",
            indent(ind),
            sel,
            bind,
            body,
            indent(ind)
        )));
    }

    Ok(None)
}

fn emit_content_element(
    tag: &str,
    el: &Element,
    cx: &GenCtx,
    ind: usize,
) -> anyhow::Result<Option<String>> {
    if tag == "label" {
        let title = binding_value(el, "title")
            .or_else(|| {
                collect_inline_text(&el.children, cx)
                    .ok()
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
            })
            .unwrap_or_default();
        let symbol = binding_value(el, "symbol")
            .or_else(|| binding_value(el, "icon"))
            .unwrap_or_else(|| "circle".to_string());
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        return Ok(Some(format!(
            "{}Label(\"{}\", systemImage: \"{}\"){}",
            indent(ind),
            escape_swift_string(&title),
            escape_swift_string(&symbol),
            mods
        )));
    }

    if tag == "image" {
        let system = binding_value(el, "system")
            .or_else(|| binding_value(el, "symbol"))
            .or_else(|| {
                binding_value(el, "name").and_then(|v| {
                    if v.starts_with("sf-") {
                        Some(v.trim_start_matches("sf-").replace('-', "."))
                    } else {
                        None
                    }
                })
            });
        let mods = swiftgen_style::container_modifiers(&el.classes, false);
        if let Some(sym) = system {
            return Ok(Some(format!(
                "{}Image(systemName: \"{}\"){}",
                indent(ind),
                escape_swift_string(&sym),
                mods
            )));
        }
        let asset = binding_value(el, "name").unwrap_or_else(|| "photo".to_string());
        return Ok(Some(format!(
            "{}Image(\"{}\"){}",
            indent(ind),
            escape_swift_string(&asset),
            mods
        )));
    }

    if tag == "span" || tag == "p" {
        let parts = first_text_parts(&el.children).ok_or_else(|| {
            anyhow!(
                "<{}> must contain text (swiftgen span/p limitation)",
                el.tag
            )
        })?;
        let inner = emit_text(parts, cx, 0)?;
        let trimmed = inner.trim_start();
        let mods = swiftgen_style::text_modifiers(&el.classes);
        return Ok(Some(format!("{}{}{}", indent(ind), trimmed, mods)));
    }

    if tag == "spacer" {
        return Ok(Some(format!("{}Spacer()", indent(ind))));
    }

    if tag == "divider" {
        return Ok(Some(format!("{}Divider()", indent(ind))));
    }

    Ok(None)
}
fn emit_element(el: &Element, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let tag = el.tag.to_ascii_lowercase();

    // Semantic component layer for native SwiftUI tags. These coexist with utility-first
    // layout (`div flex`, `div flex-col`, gaps, padding, etc.), so templates can mix
    // declarative SwiftUI structure and Tailwind-style class composition in one engine.
    //
    // Supported semantic tags include:
    // - Containers: navigationstack, navigationsplitview, sidebar, detail/content,
    //   form, section, group, list, item, vstack/hstack/zstack, scrollview/scroll
    // - Controls: button, toggle, picker, textfield/input, securefield, menu
    // - Content: span/p, label, image, sf/sf-*, divider, spacer
    // - Control flow is still handled by the AST nodes (if/for/match), unchanged.

    if let Some(res) = emit_custom_element(&tag, el, cx, ind)? {
        return Ok(res);
    }
    if let Some(res) = emit_container_element(&tag, el, cx, ind)? {
        return Ok(res);
    }
    if let Some(res) = emit_control_element(&tag, el, cx, ind)? {
        return Ok(res);
    }
    if let Some(res) = emit_content_element(&tag, el, cx, ind)? {
        return Ok(res);
    }
    if tag == "sf" || tag.starts_with("sf-") {
        return emit_sf_symbol(el, cx, ind);
    }
    let axis = if el.classes.iter().any(|c| c == "flex-col") {
        "VStack"
    } else {
        "HStack"
    };
    let spacing = gap_points(&el.classes);
    let scroll = tag == "scroll" || tag == "scrollview" || el.classes.iter().any(|c| c == "scroll");
    let inner_ind = if scroll { ind + 8 } else { ind + 4 };
    let close_ind = if scroll { ind + 4 } else { ind };
    let children = emit_nodes_block(&el.children, cx, inner_ind)?;

    let stack_inner = match spacing {
        Some(s) if axis == "VStack" => format!(
            "{}(alignment: .leading, spacing: {}) {{\n{}\n{}}}",
            axis,
            s,
            children,
            indent(close_ind)
        ),
        Some(s) => format!(
            "{}(alignment: .top, spacing: {}) {{\n{}\n{}}}",
            axis,
            s,
            children,
            indent(close_ind)
        ),
        None if axis == "VStack" => format!(
            "{}(alignment: .leading, spacing: 0) {{\n{}\n{}}}",
            axis,
            children,
            indent(close_ind)
        ),
        None => format!(
            "{}(alignment: .top, spacing: 0) {{\n{}\n{}}}",
            axis,
            children,
            indent(close_ind)
        ),
    };

    let mods = swiftgen_style::container_modifiers(&el.classes, scroll);

    if scroll {
        Ok(format!(
            "{}ScrollView {{\n{}\n{}}}{}",
            indent(ind),
            stack_inner,
            indent(ind),
            mods
        ))
    } else {
        Ok(format!("{}{}{}", indent(ind), stack_inner, mods))
    }
}
fn emit_sf_symbol(el: &Element, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
    let from_binding = el
        .bindings
        .iter()
        .find(|b| b.prop == "name" || b.prop == "symbol")
        .map(|b| {
            b.value
                .trim()
                .trim_matches(|c| c == '"' || c == '\'')
                .to_string()
        })
        .filter(|s| !s.is_empty());
    let from_tag = el
        .tag
        .strip_prefix("sf-")
        .map(|rest| rest.replace('-', "."));
    let from_text = collect_inline_text(&el.children, cx)?
        .trim()
        .trim_matches(|c| c == '"' || c == '\'')
        .to_string();

    let symbol = from_binding
        .or(from_tag)
        .or({
            if from_text.is_empty() {
                None
            } else {
                Some(from_text)
            }
        })
        .unwrap_or_else(|| "questionmark.circle".to_string());

    let mods = swiftgen_style::container_modifiers(&el.classes, false);
    Ok(format!(
        "{}Image(systemName: \"{}\"){}",
        indent(ind),
        escape_swift_string(&symbol),
        mods
    ))
}

fn event_handler_expr(handler: &str, cx: &GenCtx) -> anyhow::Result<String> {
    if handler.is_empty() {
        return Ok("\"\"".to_string());
    }

    let mut out = String::new();
    let mut rest = handler;
    while let Some(start) = rest.find('{') {
        let (literal, after_start) = rest.split_at(start);
        out.push_str(&escape_for_swift_interpolation_literal(literal));
        let after_brace = &after_start[1..];
        let Some(end) = after_brace.find('}') else {
            out.push_str(&escape_for_swift_interpolation_literal(after_start));
            rest = "";
            break;
        };
        let expr = &after_brace[..end];
        out.push_str("\\(");
        out.push_str(&translate_expr(expr, cx)?);
        out.push(')');
        rest = &after_brace[end + 1..];
    }
    out.push_str(&escape_for_swift_interpolation_literal(rest));
    Ok(format!("\"{}\"", out))
}

struct PickerOpt(String, String);

fn collect_picker_options(el: &Element, cx: &GenCtx) -> anyhow::Result<Vec<PickerOpt>> {
    let mut out = Vec::new();
    for ch in &el.children {
        if let Node::Element(inner) = ch {
            if inner.tag == "span" || inner.tag == "button" {
                let label = collect_inline_text(&inner.children, cx)?;
                let value = inner
                    .bindings
                    .iter()
                    .find(|b| b.prop == "value")
                    .map(|b| {
                        b.value
                            .trim()
                            .trim_matches(|c| c == '"' || c == '\'')
                            .to_string()
                    })
                    .filter(|s| !s.is_empty())
                    .unwrap_or_else(|| slug_label(&label));
                out.push(PickerOpt(value, label));
            }
        }
    }
    Ok(out)
}

fn slug_label(label: &str) -> String {
    label
        .to_lowercase()
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
        .collect::<String>()
        .trim_matches('_')
        .to_string()
}

fn field_accessor(bind: &str, cx: &GenCtx) -> anyhow::Result<String> {
    let bind = bind.trim();
    if bind.contains('.') || bind.contains(' ') {
        return translate_expr(bind, cx);
    }
    if let Some(ex) = cx.lets.get(bind) {
        return Ok(format!("({})", ex));
    }
    Ok(cx.resolve_ident(bind))
}

fn collect_inline_text(children: &[Node], cx: &GenCtx) -> anyhow::Result<String> {
    for c in children {
        match c {
            Node::Text(parts) => {
                let mut s = String::new();
                for p in parts {
                    match p {
                        TextPart::Literal(t) => s.push_str(t),
                        TextPart::Expr(e) => s.push_str(&translate_expr(e, cx)?),
                    }
                }
                if !s.is_empty() {
                    return Ok(s);
                }
            }
            Node::Element(inner) => {
                let s = collect_inline_text(&inner.children, cx)?;
                if !s.is_empty() {
                    return Ok(s);
                }
            }
            _ => {}
        }
    }
    Ok(String::new())
}

fn gap_points(classes: &[String]) -> Option<u32> {
    for c in classes {
        if let Some(r) = c.strip_prefix("gap-") {
            if let Ok(n) = r.parse::<u32>() {
                return Some(n * 4);
            }
        }
    }
    None
}

/// Map template expressions (identifiers, simple chains, `&&`, comparisons) to Swift.
fn translate_expr(expr: &str, cx: &GenCtx) -> anyhow::Result<String> {
    let e = expr.trim();
    if e.is_empty() {
        return Ok("false".to_string());
    }
    rewrite_expr_tokens(e, cx)
}

fn rewrite_expr_tokens(expr: &str, cx: &GenCtx) -> anyhow::Result<String> {
    let chars: Vec<char> = expr.chars().collect();
    let mut i = 0;
    let mut out = String::new();
    while i < chars.len() {
        // string literal
        if chars[i] == '"' || chars[i] == '\'' {
            let quote = chars[i];
            out.push(quote);
            i += 1;
            while i < chars.len() && chars[i] != quote {
                out.push(chars[i]);
                i += 1;
            }
            if i < chars.len() {
                out.push(chars[i]);
                i += 1;
            }
            continue;
        }
        // identifiers and chains
        if chars[i].is_ascii_alphabetic() || chars[i] == '_' {
            let start = i;
            while i < chars.len()
                && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '.')
            {
                i += 1;
            }
            let word = chars[start..i].iter().collect::<String>();
            if word == "true" || word == "false" || word == "null" {
                out.push_str(&word);
                continue;
            }
            if word.contains('.') {
                let parts: Vec<&str> = word.split('.').collect();
                let head = parts[0];
                let rest: Vec<&str> = parts[1..].to_vec();
                let root = resolve_chain_head(head, cx)?;
                let tail = rest.join(".");
                if tail.is_empty() {
                    out.push_str(&root);
                } else {
                    out.push_str(&format!("{}.{}", root, tail));
                }
                continue;
            }
            out.push_str(&resolve_chain_head(&word, cx)?);
            continue;
        }
        // numbers
        if chars[i].is_ascii_digit()
            || (chars[i] == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit())
        {
            let start = i;
            if chars[i] == '-' {
                i += 1;
            }
            while i < chars.len() && chars[i].is_ascii_digit() {
                i += 1;
            }
            if i < chars.len() && chars[i] == '.' {
                i += 1;
                while i < chars.len() && chars[i].is_ascii_digit() {
                    i += 1;
                }
            }
            let w = chars[start..i].iter().collect::<String>();
            out.push_str(&w);
            continue;
        }
        out.push(chars[i]);
        i += 1;
    }
    Ok(out)
}

fn resolve_chain_head(head: &str, cx: &GenCtx) -> anyhow::Result<String> {
    if let Some(l) = cx.lets.get(head) {
        return Ok(format!("({})", l));
    }
    if cx.loop_vars.iter().any(|v| v == head) {
        return Ok(head.to_string());
    }
    Ok(cx.resolve_ident(head))
}

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

    #[test]
    fn swiftgen_stack_and_text() {
        let tpl = "div flex flex-col gap-2 p-2\n  span \"Hello\"";
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("VStack"));
        assert!(s.contains("Hello"));
    }

    #[test]
    fn swiftgen_button_click() {
        let tpl = "button @click=\"go\"\n  \"OK\"";
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("eventSink(\"go\")"));
    }

    #[test]
    fn swiftgen_dynamic_button_event_and_list() {
        // `for` bodies must nest under a container with sufficient indent (matches HyperChat layout).
        let tpl = "navigationsplitview\n  sidebar\n    section header=\"T\"\n      for conv in {conversations}\n        button button-plain @click=\"select:{conv.id}\"\n          span \"{conv.title}\"\n  detail\n    span \"x\"";
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("ForEach"));
        assert!(s.contains("eventSink(\"select:\\(conv.id)\")"));
        assert!(s.contains(".buttonStyle(.plain)"));
    }

    #[test]
    fn swiftgen_sidebar_item_sf() {
        let tpl = "for row in rows\n  sidebar\n    item @click=\"select:{row.id}\"\n      sf-plus\n      span \"Row\"";
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains(".listStyle(.sidebar)"));
        assert!(s.contains("eventSink(\"select:\\(row.id)\")"));
        assert!(s.contains("Image(systemName: \"plus\")"));
    }

    #[test]
    fn swiftgen_form_section_and_divider() {
        let tpl = "form\n  section header=\"General\"\n    span \"A\"\n  divider\n  section\n    textfield bind=name placeholder=\"Name\"";
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("Form {"));
        assert!(s.contains("Section {"));
        assert!(s.contains("header: {"));
        assert!(s.contains("Divider()"));
        assert!(s.contains("TextField"));
    }

    #[test]
    fn swiftgen_menubar_emits_commands_struct() {
        let tpl = r#"menubar
  menu title="App"
    menuitem @click="openSettings" shortcut="cmd+,"
      "Settings"
    menuseparator
    menuitem @click="refresh"
      "Refresh"
navigationsplitview
  sidebar
    span "Hi"
  detail
    span "There"
"#;
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "MainView", "MainCtx").unwrap();
        assert!(s.contains("struct MainViewCommands: Commands"));
        assert!(s.contains("CommandMenu(\"App\")"));
        assert!(s.contains("keyboardShortcut(\",\", modifiers: .command)"));
        assert!(s.contains("NavigationSplitView"));
    }

    #[test]
    fn swiftgen_navigation_and_misc_components() {
        let tpl = r#"navigationstack
  navigationsplitview
    sidebar
      list
        item @click="select"
          label title="Inbox" symbol="tray"
    detail
      vstack
        menu title="More"
          button @click="x"
            "Action"
        toggle bind=isOn label="Enabled"
        image system="paperplane"
"#;
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("NavigationStack"));
        assert!(s.contains("NavigationSplitView"));
        assert!(s.contains("Menu {"));
        assert!(s.contains("Toggle("));
        assert!(s.contains("Label(\"Inbox\", systemImage: \"tray\")"));
        assert!(s.contains("Image(systemName: \"paperplane\")"));
    }

    #[test]
    fn swiftgen_if_for() {
        let tpl = "if show\n  span \"y\"\nfor x in items\n  span \"z\"";
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("if context.show"));
        assert!(s.contains("ForEach"));
    }

    #[test]
    fn swiftgen_input_picker() {
        let tpl = r#"input bind=draft placeholder="Hi"
picker bind=mode
  span "A"
"#;
        let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
        let s = emit_view(&nodes, "TView", "TContext").unwrap();
        assert!(s.contains("TextField"));
        assert!(s.contains("bind:draft"));
        assert!(s.contains("Picker"));
    }
}
