//! Tree Index Data Model — Phase-90 (T90.1)
//!
//! Core data structures for tree-based document indexing, inspired by PageIndex.
//!
//! # Architecture
//!
//! A document is parsed into a hierarchical tree of sections:
//!
//! ```text
//! TreeDocument
//! ├── TreeMetadata (doc_name, description, page_count, ...)
//! └── TreeNode (root section)
//!     ├── TreeNode (sub-section)
//!     │   ├── TreeNode (leaf)
//!     │   └── TreeNode (leaf)
//!     └── TreeNode (sub-section)
//!         └── TreeNode (leaf)
//! ```
//!
//! Each `TreeNode` carries a `NodeContent` with the full text of that section.
//! The tree structure enables hierarchical retrieval: start from root, LLM selects
//! the most relevant branch, then drills down to leaves.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

// ─── TreeNode ────────────────────────────────────────────────────────────────

/// A node in the document tree structure.
///
/// Each node represents a section/heading in the document with its own content
/// and optionally nested child nodes (sub-sections).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeNode {
    /// Unique node identifier within the document (e.g., "0001", "0002").
    ///
    /// Format follows PageIndex convention: zero-padded 4-digit string.
    pub node_id: String,

    /// Section title (the heading text).
    pub title: String,

    /// Heading level (1 = H1, 2 = H2, ..., 6 = H6).
    ///
    /// Level 0 is reserved for synthetic root nodes.
    pub level: u8,

    /// Node content (the text under this heading, excluding child sections).
    ///
    /// For leaf nodes, this is the complete section text.
    /// For branch nodes, this is the "prefix text" before the first child heading.
    pub content: NodeContent,

    /// Summary of this node's content (generated by LLM or truncated text).
    ///
    /// Used during tree traversal: the LLM reads summaries to decide which
    /// branch to explore, avoiding the need to read full content at every level.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,

    /// Child nodes (sub-sections).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub children: Vec<TreeNode>,

    /// Source line number in the original document (1-based).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line_num: Option<usize>,

    /// Start page number (for PDF-sourced documents).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_page: Option<usize>,

    /// End page number (for PDF-sourced documents).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_page: Option<usize>,

    /// Additional properties (extensible metadata).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub properties: BTreeMap<String, serde_json::Value>,
}

impl TreeNode {
    /// Create a new tree node.
    pub fn new(
        node_id: impl Into<String>,
        title: impl Into<String>,
        level: u8,
    ) -> Self {
        Self {
            node_id: node_id.into(),
            title: title.into(),
            level,
            content: NodeContent::default(),
            summary: None,
            children: Vec::new(),
            line_num: None,
            start_page: None,
            end_page: None,
            properties: BTreeMap::new(),
        }
    }

    /// Set the full text content.
    pub fn with_content(mut self, text: impl Into<String>) -> Self {
        self.content = NodeContent::new(text);
        self
    }

    /// Set the summary.
    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
        self.summary = Some(summary.into());
        self
    }

    /// Set line number.
    pub fn with_line_num(mut self, line_num: usize) -> Self {
        self.line_num = Some(line_num);
        self
    }

    /// Set page range (for PDF sources).
    pub fn with_page_range(mut self, start: usize, end: usize) -> Self {
        self.start_page = Some(start);
        self.end_page = Some(end);
        self
    }

    /// Add a child node.
    pub fn add_child(&mut self, child: TreeNode) {
        self.children.push(child);
    }

    /// Set a property.
    pub fn with_property(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
        self.properties.insert(key.into(), value);
        self
    }

    /// Check if this node is a leaf (no children).
    pub fn is_leaf(&self) -> bool {
        self.children.is_empty()
    }

    /// Count total nodes in this subtree (including self).
    pub fn node_count(&self) -> usize {
        1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
    }

    /// Get the maximum depth of this subtree.
    pub fn depth(&self) -> usize {
        if self.children.is_empty() {
            1
        } else {
            1 + self.children.iter().map(|c| c.depth()).max().unwrap_or(0)
        }
    }

    /// Collect all leaf nodes in this subtree.
    pub fn leaves(&self) -> Vec<&TreeNode> {
        if self.is_leaf() {
            vec![self]
        } else {
            self.children.iter().flat_map(|c| c.leaves()).collect()
        }
    }

    /// Find a node by ID in this subtree (DFS).
    pub fn find_by_id(&self, id: &str) -> Option<&TreeNode> {
        if self.node_id == id {
            return Some(self);
        }
        for child in &self.children {
            if let Some(found) = child.find_by_id(id) {
                return Some(found);
            }
        }
        None
    }

    /// Iterate over all nodes in pre-order (DFS).
    pub fn iter_preorder(&self) -> TreeNodeIter<'_> {
        TreeNodeIter {
            stack: vec![self],
        }
    }
}

/// Pre-order iterator over tree nodes.
pub struct TreeNodeIter<'a> {
    stack: Vec<&'a TreeNode>,
}

impl<'a> Iterator for TreeNodeIter<'a> {
    type Item = &'a TreeNode;

    fn next(&mut self) -> Option<Self::Item> {
        let node = self.stack.pop()?;
        // Push children in reverse order so leftmost child is processed first
        for child in node.children.iter().rev() {
            self.stack.push(child);
        }
        Some(node)
    }
}

// ─── NodeContent ─────────────────────────────────────────────────────────────

/// Content of a tree node.
///
/// Stores the full text of a section and its computed token count.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeContent {
    /// Full text content of this section.
    pub text: String,

    /// Approximate token count (whitespace-split estimate).
    ///
    /// Used for tree thinning decisions and context budget management.
    pub token_count: usize,
}

impl Default for NodeContent {
    fn default() -> Self {
        Self {
            text: String::new(),
            token_count: 0,
        }
    }
}

impl NodeContent {
    /// Create a new NodeContent with automatic token count estimation.
    pub fn new(text: impl Into<String>) -> Self {
        let text = text.into();
        let token_count = estimate_tokens(&text);
        Self { text, token_count }
    }

    /// Check if the content is empty.
    pub fn is_empty(&self) -> bool {
        self.text.is_empty()
    }
}

// ─── TreeMetadata ────────────────────────────────────────────────────────────

/// Metadata about a tree-indexed document.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeMetadata {
    /// Document name (typically the filename without extension).
    pub doc_name: String,

    /// Human-readable description of the document.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Total page count (for PDF documents).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub page_count: Option<usize>,

    /// Source file path (relative or absolute).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_path: Option<String>,

    /// Source format ("markdown", "pdf", "imported").
    #[serde(default = "default_source_format")]
    pub source_format: String,

    /// Creation timestamp (Unix millis).
    pub created_at: i64,

    /// Last update timestamp (Unix millis).
    pub updated_at: i64,

    /// Additional metadata properties.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub properties: BTreeMap<String, serde_json::Value>,
}

fn default_source_format() -> String {
    "markdown".to_string()
}

impl TreeMetadata {
    /// Create metadata for a document.
    pub fn new(doc_name: impl Into<String>) -> Self {
        let now = chrono_now();
        Self {
            doc_name: doc_name.into(),
            description: None,
            page_count: None,
            source_path: None,
            source_format: "markdown".to_string(),
            created_at: now,
            updated_at: now,
            properties: BTreeMap::new(),
        }
    }

    /// Set description.
    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    /// Set page count.
    pub fn with_page_count(mut self, count: usize) -> Self {
        self.page_count = Some(count);
        self
    }

    /// Set source path.
    pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
        self.source_path = Some(path.into());
        self
    }

    /// Set source format.
    pub fn with_source_format(mut self, format: impl Into<String>) -> Self {
        self.source_format = format.into();
        self
    }

    /// Set a property.
    pub fn with_property(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
        self.properties.insert(key.into(), value);
        self
    }

    /// Touch the updated_at timestamp.
    pub fn touch(&mut self) {
        self.updated_at = chrono_now();
    }
}

// ─── TreeDocument ────────────────────────────────────────────────────────────

/// A complete tree-indexed document, combining metadata and tree structure.
///
/// This is the top-level type that represents one indexed document.
/// It contains metadata about the document and one or more root tree nodes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeDocument {
    /// Unique document identifier (UUID).
    pub id: String,

    /// Document metadata.
    pub metadata: TreeMetadata,

    /// Tree structure — one or more root nodes.
    ///
    /// Most documents have a single root, but some may have multiple
    /// top-level sections (e.g., a Markdown file with multiple H1 headings).
    pub structure: Vec<TreeNode>,
}

impl TreeDocument {
    /// Create a new tree document.
    pub fn new(metadata: TreeMetadata, structure: Vec<TreeNode>) -> Self {
        Self {
            id: uuid::Uuid::new_v4().to_string(),
            metadata,
            structure,
        }
    }

    /// Create with a specific ID.
    pub fn with_id(mut self, id: impl Into<String>) -> Self {
        self.id = id.into();
        self
    }

    /// Total node count across all root trees.
    pub fn total_nodes(&self) -> usize {
        self.structure.iter().map(|n| n.node_count()).sum()
    }

    /// Maximum depth across all root trees.
    pub fn max_depth(&self) -> usize {
        self.structure.iter().map(|n| n.depth()).max().unwrap_or(0)
    }

    /// Find a node by ID across all root trees.
    pub fn find_node(&self, node_id: &str) -> Option<&TreeNode> {
        for root in &self.structure {
            if let Some(found) = root.find_by_id(node_id) {
                return Some(found);
            }
        }
        None
    }

    /// Get all leaf nodes across the entire document tree.
    pub fn all_leaves(&self) -> Vec<&TreeNode> {
        self.structure.iter().flat_map(|n| n.leaves()).collect()
    }

    /// Iterate over all nodes in all root trees (pre-order DFS).
    pub fn iter_all_nodes(&self) -> impl Iterator<Item = &TreeNode> {
        self.structure.iter().flat_map(|n| n.iter_preorder())
    }

    /// Get a formatted table of contents string.
    pub fn table_of_contents(&self) -> String {
        let mut toc = String::new();
        for root in &self.structure {
            build_toc(root, &mut toc, 0);
        }
        toc
    }
}

// ─── TreeIndexConfig ─────────────────────────────────────────────────────────

/// Configuration for tree index operations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeIndexConfig {
    /// Minimum token count per node for tree thinning.
    ///
    /// Nodes with fewer tokens than this will have their children merged
    /// into the parent. Default: 100.
    #[serde(default = "default_min_tokens")]
    pub min_tokens_per_node: usize,

    /// Maximum heading level to include in the tree (1-6).
    ///
    /// Headings deeper than this level are merged into their parent.
    /// Default: 6 (include all levels).
    #[serde(default = "default_max_level")]
    pub max_heading_level: u8,

    /// Whether to enable tree thinning (merging small nodes).
    #[serde(default)]
    pub enable_thinning: bool,

    /// Whether to generate summaries for nodes.
    ///
    /// Requires an LLM provider when enabled.
    #[serde(default)]
    pub generate_summaries: bool,

    /// Token threshold for summary generation.
    ///
    /// Nodes with fewer tokens than this use their full text as the "summary".
    /// Default: 200.
    #[serde(default = "default_summary_threshold")]
    pub summary_token_threshold: usize,
}

fn default_min_tokens() -> usize {
    100
}

fn default_max_level() -> u8 {
    6
}

fn default_summary_threshold() -> usize {
    200
}

impl Default for TreeIndexConfig {
    fn default() -> Self {
        Self {
            min_tokens_per_node: default_min_tokens(),
            max_heading_level: default_max_level(),
            enable_thinning: false,
            generate_summaries: false,
            summary_token_threshold: default_summary_threshold(),
        }
    }
}

// ─── Error Types ─────────────────────────────────────────────────────────────

/// Errors that can occur during tree index operations.
#[derive(Debug, thiserror::Error)]
pub enum TreeIndexError {
    /// Document not found.
    #[error("Tree document not found: {0}")]
    DocumentNotFound(String),

    /// Node not found.
    #[error("Tree node not found: {0}")]
    NodeNotFound(String),

    /// Parse error (e.g., invalid Markdown structure).
    #[error("Parse error: {0}")]
    ParseError(String),

    /// Storage error.
    #[error("Storage error: {0}")]
    StorageError(String),

    /// Invalid tree structure (e.g., empty document, broken hierarchy).
    #[error("Invalid tree structure: {0}")]
    InvalidStructure(String),

    /// Serialization/deserialization error.
    #[error("Serialization error: {0}")]
    SerdeError(#[from] serde_json::Error),

    /// Configuration error.
    #[error("Configuration error: {0}")]
    ConfigError(String),
}

/// Result type alias for tree index operations.
pub type TreeIndexResult<T> = Result<T, TreeIndexError>;

// ─── Dynamic Tree Index Types (Phase-95) ─────────────────────────────────────

/// Access statistics for a tree node.
///
/// Tracks retrieval hit count and timestamps.
/// Stored as JSON in `Entity::properties["access_stats"]`.
///
/// Inspired by BriLLM's co-activation counters used for edge weight
/// promotion/demotion decisions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeAccessStats {
    /// Total number of retrieval hits on this node.
    pub hit_count: u64,

    /// Last access timestamp (Unix millis). 0 = never accessed.
    pub last_accessed_at: i64,

    /// First access timestamp (Unix millis). 0 = never accessed.
    pub first_accessed_at: i64,
}

impl Default for NodeAccessStats {
    fn default() -> Self {
        Self {
            hit_count: 0,
            last_accessed_at: 0,
            first_accessed_at: 0,
        }
    }
}

impl NodeAccessStats {
    /// Record one access event at the given timestamp.
    pub fn record_hit(&mut self, now: i64) {
        self.hit_count += 1;
        self.last_accessed_at = now;
        if self.first_accessed_at == 0 {
            self.first_accessed_at = now;
        }
    }

    /// Check if the node has been accessed since `since_ms` (Unix millis).
    pub fn accessed_since(&self, since_ms: i64) -> bool {
        self.last_accessed_at >= since_ms
    }

    /// Check if the node is idle (not accessed within `timeout_secs` from `now_ms`).
    pub fn is_idle(&self, now_ms: i64, timeout_secs: u64) -> bool {
        if self.last_accessed_at == 0 {
            return true; // Never accessed → idle
        }
        let timeout_ms = (timeout_secs as i64) * 1000;
        now_ms - self.last_accessed_at > timeout_ms
    }
}

/// Configuration for dynamic tree index behavior.
///
/// Controls the automated promotion (expand hot nodes) and demotion
/// (merge cold nodes) lifecycle, inspired by BriLLM's three-tier
/// weight model (`PromotionConfig`).
///
/// # BriLLM Analogy
///
/// | BriLLM PromotionConfig | DynamicTreeConfig |
/// |---|---|
/// | `promote_to_independent_threshold` | `promote_hit_threshold` |
/// | `demotion_scan_interval_secs` | `scan_interval_secs` |
/// | `max_independent_edges` | `max_shadow_nodes_per_doc` |
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DynamicTreeConfig {
    /// Enable dynamic tree adaptation.
    ///
    /// When false, the tree structure is static (traditional behavior).
    #[serde(default)]
    pub enabled: bool,

    /// Promotion threshold: cumulative hits on a parent node with shadow
    /// children before expanding (restoring) them to active nodes.
    ///
    /// A high hit count on a merged parent means its content area is
    /// popular → restore fine-grained children for better precision.
    ///
    /// Analogous to BriLLM's `promote_to_independent_threshold`.
    #[serde(default = "default_promote_hit_threshold")]
    pub promote_hit_threshold: u64,

    /// Demotion idle timeout (seconds): if ALL children of a node have
    /// not been accessed within this window, they become candidates
    /// for merging into the parent.
    ///
    /// Analogous to BriLLM's inactivity-based demotion.
    #[serde(default = "default_demote_idle_timeout")]
    pub demote_idle_timeout_secs: u64,

    /// Minimum token count for a subtree to resist demotion.
    ///
    /// Subtrees with total tokens above this value won't be demoted
    /// even if idle, because they carry significant content.
    #[serde(default = "default_demote_min_tokens")]
    pub demote_min_tokens: usize,

    /// Protection level: nodes at heading level ≤ this are never demoted.
    ///
    /// Protects the document's top-level structure from being merged away.
    /// Default: 2 (H1 and H2 are always preserved).
    #[serde(default = "default_preserve_top_levels")]
    pub preserve_top_levels: u8,

    /// Scan interval (seconds): how often to run promotion/demotion checks.
    ///
    /// Analogous to BriLLM's `demotion_scan_interval_secs`.
    #[serde(default = "default_scan_interval")]
    pub scan_interval_secs: u64,

    /// Maximum shadow (dormant) nodes per document.
    ///
    /// Prevents unbounded shadow accumulation. When exceeded, the oldest
    /// shadow nodes are permanently removed (structure no longer restorable).
    #[serde(default = "default_max_shadow_nodes")]
    pub max_shadow_nodes_per_doc: usize,
}

fn default_promote_hit_threshold() -> u64 { 50 }
fn default_demote_idle_timeout() -> u64 { 86400 } // 24 hours
fn default_demote_min_tokens() -> usize { 100 }
fn default_preserve_top_levels() -> u8 { 2 }
fn default_scan_interval() -> u64 { 3600 } // 1 hour
fn default_max_shadow_nodes() -> usize { 500 }

impl Default for DynamicTreeConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            promote_hit_threshold: default_promote_hit_threshold(),
            demote_idle_timeout_secs: default_demote_idle_timeout(),
            demote_min_tokens: default_demote_min_tokens(),
            preserve_top_levels: default_preserve_top_levels(),
            scan_interval_secs: default_scan_interval(),
            max_shadow_nodes_per_doc: default_max_shadow_nodes(),
        }
    }
}

/// Report from a single scan cycle of the DynamicTreeManager.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScanReport {
    /// Number of nodes promoted (shadow → active) in this scan.
    pub promoted: usize,

    /// Number of nodes demoted (active → shadow) in this scan.
    pub demoted: usize,

    /// Number of documents scanned.
    pub scanned_docs: usize,

    /// Scan duration in milliseconds.
    pub scan_duration_ms: u64,
}

/// Report from a single demotion operation.
#[derive(Debug, Clone, Default)]
pub struct DemoteReport {
    /// Number of nodes converted to shadow.
    pub shadow_count: usize,
}

/// Report from a single promotion operation.
#[derive(Debug, Clone, Default)]
pub struct PromoteReport {
    /// Number of nodes restored from shadow to active.
    pub restored_count: usize,
}

// ─── Entity/Relation Type Constants ──────────────────────────────────────────

/// Entity types used in SocialGraphV2 for tree storage.
pub mod tree_entity_types {
    /// A tree-indexed document (root entity).
    pub const TREE_DOC: &str = "tree_doc";

    /// A section node in the tree (active, independently stored).
    pub const TREE_SECTION: &str = "tree_section";

    /// A shadow section node (merged into parent, content preserved for restoration).
    ///
    /// Shadow nodes are created by the Dynamic Tree Manager when a subtree is
    /// demoted (merged into parent). They retain the original content and structure
    /// so the subtree can be restored (promoted) later.
    ///
    /// Analogous to BriLLM's "shared layer" edges that were demoted from independent storage.
    pub const TREE_SECTION_SHADOW: &str = "tree_section_shadow";
}

/// Relation types used in SocialGraphV2 for tree storage.
pub mod tree_relation_types {
    /// Document → root section node.
    pub const HAS_ROOT: &str = "tree_has_root";

    /// Parent section → child section (active relationship).
    pub const HAS_CHILD: &str = "tree_has_child";

    /// Parent section → shadow child (dormant, merged relationship).
    ///
    /// Created when a child node is demoted. Preserves the original
    /// parent-child structure for potential restoration.
    pub const HAS_SHADOW: &str = "tree_has_shadow";
}

/// Property keys used for dynamic tree index metadata.
pub mod tree_property_keys {
    /// Access statistics (JSON of `NodeAccessStats`).
    pub const ACCESS_STATS: &str = "access_stats";

    /// Whether this node has shadow (merged) children.
    pub const HAS_SHADOWS: &str = "has_shadows";

    /// Pre-merge content snapshot (parent's original content before children were merged).
    pub const PRE_MERGE_CONTENT: &str = "pre_merge_content";

    /// Pre-merge token count (parent's original token count before merge).
    pub const PRE_MERGE_TOKEN_COUNT: &str = "pre_merge_token_count";

    /// Original content (shadow node's content before it was merged into parent).
    pub const ORIGINAL_CONTENT: &str = "original_content";

    /// Original token count (shadow node's token count before merge).
    pub const ORIGINAL_TOKEN_COUNT: &str = "original_token_count";

    /// Timestamp when this node was demoted (shadow created).
    pub const DEMOTED_AT: &str = "demoted_at";
}

// ─── Helper Functions ────────────────────────────────────────────────────────

/// Estimate token count using whitespace splitting.
///
/// This is a rough approximation (word count ≈ 0.75 × token count for English).
/// For CJK text, each character is roughly one token.
pub fn estimate_tokens(text: &str) -> usize {
    if text.is_empty() {
        return 0;
    }

    let word_count = text.split_whitespace().count();

    // Count CJK characters (each is ~1 token)
    let cjk_count = text
        .chars()
        .filter(|c| {
            let cp = *c as u32;
            // CJK Unified Ideographs and common ranges
            (0x4E00..=0x9FFF).contains(&cp)
                || (0x3400..=0x4DBF).contains(&cp)
                || (0x3000..=0x303F).contains(&cp)
                || (0xFF00..=0xFFEF).contains(&cp)
                || (0xAC00..=0xD7AF).contains(&cp)  // Korean
                || (0x3040..=0x309F).contains(&cp)  // Hiragana
                || (0x30A0..=0x30FF).contains(&cp)  // Katakana
        })
        .count();

    // For mixed text: words * 1.33 (avg tokens/word) + CJK chars
    let estimated = ((word_count as f64) * 1.33) as usize + cjk_count;
    estimated.max(1) // At least 1 token for non-empty text
}

/// Get current Unix timestamp in milliseconds.
fn chrono_now() -> i64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_millis() as i64
}

/// Build a table-of-contents string recursively.
fn build_toc(node: &TreeNode, buf: &mut String, indent: usize) {
    let prefix = "  ".repeat(indent);
    buf.push_str(&format!("{}{}\n", prefix, node.title));
    for child in &node.children {
        build_toc(child, buf, indent + 1);
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

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

    #[test]
    fn test_tree_node_creation() {
        let node = TreeNode::new("0001", "Introduction", 1)
            .with_content("This is the introduction.")
            .with_summary("Intro summary")
            .with_line_num(1);

        assert_eq!(node.node_id, "0001");
        assert_eq!(node.title, "Introduction");
        assert_eq!(node.level, 1);
        assert_eq!(node.content.text, "This is the introduction.");
        assert_eq!(node.summary.as_deref(), Some("Intro summary"));
        assert_eq!(node.line_num, Some(1));
        assert!(node.is_leaf());
        assert_eq!(node.node_count(), 1);
        assert_eq!(node.depth(), 1);
    }

    #[test]
    fn test_tree_node_children() {
        let mut root = TreeNode::new("0001", "Root", 1)
            .with_content("Root content");

        let child1 = TreeNode::new("0002", "Child 1", 2)
            .with_content("Child 1 content");
        let child2 = TreeNode::new("0003", "Child 2", 2)
            .with_content("Child 2 content");

        root.add_child(child1);
        root.add_child(child2);

        assert!(!root.is_leaf());
        assert_eq!(root.node_count(), 3);
        assert_eq!(root.depth(), 2);
        assert_eq!(root.children.len(), 2);
    }

    #[test]
    fn test_tree_node_find_by_id() {
        let mut root = TreeNode::new("0001", "Root", 1);
        let mut child = TreeNode::new("0002", "Child", 2);
        let grandchild = TreeNode::new("0003", "Grandchild", 3);
        child.add_child(grandchild);
        root.add_child(child);

        assert!(root.find_by_id("0001").is_some());
        assert!(root.find_by_id("0002").is_some());
        assert!(root.find_by_id("0003").is_some());
        assert!(root.find_by_id("0004").is_none());
    }

    #[test]
    fn test_tree_node_iterator() {
        let mut root = TreeNode::new("0001", "Root", 1);
        let mut child1 = TreeNode::new("0002", "Child 1", 2);
        let child2 = TreeNode::new("0003", "Child 2", 2);
        let grandchild = TreeNode::new("0004", "Grandchild", 3);
        child1.add_child(grandchild);
        root.add_child(child1);
        root.add_child(child2);

        let ids: Vec<&str> = root.iter_preorder().map(|n| n.node_id.as_str()).collect();
        assert_eq!(ids, vec!["0001", "0002", "0004", "0003"]);
    }

    #[test]
    fn test_tree_node_leaves() {
        let mut root = TreeNode::new("0001", "Root", 1);
        let child1 = TreeNode::new("0002", "Leaf 1", 2);
        let mut child2 = TreeNode::new("0003", "Branch", 2);
        let leaf2 = TreeNode::new("0004", "Leaf 2", 3);
        child2.add_child(leaf2);
        root.add_child(child1);
        root.add_child(child2);

        let leaf_ids: Vec<&str> = root.leaves().iter().map(|n| n.node_id.as_str()).collect();
        assert_eq!(leaf_ids, vec!["0002", "0004"]);
    }

    #[test]
    fn test_node_content() {
        let content = NodeContent::new("Hello world, this is a test.");
        assert!(!content.is_empty());
        assert!(content.token_count > 0);

        let empty = NodeContent::default();
        assert!(empty.is_empty());
        assert_eq!(empty.token_count, 0);
    }

    #[test]
    fn test_tree_metadata() {
        let meta = TreeMetadata::new("test-doc")
            .with_description("A test document")
            .with_page_count(10)
            .with_source_format("pdf")
            .with_source_path("/docs/test.pdf");

        assert_eq!(meta.doc_name, "test-doc");
        assert_eq!(meta.description.as_deref(), Some("A test document"));
        assert_eq!(meta.page_count, Some(10));
        assert_eq!(meta.source_format, "pdf");
        assert!(meta.created_at > 0);
    }

    #[test]
    fn test_tree_document() {
        let meta = TreeMetadata::new("my-doc");
        let mut root = TreeNode::new("0001", "Intro", 1);
        root.add_child(TreeNode::new("0002", "Sub", 2));

        let doc = TreeDocument::new(meta, vec![root]);

        assert!(!doc.id.is_empty());
        assert_eq!(doc.total_nodes(), 2);
        assert_eq!(doc.max_depth(), 2);
        assert!(doc.find_node("0001").is_some());
        assert!(doc.find_node("0002").is_some());
        assert!(doc.find_node("9999").is_none());
    }

    #[test]
    fn test_tree_document_toc() {
        let mut root = TreeNode::new("0001", "Chapter 1", 1);
        root.add_child(TreeNode::new("0002", "Section 1.1", 2));
        root.add_child(TreeNode::new("0003", "Section 1.2", 2));

        let doc = TreeDocument::new(TreeMetadata::new("doc"), vec![root]);
        let toc = doc.table_of_contents();

        assert!(toc.contains("Chapter 1"));
        assert!(toc.contains("  Section 1.1"));
        assert!(toc.contains("  Section 1.2"));
    }

    #[test]
    fn test_estimate_tokens() {
        assert_eq!(estimate_tokens(""), 0);
        assert!(estimate_tokens("hello") > 0);
        assert!(estimate_tokens("hello world foo bar") > 0);

        // CJK text should count each character
        let cjk_count = estimate_tokens("你好世界");
        assert!(cjk_count >= 4);
    }

    #[test]
    fn test_tree_index_config_default() {
        let config = TreeIndexConfig::default();
        assert_eq!(config.min_tokens_per_node, 100);
        assert_eq!(config.max_heading_level, 6);
        assert!(!config.enable_thinning);
        assert!(!config.generate_summaries);
        assert_eq!(config.summary_token_threshold, 200);
    }

    #[test]
    fn test_tree_document_serialization() {
        let meta = TreeMetadata::new("test");
        let node = TreeNode::new("0001", "Root", 1).with_content("Some text");
        let doc = TreeDocument::new(meta, vec![node]);

        let json = serde_json::to_string(&doc).unwrap();
        let parsed: TreeDocument = serde_json::from_str(&json).unwrap();

        assert_eq!(parsed.id, doc.id);
        assert_eq!(parsed.metadata.doc_name, "test");
        assert_eq!(parsed.structure.len(), 1);
        assert_eq!(parsed.structure[0].content.text, "Some text");
    }

    // ─── Phase-95: Dynamic Tree Index Tests ──────────────────────────

    #[test]
    fn test_node_access_stats_default() {
        let stats = NodeAccessStats::default();
        assert_eq!(stats.hit_count, 0);
        assert_eq!(stats.last_accessed_at, 0);
        assert_eq!(stats.first_accessed_at, 0);
    }

    #[test]
    fn test_node_access_stats_record_hit() {
        let mut stats = NodeAccessStats::default();

        stats.record_hit(1000);
        assert_eq!(stats.hit_count, 1);
        assert_eq!(stats.first_accessed_at, 1000);
        assert_eq!(stats.last_accessed_at, 1000);

        stats.record_hit(2000);
        assert_eq!(stats.hit_count, 2);
        assert_eq!(stats.first_accessed_at, 1000); // unchanged
        assert_eq!(stats.last_accessed_at, 2000);
    }

    #[test]
    fn test_node_access_stats_idle_check() {
        let mut stats = NodeAccessStats::default();

        // Never accessed → always idle
        assert!(stats.is_idle(10_000, 5));

        stats.record_hit(1000);
        // Accessed at 1000ms, now=2000ms, timeout=5s → NOT idle (only 1s elapsed)
        assert!(!stats.is_idle(2000, 5));
        // Accessed at 1000ms, now=7000ms, timeout=5s → idle (6s > 5s)
        assert!(stats.is_idle(7000, 5));
    }

    #[test]
    fn test_node_access_stats_accessed_since() {
        let mut stats = NodeAccessStats::default();
        assert!(!stats.accessed_since(500));

        stats.record_hit(1000);
        assert!(stats.accessed_since(500));
        assert!(stats.accessed_since(1000));
        assert!(!stats.accessed_since(1001));
    }

    #[test]
    fn test_node_access_stats_serialization() {
        let mut stats = NodeAccessStats::default();
        stats.record_hit(12345);
        stats.record_hit(67890);

        let json = serde_json::to_string(&stats).unwrap();
        let parsed: NodeAccessStats = serde_json::from_str(&json).unwrap();

        assert_eq!(parsed.hit_count, 2);
        assert_eq!(parsed.first_accessed_at, 12345);
        assert_eq!(parsed.last_accessed_at, 67890);
    }

    #[test]
    fn test_dynamic_tree_config_default() {
        let config = DynamicTreeConfig::default();
        assert!(!config.enabled);
        assert_eq!(config.promote_hit_threshold, 50);
        assert_eq!(config.demote_idle_timeout_secs, 86400);
        assert_eq!(config.demote_min_tokens, 100);
        assert_eq!(config.preserve_top_levels, 2);
        assert_eq!(config.scan_interval_secs, 3600);
        assert_eq!(config.max_shadow_nodes_per_doc, 500);
    }

    #[test]
    fn test_dynamic_tree_config_serialization() {
        let config = DynamicTreeConfig {
            enabled: true,
            promote_hit_threshold: 100,
            demote_idle_timeout_secs: 3600,
            demote_min_tokens: 200,
            preserve_top_levels: 1,
            scan_interval_secs: 600,
            max_shadow_nodes_per_doc: 1000,
        };

        let json = serde_json::to_string(&config).unwrap();
        let parsed: DynamicTreeConfig = serde_json::from_str(&json).unwrap();

        assert!(parsed.enabled);
        assert_eq!(parsed.promote_hit_threshold, 100);
        assert_eq!(parsed.demote_idle_timeout_secs, 3600);
        assert_eq!(parsed.demote_min_tokens, 200);
        assert_eq!(parsed.preserve_top_levels, 1);
    }

    #[test]
    fn test_scan_report_default() {
        let report = ScanReport::default();
        assert_eq!(report.promoted, 0);
        assert_eq!(report.demoted, 0);
        assert_eq!(report.scanned_docs, 0);
        assert_eq!(report.scan_duration_ms, 0);
    }

    #[test]
    fn test_entity_type_constants() {
        assert_eq!(tree_entity_types::TREE_DOC, "tree_doc");
        assert_eq!(tree_entity_types::TREE_SECTION, "tree_section");
        assert_eq!(tree_entity_types::TREE_SECTION_SHADOW, "tree_section_shadow");
    }

    #[test]
    fn test_relation_type_constants() {
        assert_eq!(tree_relation_types::HAS_ROOT, "tree_has_root");
        assert_eq!(tree_relation_types::HAS_CHILD, "tree_has_child");
        assert_eq!(tree_relation_types::HAS_SHADOW, "tree_has_shadow");
    }

    #[test]
    fn test_property_key_constants() {
        assert_eq!(tree_property_keys::ACCESS_STATS, "access_stats");
        assert_eq!(tree_property_keys::HAS_SHADOWS, "has_shadows");
        assert_eq!(tree_property_keys::PRE_MERGE_CONTENT, "pre_merge_content");
        assert_eq!(tree_property_keys::ORIGINAL_CONTENT, "original_content");
    }
}
