use crate::dag::{DisplayFilter, SessionDag, TipStrategy};
use crate::session::opencode::{self, OpencodeSession};
use crate::session::record::{parse_content_blocks, ContentBlock, ContentMode, SessionRecord};
use crate::session::{self, SessionSource};
use chrono::{DateTime, Utc};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};

/// A node in the session DAG. Represents every uuid-bearing record.
#[derive(Debug, Clone)]
pub struct DagNode {
    pub uuid: String,
    pub parent_uuid: Option<String>,
    pub timestamp: Option<DateTime<Utc>>,
    pub line_index: usize,
    /// Only populated for user/assistant
    pub role: Option<String>,
    /// First ~120 chars of content, sanitized
    pub content_preview: Option<String>,
    /// True if this is a subagent sidechain message
    pub is_sidechain: bool,
}

/// A flattened row ready for display in the tree view.
#[derive(Debug, Clone)]
pub struct TreeRow {
    pub uuid: String,
    pub role: String,
    pub timestamp: DateTime<Utc>,
    pub content_preview: String,
    pub graph_symbols: String,
    pub is_on_latest_chain: bool,
    pub is_branch_point: bool,
    /// True if this is an auto-compaction (summary) event
    pub is_compaction: bool,
}

/// The full parsed session tree.
pub struct SessionTree {
    /// All uuid-bearing nodes
    nodes: HashMap<String, DagNode>,
    /// parent_uuid -> vec of child uuids (ordered by line_index)
    children: HashMap<String, Vec<String>>,
    /// Set of uuids on the latest chain
    latest_chain: HashSet<String>,
    /// Flattened display rows (only user/assistant messages)
    pub rows: Vec<TreeRow>,
    /// Session metadata
    pub session_id: String,
    pub file_path: String,
    pub source: SessionSource,
    /// Cache for full message content to avoid re-reading file on every navigation keystroke
    content_cache: RefCell<HashMap<String, String>>,
}

impl SessionTree {
    /// Parse a session file into a tree structure.
    ///
    /// Detects Opencode session metadata files (path under
    /// `<opencode>/storage/session/...`) and dispatches to the Opencode loader,
    /// which materializes the multi-file storage layout into a synthetic
    /// linear chain. Everything else is parsed as JSONL.
    pub fn from_file(file_path: &str) -> Result<Self, String> {
        if opencode::is_opencode_session_path(file_path) {
            return Self::from_opencode_file(file_path);
        }

        let file = fs::File::open(file_path)
            .map_err(|e| format!("Failed to open {}: {}", file_path, e))?;
        let reader = BufReader::new(file);

        let mut nodes: HashMap<String, DagNode> = HashMap::new();
        let mut children: HashMap<String, Vec<String>> = HashMap::new();
        let mut roots: Vec<String> = Vec::new();
        let mut session_id = String::new();
        let mut dag_records: Vec<(SessionRecord, usize, Option<DateTime<Utc>>)> = Vec::new();

        for (line_idx, line_result) in reader.lines().enumerate() {
            let line =
                line_result.map_err(|e| format!("Read error at line {}: {}", line_idx, e))?;
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }

            let json: serde_json::Value = match serde_json::from_str(trimmed) {
                Ok(v) => v,
                Err(_) => continue,
            };

            let uuid = match session::extract_uuid(&json) {
                Some(u) => u,
                None => continue,
            };

            let parent_uuid = session::extract_parent_uuid_or_logical(&json);

            let record_type = session::extract_record_type(&json)
                .unwrap_or("")
                .to_string();

            let timestamp = session::extract_timestamp(&json);

            let is_sidechain = session::is_sidechain(&json);

            // Collect parsed record for DAG building (avoids re-reading the file)
            if let Some(record) = SessionRecord::from_value(&json) {
                dag_records.push((record, line_idx, timestamp));
            }

            // Extract role and content preview for displayable types
            let (role, content_preview) = if record_type == "user" || record_type == "assistant" {
                let message = json.get("message");
                let role = message
                    .and_then(|m| m.get("role"))
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string());

                let preview = message.and_then(|m| m.get("content")).map(|c| {
                    let blocks = parse_content_blocks(c);
                    SessionRecord::render_content(&blocks, &ContentMode::Preview { max_chars: 120 })
                });

                (role, preview)
            } else if record_type == "summary" {
                // Auto-compaction event — make it displayable
                let summary_text = json
                    .get("summary")
                    .and_then(|v| v.as_str())
                    .unwrap_or("(auto-compacted)")
                    .to_string();
                (Some("compaction".to_string()), Some(summary_text))
            } else {
                (None, None)
            };

            // Capture session_id from first available record
            if session_id.is_empty() {
                if let Some(sid) = session::extract_session_id(&json) {
                    session_id = sid;
                }
            }

            // Track parent->child relationships
            match &parent_uuid {
                Some(parent) => {
                    children
                        .entry(parent.clone())
                        .or_default()
                        .push(uuid.clone());
                }
                None => {
                    roots.push(uuid.clone());
                }
            }

            nodes.insert(
                uuid.clone(),
                DagNode {
                    uuid: uuid.clone(),
                    parent_uuid,
                    timestamp,
                    line_index: line_idx,
                    role,
                    content_preview,
                    is_sidechain,
                },
            );
        }

        // Build latest chain using unified DAG engine (reuses parsed records, no second file read)
        let latest_chain = {
            let dag = SessionDag::from_records(dag_records.into_iter(), DisplayFilter::Standard);
            if let Some(tip) = dag.tip(TipStrategy::MaxTimestamp) {
                dag.chain_from(tip)
            } else {
                HashSet::new()
            }
        };

        let source = SessionSource::from_path(file_path);

        let mut tree = SessionTree {
            nodes,
            children,
            latest_chain,
            rows: Vec::new(),
            session_id,
            file_path: file_path.to_string(),
            source,
            content_cache: RefCell::new(HashMap::new()),
        };

        tree.flatten_to_rows();
        Ok(tree)
    }

    /// Load an Opencode session into a SessionTree.
    ///
    /// Opencode 1.x stores everything in `<storage>/opencode.db`. The session
    /// is identified by the synthetic path `<db>#<session_id>`. We materialize
    /// the session into a linear chain (Opencode doesn't branch the way
    /// Claude does) and pre-cache the full content per message so
    /// `get_full_content` doesn't need to re-query SQLite.
    fn from_opencode_file(file_path: &str) -> Result<Self, String> {
        let path = std::path::Path::new(file_path);
        let session = OpencodeSession::from_file(path)
            .ok_or_else(|| format!("Failed to load Opencode session at {}", file_path))?;
        let (db_path, _) = opencode::parse_session_path(file_path)
            .ok_or_else(|| format!("Not an Opencode session path: {}", file_path))?;

        let messages = opencode::load_messages(&db_path, &session.id);

        let mut nodes: HashMap<String, DagNode> = HashMap::new();
        let mut children: HashMap<String, Vec<String>> = HashMap::new();
        let mut prev_uuid: Option<String> = None;
        let mut full_content_cache: HashMap<String, String> = HashMap::new();

        for (line_idx, msg) in messages.iter().enumerate() {
            let preview = SessionRecord::render_content(
                &msg.content_blocks,
                &ContentMode::Preview { max_chars: 120 },
            );
            let full = render_full_text(&msg.content_blocks);
            full_content_cache.insert(msg.id.clone(), full);

            let role_str = match msg.role {
                crate::session::record::MessageRole::User => "user",
                crate::session::record::MessageRole::Assistant => "assistant",
            };

            if let Some(parent) = prev_uuid.as_ref() {
                children
                    .entry(parent.clone())
                    .or_default()
                    .push(msg.id.clone());
            }

            nodes.insert(
                msg.id.clone(),
                DagNode {
                    uuid: msg.id.clone(),
                    parent_uuid: prev_uuid.clone(),
                    timestamp: Some(msg.created_at),
                    line_index: line_idx,
                    role: Some(role_str.to_string()),
                    content_preview: Some(preview),
                    is_sidechain: false,
                },
            );

            prev_uuid = Some(msg.id.clone());
        }

        // Opencode's chain is linear by construction, so the "latest chain" is
        // every node in the tree.
        let latest_chain: HashSet<String> = nodes.keys().cloned().collect();

        let mut tree = SessionTree {
            nodes,
            children,
            latest_chain,
            rows: Vec::new(),
            session_id: session.id,
            file_path: file_path.to_string(),
            source: SessionSource::CLI,
            content_cache: RefCell::new(full_content_cache),
        };

        tree.flatten_to_rows();
        Ok(tree)
    }

    /// Number of branch points (nodes with >1 child in display graph)
    pub fn branch_count(&self) -> usize {
        self.children.values().filter(|kids| kids.len() > 1).count()
    }

    /// Get the full content of a message by reading its JSONL line from file.
    /// Caches the result so repeated calls for the same uuid (e.g. on every
    /// render tick during tree navigation) don't re-read the file.
    pub fn get_full_content(&self, uuid: &str) -> Option<String> {
        // Check cache first
        if let Some(cached) = self.content_cache.borrow().get(uuid) {
            return Some(cached.clone());
        }

        let node = self.nodes.get(uuid)?;
        let file = fs::File::open(&self.file_path).ok()?;
        let reader = BufReader::new(file);

        let line = reader.lines().nth(node.line_index)?.ok()?;
        let json: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
        let content_raw = json.get("message")?.get("content")?;
        let blocks = parse_content_blocks(content_raw);
        let content = SessionRecord::render_content(&blocks, &ContentMode::Full);

        // Cache the result (keep cache bounded to avoid unbounded memory growth)
        let mut cache = self.content_cache.borrow_mut();
        if cache.len() >= 64 {
            cache.clear();
        }
        cache.insert(uuid.to_string(), content.clone());

        Some(content)
    }

    /// Build the display graph (only user/assistant) and flatten via DFS.
    ///
    /// Uses an explicit work stack instead of recursion so that very long
    /// linear chains (thousands of messages) don't overflow the OS thread
    /// stack — `SessionTree::from_file` is invoked from a `std::thread::spawn`
    /// background thread whose default stack is ~2 MB.
    fn flatten_to_rows(&mut self) {
        // Step 1: Build display graph — collapse non-displayable nodes
        let (display_children, display_roots) = self.build_display_graph();

        // Step 2: iterative DFS with compact branch-lane tracking
        let mut rows: Vec<TreeRow> = Vec::new();
        let root_count = display_roots.len();

        // Frames hold (uuid, prefix_lanes, connector) — same parameters the
        // recursive version used to receive on the call stack.
        let mut stack: Vec<(String, Vec<bool>, Option<bool>)> =
            Vec::with_capacity(self.nodes.len());

        // Push roots in reverse so the leftmost root is popped (and emitted) first.
        for (i, root) in display_roots.iter().enumerate().rev() {
            let connector = if root_count > 1 {
                Some(i == root_count - 1)
            } else {
                None
            };
            stack.push((root.clone(), Vec::new(), connector));
        }

        while let Some((uuid, prefix_lanes, connector)) = stack.pop() {
            let Some(node) = self.nodes.get(&uuid) else {
                continue;
            };

            let kids = display_children.get(&uuid).cloned().unwrap_or_default();
            let is_branch_point = kids.len() > 1;
            let is_on_latest = self.latest_chain.contains(&uuid);

            let graph = build_graph_symbols(&prefix_lanes, connector);

            let is_compaction = node.role.as_deref() == Some("compaction")
                || is_context_loss_message(&node.content_preview);

            rows.push(TreeRow {
                uuid: uuid.clone(),
                role: node.role.clone().unwrap_or_else(|| "?".to_string()),
                timestamp: node.timestamp.unwrap_or_else(Utc::now),
                content_preview: node.content_preview.clone().unwrap_or_default(),
                graph_symbols: graph,
                is_on_latest_chain: is_on_latest,
                is_branch_point,
                is_compaction,
            });

            // Sort children: latest chain first, then by line_index
            let mut sorted_kids = kids;
            sorted_kids.sort_by(|a, b| {
                let a_latest = self.is_descendant_of_latest(a, &display_children);
                let b_latest = self.is_descendant_of_latest(b, &display_children);
                b_latest.cmp(&a_latest).then_with(|| {
                    let a_idx = self.nodes.get(a).map(|n| n.line_index).unwrap_or(0);
                    let b_idx = self.nodes.get(b).map(|n| n.line_index).unwrap_or(0);
                    a_idx.cmp(&b_idx)
                })
            });

            let mut child_prefix = prefix_lanes;
            if let Some(is_last) = connector {
                child_prefix.push(!is_last);
            }

            let child_count = sorted_kids.len();
            // Push children in REVERSE so the leftmost child is popped first.
            for (i, child) in sorted_kids.into_iter().enumerate().rev() {
                let child_connector = if child_count > 1 {
                    Some(i == child_count - 1)
                } else {
                    None
                };
                stack.push((child, child_prefix.clone(), child_connector));
            }
        }

        self.rows = rows;
    }

    /// Build a display graph that only connects user/assistant nodes,
    /// skipping intermediate progress/system nodes.
    fn build_display_graph(&self) -> (HashMap<String, Vec<String>>, Vec<String>) {
        let mut display_children: HashMap<String, Vec<String>> = HashMap::new();
        let mut display_roots: Vec<String> = Vec::new();

        // For each displayable node, find its displayable parent and register
        // as a child of that parent. If no displayable parent exists, it's a root.
        let mut displayable_parent_cache: HashMap<String, Option<String>> = HashMap::new();

        // Collect all displayable node uuids (as set for O(1) lookup + sorted vec for deterministic iteration)
        let displayable_uuids: HashSet<String> = self
            .nodes
            .values()
            .filter(|n| n.role.is_some() && n.content_preview.is_some() && !n.is_sidechain)
            .map(|n| n.uuid.clone())
            .collect();

        // Sort by line_index for deterministic processing order
        let mut displayable_sorted: Vec<&String> = displayable_uuids.iter().collect();
        displayable_sorted.sort_by_key(|uuid| {
            self.nodes
                .get(uuid.as_str())
                .map(|n| n.line_index)
                .unwrap_or(0)
        });

        // For each displayable node, walk up parents until we find another displayable node
        for uuid in displayable_sorted {
            let display_parent = self.find_displayable_parent(
                uuid,
                &displayable_uuids,
                &mut displayable_parent_cache,
            );
            match display_parent {
                Some(parent_uuid) => {
                    display_children
                        .entry(parent_uuid)
                        .or_default()
                        .push(uuid.clone());
                }
                None => {
                    display_roots.push(uuid.clone());
                }
            }
        }

        // Sort children by line_index for consistent ordering
        for kids in display_children.values_mut() {
            kids.sort_by_key(|uuid| self.nodes.get(uuid).map(|n| n.line_index).unwrap_or(0));
        }

        // Sort roots by line_index
        display_roots.sort_by_key(|uuid| self.nodes.get(uuid).map(|n| n.line_index).unwrap_or(0));

        (display_children, display_roots)
    }

    /// Walk up parent chain to find the nearest displayable ancestor.
    fn find_displayable_parent(
        &self,
        uuid: &str,
        displayable: &HashSet<String>,
        cache: &mut HashMap<String, Option<String>>,
    ) -> Option<String> {
        let node = self.nodes.get(uuid)?;
        let mut current_parent = node.parent_uuid.clone();

        // Walk up through non-displayable nodes
        let mut visited = HashSet::new();
        while let Some(ref parent_uuid) = current_parent {
            if visited.contains(parent_uuid) {
                break; // Cycle protection
            }
            visited.insert(parent_uuid.clone());

            if displayable.contains(parent_uuid) {
                cache.insert(uuid.to_string(), Some(parent_uuid.clone()));
                return Some(parent_uuid.clone());
            }

            if let Some(cached) = cache.get(parent_uuid) {
                return cached.clone();
            }

            current_parent = self
                .nodes
                .get(parent_uuid)
                .and_then(|n| n.parent_uuid.clone());
        }

        cache.insert(uuid.to_string(), None);
        None
    }

    /// Check if a node or any of its descendants is on the latest chain.
    ///
    /// Iterative DFS — recursion would blow the thread stack on very deep
    /// (mostly-linear) sessions, mirroring the issue in `flatten_to_rows`.
    fn is_descendant_of_latest(
        &self,
        uuid: &str,
        display_children: &HashMap<String, Vec<String>>,
    ) -> bool {
        let mut stack: Vec<&str> = vec![uuid];
        let mut visited: HashSet<&str> = HashSet::new();
        while let Some(current) = stack.pop() {
            if !visited.insert(current) {
                continue;
            }
            if self.latest_chain.contains(current) {
                return true;
            }
            if let Some(kids) = display_children.get(current) {
                for kid in kids {
                    stack.push(kid.as_str());
                }
            }
        }
        false
    }
}

/// Render `ContentBlock`s as the full preview body shown in the tree's right
/// pane. Mirrors `ContentMode::Full` so Opencode messages look the same as
/// Claude/Codex messages when expanded.
fn render_full_text(blocks: &[ContentBlock]) -> String {
    SessionRecord::render_content(blocks, &ContentMode::Full)
}

/// Build the graph gutter string for a row.
fn build_graph_symbols(prefix_lanes: &[bool], connector: Option<bool>) -> String {
    let mut result = String::new();

    for has_later_sibling in prefix_lanes {
        if *has_later_sibling {
            result.push_str("| ");
        } else {
            result.push_str("  ");
        }
    }

    match connector {
        Some(false) => result.push_str("|-* "),
        Some(true) => result.push_str("\\-* "),
        None => result.push_str("* "),
    }

    result
}

/// Detect messages related to context loss / auto-compaction by content.
/// These are regular user/assistant messages but carry compaction metadata.
fn is_context_loss_message(content_preview: &Option<String>) -> bool {
    let Some(preview) = content_preview else {
        return false;
    };
    let lower = preview.to_lowercase();
    lower.contains("being continued from a previous conversation that ran out of context")
        || lower.contains("/compact")
        || lower.contains("compacted (ctrl+o to see full summary)")
}

impl SessionTree {
    /// Create a SessionTree with minimal fields for testing.
    #[cfg(test)]
    pub fn new_for_test(
        session_id: String,
        file_path: String,
        source: SessionSource,
        rows: Vec<TreeRow>,
    ) -> Self {
        Self {
            nodes: HashMap::new(),
            children: HashMap::new(),
            latest_chain: HashSet::new(),
            rows,
            session_id,
            file_path,
            source,
            content_cache: RefCell::new(HashMap::new()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session::record::{parse_content_blocks, ContentMode, SessionRecord};
    use std::io::Write;
    use tempfile::TempDir;

    /// Helper: create a simple linear session (no branches)
    fn create_linear_session(dir: &TempDir) -> std::path::PathBuf {
        let path = dir.path().join("linear.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi there!"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Fix the bug"}}]}},"uuid":"u3","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Done fixing"}}]}},"uuid":"u4","parentUuid":"u3","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
        path
    }

    /// Helper: create a branched session with progress nodes
    fn create_branched_session(dir: &TempDir) -> std::path::PathBuf {
        let path = dir.path().join("branched.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        // Common: progress(p1) -> user(a1) -> assistant(a2)
        writeln!(f, r#"{{"type":"progress","uuid":"p1","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"a1","parentUuid":"p1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi"}}]}},"uuid":"a2","parentUuid":"a1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        // Branch A: system(a3) -> user(a4) -> assistant(a5)
        writeln!(f, r#"{{"type":"system","uuid":"a3","parentUuid":"a2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Branch A msg"}}]}},"uuid":"a4","parentUuid":"a3","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Branch A reply"}}]}},"uuid":"a5","parentUuid":"a4","sessionId":"s1","timestamp":"2025-01-01T00:05:00Z"}}"#).unwrap();
        // Branch B: system(b3) -> user(b4) -> assistant(b5)
        writeln!(f, r#"{{"type":"system","uuid":"b3","parentUuid":"a2","sessionId":"s1","timestamp":"2025-01-01T00:03:30Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Branch B msg"}}]}},"uuid":"b4","parentUuid":"b3","sessionId":"s1","timestamp":"2025-01-01T00:04:30Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Branch B reply"}}]}},"uuid":"b5","parentUuid":"b4","sessionId":"s1","timestamp":"2025-01-01T00:05:30Z"}}"#).unwrap();
        path
    }

    #[test]
    fn test_linear_session_parse() {
        let dir = TempDir::new().unwrap();
        let path = create_linear_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        assert_eq!(tree.rows.len(), 4);
        assert_eq!(tree.session_id, "s1");
        assert_eq!(tree.branch_count(), 0);
    }

    #[test]
    fn test_linear_session_order() {
        let dir = TempDir::new().unwrap();
        let path = create_linear_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        assert_eq!(tree.rows[0].role, "user");
        assert!(tree.rows[0].content_preview.contains("Hello"));
        assert_eq!(tree.rows[1].role, "assistant");
        assert!(tree.rows[1].content_preview.contains("Hi there"));
        assert_eq!(tree.rows[2].role, "user");
        assert!(tree.rows[2].content_preview.contains("Fix the bug"));
        assert_eq!(tree.rows[3].role, "assistant");
        assert!(tree.rows[3].content_preview.contains("Done fixing"));
    }

    #[test]
    fn test_linear_all_on_latest_chain() {
        let dir = TempDir::new().unwrap();
        let path = create_linear_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        for row in &tree.rows {
            assert!(
                row.is_on_latest_chain,
                "All linear messages should be on latest chain"
            );
        }
    }

    #[test]
    fn test_deep_linear_chain_does_not_overflow_stack() {
        // Long linear sessions used to overflow the background thread's
        // default ~2 MB stack via recursive `dfs_flatten` /
        // `is_descendant_of_latest`. Run from_file on a worker thread with
        // an explicitly small stack to lock in the iterative implementation.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("deep_linear.jsonl");
        let mut f = fs::File::create(&path).unwrap();

        let depth = 5000;
        let mut prev: Option<String> = None;
        for i in 0..depth {
            let uuid = format!("u{}", i);
            let parent = match &prev {
                Some(p) => format!(r#","parentUuid":"{}""#, p),
                None => String::new(),
            };
            writeln!(
                f,
                r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"m{}"}}]}},"uuid":"{}"{},"sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}}"#,
                i, uuid, parent
            )
            .unwrap();
            prev = Some(uuid);
        }
        drop(f);

        let path_str = path.to_str().unwrap().to_string();
        let handle = std::thread::Builder::new()
            .stack_size(512 * 1024) // 512 KB — well under what recursion at depth=5000 needs
            .spawn(move || SessionTree::from_file(&path_str))
            .expect("spawn worker thread");

        let tree = handle.join().expect("worker did not panic").unwrap();
        assert_eq!(tree.rows.len(), depth);
    }

    #[test]
    fn test_branched_session_parse() {
        let dir = TempDir::new().unwrap();
        let path = create_branched_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // Should have 6 displayable messages: a1, a2, a4, a5, b4, b5
        assert_eq!(tree.rows.len(), 6);
    }

    #[test]
    fn test_branched_session_has_branches() {
        let dir = TempDir::new().unwrap();
        let path = create_branched_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // a2 has two displayable children (a4 via a3, and b4 via b3) so it's a branch point
        assert!(
            tree.branch_count() >= 1,
            "Should have at least one branch point"
        );
    }

    #[test]
    fn test_branched_latest_chain() {
        let dir = TempDir::new().unwrap();
        let path = create_branched_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // b5 is last line, so latest chain includes: b5, b4, b3, a2, a1, p1
        // In display rows, b4 and b5 should be on latest chain
        // a4 and a5 should NOT be on latest chain

        let find_row = |content: &str| {
            tree.rows
                .iter()
                .find(|r| r.content_preview.contains(content))
                .unwrap()
        };

        assert!(find_row("Branch B msg").is_on_latest_chain);
        assert!(find_row("Branch B reply").is_on_latest_chain);
        assert!(!find_row("Branch A msg").is_on_latest_chain);
        assert!(!find_row("Branch A reply").is_on_latest_chain);
        // Common ancestors
        assert!(find_row("Hello").is_on_latest_chain);
        assert!(find_row("Hi").is_on_latest_chain);
    }

    #[test]
    fn test_branched_latest_chain_first_in_order() {
        let dir = TempDir::new().unwrap();
        let path = create_branched_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // After common messages (Hello, Hi), the latest chain branch should come first
        // Row 0: Hello (common)
        // Row 1: Hi (common)
        // Then latest chain branch (B) should come before branch A

        let b_msg_idx = tree
            .rows
            .iter()
            .position(|r| r.content_preview.contains("Branch B msg"))
            .unwrap();
        let a_msg_idx = tree
            .rows
            .iter()
            .position(|r| r.content_preview.contains("Branch A msg"))
            .unwrap();

        assert!(
            b_msg_idx < a_msg_idx,
            "Latest chain (B) should appear before fork (A)"
        );
    }

    #[test]
    fn test_get_full_content() {
        let dir = TempDir::new().unwrap();
        let path = create_linear_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        let content = tree.get_full_content("u1").unwrap();
        assert_eq!(content, "Hello");
    }

    #[test]
    fn test_compaction_event_visible() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("compact.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi there"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"summary","summary":"Discussed greeting","leafUuid":"u2","uuid":"s1sum","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Continue after compact"}}]}},"uuid":"u3","parentUuid":"s1sum","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Sure!"}}]}},"uuid":"u4","parentUuid":"u3","sessionId":"s1","timestamp":"2025-01-01T00:05:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // Should have 5 rows: u1, u2, summary, u3, u4
        assert_eq!(tree.rows.len(), 5);

        // Find the compaction row
        let compact_row = tree.rows.iter().find(|r| r.is_compaction).unwrap();
        assert_eq!(compact_row.role, "compaction");
        assert!(compact_row.content_preview.contains("Discussed greeting"));

        // Non-compaction rows should not be marked
        let user_rows: Vec<_> = tree.rows.iter().filter(|r| !r.is_compaction).collect();
        assert_eq!(user_rows.len(), 4);
    }

    #[test]
    fn test_compaction_without_uuid_not_displayed() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("compact_no_uuid.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        // Summary without uuid — should be skipped entirely (no uuid = not in DAG)
        writeln!(
            f,
            r#"{{"type":"summary","summary":"Some summary","leafUuid":"u1","sessionId":"s1"}}"#
        )
        .unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
        // Only the user message should be visible
        assert_eq!(tree.rows.len(), 1);
        assert!(!tree.rows[0].is_compaction);
    }

    #[test]
    fn test_empty_file() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("empty.jsonl");
        fs::write(&path, "").unwrap();
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
        assert!(tree.rows.is_empty());
    }

    #[test]
    fn test_extract_preview_plain_string() {
        let content = serde_json::json!("Hello world this is a test message");
        let blocks = parse_content_blocks(&content);
        let preview =
            SessionRecord::render_content(&blocks, &ContentMode::Preview { max_chars: 20 });
        assert_eq!(preview, "Hello world this is ...");
    }

    #[test]
    fn test_extract_preview_array() {
        let content = serde_json::json!([
            {"type": "text", "text": "Part one"},
            {"type": "tool_use", "name": "Read"},
        ]);
        let blocks = parse_content_blocks(&content);
        let preview =
            SessionRecord::render_content(&blocks, &ContentMode::Preview { max_chars: 100 });
        assert!(preview.contains("Part one"));
        assert!(preview.contains("[tool: Read]"));
    }

    #[test]
    fn test_extract_preview_thinking_block() {
        let content = serde_json::json!([
            {"type": "thinking", "thinking": "Let me analyze the code structure"},
            {"type": "text", "text": "Here is my analysis"},
        ]);
        let blocks = parse_content_blocks(&content);
        let preview =
            SessionRecord::render_content(&blocks, &ContentMode::Preview { max_chars: 100 });
        assert!(preview.contains("Let me analyze the code structure"));
        assert!(preview.contains("Here is my analysis"));
    }

    #[test]
    fn test_extract_preview_collapses_whitespace() {
        let content = serde_json::json!("Hello\n\n  world\t\ttab");
        let blocks = parse_content_blocks(&content);
        let preview =
            SessionRecord::render_content(&blocks, &ContentMode::Preview { max_chars: 100 });
        assert!(!preview.contains('\n'));
        assert!(!preview.contains('\t'));
    }

    #[test]
    fn test_graph_symbols_single_column() {
        assert_eq!(build_graph_symbols(&[], None), "* ");
    }

    #[test]
    fn test_graph_symbols_multiple_columns() {
        let symbols = build_graph_symbols(&[true], Some(false));
        assert!(symbols.contains('|'));
        assert!(symbols.contains('*'));
    }

    #[test]
    fn test_branched_graph_symbols_show_separate_lanes() {
        let dir = TempDir::new().unwrap();
        let path = create_branched_session(&dir);
        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        let symbols: Vec<&str> = tree.rows.iter().map(|r| r.graph_symbols.as_str()).collect();

        assert!(
            symbols.iter().any(|s| s.contains("|-*")),
            "first branch should use a visible split connector: {symbols:?}"
        );
        assert!(
            symbols.iter().any(|s| s.contains("| *")),
            "rows inside an earlier branch should keep the pending sibling lane visible: {symbols:?}"
        );
        assert!(
            symbols.iter().any(|s| s.contains("\\-*")),
            "last branch should use a visible closing connector: {symbols:?}"
        );
    }

    #[test]
    fn test_latest_chain_ignores_trailing_metadata() {
        // Bug: old code used last_uuid (last line) as tip. If the last line is a
        // metadata/summary record, it should NOT become the tip.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("trailing_meta.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi there"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        // Trailing system record — has uuid but is not user/assistant
        writeln!(f, r#"{{"type":"system","subtype":"attribution-snapshot","uuid":"sys1","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // u2 should be on latest chain (it's the latest user/assistant terminal)
        // u1 should also be on latest chain (ancestor of u2)
        let find_row = |content: &str| {
            tree.rows
                .iter()
                .find(|r| r.content_preview.contains(content))
                .unwrap()
        };

        assert!(find_row("Hello").is_on_latest_chain);
        assert!(find_row("Hi there").is_on_latest_chain);
    }

    #[test]
    fn test_latest_chain_ignores_sidechain_leaf() {
        // Sidechain messages (subagent) should not become the tip even if they
        // are terminal and have the latest timestamp.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("sidechain_leaf.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Working on it"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        // Sidechain leaf with later timestamp
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Subagent result"}}]}},"uuid":"sc1","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:05:00Z","isSidechain":true}}"#).unwrap();
        // Real continuation (earlier timestamp than sidechain but this is the real tip)
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Thanks"}}]}},"uuid":"u3","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        let find_row = |content: &str| {
            tree.rows
                .iter()
                .find(|r| r.content_preview.contains(content))
                .unwrap()
        };

        // u3 ("Thanks") should be on latest chain, not sc1 ("Subagent result")
        assert!(find_row("Thanks").is_on_latest_chain);
        assert!(find_row("Hello").is_on_latest_chain);
        assert!(find_row("Working on it").is_on_latest_chain);
    }

    #[test]
    fn test_latest_chain_picks_latest_terminal() {
        // With multiple terminal branches, pick the one with the latest timestamp.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("multi_terminal.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Root"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        // Branch A: earlier terminal
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Branch A"}}]}},"uuid":"a1","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        // Branch B: later terminal
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Branch B"}}]}},"uuid":"b1","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        let find_row = |content: &str| {
            tree.rows
                .iter()
                .find(|r| r.content_preview.contains(content))
                .unwrap()
        };

        // Branch B has later timestamp, so it should be on latest chain
        assert!(find_row("Branch B").is_on_latest_chain);
        assert!(find_row("Root").is_on_latest_chain);
        // Branch A should NOT be on latest chain
        assert!(!find_row("Branch A").is_on_latest_chain);
    }

    #[test]
    fn test_logical_parent_uuid_bridges_compact_boundary() {
        // compact_boundary messages have parentUuid: null but logicalParentUuid
        // preserves the link. Without this fallback, post-compact messages become
        // orphaned roots and the tree breaks into disconnected pieces.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("logical_parent.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        // Pre-compact chain
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Before compact"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Reply before"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        // compact_boundary with logicalParentUuid bridging to u2
        writeln!(f, r#"{{"type":"system","subtype":"compact_boundary","uuid":"cb1","logicalParentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
        // Post-compact chain continuing from compact_boundary
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"After compact"}}]}},"uuid":"u3","parentUuid":"cb1","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Reply after"}}]}},"uuid":"u4","parentUuid":"u3","sessionId":"s1","timestamp":"2025-01-01T00:05:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // All messages should form one connected chain via logicalParentUuid bridge
        assert_eq!(
            tree.rows.len(),
            4,
            "Should have 4 displayable rows (u1, u2, u3, u4)"
        );

        let find_row = |content: &str| {
            tree.rows
                .iter()
                .find(|r| r.content_preview.contains(content))
                .unwrap()
        };

        // All should be on latest chain — the logicalParentUuid bridges the gap
        assert!(find_row("Before compact").is_on_latest_chain);
        assert!(find_row("Reply before").is_on_latest_chain);
        assert!(find_row("After compact").is_on_latest_chain);
        assert!(find_row("Reply after").is_on_latest_chain);

        // Post-compact messages should NOT be orphaned roots
        // (they should have display parents, not be at tree root level)
        let after_idx = tree
            .rows
            .iter()
            .position(|r| r.content_preview.contains("After compact"))
            .unwrap();
        let reply_before_idx = tree
            .rows
            .iter()
            .position(|r| r.content_preview.contains("Reply before"))
            .unwrap();
        assert!(
            after_idx > reply_before_idx,
            "After compact should come after Reply before in display order"
        );
    }

    #[test]
    fn test_display_graph_bridges_progress_nodes() {
        // Progress nodes are non-displayable (no role/content_preview).
        // find_displayable_parent() should walk through them so that
        // user(u1) -> progress(p1) -> user(u2) displays as u1 -> u2.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("progress_bridge.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"First message"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        // Progress node in the middle — has uuid/parentUuid but no role/content
        writeln!(f, r#"{{"type":"progress","uuid":"p1","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Second message"}}]}},"uuid":"u2","parentUuid":"p1","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // Only u1 and u2 should be displayed (progress is not displayable)
        assert_eq!(tree.rows.len(), 2, "Only user messages should be displayed");
        assert!(tree.rows[0].content_preview.contains("First message"));
        assert!(tree.rows[1].content_preview.contains("Second message"));

        // Both should be on latest chain (connected through progress bridge)
        assert!(tree.rows[0].is_on_latest_chain);
        assert!(tree.rows[1].is_on_latest_chain);

        // u2 should NOT be a root — it should be a child of u1 via the progress bridge
        assert_eq!(
            tree.branch_count(),
            0,
            "Should be linear chain, no branches"
        );
    }

    #[test]
    fn test_system_nodes_bridge_correctly() {
        // System nodes (uuid/parentUuid but no role/content) are non-displayable.
        // find_displayable_parent() should walk through them so that
        // user(u1) -> system(s1) -> user(u2) displays as u1 -> u2.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("system_bridge.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Before system"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        // System node in the middle — has uuid/parentUuid but no message/role
        writeln!(f, r#"{{"type":"system","uuid":"s1","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"After system"}}]}},"uuid":"u2","parentUuid":"s1","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // Only u1 and u2 should be displayed (system node is not displayable)
        assert_eq!(tree.rows.len(), 2, "Only user messages should be displayed");
        assert!(tree.rows[0].content_preview.contains("Before system"));
        assert!(tree.rows[1].content_preview.contains("After system"));

        // Both should be on latest chain (connected through system bridge)
        assert!(tree.rows[0].is_on_latest_chain);
        assert!(tree.rows[1].is_on_latest_chain);

        // Should be linear — no branches
        assert_eq!(
            tree.branch_count(),
            0,
            "Should be linear chain, no branches"
        );
    }

    #[test]
    fn test_attachment_nodes_bridge_correctly() {
        // Attachment nodes (uuid/parentUuid but no role/content) are non-displayable.
        // find_displayable_parent() should walk through them so that
        // user(u1) -> attachment(att1) -> assistant(u2) displays as u1 -> u2.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("attachment_bridge.jsonl");
        let mut f = fs::File::create(&path).unwrap();
        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Upload file"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
        // Attachment node — has uuid/parentUuid but no message/role
        writeln!(f, r#"{{"type":"attachment","uuid":"att1","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"File received"}}]}},"uuid":"u2","parentUuid":"att1","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();

        let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();

        // Only u1 and u2 should be displayed (attachment node is not displayable)
        assert_eq!(
            tree.rows.len(),
            2,
            "Only user/assistant messages should be displayed"
        );
        assert!(tree.rows[0].content_preview.contains("Upload file"));
        assert!(tree.rows[1].content_preview.contains("File received"));

        // Both should be on latest chain (connected through attachment bridge)
        assert!(tree.rows[0].is_on_latest_chain);
        assert!(tree.rows[1].is_on_latest_chain);

        // Should be linear — no branches
        assert_eq!(
            tree.branch_count(),
            0,
            "Should be linear chain, no branches"
        );
    }
}
