//! Integration tests for `skim git` subcommand (#50).
//!
//! Tests end-to-end CLI behavior for git status/diff/log compression.
//! These tests run against the real skim repository (which is a git repo),
//! so they exercise the actual git binary.

use assert_cmd::Command;
use predicates::prelude::*;

// ============================================================================
// Helpers
// ============================================================================

/// Create a hermetic local git repo with an `origin` remote pointing at a
/// local bare repo. Returns the temp dir (caller must keep alive) and the
/// path of the worker clone directory.
///
/// This avoids hitting the project's own git remote during `skim git fetch`
/// tests, preventing flakiness caused by stale remote tracking refs on the
/// host machine (e.g. `cannot lock ref refs/remotes/origin/...`).
fn make_hermetic_fetch_repo() -> (tempfile::TempDir, std::path::PathBuf) {
    let dir = tempfile::tempdir().expect("tempdir must succeed");
    let base = dir.path();

    // Bare repo acts as the remote.
    let bare = base.join("bare.git");
    std::process::Command::new("git")
        .args(["init", "--bare"])
        .arg(&bare)
        .output()
        .expect("git init --bare");

    // Seed repo: one empty commit, then push to bare so it is non-empty.
    let seed = base.join("seed");
    std::process::Command::new("git")
        .args(["init"])
        .arg(&seed)
        .output()
        .expect("git init seed");
    for (k, v) in [("user.email", "test@example.com"), ("user.name", "Test")] {
        std::process::Command::new("git")
            .args(["config", k, v])
            .current_dir(&seed)
            .output()
            .expect("git config");
    }
    std::process::Command::new("git")
        .args(["commit", "--allow-empty", "-m", "init"])
        .current_dir(&seed)
        .output()
        .expect("git commit seed");
    std::process::Command::new("git")
        .args(["remote", "add", "origin"])
        .arg(bare.to_str().unwrap())
        .current_dir(&seed)
        .output()
        .expect("git remote add seed");
    std::process::Command::new("git")
        .args(["push", "origin", "HEAD:refs/heads/main"])
        .current_dir(&seed)
        .output()
        .expect("git push seed");

    // Worker: clone from bare — this is the repo tests run `skim git fetch` in.
    let worker = base.join("worker");
    std::process::Command::new("git")
        .args(["clone"])
        .arg(bare.to_str().unwrap())
        .arg(&worker)
        .output()
        .expect("git clone worker");

    (dir, worker)
}

// ============================================================================
// Help
// ============================================================================

#[test]
fn test_skim_git_help() {
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("status "))
        .stdout(predicate::str::contains("diff"))
        .stdout(predicate::str::contains("fetch "))
        .stdout(predicate::str::contains("log "));
}

#[test]
fn test_skim_git_no_args_shows_help() {
    Command::cargo_bin("skim")
        .unwrap()
        .arg("git")
        .assert()
        .success()
        .stdout(predicate::str::contains("status "));
}

// ============================================================================
// Status
// ============================================================================

#[test]
fn test_skim_git_status_in_repo() {
    // Run against the skim repo itself — should succeed
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "status"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("status ")
                .and(predicate::str::contains("branch").or(predicate::str::contains("clean"))),
        );
}

#[test]
fn test_skim_git_status_porcelain_compresses() {
    // --porcelain is now stripped by the handler; output is still compressed.
    // The `status ` prefix confirms the handler ran (not raw passthrough).
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "status", "--porcelain"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("status ")
                .and(predicate::str::contains("branch").or(predicate::str::contains("clean"))),
        );
}

#[test]
fn test_skim_git_status_short_compresses() {
    // -s is now stripped by the handler; output is still compressed.
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "status", "-s"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("status ")
                .and(predicate::str::contains("branch").or(predicate::str::contains("clean"))),
        );
}

// ============================================================================
// Diff
// ============================================================================

#[test]
fn test_skim_git_diff_in_repo() {
    // Clean repo has no diff — AST-aware pipeline outputs "No changes" to stderr
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "diff"])
        .assert()
        .success();
}

#[test]
fn test_skim_git_diff_name_only_passthrough() {
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "diff", "--name-only"])
        .assert()
        .success();
}

// ============================================================================
// Log
// ============================================================================

#[test]
fn test_skim_git_log_in_repo() {
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "log"])
        .assert()
        .success()
        .stdout(predicate::str::contains("log ").and(predicate::str::contains("commit")));
}

#[test]
fn test_skim_git_log_with_limit() {
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "log", "-n", "3"])
        .assert()
        .success()
        .stdout(predicate::str::contains("log "));
}

#[test]
fn test_skim_git_log_oneline_compresses() {
    // --oneline is now stripped by the handler; the log is still compressed.
    // The `log ` prefix confirms the handler ran (not raw passthrough).
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "log", "--oneline", "-n", "3"])
        .assert()
        .success()
        .stdout(predicate::str::contains("log ").and(predicate::str::contains("commit")));
}

// ============================================================================
// Fetch
// ============================================================================

/// Run `skim git fetch` using a hermetic local bare-repo remote so the test
/// is not affected by stale remote tracking refs on the host machine.
///
/// The setup creates a bare repo (the "remote") and a worker clone. Fetching
/// from the local bare repo always succeeds and produces "up to date" output,
/// which exercises the `parse_fetch` handler without touching the network or
/// the project's own git remote.
#[test]
fn test_skim_git_fetch_in_repo() {
    let (_dir, worker) = make_hermetic_fetch_repo();
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "fetch"])
        .current_dir(&worker)
        .assert()
        .success()
        .stdout(predicate::str::contains("fetch ").or(predicate::str::contains("up to date")));
}

// ============================================================================
// Error cases
// ============================================================================

#[test]
fn test_skim_git_unknown_subcommand() {
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "unknown_subcmd"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("unknown git subcommand"));
}

#[test]
fn test_skim_git_log_contains_hashes() {
    // Compressed log output should contain commit hashes (short 7-char hex).
    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "log", "-n", "1"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).unwrap();
    // git log format is "%h %s (%cr) <%an>" — first word is the short hash
    assert!(
        stdout.starts_with("log "),
        "Expected 'log ' prefix in output, got: {stdout}"
    );
    // Verify at least one line looks like a commit (7-char hex prefix)
    let has_hash = stdout.lines().skip(1).any(|l| {
        l.split_whitespace()
            .next()
            .is_some_and(|w| w.len() >= 7 && w.chars().all(|c| c.is_ascii_hexdigit()))
    });
    assert!(
        has_hash,
        "Expected a line with a hex commit hash in output, got: {stdout}"
    );
}

// ============================================================================
// Show — new subcommand (#132)
// ============================================================================

#[test]
fn test_skim_git_show_help_listed_in_git_help() {
    // Match the exact subcommand row from print_help() in cmd/git/mod.rs so that
    // removing or renaming the "show" line causes this test to fail.
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains(
            "  show      Show compressed commit or file content at a ref",
        ));
}

#[test]
fn test_skim_git_show_help_subcommand() {
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("commit").and(predicate::str::contains("USAGE")));
}

#[test]
fn test_skim_git_show_head_commit_mode() {
    // HIGH-8: Distinguish compressed output from raw passthrough.
    //
    // Three assertions that raw `git show HEAD` cannot satisfy simultaneously:
    //   1. Output is STRICTLY shorter (byte count) than raw git show HEAD.
    //   2. Output contains em-dash (U+2014) — only present in ShowCommitResult::render.
    //   3. A 7-char hex token appears on any line.
    //
    // Raw git show HEAD starts with "commit <full-hash>\nAuthor:" which never
    // contains U+2014, and is always larger than the compressed one-liner.

    // Collect raw git show HEAD for byte-count comparison.
    let raw_output = std::process::Command::new("git")
        .args(["show", "HEAD"])
        .output()
        .expect("git must be available");
    let raw_bytes = raw_output.stdout.len();

    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "HEAD"])
        .output()
        .unwrap();
    assert!(output.status.success(), "skim git show HEAD should succeed");
    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();

    let guardrail_triggered = stderr.contains("[skim:guardrail]");
    let has_emdash = stdout.contains('\u{2014}');

    if guardrail_triggered {
        // Guardrail passthrough: compressed was larger than raw, so skim
        // emitted raw git output.  This is correct behaviour — verify the
        // output looks like raw `git show` (starts with "commit <hash>").
        assert!(
            stdout.starts_with("commit "),
            "Guardrail passthrough should emit raw git show output; got: {stdout}"
        );
    } else {
        // Compressed path: em-dash separator must be present.
        assert!(
            has_emdash,
            "Expected em-dash separator (U+2014) in compressed show output; got: {stdout}"
        );

        // Compressed output must not exceed raw by more than annotation overhead.
        // Line numbers add ~8 bytes/line (prefix char + up to 5 digits + space).
        let skim_line_count = stdout.lines().count();
        let annotation_overhead = skim_line_count * 8;
        let max_allowed = raw_bytes + annotation_overhead;
        assert!(
            stdout.len() <= max_allowed,
            "Expected compressed output ({} bytes) to be at most raw ({raw_bytes}) + \
             annotation_overhead ({annotation_overhead} = {skim_line_count} lines × 8 bytes); \
             got: {}",
            stdout.len(),
            stdout.len()
        );
    }

    // Both paths must contain a commit hash (7+ hex chars).
    // Compressed: "<hash> Author — Subject" (hash is first word).
    // Raw: "commit <full-hash>" (hash is second word).
    let has_hash = stdout.lines().any(|l| {
        l.split_whitespace()
            .any(|w| w.len() >= 7 && w.chars().all(|c| c.is_ascii_hexdigit()))
    });
    assert!(
        has_hash,
        "Expected a commit hash in show output, got: {stdout}"
    );
}

#[test]
fn test_skim_git_show_stat_passthrough() {
    // --stat triggers passthrough — git standard output format with no skim wrapping.
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "--stat", "HEAD"])
        .assert()
        .success();
}

#[test]
fn test_skim_git_show_unknown_subcommand_message() {
    // The "unknown git subcommand" error should now list "show" in the supported list.
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "totally_unknown_cmd_xyz"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("show"));
}

#[test]
fn test_skim_git_show_file_content_json_rejected() {
    // --json in file-content mode (git show <ref>:<path>) must exit with
    // code 2 (argument error) and print a clear error to stderr.
    // The error message must NOT embed the literal text "(exit code 2)"
    // since that would be self-contradictory if the code were ever changed.
    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "HEAD:Cargo.toml", "--json"])
        .output()
        .unwrap();
    assert_eq!(
        output.status.code(),
        Some(2),
        "file-content --json must exit 2 (argument error), got: {:?}",
        output.status.code()
    );
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        stderr.contains("--json is not supported"),
        "stderr must explain the rejection, got: {stderr}"
    );
    assert!(
        !stderr.contains("(exit code 2)"),
        "stderr must not embed '(exit code 2)' — self-contradictory prose, got: {stderr}"
    );
}

// ============================================================================
// Show — commit-mode JSON branch (HIGH-9, #132)
// ============================================================================

#[test]
fn test_skim_git_show_head_commit_mode_json() {
    // HIGH-9: Verify commit-mode --json path behaviour post guardrail fix.
    //
    // Commit 71a8ce6 moved the guardrail call inside the Text-only branch so
    // that the JSON branch never emits `[skim:guardrail]` to stderr.
    // This test verifies:
    //   1. Exit code 0.
    //   2. stdout parses as valid JSON with expected ShowCommitResult keys.
    //   3. stderr contains NO `[skim:guardrail]` marker.
    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "HEAD", "--json"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "skim git show HEAD --json should exit 0"
    );

    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();

    // Assertion 1: stderr must NOT contain the guardrail marker.
    assert!(
        !stderr.contains("[skim:guardrail]"),
        "JSON mode must never emit [skim:guardrail] to stderr; got stderr: {stderr}"
    );

    // Assertion 2: stdout must be valid JSON.
    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!("stdout must be valid JSON; parse error: {e}\nstdout: {stdout}")
    });

    // Assertion 3: JSON must have the expected ShowCommitResult top-level keys.
    for key in &["hash", "author", "subject", "files_changed", "files"] {
        assert!(
            json.get(key).is_some(),
            "JSON output missing expected key '{key}'; got: {stdout}"
        );
    }

    // Assertion 4: hash field should be a non-empty string.
    let hash = json["hash"]
        .as_str()
        .unwrap_or_else(|| panic!("'hash' field must be a string; got: {}", json["hash"]));
    assert!(
        !hash.is_empty(),
        "ShowCommitResult.hash must not be empty; got: {stdout}"
    );
}

// ============================================================================
// Show — file-content mode passthrough paths (MEDIUM-26, #132)
// ============================================================================

/// Tier-2 passthrough: unsupported file extension causes `Language::from_path`
/// to return `None`, routing to `passthrough_file_content` without calling
/// `rskim_core::transform`.  This exercises the "no silent drop" guarantee:
/// the file content must appear on stdout and exit code must be 0.
///
/// Note: A true Tier-3 path (transform returns `Err`) requires `rskim_core::
/// transform` to fail, which is extremely rare in practice because tree-sitter
/// is error-tolerant and `CommandRunner` performs lossy UTF-8 conversion (so
/// binary files become valid—if ugly—strings).  Tier-2 exercises the same
/// `passthrough_file_content` code path and the same "no silent drop" property.
#[test]
fn test_skim_git_show_file_content_unsupported_ext_passthrough() {
    // MEDIUM-26: Tier-2 (unsupported extension) exercises passthrough_file_content.
    // Cargo.lock has no recognised language extension → Language::from_path returns None.
    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "HEAD:Cargo.lock"])
        .output()
        .unwrap();

    // Exit code must be 0 — content is passed through, not dropped.
    assert!(
        output.status.success(),
        "skim git show HEAD:Cargo.lock should exit 0 (passthrough); \
         got exit code {:?}",
        output.status.code()
    );

    let stdout = String::from_utf8(output.stdout).unwrap();

    // stdout must be non-empty — no silent content drop.
    assert!(
        !stdout.is_empty(),
        "skim git show HEAD:Cargo.lock stdout must not be empty (content was silently dropped)"
    );

    // Cargo.lock always starts with a known header comment.
    assert!(
        stdout.contains("# This file is automatically @generated by Cargo"),
        "Expected Cargo.lock header in passthrough output; got: {}",
        &stdout[..stdout.len().min(200)]
    );
}

// ============================================================================
// Git dispatcher coverage (MEDIUM-28, #132)
// ============================================================================

/// MEDIUM-28: Verify that the `git/mod.rs::run()` dispatcher correctly routes
/// each implemented subcommand.  Each assertion checks for a subcommand-specific
/// marker that passthrough alone could not produce.
///
/// Subcommands covered: status, diff, log, show, fetch.
#[test]
fn test_skim_git_dispatcher_routes_all_subcommands() {
    // ---- status ----
    // The status handler prefixes output with `status ` (operation + space).
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "status"])
        .assert()
        .success()
        .stdout(predicate::str::contains("status "));

    // ---- log ----
    // The log handler prefixes output with `log ` (operation + space).
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "log", "-n", "1"])
        .assert()
        .success()
        .stdout(predicate::str::contains("log "));

    // ---- show ----
    // The show handler (commit mode, --json) produces a JSON object —
    // passthrough would emit raw git format starting with "commit ".
    {
        let output = Command::cargo_bin("skim")
            .unwrap()
            .args(["git", "show", "HEAD", "--json"])
            .output()
            .unwrap();
        assert!(output.status.success(), "git show dispatch: exit 0");
        let stdout = String::from_utf8(output.stdout).unwrap();
        assert!(
            stdout.trim_start().starts_with('{'),
            "git show --json must produce a JSON object; got: {}",
            &stdout[..stdout.len().min(120)]
        );
    }

    // ---- diff ----
    // `git diff` on a clean repo exits 0.  The diff handler's own help string
    // differs from native git output, but a minimal invocation only guarantees
    // exit 0 when there are no unstaged changes.
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "diff"])
        .assert()
        .success();

    // ---- fetch ----
    // The fetch handler prefixes output with `fetch ` (operation + space).
    // Uses a hermetic local bare-repo remote to avoid flakiness from stale
    // remote tracking refs on the host machine.
    let (_dir, worker) = make_hermetic_fetch_repo();
    Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "fetch"])
        .current_dir(&worker)
        .assert()
        .success()
        .stdout(predicate::str::contains("fetch ").or(predicate::str::contains("up to date")));
}

// ============================================================================
// AD-GIT-8: commit body and merge parents (Commit 1, 2026-04-11)
// ============================================================================

/// E2E test: git show must preserve multi-paragraph commit body.
///
/// Creates a temp git repo with a commit that has a multi-paragraph body,
/// invokes `skim git show HEAD`, and asserts the body paragraphs appear in
/// the output.
#[test]
fn test_git_show_preserves_commit_body() {
    let dir = tempfile::tempdir().expect("tempdir must succeed");
    let path = dir.path();

    // Init repo and configure identity.
    std::process::Command::new("git")
        .args(["init"])
        .current_dir(path)
        .output()
        .expect("git init");
    std::process::Command::new("git")
        .args(["config", "user.email", "test@example.com"])
        .current_dir(path)
        .output()
        .expect("git config email");
    std::process::Command::new("git")
        .args(["config", "user.name", "Test User"])
        .current_dir(path)
        .output()
        .expect("git config name");

    // Create a commit with multi-paragraph body.
    let commit_msg =
        "feat: multi paragraph test\n\nparagraph 1 of the body\n\nparagraph 2 of the body";
    std::process::Command::new("git")
        .args(["commit", "--allow-empty", "-m", commit_msg])
        .current_dir(path)
        .output()
        .expect("git commit");

    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "HEAD"])
        .current_dir(path)
        .output()
        .expect("skim git show must not fail to spawn");

    assert!(
        output.status.success(),
        "skim git show HEAD must exit 0 on valid commit"
    );

    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(
        stdout.contains("paragraph 1"),
        "output must contain 'paragraph 1': {stdout}"
    );
    assert!(
        stdout.contains("paragraph 2"),
        "output must contain 'paragraph 2': {stdout}"
    );
}

// ============================================================================
// Regression: git show failure path records analytics (ea4e52f)
// ============================================================================

/// Regression guard for commit ea4e52f.
///
/// Before ea4e52f, `run_show_commit` and `run_show_file_content` returned early
/// on non-zero exit WITHOUT calling `finalize_git_output`, causing the analytics
/// DB to silently miss failed `git show` invocations.
///
/// The fix introduced `ShowRawOutcome::{Success, Failure}` and threads
/// stdout/duration back so `finalize_git_output_passthrough` is called on the
/// error path. This test locks in the observable contract:
///
/// 1. `skim git show <invalid-ref>` exits with a non-zero code (failure propagates).
/// 2. stderr surfaces git's error message (not swallowed or replaced).
///
/// Analytics recording itself is fire-and-forget via a background thread and
/// cannot be verified in a pure E2E test without database introspection; the
/// exit-code + stderr assertions here are sufficient to prove the failure path
/// is traversed (a silent early-return before the fix would still have exited
/// non-zero, but stderr would have been empty).
#[test]
fn test_skim_git_show_invalid_ref_exits_nonzero() {
    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "INVALID_REF_THAT_CANNOT_EXIST_XYZ_12345"])
        .output()
        .unwrap();

    // Failure must propagate — skim must not silently succeed on a bad ref.
    assert!(
        !output.status.success(),
        "skim git show <invalid-ref> must exit non-zero; got: {:?}",
        output.status
    );

    let stderr = String::from_utf8(output.stderr).unwrap();

    // git writes "fatal: ..." to stderr on bad refs — that message must reach
    // the caller so the user knows what failed.
    assert!(
        stderr.contains("fatal") || stderr.contains("unknown revision") || !stderr.is_empty(),
        "skim git show <invalid-ref> must emit git's error on stderr; got empty stderr"
    );
}

/// Regression guard for commit ea4e52f — file-content mode failure path.
///
/// `run_show_file_content` also had the analytics-drop bug. Requesting a path
/// that does not exist in HEAD exercises the same fix.
#[test]
fn test_skim_git_show_invalid_file_ref_exits_nonzero() {
    let output = Command::cargo_bin("skim")
        .unwrap()
        .args(["git", "show", "HEAD:this_file_does_not_exist_xyz.rs"])
        .output()
        .unwrap();

    // Non-zero exit: git show exits 128 for missing objects, mapped to 1 by skim.
    assert!(
        !output.status.success(),
        "skim git show HEAD:<missing-file> must exit non-zero; got: {:?}",
        output.status
    );

    let stderr = String::from_utf8(output.stderr).unwrap();

    // git's error must surface to the caller.
    assert!(
        !stderr.is_empty(),
        "skim git show HEAD:<missing-file> must emit git's error on stderr; got empty stderr"
    );
}
