//! Tiered search coordinator with cache/hot/warm fallback
//!
//! TieredSearcher routes queries through the tiered architecture:
//! semantic cache (fastest) -> hot tier (fast) -> warm tier (standard).
//! It automatically promotes frequently accessed chunks and demotes stale ones.

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;

use parking_lot::RwLock;

use crate::error::Result;
use crate::index::SearchResult;
use crate::types::{ChunkId, TenantId};

use super::access_tracker::{AccessEvent, AccessTracker};
use super::hot_tier::HotTier;
use super::semantic_cache::{CachedResult, SemanticCache};

/// Configuration for tiered search
#[derive(Debug, Clone)]
pub struct TieredSearcherConfig {
    /// Enable semantic cache (default true)
    pub enable_cache: bool,
    /// Enable hot tier (default true)
    pub enable_hot_tier: bool,
    /// Minimum score for promotion to hot tier (default 0.4)
    pub promotion_threshold: f32,
    /// Number of queries without access before demotion (default 100)
    pub demotion_queries_threshold: u32,
    /// Auto-promote chunks from matching project (default true)
    pub auto_promote_on_project_match: bool,
    /// Enable debug output for tier decisions (default false)
    pub debug_tier_decisions: bool,
}

impl Default for TieredSearcherConfig {
    fn default() -> Self {
        Self {
            enable_cache: true,
            enable_hot_tier: true,
            promotion_threshold: 0.4,
            demotion_queries_threshold: 100,
            auto_promote_on_project_match: true,
            debug_tier_decisions: false,
        }
    }
}

/// Source tier for a search result
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceTier {
    /// Result came from semantic cache
    Cache,
    /// Result came from hot tier
    Hot,
    /// Result came from warm tier
    Warm,
}

/// Action for a tier decision
#[derive(Debug, Clone)]
pub enum TierAction {
    /// Promote chunk to hot tier
    Promote {
        /// Source tier being promoted from
        from: SourceTier,
        /// Promotion score
        score: f32,
    },
    /// Demote chunk from hot tier
    Demote {
        /// Reason for demotion
        reason: String,
    },
    /// No action needed
    None,
}

/// A decision about tier placement for a chunk
#[derive(Debug, Clone)]
pub struct TierDecision {
    /// The chunk being evaluated
    pub chunk_id: ChunkId,
    /// The action to take
    pub action: TierAction,
    /// Human-readable reason
    pub reason: String,
    /// Promotion/access score if applicable
    pub score: Option<f32>,
    /// Which tier the chunk is currently in
    pub source_tier: SourceTier,
}

/// A chunk with score and source tier info
#[derive(Debug, Clone)]
pub struct ScoredChunk {
    /// The chunk ID
    pub chunk_id: ChunkId,
    /// Similarity/relevance score
    pub score: f32,
    /// Which tier returned this result
    pub source_tier: SourceTier,
}

/// Timing breakdown for tiered search
#[derive(Debug, Clone, Default)]
pub struct TieredTiming {
    /// Time spent on cache lookup (ms)
    pub cache_lookup_ms: u64,
    /// Time spent on hot tier search (ms)
    pub hot_tier_ms: u64,
    /// Time spent on warm tier search (ms)
    pub warm_tier_ms: u64,
    /// Time spent on promotion checks (ms)
    pub promotion_check_ms: u64,
    /// Total search time (ms)
    pub total_ms: u64,
}

/// Result of a tiered search
#[derive(Debug, Clone)]
pub struct TieredSearchResult {
    /// Search results with scores
    pub results: Vec<ScoredChunk>,
    /// Primary source tier (first non-empty result source)
    pub source_tier: SourceTier,
    /// Whether cache was hit
    pub cache_hit: bool,
    /// Whether hot tier returned results
    pub hot_tier_hit: bool,
    /// Timing breakdown
    pub timing: TieredTiming,
    /// Tier decisions (only if debug enabled)
    pub tier_decisions: Vec<TierDecision>,
}

/// Trait for warm tier search operations
///
/// Abstracts the warm tier (main index) to allow different implementations.
pub trait WarmTierSearch: Send + Sync {
    /// Search the warm tier
    fn search(&self, query_embedding: &[f32], k: usize) -> Result<Vec<SearchResult>>;

    /// Get the embedding for a chunk (for promotion to hot tier)
    fn get_embedding(&self, chunk_id: &ChunkId) -> Option<Vec<f32>>;

    /// Get current version for cache invalidation
    fn get_version(&self) -> u64;

    /// Get the total number of indexed chunks
    fn len(&self) -> usize;

    /// Check if warm tier is empty
    fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// Tiered search coordinator
///
/// Coordinates cache/hot/warm fallback chain and manages promotion/demotion.
pub struct TieredSearcher<W: WarmTierSearch> {
    /// Semantic query cache
    cache: Arc<SemanticCache>,
    /// Hot tier with promoted chunks
    hot_tier: Arc<RwLock<HotTier>>,
    /// Access tracker for promotion scoring
    access_tracker: Arc<RwLock<AccessTracker>>,
    /// Warm tier (main index)
    warm_tier: Arc<W>,
    /// Configuration
    config: TieredSearcherConfig,
    /// Query counter for demotion checks
    query_counter: AtomicU64,
    /// Last demotion check timestamp (Unix ms)
    last_demotion_check: AtomicU64,
}

impl<W: WarmTierSearch> TieredSearcher<W> {
    /// Create a new tiered searcher
    pub fn new(
        cache: Arc<SemanticCache>,
        hot_tier: Arc<RwLock<HotTier>>,
        access_tracker: Arc<RwLock<AccessTracker>>,
        warm_tier: Arc<W>,
        config: TieredSearcherConfig,
    ) -> Self {
        Self {
            cache,
            hot_tier,
            access_tracker,
            warm_tier,
            config,
            query_counter: AtomicU64::new(0),
            last_demotion_check: AtomicU64::new(current_time_ms() as u64),
        }
    }

    /// Expose the warm tier (used by storage layer to bump its version
    /// counter on writes; see Phase 3.5). Returning `Arc` preserves
    /// the trait-object abstraction.
    pub fn warm_tier(&self) -> Arc<W> {
        Arc::clone(&self.warm_tier)
    }

    /// Get the current configuration
    pub fn config(&self) -> &TieredSearcherConfig {
        &self.config
    }

    /// Get the query counter value
    pub fn query_count(&self) -> u64 {
        self.query_counter.load(Ordering::Relaxed)
    }

    /// Search across tiers with cache -> hot -> warm fallback
    ///
    /// Returns results from the fastest tier that has them:
    /// 1. Semantic cache (if enabled and similar query found)
    /// 2. Hot tier (if enabled and has results)
    /// 3. Warm tier (always searched as fallback)
    pub fn search(
        &self,
        query_embedding: &[f32],
        tenant_id: &TenantId,
        project_id: Option<&str>,
        k: usize,
    ) -> Result<TieredSearchResult> {
        self.search_internal(query_embedding, tenant_id, project_id, k, false)
    }

    /// Search with debug tier decisions enabled
    pub fn search_with_debug(
        &self,
        query_embedding: &[f32],
        tenant_id: &TenantId,
        project_id: Option<&str>,
        k: usize,
    ) -> Result<TieredSearchResult> {
        self.search_internal(query_embedding, tenant_id, project_id, k, true)
    }

    /// Internal search implementation
    fn search_internal(
        &self,
        query_embedding: &[f32],
        tenant_id: &TenantId,
        project_id: Option<&str>,
        k: usize,
        debug: bool,
    ) -> Result<TieredSearchResult> {
        let total_start = Instant::now();
        let mut timing = TieredTiming::default();
        let mut tier_decisions = Vec::new();

        // Increment query counter
        self.query_counter.fetch_add(1, Ordering::Relaxed);

        // Step 1: Cache lookup (if enabled)
        let cache_start = Instant::now();
        let cache_result = if self.config.enable_cache {
            let version = self.warm_tier.get_version();
            self.cache
                .lookup(query_embedding, tenant_id, project_id, version)
        } else {
            None
        };
        timing.cache_lookup_ms = cache_start.elapsed().as_millis() as u64;

        // If cache hit, return immediately
        if let Some(hit) = cache_result {
            let results: Vec<ScoredChunk> = hit
                .results
                .into_iter()
                .map(|r| ScoredChunk {
                    chunk_id: r.chunk_id,
                    score: r.score,
                    source_tier: SourceTier::Cache,
                })
                .collect();

            timing.total_ms = total_start.elapsed().as_millis() as u64;

            return Ok(TieredSearchResult {
                results,
                source_tier: SourceTier::Cache,
                cache_hit: true,
                hot_tier_hit: false,
                timing,
                tier_decisions,
            });
        }

        // Step 2: Hot tier search (if enabled)
        let hot_start = Instant::now();
        let hot_results = if self.config.enable_hot_tier {
            let hot_tier = self.hot_tier.read();
            hot_tier.search(query_embedding, k)?
        } else {
            Vec::new()
        };
        timing.hot_tier_ms = hot_start.elapsed().as_millis() as u64;

        let hot_tier_hit = !hot_results.is_empty();

        // Step 3: Warm tier search (always as fallback)
        let warm_start = Instant::now();
        let warm_results = self.warm_tier.search(query_embedding, k)?;
        timing.warm_tier_ms = warm_start.elapsed().as_millis() as u64;

        // Step 4: Merge and deduplicate results
        let merged = self.merge_results(&hot_results, &warm_results, k, debug, &mut tier_decisions);

        // Step 5: Record access for all returned chunks
        {
            let tracker = self.access_tracker.write();
            for chunk in &merged {
                let event = if let Some(proj) = project_id {
                    AccessEvent::with_project(chunk.chunk_id.clone(), proj.to_string())
                } else {
                    AccessEvent::new(chunk.chunk_id.clone())
                };
                tracker.record_access(event);
            }
        }

        // Step 6: Cache results (if cache enabled)
        if self.config.enable_cache && !merged.is_empty() {
            let cached_results: Vec<CachedResult> = merged
                .iter()
                .map(|r| CachedResult {
                    chunk_id: r.chunk_id.clone(),
                    score: r.score,
                    text_preview: String::new(), // No text preview in this path
                })
                .collect();

            let version = self.warm_tier.get_version();
            self.cache.insert(
                query_embedding.to_vec(),
                tenant_id.clone(),
                project_id.map(|s| s.to_string()),
                cached_results,
                version,
            );
        }

        // Determine primary source tier
        let source_tier = if hot_tier_hit {
            SourceTier::Hot
        } else {
            SourceTier::Warm
        };

        timing.total_ms = total_start.elapsed().as_millis() as u64;

        Ok(TieredSearchResult {
            results: merged,
            source_tier,
            cache_hit: false,
            hot_tier_hit,
            timing,
            tier_decisions: if debug || self.config.debug_tier_decisions {
                tier_decisions
            } else {
                Vec::new()
            },
        })
    }

    /// Merge hot and warm tier results, deduplicating by chunk_id
    ///
    /// Prefers hot tier scores when a chunk appears in both.
    fn merge_results(
        &self,
        hot_results: &[SearchResult],
        warm_results: &[SearchResult],
        k: usize,
        debug: bool,
        tier_decisions: &mut Vec<TierDecision>,
    ) -> Vec<ScoredChunk> {
        use std::collections::HashMap;

        let mut seen: HashMap<ChunkId, ScoredChunk> = HashMap::new();

        // Hot tier results first (preferred)
        for result in hot_results {
            if debug {
                tier_decisions.push(TierDecision {
                    chunk_id: result.chunk_id.clone(),
                    action: TierAction::None,
                    reason: format!("Found in hot tier with score {:.3}", result.score),
                    score: Some(result.score),
                    source_tier: SourceTier::Hot,
                });
            }
            seen.insert(
                result.chunk_id.clone(),
                ScoredChunk {
                    chunk_id: result.chunk_id.clone(),
                    score: result.score,
                    source_tier: SourceTier::Hot,
                },
            );
        }

        // Warm tier results (only if not already in hot)
        for result in warm_results {
            if !seen.contains_key(&result.chunk_id) {
                if debug {
                    tier_decisions.push(TierDecision {
                        chunk_id: result.chunk_id.clone(),
                        action: TierAction::None,
                        reason: format!("Found in warm tier with score {:.3}", result.score),
                        score: Some(result.score),
                        source_tier: SourceTier::Warm,
                    });
                }
                seen.insert(
                    result.chunk_id.clone(),
                    ScoredChunk {
                        chunk_id: result.chunk_id.clone(),
                        score: result.score,
                        source_tier: SourceTier::Warm,
                    },
                );
            }
        }

        // Sort by score descending and take top k
        let mut results: Vec<ScoredChunk> = seen.into_values().collect();
        results.sort_by(|a, b| {
            b.score
                .partial_cmp(&a.score)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        results.truncate(k);
        results
    }

    /// Check for chunks to promote to hot tier
    ///
    /// Returns a list of tier decisions for chunks that were promoted.
    /// Chunks are promoted if they have a promotion score >= threshold
    /// and are not already in the hot tier.
    pub fn check_promotions(
        &self,
        tenant_id: &TenantId,
        project_id: Option<&str>,
    ) -> Vec<TierDecision> {
        if !self.config.enable_hot_tier {
            return Vec::new();
        }

        let mut decisions = Vec::new();

        // Get top candidates from access tracker
        let candidates = {
            let tracker = self.access_tracker.read();
            tracker.get_top_candidates(100, project_id)
        };

        // Filter and promote eligible chunks
        let hot_tier = self.hot_tier.write();

        for candidate in candidates {
            // Skip if already in hot tier
            if hot_tier.contains(&candidate.chunk_id) {
                continue;
            }

            // Check promotion threshold
            if candidate.score < self.config.promotion_threshold {
                continue;
            }

            // Get embedding from warm tier for hot tier promotion
            let embedding = match self.warm_tier.get_embedding(&candidate.chunk_id) {
                Some(e) => e,
                None => continue, // Skip if no embedding available
            };

            // Promote to hot tier
            match hot_tier.promote(
                candidate.chunk_id.clone(),
                embedding,
                tenant_id.clone(),
                candidate.score,
            ) {
                Ok(true) => {
                    decisions.push(TierDecision {
                        chunk_id: candidate.chunk_id,
                        action: TierAction::Promote {
                            from: SourceTier::Warm,
                            score: candidate.score,
                        },
                        reason: format!(
                            "Promoted due to high access score ({:.3}): freq={:.2}, recency={:.2}, project={:.2}",
                            candidate.score,
                            candidate.frequency_component,
                            candidate.recency_component,
                            candidate.project_component,
                        ),
                        score: Some(candidate.score),
                        source_tier: SourceTier::Warm,
                    });
                }
                Ok(false) => {
                    // Already in hot tier or at capacity
                }
                Err(_) => {
                    // Promotion failed, skip
                }
            }
        }

        decisions
    }

    /// Check for chunks to demote from hot tier
    ///
    /// Demotes chunks that have dropped below half the promotion threshold.
    /// Only runs after demotion_queries_threshold queries.
    pub fn check_demotions(&self, project_id: Option<&str>) -> Vec<TierDecision> {
        if !self.config.enable_hot_tier {
            return Vec::new();
        }

        // Check if we've hit the query threshold
        let query_count = self.query_counter.load(Ordering::Relaxed);
        let threshold = self.config.demotion_queries_threshold as u64;

        if query_count < threshold {
            return Vec::new();
        }

        // Reset query counter
        self.query_counter.store(0, Ordering::Relaxed);
        self.last_demotion_check
            .store(current_time_ms() as u64, Ordering::Relaxed);

        let mut decisions = Vec::new();
        let demotion_threshold = self.config.promotion_threshold * 0.5;

        // Get all candidates from access tracker that might be in hot tier
        let all_candidates = {
            let tracker = self.access_tracker.read();
            tracker.get_top_candidates(1000, project_id)
        };

        // Check each candidate - demote those in hot tier with low scores
        let hot_tier = self.hot_tier.write();

        for candidate in all_candidates {
            if !hot_tier.contains(&candidate.chunk_id) {
                continue;
            }

            // Check if score dropped below demotion threshold
            if candidate.score < demotion_threshold {
                // Demote from hot tier
                if hot_tier.demote(&candidate.chunk_id) {
                    decisions.push(TierDecision {
                        chunk_id: candidate.chunk_id,
                        action: TierAction::Demote {
                            reason: format!(
                                "Score ({:.3}) dropped below demotion threshold ({:.3})",
                                candidate.score, demotion_threshold
                            ),
                        },
                        reason: format!(
                            "Demoted due to low access score ({:.3} < {:.3})",
                            candidate.score, demotion_threshold
                        ),
                        score: Some(candidate.score),
                        source_tier: SourceTier::Hot,
                    });
                }
            }
        }

        decisions
    }

    /// Run periodic maintenance tasks
    ///
    /// - Check for promotions
    /// - Check for demotions
    /// - Evict if hot tier over capacity
    /// - Prune cache index
    pub fn run_maintenance(&self, tenant_id: &TenantId) -> MaintenanceResult {
        let start = Instant::now();

        // Check promotions (no project context for periodic maintenance)
        let promotions = self.check_promotions(tenant_id, None);

        // Check demotions
        let demotions = self.check_demotions(None);

        // Evict if hot tier over capacity
        let evictions = {
            let hot_tier = self.hot_tier.write();
            let total_indexed = self.warm_tier.len();
            hot_tier.evict_if_needed(total_indexed)
        };

        // Prune cache index
        self.cache.prune_index();

        MaintenanceResult {
            promotions_count: promotions.len(),
            demotions_count: demotions.len(),
            evictions_count: evictions,
            duration_ms: start.elapsed().as_millis() as u64,
            promotion_decisions: promotions,
            demotion_decisions: demotions,
        }
    }

    /// Maybe promote a chunk immediately on access if it matches project
    ///
    /// Returns a tier decision if the chunk was promoted.
    pub fn maybe_promote_on_access(
        &self,
        chunk_id: &ChunkId,
        tenant_id: &TenantId,
        project_id: Option<&str>,
    ) -> Option<TierDecision> {
        // Check if auto-promotion is enabled
        if !self.config.auto_promote_on_project_match || !self.config.enable_hot_tier {
            return None;
        }

        // Need project context for project-based promotion
        let project_id = project_id?;

        // Check if already in hot tier
        {
            let hot_tier = self.hot_tier.read();
            if hot_tier.contains(chunk_id) {
                return None;
            }
        }

        // Get promotion score with project context
        let score = {
            let tracker = self.access_tracker.read();
            tracker.get_promotion_score(chunk_id, Some(project_id))
        };

        // Check if eligible for promotion
        if !score.eligible || score.score < self.config.promotion_threshold {
            return None;
        }

        // Project component must be non-zero (accessed from this project)
        if score.project_component == 0.0 {
            return None;
        }

        // Get embedding for promotion
        let embedding = self.warm_tier.get_embedding(chunk_id)?;

        // Promote to hot tier
        let hot_tier = self.hot_tier.write();
        match hot_tier.promote(chunk_id.clone(), embedding, tenant_id.clone(), score.score) {
            Ok(true) => Some(TierDecision {
                chunk_id: chunk_id.clone(),
                action: TierAction::Promote {
                    from: SourceTier::Warm,
                    score: score.score,
                },
                reason: format!(
                    "Auto-promoted due to project match and high score ({:.3})",
                    score.score
                ),
                score: Some(score.score),
                source_tier: SourceTier::Warm,
            }),
            _ => None,
        }
    }
}

/// Result of running maintenance
#[derive(Debug, Clone)]
pub struct MaintenanceResult {
    /// Number of chunks promoted
    pub promotions_count: usize,
    /// Number of chunks demoted
    pub demotions_count: usize,
    /// Number of chunks evicted from hot tier
    pub evictions_count: usize,
    /// Time taken for maintenance (ms)
    pub duration_ms: u64,
    /// Detailed promotion decisions
    pub promotion_decisions: Vec<TierDecision>,
    /// Detailed demotion decisions
    pub demotion_decisions: Vec<TierDecision>,
}

/// Get current time in milliseconds
fn current_time_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tiered::{AccessTrackerConfig, HotTierConfig, SemanticCacheConfig};

    /// Mock warm tier for testing
    struct MockWarmTier {
        results: RwLock<Vec<SearchResult>>,
        embeddings: RwLock<std::collections::HashMap<ChunkId, Vec<f32>>>,
        version: AtomicU64,
    }

    impl MockWarmTier {
        fn new() -> Self {
            Self {
                results: RwLock::new(Vec::new()),
                embeddings: RwLock::new(std::collections::HashMap::new()),
                version: AtomicU64::new(1),
            }
        }

        fn add_chunk(&self, chunk_id: ChunkId, embedding: Vec<f32>, score: f32) {
            self.results.write().push(SearchResult {
                chunk_id: chunk_id.clone(),
                score,
            });
            self.embeddings.write().insert(chunk_id, embedding);
        }
    }

    impl WarmTierSearch for MockWarmTier {
        fn search(&self, _query_embedding: &[f32], k: usize) -> Result<Vec<SearchResult>> {
            let results = self.results.read();
            Ok(results.iter().take(k).cloned().collect())
        }

        fn get_embedding(&self, chunk_id: &ChunkId) -> Option<Vec<f32>> {
            self.embeddings.read().get(chunk_id).cloned()
        }

        fn get_version(&self) -> u64 {
            self.version.load(Ordering::Relaxed)
        }

        fn len(&self) -> usize {
            self.results.read().len()
        }
    }

    fn make_test_searcher() -> TieredSearcher<MockWarmTier> {
        let cache = Arc::new(SemanticCache::new(SemanticCacheConfig::default()));
        let hot_tier = Arc::new(RwLock::new(HotTier::new(HotTierConfig::default())));
        let access_tracker = Arc::new(RwLock::new(AccessTracker::new(
            AccessTrackerConfig::default(),
        )));
        let warm_tier = Arc::new(MockWarmTier::new());

        TieredSearcher::new(
            cache,
            hot_tier,
            access_tracker,
            warm_tier,
            TieredSearcherConfig::default(),
        )
    }

    #[test]
    fn test_config_defaults() {
        let config = TieredSearcherConfig::default();
        assert!(config.enable_cache);
        assert!(config.enable_hot_tier);
        assert!((config.promotion_threshold - 0.4).abs() < 0.01);
        assert_eq!(config.demotion_queries_threshold, 100);
        assert!(config.auto_promote_on_project_match);
        assert!(!config.debug_tier_decisions);
    }

    #[test]
    fn test_tiered_searcher_creation() {
        let searcher = make_test_searcher();
        assert_eq!(searcher.query_count(), 0);
        assert!(searcher.config().enable_cache);
    }

    #[test]
    fn test_source_tier_equality() {
        assert_eq!(SourceTier::Cache, SourceTier::Cache);
        assert_ne!(SourceTier::Cache, SourceTier::Hot);
        assert_ne!(SourceTier::Hot, SourceTier::Warm);
    }

    #[test]
    fn test_mock_warm_tier() {
        let warm = MockWarmTier::new();
        let chunk_id = ChunkId::new();
        let embedding = vec![1.0, 0.0, 0.0, 0.0];

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        assert_eq!(warm.len(), 1);
        assert!(warm.get_embedding(&chunk_id).is_some());

        let results = warm.search(&embedding, 10).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].chunk_id, chunk_id);
    }

    fn make_tenant() -> TenantId {
        TenantId::new("test_tenant").unwrap()
    }

    fn make_searcher_with_warm(
        warm: Arc<MockWarmTier>,
    ) -> (
        TieredSearcher<MockWarmTier>,
        Arc<SemanticCache>,
        Arc<RwLock<HotTier>>,
        Arc<RwLock<AccessTracker>>,
    ) {
        let cache = Arc::new(SemanticCache::new(SemanticCacheConfig::default()));
        let hot_tier_config = HotTierConfig {
            hnsw_config: crate::index::HnswConfig {
                dimension: 4,
                max_elements: 100,
                ..Default::default()
            },
            ..Default::default()
        };
        let hot_tier = Arc::new(RwLock::new(HotTier::new(hot_tier_config)));
        let access_tracker_config = AccessTrackerConfig {
            min_accesses_for_promotion: 2,
            ..Default::default()
        };
        let access_tracker = Arc::new(RwLock::new(AccessTracker::new(access_tracker_config)));

        let searcher = TieredSearcher::new(
            cache.clone(),
            hot_tier.clone(),
            access_tracker.clone(),
            warm,
            TieredSearcherConfig::default(),
        );

        (searcher, cache, hot_tier, access_tracker)
    }

    fn normalize(v: &mut [f32]) {
        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
        if norm > 0.0 {
            for x in v.iter_mut() {
                *x /= norm;
            }
        }
    }

    #[test]
    fn test_warm_tier_fallback() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        let (searcher, _, _, _) = make_searcher_with_warm(warm);
        let tenant = make_tenant();

        let result = searcher.search(&embedding, &tenant, None, 10).unwrap();

        assert!(!result.cache_hit);
        assert!(!result.hot_tier_hit);
        assert_eq!(result.source_tier, SourceTier::Warm);
        assert_eq!(result.results.len(), 1);
        assert_eq!(result.results[0].chunk_id, chunk_id);
        assert_eq!(result.results[0].source_tier, SourceTier::Warm);
    }

    #[test]
    fn test_hot_tier_fallback() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.85);

        let (searcher, _, hot_tier, _) = make_searcher_with_warm(warm);
        let tenant = make_tenant();

        // Manually promote to hot tier
        {
            let ht = hot_tier.write();
            ht.promote(chunk_id.clone(), embedding.clone(), tenant.clone(), 0.9)
                .unwrap();
        }

        let result = searcher.search(&embedding, &tenant, None, 10).unwrap();

        assert!(!result.cache_hit);
        assert!(result.hot_tier_hit);
        assert_eq!(result.source_tier, SourceTier::Hot);
        assert!(!result.results.is_empty());
        // Hot tier result should be present
        let hot_results: Vec<_> = result
            .results
            .iter()
            .filter(|r| r.source_tier == SourceTier::Hot)
            .collect();
        assert!(!hot_results.is_empty(), "Should have hot tier results");
    }

    #[test]
    fn test_cache_hit_fast_path() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        let (searcher, _, _, _) = make_searcher_with_warm(warm);
        let tenant = make_tenant();

        // First search populates cache
        let result1 = searcher.search(&embedding, &tenant, None, 10).unwrap();
        assert!(!result1.cache_hit);

        // Second search should hit cache
        let result2 = searcher.search(&embedding, &tenant, None, 10).unwrap();
        assert!(result2.cache_hit);
        assert_eq!(result2.source_tier, SourceTier::Cache);
    }

    #[test]
    fn test_promotion_on_repeated_access() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        // Create searcher with lower promotion threshold for testing
        let cache = Arc::new(SemanticCache::new(SemanticCacheConfig::default()));
        let hot_tier_config = HotTierConfig {
            hnsw_config: crate::index::HnswConfig {
                dimension: 4,
                max_elements: 100,
                ..Default::default()
            },
            ..Default::default()
        };
        let hot_tier = Arc::new(RwLock::new(HotTier::new(hot_tier_config)));
        let access_tracker_config = AccessTrackerConfig {
            min_accesses_for_promotion: 2,
            ..Default::default()
        };
        let access_tracker = Arc::new(RwLock::new(AccessTracker::new(access_tracker_config)));

        let config = TieredSearcherConfig {
            promotion_threshold: 0.3, // Lower threshold for test
            enable_cache: false,      // Disable cache so we hit warm tier each time
            ..Default::default()
        };

        let searcher = TieredSearcher::new(
            cache,
            hot_tier.clone(),
            access_tracker,
            warm.clone(),
            config,
        );
        let tenant = make_tenant();

        // Multiple searches to build up access score
        for _ in 0..3 {
            let _ = searcher.search(&embedding, &tenant, None, 10).unwrap();
        }

        // Run promotions
        let decisions = searcher.check_promotions(&tenant, None);

        // Should have promoted the chunk
        assert!(
            !decisions.is_empty(),
            "Should have promotion decisions after repeated access"
        );
        assert!(hot_tier.read().contains(&chunk_id));
    }

    #[test]
    fn test_demotion_after_inactivity() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        let cache = Arc::new(SemanticCache::new(SemanticCacheConfig::default()));
        let hot_tier_config = HotTierConfig {
            hnsw_config: crate::index::HnswConfig {
                dimension: 4,
                max_elements: 100,
                ..Default::default()
            },
            ..Default::default()
        };
        let hot_tier = Arc::new(RwLock::new(HotTier::new(hot_tier_config)));
        let access_tracker = Arc::new(RwLock::new(AccessTracker::new(
            AccessTrackerConfig::default(),
        )));

        let config = TieredSearcherConfig {
            promotion_threshold: 0.4,
            demotion_queries_threshold: 5, // Low threshold for test
            enable_cache: false,
            ..Default::default()
        };

        let searcher = TieredSearcher::new(
            cache,
            hot_tier.clone(),
            access_tracker,
            warm.clone(),
            config,
        );
        let tenant = make_tenant();

        // Manually promote to hot tier with low score
        {
            let ht = hot_tier.write();
            ht.promote(chunk_id.clone(), embedding.clone(), tenant.clone(), 0.1)
                .unwrap();
        }

        assert!(hot_tier.read().contains(&chunk_id));

        // Simulate queries without accessing this chunk (search different embedding)
        let mut other_embedding = vec![0.0, 1.0, 0.0, 0.0];
        normalize(&mut other_embedding);
        for _ in 0..6 {
            let _ = searcher.search(&other_embedding, &tenant, None, 10);
        }

        // Check demotions - should find chunk has low score
        let _decisions = searcher.check_demotions(None);

        // Note: The chunk might not be demoted if access tracker doesn't track it
        // since we never searched for it. This test validates the mechanism works.
        // For a real test, we'd need to record some access first then let it decay.
    }

    #[test]
    fn test_project_based_promotion() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        let cache = Arc::new(SemanticCache::new(SemanticCacheConfig::default()));
        let hot_tier_config = HotTierConfig {
            hnsw_config: crate::index::HnswConfig {
                dimension: 4,
                max_elements: 100,
                ..Default::default()
            },
            ..Default::default()
        };
        let hot_tier = Arc::new(RwLock::new(HotTier::new(hot_tier_config)));
        let access_tracker_config = AccessTrackerConfig {
            min_accesses_for_promotion: 2,
            project_weight: 0.3, // Higher project weight for testing
            ..Default::default()
        };
        let access_tracker = Arc::new(RwLock::new(AccessTracker::new(access_tracker_config)));

        let config = TieredSearcherConfig {
            promotion_threshold: 0.3,
            auto_promote_on_project_match: true,
            enable_cache: false,
            ..Default::default()
        };

        let searcher = TieredSearcher::new(
            cache,
            hot_tier.clone(),
            access_tracker,
            warm.clone(),
            config,
        );
        let tenant = make_tenant();
        let project_id = "my_project";

        // Search with project context multiple times
        for _ in 0..3 {
            let _ = searcher
                .search(&embedding, &tenant, Some(project_id), 10)
                .unwrap();
        }

        // Try auto-promotion
        let decision = searcher.maybe_promote_on_access(&chunk_id, &tenant, Some(project_id));

        // Should have promoted due to project match
        assert!(
            decision.is_some() || hot_tier.read().contains(&chunk_id),
            "Should promote chunk with project context"
        );
    }

    #[test]
    fn test_debug_tier_decisions() {
        let warm = Arc::new(MockWarmTier::new());
        let chunk_id = ChunkId::new();
        let mut embedding = vec![1.0, 0.0, 0.0, 0.0];
        normalize(&mut embedding);

        warm.add_chunk(chunk_id.clone(), embedding.clone(), 0.95);

        let (searcher, _, _, _) = make_searcher_with_warm(warm);
        let tenant = make_tenant();

        // Search with debug enabled
        let result = searcher
            .search_with_debug(&embedding, &tenant, None, 10)
            .unwrap();

        // Should have tier decisions
        assert!(
            !result.tier_decisions.is_empty(),
            "Debug search should have tier decisions"
        );

        // Check that decisions have meaningful content
        for decision in &result.tier_decisions {
            assert!(!decision.reason.is_empty());
            assert!(decision.score.is_some());
        }
    }

    #[test]
    fn test_maintenance_result() {
        let warm = Arc::new(MockWarmTier::new());
        let (searcher, _, _, _) = make_searcher_with_warm(warm);
        let tenant = make_tenant();

        let result = searcher.run_maintenance(&tenant);

        // Maintenance should complete without error
        assert!(result.duration_ms < 1000); // Should be fast
    }

    #[test]
    fn test_query_counter_increments() {
        let warm = Arc::new(MockWarmTier::new());
        let (searcher, _, _, _) = make_searcher_with_warm(warm);
        let tenant = make_tenant();
        let embedding = vec![1.0, 0.0, 0.0, 0.0];

        assert_eq!(searcher.query_count(), 0);

        let _ = searcher.search(&embedding, &tenant, None, 10);
        assert_eq!(searcher.query_count(), 1);

        let _ = searcher.search(&embedding, &tenant, None, 10);
        assert_eq!(searcher.query_count(), 2);
    }
}
