//! Repo context engine: when lucid-train opens inside a project (VS Code,
//! Cursor, any repo), it builds a compact map of the codebase — file tree +
//! extracted symbols — split into per-file chunks. Each turn, only the chunks
//! relevant to the user's request are injected (keyword-scored, within a hard
//! character budget), so large repos never blow the context window.
//!
//! Project memory lives in `.lucid/knowledge.md` and `.lucid/CHANGELOG.md`,
//! which the agent is instructed to maintain — the map grows with the project.

use regex::Regex;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;

/// Hard budget for injected repo context (~2k tokens).
pub const CONTEXT_BUDGET_CHARS: usize = 8_000;
const MAX_FILES: usize = 2_000;
const MAX_FILE_BYTES: u64 = 300_000;
const MAX_SYMBOLS_PER_FILE: usize = 14;

const CODE_EXTS: &[&str] = &[
    "rs", "go", "py", "ts", "tsx", "js", "jsx", "java", "kt", "rb", "php", "c", "h", "cpp",
    "hpp", "cs", "swift", "scala", "sh", "sql", "proto", "vue", "svelte",
];

fn symbol_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?m)^\s*(?:pub\s+|export\s+|public\s+|private\s+|protected\s+|async\s+|static\s+)*(?:fn|func|def|class|struct|enum|trait|interface|impl|type|module)\s+[A-Za-z_][\w:<>]*",
        )
        .expect("valid symbol regex")
    })
}

#[derive(Debug, Clone)]
pub struct Chunk {
    pub path: String,
    pub text: String,
}

#[derive(Debug, Default)]
pub struct RepoContext {
    pub tree: String,
    pub chunks: Vec<Chunk>,
    pub knowledge: String,
}

impl RepoContext {
    pub fn is_empty(&self) -> bool {
        self.chunks.is_empty() && self.tree.is_empty()
    }

    /// Build the map for a directory. Uses `git ls-files` when available so
    /// .gitignore is respected; falls back to a bounded walk.
    pub fn build(root: &Path) -> RepoContext {
        let files = list_files(root);
        if files.is_empty() {
            return RepoContext::default();
        }

        let mut tree = String::from("Files:\n");
        let mut chunks = Vec::new();
        for rel in files.iter().take(MAX_FILES) {
            tree.push_str("  ");
            tree.push_str(rel);
            tree.push('\n');

            let ext = rel.rsplit('.').next().unwrap_or("");
            if !CODE_EXTS.contains(&ext) {
                continue;
            }
            let full = root.join(rel);
            if full.metadata().map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
                continue;
            }
            let Ok(content) = std::fs::read_to_string(&full) else { continue };
            let symbols: Vec<&str> = symbol_re()
                .find_iter(&content)
                .take(MAX_SYMBOLS_PER_FILE)
                .map(|m| m.as_str().trim())
                .collect();
            if symbols.is_empty() {
                continue;
            }
            let text = format!("{rel}:\n  {}", symbols.join("\n  "));
            chunks.push(Chunk { path: rel.clone(), text });
        }
        // Cap the tree itself.
        if tree.len() > CONTEXT_BUDGET_CHARS / 2 {
            tree.truncate(CONTEXT_BUDGET_CHARS / 2);
            tree.push_str("\n  …(tree truncated)");
        }

        let knowledge = std::fs::read_to_string(root.join(".lucid/knowledge.md"))
            .unwrap_or_default()
            .chars()
            .take(2_500)
            .collect();

        RepoContext { tree, chunks, knowledge }
    }

    /// Select the chunks most relevant to `query`, within the budget.
    /// Simple, fast lexical scoring: term hits in path (3x) and symbols (1x).
    pub fn retrieve(&self, query: &str, budget_chars: usize) -> String {
        if self.is_empty() {
            return String::new();
        }
        let terms: Vec<String> = query
            .split(|c: char| !c.is_alphanumeric() && c != '_')
            .filter(|w| w.len() > 2)
            .map(|w| w.to_ascii_lowercase())
            .collect();

        let mut scored: Vec<(i64, &Chunk)> = self
            .chunks
            .iter()
            .map(|c| {
                let path_l = c.path.to_ascii_lowercase();
                let text_l = c.text.to_ascii_lowercase();
                let mut score = 0i64;
                for t in &terms {
                    if path_l.contains(t.as_str()) {
                        score += 3;
                    }
                    score += text_l.matches(t.as_str()).count() as i64;
                }
                (score, c)
            })
            .collect();
        scored.sort_by(|a, b| b.0.cmp(&a.0));

        let mut out = String::new();
        if !self.knowledge.is_empty() {
            out.push_str("Project knowledge (.lucid/knowledge.md):\n");
            out.push_str(&self.knowledge);
            out.push_str("\n\n");
        }
        out.push_str(&self.tree);
        out.push_str("\nRelevant symbols:\n");
        for (score, chunk) in scored {
            if out.len() + chunk.text.len() > budget_chars {
                break;
            }
            // Include zero-score chunks only while plenty of budget remains
            // (small repos fit whole; big repos stay query-focused).
            if score == 0 && out.len() > budget_chars / 2 {
                break;
            }
            out.push_str(&chunk.text);
            out.push('\n');
        }
        out.truncate(budget_chars);
        out
    }
}

fn list_files(root: &Path) -> Vec<String> {
    // Prefer git: respects .gitignore.
    if let Ok(out) = Command::new("git")
        .args(["-C", &root.display().to_string(), "ls-files", "--cached", "--others", "--exclude-standard"])
        .output()
    {
        if out.status.success() {
            let files: Vec<String> = String::from_utf8_lossy(&out.stdout)
                .lines()
                .map(str::to_string)
                .filter(|f| !f.is_empty())
                .take(MAX_FILES)
                .collect();
            if !files.is_empty() {
                return files;
            }
        }
    }
    // Fallback: bounded recursive walk.
    let mut files = Vec::new();
    walk(root, root, &mut files, 0);
    files
}

fn walk(root: &Path, dir: &Path, files: &mut Vec<String>, depth: usize) {
    if depth > 6 || files.len() >= MAX_FILES {
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else { return };
    for entry in entries.flatten() {
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        if name.starts_with('.')
            || ["node_modules", "target", "dist", "build", "venv", "__pycache__", "vendor"]
                .contains(&name.as_str())
        {
            continue;
        }
        if path.is_dir() {
            walk(root, &path, files, depth + 1);
        } else if let Ok(rel) = path.strip_prefix(root) {
            files.push(rel.display().to_string());
            if files.len() >= MAX_FILES {
                return;
            }
        }
    }
}

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

    fn fixture_repo(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("lucid-repomap-{}-{name}", std::process::id()));
        let src = dir.join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(
            src.join("auth.rs"),
            "pub fn login(user: &str) {}\npub fn logout() {}\npub struct Session {}\n",
        )
        .unwrap();
        std::fs::write(
            src.join("billing.rs"),
            "pub fn charge_card(amount: u64) {}\npub struct Invoice {}\n",
        )
        .unwrap();
        std::fs::write(dir.join("README.md"), "# test\n").unwrap();
        dir
    }

    #[test]
    fn builds_and_retrieves_relevant_chunks() {
        let dir = fixture_repo("retrieve");
        let ctx = RepoContext::build(&dir);
        assert!(!ctx.is_empty());
        assert!(ctx.tree.contains("src/auth.rs"), "{}", ctx.tree);

        let out = ctx.retrieve("fix the login session bug in auth", 4_000);
        assert!(out.contains("fn login"), "{out}");
        // Ranked section: the auth chunk must be listed before billing.
        let ranked = &out[out.find("Relevant symbols:").unwrap()..];
        let auth_pos = ranked.find("auth.rs").unwrap();
        let billing_pos = ranked.find("billing.rs").unwrap_or(usize::MAX);
        assert!(auth_pos < billing_pos, "{ranked}");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn respects_budget() {
        let dir = fixture_repo("budget");
        let ctx = RepoContext::build(&dir);
        let out = ctx.retrieve("anything", 200);
        assert!(out.len() <= 200);
        std::fs::remove_dir_all(&dir).ok();
    }
}
