//! Project walker for traversing and analyzing codebases.
//!
//! The walker:
//! 1. Traverses the project directory (respecting .gitignore)
//! 2. Detects the primary language
//! 3. Maps file paths to ConceptPath segments
//! 4. Filters files based on configuration
//!
//! ## Exclude Patterns
//!
//! The `exclude` list in `aphoria.toml` supports glob patterns:
//!
//! - `*` matches any sequence of characters except `/`
//! - `**` matches any sequence of characters including `/`
//! - `?` matches any single character except `/`
//! - `[abc]` matches any character in the brackets
//! - `{a,b}` matches either `a` or `b`
//!
//! Examples:
//! ```toml
//! [scan]
//! exclude = [
//!     "target/**",           # All files under target/
//!     "**/test_fixtures/**", # test_fixtures anywhere
//!     "**/*_test.rs",        # All Rust test files
//!     "docs/demo/**",        # Demo apps
//! ]
//! ```

mod git;
mod ignore_file;
mod language;
mod path_mapper;

pub use git::{find_repo_root, get_staged_files};
pub use ignore_file::IgnorePatterns;
pub use path_mapper::PathMapper;

use std::path::Path;

use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;

use crate::config::AphoriaConfig;
use crate::types::Language;
use crate::AphoriaError;

/// A file discovered during walking.
#[derive(Debug)]
pub struct WalkedFile {
    /// Absolute path to the file.
    pub path: std::path::PathBuf,

    /// Path relative to project root.
    pub relative_path: String,

    /// Detected language.
    pub language: Language,

    /// ConceptPath segments derived from the path.
    pub path_segments: Vec<String>,
}

/// Build a GlobSet from a list of exclude patterns.
///
/// Supports standard glob syntax:
/// - `*` matches any sequence of characters except `/`
/// - `**` matches any sequence of characters including `/`
/// - `?` matches any single character except `/`
/// - `[abc]` matches any character in the brackets
/// - `{a,b}` matches either `a` or `b`
///
/// For backwards compatibility, patterns without wildcards are treated as prefixes.
fn build_exclude_globset(patterns: &[String]) -> Result<GlobSet, AphoriaError> {
    let mut builder = GlobSetBuilder::new();

    for pattern in patterns {
        // Normalize pattern: remove trailing slashes for consistent matching
        let pattern = pattern.trim_end_matches('/');

        // Check if pattern contains glob characters
        let has_glob_chars = pattern.contains('*')
            || pattern.contains('?')
            || pattern.contains('[')
            || pattern.contains('{');

        let glob_pattern = if has_glob_chars {
            // Use pattern as-is (it's already a glob)
            pattern.to_string()
        } else {
            // Legacy prefix pattern: convert to glob by adding /**
            format!("{pattern}/**")
        };

        let glob = Glob::new(&glob_pattern).map_err(|e| {
            AphoriaError::Walker(format!("Invalid exclude pattern '{pattern}': {e}"))
        })?;
        builder.add(glob);

        // Also match the directory itself (not just its contents)
        if !has_glob_chars {
            let dir_glob = Glob::new(pattern).map_err(|e| {
                AphoriaError::Walker(format!("Invalid exclude pattern '{pattern}': {e}"))
            })?;
            builder.add(dir_glob);
        }
    }

    builder
        .build()
        .map_err(|e| AphoriaError::Walker(format!("Failed to build exclude patterns: {e}")))
}

/// Walk a project directory and yield files for extraction.
pub fn walk_project(root: &Path, config: &AphoriaConfig) -> Result<Vec<WalkedFile>, AphoriaError> {
    // Load .aphoriaignore if present
    let ignore_patterns = IgnorePatterns::load(root)?;
    walk_project_with_ignore(root, config, &ignore_patterns)
}

/// Walk a project directory with pre-loaded ignore patterns.
pub fn walk_project_with_ignore(
    root: &Path,
    config: &AphoriaConfig,
    ignore_patterns: &IgnorePatterns,
) -> Result<Vec<WalkedFile>, AphoriaError> {
    if !root.exists() {
        return Err(AphoriaError::ProjectNotFound(root.to_path_buf()));
    }

    // Combine config excludes with .aphoriaignore patterns
    let mut all_excludes = config.scan.exclude.clone();
    all_excludes.extend(ignore_patterns.patterns().iter().cloned());

    // Build globset for exclude patterns
    let exclude_globset = build_exclude_globset(&all_excludes)?;

    let mut files = Vec::new();
    let mapper = PathMapper::new(root, config);

    let walker = WalkBuilder::new(root)
        .hidden(false) // Include hidden files (we filter selectively below)
        .git_ignore(true) // Respect .gitignore
        .filter_entry(|entry| {
            let file_name = entry.path().file_name().and_then(|s| s.to_str()).unwrap_or("");

            // Always exclude .git directory
            if file_name == ".git" {
                return false;
            }

            // Include .env files explicitly (important for secrets detection)
            if file_name.starts_with(".env") {
                return true;
            }

            // Exclude other hidden directories (but not hidden files)
            if file_name.starts_with('.') && entry.path().is_dir() {
                return false;
            }

            true
        })
        .build();

    for entry in walker {
        let entry = entry
            .map_err(|e| AphoriaError::Walker(format!("Failed to read directory entry: {e}")))?;
        if let Some(walked) = process_file(entry.path(), root, config, &mapper, &exclude_globset) {
            files.push(walked);
        }
    }

    Ok(files)
}

/// Check if a file is a test file.
fn is_test_file(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("test")
        || lower.contains("spec")
        || lower.contains("_test.")
        || lower.contains(".test.")
        || lower.contains("tests/")
        || lower.contains("__tests__")
}

/// Filter and process a candidate file path into a WalkedFile.
///
/// Returns `None` if the file should be skipped (directory, too large,
/// excluded, unknown language, or test file when tests are excluded).
fn process_file(
    path: &Path,
    scan_root: &Path,
    config: &AphoriaConfig,
    mapper: &PathMapper,
    exclude_globset: &GlobSet,
) -> Option<WalkedFile> {
    // Skip directories
    if path.is_dir() {
        return None;
    }

    // Skip files that are too large
    if let Ok(metadata) = path.metadata() {
        if metadata.len() > config.scan.max_file_size {
            return None;
        }
    }

    // Get relative path
    let relative = path.strip_prefix(scan_root).ok()?;
    let relative_str = relative.to_string_lossy().to_string();

    // Check exclusions using glob patterns
    if exclude_globset.is_match(&relative_str) {
        return None;
    }

    // Detect language
    let language = Language::from_path(path);

    // Skip unknown file types
    if language == Language::Unknown {
        return None;
    }

    // Skip test files if configured
    if !config.scan.include_tests && is_test_file(&relative_str) {
        return None;
    }

    // Map to concept path segments
    let path_segments = mapper.to_segments(&relative_str, language);

    Some(WalkedFile {
        path: path.to_path_buf(),
        relative_path: relative_str,
        language,
        path_segments,
    })
}

/// Walk only git-staged files within the scan root.
///
/// This is used for fast pre-commit scanning. It:
/// 1. Finds the git repository root
/// 2. Gets staged files via `git diff --cached`
/// 3. Filters to only files within the scan root
/// 4. Applies the same filters as `walk_project()` (size, language, test exclusion)
pub fn walk_staged_files(
    scan_root: &Path,
    config: &AphoriaConfig,
) -> Result<Vec<WalkedFile>, AphoriaError> {
    // Find the git repository root (may be a parent of scan_root)
    let repo_root = find_repo_root(scan_root)?;

    // Get all staged files from git
    let staged_files = get_staged_files(&repo_root)?;

    // Canonicalize scan_root for path comparisons
    let scan_root_canonical = scan_root.canonicalize().unwrap_or_else(|_| scan_root.to_path_buf());

    // Load .aphoriaignore if present
    let ignore_patterns = IgnorePatterns::load(&scan_root_canonical)?;

    // Combine config excludes with .aphoriaignore patterns
    let mut all_excludes = config.scan.exclude.clone();
    all_excludes.extend(ignore_patterns.patterns().iter().cloned());

    // Build globset for exclude patterns
    let exclude_globset = build_exclude_globset(&all_excludes)?;

    let mapper = PathMapper::new(&scan_root_canonical, config);
    let mut files = Vec::new();

    for path in staged_files {
        // Canonicalize the staged file path
        let path_canonical = match path.canonicalize() {
            Ok(p) => p,
            Err(_) => continue, // File might have been deleted after staging
        };

        // Skip if not within scan root
        if !path_canonical.starts_with(&scan_root_canonical) {
            continue;
        }

        // Use shared helper for filtering and processing
        if let Some(walked) =
            process_file(&path_canonical, &scan_root_canonical, config, &mapper, &exclude_globset)
        {
            files.push(walked);
        }
    }

    Ok(files)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_is_test_file() {
        assert!(is_test_file("src/auth/jwt_test.rs"));
        assert!(is_test_file("tests/integration.rs"));
        assert!(is_test_file("src/__tests__/app.tsx"));
        assert!(!is_test_file("src/auth/jwt.rs"));
    }

    #[test]
    fn test_env_files_included() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let root = temp_dir.path();

        // Create .env files
        fs::write(root.join(".env"), "SECRET=abc123").expect("write .env");
        fs::write(root.join(".env.production"), "DB_PASSWORD=prod123")
            .expect("write .env.production");

        // Create a .git directory (should be excluded)
        fs::create_dir(root.join(".git")).expect("create .git");
        fs::write(root.join(".git/config"), "[core]").expect("write .git/config");

        // Create a normal file
        fs::write(root.join("main.rs"), "fn main() {}").expect("write main.rs");

        let config = AphoriaConfig::default();
        let files = walk_project(root, &config).expect("walk project");

        // Check that .env files are included
        let paths: Vec<_> = files.iter().map(|f| f.relative_path.as_str()).collect();
        assert!(paths.contains(&".env"), ".env should be included");
        assert!(paths.contains(&".env.production"), ".env.production should be included");

        // Check that .git is excluded
        assert!(!paths.iter().any(|p| p.contains(".git")), ".git should be excluded");
    }

    #[test]
    fn test_glob_exclude_patterns() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let root = temp_dir.path();

        // Create a nested structure
        fs::create_dir_all(root.join("src/auth")).expect("create src/auth");
        fs::create_dir_all(root.join("uat/fixtures")).expect("create uat/fixtures");
        fs::create_dir_all(root.join("docs/demo/vulnbank")).expect("create docs/demo/vulnbank");

        // Create files
        fs::write(root.join("src/auth/jwt.rs"), "fn verify() {}").expect("write jwt.rs");
        fs::write(root.join("src/auth/jwt_test.rs"), "fn test() {}").expect("write jwt_test.rs");
        fs::write(root.join("uat/fixtures/bad_tls.rs"), "// insecure").expect("write fixture");
        fs::write(root.join("docs/demo/vulnbank/main.rs"), "// vuln").expect("write vulnbank");

        // Configure glob excludes
        let mut config = AphoriaConfig::default();
        config.scan.exclude = vec![
            "uat/fixtures/**".to_string(),
            "docs/demo/**".to_string(),
            "**/*_test.rs".to_string(),
        ];
        config.scan.include_tests = true; // Don't let test detection interfere

        let files = walk_project(root, &config).expect("walk project");
        let paths: Vec<_> = files.iter().map(|f| f.relative_path.as_str()).collect();

        // jwt.rs should be included
        assert!(paths.contains(&"src/auth/jwt.rs"), "jwt.rs should be included");

        // Test file should be excluded by glob
        assert!(
            !paths.contains(&"src/auth/jwt_test.rs"),
            "jwt_test.rs should be excluded by **/*_test.rs"
        );

        // Fixtures should be excluded
        assert!(
            !paths.iter().any(|p| p.contains("uat/fixtures")),
            "uat/fixtures should be excluded"
        );

        // Vulnbank should be excluded
        assert!(!paths.iter().any(|p| p.contains("vulnbank")), "vulnbank should be excluded");
    }

    #[test]
    fn test_legacy_prefix_patterns() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let root = temp_dir.path();

        // Create structure
        fs::create_dir_all(root.join("vendor/lib")).expect("create vendor/lib");
        fs::create_dir_all(root.join("src")).expect("create src");

        // Create files
        fs::write(root.join("vendor/lib/dep.rs"), "fn dep() {}").expect("write dep.rs");
        fs::write(root.join("src/main.rs"), "fn main() {}").expect("write main.rs");

        // Use legacy prefix pattern (no glob chars)
        let mut config = AphoriaConfig::default();
        config.scan.exclude = vec!["vendor/".to_string()];

        let files = walk_project(root, &config).expect("walk project");
        let paths: Vec<_> = files.iter().map(|f| f.relative_path.as_str()).collect();

        // main.rs should be included
        assert!(paths.contains(&"src/main.rs"), "main.rs should be included");

        // vendor should be excluded (legacy prefix behavior)
        assert!(!paths.iter().any(|p| p.contains("vendor")), "vendor should be excluded");
    }

    #[test]
    fn test_aphoriaignore_file() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let root = temp_dir.path();

        // Create structure
        fs::create_dir_all(root.join("uat/fixtures")).expect("create uat/fixtures");
        fs::create_dir_all(root.join("src")).expect("create src");

        // Create files
        fs::write(root.join("uat/fixtures/bad.rs"), "// bad").expect("write bad.rs");
        fs::write(root.join("src/main.rs"), "fn main() {}").expect("write main.rs");

        // Create .aphoriaignore
        fs::write(root.join(".aphoriaignore"), "uat/fixtures/").expect("write .aphoriaignore");

        let config = AphoriaConfig::default();
        let files = walk_project(root, &config).expect("walk project");
        let paths: Vec<_> = files.iter().map(|f| f.relative_path.as_str()).collect();

        // main.rs should be included
        assert!(paths.contains(&"src/main.rs"), "main.rs should be included");

        // uat/fixtures should be excluded via .aphoriaignore
        assert!(
            !paths.iter().any(|p| p.contains("uat/fixtures")),
            "uat/fixtures should be excluded"
        );
    }

    #[test]
    fn test_build_exclude_globset_valid() {
        let patterns =
            vec!["target/**".to_string(), "**/test_fixtures/**".to_string(), "vendor/".to_string()];
        let globset = build_exclude_globset(&patterns).expect("build globset");

        assert!(globset.is_match("target/debug/build"));
        assert!(globset.is_match("src/test_fixtures/bad.rs"));
        assert!(globset.is_match("vendor/lib.rs"));
    }

    #[test]
    fn test_build_exclude_globset_invalid() {
        let patterns = vec!["[invalid".to_string()];
        let result = build_exclude_globset(&patterns);
        assert!(result.is_err());
    }
}
