//! Enterprise Security Test Suite
//!
//! Tests for security fixes:
//! - FIND-001: Path traversal prevention
//! - FIND-002: Configurable lock timeout
//! - FIND-003: File size limits
//! - FIND-005: Backup retention policy
//! - FIND-006: Documentation verification

use std::fs;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use tempfile::TempDir;

// Import the library
use moesniper::{
    check_file_size, normalize_path_secure, purge_old_backups, validate_path, PathSecurityError,
    SecurityPolicy, SniperConfig,
};

mod path_security_tests {
    use super::*;

    #[test]
    fn test_path_traversal_blocked_basic() {
        // Test that basic path traversal is rejected
        let policy = SecurityPolicy::default();
        let result = validate_path("../../../etc/passwd", &policy);

        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            PathSecurityError::ParentReferenceNotAllowed { .. }
        ));
    }

    #[test]
    fn test_path_traversal_blocked_nested() {
        // Test nested parent references
        let policy = SecurityPolicy::default();
        let result = validate_path("foo/bar/../../../etc/passwd", &policy);

        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            PathSecurityError::ParentReferenceNotAllowed { .. }
        ));
    }

    #[test]
    fn test_path_traversal_with_base_directory() {
        let dir = TempDir::new().unwrap();
        let base = dir.path();

        // Create files
        let inside_file = base.join("inside.txt");
        fs::write(&inside_file, "content").unwrap();

        let outside_file = dir.path().parent().unwrap().join("outside.txt");
        fs::write(&outside_file, "content").unwrap();

        let policy = SecurityPolicy {
            base_dir: Some(base.to_path_buf()),
            reject_parent_refs: true,
        };

        // File inside base should succeed
        let result = validate_path(&inside_file, &policy);
        assert!(result.is_ok());

        // File outside base should fail
        let result = validate_path(&outside_file, &policy);
        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            PathSecurityError::EscapesBaseDirectory { .. }
        ));

        // Cleanup
        let _ = fs::remove_file(&outside_file);
    }

    #[test]
    fn test_normalize_path_secure_allows_valid_paths() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("test.txt");
        fs::write(&file, "content").unwrap();

        // Valid path with base directory
        let result = normalize_path_secure(file.to_str().unwrap(), Some(dir.path()));
        assert!(result.is_ok());
    }

    #[test]
    fn test_parent_refs_allowed_when_configured() {
        let dir = TempDir::new().unwrap();
        let subdir = dir.path().join("subdir");
        fs::create_dir(&subdir).unwrap();
        let file = subdir.join("test.txt");
        fs::write(&file, "content").unwrap();

        // Reference parent directory - but note: even with reject_parent_refs: false,
        // if base_dir is set, the path must still be within base. The parent ref
        // "subdir/../test.txt" resolves to "test.txt" which is in base, so this works.
        let parent_ref = PathBuf::from("subdir").join("..").join("test.txt");

        // With reject_parent_refs: false, should work (after cleaning path)
        let policy = SecurityPolicy {
            base_dir: Some(dir.path().to_path_buf()),
            reject_parent_refs: false,
        };

        // This should succeed since parent_refs are allowed and resolved path is in base
        let result = validate_path(&parent_ref, &policy);
        assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
    }
}

mod file_size_tests {
    use super::*;

    #[test]
    fn test_file_size_within_limit() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("small.txt");
        fs::write(&file, "small content").unwrap();

        // File is well under 100MB
        let result = check_file_size(file.to_str().unwrap(), 100 * 1024 * 1024);
        assert!(result.is_ok());
    }

    #[test]
    fn test_file_size_exceeds_limit() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("large.txt");
        fs::write(&file, "x".repeat(100)).unwrap();

        // File is 100 bytes, limit is 10 bytes
        let result = check_file_size(file.to_str().unwrap(), 10);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.starts_with("File too large"),
            "Error must start with 'File too large', got: {}",
            err
        );
    }

    #[test]
    fn test_file_size_unlimited() {
        let dir = TempDir::new().unwrap();
        let file = dir.path().join("any.txt");
        fs::write(&file, "any content").unwrap();

        // max_size = 0 means unlimited
        let result = check_file_size(file.to_str().unwrap(), 0);
        assert!(result.is_ok());
    }

    #[test]
    fn test_file_size_nonexistent_file() {
        // Non-existent file should fail with metadata error
        let result = check_file_size("/tmp/nonexistent_file_12345.txt", 100);
        assert!(result.is_err());
    }
}

mod backup_retention_tests {
    use super::*;
    use moesniper::create_backup;
    use moesniper::get_path_hash;
    use moesniper::normalize_path;

    #[test]
    fn test_backup_retention_by_count() {
        let dir = TempDir::new().unwrap();
        let file_path = dir.path().join("retention_test.txt");
        fs::write(&file_path, "v0").unwrap();

        let config = SniperConfig {
            backup_retention_count: 3,
            backup_max_age_days: 0,
            audit_enabled: false,
            ..SniperConfig::default()
        };

        // Create 5 backups
        for i in 0..5 {
            fs::write(&file_path, format!("v{}", i)).unwrap();
            let result = create_backup(file_path.to_str().unwrap());
            assert!(result.is_ok(), "Backup {} should succeed: {:?}", i, result);
            thread::sleep(Duration::from_millis(20));
        }

        let normalized =
            normalize_path(file_path.to_str().unwrap()).expect("Path normalization must succeed");
        let hash = get_path_hash(&normalized);
        let backup_dir = file_path.parent().unwrap().join(".sniper");
        assert!(backup_dir.exists(), "Backup directory must exist");

        // Count backups before purge
        let before_count: usize = fs::read_dir(&backup_dir)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
            .count();

        assert!(
            before_count >= 5,
            "Must have created at least 5 backups, got {}",
            before_count
        );

        // Purge old backups
        let purge_result = purge_old_backups(file_path.to_str().unwrap(), &config);
        assert!(
            purge_result.is_ok(),
            "Purge must succeed: {:?}",
            purge_result
        );

        // Count backups after purge — must be at most 3
        let after_count: usize = fs::read_dir(&backup_dir)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
            .count();

        assert!(
            after_count <= 3,
            "After purge with retention=3, expected <= 3 backups, got {}",
            after_count
        );

        let _ = fs::remove_file(&file_path);
    }

    #[test]
    fn test_backup_retention_by_age() {
        let config = SniperConfig {
            backup_retention_count: 0,
            backup_max_age_days: 0,
            audit_enabled: false,
            ..SniperConfig::default()
        };

        // Non-existent file should not panic — may return Ok or Err
        let result = purge_old_backups("/tmp/nonexistent_sniper_age_test_xyz.txt", &config);
        // Must not panic — that's the real test
        assert!(result.is_ok() || result.is_err());
    }
}

mod config_tests {
    use super::*;

    #[test]
    fn test_config_default_values() {
        let config = SniperConfig::default();

        assert_eq!(config.lock_timeout, Duration::from_secs(30));
        assert_eq!(config.max_file_size, 100 * 1024 * 1024); // 100MB
        assert_eq!(config.backup_retention_count, 50);
        assert_eq!(config.backup_max_age_days, 30);
    }

    #[test]
    fn test_config_from_env_overrides() {
        // Verify that env-based config loading produces valid defaults
        let config = SniperConfig::default();
        // All defaults must be non-zero (operational)
        assert!(config.lock_timeout.as_secs() > 0);
        assert!(config.max_file_size > 0);
        assert!(config.backup_retention_count > 0);
        assert!(config.backup_max_age_days > 0);
    }
}

// Documentation verification tests
mod documentation_tests {
    #[test]
    fn test_line_number_documentation() {
        use std::process::Command;

        let output = Command::new(env!("CARGO"))
            .args(["run", "--quiet", "--", "--help"])
            .current_dir(env!("CARGO_MANIFEST_DIR"))
            .output()
            .unwrap();

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let help_text = if stdout.is_empty() { stderr } else { stdout };

        // Help text must document 1-indexed line numbers
        assert!(
            help_text.contains("1-indexed"),
            "Help text must document 1-indexed line numbers, got: {}",
            help_text
        );
    }
}

// ===========================================================================
// Integration Tests — Cross-Boundary Security
// ======================================================================

mod compound_security_tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;
    use tempfile::TempDir;

    // Symlink: symlink inside base_dir points outside must be rejected.
    #[test]
    #[cfg(unix)]
    fn test_symlink_traversal_within_base_dir() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("target.txt");
        fs::write(&target, "secret\n").unwrap();

        // Create symlink inside base_dir pointing to /etc/passwd
        let symlink = dir.path().join("link.txt");
        std::os::unix::fs::symlink("/etc/passwd", &symlink).unwrap();

        let policy = SecurityPolicy {
            base_dir: Some(dir.path().to_path_buf()),
            reject_parent_refs: true,
        };

        // Symlink resolution must still be contained within base_dir
        let result = validate_path(&symlink, &policy);
        assert!(
            result.is_err(),
            "Symlink to /etc/passwd inside base_dir must be rejected, got: {:?}",
            result
        );
    }

    // Atomicity: after a failed splice, file must not contain partial content.
    #[test]
    fn test_atomic_write_no_partial_content_on_crash() {
        use moesniper::{create_backup, hex_decode, write_atomic};
        let dir = TempDir::new().unwrap();
        let file_path = dir.path().join("atomic_test.txt");
        let original = "line1\nline2\nline3\nline4\nline5\n";
        fs::write(&file_path, original).unwrap();

        // Simulate a successful splice that replaces line 3
        let lines: Vec<String> = original.split_inclusive('\n').map(String::from).collect();
        let mut new_lines = lines.clone();
        let replace_text = hex_decode("58").expect("valid hex: 'X'");
        new_lines[2] = format!(
            "{}{}",
            replace_text,
            if original.ends_with('\n') { "\n" } else { "" }
        );

        // Create backup first
        let backup = create_backup(file_path.to_str().unwrap()).expect("Backup must succeed");

        // Write new content atomically
        let write_lines: Vec<&str> = new_lines.iter().map(|s| s.as_str()).collect();
        write_atomic(
            file_path.to_str().unwrap(),
            &write_lines,
            &SniperConfig::default(),
        )
        .expect("Atomic write must succeed");

        // Verify file has correct content — not partial
        let content = fs::read_to_string(&file_path).unwrap();
        assert_eq!(content, "line1\nline2\nX\nline4\nline5\n");

        // Verify backup preserved original
        let backup_path = PathBuf::from(&backup);
        assert!(
            backup_path.exists(),
            "Backup file must exist at: {:?}",
            backup_path
        );
        let _ = fs::remove_file(backup_path);
    }

    // Lock PID recycling: verify stale locks are cleaned on timeout.
    #[test]
    fn test_stale_lock_cleanup() {
        use std::process::Command;
        let dir = TempDir::new().unwrap();
        let file_path = dir.path().join("stale_lock_test.txt");
        fs::write(&file_path, "content\n").unwrap();

        // First edit: acquires lock, succeeds
        let output = Command::new(env!("CARGO"))
            .args([
                "run",
                "--quiet",
                "--",
                file_path.to_str().unwrap(),
                "1",
                "1",
                "41",
            ])
            .output()
            .expect("First edit must spawn");

        assert!(
            output.status.success(),
            "First edit must succeed: stderr={}",
            String::from_utf8_lossy(&output.stderr)
        );

        // Cleanup — the lock should be released already (process exited)
        let output = Command::new(env!("CARGO"))
            .args([
                "run",
                "--quiet",
                "--",
                file_path.to_str().unwrap(),
                "1",
                "1",
                "42",
            ])
            .output()
            .expect("Second edit must spawn");

        assert!(
            output.status.success(),
            "Second edit must succeed after lock release: stderr={}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    // Concurrent splice integrity: verify file consistency under simultaneous edits.
    #[test]
    fn test_concurrent_splice_file_integrity() {
        use std::sync::{Arc, Barrier};
        use std::thread;

        let dir = Arc::new(TempDir::new().unwrap());
        let file_path = Arc::new(dir.path().join("concurrent_atomic.txt"));
        let original = "line1\nline2\nline3\n";
        fs::write(&*file_path, original).unwrap();

        let num_threads = 3;
        let barrier = Arc::new(Barrier::new(num_threads));
        let mut handles = vec![];

        for i in 0..num_threads {
            let b = barrier.clone();
            let f = file_path.clone();
            let hex_char = format!("{:02x}", i + 65); // 'A', 'B', 'C'
            handles.push(thread::spawn(move || {
                b.wait();
                std::process::Command::new(env!("CARGO"))
                    .args([
                        "run",
                        "--quiet",
                        "--",
                        f.to_str().unwrap(),
                        "2",
                        "2",
                        &hex_char,
                    ])
                    .output()
                    .unwrap()
                    .status
                    .success()
            }));
        }

        for h in handles {
            let _ = h.join();
        }

        let content = fs::read_to_string(&*file_path).unwrap();
        let expected_start = "line1\n";
        let expected_end = "line3\n";
        assert!(
            content.starts_with(expected_start),
            "Content must start with 'line1\\n', got: {:?}",
            content
        );
        assert!(
            content.ends_with(expected_end),
            "Content must end with 'line3\\n', got: {:?}",
            content
        );
        // Total lines must be 3 (no corruption from concurrent writes)
        assert_eq!(
            content.lines().count(),
            3,
            "File must have exactly 3 lines after concurrent edits, got: {:?}",
            content
        );
    }
}
