//! Cached `nickel export --format json` wrapper.
//!
//! Spawns `nickel export` as a subprocess and caches the result keyed by
//! canonical file path + last-modified time. Cache entries are invalidated
//! automatically when the source `.ncl` file is newer than the cached entry.

use crate::protocol::ToolError;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::process::Command;

/// Per-path cache entry: the mtime at which the value was computed and the
/// parsed JSON value.
#[derive(Clone)]
struct Entry {
    mtime: SystemTime,
    value: Arc<serde_json::Value>,
}

/// Thread-safe, in-process cache for `nickel export` results.
///
/// Wrap in `Arc<NclCache>` and share across async handlers — the inner
/// `RwLock` allows concurrent reads and serialises writes.
pub struct NclCache {
    store: RwLock<HashMap<PathBuf, Entry>>,
    import_path: Option<Arc<str>>,
}

impl NclCache {
    pub fn new() -> Self {
        Self { store: RwLock::new(HashMap::new()), import_path: None }
    }

    pub fn with_import_path(import_path: impl Into<Arc<str>>) -> Self {
        Self { store: RwLock::new(HashMap::new()), import_path: Some(import_path.into()) }
    }

    /// Return the JSON value for `path`, re-exporting only when the file's
    /// mtime has changed since the last export.
    ///
    /// `extra_path` is prepended to the cache's global import path for this
    /// export only — use it to add workspace-specific directories so relative
    /// Nickel imports like `"infra/lib/..."` resolve correctly per workspace.
    pub async fn get_with_path(
        &self,
        path: &Path,
        extra_path: Option<&str>,
    ) -> Result<Arc<serde_json::Value>, ToolError> {
        let canonical = path
            .canonicalize()
            .map_err(|e| ToolError::not_found(format!("{}: {e}", path.display())))?;

        let mtime = file_mtime(&canonical).await?;

        // Fast path: read lock, check freshness.
        {
            let store = self.store.read();
            if let Some(entry) = store.get(&canonical) {
                if entry.mtime == mtime {
                    tracing::debug!(path = %canonical.display(), "ncl cache hit");
                    return Ok(Arc::clone(&entry.value));
                }
            }
        }

        // Slow path: build effective import path, export, then write.
        let effective_ip: Option<String> = match (extra_path, self.import_path.as_deref()) {
            (Some(extra), Some(base)) => Some(format!("{extra}:{base}")),
            (Some(extra), None) => Some(extra.to_string()),
            (None, base) => base.map(str::to_string),
        };
        tracing::info!(path = %canonical.display(), import_path = effective_ip.as_deref().unwrap_or("(none)"), "ncl export");
        let value = nickel_export(&canonical, effective_ip.as_deref()).await?;
        let entry = Entry { mtime, value: Arc::new(value) };
        {
            let mut store = self.store.write();
            store.insert(canonical, entry.clone());
        }
        Ok(entry.value)
    }

    /// Return the JSON value for `path` using the cache's global import path.
    pub async fn get(&self, path: &Path) -> Result<Arc<serde_json::Value>, ToolError> {
        self.get_with_path(path, None).await
    }

    /// Remove all cached entries whose source file no longer exists.
    pub async fn evict_missing(&self) {
        let mut store = self.store.write();
        store.retain(|path, _| path.exists());
    }

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

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

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

impl std::fmt::Debug for NclCache {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("NclCache").field("entries", &self.store.read().len()).finish()
    }
}

async fn file_mtime(path: &Path) -> Result<SystemTime, ToolError> {
    let meta = tokio::fs::metadata(path).await.map_err(|e| {
        ToolError::not_found(format!("{}: {e}", path.display()))
    })?;
    meta.modified().map_err(|e| {
        ToolError::invocation(format!("mtime unavailable for {}: {e}", path.display()))
    })
}

async fn nickel_export(path: &Path, import_path: Option<&str>) -> Result<serde_json::Value, ToolError> {
    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);
    }
    let output = cmd.output()
        .await
        .map_err(|e| ToolError::invocation(format!("nickel export failed to spawn: {e}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(ToolError::invocation(format!(
            "nickel export {}: {stderr}",
            path.display()
        )));
    }

    serde_json::from_slice(&output.stdout).map_err(|e| {
        ToolError::invocation(format!("nickel export output is not valid JSON: {e}"))
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    /// Write a trivial Nickel file that exports `{"ok": true}` and verify
    /// that `nickel` is available in PATH before running cache tests.
    fn nickel_available() -> bool {
        std::process::Command::new("nickel").arg("--version").output().is_ok()
    }

    #[tokio::test]
    async fn cache_miss_then_hit_on_unchanged_file() {
        if !nickel_available() {
            return;
        }

        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "{{ ok = true }}").unwrap();
        let path = f.path().to_owned();

        let cache = NclCache::new();
        assert!(cache.is_empty());

        let v1 = cache.get(&path).await.unwrap();
        assert_eq!(cache.len(), 1);

        // Second call must return same Arc (cache hit — pointer equality).
        let v2 = cache.get(&path).await.unwrap();
        assert!(Arc::ptr_eq(&v1, &v2));
    }

    #[tokio::test]
    async fn not_found_returns_error() {
        let cache = NclCache::new();
        let err = cache.get(Path::new("/nonexistent/file.ncl")).await.unwrap_err();
        assert!(matches!(err, ToolError::NotFound(_)));
    }

    #[tokio::test]
    async fn evict_missing_removes_stale_entries() {
        if !nickel_available() {
            return;
        }

        let mut f = NamedTempFile::new().unwrap();
        writeln!(f, "{{ x = 1 }}").unwrap();
        let path = f.path().to_owned();

        let cache = NclCache::new();
        cache.get(&path).await.unwrap();
        assert_eq!(cache.len(), 1);

        drop(f); // deletes the temp file
        cache.evict_missing().await;
        assert!(cache.is_empty());
    }
}
