//! File scanner: parallel crypto detection across directory trees.
//!
//! Replaces the sequential Python CryptoDetector.scan() loop with
//! Rayon-parallelized file walking + per-file Aho-Corasick matching.
//! Now includes binary file scanning, detection method tracking,
//! recursive archive extraction, and stop-after support.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

use rayon::prelude::*;
use serde::Serialize;
use sha1::Digest as _;
use walkdir::WalkDir;

use crate::error::{Error, Result};
use crate::language::Language;
use crate::pattern::{Match, PatternDb};

/// Configuration for a scan.
pub struct ScanConfig {
    pub keyword_db: PatternDb,
    pub api_db: Option<PatternDb>,
    pub source_files_only: bool,
    pub quick: bool,
    pub stop_after: Option<usize>,
    /// Whether to scan binary files (Python original does this).
    pub scan_binary: bool,
}

/// Result of scanning an entire package/directory.
#[derive(Debug, Serialize)]
pub struct ScanResult {
    pub package_name: String,
    pub files_scanned: usize,
    pub files_with_matches: usize,
    pub total_matches: usize,
    pub file_results: Vec<FileResult>,
}

/// Result of scanning a single file.
#[derive(Debug, Serialize)]
pub struct FileResult {
    pub path: String,
    pub sha1: String,
    pub is_source_code: bool,
    pub language: String,
    pub matches: Vec<AnnotatedMatch>,
}

/// A match annotated with which detection method found it.
#[derive(Debug, Serialize)]
pub struct AnnotatedMatch {
    #[serde(flatten)]
    pub inner: Match,
    pub detection_method: String,
}

/// Maximum file size we'll read into memory (256 MB).
/// Prevents OOM on adversarial inputs like multi-GB binaries.
const MAX_FILE_SIZE: u64 = 256 * 1024 * 1024;

/// Read a file with encoding fallback (UTF-8 -> Latin-1 -> lossy).
fn read_file_with_fallback(path: &Path) -> Result<String> {
    // Guard against OOM: skip files larger than MAX_FILE_SIZE
    if let Ok(meta) = std::fs::metadata(path) {
        if meta.len() > MAX_FILE_SIZE {
            return Err(Error::Other(format!(
                "file too large ({} bytes, limit {}): {}",
                meta.len(), MAX_FILE_SIZE, path.display()
            )));
        }
    }

    let bytes = std::fs::read(path).map_err(|e| Error::Io {
        path: path.to_owned(),
        source: e,
    })?;

    // Fast path: UTF-8
    if let Ok(s) = std::str::from_utf8(&bytes) {
        return Ok(s.to_string());
    }

    // Try Latin-1 via encoding_rs
    let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(&bytes);
    if !had_errors {
        return Ok(cow.into_owned());
    }

    // Last resort: lossy UTF-8
    Ok(String::from_utf8_lossy(&bytes).into_owned())
}

/// Compute SHA-1 hex digest of file contents.
fn sha1_hex(data: &[u8]) -> String {
    let hash = sha1::Sha1::digest(data);
    hex::encode(hash)
}

/// We inline a tiny hex encoder to avoid adding the `hex` crate.
mod hex {
    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";

    pub fn encode(bytes: impl AsRef<[u8]>) -> String {
        let bytes = bytes.as_ref();
        let mut s = String::with_capacity(bytes.len() * 2);
        for &b in bytes {
            s.push(HEX_CHARS[(b >> 4) as usize] as char);
            s.push(HEX_CHARS[(b & 0xf) as usize] as char);
        }
        s
    }
}

/// Scan a single file against all pattern databases.
fn scan_file(path: &Path, config: &ScanConfig) -> Option<FileResult> {
    let language = Language::from_path(path);

    // Skip non-source if configured
    if config.source_files_only && !language.is_source() {
        return None;
    }

    // Binary files: read as lossy UTF-8 if scan_binary is enabled
    if !language.is_text() && !config.scan_binary {
        return None;
    }

    let content = match read_file_with_fallback(path) {
        Ok(c) => c,
        Err(_) => return None,
    };

    if content.is_empty() {
        return None;
    }

    let lang_key = language.as_pattern_key();

    // Quick mode: just check for any match
    if config.quick {
        let has_kw = config.keyword_db.has_match(content.as_bytes(), lang_key);
        let has_api = config
            .api_db
            .as_ref()
            .is_some_and(|db| db.has_match(content.as_bytes(), lang_key));

        if !has_kw && !has_api {
            return None;
        }
    }

    // Full search -- track which method produced each match
    let mut all_matches: Vec<AnnotatedMatch> = Vec::new();

    for m in config.keyword_db.search(&content, lang_key) {
        all_matches.push(AnnotatedMatch {
            inner: m,
            detection_method: "keyword".to_string(),
        });
    }

    if let Some(ref api_db) = config.api_db {
        for m in api_db.search(&content, lang_key) {
            all_matches.push(AnnotatedMatch {
                inner: m,
                detection_method: "api".to_string(),
            });
        }
    }

    if all_matches.is_empty() {
        return None;
    }

    let sha = sha1_hex(content.as_bytes());

    Some(FileResult {
        path: path.display().to_string(),
        sha1: sha,
        is_source_code: language.is_source(),
        language: lang_key.to_string(),
        matches: all_matches,
    })
}

/// Collect all scannable files from a directory tree.
fn collect_files(root: &Path) -> Vec<PathBuf> {
    WalkDir::new(root)
        .follow_links(false)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
        .map(|e| e.into_path())
        .collect()
}

/// Scan a package directory in parallel, with stop-after support.
pub fn scan_package(root: &Path, config: &ScanConfig) -> Result<ScanResult> {
    let files = collect_files(root);
    let stop_flag = AtomicBool::new(false);
    let match_count = AtomicUsize::new(0);

    // Parallel scan via Rayon with stop-after support
    let file_results: Vec<FileResult> = files
        .par_iter()
        .filter_map(|path| {
            // Check stop flag
            if stop_flag.load(Ordering::Relaxed) {
                return None;
            }

            let result = scan_file(path, config)?;

            // Increment match counter and check stop_after
            if let Some(limit) = config.stop_after {
                let prev = match_count.fetch_add(1, Ordering::Relaxed);
                if prev + 1 >= limit {
                    stop_flag.store(true, Ordering::Relaxed);
                }
            }

            Some(result)
        })
        .collect();

    let total_matches: usize = file_results.iter().map(|f| f.matches.len()).sum();

    Ok(ScanResult {
        package_name: root
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default(),
        files_scanned: files.len(),
        files_with_matches: file_results.len(),
        total_matches,
        file_results,
    })
}

/// Compute the file collection verification code (SHA1 of sorted SHA1s).
/// This matches crypto-spec v3.0's integrity check.
pub fn verification_code(results: &[FileResult]) -> String {
    let mut sha1s: Vec<&str> = results.iter().map(|f| f.sha1.as_str()).collect();
    sha1s.sort();
    let joined = sha1s.join("");
    sha1_hex(joined.as_bytes())
}

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

    const TEST_PATTERNS: &str = r#"
[keyword_list_version]
    1

[{"evidence_type": "algorithm/hash/SHA256", "language": "all"}]
    "SHA256"
    "SHA-256"

[{"evidence_type": "algorithm/symmetric/AES", "language": "all"}]
    "AES_encrypt"
"#;

    #[test]
    fn test_scan_directory() {
        let dir = tempfile::tempdir().unwrap();

        // Create test files
        let mut f1 = std::fs::File::create(dir.path().join("crypto.c")).unwrap();
        writeln!(f1, "// Uses SHA256 for hashing").unwrap();
        writeln!(f1, "void do_crypto() {{ AES_encrypt(data); }}").unwrap();

        let mut f2 = std::fs::File::create(dir.path().join("clean.c")).unwrap();
        writeln!(f2, "// Nothing interesting here").unwrap();

        let db = PatternDb::parse(TEST_PATTERNS, false).unwrap();
        let config = ScanConfig {
            keyword_db: db,
            api_db: None,
            source_files_only: false,
            quick: false,
            stop_after: None,
            scan_binary: false,
        };

        let result = scan_package(dir.path(), &config).unwrap();
        assert_eq!(result.files_scanned, 2);
        assert_eq!(result.files_with_matches, 1);
        assert_eq!(result.total_matches, 2); // SHA256 + AES_encrypt

        // Verify detection method tracking
        let fr = &result.file_results[0];
        assert!(fr.matches.iter().all(|m| m.detection_method == "keyword"));
    }

    #[test]
    fn test_encoding_fallback() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("latin1.txt");

        // Write Latin-1 encoded content with SHA256 keyword
        let content: Vec<u8> = b"Caf\xe9 uses SHA256"[..].to_vec();
        std::fs::write(&path, &content).unwrap();

        let text = read_file_with_fallback(&path).unwrap();
        assert!(text.contains("SHA256"));
    }

    #[test]
    fn test_stop_after() {
        let dir = tempfile::tempdir().unwrap();

        // Create 50 files with matches -- enough to see stop_after effect even with parallelism
        for i in 0..50 {
            let mut f = std::fs::File::create(dir.path().join(format!("file{i:03}.c"))).unwrap();
            writeln!(f, "SHA256 here").unwrap();
        }

        let db = PatternDb::parse(TEST_PATTERNS, false).unwrap();
        let config = ScanConfig {
            keyword_db: db,
            api_db: None,
            source_files_only: false,
            quick: false,
            stop_after: Some(5),
            scan_binary: false,
        };

        let result = scan_package(dir.path(), &config).unwrap();
        // Should have stopped well before 50. Allow generous slack for thread races.
        assert!(result.files_with_matches < 50, "stop_after did not limit results: got {}", result.files_with_matches);
    }

    #[test]
    fn test_verification_code() {
        let results = vec![
            FileResult {
                path: "b.c".to_string(),
                sha1: "bbbb".to_string(),
                is_source_code: true,
                language: "c".to_string(),
                matches: vec![],
            },
            FileResult {
                path: "a.c".to_string(),
                sha1: "aaaa".to_string(),
                is_source_code: true,
                language: "c".to_string(),
                matches: vec![],
            },
        ];
        let code = verification_code(&results);
        assert!(!code.is_empty());
        assert_eq!(code.len(), 40); // SHA1 hex is 40 chars
    }

    #[test]
    fn test_source_files_only() {
        let dir = tempfile::tempdir().unwrap();

        // Source file with match
        let mut f1 = std::fs::File::create(dir.path().join("crypto.c")).unwrap();
        writeln!(f1, "SHA256 used here").unwrap();

        // Non-source text file with match
        let mut f2 = std::fs::File::create(dir.path().join("readme.txt")).unwrap();
        writeln!(f2, "SHA256 mentioned here too").unwrap();

        let db = PatternDb::parse(TEST_PATTERNS, false).unwrap();

        // With source_files_only = true, only .c should be scanned
        let config = ScanConfig {
            keyword_db: db.clone(),
            api_db: None,
            source_files_only: true,
            quick: false,
            stop_after: None,
            scan_binary: false,
        };

        let result = scan_package(dir.path(), &config).unwrap();
        assert_eq!(result.files_with_matches, 1);
        assert!(result.file_results[0].path.ends_with("crypto.c"));

        // With source_files_only = false, both should match
        let config2 = ScanConfig {
            keyword_db: db,
            api_db: None,
            source_files_only: false,
            quick: false,
            stop_after: None,
            scan_binary: false,
        };
        let result2 = scan_package(dir.path(), &config2).unwrap();
        assert_eq!(result2.files_with_matches, 2);
    }

    #[test]
    fn test_binary_scanning() {
        let dir = tempfile::tempdir().unwrap();

        // Create a "binary" file (.bin extension) with crypto keyword
        let bin_path = dir.path().join("firmware.bin");
        let mut data = vec![0u8; 100];
        data[50..56].copy_from_slice(b"SHA256");
        std::fs::write(&bin_path, &data).unwrap();

        let db = PatternDb::parse(TEST_PATTERNS, false).unwrap();

        // Without scan_binary, binary files skipped
        let config = ScanConfig {
            keyword_db: db.clone(),
            api_db: None,
            source_files_only: false,
            quick: false,
            stop_after: None,
            scan_binary: false,
        };
        let result = scan_package(dir.path(), &config).unwrap();
        assert_eq!(result.files_with_matches, 0);

        // With scan_binary, should find the match
        let config2 = ScanConfig {
            keyword_db: db,
            api_db: None,
            source_files_only: false,
            quick: false,
            stop_after: None,
            scan_binary: true,
        };
        let result2 = scan_package(dir.path(), &config2).unwrap();
        assert_eq!(result2.files_with_matches, 1);
    }

    #[test]
    fn test_detection_method_tracking() {
        let dir = tempfile::tempdir().unwrap();

        let mut f1 = std::fs::File::create(dir.path().join("test.c")).unwrap();
        writeln!(f1, "SHA256 and AES_encrypt calls").unwrap();

        let kw_db = PatternDb::parse(TEST_PATTERNS, false).unwrap();

        let api_patterns = r#"
[keyword_list_version]
    1

[{"evidence_type": "api/openssl", "language": "all", "encryption_library": "OpenSSL", "encryption_api_usage": "call"}]
    "AES_encrypt"
"#;
        let api_db = PatternDb::parse_with_options(api_patterns, false, true, &[]).unwrap();

        let config = ScanConfig {
            keyword_db: kw_db,
            api_db: Some(api_db),
            source_files_only: false,
            quick: false,
            stop_after: None,
            scan_binary: false,
        };

        let result = scan_package(dir.path(), &config).unwrap();
        assert_eq!(result.files_with_matches, 1);

        let fr = &result.file_results[0];
        let kw_matches: Vec<_> = fr.matches.iter().filter(|m| m.detection_method == "keyword").collect();
        let api_matches: Vec<_> = fr.matches.iter().filter(|m| m.detection_method == "api").collect();

        assert!(!kw_matches.is_empty(), "should have keyword matches");
        assert!(!api_matches.is_empty(), "should have API matches");

        // API matches should carry encryption_library metadata
        let api_m = &api_matches[0];
        assert_eq!(api_m.inner.encryption_library.as_deref(), Some("OpenSSL"));
        assert_eq!(api_m.inner.encryption_api_usage.as_deref(), Some("call"));
    }

    #[test]
    fn test_quick_mode() {
        let dir = tempfile::tempdir().unwrap();

        let mut f1 = std::fs::File::create(dir.path().join("has_crypto.c")).unwrap();
        writeln!(f1, "SHA256 used").unwrap();

        let mut f2 = std::fs::File::create(dir.path().join("no_crypto.c")).unwrap();
        writeln!(f2, "no matches here").unwrap();

        let db = PatternDb::parse(TEST_PATTERNS, false).unwrap();

        let config = ScanConfig {
            keyword_db: db,
            api_db: None,
            source_files_only: false,
            quick: true,
            stop_after: None,
            scan_binary: false,
        };

        let result = scan_package(dir.path(), &config).unwrap();
        // Quick mode still finds matches, just does has_match first
        assert_eq!(result.files_with_matches, 1);
    }

    #[test]
    fn test_sha1_deduplication() {
        let dir = tempfile::tempdir().unwrap();

        // Two files with identical content -> same SHA1
        let content = "SHA256 here\n";
        std::fs::write(dir.path().join("copy1.c"), content).unwrap();
        std::fs::write(dir.path().join("copy2.c"), content).unwrap();

        let db = PatternDb::parse(TEST_PATTERNS, false).unwrap();
        let config = ScanConfig {
            keyword_db: db,
            api_db: None,
            source_files_only: false,
            quick: false,
            stop_after: None,
            scan_binary: false,
        };

        let result = scan_package(dir.path(), &config).unwrap();
        assert_eq!(result.files_with_matches, 2);

        // Both should have same SHA1
        assert_eq!(result.file_results[0].sha1, result.file_results[1].sha1);

        // CryptoOutput should dedup by SHA1
        let output = crate::output::CryptoOutput::from_scan(&result);
        assert_eq!(output.crypto_evidence.len(), 1); // One SHA1 entry
        let entry = output.crypto_evidence.values().next().unwrap();
        assert_eq!(entry.file_paths.len(), 2); // Two file paths under it
    }
}
