//! Call tree construction and processing.
//!
//! This module handles building and manipulating the call tree that traces
//! paths from panic symbols back to user code.

use crate::config::Config;
use crate::heuristics::detect_panic_cause;
use crate::heuristics::is_panic_triggering_function;
use crate::panic_cause::PanicCause;
use crate::project_context::ProjectContext;
use crate::sym::CallGraph;
use dashmap::DashSet;
use rayon::prelude::*;
use rustc_demangle::demangle;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;

/// A node in the call tree representing a function that can lead to the target symbol
#[derive(Debug)]
pub struct CallTreeNode {
    /// Symbol/function name
    pub name: String,
    /// Source file (if available from debug info)
    pub file: Option<String>,
    /// Line number (if available from debug info)
    pub line: Option<u32>,
    /// Column number (if available from debug info)
    pub column: Option<u32>,
    /// Functions that call this one
    pub callers: Vec<CallTreeNode>,
}

impl CallTreeNode {
    /// Create a new root node for a call tree
    pub fn new_root(name: String) -> Self {
        CallTreeNode {
            name,
            file: None,
            line: None,
            column: None,
            callers: Vec::new(),
        }
    }
}

/// Build a call tree by recursively finding callers of the target address.
/// Uses a thread-safe visited set to avoid infinite recursion when there are cycles.
/// Uses pre-computed CallGraph for O(1) lookups instead of re-scanning instructions.
/// Parallelizes exploration of top-level callers, with sequential recursion within each branch.
/// Build a call tree with early filtering during construction.
/// Nodes that would be pruned (not in crate and no crate children) are never created.
pub fn build_call_tree_parallel_filtered(
    call_graph: &CallGraph<'_>,
    target_addr: u64,
    visited: &Arc<DashSet<u64>>,
    project_context: &ProjectContext,
) -> Vec<CallTreeNode> {
    let callers = call_graph.get_callers(target_addr);

    callers
        .par_iter()
        .filter_map(|caller_info| {
            let caller_addr = caller_info.caller_start_address;
            let should_recurse = visited.insert(caller_addr);

            let file = caller_info.caller_file.clone().or(caller_info.file.clone());
            let child_callers = if should_recurse {
                build_call_tree_sequential_filtered(
                    call_graph,
                    caller_addr,
                    visited,
                    project_context,
                )
            } else {
                build_shallow_callers_filtered(call_graph, caller_addr, project_context)
            };

            // Early pruning: skip leaf nodes that aren't in crate source
            if child_callers.is_empty()
                && !file
                    .as_ref()
                    .is_some_and(|f| project_context.is_crate_source(f))
            {
                return None;
            }

            Some(CallTreeNode {
                name: caller_info.caller_name.clone().into_owned(),
                file,
                line: caller_info.line,
                column: caller_info.column,
                callers: child_callers,
            })
        })
        .collect()
}

/// Sequential version with early filtering during construction.
pub fn build_call_tree_sequential_filtered(
    call_graph: &CallGraph<'_>,
    target_addr: u64,
    visited: &Arc<DashSet<u64>>,
    project_context: &ProjectContext,
) -> Vec<CallTreeNode> {
    let callers = call_graph.get_callers(target_addr);

    callers
        .iter()
        .filter_map(|caller_info| {
            let caller_addr = caller_info.caller_start_address;
            let should_recurse = visited.insert(caller_addr);

            let file = caller_info.caller_file.clone().or(caller_info.file.clone());
            let child_callers = if should_recurse {
                build_call_tree_sequential_filtered(
                    call_graph,
                    caller_addr,
                    visited,
                    project_context,
                )
            } else {
                build_shallow_callers_filtered(call_graph, caller_addr, project_context)
            };

            // Early pruning: skip leaf nodes that aren't in crate source
            if child_callers.is_empty()
                && !file
                    .as_ref()
                    .is_some_and(|f| project_context.is_crate_source(f))
            {
                return None;
            }

            Some(CallTreeNode {
                name: caller_info.caller_name.clone().into_owned(),
                file,
                line: caller_info.line,
                column: caller_info.column,
                callers: child_callers,
            })
        })
        .collect()
}

/// Build shallow caller nodes with filtering.
/// Only creates nodes that are in the crate (leaves are filtered).
pub fn build_shallow_callers_filtered(
    call_graph: &CallGraph<'_>,
    target_addr: u64,
    project_context: &ProjectContext,
) -> Vec<CallTreeNode> {
    call_graph
        .get_callers(target_addr)
        .iter()
        .filter_map(|caller_info| {
            let file = caller_info.caller_file.clone().or(caller_info.file.clone());

            // Shallow callers are leaves — only keep if in crate source
            if !file
                .as_ref()
                .is_some_and(|f| project_context.is_crate_source(f))
            {
                return None;
            }

            Some(CallTreeNode {
                name: caller_info.caller_name.clone().into_owned(),
                file,
                line: caller_info.line,
                column: caller_info.column,
                callers: vec![], // No deeper recursion to prevent infinite loops
            })
        })
        .collect()
}

/// A crate code point with its hierarchical children (code points it calls toward panic)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrateCodePoint {
    pub name: String,
    pub file: String,
    pub line: u32,
    /// Column number of the call site (if available from DWARF)
    pub column: Option<u32>,
    /// All detected causes of panic paths through this point
    pub causes: HashSet<PanicCause>,
    /// Code points that this one calls (closer to panic in the call chain)
    pub children: Vec<CrateCodePoint>,
    /// Whether this code point directly calls a panic-triggering function (e.g., unwrap, expect)
    /// vs calling another function that eventually panics
    pub is_direct_panic: bool,
    /// Name of the function called at this code point (for indirect panics)
    /// Used to show "This calls `foo` which may panic" in help messages
    pub called_function: Option<String>,
}

/// Key for identifying a code point: (file, line)
pub type CodePointKey = (String, u32);

/// Info stored for each code point: (function name, column, set of causes, set of child keys, is_direct_panic, called_function)
pub type CodePointInfo = (
    String,
    Option<u32>,
    HashSet<PanicCause>,
    HashSet<CodePointKey>,
    bool,           // is_direct_panic
    Option<String>, // called_function (for indirect panics)
);

/// Map of code points: key -> info
pub type CodePointMap = HashMap<CodePointKey, CodePointInfo>;

/// Extract the full qualified function name from a potentially mangled name.
/// Demangles Rust symbols first, strips generics and hash suffixes.
/// Returns the full path for use in config rules (exact match).
/// E.g., "my_crate::module::TimeStamp::now" -> "my_crate::module::TimeStamp::now"
///       "_ZN3std11collections4hash3set16HashSet$LT$T$GT$3new17h..." -> "std::collections::hash::set::HashSet::new"
///       "simple_function" -> "simple_function"
fn extract_qualified_function_name(full_name: &str) -> String {
    // Demangle the name first
    let demangled = demangle(full_name).to_string();

    // Remove all generic parameters while preserving the rest
    let mut cleaned = String::new();
    let mut depth = 0;
    for c in demangled.chars() {
        match c {
            '<' => depth += 1,
            '>' => depth -= 1,
            _ if depth == 0 => cleaned.push(c),
            _ => {}
        }
    }

    // Collect non-empty segments, only dropping a trailing Rust hash suffix
    let mut segments: Vec<&str> = cleaned
        .split("::")
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .collect();

    // Only strip the last segment if it looks like a Rust hash (e.g., "h1234abcdef")
    // This preserves legitimate names like "h2::send"
    if let Some(last) = segments.last() {
        if last.starts_with('h')
            && last.len() > 1
            && last[1..].chars().all(|c| c.is_ascii_hexdigit())
        {
            segments.pop();
        }
    }

    if segments.is_empty() {
        cleaned.trim().to_string()
    } else {
        segments.join("::")
    }
}

/// Collect crate code points with hierarchy.
/// Returns a list of "root" code points (entry points) with their children.
pub fn collect_crate_code_points_hierarchical(
    node: &CallTreeNode,
    project_context: &ProjectContext,
) -> Vec<CrateCodePoint> {
    // First pass: collect all crate code points and their relationships
    // Map: (file, line) -> (name, cause, set of child keys)
    let mut points: CodePointMap = CodePointMap::new();

    collect_crate_relationships(node, &mut points, None, None, None, project_context);

    // All crate code points should be reported as roots.
    // Each point that can lead to a panic deserves its own entry,
    // regardless of whether it's also called by another crate function.
    // Children show what each function calls (toward the panic).
    let mut roots: Vec<CodePointKey> = points.keys().cloned().collect();
    roots.sort(); // Deterministic ordering

    // Build each root with its own cache so each top-level root keeps a full subtree.
    // (Still caches within a root to avoid repeated rebuilding in that root.)
    fn build_subtree(
        key: &CodePointKey,
        points: &CodePointMap,
        path: &mut HashSet<CodePointKey>,
        cache: &mut HashMap<CodePointKey, CrateCodePoint>,
    ) -> Option<CrateCodePoint> {
        // Prevent cycles only on current DFS path
        if path.contains(key) {
            return None;
        }

        // Return cached result if already built
        // Return a shallow copy WITHOUT children to avoid exponential memory from deep cloning.
        // The full subtree is available at its first occurrence.
        if let Some(cached) = cache.get(key) {
            return Some(CrateCodePoint {
                name: cached.name.clone(),
                file: cached.file.clone(),
                line: cached.line,
                column: cached.column,
                causes: cached.causes.clone(),
                children: vec![], // Don't clone children - they're at first occurrence
                is_direct_panic: cached.is_direct_panic,
                called_function: cached.called_function.clone(),
            });
        }

        path.insert(key.clone());

        let (name, column, causes, child_keys_set, is_direct_panic, called_function) =
            points.get(key)?;
        // Sort child keys for deterministic output
        let mut child_keys: Vec<_> = child_keys_set.iter().cloned().collect();
        child_keys.sort();
        let mut children: Vec<CrateCodePoint> = child_keys
            .iter()
            .filter_map(|child_key| build_subtree(child_key, points, path, cache))
            .collect();
        children.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
        path.remove(key);

        let point = CrateCodePoint {
            name: name.clone(),
            file: key.0.clone(),
            line: key.1,
            column: *column,
            causes: causes.clone(),
            children,
            is_direct_panic: *is_direct_panic,
            called_function: called_function.clone(),
        };

        // Cache for reuse by other parents
        cache.insert(key.clone(), point.clone());
        Some(point)
    }

    roots
        .iter()
        .filter_map(|root| {
            let mut cache: HashMap<CodePointKey, CrateCodePoint> = HashMap::new();
            build_subtree(root, &points, &mut HashSet::new(), &mut cache)
        })
        .collect()
}

/// Collect crate code point relationships by walking the call tree.
/// For each crate code point, records which other crate code points it "calls"
/// (i.e., are closer to panic in the call chain).
///
/// In the CallTreeNode tree, "callers" are functions that CALL this node.
/// So if A.callers contains B, then B calls A.
/// For our output hierarchy: B is the parent (entry point), A is the child (closer to panic).
///
/// Also detects panic causes from function names in the call path.
/// Tracks `immediate_callee` to determine if panic is direct (calling unwrap/expect directly)
/// or indirect (calling a function that eventually panics).
pub fn collect_crate_relationships(
    node: &CallTreeNode,
    points: &mut CodePointMap,
    child_crate_key: Option<CodePointKey>,
    current_cause: Option<PanicCause>,
    immediate_callee: Option<&str>, // Name of function this node calls (toward panic)
    project_context: &ProjectContext,
) {
    // Try to detect panic cause from this node's function name
    let detected_cause = detect_panic_cause(&node.name).or(current_cause);

    // Check if file matches any of the patterns
    let file_matches = node
        .file
        .as_ref()
        .is_some_and(|file| project_context.is_crate_source(file));

    let node_key = if let (Some(file), Some(line)) = (&node.file, &node.line)
        && file_matches
        && *line > 0
    {
        Some((file.clone(), *line))
    } else {
        None
    };

    if let Some(key) = &node_key {
        // Determine if this is a direct panic (immediate callee is a panic-triggering function)
        let is_direct = immediate_callee
            .map(is_panic_triggering_function)
            .unwrap_or(false);

        // For indirect panics, store the called function name for help messages
        let called_fn = if !is_direct {
            immediate_callee.map(extract_qualified_function_name)
        } else {
            None
        };

        // Ensure this point exists in the map and accumulate all causes
        let entry = points.entry(key.clone()).or_insert_with(|| {
            (
                node.name.clone(),
                node.column,
                HashSet::new(),
                HashSet::new(),
                is_direct,
                called_fn.clone(),
            )
        });

        // Update is_direct_panic: true if ANY path through this point is direct
        // (conservative: if one path is direct, user could be calling directly)
        if is_direct {
            entry.4 = true;
            entry.5 = None; // Clear called_function for direct panics
        } else if entry.5.is_none() && called_fn.is_some() {
            // Store called function if not already set
            entry.5 = called_fn;
        }

        // Add this path's cause to the set of causes (if detected)
        if let Some(cause) = &detected_cause {
            entry.2.insert(cause.clone());
        }

        // If there's a child crate code point (closer to panic), add it as a child of this node
        if let Some(child_key) = &child_crate_key {
            entry.3.insert(child_key.clone());
        }
    }

    // When recursing to callers, the current node becomes the child
    // (since callers are further from panic, they are parents in our hierarchy)
    let next_child = node_key.or(child_crate_key);

    for caller in &node.callers {
        collect_crate_relationships(
            caller,
            points,
            next_child.clone(),
            detected_cause.clone(),
            Some(&node.name),
            project_context,
        );
    }
}

/// Collect crate code points without printing.
/// Returns the filtered, deduplicated, and sorted code points along with summary.
///
/// The `workspace_root` parameter is used to resolve relative file paths for inline allow checks.
pub fn collect_crate_code_points(
    node: &CallTreeNode,
    config: &Config,
    project_context: &ProjectContext,
) -> (Vec<CrateCodePoint>, AnalysisSummary) {
    let mut roots = collect_crate_code_points_hierarchical(node, project_context);

    // Assign Unknown cause to leaf points without identified causes
    assign_unknown_causes(&mut roots);

    // Filter out code points with allowed causes
    filter_allowed_causes(&mut roots, config, project_context);

    // Deduplicate roots by (file, line)
    dedupe_crate_points(&mut roots);

    // Sort roots by file then line number
    roots.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));

    let summary = count_crate_points_and_files(&roots);
    (roots, summary)
}

/// Assign `Unknown` cause to points that have no identified causes.
/// This ensures every `CrateCodePoint` has at least one cause, making all
/// points filterable by allow mechanisms (inline comments, config rules).
///
/// Special case: if the point is a direct panic (user code directly calls a
/// panic-triggering function like `panic_fmt`), assign `ExplicitPanic` instead
/// of `Unknown`. This handles `panic!("literal")` in Rust 1.78+ where the macro
/// routes through `panic_fmt` without an intermediate function that
/// `detect_panic_cause` recognises.
fn assign_unknown_causes(points: &mut [CrateCodePoint]) {
    for point in points.iter_mut() {
        assign_unknown_causes(&mut point.children);

        if point.causes.is_empty() {
            if point.is_direct_panic {
                point.causes.insert(PanicCause::ExplicitPanic);
            } else {
                point.causes.insert(PanicCause::Unknown);
            }
        }
    }
}

/// Filter out code points whose causes are ALL allowed (not denied) by config or inline comments.
/// A point is kept if ANY of its causes is denied.
/// Also removes allowed causes from the causes set so only denied causes are displayed.
///
/// The `workspace_root` parameter is used to resolve relative file paths for inline allow checks.
pub fn filter_allowed_causes(
    points: &mut Vec<CrateCodePoint>,
    config: &Config,
    project_context: &ProjectContext,
) {
    use crate::inline_allows::check_inline_allow;

    let workspace_root = Some(std::path::Path::new(project_context.project_root()));

    points.retain_mut(|point| {
        point.causes.retain(|cause| {
            let cause_id = cause.id();

            if check_inline_allow(&point.file, point.line, cause_id, workspace_root) {
                return false;
            }

            if !config.is_denied_at(cause, Some(&point.file), Some(&point.name)) {
                return false;
            }

            if let Some(ref called_fn) = point.called_function {
                if !config.is_denied_at(cause, Some(&point.file), Some(called_fn)) {
                    return false;
                }
            }

            true
        });

        let should_keep = !point.causes.is_empty();

        if should_keep {
            filter_allowed_causes(&mut point.children, config, project_context);
        }

        should_keep
    });
}

/// Complete analysis results ready for output rendering.
/// This is the shared structure used by all output formats (text, JSON, HTML).
#[derive(Debug, Clone)]
pub struct AnalysisResult {
    /// Name of the project/crate
    pub project_name: String,
    /// Root path of the project
    pub project_root: String,
    /// All panic code points found (filtered and deduplicated)
    pub code_points: Vec<CrateCodePoint>,
}

impl AnalysisResult {
    /// Create a new analysis result
    pub fn new(
        project_name: impl Into<String>,
        project_root: impl Into<String>,
        code_points: Vec<CrateCodePoint>,
    ) -> Self {
        Self {
            project_name: project_name.into(),
            project_root: project_root.into(),
            code_points,
        }
    }

    /// Compute the summary from the code points
    pub fn summary(&self) -> AnalysisSummary {
        count_crate_points_and_files(&self.code_points)
    }

    /// Get the number of panic points
    pub fn panic_points(&self) -> usize {
        self.summary().panic_points()
    }
}

/// Summary of analysis results.
/// Uses HashSets internally to avoid double-counting when merging summaries
/// from multiple artifacts (e.g., multi-bin workspaces).
#[derive(Debug, Default, Clone)]
pub struct AnalysisSummary {
    /// Unique panic code points: (file, line)
    points: HashSet<(String, u32)>,
    /// Unique files with panic points
    files: HashSet<String>,
}

impl AnalysisSummary {
    /// Create a new summary from collected points
    pub fn from_points(points: HashSet<(String, u32)>, files: HashSet<String>) -> Self {
        Self { points, files }
    }

    /// Merge another summary into this one (union of sets, no double-counting)
    pub fn add(&mut self, other: &AnalysisSummary) {
        self.points.extend(other.points.iter().cloned());
        self.files.extend(other.files.iter().cloned());
    }

    /// Get the number of unique panic code points
    pub fn panic_points(&self) -> usize {
        self.points.len()
    }

    /// Get the number of unique files with panic points
    pub fn files_affected(&self) -> usize {
        self.files.len()
    }
}

/// Count unique crate code points and files in the hierarchy
fn count_crate_points_and_files(points: &[CrateCodePoint]) -> AnalysisSummary {
    let mut seen_points = HashSet::new();
    let mut seen_files = HashSet::new();
    collect_unique_point_keys_and_files(points, &mut seen_points, &mut seen_files);
    AnalysisSummary {
        points: seen_points,
        files: seen_files,
    }
}

/// Collect unique (file, line) keys and unique files from the hierarchy
fn collect_unique_point_keys_and_files(
    points: &[CrateCodePoint],
    seen_points: &mut HashSet<(String, u32)>,
    seen_files: &mut HashSet<String>,
) {
    for p in points {
        seen_points.insert((p.file.clone(), p.line));
        seen_files.insert(p.file.clone());
        collect_unique_point_keys_and_files(&p.children, seen_points, seen_files);
    }
}

/// Deduplicate crate code points by (file, line), merging children
fn dedupe_crate_points(points: &mut Vec<CrateCodePoint>) {
    // Group by (file, line)
    let mut seen: HashMap<(String, u32), usize> = HashMap::new();
    let mut result: Vec<CrateCodePoint> = Vec::new();

    for point in points.drain(..) {
        let key = (point.file.clone(), point.line);
        if let Some(&idx) = seen.get(&key) {
            // Merge children into existing point
            result[idx].children.extend(point.children);
        } else {
            seen.insert(key, result.len());
            result.push(point);
        }
    }

    // Recursively dedupe children
    for point in &mut result {
        dedupe_crate_points(&mut point.children);
    }

    *points = result;
}

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

    #[test]
    fn test_extract_qualified_function_name() {
        // Full path preserved
        assert_eq!(
            extract_qualified_function_name("my_crate::TimeStamp::now"),
            "my_crate::TimeStamp::now"
        );
        assert_eq!(
            extract_qualified_function_name("std::collections::HashMap::new"),
            "std::collections::HashMap::new"
        );
        assert_eq!(
            extract_qualified_function_name("my_crate::module::init"),
            "my_crate::module::init"
        );
        // Simple function (no ::)
        assert_eq!(
            extract_qualified_function_name("simple_function"),
            "simple_function"
        );
        // Mangled Rust symbol - full demangled path
        assert_eq!(
            extract_qualified_function_name(
                "_ZN3std11collections4hash3set16HashSet$LT$T$GT$3new17ha7a7fdf7dbcd659dE"
            ),
            "std::collections::hash::set::HashSet::new"
        );
        // Short qualified name preserved as-is
        assert_eq!(
            extract_qualified_function_name("Option::unwrap"),
            "Option::unwrap"
        );
        // Legitimate crate names starting with 'h' are preserved (not stripped as hash)
        assert_eq!(
            extract_qualified_function_name("h2::client::send"),
            "h2::client::send"
        );
    }

    #[test]
    fn test_filter_allows_called_function_rule() {
        use crate::config::Config;
        use std::io::Write;

        // Create a temp jonesy.toml with a rule allowing unwrap on my_crate::time::TimeStamp::now
        let dir = tempfile::tempdir().unwrap();
        let toml_path = dir.path().join("jonesy.toml");
        let mut f = std::fs::File::create(&toml_path).unwrap();
        writeln!(
            f,
            "[[rules]]\nfunction = \"my_crate::time::TimeStamp::now\"\nallow = [\"unwrap\"]"
        )
        .unwrap();

        let mut config = Config::with_defaults();
        config.load_from_jones_toml(&toml_path);

        // Code point in "app::run" that calls "my_crate::time::TimeStamp::now" which may unwrap
        let mut points = vec![CrateCodePoint {
            name: "app::run".to_string(),
            file: "src/main.rs".to_string(),
            line: 42,
            column: Some(5),
            causes: vec![PanicCause::Unwrap].into_iter().collect(),
            children: vec![],
            is_direct_panic: false,
            called_function: Some("my_crate::time::TimeStamp::now".to_string()),
        }];

        let ctx = ProjectContext::default();
        filter_allowed_causes(&mut points, &config, &ctx);

        // The point should be filtered out because the rule allows unwrap on that function
        assert!(
            points.is_empty(),
            "Point should be filtered out by called-function allow rule, but found: {points:?}"
        );
    }

    #[test]
    fn test_filter_global_allow_capacity_single_cause() {
        use crate::config::Config;
        use std::io::Write;

        // Create a jonesy.toml with a global (crate-level) allow for "capacity"
        let dir = tempfile::tempdir().unwrap();
        let toml_path = dir.path().join("jonesy.toml");
        let mut f = std::fs::File::create(&toml_path).unwrap();
        writeln!(f, "allow = [\"capacity\"]").unwrap();

        let mut config = Config::with_defaults();
        config.load_from_jones_toml(&toml_path);

        // Code point with only CapacityOverflow cause
        let mut points = vec![CrateCodePoint {
            name: "meshchat::device::DeviceIdentifier::hash".to_string(),
            file: "src/device.rs".to_string(),
            line: 74,
            column: Some(22),
            causes: vec![PanicCause::CapacityOverflow].into_iter().collect(),
            children: vec![],
            is_direct_panic: false,
            called_function: Some("core::hash::Hash::hash".to_string()),
        }];

        let ctx = ProjectContext::default();
        filter_allowed_causes(&mut points, &config, &ctx);

        // The point should be filtered out because capacity is globally allowed
        assert!(
            points.is_empty(),
            "Point should be filtered out by global allow for 'capacity', but found: {points:?}"
        );
    }

    #[test]
    fn test_filter_global_allow_capacity_with_other_cause_remaining() {
        use crate::config::Config;
        use std::io::Write;

        // Create a jonesy.toml with a global (crate-level) allow for "capacity"
        let dir = tempfile::tempdir().unwrap();
        let toml_path = dir.path().join("jonesy.toml");
        let mut f = std::fs::File::create(&toml_path).unwrap();
        writeln!(f, "allow = [\"capacity\"]").unwrap();

        let mut config = Config::with_defaults();
        config.load_from_jones_toml(&toml_path);

        // Code point with BOTH CapacityOverflow and Unknown causes
        // (simulates multiple panic paths through the same code point)
        let mut points = vec![CrateCodePoint {
            name: "meshchat::device::DeviceIdentifier::hash".to_string(),
            file: "src/device.rs".to_string(),
            line: 74,
            column: Some(22),
            causes: vec![PanicCause::CapacityOverflow, PanicCause::Unknown]
                .into_iter()
                .collect(),
            children: vec![],
            is_direct_panic: false,
            called_function: Some("core::hash::Hash::hash".to_string()),
        }];

        let ctx = ProjectContext::default();
        filter_allowed_causes(&mut points, &config, &ctx);

        // The point should remain because Unknown is NOT allowed
        assert_eq!(
            points.len(),
            1,
            "Point should remain because Unknown cause is not allowed"
        );
        // But CapacityOverflow should have been removed from causes
        assert!(
            !points[0].causes.contains(&PanicCause::CapacityOverflow),
            "CapacityOverflow should have been removed"
        );
        assert!(
            points[0].causes.contains(&PanicCause::Unknown),
            "Unknown should remain"
        );
    }

    #[test]
    fn test_filter_keeps_non_matching_called_function() {
        use crate::config::Config;
        use std::io::Write;

        // Rule allows "unwrap" on calls to "my_crate::time::TimeStamp::now"
        let dir = tempfile::tempdir().unwrap();
        let toml_path = dir.path().join("jonesy.toml");
        let mut f = std::fs::File::create(&toml_path).unwrap();
        writeln!(
            f,
            "[[rules]]\nfunction = \"my_crate::time::TimeStamp::now\"\nallow = [\"unwrap\"]"
        )
        .unwrap();

        let mut config = Config::with_defaults();
        config.load_from_jones_toml(&toml_path);

        // Code point calling a DIFFERENT function - should NOT be filtered
        let mut points = vec![CrateCodePoint {
            name: "app::run".to_string(),
            file: "src/main.rs".to_string(),
            line: 42,
            column: Some(5),
            causes: vec![PanicCause::Unwrap].into_iter().collect(),
            children: vec![],
            is_direct_panic: false,
            called_function: Some("other_crate::Config::parse".to_string()),
        }];

        let ctx = ProjectContext::default();
        filter_allowed_causes(&mut points, &config, &ctx);

        // The point should be kept because the rule doesn't match Config::parse
        assert_eq!(
            points.len(),
            1,
            "Point should be kept - rule doesn't match different called function"
        );
    }

    #[test]
    fn test_assign_unknown_causes_non_leaf() {
        let mut points = vec![CrateCodePoint {
            name: "app::run".to_string(),
            file: "src/main.rs".to_string(),
            line: 10,
            column: Some(1),
            causes: HashSet::new(),
            children: vec![CrateCodePoint {
                name: "app::helper".to_string(),
                file: "src/main.rs".to_string(),
                line: 20,
                column: Some(1),
                causes: HashSet::new(),
                children: vec![],
                is_direct_panic: false,
                called_function: None,
            }],
            is_direct_panic: false,
            called_function: None,
        }];

        assign_unknown_causes(&mut points);

        assert!(
            points[0].causes.contains(&PanicCause::Unknown),
            "Non-leaf point with empty causes should get Unknown assigned"
        );
        assert!(
            points[0].children[0].causes.contains(&PanicCause::Unknown),
            "Leaf child with empty causes should get Unknown assigned"
        );
    }

    #[test]
    fn test_filter_unknown_cause_with_allow() {
        use crate::config::Config;
        use std::io::Write;

        let dir = tempfile::tempdir().unwrap();
        let toml_path = dir.path().join("jonesy.toml");
        let mut f = std::fs::File::create(&toml_path).unwrap();
        writeln!(f, "allow = [\"unknown\"]").unwrap();

        let mut config = Config::with_defaults();
        config.load_from_jones_toml(&toml_path);

        let mut points = vec![CrateCodePoint {
            name: "app::run".to_string(),
            file: "src/main.rs".to_string(),
            line: 42,
            column: Some(5),
            causes: vec![PanicCause::Unknown].into_iter().collect(),
            children: vec![],
            is_direct_panic: false,
            called_function: None,
        }];

        let ctx = ProjectContext::default();
        filter_allowed_causes(&mut points, &config, &ctx);

        assert!(
            points.is_empty(),
            "Point with Unknown cause should be filtered out by allow = [\"unknown\"]"
        );
    }
}
