//! Tree-sitter helpers shared across language extractors and framework
//! overlays. Anything language-specific stays in `lang/` or `framework/`.

use ckg_core::{Error, Result};
use tree_sitter::{Node, Parser, Tree};

/// Hard cap on per-file source size. Tree-sitter's `parse` is O(n) for
/// well-formed input but pathological inputs (deeply-nested parens,
/// megaline files) can blow up. 8 MiB is generous for any real source
/// file and keeps a malicious / generated file from pinning a parser
/// thread.
pub(crate) const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024;

/// Wall-clock deadline for a single `parse` call. Tree-sitter respects
/// this via `set_timeout_micros`; the parser returns `None` past the
/// deadline. Five seconds is generous for any single file at typical
/// repo sizes; a stuck file no longer pins a rayon worker indefinitely.
pub(crate) const PARSE_DEADLINE_MICROS: u64 = 5_000_000;

/// Guarded parse: enforces the byte cap, sets a wall-clock deadline,
/// and surfaces a uniform `Error::Parse` instead of letting `None`
/// propagate as an opaque "parse returned None".
///
/// Use this from every `lang::*::extract` so the DoS surface (megaline
/// files, pathological recursion) is closed in one place.
pub(crate) fn guarded_parse(parser: &mut Parser, source: &str, lang: &str) -> Result<Tree> {
    if source.len() > MAX_SOURCE_BYTES {
        return Err(Error::Parse(format!(
            "{lang}: source too large ({} bytes > {} cap)",
            source.len(),
            MAX_SOURCE_BYTES
        )));
    }
    // `set_timeout_micros` is deprecated in tree-sitter 0.25 in favor of
    // `parse_with_options` + a progress callback. The deprecated path is
    // still wired in the C runtime and is dramatically simpler than
    // re-plumbing every extractor through a streaming callback API. Once
    // tree-sitter removes it we'll migrate.
    #[allow(deprecated)]
    parser.set_timeout_micros(PARSE_DEADLINE_MICROS);
    parser.parse(source, None).ok_or_else(|| {
        Error::Parse(format!(
            "{lang}: parse returned None (deadline {PARSE_DEADLINE_MICROS}µs exceeded or fatal syntax)"
        ))
    })
}

/// PARSE-H9: thread-local parser pool. Avoids re-creating `Parser`
/// instances + re-setting the language for every file on the same rayon
/// worker thread. `parser.reset()` between uses clears incremental state
/// without the FFI allocation cost of `Parser::new()`.
///
/// Usage:
/// ```ignore
/// let mut parser = get_pooled_parser("rust", &tree_sitter_rust::LANGUAGE.into());
/// let tree = guarded_parse(&mut parser, source, "rust")?;
/// return_pooled_parser("rust", parser);
/// ```
use std::cell::RefCell;
use std::collections::HashMap;

thread_local! {
    static PARSER_POOL: RefCell<HashMap<&'static str, Parser>> = RefCell::new(HashMap::new());
}

/// Borrow a parser from the thread-local pool. Creates one if not present.
pub(crate) fn get_pooled_parser(lang_key: &'static str, language: &tree_sitter::Language) -> Parser {
    PARSER_POOL.with(|pool| {
        let mut pool = pool.borrow_mut();
        if let Some(mut p) = pool.remove(lang_key) {
            p.reset();
            p
        } else {
            let mut p = Parser::new();
            let _ = p.set_language(language);
            p
        }
    })
}

/// Return a parser to the thread-local pool for reuse.
pub(crate) fn return_pooled_parser(lang_key: &'static str, parser: Parser) {
    PARSER_POOL.with(|pool| {
        pool.borrow_mut().insert(lang_key, parser);
    });
}

/// Return the source text covered by a tree-sitter node.
///
/// ## Safety
///
/// Tree-sitter normally yields ranges aligned to UTF-8 char boundaries,
/// but error-recovery on malformed input or stale cached trees can yield
/// ranges that fall mid-codepoint. Direct slicing (`&src[n.byte_range()]`)
/// would panic the parser thread; one panic kills the whole index pass.
/// `str::get` returns `None` on misaligned ranges so we degrade to an
/// empty slice instead.
///
/// Identical helper duplicated across each language extractor pre-refactor;
/// extracted here so `framework/` and `schema/` overlays can call it
/// without reaching into a specific `lang/` module.
#[inline]
pub(crate) fn text<'a>(n: Node<'_>, src: &'a str) -> &'a str {
    match src.get(n.byte_range()) {
        Some(s) => s,
        None => {
            // CR-M-4: surface the silent corruption. A misaligned range
            // means tree-sitter recovered from a malformed input and
            // pointed at a mid-codepoint offset; the resulting empty
            // slice yields a Symbol with `name=""` that pollutes the
            // `Symbol:by_name` index. The operator needs to see this.
            // CR-parse-H-3: dropped from `warn` to `debug` — `text()`
            // is called 10-100× per Symbol, so a single broken file
            // could spam the log. The empty-slice fallback already
            // ensures Symbol pollution is constrained at the
            // extractor level (zero-length names are stamped through
            // node_id which downstream filters can detect).
            tracing::debug!(
                "util::text: tree-sitter byte range {:?} misaligned with source — returning empty slice (likely AST recovery on malformed UTF-8 boundary)",
                n.byte_range()
            );
            ""
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// CR4 verification: confirm `set_timeout_micros` actually bounds
    /// `parse` runtime. Tree-sitter 0.25 deprecates the call but the C
    /// runtime still respects it. Submits a deeply-recursive Python
    /// expression that takes several seconds to parse without a bound;
    /// asserts we either complete quickly OR `guarded_parse` returns
    /// the deadline error within an envelope > the deadline.
    ///
    /// This is the single load-bearing test for the C2 / CR4 fix —
    /// without it, a future tree-sitter upgrade silently turning
    /// `set_timeout_micros` into a no-op goes undetected.
    #[test]
    fn guarded_parse_respects_deadline() {
        use std::time::Instant;
        // 100 KB of nested parens — pathological for tree-sitter's
        // error-recovery path, fast to construct.
        let src = "(".repeat(50_000) + &")".repeat(50_000);
        let mut parser = Parser::new();
        parser
            .set_language(&tree_sitter_python::LANGUAGE.into())
            .unwrap();
        let started = Instant::now();
        // Parse OR error must return within (deadline + 5 s) buffer.
        // If `set_timeout_micros` has become a silent no-op, this test
        // hangs the test runner until the OS kills it.
        let result = guarded_parse(&mut parser, &src, "python");
        let elapsed = started.elapsed();
        assert!(
            elapsed.as_secs() < 15,
            "guarded_parse exceeded 15s envelope (deadline {}µs); \
             set_timeout_micros may be a no-op in this tree-sitter — \
             migrate to parse_with_options",
            PARSE_DEADLINE_MICROS
        );
        // Either parser completed (legitimate) or returned a deadline
        // Error::Parse. Both are acceptable — what's NOT is hanging.
        let _ = result;
    }

    #[test]
    fn guarded_parse_rejects_oversize_source() {
        let mut parser = Parser::new();
        parser
            .set_language(&tree_sitter_python::LANGUAGE.into())
            .unwrap();
        let huge = vec![b'x'; MAX_SOURCE_BYTES + 1];
        let src = std::str::from_utf8(&huge).unwrap();
        let result = guarded_parse(&mut parser, src, "python");
        assert!(matches!(result, Err(Error::Parse(_))));
    }

    #[test]
    fn text_handles_misaligned_byte_range() {
        // Verify the C1 fix — out-of-bounds / mid-codepoint range
        // returns "" instead of panicking. We can't easily construct a
        // Node here, but we can validate the underlying defensive
        // pattern.
        let src = "héllo";
        // Range cuts the multi-byte 'é' in half.
        assert_eq!(src.get(1..2), None);
        assert_eq!(src.get(1..2).unwrap_or(""), "");
    }

    #[test]
    fn path_to_relpath_rejects_non_utf8() {
        // On all mainstream platforms file paths are valid UTF-8.
        // This test validates the explicit rejection path exists.
        let p = std::path::Path::new("valid/path.rs");
        assert!(super::path_to_relpath(p).is_ok());
    }

    /// M6 negative path: non-UTF-8 bytes must produce Error::Parse, not a
    /// replacement-character path. Uses OsStr::from_bytes (Unix-only API) to
    /// inject 0xFF bytes that are valid OS paths but invalid UTF-8.
    #[cfg(unix)]
    #[test]
    fn path_to_relpath_rejects_invalid_utf8_bytes() {
        use std::os::unix::ffi::OsStrExt;

        // 0xFF 0x00 0xFF — valid bytes on a Unix filesystem but invalid UTF-8.
        let raw = std::ffi::OsStr::from_bytes(&[0xFF, 0x00, 0xFF]);
        let p = std::path::Path::new(raw);
        let result = super::path_to_relpath(p);
        assert!(
            matches!(result, Err(Error::Parse(_))),
            "expected Error::Parse for non-UTF-8 path, got {:?}",
            result
        );
    }
}

/// M6: Convert a `Path` to a UTF-8 string, explicitly rejecting non-UTF-8
/// paths instead of silently replacing them with `\u{FFFD}` via
/// `to_string_lossy`. This surfaces encoding issues early instead of
/// creating phantom filenames in the index.
pub(crate) fn path_to_relpath(path: &std::path::Path) -> Result<String> {
    path.to_str()
        .map(|s| s.to_string())
        .ok_or_else(|| Error::Parse(format!(
            "non-UTF-8 path rejected: {}",
            path.to_string_lossy()
        )))
}
