use std::collections::HashSet;
use std::io::{IsTerminal, Read, Write};

use clap::Args;
use crossterm::{
    cursor::Show,
    event::{
        read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
        MouseButton, MouseEventKind,
    },
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use hefesto_widgets::{TreeNode, TreeState, TreePopup};
use ratatui::{
    backend::CrosstermBackend,
    layout::Rect,
    widgets::StatefulWidget,
    Terminal,
};

use crate::{keybinds, style, popup_config::{PopupConfig, PopupConfigurable}};

const MENU_GUIDE: &str = include_str!("../guides/MENU_GUIDE.md");

#[derive(Args)]
pub struct MenuArgs {
    /// Path to JSON file (reads from stdin if not provided)
    #[arg()]
    pub file: Option<String>,

    /// Input format: auto (detect from extension), json, plano
    #[arg(short = 'f', long, default_value = "auto")]
    pub format: String,

    /// Title displayed in the menu header
    #[arg(short, long, default_value = "Menú")]
    pub title: String,

    /// Popup width (0 = auto)
    #[arg(short = 'W', long, default_value = "0")]
    pub width: u16,

    /// Popup height (0 = auto)
    #[arg(short = 'H', long, default_value = "0")]
    pub height: u16,

    /// Show detailed guide for this command
    #[arg(long)]
    pub guide: bool,
}

// ── JSON deserialization ─────────────────────────────────

#[derive(serde::Deserialize)]
struct TreeNodeInput {
    id: usize,
    text: String,
    #[serde(default)]
    children: Vec<TreeNodeInput>,
}

impl From<TreeNodeInput> for TreeNode<'static> {
    fn from(input: TreeNodeInput) -> Self {
        TreeNode {
            id: input.id,
            text: ratatui::text::Line::from(input.text.trim().to_string()),
            children: input.children.into_iter().map(Into::into).collect(),
        }
    }
}

/// Check for duplicate ids across the tree. Returns the first duplicate found.
fn find_duplicate_id(nodes: &[TreeNodeInput]) -> Option<usize> {
    let mut seen = HashSet::new();
    fn walk(nodes: &[TreeNodeInput], seen: &mut HashSet<usize>) -> Option<usize> {
        for node in nodes {
            if !seen.insert(node.id) {
                return Some(node.id);
            }
            if let Some(dup) = walk(&node.children, seen) {
                return Some(dup);
            }
        }
        None
    }
    walk(nodes, &mut seen)
}

// ── Plano parser ───────────────────────────────────────────

/// Parse a flat list of `/`-separated paths into a tree.
/// Each line is a path from root to leaf, e.g. `Archivo/Nuevo/Documento`.
fn parse_plano(input: &str) -> Vec<TreeNodeInput> {
    let mut roots: Vec<TreeNodeInput> = Vec::new();
    let mut next_id: usize = 1;

    for line in input.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let parts: Vec<&str> = line.split('/').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
        if parts.is_empty() {
            continue;
        }
        insert_path(&mut roots, &parts, &mut next_id);
    }

    roots
}

/// Insert a path (split by `/`) into the tree, creating nodes as needed.
fn insert_path(nodes: &mut Vec<TreeNodeInput>, parts: &[&str], next_id: &mut usize) {
    let name = parts[0];
    let idx = if let Some(pos) = nodes.iter().position(|n| n.text == name) {
        pos
    } else {
        let id = *next_id;
        *next_id += 1;
        nodes.push(TreeNodeInput {
            id,
            text: name.to_string(),
            children: Vec::new(),
        });
        nodes.len() - 1
    };

    if parts.len() > 1 {
        insert_path(&mut nodes[idx].children, &parts[1..], next_id);
    }
}

/// Detect format from file extension when `--format auto`.
fn detect_format(file: &Option<String>) -> &str {
    match file {
        Some(path) if path.ends_with(".json") => "json",
        _ => "plano",
    }
}

// ── Tree helpers ─────────────────────────────────────────

/// Find the node id at the given visible flat index (0-based).
fn node_id_at_visible(
    nodes: &[TreeNode],
    expanded: &HashSet<usize>,
    cursor: usize,
) -> Option<usize> {
    fn walk(
        nodes: &[TreeNode],
        expanded: &HashSet<usize>,
        cursor: usize,
        count: &mut usize,
    ) -> Option<usize> {
        for node in nodes {
            if *count == cursor {
                return Some(node.id);
            }
            *count += 1;
            if !node.children.is_empty() && expanded.contains(&node.id) {
                if let Some(found) = walk(&node.children, expanded, cursor, count) {
                    return Some(found);
                }
            }
        }
        None
    }
    walk(nodes, expanded, cursor, &mut 0)
}

/// Total number of visible items given the current expand state.
fn total_visible(nodes: &[TreeNode], expanded: &HashSet<usize>) -> usize {
    fn walk(nodes: &[TreeNode], expanded: &HashSet<usize>, count: &mut usize) {
        for node in nodes {
            *count += 1;
            if !node.children.is_empty() && expanded.contains(&node.id) {
                walk(&node.children, expanded, count);
            }
        }
    }
    let mut count = 0;
    walk(nodes, expanded, &mut count);
    count
}

/// Whether the node with the given id has children.
fn has_children(nodes: &[TreeNode], id: usize) -> bool {
    fn walk(nodes: &[TreeNode], id: usize) -> Option<bool> {
        for node in nodes {
            if node.id == id {
                return Some(!node.children.is_empty());
            }
            if let Some(found) = walk(&node.children, id) {
                return Some(found);
            }
        }
        None
    }
    walk(nodes, id).unwrap_or(false)
}


fn node_path(nodes: &[TreeNode], id: usize) -> Option<String> {
    let mut segments = Vec::new();
    fn walk<'a>(nodes: &'a [TreeNode<'a>], id: usize, segments: &mut Vec<String>) -> bool {
        for node in nodes {
            segments.push(node.text.to_string());
            if node.id == id {
                return true;
            }
            if !node.children.is_empty() && walk(&node.children, id, segments) {
                return true;
            }
            segments.pop();
        }
        false
    }
    if walk(nodes, id, &mut segments) {
        Some(segments.join("/"))
    } else {
        None
    }
}

// ── Popup helpers ────────────────────────────────────────

fn contains(rect: Rect, col: u16, row: u16) -> bool {
    col >= rect.x
        && col < rect.x.saturating_add(rect.width)
        && row >= rect.y
        && row < rect.y.saturating_add(rect.height)
}

fn is_drag_area(popup: Rect, col: u16, row: u16) -> bool {
    let inner = Rect {
        x: popup.x + 1,
        y: popup.y + 1,
        width: popup.width.saturating_sub(2),
        height: popup.height.saturating_sub(2),
    };
    col >= popup.x
        && col < popup.x.saturating_add(popup.width)
        && row >= popup.y
        && row < popup.y.saturating_add(popup.height)
        && !contains(inner, col, row)
}

// ── Run ──────────────────────────────────────────────────
/// RAII guard: restores the terminal (raw mode, alternate screen, cursor)
/// on any exit path — normal return, early `break`, or explicit `cleanup()`.
struct TerminalGuard {
    cleaned: bool,
}

impl TerminalGuard {
    fn new() -> Self {
        Self { cleaned: false }
    }

    fn cleanup(&mut self) {
        if self.cleaned {
            return;
        }
        self.cleaned = true;

        // Re-open the terminal writer to match the setup logic.
        let mut writer: Box<dyn Write> =
            match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
                Ok(f) => Box::new(f),
                Err(_) => Box::new(std::io::stdout()),
            };

        let _ = disable_raw_mode();
        let _ = execute!(writer, LeaveAlternateScreen, DisableMouseCapture, Show);
    }
}
impl Drop for TerminalGuard {
    fn drop(&mut self) {
        self.cleanup();
    }
}

pub fn run(args: MenuArgs) {
    if args.guide {
        println!("{}", MENU_GUIDE);
        return;
    }

    // ── Detect format ────────────────────────────────────

    let format = if args.format == "auto" {
        detect_format(&args.file)
    } else {
        &args.format
    };

    match format {
        "json" | "plano" => {}
        _ => {
            eprintln!("{} menu: formato '{}' no soportado (json, plano)", crate::BIN_NAME, format);
            std::process::exit(1);
        }
    }

    // ── Read input ───────────────────────────────────────

    let input = if let Some(path) = &args.file {
        match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("{} menu: error al leer '{}': {}", crate::BIN_NAME, path, e);
                std::process::exit(1);
            }
        }
    } else {
        // If stdin is a terminal (no pipe), error immediately instead of hanging
        if std::io::stdin().is_terminal() {
            eprintln!("{} menu: se requiere un archivo como argumento o datos por pipe a stdin", crate::BIN_NAME);
            std::process::exit(1);
        }
        let mut buf = String::new();
        if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() {
            eprintln!("{} menu: no se recibieron datos (provee un archivo como argumento o pipe a stdin)", crate::BIN_NAME);
            std::process::exit(1);
        }
        buf
    };

    // ── Parse ─────────────────────────────────────────────

    let input_nodes: Vec<TreeNodeInput> = match format {
        "json" => match serde_json::from_str(&input) {
            Ok(n) => n,
            Err(e) => {
                eprintln!("{} menu: error al parsear JSON: {}", crate::BIN_NAME, e);
                std::process::exit(1);
            }
        },
        "plano" => parse_plano(&input),
        _ => unreachable!(),
    };

    // Validate unique ids
    if let Some(dup) = find_duplicate_id(&input_nodes) {
        eprintln!("{} menu: id duplicado '{}' en el árbol", crate::BIN_NAME, dup);
        std::process::exit(1);
    }

    let nodes: Vec<TreeNode<'static>> = input_nodes.into_iter().map(Into::into).collect();

    if nodes.is_empty() {
        eprintln!("{} menu: árbol vacío", crate::BIN_NAME);
        std::process::exit(1);
    }

    // ── Terminal setup ───────────────────────────────────

    crate::tty::ensure_terminal_stdin();

    let mut tty: Box<dyn Write> =
        match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
            Ok(f) => Box::new(f),
            Err(_) => Box::new(std::io::stdout()),
        };
    if enable_raw_mode().is_err()
        || execute!(tty, EnterAlternateScreen, EnableMouseCapture).is_err()
    {
        eprintln!("{} menu: el terminal no es interactivo", crate::BIN_NAME);
        std::process::exit(1);
    }
    let mut terminal = match Terminal::new(CrosstermBackend::new(tty)) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("{} menu: error al inicializar terminal: {}", crate::BIN_NAME, e);
            std::process::exit(1);
        }
    };
    let mut guard = TerminalGuard::new();
    if let Err(e) = crate::tty::with_terminal_stdout(|| terminal.clear()) {
        eprintln!("{} menu: error al limpiar pantalla: {}", crate::BIN_NAME, e);
        guard.cleanup();
        std::process::exit(1);
    }
    if let Err(e) = crate::tty::with_terminal_stdout(|| terminal.hide_cursor()) {
        eprintln!("{} menu: error al ocultar cursor: {}", crate::BIN_NAME, e);
        guard.cleanup();
        std::process::exit(1);
    }

    // ── Tree state ───────────────────────────────────────

    let mut tree_state = TreeState::default();
    tree_state.scroll_state.follow = false;
    tree_state.scroll_state.select(Some(0));

    let title = args.title;
    let mut result: Option<String> = None;
    let mut pending_g = false;

    // ── Popup config ─────────────────────────────────────

    let mut cfg = PopupConfig::new()
        .border_type(style::BORDER)
        .header();
    if args.width > 0 {
        cfg = cfg.width(args.width);
    }
    if args.height > 0 {
        cfg = cfg.height(args.height);
    }

    // ── Popup state ──────────────────────────────────────

    let mut drag_offset: Option<(u16, u16)> = None;
    let mut origin: Option<(u16, u16)> = None;

    // ── Event loop ───────────────────────────────────────

    while result.is_none() {
        let size = match terminal.size() {
            Ok(s) => s,
            Err(e) => {
                eprintln!("{} menu: error al leer tamaño del terminal: {}", crate::BIN_NAME, e);
                break;
            }
        };
        let area = Rect::new(0, 0, size.width, size.height);

        let mut popup = TreePopup::new(nodes.clone())
            .title(&title)
            .border_color(style::ACCENT)
            .with_config(&cfg);

        if let Some((ox, oy)) = origin {
            popup = popup.origin(ox, oy);
        }

        let pr = popup.resolve_rect(area);
        let inner = Rect {
            x: pr.x + 1,
            y: pr.y + 1,
            width: pr.width.saturating_sub(2),
            height: pr.height.saturating_sub(2),
        };

        if let Err(e) = terminal.draw(|frame| {
            StatefulWidget::render(popup, frame.area(), frame.buffer_mut(), &mut tree_state);
        }) {
            eprintln!("{} menu: error al dibujar: {}", crate::BIN_NAME, e);
            break;
        }

        let event = match read() {
            Ok(e) => e,
            Err(e) => {
                eprintln!("{} menu: error al leer entrada: {}", crate::BIN_NAME, e);
                break;
            }
        };
        match event {
            Event::Key(key) => {
                if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
                    result = Some(String::new());
                    break;
                }

                let total = total_visible(&nodes, &tree_state.expanded);

                match key.code {
                    keybinds::UP | keybinds::UP_ALT | keybinds::BACK_TAB => {
                        tree_state.scroll_state.previous();
                    }
                    keybinds::DOWN | keybinds::DOWN_ALT | keybinds::TAB => {
                        tree_state.scroll_state.next(total);
                    }
                    keybinds::CONFIRM => {
                        let cursor = tree_state.scroll_state.list_state.selected().unwrap_or(0);
                        if let Some(id) = node_id_at_visible(&nodes, &tree_state.expanded, cursor)
                        {
                            if has_children(&nodes, id) {
                                tree_state.toggle(id);
                                let new_total =
                                    total_visible(&nodes, &tree_state.expanded);
                                if new_total > 0 {
                                    let sel = tree_state
                                        .scroll_state
                                        .list_state
                                        .selected()
                                        .unwrap_or(0);
                                    if sel >= new_total {
                                        tree_state.scroll_state.select(Some(new_total - 1));
                                    }
                                }
                            } else {
                                result = node_path(&nodes, id);
                            }
                        }
                    }
                    keybinds::TOGGLE_MULTI | KeyCode::Right => {
                        let cursor = tree_state.scroll_state.list_state.selected().unwrap_or(0);
                        if let Some(id) = node_id_at_visible(&nodes, &tree_state.expanded, cursor)
                        {
                            if has_children(&nodes, id)
                                && !tree_state.expanded.contains(&id)
                            {
                                tree_state.expanded.insert(id);
                            }
                        }
                    }
                    KeyCode::Left => {
                        let cursor = tree_state.scroll_state.list_state.selected().unwrap_or(0);
                        if let Some(id) = node_id_at_visible(&nodes, &tree_state.expanded, cursor)
                        {
                            if tree_state.expanded.contains(&id) {
                                tree_state.expanded.remove(&id);
                                let new_total =
                                    total_visible(&nodes, &tree_state.expanded);
                                if new_total > 0 {
                                    let sel = tree_state
                                        .scroll_state
                                        .list_state
                                        .selected()
                                        .unwrap_or(0);
                                    if sel >= new_total {
                                        tree_state.scroll_state.select(Some(new_total - 1));
                                    }
                                }
                            }
                        }
                    }
                    keybinds::CANCEL => {
                        result = Some(String::new());
                    }
                    keybinds::FIRST => {
                        if pending_g {
                            tree_state.scroll_state.select(Some(0));
                            pending_g = false;
                        } else {
                            pending_g = true;
                        }
                    }
                    keybinds::LAST => {
                        let total = total_visible(&nodes, &tree_state.expanded);
                        if total > 0 {
                            tree_state.scroll_state.select(Some(total - 1));
                        }
                    }
                    _ => pending_g = false,
                }
            }
            Event::Mouse(mouse) => {
                let col = mouse.column;
                let row = mouse.row;

                match mouse.kind {
                    MouseEventKind::Down(MouseButton::Left) => {
                        if is_drag_area(pr, col, row) {
                            let ox = col.saturating_sub(pr.x);
                            let oy = row.saturating_sub(pr.y);
                            drag_offset = Some((ox, oy));
                        } else if contains(inner, col, row) {
                            let item_idx = (row - inner.y) as usize;
                            let total = total_visible(&nodes, &tree_state.expanded);
                            if item_idx < total {
                                tree_state.scroll_state.select(Some(item_idx));
                            }
                        }
                    }
                    MouseEventKind::Drag(MouseButton::Left) => {
                        if let Some((dx, dy)) = drag_offset {
                            let max_w = area.width.saturating_sub(pr.width);
                            let max_h = area.height.saturating_sub(pr.height);
                            let nx = (col as i16 - dx as i16).clamp(0, max_w as i16) as u16;
                            let ny = (row as i16 - dy as i16).clamp(0, max_h as i16) as u16;
                            origin = Some((nx, ny));
                        }
                    }
                    MouseEventKind::Up(MouseButton::Left) => {
                        drag_offset = None;
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    }

    // ── Cleanup ──────────────────────────────────────────
    // Guard restores the terminal on drop (or via explicit cleanup()).

    if result.is_none() {
        eprintln!("{} menu: el terminal dejó de responder", crate::BIN_NAME);
        guard.cleanup();
        std::process::exit(1);
    }

    let output = result.unwrap();
    guard.cleanup();

    if output.is_empty() {
        std::process::exit(1);
    }

    println!("{}", output);
}

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_snapshot;
    use ratatui::{backend::TestBackend, text::Line, Terminal};

    // ── Helpers ───────────────────────────────────────────

    fn sample_nodes() -> Vec<TreeNode<'static>> {
        vec![
            TreeNode {
                id: 1,
                text: Line::from("src"),
                children: vec![
                    TreeNode {
                        id: 2,
                        text: Line::from("main.rs"),
                        children: vec![],
                    },
                    TreeNode {
                        id: 3,
                        text: Line::from("lib.rs"),
                        children: vec![],
                    },
                ],
            },
            TreeNode {
                id: 4,
                text: Line::from("Cargo.toml"),
                children: vec![],
            },
        ]
    }

    fn sample_many() -> Vec<TreeNode<'static>> {
        let mut items = sample_nodes();
        // add 8 more leaf nodes to reach 10 total items
        for i in 5..=12 {
            items.push(TreeNode {
                id: i,
                text: Line::from(format!("item_{i}")),
                children: vec![],
            });
        }
        items
    }

    fn render_popup(name: &str, popup: TreePopup<'_>, state: &mut TreeState) {
        let backend = TestBackend::new(60, 20);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|f| f.render_stateful_widget(popup, f.area(), state))
            .unwrap();
        insta::with_settings!({
            snapshot_path => "menu/snapshots",
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(name, terminal.backend());
        });
    }

    // ── parse_plano ──────────────────────────────────────

    #[test]
    fn parse_plano_simple() {
        let nodes = parse_plano("Archivo\nEditar\nAyuda");
        assert_eq!(nodes.len(), 3);
        assert_eq!(nodes[0].text, "Archivo");
        assert_eq!(nodes[1].text, "Editar");
        assert_eq!(nodes[2].text, "Ayuda");
    }

    #[test]
    fn parse_plano_nested() {
        let nodes = parse_plano("Archivo/Nuevo/Documento\nArchivo/Abrir");
        assert_eq!(nodes.len(), 1);
        assert_eq!(nodes[0].text, "Archivo");
        assert_eq!(nodes[0].children.len(), 2);
        assert_eq!(nodes[0].children[0].text, "Nuevo");
        assert_eq!(nodes[0].children[0].children[0].text, "Documento");
        assert_eq!(nodes[0].children[1].text, "Abrir");
    }

    #[test]
    fn parse_plano_trims_whitespace() {
        let nodes = parse_plano("  Archivo  \n\tEditar\t");
        assert_eq!(nodes.len(), 2);
        assert_eq!(nodes[0].text, "Archivo");
        assert_eq!(nodes[1].text, "Editar");
    }

    #[test]
    fn parse_plano_skips_blank_lines() {
        let nodes = parse_plano("Archivo\n\n   \nEditar");
        assert_eq!(nodes.len(), 2);
        assert_eq!(nodes[0].text, "Archivo");
        assert_eq!(nodes[1].text, "Editar");
    }

    #[test]
    fn parse_plano_ignores_empty_segments() {
        let nodes = parse_plano("Archivo//Nuevo///Documento");
        assert_eq!(nodes.len(), 1);
        assert_eq!(nodes[0].children[0].text, "Nuevo");
        assert_eq!(nodes[0].children[0].children[0].text, "Documento");
    }

    #[test]
    fn parse_plano_auto_increments_ids() {
        let nodes = parse_plano("A/B\nC/D/E");
        assert_eq!(nodes[0].id, 1);
        assert_eq!(nodes[0].children[0].id, 2);
        assert_eq!(nodes[1].id, 3);
        assert_eq!(nodes[1].children[0].id, 4);
        assert_eq!(nodes[1].children[0].children[0].id, 5);
    }

    #[test]
    fn parse_plano_merges_shared_prefixes() {
        let nodes = parse_plano("Archivo/Nuevo\nArchivo/Abrir");
        assert_eq!(nodes.len(), 1);
        assert_eq!(nodes[0].text, "Archivo");
        assert_eq!(nodes[0].children.len(), 2);
    }

    // ── find_duplicate_id ────────────────────────────────

    #[test]
    fn find_duplicate_id_none_when_unique() {
        let nodes = vec![
            TreeNodeInput { id: 1, text: "a".into(), children: vec![] },
            TreeNodeInput { id: 2, text: "b".into(), children: vec![] },
        ];
        assert_eq!(find_duplicate_id(&nodes), None);
    }

    #[test]
    fn find_duplicate_id_at_root() {
        let nodes = vec![
            TreeNodeInput { id: 1, text: "a".into(), children: vec![] },
            TreeNodeInput { id: 1, text: "b".into(), children: vec![] },
        ];
        assert_eq!(find_duplicate_id(&nodes), Some(1));
    }

    #[test]
    fn find_duplicate_id_nested() {
        let nodes = vec![
            TreeNodeInput {
                id: 1,
                text: "a".into(),
                children: vec![
                    TreeNodeInput { id: 2, text: "x".into(), children: vec![] },
                ],
            },
            TreeNodeInput { id: 2, text: "b".into(), children: vec![] },
        ];
        assert_eq!(find_duplicate_id(&nodes), Some(2));
    }

    // ── JSON: REGRESIÓN del bug de whitespace ────────────

    #[test]
    fn json_text_trims_leading_trailing_whitespace() {
        let json = r#"[
            {"id": 1, "text": "  Archivo  ", "children": [
                {"id": 2, "text": "  Nuevo  "}
            ]}
        ]"#;
        let nodes: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
        let tree: Vec<TreeNode<'static>> = nodes.into_iter().map(Into::into).collect();

        assert_eq!(tree[0].text.to_string(), "Archivo");
        assert_eq!(tree[0].children[0].text.to_string(), "Nuevo");
    }

    #[test]
    fn json_text_keeps_internal_spaces() {
        let json = r#"[{"id": 1, "text": "Foo   Bar"}]"#;
        let nodes: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
        let tree: Vec<TreeNode<'static>> = nodes.into_iter().map(Into::into).collect();

        assert_eq!(tree[0].text.to_string(), "Foo   Bar");
    }

    #[test]
    fn json_nested_text_also_trimmed() {
        let json = r#"[
            {"id": 1, "text": "\tRoot\n", "children": [
                {"id": 2, "text": "\n  Leaf\t"}
            ]}
        ]"#;
        let nodes: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
        let tree: Vec<TreeNode<'static>> = nodes.into_iter().map(Into::into).collect();

        assert_eq!(tree[0].text.to_string(), "Root");
        assert_eq!(tree[0].children[0].text.to_string(), "Leaf");
    }

    // ── Tree helpers ─────────────────────────────────────

    #[test]
    fn total_visible_collapsed() {
        let nodes = sample_nodes();
        assert_eq!(total_visible(&nodes, &HashSet::new()), 2);
    }

    #[test]
    fn total_visible_expanded() {
        let nodes = sample_nodes();
        let mut expanded = HashSet::new();
        expanded.insert(1);
        assert_eq!(total_visible(&nodes, &expanded), 4);
    }

    #[test]
    fn node_id_at_visible_root() {
        let nodes = sample_nodes();
        assert_eq!(node_id_at_visible(&nodes, &HashSet::new(), 0), Some(1));
        assert_eq!(node_id_at_visible(&nodes, &HashSet::new(), 1), Some(4));
    }

    #[test]
    fn node_id_at_visible_nested() {
        let nodes = sample_nodes();
        let mut expanded = HashSet::new();
        expanded.insert(1);
        assert_eq!(node_id_at_visible(&nodes, &expanded, 1), Some(2));
        assert_eq!(node_id_at_visible(&nodes, &expanded, 2), Some(3));
    }

    #[test]
    fn node_id_at_visible_out_of_range() {
        let nodes = sample_nodes();
        assert_eq!(node_id_at_visible(&nodes, &HashSet::new(), 99), None);
    }

    #[test]
    fn has_children_true_for_parent() {
        let nodes = sample_nodes();
        assert!(has_children(&nodes, 1));
    }

    #[test]
    fn has_children_false_for_leaf() {
        let nodes = sample_nodes();
        assert!(!has_children(&nodes, 4));
    }

    #[test]
    fn has_children_missing_returns_false() {
        let nodes = sample_nodes();
        assert!(!has_children(&nodes, 999));
    }

    #[test]
    fn node_path_root() {
        let nodes = sample_nodes();
        assert_eq!(node_path(&nodes, 4), Some("Cargo.toml".to_string()));
    }

    #[test]
    fn node_path_nested() {
        let nodes = sample_nodes();
        assert_eq!(node_path(&nodes, 2), Some("src/main.rs".to_string()));
        assert_eq!(node_path(&nodes, 3), Some("src/lib.rs".to_string()));
    }

    #[test]
    fn node_path_missing_returns_none() {
        let nodes = sample_nodes();
        assert_eq!(node_path(&nodes, 999), None);
    }

    // ── Format detection ─────────────────────────────────

    #[test]
    fn detect_format_json_extension() {
        assert_eq!(detect_format(&Some("menu.json".to_string())), "json");
    }

    #[test]
    fn detect_format_other_extension_is_plano() {
        assert_eq!(detect_format(&Some("menu.txt".to_string())), "plano");
    }

    #[test]
    fn detect_format_none_is_plano() {
        assert_eq!(detect_format(&None), "plano");
    }

    // ── Snapshots visuales ───────────────────────────────

    #[test]
    fn snapshot_tree_popup_default() {
        let popup = TreePopup::new(sample_nodes()).header();
        let mut state = TreeState::default();
        render_popup("tree_popup_default", popup, &mut state);
    }

    #[test]
    fn snapshot_tree_popup_with_title() {
        let popup = TreePopup::new(sample_nodes()).title("Project").header();
        let mut state = TreeState::default();
        render_popup("tree_popup_with_title", popup, &mut state);
    }

    #[test]
    fn snapshot_tree_popup_expanded() {
        let popup = TreePopup::new(sample_nodes())
            .title("Project")
            .header()
            .height(hefesto_widgets::PopupSize::Fixed(10));
        let mut state = TreeState {
            expanded: HashSet::from([1]),
            ..TreeState::default()
        };
        state.scroll_state.follow = false;
        state.scroll_state.select(Some(0));
        render_popup("tree_popup_expanded", popup, &mut state);
    }

    #[test]
    fn snapshot_tree_popup_with_whitespace_text() {
        let json = r#"[
            {"id": 1, "text": "  Archivo  ", "children": [
                {"id": 2, "text": "  Nuevo  "}
            ]}
        ]"#;
        let inputs: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
        let nodes: Vec<TreeNode<'static>> = inputs.into_iter().map(Into::into).collect();
        let popup = TreePopup::new(nodes).title("Menú").header();
        let mut state = TreeState {
            expanded: HashSet::from([1]),
            ..TreeState::default()
        };
        state.scroll_state.follow = false;
        state.scroll_state.select(Some(0));
        render_popup("tree_popup_with_whitespace_text", popup, &mut state);
    }

    #[test]
    fn snapshot_tree_popup_auto_height_many_items() {
        // 10 items with default Auto height — scrollbar should appear
        let popup = TreePopup::new(sample_many()).header();
        let mut state = TreeState::default();
        state.scroll_state.follow = false;
        state.scroll_state.select(Some(0));
        render_popup("tree_popup_auto_height_many_items", popup, &mut state);
    }

    // ── Fixture-based test (plano menu) ──────────────

    const FIXTURE_MENU: &str = include_str!("menu/fixtures/ejemplo_menu.txt");

    #[test]
    fn parse_fixture_has_all_items() {
        let nodes = parse_plano(FIXTURE_MENU);
        // 6 root items: Archivo, Editar, Ver, Ayuda
        assert_eq!(nodes.len(), 4);
        assert_eq!(nodes[0].text, "Archivo");
        assert_eq!(nodes[1].text, "Editar");
        assert_eq!(nodes[2].text, "Ver");
        assert_eq!(nodes[3].text, "Ayuda");
    }

    #[test]
    fn parse_fixture_total_nodes() {
        fn count(nodes: &[TreeNodeInput]) -> usize {
            let mut c = nodes.len();
            for n in nodes {
                c += count(&n.children);
            }
            c
        }
        let nodes = parse_plano(FIXTURE_MENU);
        assert_eq!(count(&nodes), 25);
    }

    #[test]
    fn parse_fixture_archivo_children() {
        let nodes = parse_plano(FIXTURE_MENU);
        assert_eq!(nodes[0].children.len(), 5);
        assert_eq!(nodes[0].children[0].text, "Nuevo");
        assert_eq!(nodes[0].children[0].children[0].text, "Documento");
        assert_eq!(nodes[0].children[0].children[1].text, "Hoja de cálculo");
    }

    #[test]
    fn parse_fixture_no_duplicates() {
        let nodes = parse_plano(FIXTURE_MENU);
        assert_eq!(find_duplicate_id(&nodes), None);
    }

    #[test]
    fn snapshot_fixture_menu_collapsed() {
        let input = parse_plano(FIXTURE_MENU);
        let nodes: Vec<TreeNode<'static>> = input.into_iter().map(Into::into).collect();
        let popup = TreePopup::new(nodes).header();
        let mut state = TreeState::default();
        state.scroll_state.follow = false;
        state.scroll_state.select(Some(0));
        render_popup("fixture_menu_collapsed", popup, &mut state);
    }

    #[test]
    fn snapshot_fixture_menu_archivo_expanded() {
        let input = parse_plano(FIXTURE_MENU);
        let nodes: Vec<TreeNode<'static>> = input.into_iter().map(Into::into).collect();
        let popup = TreePopup::new(nodes).header();
        let mut state = TreeState {
            expanded: HashSet::from([1]), // Archivo has id 1
            ..TreeState::default()
        };
        state.scroll_state.follow = false;
        state.scroll_state.select(Some(0));
        render_popup("fixture_menu_archivo_expanded", popup, &mut state);
    }
}
