//! Walk the repository, honoring `.gitignore`/`.hephaesignore`, and classify
//! every text file as code or non-code (decision #6: both are sources).

use crate::config::Config;
use anyhow::Result;
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
    Code,
    Doc,
    Config,
    Data,
    Other,
}

impl Category {
    pub fn label(self) -> &'static str {
        match self {
            Category::Code => "code",
            Category::Doc => "doc",
            Category::Config => "config",
            Category::Data => "data",
            Category::Other => "other",
        }
    }
}

#[derive(Debug, Clone)]
pub struct SourceFile {
    /// Path relative to the repo root, using forward slashes.
    pub rel: String,
    pub abs: PathBuf,
    pub lang: &'static str,
    pub category: Category,
    #[allow(dead_code)]
    pub size: u64,
}

#[derive(Debug)]
pub struct Scan {
    #[allow(dead_code)]
    pub root: PathBuf,
    pub files: Vec<SourceFile>,
    /// A compact directory tree string for the planner.
    pub tree: String,
}

impl Scan {
    pub fn count_by_category(&self) -> Vec<(Category, usize)> {
        let cats = [
            Category::Code,
            Category::Doc,
            Category::Config,
            Category::Data,
            Category::Other,
        ];
        cats.into_iter()
            .map(|c| (c, self.files.iter().filter(|f| f.category == c).count()))
            .filter(|(_, n)| *n > 0)
            .collect()
    }

    pub fn languages(&self) -> Vec<(&'static str, usize)> {
        let mut map: std::collections::BTreeMap<&'static str, usize> = Default::default();
        for f in &self.files {
            if f.category == Category::Code {
                *map.entry(f.lang).or_default() += 1;
            }
        }
        let mut v: Vec<_> = map.into_iter().collect();
        v.sort_by_key(|&(_, n)| std::cmp::Reverse(n));
        v
    }
}

const HARD_SKIP_DIRS: &[&str] = &[
    ".git",
    ".hephaes",
    "node_modules",
    "target",
    "vendor",
    "dist",
    "build",
    ".venv",
    "venv",
    "__pycache__",
    ".next",
    ".turbo",
    ".cargo",
];

/// Scan the repository (rooted at `root`) into a list of text source files
/// plus a compact tree string.
pub fn scan_root(root: &Path, cfg: &Config) -> Result<Scan> {
    let wiki_abs = abs_join(root, &cfg.wiki_dir);
    let includes = build_globset(&cfg.include)?;
    let excludes = build_globset(&cfg.exclude)?;

    let mut files = Vec::new();
    let mut tree_paths: Vec<String> = Vec::new();

    let walker = WalkBuilder::new(root)
        .standard_filters(true)
        .add_custom_ignore_filename(".hephaesignore")
        .hidden(true)
        .parents(true)
        .build();

    for result in walker {
        let entry = match result {
            Ok(e) => e,
            Err(_) => continue,
        };
        let path = entry.path();
        if path == root {
            continue;
        }
        // Skip anything inside the wiki output dir.
        if path.starts_with(&wiki_abs) {
            continue;
        }
        if has_skipped_component(path, root) {
            continue;
        }
        let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
        if !is_file {
            continue;
        }

        let rel = rel_path(root, path);
        if let Some(inc) = &includes {
            if !inc.is_match(&rel) {
                continue;
            }
        }
        if let Some(exc) = &excludes {
            if exc.is_match(&rel) {
                continue;
            }
        }

        let meta = match entry.metadata() {
            Ok(m) => m,
            Err(_) => continue,
        };
        let size = meta.len();
        if size > cfg.max_file_bytes {
            continue;
        }
        if is_probably_binary(path) {
            continue;
        }

        let (lang, category) = classify(&rel);
        tree_paths.push(rel.clone());
        files.push(SourceFile {
            rel,
            abs: path.to_path_buf(),
            lang,
            category,
            size,
        });
    }

    files.sort_by(|a, b| a.rel.cmp(&b.rel));
    tree_paths.sort();
    let tree = render_tree(&tree_paths);

    Ok(Scan {
        root: root.to_path_buf(),
        files,
        tree,
    })
}

fn has_skipped_component(path: &Path, root: &Path) -> bool {
    let rel = match path.strip_prefix(root) {
        Ok(r) => r,
        Err(_) => path,
    };
    rel.components().any(|c| {
        if let std::path::Component::Normal(os) = c {
            if let Some(s) = os.to_str() {
                return HARD_SKIP_DIRS.contains(&s);
            }
        }
        false
    })
}

fn rel_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
}

fn abs_join(root: &Path, sub: &str) -> PathBuf {
    let p = Path::new(sub);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        root.join(p)
    }
}

fn build_globset(patterns: &[String]) -> Result<Option<GlobSet>> {
    if patterns.is_empty() {
        return Ok(None);
    }
    let mut b = GlobSetBuilder::new();
    for p in patterns {
        b.add(Glob::new(p)?);
    }
    Ok(Some(b.build()?))
}

fn is_probably_binary(path: &Path) -> bool {
    use std::io::Read;
    let mut f = match std::fs::File::open(path) {
        Ok(f) => f,
        Err(_) => return true,
    };
    let mut buf = [0u8; 8192];
    let n = f.read(&mut buf).unwrap_or(0);
    buf[..n].contains(&0)
}

/// (language, category) from the path/extension.
fn classify(rel: &str) -> (&'static str, Category) {
    let name = rel.rsplit('/').next().unwrap_or(rel).to_ascii_lowercase();
    let ext = name.rsplit('.').next().unwrap_or("").to_string();

    // Filename-based special cases first.
    if name == "readme" || name.starts_with("readme.") {
        return ("markdown", Category::Doc);
    }
    if name == "dockerfile" || name == "makefile" || name == "justfile" {
        return ("config", Category::Config);
    }
    if name.starts_with("license") || name.starts_with("copying") {
        return ("text", Category::Doc);
    }

    match ext.as_str() {
        "rs" => ("rust", Category::Code),
        "py" => ("python", Category::Code),
        "ts" | "tsx" => ("typescript", Category::Code),
        "js" | "jsx" | "mjs" | "cjs" => ("javascript", Category::Code),
        "go" => ("go", Category::Code),
        "java" => ("java", Category::Code),
        "kt" | "kts" => ("kotlin", Category::Code),
        "c" | "h" => ("c", Category::Code),
        "cc" | "cpp" | "cxx" | "hpp" | "hh" => ("cpp", Category::Code),
        "cs" => ("csharp", Category::Code),
        "rb" => ("ruby", Category::Code),
        "php" => ("php", Category::Code),
        "swift" => ("swift", Category::Code),
        "scala" => ("scala", Category::Code),
        "sh" | "bash" | "zsh" => ("shell", Category::Code),
        "lua" => ("lua", Category::Code),
        "ex" | "exs" => ("elixir", Category::Code),
        "dart" => ("dart", Category::Code),
        "sql" => ("sql", Category::Code),

        "md" | "markdown" | "mdx" => ("markdown", Category::Doc),
        "rst" | "adoc" | "txt" | "org" => ("text", Category::Doc),

        "toml" | "yaml" | "yml" | "json" | "ini" | "cfg" | "conf" | "env" | "properties" => {
            ("config", Category::Config)
        }
        "xml" | "gradle" | "lock" => ("config", Category::Config),

        "csv" | "tsv" | "ndjson" | "parquet" => ("data", Category::Data),

        _ => ("text", Category::Other),
    }
}

/// A compact indented tree of all scanned paths (capped to keep prompts small).
fn render_tree(paths: &[String]) -> String {
    const MAX_LINES: usize = 600;
    let mut out = String::new();
    for (shown, p) in paths.iter().enumerate() {
        if shown >= MAX_LINES {
            out.push_str(&format!("… (+{} more files)\n", paths.len() - shown));
            break;
        }
        let depth = p.matches('/').count();
        let name = p.rsplit('/').next().unwrap_or(p);
        for _ in 0..depth {
            out.push_str("  ");
        }
        out.push_str(name);
        out.push('\n');
    }
    out
}
