//! Language directory — facade over hjkl-bonsai's runtime grammar API.
//!
//! Also provides a pure-Rust comment-prefix table ([`comment`]) that is
//! independent of any grammar loading and can be used by editor-engine crates
//! without pulling in tree-sitter.
//!
//! Wraps a [`GrammarRegistry`] (manifest lookup), [`AsyncGrammarLoader`]
//! (non-blocking clone+compile), and a cache behind a single struct that
//! resolves a `Path` or language name to a cached `Arc<Grammar>`.
//!
//! ## Fast paths
//!
//! 1. **Cache hit** — returns `GrammarRequest::Cached` immediately.
//! 2. **Disk hit** — `lookup_fresh` found a fresh installed parser without
//!    any network I/O; builds the Grammar synchronously (cheap: just dlopen
//!    + read two `.scm` files) and returns `GrammarRequest::Cached`.
//! 3. **True miss** — clone+compile required; fires `load_async` on the
//!    background pool, returns `GrammarRequest::Loading { name, handle }`.
//!    The caller polls `handle.try_recv()` each tick and calls
//!    `complete_load` when the handle resolves.
//!
//! Remote loading can be disabled with [`LanguageDirectory::set_allow_remote`]
//! (the default `new()` allows it). When disabled, a true miss returns
//! `GrammarRequest::Unknown` instead of firing a clone+compile, so a remote
//! caller cannot force network fetch+compile+`dlopen` on demand. The cache
//! and on-disk fast paths above keep working unchanged.
//!
//! # Example
//!
//! ```
//! use hjkl_lang::{GrammarRequest, LanguageDirectory};
//! use std::path::PathBuf;
//!
//! let dir = LanguageDirectory::new().expect("language directory");
//! // An unrecognised extension returns Unknown without blocking.
//! assert!(matches!(
//!     dir.request_for_path(&PathBuf::from("hello.zzznope")),
//!     GrammarRequest::Unknown
//! ));
//! ```

pub mod comment;
pub mod detect;

use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};

use anyhow::Result;
use hjkl_bonsai::runtime::{
    AsyncGrammarLoader, Grammar, GrammarLoader, GrammarRegistry, LoadHandle,
};

/// Result of an async-friendly grammar resolution request.
#[non_exhaustive]
pub enum GrammarRequest {
    /// Already cached — install immediately.
    Cached(Arc<Grammar>),
    /// First time we've seen this language — clone+compile is in flight on
    /// the background pool. Caller must poll `handle.try_recv()` each tick
    /// and call `complete_load(name, path)` when `Some(Ok(path))` arrives.
    Loading { name: String, handle: LoadHandle },
    /// No spec / no recognised extension. Plain-text only for this buffer.
    Unknown,
}

/// Shared language resolver. `Arc<LanguageDirectory>` is the right thing to
/// pass around so the in-memory `Grammar` cache is shared across the
/// `SyntaxLayer` and every picker source.
///
/// # Example
///
/// ```
/// use hjkl_lang::{GrammarRequest, LanguageDirectory};
/// use std::path::PathBuf;
///
/// let dir = LanguageDirectory::new().expect("init");
/// match dir.request_for_path(&PathBuf::from("main.rs")) {
///     GrammarRequest::Unknown => {}     // fine on a cold system
///     GrammarRequest::Cached(_) => {}   // grammar already installed
///     GrammarRequest::Loading { .. } => {} // clone+compile kicked off
///     _ => {}                            // future variants
/// }
/// ```
pub struct LanguageDirectory {
    registry: GrammarRegistry,
    async_loader: AsyncGrammarLoader,
    cache: Mutex<HashMap<String, Arc<Grammar>>>,
    /// Whether a true miss may kick off a network clone+compile. Default
    /// `true`; set `false` when the process must never fetch grammars from
    /// the network (restricted RPC modes).
    allow_remote: bool,
}

impl LanguageDirectory {
    /// Build a new directory rooted at the user's standard XDG dirs. Fails
    /// only if the embedded `bonsai.toml` doesn't parse or `$HOME` is unset.
    pub fn new() -> Result<Self> {
        let registry = GrammarRegistry::embedded()?;
        let loader = GrammarLoader::user_default(registry.meta())?;
        let async_loader = AsyncGrammarLoader::new(loader);
        Ok(Self {
            registry,
            async_loader,
            cache: Mutex::new(HashMap::new()),
            allow_remote: true,
        })
    }

    /// Control whether a true miss may kick off a background clone+compile
    /// (network fetch + `$CC` build + install + `dlopen`). Default `true`.
    /// Callers that restrict filesystem access (the RPC entry points) set this
    /// to `false` so a remote caller cannot force grammar compilation on
    /// demand; the cache and on-disk installed tiers keep working either way.
    pub fn set_allow_remote(&mut self, allow: bool) {
        self.allow_remote = allow;
    }

    // ── Async-friendly API (UI-thread callers) ────────────────────────────────

    /// Resolve a path to its canonical language name (e.g. `"rust"` for
    /// `foo.rs`). Returns `None` for unknown / extensionless paths.
    ///
    /// Cheap (extension lookup against the bonsai manifest) and synchronous
    /// — does not load any grammar. Use this when you only need the
    /// language identifier (e.g. for `:set filetype=`, commentstring
    /// resolution, LSP language-id mapping) and don't care about the
    /// grammar object.
    pub fn name_for_path(&self, path: &Path) -> Option<String> {
        self.registry.name_for_path(path).map(|s| s.to_string())
    }

    /// [`Self::name_for_path`] for callers that already hold a bare file
    /// extension (no leading dot, any case) instead of a path — e.g. the LSP
    /// glue resolving a language id for `:LspInfo` / attach.
    pub fn name_for_ext(&self, ext: &str) -> Option<String> {
        self.registry.name_for_ext(ext).map(|s| s.to_string())
    }

    /// Async-friendly resolution by path. Returns immediately; never blocks
    /// on clone+compile. See module-level docs for the three fast paths.
    pub fn request_for_path(&self, path: &Path) -> GrammarRequest {
        let name = match self.registry.name_for_path(path) {
            Some(n) => n.to_string(),
            None => return GrammarRequest::Unknown,
        };
        self.request_by_name(&name)
    }

    /// Async-friendly resolution by language name. Returns immediately.
    pub fn request_by_name(&self, name: &str) -> GrammarRequest {
        // Fast path 1: cache hit.
        if let Some(g) = self.cache_get(name) {
            return GrammarRequest::Cached(g);
        }

        let Some(spec) = self.registry.by_name(name) else {
            return GrammarRequest::Unknown;
        };
        let meta = self.registry.meta();

        // Fast path 2: already installed on disk (lookup_fresh short-circuits).
        if let Some(path) = self.async_loader.inner().lookup_fresh(name, spec, meta) {
            match Grammar::load_from_path(name, &path) {
                Ok(g) => {
                    let arc = self.cache_insert(name, g);
                    return GrammarRequest::Cached(arc);
                }
                Err(e) => {
                    tracing::debug!("load_from_path({name}) failed after lookup_fresh: {e:#}");
                    // Fall through to async load — the .so may be corrupted.
                }
            }
        }

        // Slow path: kick off (or subscribe to) a background clone+compile.
        // Gated: when remote loading is disabled (restricted RPC modes), a
        // true miss stays a miss — plain text for this buffer, and no
        // network fetch+compile the remote caller could trigger on demand.
        if !self.allow_remote {
            return GrammarRequest::Unknown;
        }
        let handle = self
            .async_loader
            .load_async(name, spec.clone(), meta.clone());
        GrammarRequest::Loading {
            name: name.to_string(),
            handle,
        }
    }

    /// Snapshot of grammar names currently in flight on the async pool.
    /// Order unspecified. Surfaced to the renderer so a single
    /// status-bar spinner can reflect *any* queued lang (active buffer,
    /// preview pane, or otherwise), not just the focused one.
    pub fn in_flight_names(&self) -> Vec<String> {
        self.async_loader.in_flight_names()
    }

    /// Called by the consumer when a `Loading` handle resolves with
    /// `Some(Ok(lib_path))`. Constructs the `Grammar`, caches it, returns
    /// the `Arc`.
    pub fn complete_load(&self, name: &str, lib_path: &Path) -> Result<Arc<Grammar>> {
        let grammar = Grammar::load_from_path(name, lib_path)?;
        Ok(self.cache_insert(name, grammar))
    }

    // ── Sync API (picker-thread callers; blocking is OK there) ───────────────

    /// Resolve a language name (e.g. `"rust"`, `"python"`) to a loaded
    /// grammar. May block on first use to clone + compile + install.
    /// Pickers run on their own background threads so blocking is fine.
    pub fn by_name(&self, name: &str) -> Option<Arc<Grammar>> {
        if let Some(g) = self.cache_get(name) {
            return Some(g);
        }
        let spec = self.registry.by_name(name)?;
        let meta = self.registry.meta();
        let grammar = Grammar::load(name, spec, self.async_loader.inner(), meta).ok()?;
        Some(self.cache_insert(name, grammar))
    }

    /// Cache-only variant of [`Self::by_name`]: returns a grammar already
    /// loaded by an earlier request, and `None` otherwise. NEVER loads —
    /// no clone, compile, install or network fetch.
    ///
    /// Use this on paths that must not be the trigger for grammar
    /// acquisition — a per-frame fold pass can otherwise block the UI
    /// thread on a first-use clone+compile+dlopen. Grammars loaded by the
    /// highlight path or the async loader are already cached, so a buffer
    /// that has rendered once resolves here the same way `by_name` would.
    pub fn by_name_cached(&self, name: &str) -> Option<Arc<Grammar>> {
        self.cache_get(name)
    }

    // ── Cache helpers ─────────────────────────────────────────────────────────

    fn cache_get(&self, name: &str) -> Option<Arc<Grammar>> {
        let g = self.cache.lock().ok()?;
        g.get(name).cloned()
    }

    fn cache_insert(&self, name: &str, grammar: Grammar) -> Arc<Grammar> {
        let arc = Arc::new(grammar);
        if let Ok(mut g) = self.cache.lock() {
            // Lost-race guard: if another caller raced us to insert, prefer
            // theirs so all consumers share the same Arc.
            if let Some(existing) = g.get(name) {
                return existing.clone();
            }
            g.insert(name.to_string(), arc.clone());
        }
        arc
    }
}

// ── Unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;

    #[test]
    fn request_for_path_returns_unknown_for_unrecognized_extension() {
        let dir = LanguageDirectory::new().unwrap();
        assert!(matches!(
            dir.request_for_path(&PathBuf::from("foo.zzznope")),
            GrammarRequest::Unknown
        ));
    }

    #[test]
    fn request_by_name_returns_unknown_for_nonexistent_lang() {
        let dir = LanguageDirectory::new().unwrap();
        assert!(matches!(
            dir.request_by_name("definitely_not_a_real_language"),
            GrammarRequest::Unknown
        ));
    }

    #[test]
    fn in_flight_names_is_empty_on_fresh_directory() {
        let dir = LanguageDirectory::new().unwrap();
        assert!(dir.in_flight_names().is_empty());
    }

    #[test]
    fn by_name_cached_never_loads_synchronously() {
        // A fresh directory has nothing in the cache, so the cache-only
        // lookup must return None even for a language `by_name` would load
        // from the installed tier (on a machine with grammars present).
        // It never clone+compiles or fetches — the whole point.
        let dir = LanguageDirectory::new().unwrap();
        assert!(dir.by_name_cached("rust").is_none());
        assert!(dir.by_name_cached("python").is_none());
    }

    #[test]
    fn request_for_path_with_txt_returns_unknown() {
        let dir = LanguageDirectory::new().unwrap();
        // .txt has no grammar spec in bonsai.toml.
        let result = dir.request_for_path(&PathBuf::from("README.txt"));
        assert!(matches!(result, GrammarRequest::Unknown));
    }

    #[test]
    fn multiple_unknown_requests_do_not_panic() {
        let dir = LanguageDirectory::new().unwrap();
        for _ in 0..10 {
            let _ = dir.request_by_name("nolang");
        }
    }

    #[test]
    fn disabled_remote_loading_never_returns_loading() {
        // With remote loading off (as the restricted RPC modes set it), a true
        // miss must resolve to Unknown — never a network clone+compile. The
        // result may be Cached if "rust" happens to be installed on disk;
        // either way it must not be Loading.
        let mut dir = LanguageDirectory::new().unwrap();
        dir.set_allow_remote(false);
        let result = dir.request_by_name("rust");
        assert!(
            !matches!(result, GrammarRequest::Loading { .. }),
            "remote loading disabled but request_by_name(\"rust\") returned Loading"
        );
    }
}
