use crate::manifest::{derive_cache_key, Manifest};
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{debug, info, warn};

pub struct ExportRequest {
    pub path: PathBuf,
    /// Import paths passed to `nickel export --import-path`.
    pub import_paths: Vec<String>,
}

/// Export a single NCL file and write raw JSON to the cache dir.
/// Skips if the manifest shows the file is still fresh (mtime unchanged).
pub async fn export_ncl(
    req: &ExportRequest,
    cache_dir: &Path,
    manifest: &mut Manifest,
) -> Result<()> {
    let canonical = req
        .path
        .canonicalize()
        .with_context(|| format!("canonicalize {}", req.path.display()))?;

    if !manifest.is_stale(&canonical) {
        debug!("cache fresh: {}", canonical.display());
        return Ok(());
    }

    info!("exporting: {}", canonical.display());

    let cache_key = derive_cache_key(&canonical, &req.import_paths, "json")?;

    let mut cmd = Command::new("nickel");
    cmd.arg("export").arg("--format").arg("json");
    for p in &req.import_paths {
        cmd.arg("--import-path").arg(p);
    }
    cmd.arg(&canonical);

    let output = cmd
        .output()
        .await
        .with_context(|| format!("spawn nickel for {}", canonical.display()))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        warn!("nickel export failed {}: {}", canonical.display(), stderr.trim());
        return Err(anyhow::anyhow!(
            "nickel export failed for {}: {}",
            canonical.display(),
            stderr.trim()
        ));
    }

    std::fs::create_dir_all(cache_dir)
        .with_context(|| format!("creating cache dir {}", cache_dir.display()))?;

    let cache_file = cache_dir.join(format!("{}.json", cache_key));
    let tmp = cache_file.with_extension("tmp");
    std::fs::write(&tmp, &output.stdout)
        .with_context(|| format!("writing cache tmp {}", tmp.display()))?;
    std::fs::rename(&tmp, &cache_file)
        .with_context(|| format!("rename to {}", cache_file.display()))?;

    manifest.update(&canonical, &req.import_paths, &cache_key);
    debug!("cached: {} → {}", canonical.display(), cache_key);

    Ok(())
}

/// Remove a file's cache entry and its JSON output.
pub async fn evict(path: &Path, cache_dir: &Path, manifest: &mut Manifest) {
    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_owned());

    if let Some(entry) = manifest.entry(&canonical) {
        let cache_file = cache_dir.join(format!("{}.json", entry.cache_key));
        if let Err(e) = std::fs::remove_file(&cache_file) {
            debug!("evict remove {}: {}", cache_file.display(), e);
        }
    }

    manifest.evict(&canonical);
}

/// Warm the cache for all NCL files under `workspace_path` up to `concurrency` parallel exports.
/// Files matching `skip_patterns` or under `skip_dirs` are excluded (schemas, defaults, etc.).
pub async fn warm_workspace(
    workspace_path: &Path,
    cache_dir: &Path,
    manifest: &mut Manifest,
    import_paths: &[String],
    concurrency: usize,
    skip_patterns: &[String],
    skip_dirs: &[String],
) -> Result<usize> {
    use std::sync::Arc;
    use tokio::sync::Semaphore;
    use walkdir::WalkDir;

    let paths: Vec<PathBuf> = WalkDir::new(workspace_path)
        .follow_links(false)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().is_some_and(|ext| ext == "ncl"))
        .map(|e| e.path().to_owned())
        .filter(|p| !is_skipped(p, skip_patterns, skip_dirs))
        .collect();

    let total = paths.len();
    let semaphore = Arc::new(Semaphore::new(concurrency.max(1)));
    std::fs::create_dir_all(cache_dir)?;

    let mut handles = Vec::with_capacity(total);

    for path in &paths {
        let sem = Arc::clone(&semaphore);
        let path = path.clone();
        let import_paths = import_paths.to_vec();
        let cache_dir = cache_dir.to_owned();

        handles.push(tokio::spawn(async move {
            let _permit = sem.acquire().await;
            run_and_write(&path, &import_paths, &cache_dir).await
        }));
    }

    let mut count = 0usize;
    for (i, handle) in handles.into_iter().enumerate() {
        match handle.await {
            Ok(Ok((canonical, cache_key))) => {
                manifest.update(&canonical, import_paths, &cache_key);
                count += 1;
            }
            Ok(Err(e)) => warn!("warm skip {}: {}", paths[i].display(), e),
            Err(e) => warn!("warm task panic {}: {}", paths[i].display(), e),
        }
    }

    info!("warm-up: {}/{} files cached", count, total);
    Ok(count)
}

/// Run `nickel export`, write raw JSON to cache. Returns (canonical, cache_key).
async fn run_and_write(
    path: &Path,
    import_paths: &[String],
    cache_dir: &Path,
) -> Result<(PathBuf, String)> {
    let canonical = path
        .canonicalize()
        .with_context(|| format!("canonicalize {}", path.display()))?;

    let cache_key = derive_cache_key(&canonical, import_paths, "json")?;

    // Skip if mtime matches what's already on disk for this key
    let cache_file = cache_dir.join(format!("{}.json", cache_key));
    if cache_file.exists() {
        // File content hasn't changed (same key), no need to re-export
        debug!("warm skip (content unchanged): {}", canonical.display());
        return Ok((canonical, cache_key));
    }

    let mut cmd = Command::new("nickel");
    cmd.arg("export").arg("--format").arg("json");
    for p in import_paths {
        cmd.arg("--import-path").arg(p);
    }
    cmd.arg(&canonical);

    let output = cmd
        .output()
        .await
        .with_context(|| format!("spawn nickel for {}", canonical.display()))?;

    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "nickel export failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }

    let tmp = cache_file.with_extension("tmp");
    std::fs::write(&tmp, &output.stdout)?;
    std::fs::rename(&tmp, &cache_file)?;

    Ok((canonical, cache_key))
}

/// Build the import path list for `nickel export` invocations.
///
/// Matches the ontoref convention — see `reflection/nulib/shared.nu::nickel-import-path`.
///
/// Priority (duplicates removed, preserving first occurrence):
/// 1. workspace paths: `<ws>/.ontology`, `<ws>/adrs`, `<ws>/.ontoref/ontology/schemas`,
///    `<ws>/.ontoref/adrs`, `<ws>/.onref`, `<ws>`
/// 2. `$PROVISIONING`
/// 3. `$NICKEL_IMPORT_PATH` entries (colon-separated)
/// 4. `$ONTOREF_ROOT` paths: `$ONTOREF_ROOT/ontology`, `/ontology/schemas`,
///    `/reflection`, `/reflection/schemas`, `/adrs`, root
/// 5. explicit `extra_import_paths` from config (with `~` expansion)
pub fn default_import_paths(workspace_path: &Path, extra: &[String]) -> Vec<String> {
    let mut paths: Vec<String> = Vec::new();
    let ws = workspace_path.to_path_buf();

    // Workspace-scoped paths (same order as ontoref)
    paths.push(ws.join(".ontology").to_string_lossy().into_owned());
    paths.push(ws.join("adrs").to_string_lossy().into_owned());
    paths.push(ws.join(".ontoref/ontology/schemas").to_string_lossy().into_owned());
    paths.push(ws.join(".ontoref/adrs").to_string_lossy().into_owned());
    paths.push(ws.join(".onref").to_string_lossy().into_owned());
    paths.push(ws.to_string_lossy().into_owned());

    if let Ok(prov) = std::env::var("PROVISIONING") {
        paths.push(prov);
    }

    // $NICKEL_IMPORT_PATH — colon-separated, matches bash wrapper convention
    if let Ok(nip) = std::env::var("NICKEL_IMPORT_PATH") {
        for entry in nip.split(':').filter(|s| !s.is_empty()) {
            paths.push(entry.to_string());
        }
    }

    // $ONTOREF_ROOT — auto-discover if set or default location exists
    let ontoref_root = std::env::var("ONTOREF_ROOT").ok().or_else(|| {
        dirs::home_dir()
            .map(|h| h.join("Library/Application Support/ontoref"))
            .filter(|p| p.exists())
            .map(|p| p.to_string_lossy().into_owned())
            .or_else(|| {
                dirs::home_dir()
                    .map(|h| h.join(".local/share/ontoref"))
                    .filter(|p| p.exists())
                    .map(|p| p.to_string_lossy().into_owned())
            })
    });
    if let Some(root) = ontoref_root {
        let root = std::path::PathBuf::from(root);
        paths.push(root.join("ontology").to_string_lossy().into_owned());
        paths.push(root.join("ontology/schemas").to_string_lossy().into_owned());
        paths.push(root.join("reflection").to_string_lossy().into_owned());
        paths.push(root.join("reflection/schemas").to_string_lossy().into_owned());
        paths.push(root.join("adrs").to_string_lossy().into_owned());
        paths.push(root.to_string_lossy().into_owned());
    }

    for p in extra {
        paths.push(expand_home(p));
    }

    // Keep only existing paths (nickel happily accepts non-existent, but noisier).
    // De-duplicate while preserving order.
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    paths.into_iter().filter(|p| seen.insert(p.clone())).collect()
}

/// Expand `~` and `~/` to `$HOME` in a path string. Leaves other paths untouched.
fn expand_home(path: &str) -> String {
    if let Some(rest) = path.strip_prefix("~/") {
        if let Some(home) = dirs::home_dir() {
            return home.join(rest).to_string_lossy().into_owned();
        }
    }
    if path == "~" {
        if let Some(home) = dirs::home_dir() {
            return home.to_string_lossy().into_owned();
        }
    }
    path.to_string()
}

/// Return true if the file should be skipped (non-exportable library file).
/// Matches filename suffix against `skip_patterns` and any parent dir basename against `skip_dirs`.
pub fn is_skipped(path: &Path, skip_patterns: &[String], skip_dirs: &[String]) -> bool {
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    if skip_patterns.iter().any(|p| name.ends_with(p)) {
        return true;
    }
    for ancestor in path.ancestors() {
        let basename = ancestor.file_name().and_then(|n| n.to_str()).unwrap_or("");
        if skip_dirs.iter().any(|d| d == basename) {
            return true;
        }
    }
    false
}

