#![cfg(unix)]
//! Integration tests for the shared grep/rg compression filter: the GROUP path
//! with context flags (-A/-B/-C) and the safety-net passthrough for flags (some
//! grep-only like -I, some rg-only like --heading/-p) that break the NUL reparse.

use std::process::Command;

fn rtk() -> Command {
    Command::new(env!("CARGO_BIN_EXE_rtk"))
}

fn rg_available() -> bool {
    Command::new("rg")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// `rtk grep` shells out to `grep`, never `rg` (`search.rs`: `Engine::Grep => "grep"`).
/// Guarding a grep-engine test on `rg_available()` makes it silently skip -- and
/// report success -- on a box without ripgrep, which is a false green.
fn grep_available() -> bool {
    Command::new("grep")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().join("test.txt");
    std::fs::write(&path, content).expect("write");
    (dir, path)
}

// --- context compression (the gain win) ---

#[test]
fn single_file_context_shown_without_header() {
    if !grep_available() {
        return;
    }
    let long = "x".repeat(120);
    let content =
        format!("before {long}\nMATCH {long}\nafter1 {long}\nafter2 {long}\nend {long}\n");
    let (_dir, path) = write_temp(&content);
    let out = rtk()
        .args(["grep", "-A2", "MATCH", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains("matches in"),
        "single-file search must not add a grouped header:\n{stdout}"
    );
    assert!(
        stdout.contains("after1") && stdout.contains("after2"),
        "after-context lines must be shown:\n{stdout}"
    );
}

#[test]
fn after_context_uses_dash_separator_for_context_lines() {
    if !grep_available() {
        return;
    }
    let (_dir, path) = write_temp("MATCH\nafter1\n");
    let out = rtk()
        .args(["grep", "-nA1", "MATCH", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    let has_context_dash = stdout.lines().any(|l| l.contains("-after1"));
    assert!(
        has_context_dash,
        "context lines must use dash separator:\n{stdout}"
    );
}

#[test]
fn capped_single_file_shows_header() {
    if !grep_available() {
        return;
    }
    let filler: String = (0..40).map(|i| format!("w{i} ")).collect();
    let content: String = (0..60).map(|i| format!("foo {i} {filler}\n")).collect();
    let (_dir, path) = write_temp(&content);
    let out = rtk()
        .args(["grep", "foo", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        stdout.contains("matches in"),
        "header must show once capping compresses:\n{stdout}"
    );
}

#[test]
fn true_no_match_exits_1() {
    if !grep_available() {
        return;
    }
    let (_dir, path) = write_temp("hello world\n");
    let out = rtk()
        .args(["grep", "zzzz_no_match_xyz", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");

    assert_eq!(
        out.status.code(),
        Some(1),
        "true no-match must exit 1, not 0"
    );
}

// --- safety net: flags that break the NUL-based reparse ---

#[test]
fn no_line_number_flag_produces_output_not_zero_matches() {
    if !rg_available() {
        return;
    }
    let (_dir, path) = write_temp("hello world\n");
    let out = rtk()
        .args(["rg", "-N", "hello", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains("0 matches"),
        "-N output must not be reported as '0 matches':\n{stdout}"
    );
    assert!(
        stdout.contains("hello"),
        "-N output must contain the matching line:\n{stdout}"
    );
}

#[test]
fn no_filename_flag_produces_output_not_zero_matches() {
    if !grep_available() {
        return;
    }
    let (_dir, path) = write_temp("hello world\n");
    let out = rtk()
        .args(["grep", "-I", "hello", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains("0 matches"),
        "-I output must not be reported as '0 matches':\n{stdout}"
    );
    assert!(
        stdout.contains("hello"),
        "-I output must contain the matching line:\n{stdout}"
    );
}

#[test]
fn heading_flag_produces_output_not_zero_matches() {
    if !rg_available() {
        return;
    }
    let (_dir, path) = write_temp("hello world\n");
    let out = rtk()
        .args(["rg", "--heading", "hello", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains("0 matches"),
        "--heading output must not be reported as '0 matches':\n{stdout}"
    );
    assert!(
        stdout.contains("hello"),
        "--heading output must contain the matching line:\n{stdout}"
    );
}

#[test]
fn pretty_flag_produces_output_not_zero_matches() {
    if !rg_available() {
        return;
    }
    let (_dir, path) = write_temp("hello world\n");
    let out = rtk()
        .args(["rg", "-p", "hello", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains("0 matches"),
        "-p output must not be reported as '0 matches':\n{stdout}"
    );
    assert!(
        stdout.contains("hello"),
        "-p output must contain the matching line:\n{stdout}"
    );
}

// --- shape flags: passthrough, no NUL leak ---

#[test]
fn column_flag_output_has_no_nul() {
    if !rg_available() {
        return;
    }
    let (_dir, path) = write_temp("hello world\n");
    let out = rtk()
        .args(["rg", "--column", "hello", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains('\u{0}'),
        "--column output must not contain NUL:\n{stdout:?}"
    );
    assert!(
        stdout.contains("hello"),
        "--column output must contain the match:\n{stdout}"
    );
}

// --- token savings (the compression gain) ---

fn count_tokens(s: &str) -> usize {
    s.split_whitespace().count()
}

// Covers #545: grep savings are measured against the real grep output.
#[test]
fn bulky_grep_yields_token_savings() {
    if !rg_available() || !grep_available() {
        return;
    }
    let filler: String = (0..50).map(|i| format!("word{i} ")).collect();
    let mut content = String::new();
    for i in 0..60 {
        content.push_str(&format!("MATCH line {i} {filler}\n"));
    }
    let (_dir, path) = write_temp(&content);

    let raw = Command::new("rg")
        .args(["-nH", "MATCH", path.to_str().unwrap()])
        .output()
        .expect("rg");
    let raw_tokens = count_tokens(&String::from_utf8_lossy(&raw.stdout));

    let out = rtk()
        .args(["grep", "MATCH", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let rtk_tokens = count_tokens(&String::from_utf8_lossy(&out.stdout));

    let savings = 100.0 - (rtk_tokens as f64 / raw_tokens as f64 * 100.0);
    assert!(
        savings >= 50.0,
        "expected >=50% token savings, got {savings:.1}% (raw={raw_tokens}, rtk={rtk_tokens})"
    );
}

// --- grep-only syntax falls back to system grep (#2543) ---

#[test]
fn grep_only_flags_fall_back_to_system_grep() {
    if !grep_available() {
        return;
    }
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("a.java"), "DRAFT in java\n").expect("write");
    std::fs::write(dir.path().join("b.txt"), "DRAFT in text\n").expect("write");
    let path = dir.path().to_str().unwrap();

    let out = rtk()
        .args(["grep", "-rn", "DRAFT", "--include=*.java", path])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        stdout.contains("a.java"),
        "--include=*.java should match the java file:\n{stdout}"
    );
    assert!(
        !stdout.contains("b.txt"),
        "--include=*.java should exclude the txt file:\n{stdout}"
    );
    assert!(
        !stdout.contains("grep failed") && !stdout.contains("unrecognized"),
        "rg-incompatible flag must fall back to grep, not error:\n{stdout}"
    );
}

// --- never-worse guard: small greps must not cost more than plain grep ---

#[test]
fn small_grep_not_worse_than_plain() {
    if !grep_available() {
        return;
    }
    let (_dir, path) = write_temp("foo\n");
    let out = rtk()
        .args(["grep", "foo", path.to_str().unwrap()])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        stdout.trim() == "foo",
        "single-file grep must equal plain `grep` (content only, no position/filename):\n{stdout}"
    );
    assert!(
        !stdout.contains("matches in"),
        "header that costs more than raw must be dropped:\n{stdout}"
    );
}

// --- a numeric pattern must not bind to rtk's removed `-l` short ---

#[test]
fn numeric_pattern_with_files_with_matches_flag() {
    if !grep_available() {
        return;
    }
    // The pattern MUST parse as a usize for this test to gate anything. With
    // `-l` bound to `--max-len: usize`, a non-numeric pattern makes clap fail,
    // and run_fallback then re-runs raw grep and prints the right answer -- so
    // a word pattern passes with or without the fix. A numeric pattern instead
    // binds silently: `-l 8080` sets max_len=8080, leaving the first filename
    // as the pattern and the second as the only path. No error, no fallback,
    // wrong answer.
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("hit.txt"), "listen on port 8080 today\n").expect("write");
    std::fs::write(dir.path().join("miss.txt"), "nothing numeric here\n").expect("write");
    let hit = dir.path().join("hit.txt");
    let miss = dir.path().join("miss.txt");

    let out = rtk()
        .args([
            "grep",
            "-l",
            "8080",
            hit.to_str().unwrap(),
            miss.to_str().unwrap(),
        ])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        out.status.success(),
        "`grep -l <number>` must find the match (#2628); status={:?} stderr={}",
        out.status.code(),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        stdout.contains("hit.txt"),
        "-l must report the file containing 8080:\n{stdout}"
    );
    assert!(
        !stdout.contains("miss.txt"),
        "-l must not report the non-matching file:\n{stdout}"
    );
}

// --- #2628: rtk's own long options still bind after the short forms were removed ---

#[test]
fn max_len_long_option_still_binds_to_rtk() {
    if !grep_available() {
        return;
    }
    // The other half of #2628: dropping the `-l` short must not stop the long
    // form from reaching rtk's truncation. Needs a bulky match set -- a small
    // result passes through uncompressed under the never-worse-than-plain
    // guard, so a one-line file would prove nothing either way.
    let body: String = (0..60)
        .map(|i| format!("MATCH {i} {}\n", "x".repeat(400)))
        .collect();
    let (_dir, path) = write_temp(&body);
    let file = path.to_str().unwrap();

    // Measure matched lines only. rtk's own trailer ("+N more ... [see
    // remaining: tail ...]") is fixed-width chrome that --max-len does not
    // govern, and it is the longest line in either run.
    let widest_match = |args: &[&str]| -> usize {
        let out = rtk().args(args).output().expect("rtk grep");
        assert!(
            out.status.success(),
            "stderr={}",
            String::from_utf8_lossy(&out.stderr)
        );
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .filter(|l| l.starts_with("MATCH "))
            .map(|l| l.chars().count())
            .max()
            .unwrap_or(0)
    };

    let narrow = widest_match(&["grep", "--max-len", "40", "MATCH", file]);
    let default = widest_match(&["grep", "MATCH", file]);

    assert!(
        narrow > 0 && default > 0,
        "expected matched lines in both runs"
    );
    assert!(
        narrow < default,
        "--max-len must still bind after the short form was removed \
         (narrow={narrow}, default={default})"
    );
}

// --- #2543: bundled files-with-matches cluster (-rln / -ln) lists files, not "0 matches" ---

#[test]
fn bundled_files_with_matches_cluster_lists_files() {
    if !grep_available() {
        return;
    }
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("a.txt"), "TODO alpha\n").expect("write");
    std::fs::write(dir.path().join("b.txt"), "TODO beta\n").expect("write");
    std::fs::write(dir.path().join("c.txt"), "nothing here\n").expect("write");
    let path = dir.path().to_str().unwrap();

    let out = rtk()
        .args(["grep", "-rln", "TODO", path])
        .output()
        .expect("rtk grep");
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !stdout.contains("0 matches"),
        "-rln must list files, not report a false '0 matches' (#2543):\n{stdout}"
    );
    assert!(
        stdout.contains("a.txt") && stdout.contains("b.txt"),
        "-rln must list every matching file:\n{stdout}"
    );
    assert!(
        !stdout.contains("c.txt"),
        "-rln must not list non-matching files:\n{stdout}"
    );
}

// A match inside a binary file is noise (grep prints only a "binary file matches"
// notice); skip it by default, but `-a` lets the agent opt back into the content.
#[test]
fn binary_match_is_skipped_unless_text_requested() {
    let dir = tempfile::tempdir().expect("tempdir");
    let p = dir.path().join("blob.bin");
    std::fs::write(&p, b"SECRET\x00\x01binary\xff\xfe").expect("write");
    let path = p.to_str().unwrap();

    let out = rtk().args(["grep", "SECRET", path]).output().expect("rtk");
    assert_eq!(
        out.status.code(),
        Some(1),
        "binary match must be skipped as noise by default"
    );
    assert!(out.stdout.is_empty(), "no binary content by default");

    let out = rtk()
        .args(["grep", "-a", "SECRET", path])
        .output()
        .expect("rtk -a");
    assert_eq!(out.status.code(), Some(0), "-a must surface the match");
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("SECRET"),
        "-a must show the binary content"
    );
}
