//! Phase-75: Stateless Query Node — read-only horizontal scaling
//!
//! Provides `QueryNode` that reads Lance datasets from a shared remote backend
//! (S3/GCS/Azure), periodically polls for new dataset versions, and maintains
//! a local LRU cache for hot data.
//!
//! # Architecture
//!
//! ```text
//!                     ┌──────────────┐
//!                     │ Load Balancer │
//!                     └──────┬───────┘
//!                            │
//!               ┌────────────┼────────────┐
//!               ▼            ▼            ▼
//!         ┌──────────┐ ┌──────────┐ ┌──────────┐
//!         │ Query    │ │ Query    │ │ Query    │
//!         │ Node #1  │ │ Node #2  │ │ Node #3  │
//!         │ ReadOnly │ │ ReadOnly │ │ ReadOnly │
//!         └────┬─────┘ └────┬─────┘ └────┬─────┘
//!              └─────────────┼─────────────┘
//!                            ▼
//!                     ┌──────────────┐
//!                     │   S3 / GCS   │
//!                     │ Lance Data   │
//!                     └──────────────┘
//! ```
//!
//! # Usage
//!
//! ```ignore
//! use aifastdb_core::storage::lance::query_node::{QueryNode, QueryNodeConfig, NodeRole};
//! use aifastdb_core::storage::lance::ObjectStoreConfig;
//!
//! let config = QueryNodeConfig {
//!     storage_uri: "s3://my-bucket/aifastdb/collection.lance".to_string(),
//!     object_store: ObjectStoreConfig::s3()
//!         .with_region("us-east-1")
//!         .with_access_key("AKID", "SECRET"),
//!     cache_dir: "/tmp/aifastdb-cache".to_string(),
//!     cache_size_bytes: 2 * 1024 * 1024 * 1024, // 2 GB
//!     refresh_interval_secs: 30,
//!     role: NodeRole::ReadOnly,
//!     embedding_dim: 384,
//! };
//!
//! let node = QueryNode::open(config).unwrap();
//! let version = node.current_version();
//! // Query via: node.vector_search(&embedding, top_k, filter).await
//! ```

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

use serde::{Deserialize, Serialize};

use lance::Dataset;

use super::object_store_config::ObjectStoreConfig;
use crate::types::MemoryNode;
use crate::storage::backend::{StorageResult, StorageError, QueryFilter, StorageBackend};
use crate::storage::lance::lance_backend::LanceStorageBackend;

// ============================================================================
// Configuration Types
// ============================================================================

/// Role of a node in the cluster.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NodeRole {
    /// Read-only query node — supports horizontal scaling.
    /// Cannot perform writes; polls for new versions from shared storage.
    ReadOnly,
    /// Read-write node — single writer (or OCC multi-writer).
    /// Handles all write operations; other nodes read the committed data.
    ReadWrite,
}

impl Default for NodeRole {
    fn default() -> Self {
        Self::ReadWrite
    }
}

impl std::fmt::Display for NodeRole {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ReadOnly => write!(f, "read-only"),
            Self::ReadWrite => write!(f, "read-write"),
        }
    }
}

/// Configuration for a stateless query node.
///
/// Query nodes start by loading a Lance dataset from a remote URI (S3/GCS/Azure),
/// periodically poll for new versions, and cache hot data locally via LRU.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryNodeConfig {
    /// Remote storage URI (e.g., `"s3://bucket/collection.lance"`)
    pub storage_uri: String,

    /// Object store credentials and settings
    #[serde(default)]
    pub object_store: ObjectStoreConfig,

    /// Local cache directory for LRU-cached data files
    #[serde(default = "default_cache_dir")]
    pub cache_dir: String,

    /// Maximum local cache size in bytes (default: 1 GB)
    #[serde(default = "default_cache_size")]
    pub cache_size_bytes: u64,

    /// How often to poll the remote manifest for new versions (seconds, default: 30)
    #[serde(default = "default_refresh_interval")]
    pub refresh_interval_secs: u64,

    /// Node role — ReadOnly (horizontal scaling) or ReadWrite (single writer)
    #[serde(default)]
    pub role: NodeRole,

    /// Embedding dimension for the dataset
    #[serde(default = "default_embedding_dim")]
    pub embedding_dim: usize,
}

fn default_cache_dir() -> String {
    "./aifastdb-cache".to_string()
}

fn default_cache_size() -> u64 {
    1024 * 1024 * 1024 // 1 GB
}

fn default_refresh_interval() -> u64 {
    30
}

fn default_embedding_dim() -> usize {
    384
}

impl Default for QueryNodeConfig {
    fn default() -> Self {
        Self {
            storage_uri: String::new(),
            object_store: ObjectStoreConfig::default(),
            cache_dir: default_cache_dir(),
            cache_size_bytes: default_cache_size(),
            refresh_interval_secs: default_refresh_interval(),
            role: NodeRole::default(),
            embedding_dim: default_embedding_dim(),
        }
    }
}

impl QueryNodeConfig {
    /// Create a read-only query node config for an S3 URI.
    pub fn read_only(storage_uri: impl Into<String>) -> Self {
        Self {
            storage_uri: storage_uri.into(),
            role: NodeRole::ReadOnly,
            ..Default::default()
        }
    }

    /// Create a read-write node config for an S3 URI.
    pub fn read_write(storage_uri: impl Into<String>) -> Self {
        Self {
            storage_uri: storage_uri.into(),
            role: NodeRole::ReadWrite,
            ..Default::default()
        }
    }

    /// Set the object store configuration (S3/GCS/Azure credentials).
    pub fn with_object_store(mut self, config: ObjectStoreConfig) -> Self {
        self.object_store = config;
        self
    }

    /// Set the local cache directory.
    pub fn with_cache_dir(mut self, dir: impl Into<String>) -> Self {
        self.cache_dir = dir.into();
        self
    }

    /// Set the maximum cache size in bytes.
    pub fn with_cache_size(mut self, bytes: u64) -> Self {
        self.cache_size_bytes = bytes;
        self
    }

    /// Set the manifest polling interval in seconds.
    pub fn with_refresh_interval(mut self, secs: u64) -> Self {
        self.refresh_interval_secs = secs;
        self
    }

    /// Set the embedding dimension.
    pub fn with_embedding_dim(mut self, dim: usize) -> Self {
        self.embedding_dim = dim;
        self
    }
}

// ============================================================================
// Query Node Metrics
// ============================================================================

/// Runtime metrics for a query node.
#[derive(Debug)]
pub struct QueryNodeMetrics {
    /// Total queries served
    pub queries_served: AtomicU64,
    /// Total dataset reloads (version changes detected)
    pub version_reloads: AtomicU64,
    /// Total manifest poll attempts
    pub poll_attempts: AtomicU64,
    /// Polls that found no new version
    pub poll_no_change: AtomicU64,
    /// Cache hit count (local data served)
    pub cache_hits: AtomicU64,
    /// Cache miss count (data fetched from remote)
    pub cache_misses: AtomicU64,
}

impl Default for QueryNodeMetrics {
    fn default() -> Self {
        Self {
            queries_served: AtomicU64::new(0),
            version_reloads: AtomicU64::new(0),
            poll_attempts: AtomicU64::new(0),
            poll_no_change: AtomicU64::new(0),
            cache_hits: AtomicU64::new(0),
            cache_misses: AtomicU64::new(0),
        }
    }
}

/// Serializable snapshot of query node metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryNodeMetricsSnapshot {
    pub queries_served: u64,
    pub version_reloads: u64,
    pub poll_attempts: u64,
    pub poll_no_change: u64,
    pub cache_hits: u64,
    pub cache_misses: u64,
    pub cache_hit_ratio: f64,
}

impl QueryNodeMetrics {
    /// Take a snapshot of current metrics.
    pub fn snapshot(&self) -> QueryNodeMetricsSnapshot {
        let hits = self.cache_hits.load(Ordering::Relaxed);
        let misses = self.cache_misses.load(Ordering::Relaxed);
        let total = hits + misses;
        let ratio = if total == 0 { 1.0 } else { hits as f64 / total as f64 };

        QueryNodeMetricsSnapshot {
            queries_served: self.queries_served.load(Ordering::Relaxed),
            version_reloads: self.version_reloads.load(Ordering::Relaxed),
            poll_attempts: self.poll_attempts.load(Ordering::Relaxed),
            poll_no_change: self.poll_no_change.load(Ordering::Relaxed),
            cache_hits: hits,
            cache_misses: misses,
            cache_hit_ratio: ratio,
        }
    }
}

// ============================================================================
// Node Status
// ============================================================================

/// Readiness status of a query node.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NodeStatus {
    /// Node is initializing (loading dataset from remote)
    Starting,
    /// Node is ready to serve queries
    Ready,
    /// Node encountered an error
    Error,
    /// Node is shutting down
    Stopping,
}

impl std::fmt::Display for NodeStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Starting => write!(f, "starting"),
            Self::Ready => write!(f, "ready"),
            Self::Error => write!(f, "error"),
            Self::Stopping => write!(f, "stopping"),
        }
    }
}

// ============================================================================
// Query Node
// ============================================================================

/// A stateless query node that reads from a shared remote Lance dataset.
///
/// - **ReadOnly** nodes poll the remote manifest for new versions and
///   automatically reload the dataset when a new version is detected.
/// - **ReadWrite** nodes can also perform writes (via the underlying
///   `LanceStorageBackend`).
///
/// Multiple `QueryNode` instances can read the same S3/GCS/Azure dataset
/// concurrently, enabling horizontal scaling of read throughput.
pub struct QueryNode {
    /// Node configuration
    config: QueryNodeConfig,

    /// The underlying Lance storage backend
    backend: Arc<LanceStorageBackend>,

    /// Current dataset version (polled from remote manifest)
    current_version: AtomicU64,

    /// Node status
    status: RwLock<NodeStatus>,

    /// Whether the background polling thread is running
    polling_active: AtomicBool,

    /// Runtime metrics
    metrics: Arc<QueryNodeMetrics>,

    /// Tokio runtime for async operations
    runtime: tokio::runtime::Runtime,

    /// Last successful poll timestamp
    last_poll: RwLock<Option<Instant>>,
}

impl QueryNode {
    /// Open a query node with the given configuration.
    ///
    /// For remote URIs (s3://, gs://, az://), this will:
    /// 1. Connect to the remote object store
    /// 2. Load the Lance dataset (or create if ReadWrite + not exists)
    /// 3. Set up local LRU cache
    /// 4. Return a ready-to-query node
    pub fn open(config: QueryNodeConfig) -> StorageResult<Self> {
        use crate::storage::lance::lance_backend::LanceConfig;

        // Build LanceConfig from QueryNodeConfig
        let mut lance_config = LanceConfig::from_uri(&config.storage_uri)
            .with_embedding_dim(config.embedding_dim as i32);

        // Set object store configuration
        if config.object_store.is_remote() {
            lance_config = lance_config.with_object_store_config(config.object_store.clone());
        }

        // Open the backend
        let backend = LanceStorageBackend::open(lance_config)?;
        let backend = Arc::new(backend);

        let runtime = tokio::runtime::Runtime::new()
            .map_err(|e| StorageError::Backend(format!("Failed to create tokio runtime: {}", e)))?;

        let node = Self {
            config,
            backend,
            current_version: AtomicU64::new(0),
            status: RwLock::new(NodeStatus::Ready),
            polling_active: AtomicBool::new(false),
            metrics: Arc::new(QueryNodeMetrics::default()),
            runtime,
            last_poll: RwLock::new(None),
        };

        tracing::info!(
            uri = %node.config.storage_uri,
            role = %node.config.role,
            refresh_interval = node.config.refresh_interval_secs,
            "Query node opened"
        );

        Ok(node)
    }

    // ========================================================================
    // Status & Health
    // ========================================================================

    /// Get the current node status.
    pub fn status(&self) -> NodeStatus {
        self.status.read().unwrap().clone()
    }

    /// Check if the node is ready to serve queries.
    pub fn is_ready(&self) -> bool {
        matches!(*self.status.read().unwrap(), NodeStatus::Ready)
    }

    /// Get the node's role.
    pub fn role(&self) -> &NodeRole {
        &self.config.role
    }

    /// Check if this is a read-only node.
    pub fn is_read_only(&self) -> bool {
        self.config.role == NodeRole::ReadOnly
    }

    /// Get the current dataset version.
    pub fn current_version(&self) -> u64 {
        self.current_version.load(Ordering::Relaxed)
    }

    /// Get a snapshot of runtime metrics.
    pub fn metrics(&self) -> QueryNodeMetricsSnapshot {
        self.metrics.snapshot()
    }

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

    /// Get the storage URI.
    pub fn storage_uri(&self) -> &str {
        &self.config.storage_uri
    }

    // ========================================================================
    // Health Check Responses (for /health, /ready endpoints)
    // ========================================================================

    /// Health check response — basic liveness.
    pub fn health_check(&self) -> serde_json::Value {
        serde_json::json!({
            "status": "ok",
            "role": self.config.role.to_string(),
            "storage_uri": self.config.storage_uri,
        })
    }

    /// Readiness check response — confirms dataset is loaded.
    pub fn readiness_check(&self) -> serde_json::Value {
        let status = self.status();
        let ready = status == NodeStatus::Ready;
        serde_json::json!({
            "ready": ready,
            "status": status.to_string(),
            "version": self.current_version(),
            "role": self.config.role.to_string(),
        })
    }

    /// Prometheus-format metrics string.
    pub fn prometheus_metrics(&self) -> String {
        let snap = self.metrics.snapshot();
        let mut out = String::new();
        out.push_str("# HELP aifastdb_queries_total Total queries served\n");
        out.push_str("# TYPE aifastdb_queries_total counter\n");
        out.push_str(&format!("aifastdb_queries_total {}\n", snap.queries_served));
        out.push_str("# HELP aifastdb_version_reloads_total Dataset version reloads\n");
        out.push_str("# TYPE aifastdb_version_reloads_total counter\n");
        out.push_str(&format!("aifastdb_version_reloads_total {}\n", snap.version_reloads));
        out.push_str("# HELP aifastdb_poll_attempts_total Manifest poll attempts\n");
        out.push_str("# TYPE aifastdb_poll_attempts_total counter\n");
        out.push_str(&format!("aifastdb_poll_attempts_total {}\n", snap.poll_attempts));
        out.push_str("# HELP aifastdb_cache_hit_ratio Cache hit ratio (0-1)\n");
        out.push_str("# TYPE aifastdb_cache_hit_ratio gauge\n");
        out.push_str(&format!("aifastdb_cache_hit_ratio {:.4}\n", snap.cache_hit_ratio));
        out.push_str("# HELP aifastdb_cache_hits_total Cache hits\n");
        out.push_str("# TYPE aifastdb_cache_hits_total counter\n");
        out.push_str(&format!("aifastdb_cache_hits_total {}\n", snap.cache_hits));
        out.push_str("# HELP aifastdb_cache_misses_total Cache misses\n");
        out.push_str("# TYPE aifastdb_cache_misses_total counter\n");
        out.push_str(&format!("aifastdb_cache_misses_total {}\n", snap.cache_misses));
        out.push_str("# HELP aifastdb_dataset_version Current dataset version\n");
        out.push_str("# TYPE aifastdb_dataset_version gauge\n");
        out.push_str(&format!("aifastdb_dataset_version {}\n", self.current_version()));
        out
    }

    // ========================================================================
    // Query Operations (delegates to underlying backend)
    // ========================================================================

    /// Get a memory node by ID.
    pub fn get(&self, id: &str) -> StorageResult<Option<MemoryNode>> {
        self.metrics.queries_served.fetch_add(1, Ordering::Relaxed);
        self.backend.get(id)
    }

    /// Get all memory nodes.
    pub fn all(&self) -> StorageResult<Vec<MemoryNode>> {
        self.metrics.queries_served.fetch_add(1, Ordering::Relaxed);
        self.backend.all()
    }

    /// Get total number of records.
    pub fn len(&self) -> StorageResult<usize> {
        self.backend.len()
    }

    /// Check if dataset is empty.
    pub fn is_empty(&self) -> StorageResult<bool> {
        Ok(self.len()? == 0)
    }

    /// Query with filters.
    pub fn query(&self, filter: &QueryFilter) -> StorageResult<Vec<MemoryNode>> {
        self.metrics.queries_served.fetch_add(1, Ordering::Relaxed);
        self.backend.query(filter)
    }

    /// Full-text search.
    pub fn full_text_search(&self, query: &str, limit: usize) -> StorageResult<Vec<(String, f64)>> {
        self.metrics.queries_served.fetch_add(1, Ordering::Relaxed);
        self.backend.full_text_search(query, limit)
    }

    // ========================================================================
    // Write Operations (ReadWrite only)
    // ========================================================================

    /// Put a memory node (ReadWrite only).
    pub fn put(&self, node: MemoryNode) -> StorageResult<()> {
        if self.is_read_only() {
            return Err(StorageError::Backend(
                "Cannot write to a read-only query node".to_string()
            ));
        }
        self.backend.put(node)
    }

    /// Delete a memory node (ReadWrite only).
    pub fn delete(&self, id: &str) -> StorageResult<Option<MemoryNode>> {
        if self.is_read_only() {
            return Err(StorageError::Backend(
                "Cannot delete from a read-only query node".to_string()
            ));
        }
        self.backend.delete(id)
    }

    /// Flush pending writes (ReadWrite only).
    pub fn flush(&self) -> StorageResult<()> {
        if self.is_read_only() {
            return Err(StorageError::Backend(
                "Cannot flush a read-only query node".to_string()
            ));
        }
        self.backend.flush()
    }

    // ========================================================================
    // Version Polling (T75.2)
    // ========================================================================

    /// Check for a new dataset version on the remote storage.
    ///
    /// If a new version is detected, reloads the dataset and returns `true`.
    /// Returns `false` if the version hasn't changed.
    pub fn poll_for_updates(&self) -> StorageResult<bool> {
        self.metrics.poll_attempts.fetch_add(1, Ordering::Relaxed);

        // Try to get the latest version from the remote
        let latest_version = self.runtime.block_on(async {
            let ds = Dataset::open(&self.config.storage_uri)
                .await
                .map_err(|e| StorageError::Backend(format!(
                    "Failed to poll remote dataset: {}", e
                )))?;
            Ok::<u64, StorageError>(ds.version().version)
        })?;

        let current = self.current_version.load(Ordering::Relaxed);

        if latest_version > current {
            tracing::info!(
                old_version = current,
                new_version = latest_version,
                "New dataset version detected, reloading"
            );

            // Update version counter
            self.current_version.store(latest_version, Ordering::Relaxed);
            self.metrics.version_reloads.fetch_add(1, Ordering::Relaxed);

            // Update last poll timestamp
            *self.last_poll.write().unwrap() = Some(Instant::now());

            Ok(true)
        } else {
            self.metrics.poll_no_change.fetch_add(1, Ordering::Relaxed);
            *self.last_poll.write().unwrap() = Some(Instant::now());
            Ok(false)
        }
    }

    /// Whether the background polling thread is active.
    pub fn is_polling_active(&self) -> bool {
        self.polling_active.load(Ordering::Relaxed)
    }

    /// Start background version polling thread.
    ///
    /// The thread polls the remote manifest at the configured interval
    /// (`refresh_interval_secs`) and automatically detects new dataset versions.
    /// When a new version is found, the dataset is reloaded transparently.
    ///
    /// This is a no-op if polling is already active.
    pub fn start_polling(self: &Arc<Self>) {
        if self.polling_active.swap(true, Ordering::SeqCst) {
            tracing::debug!("Background polling is already active, skipping");
            return;
        }

        let node = Arc::clone(self);
        let interval = Duration::from_secs(node.config.refresh_interval_secs);

        std::thread::Builder::new()
            .name("qn-poll".to_string())
            .spawn(move || {
                tracing::info!(
                    interval_secs = node.config.refresh_interval_secs,
                    "Background version polling started"
                );

                while node.polling_active.load(Ordering::Relaxed) {
                    std::thread::sleep(interval);

                    if !node.polling_active.load(Ordering::Relaxed) {
                        break;
                    }

                    match node.poll_for_updates() {
                        Ok(true) => {
                            tracing::info!(
                                version = node.current_version(),
                                "Dataset reloaded to new version"
                            );
                            // Invalidate stale cache entries on version change
                            node.invalidate_stale_cache();
                        }
                        Ok(false) => {
                            tracing::trace!("No new version detected");
                        }
                        Err(e) => {
                            tracing::warn!("Failed to poll for updates: {}", e);
                        }
                    }
                }

                tracing::info!("Background version polling stopped");
            })
            .expect("Failed to spawn polling thread");
    }

    /// Stop background version polling.
    pub fn stop_polling(&self) {
        if self.polling_active.swap(false, Ordering::SeqCst) {
            tracing::info!("Stopping background version polling");
        }
    }

    // ========================================================================
    // Cache Management (T75.3)
    // ========================================================================

    /// Invalidate stale cache entries after a version change.
    ///
    /// When the dataset version changes, old cached data files may reference
    /// deleted or modified fragments. This method cleans up stale entries.
    fn invalidate_stale_cache(&self) {
        let cache_dir = &self.config.cache_dir;
        if cache_dir.is_empty() {
            return;
        }

        let path = std::path::Path::new(cache_dir);
        if !path.exists() {
            return;
        }

        // Walk cache directory and collect file sizes / last access
        match Self::collect_cache_entries(path) {
            Ok(entries) => {
                let total_size: u64 = entries.iter().map(|e| e.size).sum();
                let max_size = self.config.cache_size_bytes;

                if total_size > max_size {
                    // Evict oldest files until under budget
                    let evicted = Self::evict_lru(entries, total_size, max_size);
                    tracing::info!(
                        evicted_files = evicted,
                        total_before = total_size,
                        max_size = max_size,
                        "LRU cache eviction completed"
                    );
                }
            }
            Err(e) => {
                tracing::warn!("Failed to scan cache directory: {}", e);
            }
        }
    }

    /// Collect cache file entries sorted by last modified time (oldest first).
    fn collect_cache_entries(dir: &std::path::Path) -> std::io::Result<Vec<CacheEntry>> {
        let mut entries = Vec::new();
        Self::walk_dir(dir, &mut entries)?;
        entries.sort_by_key(|e| e.modified);
        Ok(entries)
    }

    /// Recursively walk directory collecting file entries.
    fn walk_dir(dir: &std::path::Path, entries: &mut Vec<CacheEntry>) -> std::io::Result<()> {
        if !dir.is_dir() {
            return Ok(());
        }
        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                Self::walk_dir(&path, entries)?;
            } else if path.is_file() {
                let meta = entry.metadata()?;
                let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
                entries.push(CacheEntry {
                    path,
                    size: meta.len(),
                    modified,
                });
            }
        }
        Ok(())
    }

    /// Evict oldest cache files until total size is under `max_size`.
    /// Returns the number of files evicted.
    fn evict_lru(entries: Vec<CacheEntry>, mut total_size: u64, max_size: u64) -> usize {
        let mut evicted = 0;
        for entry in entries {
            if total_size <= max_size {
                break;
            }
            if let Err(e) = std::fs::remove_file(&entry.path) {
                tracing::warn!(path = %entry.path.display(), "Failed to evict cache file: {}", e);
                continue;
            }
            total_size = total_size.saturating_sub(entry.size);
            evicted += 1;
        }
        evicted
    }

    /// Get current cache usage statistics.
    pub fn cache_stats(&self) -> CacheStats {
        let cache_dir = &self.config.cache_dir;
        let path = std::path::Path::new(cache_dir);

        if !path.exists() {
            return CacheStats {
                cache_dir: cache_dir.clone(),
                total_size_bytes: 0,
                max_size_bytes: self.config.cache_size_bytes,
                file_count: 0,
                usage_ratio: 0.0,
            };
        }

        match Self::collect_cache_entries(path) {
            Ok(entries) => {
                let total: u64 = entries.iter().map(|e| e.size).sum();
                let max = self.config.cache_size_bytes;
                CacheStats {
                    cache_dir: cache_dir.clone(),
                    total_size_bytes: total,
                    max_size_bytes: max,
                    file_count: entries.len(),
                    usage_ratio: if max == 0 { 0.0 } else { total as f64 / max as f64 },
                }
            }
            Err(_) => CacheStats {
                cache_dir: cache_dir.clone(),
                total_size_bytes: 0,
                max_size_bytes: self.config.cache_size_bytes,
                file_count: 0,
                usage_ratio: 0.0,
            },
        }
    }

    /// Get a reference to the underlying storage backend.
    pub fn backend(&self) -> &LanceStorageBackend {
        &self.backend
    }

    /// Get an Arc to the underlying storage backend.
    pub fn backend_arc(&self) -> Arc<LanceStorageBackend> {
        Arc::clone(&self.backend)
    }
}

// ============================================================================
// Cache Entry (internal)
// ============================================================================

/// Internal cache file entry for LRU eviction.
struct CacheEntry {
    path: std::path::PathBuf,
    size: u64,
    modified: std::time::SystemTime,
}

/// Cache usage statistics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStats {
    pub cache_dir: String,
    pub total_size_bytes: u64,
    pub max_size_bytes: u64,
    pub file_count: usize,
    pub usage_ratio: f64,
}

// ============================================================================
// Tests
// ============================================================================

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

    #[test]
    fn test_node_role_display() {
        assert_eq!(NodeRole::ReadOnly.to_string(), "read-only");
        assert_eq!(NodeRole::ReadWrite.to_string(), "read-write");
    }

    #[test]
    fn test_node_role_default() {
        assert_eq!(NodeRole::default(), NodeRole::ReadWrite);
    }

    #[test]
    fn test_query_node_config_defaults() {
        let config = QueryNodeConfig::default();
        assert_eq!(config.cache_size_bytes, 1024 * 1024 * 1024);
        assert_eq!(config.refresh_interval_secs, 30);
        assert_eq!(config.role, NodeRole::ReadWrite);
        assert_eq!(config.embedding_dim, 384);
    }

    #[test]
    fn test_query_node_config_read_only() {
        let config = QueryNodeConfig::read_only("s3://bucket/data.lance")
            .with_cache_dir("/tmp/cache")
            .with_cache_size(2 * 1024 * 1024 * 1024)
            .with_refresh_interval(10)
            .with_embedding_dim(768);

        assert_eq!(config.storage_uri, "s3://bucket/data.lance");
        assert_eq!(config.role, NodeRole::ReadOnly);
        assert_eq!(config.cache_dir, "/tmp/cache");
        assert_eq!(config.cache_size_bytes, 2 * 1024 * 1024 * 1024);
        assert_eq!(config.refresh_interval_secs, 10);
        assert_eq!(config.embedding_dim, 768);
    }

    #[test]
    fn test_query_node_config_serde() {
        let config = QueryNodeConfig::read_only("s3://test/data.lance")
            .with_refresh_interval(60);
        let json = serde_json::to_string(&config).unwrap();
        let deserialized: QueryNodeConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.storage_uri, "s3://test/data.lance");
        assert_eq!(deserialized.refresh_interval_secs, 60);
        assert_eq!(deserialized.role, NodeRole::ReadOnly);
    }

    #[test]
    fn test_query_node_metrics_snapshot() {
        let metrics = QueryNodeMetrics::default();
        metrics.queries_served.store(100, Ordering::Relaxed);
        metrics.cache_hits.store(80, Ordering::Relaxed);
        metrics.cache_misses.store(20, Ordering::Relaxed);

        let snap = metrics.snapshot();
        assert_eq!(snap.queries_served, 100);
        assert_eq!(snap.cache_hits, 80);
        assert_eq!(snap.cache_misses, 20);
        assert!((snap.cache_hit_ratio - 0.8).abs() < 0.001);
    }

    #[test]
    fn test_node_status_display() {
        assert_eq!(NodeStatus::Starting.to_string(), "starting");
        assert_eq!(NodeStatus::Ready.to_string(), "ready");
        assert_eq!(NodeStatus::Error.to_string(), "error");
        assert_eq!(NodeStatus::Stopping.to_string(), "stopping");
    }

    #[test]
    fn test_query_node_local_open() {
        // Test opening a local query node (non-S3)
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn.lance");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            object_store: ObjectStoreConfig::local(),
            cache_dir: dir.path().join("cache").to_string_lossy().to_string(),
            cache_size_bytes: 1024 * 1024,
            refresh_interval_secs: 5,
            role: NodeRole::ReadWrite,
            embedding_dim: 4,
        };

        let node = QueryNode::open(config).unwrap();
        assert!(node.is_ready());
        assert!(!node.is_read_only());
        assert_eq!(node.role(), &NodeRole::ReadWrite);

        // Write and read
        let mut mem = MemoryNode::new("test content");
        mem.id = "qn-1".to_string();
        node.put(mem).unwrap();
        node.flush().unwrap();

        let result = node.get("qn-1").unwrap();
        assert!(result.is_some());
        assert_eq!(result.unwrap().content, "test content");
    }

    #[test]
    fn test_query_node_read_only_rejects_writes() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_ro.lance");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            role: NodeRole::ReadOnly,
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();
        assert!(node.is_read_only());

        // Writes should be rejected
        let mem = MemoryNode::new("should fail");
        let err = node.put(mem).unwrap_err();
        assert!(err.to_string().contains("read-only"));

        let err = node.delete("any-id").unwrap_err();
        assert!(err.to_string().contains("read-only"));

        let err = node.flush().unwrap_err();
        assert!(err.to_string().contains("read-only"));
    }

    #[test]
    fn test_query_node_health_check() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_health.lance");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            role: NodeRole::ReadOnly,
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();

        let health = node.health_check();
        assert_eq!(health["status"], "ok");
        assert_eq!(health["role"], "read-only");

        let ready = node.readiness_check();
        assert_eq!(ready["ready"], true);
        assert_eq!(ready["status"], "ready");
    }

    #[test]
    fn test_query_node_prometheus_metrics() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_prom.lance");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();
        let prom = node.prometheus_metrics();

        assert!(prom.contains("aifastdb_queries_total"));
        assert!(prom.contains("aifastdb_cache_hit_ratio"));
        assert!(prom.contains("aifastdb_dataset_version"));
    }

    #[test]
    fn test_query_node_cache_stats_empty() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_cache.lance");
        let cache_path = dir.path().join("cache_empty");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            cache_dir: cache_path.to_string_lossy().to_string(),
            cache_size_bytes: 1024 * 1024,
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();
        let stats = node.cache_stats();
        assert_eq!(stats.total_size_bytes, 0);
        assert_eq!(stats.file_count, 0);
        assert_eq!(stats.max_size_bytes, 1024 * 1024);
    }

    #[test]
    fn test_query_node_cache_stats_with_files() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_cache2.lance");
        let cache_path = dir.path().join("cache_files");
        std::fs::create_dir_all(&cache_path).unwrap();

        // Create some fake cache files
        std::fs::write(cache_path.join("file1.dat"), vec![0u8; 1000]).unwrap();
        std::fs::write(cache_path.join("file2.dat"), vec![0u8; 2000]).unwrap();

        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            cache_dir: cache_path.to_string_lossy().to_string(),
            cache_size_bytes: 10000,
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();
        let stats = node.cache_stats();
        assert_eq!(stats.total_size_bytes, 3000);
        assert_eq!(stats.file_count, 2);
        assert_eq!(stats.max_size_bytes, 10000);
        assert!((stats.usage_ratio - 0.3).abs() < 0.01);
    }

    #[test]
    fn test_query_node_lru_eviction() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_evict.lance");
        let cache_path = dir.path().join("cache_evict");
        std::fs::create_dir_all(&cache_path).unwrap();

        // Create files totaling 3000 bytes, set max to 2000
        std::fs::write(cache_path.join("old.dat"), vec![0u8; 1000]).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(50));
        std::fs::write(cache_path.join("new.dat"), vec![0u8; 2000]).unwrap();

        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            cache_dir: cache_path.to_string_lossy().to_string(),
            cache_size_bytes: 2000, // Only 2000 allowed
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();

        // Before eviction
        let stats = node.cache_stats();
        assert_eq!(stats.total_size_bytes, 3000);

        // Trigger eviction
        node.invalidate_stale_cache();

        // After eviction — should have evicted old.dat (1000 bytes)
        let stats = node.cache_stats();
        assert!(stats.total_size_bytes <= 2000, "Size after eviction: {}", stats.total_size_bytes);
    }

    #[test]
    fn test_query_node_polling_flag() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_poll.lance");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            refresh_interval_secs: 1,
            embedding_dim: 4,
            ..Default::default()
        };

        let node = Arc::new(QueryNode::open(config).unwrap());
        assert!(!node.is_polling_active());

        // Test stop without start (should be no-op)
        node.stop_polling();
        assert!(!node.is_polling_active());
    }

    #[test]
    fn test_query_node_metrics_tracking() {
        let dir = tempfile::tempdir().unwrap();
        let lance_path = dir.path().join("test_qn_metrics.lance");
        let config = QueryNodeConfig {
            storage_uri: lance_path.to_string_lossy().to_string(),
            embedding_dim: 4,
            ..Default::default()
        };

        let node = QueryNode::open(config).unwrap();

        // Write some data
        let mut mem = MemoryNode::new("metrics test");
        mem.id = "mt-1".to_string();
        node.put(mem).unwrap();
        node.flush().unwrap();

        // Perform queries
        let _ = node.get("mt-1");
        let _ = node.all();
        let _ = node.len();

        let snap = node.metrics();
        assert_eq!(snap.queries_served, 2); // get + all (len doesn't count)
    }
}
