//! Pure command safety and output helpers for `service::shell`.
//!
//! All functions here are free of side effects: no filesystem access,
//! no process spawning, no I/O of any kind. They may be tested without
//! temporary directories or an async runtime.

/// Patterns that are unconditionally blocked regardless of context.
pub static BLOCKED_PATTERNS: &[&str] = &[
    "rm -rf /",
    "rm -fr /",
    "rm -rf /*",
    "rm -fr /*",
    "rm --no-preserve-root",
    ":(){ :|:& };:",
    "> /dev/sda",
    "dd if=/dev",
];

/// Binaries that are unconditionally blocked.
pub static BLOCKED_BINARIES: &[&str] = &[
    "sudo", "su", "shutdown", "reboot", "halt", "poweroff", "mkfs",
];

/// Returns an error message if the command matches a blocked pattern or binary,
/// or `None` if the command is safe to run.
///
/// Checks are purely string-based — no I/O or process inspection.
pub fn is_dangerous(command: &str) -> Option<String> {
    let trimmed = command.trim();

    for &pattern in BLOCKED_PATTERNS {
        if trimmed.contains(pattern) {
            return Some(format!(
                "Command blocked: dangerous pattern '{pattern}' detected"
            ));
        }
    }

    if let Some(first_token) = trimmed.split_whitespace().next() {
        let binary = std::path::Path::new(first_token)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(first_token);
        if BLOCKED_BINARIES.contains(&binary) {
            return Some(format!("Command blocked: '{binary}' is not permitted"));
        }
    }

    None
}

/// Truncate `output` to at most `max_lines` lines, keeping the tail.
///
/// If the output fits within `max_lines`, it is returned unchanged.
/// Otherwise a `"[... N lines omitted ...]"` header is prepended to the
/// last `max_lines` lines so callers still see the most recent output.
pub fn truncate_output(output: &str, max_lines: usize) -> String {
    let lines: Vec<&str> = output.lines().collect();
    if lines.len() <= max_lines {
        return output.to_string();
    }
    let omitted = lines.len() - max_lines;
    let tail = lines[lines.len() - max_lines..].join("\n");
    format!("[... {omitted} lines omitted ...]\n{tail}")
}

/// Parse cumulative passed/failed counts from `cargo test` output.
///
/// Scans each line for `"test result:"` summaries and accumulates the
/// `passed` and `failed` counts across all crates in the output.
pub fn parse_test_counts(output: &str) -> (u64, u64) {
    let mut total_passed = 0u64;
    let mut total_failed = 0u64;
    for line in output.lines() {
        if line.contains("test result:") {
            if let Some(p) = extract_count(line, "passed") {
                total_passed += p;
            }
            if let Some(f) = extract_count(line, "failed") {
                total_failed += f;
            }
        }
    }
    (total_passed, total_failed)
}

/// Extract the integer immediately before `label` in `line`.
///
/// For example, `extract_count("5 passed; 0 failed", "passed")` returns
/// `Some(5)`. Returns `None` if no digit sequence precedes `label`.
#[allow(clippy::string_slice)] // pos from line.find(label) → always a char boundary
pub fn extract_count(line: &str, label: &str) -> Option<u64> {
    let pos = line.find(label)?;
    let before = line[..pos].trim_end();
    let num_str: String = before
        .chars()
        .rev()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    if num_str.is_empty() {
        return None;
    }
    let num_str: String = num_str.chars().rev().collect();
    num_str.parse().ok()
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    // ── is_dangerous ──────────────────────────────────────────────────────────

    #[test]
    fn is_dangerous_blocks_rm_rf_root() {
        assert!(is_dangerous("rm -rf /").is_some());
        assert!(is_dangerous("  rm -rf /  ").is_some());
    }

    #[test]
    fn is_dangerous_blocks_rm_fr_root() {
        assert!(is_dangerous("rm -fr /").is_some());
    }

    #[test]
    fn is_dangerous_blocks_rm_rf_star() {
        assert!(is_dangerous("rm -rf /*").is_some());
        assert!(is_dangerous("rm -fr /*").is_some());
    }

    #[test]
    fn is_dangerous_blocks_sudo() {
        assert!(is_dangerous("sudo ls").is_some());
    }

    #[test]
    fn is_dangerous_blocks_shutdown() {
        assert!(is_dangerous("shutdown -h now").is_some());
    }

    #[test]
    fn is_dangerous_blocks_mkfs() {
        assert!(is_dangerous("mkfs /dev/sda1").is_some());
    }

    #[test]
    fn is_dangerous_blocks_fork_bomb() {
        assert!(is_dangerous(":(){ :|:& };:").is_some());
    }

    #[test]
    fn is_dangerous_allows_safe_commands() {
        assert!(is_dangerous("cargo build").is_none());
        assert!(is_dangerous("npm test").is_none());
        assert!(is_dangerous("git status").is_none());
        assert!(is_dangerous("ls -la").is_none());
        assert!(is_dangerous("rm -rf target/").is_none());
    }

    // ── truncate_output ───────────────────────────────────────────────────────

    #[test]
    fn truncate_short_text_unchanged() {
        let text = "line1\nline2\nline3";
        assert_eq!(truncate_output(text, 10), text);
    }

    #[test]
    fn truncate_long_text_keeps_tail() {
        let lines: Vec<String> = (1..=200).map(|i| format!("line {i}")).collect();
        let text = lines.join("\n");
        let result = truncate_output(&text, 50);
        assert!(
            result.contains("line 200"),
            "should keep last line: {result}"
        );
        assert!(
            result.contains("omitted"),
            "should note omitted lines: {result}"
        );
        assert!(
            !result.contains("line 1\n"),
            "should not keep first line: {result}"
        );
    }

    #[test]
    fn truncate_exact_max_unchanged() {
        let text = "a\nb\nc";
        assert_eq!(truncate_output(text, 3), text);
    }

    // ── parse_test_counts ─────────────────────────────────────────────────────

    #[test]
    fn parse_counts_extracts_passed_and_failed() {
        let output = "test result: ok. 5 passed; 0 failed; 0 ignored\ntest result: FAILED. 2 passed; 3 failed;";
        let (passed, failed) = parse_test_counts(output);
        assert_eq!(passed, 7);
        assert_eq!(failed, 3);
    }

    #[test]
    fn parse_counts_no_results_returns_zeros() {
        let (passed, failed) = parse_test_counts("no test output here");
        assert_eq!(passed, 0);
        assert_eq!(failed, 0);
    }

    // ── extract_count ─────────────────────────────────────────────────────────

    #[test]
    fn extract_count_finds_number_before_label() {
        assert_eq!(extract_count("5 passed; 0 failed", "passed"), Some(5));
        assert_eq!(extract_count("0 failed", "failed"), Some(0));
        assert_eq!(extract_count("no number here passed", "passed"), None);
    }

    #[test]
    fn extract_count_returns_none_for_missing_label() {
        assert_eq!(extract_count("5 passed", "failed"), None);
    }
}
