use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
    /// SHA256(file_content + sorted_import_paths + format) — identical to plugin key.
    pub cache_key: String,
    pub source_mtime: u64,
    pub import_paths: Vec<String>,
    pub cached_at: u64,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Manifest {
    entries: HashMap<String, ManifestEntry>,
}

impl Manifest {
    pub fn load(cache_dir: &Path) -> Result<Self> {
        let path = manifest_path(cache_dir);
        if !path.exists() {
            return Ok(Self::default());
        }
        let content = fs::read_to_string(&path)
            .with_context(|| format!("reading manifest {}", path.display()))?;
        serde_json::from_str(&content).with_context(|| "parsing manifest JSON")
    }

    pub fn save(&self, cache_dir: &Path) -> Result<()> {
        let path = manifest_path(cache_dir);
        let tmp = path.with_extension("tmp");
        fs::write(&tmp, serde_json::to_string_pretty(self)?)
            .with_context(|| format!("writing manifest tmp {}", tmp.display()))?;
        fs::rename(&tmp, &path).with_context(|| "renaming manifest")
    }

    pub fn is_stale(&self, canonical: &Path) -> bool {
        let key = canonical.to_string_lossy().into_owned();
        let Some(entry) = self.entries.get(&key) else {
            return true;
        };
        file_mtime(canonical).unwrap_or(0) != entry.source_mtime
    }

    pub fn update(&mut self, canonical: &Path, import_paths: &[String], cache_key: &str) {
        let key = canonical.to_string_lossy().into_owned();
        self.entries.insert(
            key,
            ManifestEntry {
                cache_key: cache_key.to_owned(),
                source_mtime: file_mtime(canonical).unwrap_or(0),
                import_paths: import_paths.to_vec(),
                cached_at: now_secs(),
            },
        );
    }

    pub fn evict(&mut self, canonical: &Path) {
        self.entries.remove(&canonical.to_string_lossy().into_owned());
    }

    pub fn entry(&self, canonical: &Path) -> Option<&ManifestEntry> {
        self.entries.get(&canonical.to_string_lossy().into_owned())
    }

    pub fn all_entries(&self) -> impl Iterator<Item = (&str, &ManifestEntry)> {
        self.entries.iter().map(|(k, v)| (k.as_str(), v))
    }

    #[allow(dead_code)]
    pub fn stale_paths(&self) -> Vec<PathBuf> {
        self.entries
            .iter()
            .filter_map(|(key, entry)| {
                let path = PathBuf::from(key);
                if file_mtime(&path).unwrap_or(0) != entry.source_mtime {
                    Some(path)
                } else {
                    None
                }
            })
            .collect()
    }
}

/// Derive the cache key — must match the plugin's `compute_cache_key` exactly.
///
/// Key = SHA256(file_content + format).
/// Import paths are NOT part of the key — see plugin's helpers.rs for rationale.
pub fn derive_cache_key(canonical: &Path, _import_paths: &[String], format: &str) -> Result<String> {
    let content = fs::read_to_string(canonical)
        .with_context(|| format!("reading {} for cache key", canonical.display()))?;

    let mut hasher = Sha256::new();
    hasher.update(content.as_bytes());
    hasher.update(format.as_bytes());

    Ok(format!("{:x}", hasher.finalize()))
}

/// Resolve the cache directory FOR A FILE.
///
/// Two caches coexist:
///   - **Global** (`~/.cache/provisioning/config-cache/`): files under `$PROVISIONING`
///     (extensions, schemas) — shared across workspaces.
///   - **Workspace** (`<workspace>/.ncl-cache/`): files under the workspace
///     (state, components, settings) — scoped to the workspace.
///
/// Priority:
///   1. `$NCL_CACHE_DIR` — explicit override (e.g. CI)
///   2. If file is under `$PROVISIONING` → global cache
///   3. If `workspace` is given and file is under it → workspace cache
///   4. Fallback: global cache
///
/// Must match `get_cache_dir_for_file()` in the plugin's helpers.rs.
pub fn resolve_cache_dir_for_file(file_path: &Path, workspace: Option<&Path>) -> PathBuf {
    if let Ok(dir) = std::env::var("NCL_CACHE_DIR") {
        return PathBuf::from(dir);
    }
    if let Ok(prov) = std::env::var("PROVISIONING") {
        let prov = PathBuf::from(prov);
        if file_path.starts_with(&prov) {
            return global_cache_dir();
        }
    }
    if let Some(ws) = workspace {
        if file_path.starts_with(ws) {
            return ws.join(".ncl-cache");
        }
    }
    global_cache_dir()
}

/// Cache dir for "generic" operations (stats, invalidate by path) without a file context.
/// Prefers workspace-local if a workspace is given; falls back to global.
pub fn resolve_cache_dir(workspace: Option<&Path>) -> PathBuf {
    if let Ok(dir) = std::env::var("NCL_CACHE_DIR") {
        return PathBuf::from(dir);
    }
    if let Some(ws) = workspace {
        return ws.join(".ncl-cache");
    }
    global_cache_dir()
}

/// Global cache — shared across workspaces for files under `$PROVISIONING`.
pub fn global_cache_dir() -> PathBuf {
    dirs::cache_dir()
        .unwrap_or_else(|| PathBuf::from("/tmp"))
        .join("provisioning")
        .join("config-cache")
}

pub fn file_mtime(path: &Path) -> Option<u64> {
    fs::metadata(path)
        .ok()?
        .modified()
        .ok()?
        .duration_since(UNIX_EPOCH)
        .ok()
        .map(|d| d.as_secs())
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn manifest_path(cache_dir: &Path) -> PathBuf {
    cache_dir.join("manifest.json")
}
