use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Instant, SystemTime};

use dashmap::DashMap;
use serde_json::Value;
use tokio::sync::Mutex;
use tracing::debug;

use crate::error::{DaemonError, Result};

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
    path: PathBuf,
    mtime: SystemTime,
    import_path: Option<String>,
}

impl CacheKey {
    fn new(path: &Path, mtime: SystemTime, import_path: Option<&str>) -> Self {
        Self {
            path: path.to_path_buf(),
            mtime,
            import_path: import_path.map(String::from),
        }
    }
}

struct CachedExport {
    json: Value,
    exported_at: Instant,
}

/// Caches `nickel export` subprocess results keyed by file path + mtime.
///
/// First call to a file invokes `nickel export` (~100ms). Subsequent calls
/// with unchanged mtime return the cached JSON (<1ms).
pub struct NclCache {
    cache: DashMap<CacheKey, CachedExport>,
    inflight: DashMap<PathBuf, Arc<Mutex<()>>>,
    stats: CacheStats,
}

struct CacheStats {
    hits: std::sync::atomic::AtomicU64,
    misses: std::sync::atomic::AtomicU64,
}

impl CacheStats {
    fn new() -> Self {
        Self {
            hits: std::sync::atomic::AtomicU64::new(0),
            misses: std::sync::atomic::AtomicU64::new(0),
        }
    }

    fn hit(&self) {
        self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }

    fn miss(&self) {
        self.misses
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    }

    fn hits(&self) -> u64 {
        self.hits.load(std::sync::atomic::Ordering::Relaxed)
    }

    fn misses(&self) -> u64 {
        self.misses.load(std::sync::atomic::Ordering::Relaxed)
    }
}

impl Default for NclCache {
    fn default() -> Self {
        Self::new()
    }
}

impl NclCache {
    pub fn new() -> Self {
        Self {
            cache: DashMap::new(),
            inflight: DashMap::new(),
            stats: CacheStats::new(),
        }
    }

    /// Export a Nickel file to JSON. Returns cached result if mtime unchanged.
    ///
    /// On cache miss, spawns the `nickel export` subprocess on a blocking
    /// thread to avoid stalling the Tokio runtime.
    /// Returns `(json, was_cache_hit)`.
    pub async fn export(&self, path: &Path, import_path: Option<&str>) -> Result<(Value, bool)> {
        let abs_path = if path.is_absolute() {
            path.to_path_buf()
        } else {
            std::env::current_dir()?.join(path)
        };

        let mtime = std::fs::metadata(&abs_path)
            .map_err(|e| DaemonError::NclExport {
                path: abs_path.display().to_string(),
                reason: format!("stat failed: {e}"),
            })?
            .modified()
            .map_err(|e| DaemonError::NclExport {
                path: abs_path.display().to_string(),
                reason: format!("mtime unavailable: {e}"),
            })?;

        let key = CacheKey::new(&abs_path, mtime, import_path);

        if let Some(cached) = self.cache.get(&key) {
            self.stats.hit();
            debug!(
                path = %abs_path.display(),
                age_ms = cached.exported_at.elapsed().as_millis(),
                "cache hit"
            );
            return Ok((cached.json.clone(), true));
        }

        debug!(path = %abs_path.display(), "cache miss — acquiring inflight lock");

        // Acquire per-path lock to coalesce concurrent misses for the same file.
        let lock = self
            .inflight
            .entry(abs_path.clone())
            .or_insert_with(|| Arc::new(Mutex::new(())))
            .clone();
        let _guard = lock.lock().await;

        // Re-check cache after acquiring the lock — another task may have filled it.
        // Return as a hit: the subprocess ran, but not on our behalf.
        if let Some(cached) = self.cache.get(&key) {
            self.stats.hit();
            return Ok((cached.json.clone(), true));
        }

        // Confirmed miss — no cached result exists, we will invoke the subprocess.
        self.stats.miss();

        let export_path = abs_path.clone();
        let export_ip = import_path.map(String::from);
        let result = tokio::task::spawn_blocking(move || {
            run_nickel_export(&export_path, export_ip.as_deref())
        })
        .await
        .map_err(|e| DaemonError::NclExport {
            path: abs_path.display().to_string(),
            reason: format!("spawn_blocking join failed: {e}"),
        });

        // On error: release inflight slot so future attempts can retry.
        // On success: insert into cache BEFORE releasing inflight, so concurrent
        // waiters find the result on their re-check instead of re-running the export.
        match result {
            Err(e) => {
                drop(_guard);
                self.inflight.remove(&abs_path);
                Err(e)
            }
            Ok(Err(e)) => {
                drop(_guard);
                self.inflight.remove(&abs_path);
                Err(e)
            }
            Ok(Ok(json)) => {
                self.cache.insert(
                    key,
                    CachedExport {
                        json: json.clone(),
                        exported_at: Instant::now(),
                    },
                );
                // Release inflight AFTER cache is populated — concurrent waiters
                // will hit the cache on their re-check.
                drop(_guard);
                self.inflight.remove(&abs_path);
                Ok((json, false))
            }
        }
    }

    /// Invalidate all cache entries whose path starts with the given prefix.
    pub fn invalidate_prefix(&self, prefix: &Path) {
        let before = self.cache.len();
        self.cache.retain(|k, _| !k.path.starts_with(prefix));
        let evicted = before - self.cache.len();
        if evicted > 0 {
            debug!(prefix = %prefix.display(), evicted, "cache invalidation");
        }
    }

    /// Invalidate a specific file path (all mtimes).
    ///
    /// Paths from the watcher and API are always resolved to absolute before
    /// calling this. The `debug_assert` catches programming errors in tests.
    pub fn invalidate_file(&self, path: &Path) {
        debug_assert!(path.is_absolute(), "invalidate_file expects absolute path");
        self.cache.retain(|k, _| k.path != path);
    }

    /// Drop all cached entries.
    pub fn invalidate_all(&self) {
        let count = self.cache.len();
        self.cache.clear();
        debug!(count, "full cache invalidation");
    }

    pub fn len(&self) -> usize {
        self.cache.len()
    }

    pub fn is_empty(&self) -> bool {
        self.cache.is_empty()
    }

    pub fn hit_count(&self) -> u64 {
        self.stats.hits()
    }

    pub fn miss_count(&self) -> u64 {
        self.stats.misses()
    }
}

/// Invoke `nickel export --format json` as a subprocess.
fn run_nickel_export(path: &Path, import_path: Option<&str>) -> Result<Value> {
    let mut cmd = Command::new("nickel");
    cmd.args(["export", "--format", "json"]).arg(path);

    if let Some(ip) = import_path {
        cmd.env("NICKEL_IMPORT_PATH", ip);
    } else {
        cmd.env_remove("NICKEL_IMPORT_PATH");
    }

    let output = cmd.output().map_err(|e| DaemonError::NclExport {
        path: path.display().to_string(),
        reason: format!("spawn failed: {e}"),
    })?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(DaemonError::NclExport {
            path: path.display().to_string(),
            reason: stderr.trim().to_string(),
        });
    }

    let json: Value =
        serde_json::from_slice(&output.stdout).map_err(|e| DaemonError::NclExport {
            path: path.display().to_string(),
            reason: format!("JSON parse failed: {e}"),
        })?;

    Ok(json)
}

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

    #[test]
    fn cache_key_equality() {
        let t = SystemTime::now();
        let k1 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path1"));
        let k2 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path1"));
        let k3 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path2"));
        assert_eq!(k1, k2);
        assert_ne!(k1, k3);
    }

    #[tokio::test]
    async fn concurrent_hits_all_served_from_cache() {
        use std::sync::Arc;

        use tempfile::NamedTempFile;

        let cache = Arc::new(NclCache::new());
        let file = NamedTempFile::new().unwrap();
        let path = file.path().to_path_buf();

        // Read the real mtime so the CacheKey matches what export() will compute.
        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
        let key = CacheKey::new(&path, mtime, None);
        cache.cache.insert(
            key,
            CachedExport {
                json: serde_json::json!({"ok": true}),
                exported_at: Instant::now(),
            },
        );

        let handles: Vec<_> = (0..8)
            .map(|_| {
                let c = Arc::clone(&cache);
                let p = path.clone();
                tokio::spawn(async move { c.export(&p, None).await })
            })
            .collect();

        for h in handles {
            let (json, was_hit) = h.await.unwrap().unwrap();
            assert!(was_hit, "pre-populated entry must be returned as hit");
            assert_eq!(json["ok"], true);
        }
        assert_eq!(cache.hit_count(), 8);
        assert_eq!(cache.miss_count(), 0);
    }

    #[test]
    fn invalidate_file_removes_all_mtimes_for_path() {
        let cache = NclCache::new();
        let t1 = SystemTime::now();
        let t2 = t1 + std::time::Duration::from_secs(1);
        let path = Path::new("/abs/target.ncl");

        for mtime in [t1, t2] {
            cache.cache.insert(
                CacheKey::new(path, mtime, None),
                CachedExport {
                    json: serde_json::Value::Null,
                    exported_at: Instant::now(),
                },
            );
        }
        // Unrelated file must survive.
        cache.cache.insert(
            CacheKey::new(Path::new("/abs/other.ncl"), t1, None),
            CachedExport {
                json: serde_json::Value::Null,
                exported_at: Instant::now(),
            },
        );

        assert_eq!(cache.len(), 3);
        cache.invalidate_file(path);
        assert_eq!(cache.len(), 1, "only unrelated entry must remain");
    }

    #[test]
    fn invalidation_by_prefix() {
        let cache = NclCache::new();
        let t = SystemTime::now();

        // Insert fake entries directly
        let key1 = CacheKey::new(Path::new("/project/.ontology/core.ncl"), t, None);
        let key2 = CacheKey::new(Path::new("/project/.ontology/state.ncl"), t, None);
        let key3 = CacheKey::new(Path::new("/project/adrs/adr-001.ncl"), t, None);

        for key in [key1, key2, key3] {
            cache.cache.insert(
                key,
                CachedExport {
                    json: Value::Null,
                    exported_at: Instant::now(),
                },
            );
        }

        assert_eq!(cache.len(), 3);
        cache.invalidate_prefix(Path::new("/project/.ontology"));
        assert_eq!(cache.len(), 1);
    }
}
