//! Integration tests for hook skip behavior.
//!
//! These tests verify that prek correctly identifies and reports skipped hooks
//! in various scenarios: file pattern mismatches, dry-run mode, and mixed
//! execution across priority groups.
//!
//! Includes regression tests for #1335: when all hooks in a group are skipped,
//! prek should not call `git diff` to check for file modifications.

use std::time::{Duration, SystemTime};

use anyhow::Result;
use assert_fs::prelude::*;

use crate::common::{TestContext, cmd_snapshot, git_cmd};

mod common;

fn hook_env_count(context: &TestContext) -> Result<usize> {
    let hooks_dir = context.home_dir().child("hooks");
    if !hooks_dir.exists() {
        return Ok(0);
    }
    Ok(hooks_dir.read_dir()?.count())
}

fn remove_loose_blob(cwd: &assert_fs::fixture::ChildPath, filename: &str) -> Result<()> {
    let output = git_cmd(cwd).arg("hash-object").arg(filename).output()?;
    assert!(output.status.success(), "git hash-object should succeed");
    let blob = String::from_utf8(output.stdout)?;
    let blob = blob.trim_ascii();
    let object_path = cwd
        .child(".git")
        .child("objects")
        .child(&blob[..2])
        .child(&blob[2..]);
    fs_err::remove_file(object_path.path())?;
    Ok(())
}

/// All hooks skip when no staged files match their file patterns.
#[test]
fn all_hooks_skipped_no_matching_files() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: python-check
                name: python-check
                language: system
                entry: echo "checking python"
                files: \.py$
              - id: rust-check
                name: rust-check
                language: system
                entry: echo "checking rust"
                files: \.rs$
              - id: go-check
                name: go-check
                language: system
                entry: echo "checking go"
                files: \.go$
    "#});

    cwd.child("readme.txt").write_str("Hello")?;
    cwd.child("data.json").write_str("{}")?;
    cwd.child("config.yaml").write_str("key: value")?;

    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    python-check.........................................(no files to check)Skipped
    rust-check...........................................(no files to check)Skipped
    go-check.............................................(no files to check)Skipped

    ----- stderr -----
    "#);

    Ok(())
}

/// Installable hooks with no matching files should not create environments.
#[test]
fn skipped_installable_hook_does_not_install_env() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: python-check
                name: python-check
                language: python
                entry: python -c "print('checking python')"
                files: \.py$
    "#});

    cwd.child("README.md").write_str("Hello")?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    python-check.........................................(no files to check)Skipped

    ----- stderr -----
    "#);

    assert_eq!(hook_env_count(&context)?, 0);

    Ok(())
}

/// Installable hooks excluded by group selection should not create environments.
#[test]
fn group_excluded_installable_hook_does_not_install_env() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: selected
                name: selected
                language: system
                entry: python3 -c "print('selected')"
                always_run: true
                groups: [ci]
              - id: excluded-python
                name: excluded-python
                language: python
                entry: python -c "print('excluded')"
                always_run: true
                groups: [slow]
    "#});

    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("--all-files").arg("--group").arg("ci"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    selected.................................................................Passed

    ----- stderr -----
    "#);

    assert_eq!(hook_env_count(&context)?, 0);

    Ok(())
}

/// `always_run` installable hooks still install and run without matching files.
#[test]
fn always_run_installable_hook_installs_without_matching_files() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: always-python
                name: always-python
                language: python
                entry: python -c "print('ran')"
                files: \.py$
                always_run: true
                pass_filenames: false
    "#});

    cwd.child("README.md").write_str("Hello")?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    always-python............................................................Passed

    ----- stderr -----
    "#);

    assert_eq!(hook_env_count(&context)?, 1);

    Ok(())
}

/// `--dry-run` skips hooks without executing them.
#[test]
fn dry_run_skips_all_hooks() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: formatter
                name: formatter
                language: system
                entry: python3 -c "import sys; open(sys.argv[1], 'a').write('modified')"
                files: \.txt$
              - id: linter
                name: linter
                language: system
                entry: echo "linting"
                files: \.txt$
    "#});

    cwd.child("file.txt").write_str("content")?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("--dry-run"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    formatter...............................................................Dry Run
    linter..................................................................Dry Run

    ----- stderr -----
    "#);

    assert_eq!(context.read("file.txt"), "content");

    Ok(())
}

/// Hooks that match staged files run; others are skipped.
#[test]
fn mixed_skipped_and_executed_hooks() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: txt-check
                name: txt-check
                language: system
                entry: echo "checking txt"
                files: \.txt$
              - id: py-check
                name: py-check
                language: system
                entry: echo "checking py"
                files: \.py$
              - id: rs-check
                name: rs-check
                language: system
                entry: echo "checking rs"
                files: \.rs$
    "#});

    cwd.child("readme.txt").write_str("Hello")?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    txt-check................................................................Passed
    py-check.............................................(no files to check)Skipped
    rs-check.............................................(no files to check)Skipped

    ----- stderr -----
    "#);

    Ok(())
}

/// Skipped hooks in untouched workspace projects should not install environments.
#[test]
fn skipped_workspace_project_installable_hook_does_not_install_env() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    let proj_a = cwd.child("proj-a");
    let proj_b = cwd.child("proj-b");
    proj_a.create_dir_all()?;
    proj_b.create_dir_all()?;

    context.write_pre_commit_config(indoc::indoc! {r"
        repos:
          - repo: local
            hooks:
              - id: root-skip
                name: root-skip
                language: system
                entry: echo root
                files: \.root$
    "});
    proj_a
        .child(".pre-commit-config.yaml")
        .write_str(indoc::indoc! {r"
        repos:
          - repo: local
            hooks:
              - id: proj-a-check
                name: proj-a-check
                language: system
                entry: echo proj-a
                files: \.txt$
    "})?;
    proj_b
        .child(".pre-commit-config.yaml")
        .write_str(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: proj-b-python
                name: proj-b-python
                language: python
                entry: python -c "print('proj-b')"
                files: \.py$
    "#})?;

    proj_a.child("README.txt").write_str("Hello")?;
    context.git_add(".");

    let output = context.run().output()?;
    assert!(output.status.success(), "prek should succeed");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("proj-a-check") && stdout.contains("Passed"));
    assert!(stdout.contains("proj-b-python") && stdout.contains("Skipped"));
    assert_eq!(hook_env_count(&context)?, 0);

    Ok(())
}

#[test]
fn orphan_project_early_match_still_hides_child_files_from_parent_install() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    let child = cwd.child("child");
    child.create_dir_all()?;

    context.write_pre_commit_config(indoc::indoc! {r"
        repos:
          - repo: local
            hooks:
              - id: root-pygrep
                name: root-pygrep
                language: pygrep
                entry: ROOT_SHOULD_NOT_RUN
                files: \.py$
    "});
    child
        .child(".pre-commit-config.yaml")
        .write_str(indoc::indoc! {r#"
        orphan: true
        repos:
          - repo: local
            hooks:
              - id: child-python
                name: child-python
                language: python
                entry: python -c "print('child')"
                always_run: true
                pass_filenames: false
    "#})?;

    child.child("child.py").write_str("print('child')\n")?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ child
      child-python...........................................................Passed
    ✓ <workspace>
      root-pygrep........................................(no files to check)Skipped

    ----- stderr -----
    "#);
    assert_eq!(hook_env_count(&context)?, 1);

    Ok(())
}

/// Skipped hooks across multiple priority groups
///
/// Hooks with different `priority` values form separate priority groups. Each
/// group is processed sequentially. This test verifies:
/// 1. Skip behavior works correctly across group boundaries
/// 2. `git diff` is not called when every hook is skipped
///
/// Note: This test uses manual output capture instead of `cmd_snapshot!` because
/// we need to count `get_diff` occurrences in trace-level stderr. Trace output
/// contains non-deterministic timestamps and timing data unsuitable for snapshots.
#[test]
fn all_hooks_skipped_multiple_priority_groups() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: priority-10
                name: priority-10
                language: system
                entry: echo "priority 10"
                files: \.py$
                priority: 10
              - id: priority-20
                name: priority-20
                language: system
                entry: echo "priority 20"
                files: \.rs$
                priority: 20
              - id: priority-30
                name: priority-30
                language: system
                entry: echo "priority 30"
                files: \.go$
                priority: 30
    "#});

    cwd.child("data.json").write_str("{}")?;
    context.git_add(".");

    // Run with trace logging to verify #1335 fix
    let output = context.run().env("RUST_LOG", "prek::git=trace").output()?;

    assert!(output.status.success(), "prek should succeed");

    // Verify all hooks skipped
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("priority-10") && stdout.contains("Skipped"));
    assert!(stdout.contains("priority-20") && stdout.contains("Skipped"));
    assert!(stdout.contains("priority-30") && stdout.contains("Skipped"));

    // Regression test for #1335: skipped hooks do not need modification checks.
    let stderr = String::from_utf8_lossy(&output.stderr);
    let get_diff_calls = stderr.matches("get_diff").count();
    assert_eq!(
        get_diff_calls, 0,
        "Expected no get_diff calls when all hooks skip, found {get_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}

#[test]
fn may_modify_hook_without_changes_uses_quiet_diff_check() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: noop
                name: noop
                language: system
                entry: python3 -c "pass"
                pass_filenames: false
    "#});

    cwd.child("file.txt").write_str("original\n")?;
    context.git_add(".");

    let output = context.run().env("RUST_LOG", "prek::git=trace").output()?;

    assert!(output.status.success(), "noop hook should pass");

    let stderr = String::from_utf8_lossy(&output.stderr);
    let has_worktree_diff_calls = stderr.matches("has_worktree_diff").count();
    assert_eq!(
        has_worktree_diff_calls, 1,
        "Expected one cheap worktree diff check, found {has_worktree_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    let get_diff_calls = stderr.matches("get_diff").count();
    assert_eq!(
        get_diff_calls, 0,
        "Expected no full get_diff calls when the hook leaves files unchanged, found {get_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}

#[test]
#[cfg(unix)]
fn identical_rewrite_with_stat_change_is_not_modified() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    context.write_pre_commit_config(indoc::indoc! {r"
        repos:
          - repo: local
            hooks:
              - id: rewrite-identical
                name: rewrite-identical
                language: system
                entry: python3 rewrite.py
                files: \.txt$
    "});
    cwd.child("rewrite.py").write_str(indoc::indoc! {r"
        from pathlib import Path
        import os
        import sys
        import time

        for filename in sys.argv[1:]:
            path = Path(filename)
            path.write_text(path.read_text())
            timestamp = time.time() + 10
            os.utime(path, (timestamp, timestamp))
    "})?;

    cwd.child("file.txt").write_str("original\n")?;
    context.git_add(".");

    let output = context.run().env("RUST_LOG", "prek::git=trace").output()?;

    assert!(
        output.status.success(),
        "rewriting identical content should not be treated as a hook modification"
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("rewrite-identical") && stdout.contains("Passed"));
    assert!(!stdout.contains("files were modified by this hook"));

    let stderr = String::from_utf8_lossy(&output.stderr);
    let has_worktree_diff_calls = stderr.matches("has_worktree_diff").count();
    assert_eq!(
        has_worktree_diff_calls, 1,
        "Expected one cheap worktree diff check, found {has_worktree_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    let get_diff_calls = stderr.matches("get_diff").count();
    assert_eq!(
        get_diff_calls, 1,
        "Expected one content diff to filter out stat-only changes, found {get_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}

#[test]
fn modifying_hook_uses_clean_baseline_diff_detection() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: modify
                name: modify
                language: system
                entry: python3 -c "from pathlib import Path; Path('file.txt').write_text('changed\n')"
                pass_filenames: false
    "#});

    cwd.child("file.txt").write_str("original\n")?;
    context.git_add(".");

    let output = context.run().env("RUST_LOG", "prek::git=trace").output()?;

    assert!(
        !output.status.success(),
        "prek should fail when hooks modify files"
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("files were modified by this hook"));

    let stderr = String::from_utf8_lossy(&output.stderr);
    let has_worktree_diff_calls = stderr.matches("has_worktree_diff").count();
    assert_eq!(
        has_worktree_diff_calls, 1,
        "Expected one cheap worktree diff check, found {has_worktree_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    let get_diff_calls = stderr.matches("get_diff").count();
    assert_eq!(
        get_diff_calls, 1,
        "Expected one full get_diff call after detecting modifications, found {get_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}

#[test]
fn all_files_with_existing_unstaged_changes_uses_snapshot_baseline() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: modify
                name: modify
                language: system
                entry: python3 -c "from pathlib import Path; Path('hook.txt').write_text('changed\n')"
                pass_filenames: false
    "#});

    cwd.child("file.txt").write_str("original\n")?;
    cwd.child("hook.txt").write_str("original\n")?;
    context.git_add(".");
    cwd.child("file.txt").write_str("unstaged\n")?;

    let output = context
        .run()
        .arg("--all-files")
        .env("RUST_LOG", "prek::git=trace")
        .output()?;

    assert!(
        !output.status.success(),
        "--all-files should still detect hook modifications when the worktree starts dirty"
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("files were modified by this hook"));

    let stderr = String::from_utf8_lossy(&output.stderr);
    let has_worktree_diff_calls = stderr.matches("has_worktree_diff").count();
    assert_eq!(
        has_worktree_diff_calls, 0,
        "`--all-files` should not use the clean-baseline diff check.\n\
         Trace output:\n{stderr}"
    );

    let get_diff_calls = stderr.matches("get_diff").count();
    assert_eq!(
        get_diff_calls, 2,
        "Expected a full before/after diff comparison for dirty `--all-files`, found {get_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}

#[test]
fn all_files_clean_missing_blob_ignores_diff_snapshot_errors() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: noop
                name: noop
                language: system
                entry: python3 -c "pass"
                pass_filenames: false
    "#});

    cwd.child("file.txt").write_str("original\n")?;
    context.git_add(".");
    context.git_commit("init");

    remove_loose_blob(cwd, "file.txt")?;

    // Make the index stat data stale while keeping file content unchanged. A
    // full `git diff` now exits non-zero because the blob is missing, but its
    // stdout is still a usable best-effort before/after snapshot.
    fs_err::OpenOptions::new()
        .write(true)
        .open(cwd.child("file.txt").path())?
        .set_modified(SystemTime::now() + Duration::from_secs(10))?;

    let output = context
        .run()
        .arg("--all-files")
        .env("RUST_LOG", "prek::git=trace")
        .output()?;

    assert!(
        output.status.success(),
        "`--all-files` should not require blob objects when hooks leave a clean tree.\n\
         stdout:\n{}\n\
         stderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    let ignored_diff_errors = stderr
        .matches("Continuing with git diff stdout despite non-zero exit status")
        .count();
    assert_eq!(
        ignored_diff_errors, 2,
        "Expected before/after git diff errors to be logged and ignored, found {ignored_diff_errors}.\n\
         Trace output:\n{stderr}"
    );
    assert!(
        !stderr.contains("Command `git diff` exited with an error"),
        "missing blobs should not turn hook modification detection into a fatal git diff error.\n\
         stderr:\n{stderr}"
    );

    Ok(())
}

#[test]
fn later_project_snapshots_diff_left_by_previous_project() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    let child = cwd.child("child");
    child.create_dir_all()?;

    context.write_pre_commit_config(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: root-noop
                name: root-noop
                language: system
                entry: python3 -c "pass"
                always_run: true
                pass_filenames: false
    "#});
    child
        .child(".pre-commit-config.yaml")
        .write_str(indoc::indoc! {r#"
        repos:
          - repo: local
            hooks:
              - id: child-modify
                name: child-modify
                language: system
                entry: python3 -c "from pathlib import Path; Path('child.txt').write_text('changed\n')"
                always_run: true
                pass_filenames: false
    "#})?;

    child.child("child.txt").write_str("original\n")?;
    context.git_add(".");

    let output = context.run().env("RUST_LOG", "prek::git=trace").output()?;

    assert!(
        !output.status.success(),
        "prek should fail because the child hook modified files"
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("child-modify") && stdout.contains("files were modified by this hook"));
    assert!(
        stdout.contains("root-noop") && stdout.contains("Passed"),
        "root hook should not be blamed for the child project's diff.\n\
         stdout:\n{stdout}"
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    let has_worktree_diff_calls = stderr.matches("has_worktree_diff").count();
    assert_eq!(
        has_worktree_diff_calls, 1,
        "Only the first project should use the clean-baseline check.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}

#[test]
fn read_only_builtin_hook_does_not_run_diff_detection() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let cwd = context.work_dir();
    context.write_pre_commit_config(indoc::indoc! {r"
        repos:
          - repo: builtin
            hooks:
              - id: check-toml
    "});

    cwd.child("pyproject.toml")
        .write_str("[project]\nname = \"demo\"\n")?;
    context.git_add(".");

    let output = context
        .run()
        .arg("--all-files")
        .env("RUST_LOG", "prek::git=trace")
        .output()?;

    assert!(output.status.success(), "prek should succeed");

    let stderr = String::from_utf8_lossy(&output.stderr);
    let get_diff_calls = stderr.matches("get_diff").count();
    assert_eq!(
        get_diff_calls, 0,
        "Expected no get_diff calls for read-only builtin hooks, found {get_diff_calls}.\n\
         Trace output:\n{stderr}"
    );

    Ok(())
}
