use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};

use crate::coverage::count_symbols;
use crate::symbols::{ProjectTree, SymbolNode};
use crate::tracking::ReadDepth;
use crate::tracking::ContextLedger;
use crate::tracking::agents::{AgentTree, AgentNode};
use crate::ingest::AgentToolCall;

/// How files are sorted in the tree view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortMode {
    Alphabetical,
    ByCoverage,
}

/// Four-state coverage classification for files.
/// Variant order gives the desired sort: Partially → AllSeen → Fully → Not Covered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileCoverageStatus {
    PartiallyCovered,
    AllSeen,
    FullyCovered,
    NotCovered,
}

/// A flattened row in the tree view, ready for rendering.
#[derive(Debug, Clone)]
pub struct TreeRow {
    pub symbol_id: String,
    pub display_name: String,
    pub label: &'static str,  // Language-specific label (e.g., "class", "def", "fn")
    pub depth: usize,         // nesting depth for indentation
    pub is_file: bool,        // true for file headers
    pub is_expanded: bool,
    pub has_children: bool,
    pub line_range: String,
    pub token_count: usize,
    pub read_depth: ReadDepth,
    pub coverage_status: Option<FileCoverageStatus>,
    pub file_coverage_seen: usize,
    pub file_coverage_total: usize,
}

/// Which panel is focused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusPanel {
    Tree,
    Stats,
    Activity,
}

pub struct App {
    pub project_tree: ProjectTree,
    pub project_root: PathBuf,
    pub ledger: ContextLedger,
    pub should_quit: bool,

    // Tree view state.
    pub tree_rows: Vec<TreeRow>,
    pub selected_index: usize,
    pub collapsed: std::collections::HashSet<String>,

    // Activity feed.
    pub activity: Vec<AgentToolCall>,
    /// How many lines the user has scrolled up from the bottom in the activity feed (0 = pinned to latest).
    pub activity_scroll_offset: usize,

    // Agents seen.
    pub agents_seen: Vec<String>,

    // Agent hierarchy.
    pub agent_tree: AgentTree,

    // Agent filter: if Some, only show coverage from this agent.
    pub agent_filter: Option<String>,
    /// Selection index in the agent list (0 = All, 1..N = specific agent).
    pub agent_selection_index: usize,

    // Focus.
    pub focus: FocusPanel,

    // Sort mode for tree view.
    pub sort_mode: SortMode,

    // Search.
    pub search_mode: bool,
    pub search_query: String,

    // Session info for display.
    pub session_id: Option<String>,

    // Optional event log writer.
    pub event_log: Option<BufWriter<File>>,
}

impl App {
    pub fn new(project_tree: ProjectTree, project_root: PathBuf, event_log: Option<BufWriter<File>>) -> Self {
        // Start with all files collapsed.
        let collapsed: std::collections::HashSet<String> = project_tree
            .files
            .iter()
            .map(|f| f.file_path.to_string_lossy().to_string())
            .collect();

        let mut app = Self {
            project_tree,
            project_root,
            ledger: ContextLedger::new(),
            should_quit: false,
            tree_rows: Vec::new(),
            selected_index: 0,
            collapsed,
            activity: Vec::new(),
            activity_scroll_offset: 0,
            agents_seen: Vec::new(),
            agent_tree: AgentTree::new(),
            agent_filter: None,
            agent_selection_index: 0,
            focus: FocusPanel::Tree,
            sort_mode: SortMode::Alphabetical,
            search_mode: false,
            search_query: String::new(),
            session_id: None,
            event_log,
        };
        app.rebuild_tree_rows();
        app
    }

    /// Reset all live session state (ledger, agents, activity) while preserving
    /// the project tree and UI configuration. Called when a `/clear` is detected
    /// in the session log.
    pub fn reset_session(&mut self) {
        self.ledger = ContextLedger::new();
        self.activity.clear();
        self.agents_seen.clear();
        self.agent_tree = AgentTree::new();
        self.agent_filter = None;
        self.agent_selection_index = 0;
        self.rebuild_tree_rows();
    }

    /// Rebuild the flattened tree rows from the project tree + collapsed state.
    pub fn rebuild_tree_rows(&mut self) {
        let mut rows = Vec::new();
        let agent_filter = self.agent_filter.as_deref();

        // Build iteration order: sorted by coverage status if ByCoverage mode is active.
        let file_indices: Vec<usize> = if self.sort_mode == SortMode::ByCoverage {
            let mut indices: Vec<(FileCoverageStatus, &std::path::Path, usize)> = self
                .project_tree
                .files
                .iter()
                .enumerate()
                .map(|(i, f)| {
                    let (total, seen, full) = count_symbols(&f.symbols, &self.ledger, agent_filter);
                    (
                        coverage_status_from_counts(total, seen, full),
                        f.file_path.as_path(),
                        i,
                    )
                })
                .collect();
            indices.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
            indices.into_iter().map(|(_, _, i)| i).collect()
        } else {
            (0..self.project_tree.files.len()).collect()
        };

        for &idx in &file_indices {
            let file = &self.project_tree.files[idx];
            let file_path = file.file_path.to_string_lossy().to_string();
            let file_id = file_path.clone();
            let is_expanded = !self.collapsed.contains(&file_id);

            let (total, seen, full) = count_symbols(&file.symbols, &self.ledger, agent_filter);
            let status = coverage_status_from_counts(total, seen, full);
            let file_read_depth = if status != FileCoverageStatus::NotCovered {
                ReadDepth::NameOnly // Use NameOnly to indicate "has coverage"
            } else {
                ReadDepth::Unseen
            };

            rows.push(TreeRow {
                symbol_id: file_id.clone(),
                display_name: file_path.clone(),
                label: "",
                depth: 0,
                is_file: true,
                is_expanded,
                has_children: !file.symbols.is_empty(),
                line_range: format!("{} lines", file.total_lines),
                token_count: 0,
                read_depth: file_read_depth,
                coverage_status: Some(status),
                file_coverage_seen: seen,
                file_coverage_total: total,
            });

            if is_expanded {
                for sym in &file.symbols {
                    flatten_symbol(sym, 1, &self.collapsed, &self.ledger, agent_filter, &mut rows);
                }
            }
        }

        self.tree_rows = rows;
    }

    pub fn handle_key(&mut self, key: KeyEvent) {
        if self.search_mode {
            self.handle_search_key(key);
            return;
        }

        match key.code {
            KeyCode::Char('q') => self.should_quit = true,
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.should_quit = true;
            }
            KeyCode::Char('j') | KeyCode::Down => {
                if self.focus == FocusPanel::Stats {
                    self.move_agent_selection(1);
                } else {
                    self.move_selection(1);
                }
            }
            KeyCode::Char('k') | KeyCode::Up => {
                if self.focus == FocusPanel::Stats {
                    self.move_agent_selection(-1);
                } else {
                    self.move_selection(-1);
                }
            }
            KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => {
                if self.focus == FocusPanel::Stats {
                    self.apply_agent_selection();
                } else {
                    self.toggle_expand();
                }
            }
            KeyCode::Char('h') | KeyCode::Left => self.collapse_current(),
            KeyCode::Char('G') => self.select_last(),
            KeyCode::Char('g') => self.select_first(),
            KeyCode::Char('/') => {
                self.search_mode = true;
                self.search_query.clear();
            }
            KeyCode::Char('s') => {
                self.sort_mode = match self.sort_mode {
                    SortMode::Alphabetical => SortMode::ByCoverage,
                    SortMode::ByCoverage => SortMode::Alphabetical,
                };
                self.rebuild_tree_rows();
            }
            KeyCode::Char('a') => self.cycle_agent_filter(),
            KeyCode::Char('A') => self.cycle_agent_filter_backward(),
            KeyCode::Tab => self.cycle_focus(),
            KeyCode::BackTab => self.cycle_agent_filter_backward(),
            KeyCode::PageDown => self.move_selection(20),
            KeyCode::PageUp => self.move_selection(-20),
            _ => {}
        }
    }

    pub fn handle_mouse(&mut self, mouse: MouseEvent) {
        match mouse.kind {
            MouseEventKind::ScrollUp => match self.focus {
                FocusPanel::Activity => {
                    self.activity_scroll_offset = self.activity_scroll_offset.saturating_add(3);
                }
                FocusPanel::Stats => self.move_agent_selection(-1),
                FocusPanel::Tree => self.move_selection(-3),
            },
            MouseEventKind::ScrollDown => match self.focus {
                FocusPanel::Activity => {
                    self.activity_scroll_offset = self.activity_scroll_offset.saturating_sub(3);
                }
                FocusPanel::Stats => self.move_agent_selection(1),
                FocusPanel::Tree => self.move_selection(3),
            },
            _ => {}
        }
    }

    fn handle_search_key(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc => {
                self.search_mode = false;
                self.search_query.clear();
            }
            KeyCode::Enter => {
                self.search_mode = false;
                self.jump_to_search_match();
            }
            KeyCode::Backspace => {
                self.search_query.pop();
            }
            KeyCode::Char(c) => {
                self.search_query.push(c);
            }
            _ => {}
        }
    }

    fn move_selection(&mut self, delta: i32) {
        if self.tree_rows.is_empty() {
            return;
        }
        let new_idx = self.selected_index as i32 + delta;
        self.selected_index = new_idx.clamp(0, self.tree_rows.len() as i32 - 1) as usize;
    }

    fn select_first(&mut self) {
        self.selected_index = 0;
    }

    fn select_last(&mut self) {
        if !self.tree_rows.is_empty() {
            self.selected_index = self.tree_rows.len() - 1;
        }
    }

    fn toggle_expand(&mut self) {
        if let Some(row) = self.tree_rows.get(self.selected_index) {
            if row.has_children {
                let id = row.symbol_id.clone();
                if self.collapsed.contains(&id) {
                    self.collapsed.remove(&id);
                } else {
                    self.collapsed.insert(id);
                }
                self.rebuild_tree_rows();
            }
        }
    }

    fn collapse_current(&mut self) {
        if let Some(row) = self.tree_rows.get(self.selected_index) {
            let id = row.symbol_id.clone();
            if row.has_children && !self.collapsed.contains(&id) {
                self.collapsed.insert(id);
                self.rebuild_tree_rows();
            }
        }
    }

    fn cycle_agent_filter(&mut self) {
        if self.agents_seen.is_empty() {
            self.agent_filter = None;
            self.agent_selection_index = 0;
            return;
        }
        match &self.agent_filter {
            None => {
                self.agent_filter = Some(self.agents_seen[0].clone());
                self.agent_selection_index = 1;
            }
            Some(current) => {
                let idx = self.agents_seen.iter().position(|a| a == current);
                match idx {
                    Some(i) if i + 1 < self.agents_seen.len() => {
                        self.agent_filter = Some(self.agents_seen[i + 1].clone());
                        self.agent_selection_index = i + 2;
                    }
                    _ => {
                        self.agent_filter = None;
                        self.agent_selection_index = 0;
                    }
                }
            }
        }
        self.rebuild_tree_rows();
    }

    fn cycle_agent_filter_backward(&mut self) {
        if self.agents_seen.is_empty() {
            self.agent_filter = None;
            self.agent_selection_index = 0;
            return;
        }
        match &self.agent_filter {
            None => {
                let last = self.agents_seen.len() - 1;
                self.agent_filter = Some(self.agents_seen[last].clone());
                self.agent_selection_index = self.agents_seen.len();
            }
            Some(current) => {
                let idx = self.agents_seen.iter().position(|a| a == current);
                match idx {
                    Some(0) => {
                        self.agent_filter = None;
                        self.agent_selection_index = 0;
                    }
                    Some(i) => {
                        self.agent_filter = Some(self.agents_seen[i - 1].clone());
                        self.agent_selection_index = i;
                    }
                    _ => {
                        self.agent_filter = None;
                        self.agent_selection_index = 0;
                    }
                }
            }
        }
        self.rebuild_tree_rows();
    }

    fn move_agent_selection(&mut self, delta: i32) {
        let total = self.agents_seen.len() + 1; // +1 for "All"
        if total == 0 {
            return;
        }
        let new_idx = if delta > 0 {
            (self.agent_selection_index + delta as usize) % total
        } else {
            let back = (-delta) as usize;
            (self.agent_selection_index + total - (back % total)) % total
        };
        self.agent_selection_index = new_idx;
    }

    fn apply_agent_selection(&mut self) {
        if self.agent_selection_index == 0 {
            self.agent_filter = None;
        } else {
            let flat = self.flattened_agents();
            if let Some((agent_id, _)) = flat.get(self.agent_selection_index - 1) {
                self.agent_filter = Some(agent_id.clone());
            } else {
                self.agent_filter = None;
            }
        }
        self.rebuild_tree_rows();
    }

    /// Returns agent IDs in hierarchy order (DFS) with indent levels.
    /// Each entry is `(agent_id, indent_level)`.
    /// Agents not reachable from the root are appended at depth 0.
    pub fn flattened_agents(&self) -> Vec<(String, usize)> {
        let mut result = Vec::new();
        if let Some(ref root_id) = self.agent_tree.root_id {
            self.flatten_dfs(root_id, 0, &mut result);
        }
        // Append any agents not reached by DFS (orphans).
        for agent_id in &self.agents_seen {
            if !result.iter().any(|(id, _)| id == agent_id) {
                self.flatten_dfs(agent_id, 0, &mut result);
            }
        }
        result
    }

    fn flatten_dfs(&self, agent_id: &str, depth: usize, out: &mut Vec<(String, usize)>) {
        out.push((agent_id.to_string(), depth));
        let children = self.agent_tree.children_of(agent_id);
        for child in children {
            self.flatten_dfs(&child.id, depth + 1, out);
        }
    }

    fn cycle_focus(&mut self) {
        self.focus = match self.focus {
            FocusPanel::Tree => FocusPanel::Stats,
            FocusPanel::Stats => FocusPanel::Activity,
            FocusPanel::Activity => FocusPanel::Tree,
        };
    }

    fn jump_to_search_match(&mut self) {
        let query = self.search_query.to_lowercase();
        if query.is_empty() {
            return;
        }
        // Search forward from current position.
        let start = (self.selected_index + 1) % self.tree_rows.len();
        for i in 0..self.tree_rows.len() {
            let idx = (start + i) % self.tree_rows.len();
            if self.tree_rows[idx]
                .display_name
                .to_lowercase()
                .contains(&query)
            {
                self.selected_index = idx;
                return;
            }
        }
    }

    /// Process an agent tool call event and update the ledger.
    pub fn process_agent_event(&mut self, event: AgentToolCall) {
        // Track unique agents.
        if !self.agents_seen.iter().any(|a| a.as_str() == &*event.agent_id) {
            self.agents_seen.push(event.agent_id.to_string());

            // Register in the agent hierarchy tree.
            // Subagent filenames start with "agent-"; their parent is the root session.
            let parent_id = if event.agent_id.starts_with("agent-") {
                self.agent_tree.root_id.clone()
            } else {
                None
            };
            self.agent_tree.add_agent(AgentNode {
                id: event.agent_id.to_string(),
                parent_id,
                session_file: PathBuf::new(),
                label: event.label.to_string(),
            });
        }

        if let Some(ref file_path) = event.file_path {
            // Normalize the tool call path: strip the project root to get a relative path.
            let tool_rel = normalize_tool_path(file_path, &self.project_root);

            for file in &self.project_tree.files {
                if file.file_path == tool_rel {
                    if event.target_symbol.is_some() || event.target_lines.is_some() {
                        mark_targeted_symbols(&file.symbols, &event, &mut self.ledger);
                    } else {
                        mark_file_symbols(&file.symbols, &event, &mut self.ledger);
                    }
                }
            }
        }
        // Write to event log if configured.
        if let Some(ref mut writer) = self.event_log {
            let path_str = event
                .file_path
                .as_ref()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| "-".to_string());
            let target = if let Some(ref sym) = event.target_symbol {
                sym.clone()
            } else if let Some(ref lines) = event.target_lines {
                format!("L{}-{}", lines.start, lines.end)
            } else {
                "-".to_string()
            };
            let _ = writeln!(
                writer,
                "[{}] agent={} tool={} depth={:?} path={} target={} desc=\"{}\"",
                event.timestamp_str,
                event.agent_id,
                event.tool_name,
                event.read_depth,
                path_str,
                target,
                event.description,
            );
            let _ = writer.flush();
        }

        // Only push tracked events to the activity feed.
        if event.read_depth != ReadDepth::Unseen {
            self.activity.push(event);
            self.activity_scroll_offset = 0; // Auto-scroll to latest
            if self.activity.len() > 200 {
                self.activity.drain(0..100);
            }
        }
        self.rebuild_tree_rows();
    }
}

fn flatten_symbol(
    sym: &SymbolNode,
    depth: usize,
    collapsed: &std::collections::HashSet<String>,
    ledger: &ContextLedger,
    agent_filter: Option<&str>,
    rows: &mut Vec<TreeRow>,
) {
    let is_expanded = !collapsed.contains(&sym.id);
    let read_depth = match agent_filter {
        Some(agent_id) => ledger.depth_of_for_agent(&sym.id, agent_id),
        None => ledger.depth_of(&sym.id),
    };

    rows.push(TreeRow {
        symbol_id: sym.id.clone(),
        display_name: sym.name.clone(),
        label: sym.label,
        depth,
        is_file: false,
        is_expanded,
        has_children: !sym.children.is_empty(),
        line_range: format!("L{}-{}", sym.line_range.start, sym.line_range.end),
        token_count: sym.estimated_tokens,
        read_depth,
        coverage_status: None,
        file_coverage_seen: 0,
        file_coverage_total: 0,
    });

    if is_expanded {
        for child in &sym.children {
            flatten_symbol(child, depth + 1, collapsed, ledger, agent_filter, rows);
        }
    }
}

/// Convert a tool call file path (usually absolute) to a relative path matching
/// the project tree's convention. Strips the project root prefix if present.
pub fn normalize_tool_path(tool_path: &Path, project_root: &Path) -> PathBuf {
    if tool_path.is_absolute() {
        tool_path
            .strip_prefix(project_root)
            .unwrap_or(tool_path)
            .to_path_buf()
    } else {
        tool_path.to_path_buf()
    }
}

/// Unconditionally record every symbol in `symbols` (and all their descendants)
/// at the event's `read_depth`. Used when the entire file — or the entire body of
/// a named symbol — was present in the tool response.
///
/// Contrast with [`mark_targeted_symbols`], which narrows recording to only the
/// symbols that match the event's `target_symbol` or `target_lines`.
pub fn mark_file_symbols(
    symbols: &[SymbolNode],
    event: &AgentToolCall,
    ledger: &mut ContextLedger,
) {
    for sym in symbols {
        ledger.record(
            sym.id.clone(),
            event.read_depth,
            sym.content_hash,
            event.agent_id.to_string(),
            sym.estimated_tokens,
        );
        mark_file_symbols(&sym.children, event, ledger);
    }
}

/// Mark only the symbols that match the tool call's targeting info.
///
/// Name-targeted matches (via `target_symbol`) bulk-mark all descendants because
/// the tool response included the entire named symbol's body. Line-range matches
/// (via `target_lines`) recurse precisely so that only overlapping child symbols
/// are promoted — preventing unread siblings from being over-credited.
pub fn mark_targeted_symbols(
    symbols: &[SymbolNode],
    event: &AgentToolCall,
    ledger: &mut ContextLedger,
) {
    for sym in symbols {
        match classify_symbol_match(sym, event) {
            MatchKind::ByName => {
                ledger.record(
                    sym.id.clone(),
                    event.read_depth,
                    sym.content_hash,
                    event.agent_id.to_string(),
                    sym.estimated_tokens,
                );
                // Full body was in the response — bulk-mark all descendants.
                mark_file_symbols(&sym.children, event, ledger);
            }
            MatchKind::ByLineOverlap => {
                ledger.record(
                    sym.id.clone(),
                    event.read_depth,
                    sym.content_hash,
                    event.agent_id.to_string(),
                    sym.estimated_tokens,
                );
                // Parent container overlaps the read range — recurse precisely so
                // only children whose ranges also overlap get promoted.
                mark_targeted_symbols(&sym.children, event, ledger);
            }
            MatchKind::None => {
                // No match at this level — keep searching in children.
                mark_targeted_symbols(&sym.children, event, ledger);
            }
        }
    }
}

/// Normalize a `/`-separated name path by stripping leading lowercase-only keyword
/// tokens from each segment.
///
/// Different tools and language servers qualify symbol names with language keywords:
/// Serena uses `"impl App/method"`, `"async def foo"`, `"class Bar/baz"` etc., while
/// ambit's tree-sitter parsers emit just the type/identifier portion: `"App/method"`,
/// `"foo"`, `"Bar/baz"`. Normalising both sides before comparison makes matching
/// language-agnostic without special-casing individual keywords.
///
/// A leading token is considered a keyword if it is one or more ASCII lowercase
/// letters only (no digits, underscores, or colons), followed by a space. The
/// stripping repeats so that multi-word prefixes like `"async def"` are fully
/// removed.
pub fn normalize_name_path(path: &str) -> String {
    path.split('/')
        .map(strip_leading_keywords)
        .collect::<Vec<_>>()
        .join("/")
}

fn strip_leading_keywords(mut s: &str) -> &str {
    loop {
        let keyword_len = s.bytes().take_while(|b| b.is_ascii_lowercase()).count();
        if keyword_len > 0 && s.len() > keyword_len && s.as_bytes()[keyword_len] == b' ' {
            s = &s[keyword_len + 1..];
        } else {
            break;
        }
    }
    s
}

/// Check if a symbol's name path matches the tool call's `target_symbol`.
///
/// Compares both the raw target and the normalised form (see [`normalize_name_path`])
/// so that tool-qualified paths like `"impl App/method"` match ambit's tree path
/// `"App/method"` without special-casing individual languages or keywords.
pub fn symbol_name_matches(sym: &SymbolNode, event: &AgentToolCall) -> bool {
    let Some(ref target_name) = event.target_symbol else { return false };

    let norm_target = normalize_name_path(target_name);

    if let Some(name_part) = sym.id.split("::").last() {
        let norm_name_part = normalize_name_path(name_part);
        // Try both raw and normalised forms: exact match or suffix match.
        for (t, np) in [
            (target_name.as_str(), name_part),
            (norm_target.as_str(), norm_name_part.as_str()),
        ] {
            if np == t || (np.len() > t.len() && np.as_bytes()[np.len() - t.len() - 1] == b'/' && np.ends_with(t)) {
                return true;
            }
        }
    }
    // Plain name match (e.g. target = "handle_key", sym.name = "handle_key").
    let norm_sym_name = normalize_name_path(&sym.name);
    sym.name == *target_name || norm_sym_name == norm_target
}

/// Check if a symbol's line range overlaps with the tool call's `target_lines`.
pub fn symbol_lines_match(sym: &SymbolNode, event: &AgentToolCall) -> bool {
    let Some(ref target_range) = event.target_lines else { return false };
    sym.line_range.start < target_range.end && target_range.start < sym.line_range.end
}

/// Check if a symbol matches the tool call's target_symbol or target_lines.
pub fn symbol_matches_target(sym: &SymbolNode, event: &AgentToolCall) -> bool {
    symbol_name_matches(sym, event) || symbol_lines_match(sym, event)
}

/// How a symbol matched a tool call's targeting info.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchKind {
    /// The symbol was named explicitly via `target_symbol`.
    /// The full body was in the response — mark this symbol and all descendants.
    ByName,
    /// The symbol's line range overlaps `target_lines`.
    /// Only this symbol and overlapping children should be promoted.
    ByLineOverlap,
    /// No match.
    None,
}

/// Classify how `sym` matches the tool call's targeting info.
///
/// Name targeting takes priority over line-range targeting. Returns [`MatchKind::None`]
/// when the event carries no targeting info or neither predicate fires.
pub fn classify_symbol_match(sym: &SymbolNode, event: &AgentToolCall) -> MatchKind {
    if event.target_symbol.is_some() && symbol_name_matches(sym, event) {
        MatchKind::ByName
    } else if event.target_lines.is_some() && symbol_lines_match(sym, event) {
        MatchKind::ByLineOverlap
    } else {
        MatchKind::None
    }
}

/// Classify a file's coverage as fully covered, all seen, partially covered, or not covered.
/// "Fully covered" means every symbol has been read at FullBody depth.
/// "All seen" means every symbol has been seen (depth > Unseen) but not all at FullBody.
fn coverage_status_from_counts(total: usize, seen: usize, full: usize) -> FileCoverageStatus {
    if total == 0 || full == 0 {
        if seen > 0 && seen == total {
            FileCoverageStatus::AllSeen
        } else if seen > 0 {
            FileCoverageStatus::PartiallyCovered
        } else {
            FileCoverageStatus::NotCovered
        }
    } else if full == total {
        FileCoverageStatus::FullyCovered
    } else if seen == total {
        FileCoverageStatus::AllSeen
    } else {
        FileCoverageStatus::PartiallyCovered
    }
}

#[cfg(test)]
#[path = "../tests/helpers/mod.rs"]
#[allow(dead_code)]
mod helpers;

#[cfg(test)]
mod tests {
    use super::*;
    use super::helpers::*;
    use crate::symbols::FileSymbols;
    use std::path::Path;

    #[test]
    fn normalize_tool_path_absolute() {
        let result = normalize_tool_path(
            Path::new("/project/src/main.rs"),
            Path::new("/project"),
        );
        assert_eq!(result, PathBuf::from("src/main.rs"));
    }

    #[test]
    fn normalize_tool_path_relative() {
        let result = normalize_tool_path(
            Path::new("src/main.rs"),
            Path::new("/project"),
        );
        assert_eq!(result, PathBuf::from("src/main.rs"));
    }

    #[test]
    fn mark_file_symbols_recursive() {
        let child = sym("mock/f.rs::child", "child");
        let parent = sym_with_children("mock/f.rs::parent", "parent", vec![child]);
        let event = tool_call("Read", "mock/f.rs", ReadDepth::FullBody);
        let mut ledger = ContextLedger::new();

        mark_file_symbols(&[parent], &event, &mut ledger);

        assert_eq!(ledger.depth_of("mock/f.rs::parent"), ReadDepth::FullBody);
        assert_eq!(ledger.depth_of("mock/f.rs::child"), ReadDepth::FullBody);
    }

    #[test]
    fn mark_targeted_by_name() {
        let s1 = sym("mock/f.rs::alpha", "alpha");
        let s2 = sym("mock/f.rs::beta", "beta");
        let event = tool_call_targeted("find_symbol", "mock/f.rs", ReadDepth::FullBody, "beta");
        let mut ledger = ContextLedger::new();

        mark_targeted_symbols(&[s1, s2], &event, &mut ledger);

        assert_eq!(ledger.depth_of("mock/f.rs::alpha"), ReadDepth::Unseen);
        assert_eq!(ledger.depth_of("mock/f.rs::beta"), ReadDepth::FullBody);
    }

    #[test]
    fn mark_targeted_by_lines() {
        let s1 = sym_with_lines("mock/f.rs::a", "a", 1, 5);
        let s2 = sym_with_lines("mock/f.rs::b", "b", 10, 20);
        let event = tool_call_lines("Read", "mock/f.rs", ReadDepth::FullBody, 12, 18);
        let mut ledger = ContextLedger::new();

        mark_targeted_symbols(&[s1, s2], &event, &mut ledger);

        assert_eq!(ledger.depth_of("mock/f.rs::a"), ReadDepth::Unseen);
        assert_eq!(ledger.depth_of("mock/f.rs::b"), ReadDepth::FullBody);
    }

    #[test]
    fn coverage_status_from_counts_variants() {
        let mut ledger = ContextLedger::new();
        let syms = vec![sym("s1", "s1"), sym("s2", "s2")];

        // No coverage.
        let (total, seen, full) = count_symbols(&syms, &ledger, None);
        assert_eq!(coverage_status_from_counts(total, seen, full), FileCoverageStatus::NotCovered);

        // Partial: one seen, one unseen → PartiallyCovered.
        ledger.record("s1".into(), ReadDepth::NameOnly, [0; 32], "ag".into(), 10);
        let (total, seen, full) = count_symbols(&syms, &ledger, None);
        assert_eq!(coverage_status_from_counts(total, seen, full), FileCoverageStatus::PartiallyCovered);

        // All seen (both NameOnly) but none FullBody → AllSeen.
        ledger.record("s2".into(), ReadDepth::NameOnly, [0; 32], "ag".into(), 10);
        let (total, seen, full) = count_symbols(&syms, &ledger, None);
        assert_eq!(coverage_status_from_counts(total, seen, full), FileCoverageStatus::AllSeen);

        // One FullBody, one NameOnly → AllSeen (all seen, not all full).
        ledger.record("s1".into(), ReadDepth::FullBody, [0; 32], "ag".into(), 10);
        let (total, seen, full) = count_symbols(&syms, &ledger, None);
        assert_eq!(coverage_status_from_counts(total, seen, full), FileCoverageStatus::AllSeen);

        // Full: both FullBody.
        ledger.record("s2".into(), ReadDepth::FullBody, [0; 32], "ag".into(), 10);
        let (total, seen, full) = count_symbols(&syms, &ledger, None);
        assert_eq!(coverage_status_from_counts(total, seen, full), FileCoverageStatus::FullyCovered);

        // Direct FullBody with unseen siblings → PartiallyCovered (full > 0, seen < total).
        assert_eq!(coverage_status_from_counts(3, 1, 1), FileCoverageStatus::PartiallyCovered);
    }

    #[test]
    fn symbol_matches_target_formats() {
        // Plain name match.
        let s = sym("mock/app.rs::App/handle_key", "handle_key");
        let event = tool_call_targeted("find_symbol", "mock/app.rs", ReadDepth::FullBody, "handle_key");
        assert!(symbol_matches_target(&s, &event));

        // Name path suffix match.
        let event2 = tool_call_targeted("find_symbol", "mock/app.rs", ReadDepth::FullBody, "App/handle_key");
        assert!(symbol_matches_target(&s, &event2));

        // Non-match.
        let event3 = tool_call_targeted("find_symbol", "mock/app.rs", ReadDepth::FullBody, "other_fn");
        assert!(!symbol_matches_target(&s, &event3));
    }

    // --- normalize_name_path ---

    #[test]
    fn normalize_name_path_strips_impl_prefix() {
        assert_eq!(normalize_name_path("impl App"), "App");
        assert_eq!(normalize_name_path("impl App/handle_key"), "App/handle_key");
        assert_eq!(normalize_name_path("impl Trait for App"), "Trait for App");
    }

    #[test]
    fn normalize_name_path_strips_multi_word_prefix() {
        // "async def" prefix (Python-style)
        assert_eq!(normalize_name_path("async def foo"), "foo");
        // "class" prefix
        assert_eq!(normalize_name_path("class Foo/bar"), "Foo/bar");
    }

    #[test]
    fn normalize_name_path_leaves_plain_names_unchanged() {
        assert_eq!(normalize_name_path("App/handle_key"), "App/handle_key");
        assert_eq!(normalize_name_path("handle_key"), "handle_key");
        // Starts with uppercase — not a keyword.
        assert_eq!(normalize_name_path("Display for App"), "Display for App");
        // Has colons — not a simple keyword token.
        assert_eq!(normalize_name_path("std::fmt::Display for App"), "std::fmt::Display for App");
    }

    #[test]
    fn normalize_name_path_per_segment() {
        // Each `/`-separated segment is normalised independently.
        assert_eq!(normalize_name_path("impl App/fn handle_key"), "App/handle_key");
    }

    // --- symbol_name_matches with keyword-qualified paths ---

    #[test]
    fn symbol_name_matches_impl_qualified_method() {
        // Serena emits "impl App/handle_key"; ambit's tree has id "mock/app.rs::App/handle_key".
        let s = sym("mock/app.rs::App/handle_key", "handle_key");
        let event = tool_call_targeted("find_symbol", "mock/app.rs", ReadDepth::FullBody, "impl App/handle_key");
        assert!(symbol_name_matches(&s, &event));
    }

    #[test]
    fn symbol_name_matches_impl_block_itself() {
        // Serena emits "impl App"; ambit's tree has id "mock/app.rs::App" (impl block).
        let s = sym("mock/app.rs::App", "App");
        let event = tool_call_targeted("find_symbol", "mock/app.rs", ReadDepth::Signature, "impl App");
        assert!(symbol_name_matches(&s, &event));
    }

    #[test]
    fn symbol_name_matches_trait_impl() {
        // "impl Display for App" → normalised "Display for App"
        let s = sym("mock/app.rs::Display for App", "Display for App");
        let event = tool_call_targeted("find_symbol", "mock/app.rs", ReadDepth::FullBody, "impl Display for App");
        assert!(symbol_name_matches(&s, &event));
    }

    // --- line-range precision: siblings not over-marked ---

    #[test]
    fn mark_targeted_by_lines_does_not_mark_siblings() {
        // Parent impl block spans lines 1..100; two sibling methods inside.
        let method_a = sym_with_lines("f.rs::Foo/method_a", "method_a", 5, 20);
        let method_b = sym_with_lines("f.rs::Foo/method_b", "method_b", 50, 70);
        let impl_block = sym_with_children_and_lines(
            "f.rs::Foo", "Foo", vec![method_a, method_b], 1, 100,
        );

        // Read only covers method_b's range.
        let event = tool_call_lines("Read", "f.rs", ReadDepth::FullBody, 50, 70);
        let mut ledger = ContextLedger::new();

        mark_targeted_symbols(&[impl_block], &event, &mut ledger);

        // The parent is marked (it overlaps), but method_a is NOT.
        assert_eq!(ledger.depth_of("f.rs::Foo"), ReadDepth::FullBody);
        assert_eq!(ledger.depth_of("f.rs::Foo/method_b"), ReadDepth::FullBody);
        assert_eq!(ledger.depth_of("f.rs::Foo/method_a"), ReadDepth::Unseen);
    }

    #[test]
    fn mark_targeted_by_name_still_marks_all_children() {
        // Name-based match on the impl block should still bulk-mark all children.
        let method_a = sym_with_lines("f.rs::Foo/method_a", "method_a", 5, 20);
        let method_b = sym_with_lines("f.rs::Foo/method_b", "method_b", 50, 70);
        let impl_block = sym_with_children_and_lines(
            "f.rs::Foo", "Foo", vec![method_a, method_b], 1, 100,
        );

        // find_symbol("impl Foo", include_body=true) — all children embedded in response.
        let event = tool_call_targeted("find_symbol", "f.rs", ReadDepth::FullBody, "impl Foo");
        let mut ledger = ContextLedger::new();

        mark_targeted_symbols(&[impl_block], &event, &mut ledger);

        assert_eq!(ledger.depth_of("f.rs::Foo"), ReadDepth::FullBody);
        assert_eq!(ledger.depth_of("f.rs::Foo/method_a"), ReadDepth::FullBody);
        assert_eq!(ledger.depth_of("f.rs::Foo/method_b"), ReadDepth::FullBody);
    }

    // --- App method tests ---

    fn test_app(files: Vec<FileSymbols>) -> App {
        let tree = project(files);
        App::new(tree, PathBuf::from("/test/project"), None)
    }

    #[test]
    fn process_agent_event_updates_ledger() {
        let syms = vec![sym("mock/f.rs::alpha", "alpha"), sym("mock/f.rs::beta", "beta")];
        let mut app = test_app(vec![file("mock/f.rs", syms)]);

        let event = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        app.process_agent_event(event);

        assert_eq!(app.ledger.depth_of("mock/f.rs::alpha"), ReadDepth::FullBody);
        assert_eq!(app.ledger.depth_of("mock/f.rs::beta"), ReadDepth::FullBody);
    }

    #[test]
    fn process_agent_event_targeted() {
        let syms = vec![sym("mock/f.rs::alpha", "alpha"), sym("mock/f.rs::beta", "beta")];
        let mut app = test_app(vec![file("mock/f.rs", syms)]);

        let event = tool_call_targeted("find_symbol", "/test/project/mock/f.rs", ReadDepth::FullBody, "beta");
        app.process_agent_event(event);

        assert_eq!(app.ledger.depth_of("mock/f.rs::alpha"), ReadDepth::Unseen);
        assert_eq!(app.ledger.depth_of("mock/f.rs::beta"), ReadDepth::FullBody);
    }

    #[test]
    fn process_agent_event_tracks_agents() {
        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);

        let mut e1 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e1.agent_id = "agent-1".into();
        let mut e2 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e2.agent_id = "agent-2".into();

        app.process_agent_event(e1);
        app.process_agent_event(e2);

        assert_eq!(app.agents_seen.len(), 2);
        assert!(app.agents_seen.contains(&"agent-1".to_string()));
        assert!(app.agents_seen.contains(&"agent-2".to_string()));
    }

    #[test]
    fn cycle_agent_filter_backward_wraps() {
        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);

        let mut e1 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e1.agent_id = "agent-1".into();
        let mut e2 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e2.agent_id = "agent-2".into();

        app.process_agent_event(e1);
        app.process_agent_event(e2);

        // Start at None (All), backward should go to last agent
        assert_eq!(app.agent_filter, None);
        app.cycle_agent_filter_backward();
        assert_eq!(app.agent_filter, Some("agent-2".to_string()));
        app.cycle_agent_filter_backward();
        assert_eq!(app.agent_filter, Some("agent-1".to_string()));
        app.cycle_agent_filter_backward();
        assert_eq!(app.agent_filter, None); // wraps back to All
    }

    #[test]
    fn agent_selection_index_navigates_and_applies() {
        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);

        let mut e1 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e1.agent_id = "agent-1".into();
        let mut e2 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e2.agent_id = "agent-2".into();

        app.process_agent_event(e1);
        app.process_agent_event(e2);
        app.focus = FocusPanel::Stats;

        // Start at 0 (All)
        assert_eq!(app.agent_selection_index, 0);
        assert_eq!(app.agent_filter, None);

        // Move down to first agent
        app.move_agent_selection(1);
        assert_eq!(app.agent_selection_index, 1);
        // Not applied yet
        assert_eq!(app.agent_filter, None);

        // Apply selection
        app.apply_agent_selection();
        assert_eq!(app.agent_filter, Some("agent-1".to_string()));
        assert_eq!(app.agent_selection_index, 1);

        // Move down again and apply
        app.move_agent_selection(1);
        assert_eq!(app.agent_selection_index, 2);
        app.apply_agent_selection();
        assert_eq!(app.agent_filter, Some("agent-2".to_string()));

        // Move back to All and apply
        app.move_agent_selection(-2);
        assert_eq!(app.agent_selection_index, 0);
        app.apply_agent_selection();
        assert_eq!(app.agent_filter, None);
    }

    #[test]
    fn activity_scroll_offset_resets_on_new_event() {
        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);
        app.activity_scroll_offset = 10;

        let e = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        app.process_agent_event(e);

        assert_eq!(app.activity_scroll_offset, 0);
    }

    #[test]
    fn handle_mouse_scroll_routes_by_focus() {
        use crossterm::event::{MouseEvent, MouseEventKind, MouseButton};

        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);

        // Add some activity so scroll offset can increase
        for _ in 0..20 {
            let e = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
            app.process_agent_event(e);
        }

        // Focus activity panel and scroll up
        app.focus = FocusPanel::Activity;
        let scroll_up = MouseEvent {
            kind: MouseEventKind::ScrollUp,
            column: 0,
            row: 0,
            modifiers: crossterm::event::KeyModifiers::empty(),
        };
        app.handle_mouse(scroll_up);
        assert_eq!(app.activity_scroll_offset, 3);

        // Scroll down should decrease offset
        let scroll_down = MouseEvent {
            kind: MouseEventKind::ScrollDown,
            column: 0,
            row: 0,
            modifiers: crossterm::event::KeyModifiers::empty(),
        };
        app.handle_mouse(scroll_down);
        assert_eq!(app.activity_scroll_offset, 0);

        // Focus tree panel — scroll should move tree selection, not activity
        app.focus = FocusPanel::Tree;
        app.activity_scroll_offset = 5;
        app.handle_mouse(scroll_up);
        assert_eq!(app.activity_scroll_offset, 5); // unchanged
    }

    #[test]
    fn rebuild_tree_rows_alphabetical() {
        let app = test_app(vec![
            file("mock/a.rs", vec![sym("mock/a.rs::a", "a")]),
            file("mock/z.rs", vec![sym("mock/z.rs::z", "z")]),
        ]);
        // Alphabetical mode preserves the file insertion order.
        let file_rows: Vec<&str> = app.tree_rows.iter()
            .filter(|r| r.is_file)
            .map(|r| r.display_name.as_str())
            .collect();
        assert_eq!(file_rows, vec!["mock/a.rs", "mock/z.rs"]);
    }

    #[test]
    fn rebuild_tree_rows_by_coverage() {
        let syms_a = vec![sym("mock/a.rs::x", "x")];
        let syms_b = vec![sym("mock/b.rs::y", "y")];
        let mut app = test_app(vec![
            file("mock/a.rs", syms_a),
            file("mock/b.rs", syms_b),
        ]);

        // Mark mock/a.rs as partially covered.
        app.ledger.record("mock/a.rs::x".into(), ReadDepth::FullBody, [0; 32], "ag".into(), 10);
        app.sort_mode = SortMode::ByCoverage;
        app.rebuild_tree_rows();

        let file_rows: Vec<&str> = app.tree_rows.iter()
            .filter(|r| r.is_file)
            .map(|r| r.display_name.as_str())
            .collect();
        // PartiallyCovered (mock/a.rs) sorts before NotCovered (mock/b.rs).
        assert_eq!(file_rows, vec!["mock/a.rs", "mock/b.rs"]);
    }

    #[test]
    fn process_agent_event_populates_agent_tree() {
        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);

        // Simulate main session event (no "agent-" prefix → becomes root).
        let mut e1 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e1.agent_id = "session-main".into();
        e1.label = "Main session".into();
        app.process_agent_event(e1);

        assert_eq!(app.agent_tree.root_id, Some("session-main".to_string()));
        assert!(app.agent_tree.agents.contains_key("session-main"));
        assert_eq!(app.agent_tree.agents["session-main"].label, "Main session");

        // Simulate subagent event (starts with "agent-" → child of root).
        let mut e2 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::Overview);
        e2.agent_id = "agent-abc123".into();
        e2.label = "Explore parser module".into();
        app.process_agent_event(e2);

        assert_eq!(app.agent_tree.agents.len(), 2);
        let sub = &app.agent_tree.agents["agent-abc123"];
        assert_eq!(sub.parent_id, Some("session-main".to_string()));
        assert_eq!(sub.label, "Explore parser module");
    }

    #[test]
    fn flattened_agents_dfs_order() {
        let mut app = test_app(vec![file("mock/f.rs", vec![sym("mock/f.rs::a", "a")])]);

        // Register root + two subagents.
        let mut e1 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::FullBody);
        e1.agent_id = "session-main".into();
        app.process_agent_event(e1);

        let mut e2 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::Overview);
        e2.agent_id = "agent-aaa".into();
        app.process_agent_event(e2);

        let mut e3 = tool_call("Read", "/test/project/mock/f.rs", ReadDepth::Overview);
        e3.agent_id = "agent-bbb".into();
        app.process_agent_event(e3);

        let flat = app.flattened_agents();
        assert_eq!(flat.len(), 3);
        // Root at depth 0.
        assert_eq!(flat[0].0, "session-main");
        assert_eq!(flat[0].1, 0);
        // Subagents at depth 1.
        assert!(flat.iter().any(|(id, d)| id == "agent-aaa" && *d == 1));
        assert!(flat.iter().any(|(id, d)| id == "agent-bbb" && *d == 1));
    }

    #[test]
    fn flattened_agents_empty_when_no_agents() {
        let app = test_app(vec![]);
        assert!(app.flattened_agents().is_empty());
    }

    #[test]
    fn reset_session_clears_ledger_and_agents() {
        use crate::ingest::AgentToolCall;
        use crate::tracking::ReadDepth;
        use std::path::PathBuf;

        let mut app = test_app(vec![]);

        // Populate session state via a fake event.
        let event = AgentToolCall {
            agent_id: "agent-abc".into(),
            tool_name: "Read".into(),
            file_path: None,
            read_depth: ReadDepth::FullBody,
            description: "Read something".to_string(),
            timestamp_str: "2026-01-01T00:00:00Z".to_string(),
            target_symbol: None,
            target_lines: None,
            label: "agent-abc".into(),
        };
        app.process_agent_event(event);

        assert!(!app.agents_seen.is_empty());
        assert!(!app.activity.is_empty());

        // reset_session should clear all live state.
        let original_files_count = app.project_tree.files.len();
        app.reset_session();

        assert!(app.ledger.entries.is_empty());
        assert!(app.activity.is_empty());
        assert!(app.agents_seen.is_empty());
        assert!(app.agent_filter.is_none());
        assert_eq!(app.agent_selection_index, 0);
        // Project tree is preserved.
        assert_eq!(app.project_tree.files.len(), original_files_count);
    }
}
