//! End-to-end integration tests for the v0.7 git-hook surface.
//!
//! These do NOT exercise the binary as a subprocess (that pattern is
//! brittle in CI). Instead they drive the public API (`hooks::install`,
//! `hooks::check_staged::run`, `hooks::check_pushed::run`) against a
//! real tempdir-based git repository so the test catches:
//!
//!   * git-plumbing assumptions (resolve_hooks_dir, list_staged_files,
//!     list_added_lines diff parsing on actual git output),
//!   * default-shieldset behaviour against realistic staged content,
//!   * install / uninstall idempotency on a fresh repo.
//!
//! Hook *body* behaviour (the bash script we write to disk) is unit-
//! tested inside `src/hooks/install.rs`; we do NOT spawn the hook here
//! because that requires a real `aperion-shield` binary on PATH which
//! we can't guarantee under `cargo test`.

use aperion_shield::hooks::check_pushed::{parse_line, RefUpdate};
use aperion_shield::hooks::check_staged;
use aperion_shield::hooks::{install, resolve_hooks_dir, uninstall, HookInstallOutcome};
use aperion_shield::Engine;
use std::fs;
use std::process::Command;
use tempfile::TempDir;

/// Spin up a minimal git repo in a fresh tempdir.
fn make_repo() -> TempDir {
    let tmp = TempDir::new().expect("tempdir");
    must_succeed(
        Command::new("git")
            .args(["init", "-q", "-b", "main"])
            .current_dir(tmp.path()),
    );
    for (k, v) in [
        ("user.email", "test@aperion.ai"),
        ("user.name", "Aperion Test"),
        ("commit.gpgsign", "false"),
    ] {
        must_succeed(
            Command::new("git")
                .args(["config", "--local", k, v])
                .current_dir(tmp.path()),
        );
    }
    tmp
}

fn must_succeed(cmd: &mut Command) {
    let out = cmd.output().expect("spawn");
    assert!(
        out.status.success(),
        "command failed: {:?}\nstdout: {}\nstderr: {}",
        cmd,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
}

fn stage_file(repo: &std::path::Path, rel: &str, body: &str) {
    let full = repo.join(rel);
    if let Some(parent) = full.parent() {
        fs::create_dir_all(parent).expect("mkdir -p");
    }
    fs::write(&full, body).expect("write");
    must_succeed(
        Command::new("git")
            .args(["add", "--", rel])
            .current_dir(repo),
    );
}

fn load_default_engine() -> Engine {
    // Honors the bundled default shieldset (whatever ships with the
    // binary). Same path as the production `--check` / `--check-staged`
    // modes when no `--rules` is given.
    Engine::builtin_default()
}

// ─────────────────────────────────────────────────────────────────────
// 1. Install / re-install / uninstall round-trip
// ─────────────────────────────────────────────────────────────────────

#[test]
fn install_creates_executable_hooks() {
    let tmp = make_repo();
    let report = install(tmp.path(), false).expect("install");
    assert_eq!(report.pre_commit, HookInstallOutcome::Installed);
    assert_eq!(report.pre_push, HookInstallOutcome::Installed);

    for name in ["pre-commit", "pre-push"] {
        let p = report.hooks_dir.join(name);
        assert!(p.exists(), "{} should exist", name);
        let meta = fs::metadata(&p).expect("metadata");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            assert_eq!(meta.permissions().mode() & 0o111, 0o111,
                "hook {} not executable", name);
        }
        let body = fs::read_to_string(&p).unwrap();
        assert!(body.contains("APERION-SHIELD-HOOK"));
        assert!(body.contains("SHIELD_HOOKS_DISABLE"));
    }
}

#[test]
fn install_then_uninstall_round_trips_cleanly() {
    let tmp = make_repo();
    install(tmp.path(), false).expect("install");
    let report = uninstall(tmp.path()).expect("uninstall");
    assert!(report.pre_commit_removed);
    assert!(report.pre_push_removed);
    assert!(!report.pre_commit_chain_restored);
    assert!(!report.pre_push_chain_restored);

    let hooks_dir = resolve_hooks_dir(tmp.path()).unwrap();
    assert!(!hooks_dir.join("pre-commit").exists());
    assert!(!hooks_dir.join("pre-push").exists());
}

// ─────────────────────────────────────────────────────────────────────
// 2. Pre-commit corpus: catches destructive SQL in a staged migration
// ─────────────────────────────────────────────────────────────────────

#[test]
fn check_staged_blocks_drop_database_migration() {
    let tmp = make_repo();
    stage_file(
        tmp.path(),
        "migrations/2026_05_20_purge.sql",
        "-- generated by AI agent\nDROP DATABASE prod;\n",
    );
    let engine = load_default_engine();
    let report = check_staged::run(tmp.path(), &engine, None).expect("run");

    assert_eq!(report.files_scanned, 1);
    assert!(
        report.lines_scanned >= 1,
        "expected at least one non-comment line to be scanned, got {}",
        report.lines_scanned
    );
    assert!(
        !report.findings.is_empty(),
        "expected DROP DATABASE to be flagged, got no findings"
    );
    let exit = report.exit_code();
    assert!(
        exit >= 1,
        "DROP DATABASE in prod-named workspace should refuse commit; got exit {}",
        exit
    );
    // and it correctly attributed to a sql.* rule
    let any_sql = report
        .findings
        .iter()
        .any(|f| f.rule_id.starts_with("sql."));
    assert!(any_sql, "expected sql.* rule attribution, findings: {:#?}", report.findings);
}

// ─────────────────────────────────────────────────────────────────────
// 3. Pre-commit corpus: catches destructive shell in a staged script
// ─────────────────────────────────────────────────────────────────────

#[test]
fn check_staged_blocks_rm_rf_root_in_shell_script() {
    let tmp = make_repo();
    stage_file(
        tmp.path(),
        "scripts/cleanup.sh",
        "#!/bin/sh\nset -e\nrm -rf /\necho done\n",
    );
    let engine = load_default_engine();
    let report = check_staged::run(tmp.path(), &engine, None).expect("run");

    assert!(report.files_scanned >= 1);
    assert!(
        !report.findings.is_empty(),
        "rm -rf / should fire a fs.* or shell.* rule"
    );
    assert!(
        report.exit_code() >= 1,
        "shell rm-rf-/ should refuse commit"
    );
}

// ─────────────────────────────────────────────────────────────────────
// 4. Pre-commit corpus: benign content passes through cleanly
// ─────────────────────────────────────────────────────────────────────

#[test]
fn check_staged_passes_benign_changes() {
    let tmp = make_repo();
    stage_file(
        tmp.path(),
        "src/lib.py",
        "def add(a, b):\n    return a + b\n",
    );
    stage_file(
        tmp.path(),
        "README.md",
        "# A project\n\nSome description.\n",
    );
    let engine = load_default_engine();
    let report = check_staged::run(tmp.path(), &engine, None).expect("run");

    assert_eq!(
        report.findings.len(),
        0,
        "benign code should not trigger any rule, got: {:#?}",
        report.findings
    );
    assert_eq!(report.exit_code(), 0);
}

// ─────────────────────────────────────────────────────────────────────
// 5. Pre-push: refuses a force-push to main
// ─────────────────────────────────────────────────────────────────────

#[test]
fn check_pushed_refs_blocks_force_push_to_main() {
    let tmp = make_repo();
    // Make two divergent commit graphs, then ask check_pushed_refs
    // whether pushing the local sha would rewrite the remote sha.
    stage_file(tmp.path(), "a.txt", "first\n");
    must_succeed(
        Command::new("git")
            .args(["commit", "-q", "-m", "first"])
            .current_dir(tmp.path()),
    );
    let first_sha = git_head(tmp.path());
    stage_file(tmp.path(), "b.txt", "second\n");
    must_succeed(
        Command::new("git")
            .args(["commit", "-q", "-m", "second"])
            .current_dir(tmp.path()),
    );
    let second_sha = git_head(tmp.path());

    // Build a stdin line pretending the remote is AT `second_sha` and
    // we are pushing `first_sha` (= rewinding history = force-push).
    let stdin_line = format!(
        "refs/heads/main {first} refs/heads/main {second}\n",
        first = first_sha,
        second = second_sha,
    );
    let report = aperion_shield::hooks::check_pushed::run(
        tmp.path(),
        std::io::Cursor::new(stdin_line.into_bytes()),
    )
    .expect("run check_pushed_refs");

    assert_eq!(report.refs_inspected, 1);
    assert_eq!(report.violations.len(), 1, "expected 1 force-push violation");
    assert_eq!(report.exit_code(), 1);
}

// ─────────────────────────────────────────────────────────────────────
// 6. Pre-push: allows a normal fast-forward push to main
// ─────────────────────────────────────────────────────────────────────

#[test]
fn check_pushed_refs_allows_fast_forward_push() {
    let tmp = make_repo();
    stage_file(tmp.path(), "a.txt", "first\n");
    must_succeed(
        Command::new("git")
            .args(["commit", "-q", "-m", "first"])
            .current_dir(tmp.path()),
    );
    let first_sha = git_head(tmp.path());
    stage_file(tmp.path(), "b.txt", "second\n");
    must_succeed(
        Command::new("git")
            .args(["commit", "-q", "-m", "second"])
            .current_dir(tmp.path()),
    );
    let second_sha = git_head(tmp.path());

    // Fast-forward push: remote is at `first_sha`, pushing `second_sha`.
    // `first_sha` IS an ancestor of `second_sha`, so this is allowed.
    let stdin_line = format!(
        "refs/heads/main {second} refs/heads/main {first}\n",
        first = first_sha,
        second = second_sha,
    );
    let report = aperion_shield::hooks::check_pushed::run(
        tmp.path(),
        std::io::Cursor::new(stdin_line.into_bytes()),
    )
    .expect("run check_pushed_refs");
    assert_eq!(report.violations.len(), 0);
    assert_eq!(report.exit_code(), 0);
}

// ─────────────────────────────────────────────────────────────────────
// 7. Pre-push: blocks deletion of main
// ─────────────────────────────────────────────────────────────────────

#[test]
fn check_pushed_refs_blocks_protected_branch_deletion() {
    let tmp = make_repo();
    stage_file(tmp.path(), "a.txt", "x\n");
    must_succeed(
        Command::new("git")
            .args(["commit", "-q", "-m", "first"])
            .current_dir(tmp.path()),
    );
    let head = git_head(tmp.path());
    let null = "0000000000000000000000000000000000000000";
    let stdin_line = format!("(delete) {null} refs/heads/main {head}\n");
    let report = aperion_shield::hooks::check_pushed::run(
        tmp.path(),
        std::io::Cursor::new(stdin_line.into_bytes()),
    )
    .expect("run");
    assert_eq!(report.violations.len(), 1);
    assert_eq!(report.exit_code(), 1);
}

// ─────────────────────────────────────────────────────────────────────
// 8. parse_line basic shape (sanity)
// ─────────────────────────────────────────────────────────────────────

#[test]
fn parse_line_round_trip() {
    let upd: RefUpdate =
        parse_line("refs/heads/feat 1234 refs/heads/main 5678").unwrap();
    assert_eq!(upd.local_ref, "refs/heads/feat");
    assert_eq!(upd.local_sha, "1234");
    assert_eq!(upd.remote_ref, "refs/heads/main");
    assert_eq!(upd.remote_sha, "5678");
}

fn git_head(repo: &std::path::Path) -> String {
    let out = Command::new("git")
        .args(["rev-parse", "HEAD"])
        .current_dir(repo)
        .output()
        .expect("git rev-parse");
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}
