mod config;
mod exporter;
mod manifest;
#[cfg(feature = "nats")]
mod nats_subscriber;
mod sync_request;
mod watcher;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use config::NclSyncConfig;
use exporter::{default_import_paths, evict, export_ncl, warm_workspace, ExportRequest};
use manifest::{derive_cache_key, Manifest};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time::interval;
use tracing::{error, info};
use watcher::WatchEvent;

#[derive(Parser)]
#[command(name = "provisioning-ncl-sync", about = "Nickel configuration sync daemon")]
struct Cli {
    #[command(subcommand)]
    command: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Start as background daemon: watch NCL files, maintain workspace cache
    Daemon {
        #[arg(long)]
        workspace: Option<PathBuf>,
        #[arg(long, default_value = "600")]
        idle_timeout: u64,
    },
    /// One-shot warm-up for all NCL files in a workspace, then exit
    Warm { workspace: PathBuf },
    /// Evict a single NCL file from cache
    Invalidate {
        path: PathBuf,
        #[arg(long)]
        workspace: Option<PathBuf>,
    },
    /// Print the cache key for a file — for parity testing with the plugin
    Key {
        path: PathBuf,
        #[arg(long = "import-path", value_name = "PATH")]
        import_paths: Vec<String>,
        #[arg(long, default_value = "json")]
        format: String,
    },
    /// Print cache statistics for a workspace
    Stats {
        #[arg(long)]
        workspace: Option<PathBuf>,
    },
}

fn pid_file(cache_dir: &Path) -> PathBuf {
    cache_dir.join("ncl-sync.pid")
}

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

/// Resolve cache_dir from (1) config file override, (2) workspace-local, (3) global.
fn cache_dir_for(cfg: &NclSyncConfig, workspace: Option<&Path>) -> PathBuf {
    cfg.resolved_cache_dir(workspace)
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_env("NCL_SYNC_LOG")
                .add_directive(tracing::Level::INFO.into()),
        )
        .compact()
        .init();

    let cli = Cli::parse();
    let cfg = NclSyncConfig::load_or_default();

    match cli.command {
        Cmd::Daemon { workspace, idle_timeout } => {
            let ws = workspace
                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
            let cache_dir = cache_dir_for(&cfg, Some(&ws));
            std::fs::create_dir_all(&cache_dir)?;
            let timeout = if idle_timeout != 600 {
                idle_timeout
            } else {
                cfg.settings().idle_timeout_secs
            };
            run_daemon(ws, cache_dir, timeout, cfg).await
        }
        Cmd::Warm { workspace } => {
            let ws_cache = cache_dir_for(&cfg, Some(&workspace));
            std::fs::create_dir_all(&ws_cache)?;
            let global_cache = manifest::global_cache_dir();
            std::fs::create_dir_all(&global_cache)?;
            let import_paths = default_import_paths(&workspace, &cfg.settings().extra_import_paths);
            let extra_warm = resolve_extra_warm_paths(&cfg);

            // Workspace files → workspace cache
            let mut ws_mani = Manifest::load(&ws_cache)?;
            let ws_count = warm_workspace(
                &workspace,
                &ws_cache,
                &mut ws_mani,
                &import_paths,
                cfg.settings().warm_concurrency,
                &cfg.settings().skip_patterns,
                &cfg.settings().skip_dirs,
            )
            .await?;
            ws_mani.save(&ws_cache)?;

            // Extensions/shared files → global cache (shared across workspaces)
            let mut global_count = 0usize;
            if !extra_warm.is_empty() {
                let mut g_mani = Manifest::load(&global_cache)?;
                for extra in &extra_warm {
                    println!("warming (global): {}", extra.display());
                    global_count += warm_workspace(
                        extra,
                        &global_cache,
                        &mut g_mani,
                        &import_paths,
                        cfg.settings().warm_concurrency,
                        &cfg.settings().skip_patterns,
                        &cfg.settings().skip_dirs,
                    )
                    .await
                    .unwrap_or(0);
                }
                g_mani.save(&global_cache)?;
            }

            println!(
                "warmed {} ws files at {} + {} global files at {}",
                ws_count,
                ws_cache.display(),
                global_count,
                global_cache.display()
            );
            Ok(())
        }
        Cmd::Invalidate { path, workspace } => {
            let cache_dir = resolve_with_workspace(&cfg, workspace.as_deref());
            let mut manifest = Manifest::load(&cache_dir)?;
            evict(&path, &cache_dir, &mut manifest).await;
            manifest.save(&cache_dir)?;
            println!("evicted: {}", path.display());
            Ok(())
        }
        Cmd::Key { path, import_paths, format } => {
            let canonical = path
                .canonicalize()
                .with_context(|| format!("canonicalize {}", path.display()))?;
            println!("{}", derive_cache_key(&canonical, &import_paths, &format)?);
            Ok(())
        }
        Cmd::Stats { workspace } => {
            let cache_dir = resolve_with_workspace(&cfg, workspace.as_deref());
            print_stats(&cache_dir)
        }
    }
}

/// Resolve cache dir for subcommands that may be run outside the workspace.
/// Priority: explicit --workspace → walk up from CWD → global fallback.
fn resolve_with_workspace(cfg: &NclSyncConfig, workspace: Option<&Path>) -> PathBuf {
    if let Some(ws) = workspace {
        return cache_dir_for(cfg, Some(ws));
    }
    if let Ok(cwd) = std::env::current_dir() {
        if let Some(ws) = find_workspace_up(&cwd) {
            return cache_dir_for(cfg, Some(&ws));
        }
    }
    cache_dir_for(cfg, None)
}

/// Walk up from `start` until a workspace root is found (has infra/ or .ontology/).
fn find_workspace_up(start: &Path) -> Option<PathBuf> {
    let mut cur = start.to_path_buf();
    loop {
        if cur.join("infra").is_dir()
            || cur.join("config/provisioning.ncl").exists()
            || cur.join(".ontology").is_dir()
        {
            return Some(cur);
        }
        let parent = cur.parent()?.to_path_buf();
        if parent == cur {
            return None;
        }
        cur = parent;
    }
}

async fn run_daemon(
    workspace: PathBuf,
    cache_dir: PathBuf,
    idle_timeout_secs: u64,
    cfg: NclSyncConfig,
) -> Result<()> {
    let pid = std::process::id();
    std::fs::write(pid_file(&cache_dir), pid.to_string()).context("writing PID file")?;
    info!(
        "ncl-sync daemon pid={} workspace={} cache={}",
        pid,
        workspace.display(),
        cache_dir.display()
    );

    let import_paths = default_import_paths(&workspace, &cfg.settings().extra_import_paths);
    let extra_warm = resolve_extra_warm_paths(&cfg);
    let global_cache = manifest::global_cache_dir();
    std::fs::create_dir_all(&global_cache)?;

    info!(
        "caches: workspace={} global={}",
        cache_dir.display(),
        global_cache.display()
    );

    // Warm workspace files → workspace cache
    {
        let mut ws_manifest = Manifest::load(&cache_dir)?;
        warm_workspace(
            &workspace,
            &cache_dir,
            &mut ws_manifest,
            &import_paths,
            cfg.settings().warm_concurrency,
            &cfg.settings().skip_patterns,
            &cfg.settings().skip_dirs,
        )
        .await?;
        ws_manifest.save(&cache_dir)?;
    }

    // Warm extension/provisioning files → global cache (shared across workspaces)
    if !extra_warm.is_empty() {
        let mut global_manifest = Manifest::load(&global_cache)?;
        for extra in &extra_warm {
            info!("warming (global) {}", extra.display());
            warm_workspace(
                extra,
                &global_cache,
                &mut global_manifest,
                &import_paths,
                cfg.settings().warm_concurrency,
                &cfg.settings().skip_patterns,
                &cfg.settings().skip_dirs,
            )
            .await
            .unwrap_or_else(|e| {
                tracing::warn!("extra warm {} failed: {}", extra.display(), e);
                0
            });
        }
        global_manifest.save(&global_cache)?;
    }
    let last_activity = Arc::new(AtomicU64::new(now_secs()));
    // Watch workspace + extra warm paths in a single notify watcher.
    let mut all_watch_paths = vec![workspace.clone()];
    all_watch_paths.extend(extra_warm.iter().cloned());
    let (_watcher, mut watch_rx) = watcher::start_many(&all_watch_paths)?;
    let mut sync_tick = interval(Duration::from_millis(cfg.settings().sync_poll_interval_ms));
    let mut idle_tick = interval(Duration::from_secs(10));

    // Optional NATS subscriber for event-driven cache invalidation.
    #[cfg(feature = "nats")]
    start_nats_subscriber(&cfg, &workspace, &cache_dir, &import_paths).await;

    loop {
        tokio::select! {
            Some(event) = watch_rx.recv() => {
                last_activity.store(now_secs(), Ordering::Relaxed);
                handle_watch_event(
                    event,
                    &workspace,
                    &import_paths,
                    &cfg.settings().skip_patterns,
                    &cfg.settings().skip_dirs,
                ).await;
            }
            _ = sync_tick.tick() => {
                // Drain sidecars from BOTH caches — Nu writes to whichever is resolved.
                if let Err(e) = sync_request::drain_and_process(&cache_dir, &workspace, &import_paths).await {
                    error!("sync-request drain (workspace): {}", e);
                }
                if let Err(e) = sync_request::drain_and_process(&global_cache, &workspace, &import_paths).await {
                    error!("sync-request drain (global): {}", e);
                }
            }
            _ = idle_tick.tick() => {
                if idle_timeout_secs > 0 {
                    let idle = now_secs().saturating_sub(last_activity.load(Ordering::Relaxed));
                    if idle >= idle_timeout_secs {
                        info!("idle timeout ({}s), shutting down", idle_timeout_secs);
                        break;
                    }
                }
            }
        }
    }

    let _ = std::fs::remove_file(pid_file(&cache_dir));
    Ok(())
}

async fn handle_watch_event(
    event: WatchEvent,
    workspace: &Path,
    import_paths: &[String],
    skip_patterns: &[String],
    skip_dirs: &[String],
) {
    // Drop events for non-exportable files (schemas, defaults, etc.) — same filter as warm-up.
    let path = match &event {
        WatchEvent::Changed(p) | WatchEvent::Removed(p) => p,
    };
    if exporter::is_skipped(path, skip_patterns, skip_dirs) {
        tracing::debug!("skip watch event for library file: {}", path.display());
        return;
    }

    // Route to correct cache dir based on the file's location (global for extensions,
    // workspace-local for workspace files).
    let cache_dir = manifest::resolve_cache_dir_for_file(path, Some(workspace));

    let mut manifest = match Manifest::load(&cache_dir) {
        Ok(m) => m,
        Err(e) => {
            error!("load manifest on watch event: {}", e);
            return;
        }
    };

    match event {
        WatchEvent::Changed(path) => {
            let req = ExportRequest { path, import_paths: import_paths.to_vec() };
            if let Err(e) = export_ncl(&req, &cache_dir, &mut manifest).await {
                error!("watch export: {}", e);
            }
        }
        WatchEvent::Removed(path) => {
            evict(&path, &cache_dir, &mut manifest).await;
        }
    }

    if let Err(e) = manifest.save(&cache_dir) {
        error!("manifest save after watch event: {}", e);
    }
}

fn print_stats(cache_dir: &Path) -> Result<()> {
    let manifest = Manifest::load(cache_dir)?;

    let mut total = 0usize;
    let mut stale = 0usize;

    for (path_str, entry) in manifest.all_entries() {
        total += 1;
        let path = PathBuf::from(path_str);
        if manifest::file_mtime(&path).unwrap_or(0) != entry.source_mtime {
            stale += 1;
        }
    }

    let size_bytes: u64 = std::fs::read_dir(cache_dir)
        .into_iter()
        .flatten()
        .filter_map(|e| e.ok())
        .filter_map(|e| e.metadata().ok())
        .map(|m| m.len())
        .sum();

    println!("entries:   {total}");
    println!("stale:     {stale}");
    println!("cache_dir: {}", cache_dir.display());
    println!("size:      {:.1} MB", size_bytes as f64 / 1_048_576.0);
    Ok(())
}

#[cfg(feature = "nats")]
async fn start_nats_subscriber(
    cfg: &NclSyncConfig,
    workspace: &Path,
    cache_dir: &Path,
    import_paths: &[String],
) {
    if !cfg.settings().nats.enabled {
        return;
    }

    let url = if cfg.settings().nats.url.is_empty() {
        "nats://127.0.0.1:4222".to_string()
    } else {
        cfg.settings().nats.url.clone()
    };

    let nats_cfg = platform_nats::NatsConfig {
        url: url.clone(),
        ..Default::default()
    };

    let bridge = match platform_nats::NatsBridge::connect(&nats_cfg).await {
        Ok(b) => b,
        Err(e) => {
            tracing::warn!("NATS subscriber disabled — connect failed: {}", e);
            return;
        }
    };

    let sub_cfg = nats_subscriber::SubscriberConfig {
        workspace: workspace.to_path_buf(),
        cache_dir: cache_dir.to_path_buf(),
        default_import_paths: import_paths.to_vec(),
    };

    if let Err(e) = nats_subscriber::spawn(Arc::new(bridge), sub_cfg).await {
        tracing::warn!("NATS subscriber start failed: {}", e);
    } else {
        tracing::info!("NATS subscriber active on {}", url);
    }
}

/// Resolve extra warm paths from config + env. Expands `~` and discovers
/// $PROVISIONING/extensions automatically if it exists and not already listed.
fn resolve_extra_warm_paths(cfg: &NclSyncConfig) -> Vec<PathBuf> {
    let mut paths: Vec<PathBuf> = Vec::new();
    let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();

    // Config entries (with ~ expansion)
    for p in &cfg.settings().extra_warm_paths {
        let expanded = expand_tilde(p);
        let pb = PathBuf::from(expanded);
        if pb.exists() && seen.insert(pb.clone()) {
            paths.push(pb);
        }
    }

    // Env var NCL_SYNC_EXTRA_WARM (colon-separated)
    if let Ok(v) = std::env::var("NCL_SYNC_EXTRA_WARM") {
        for entry in v.split(':').filter(|s| !s.is_empty()) {
            let pb = PathBuf::from(expand_tilde(entry));
            if pb.exists() && seen.insert(pb.clone()) {
                paths.push(pb);
            }
        }
    }

    // Auto-discover $PROVISIONING/extensions — shared read-only catalog, almost always needed
    if let Ok(prov) = std::env::var("PROVISIONING") {
        let ext = PathBuf::from(prov).join("extensions");
        if ext.exists() && seen.insert(ext.clone()) {
            paths.push(ext);
        }
    }

    paths
}

fn expand_tilde(s: &str) -> String {
    if let Some(rest) = s.strip_prefix("~/") {
        if let Some(home) = dirs::home_dir() {
            return home.join(rest).to_string_lossy().into_owned();
        }
    }
    s.to_string()
}
