//! Cargo test parser with three-tier degradation (#46).
//!
//! Executes `cargo test` and parses the output into a structured `TestResult`.
//! Also supports cargo-nextest output via text state machine parsing.
//!
//! Three tiers:
//! - **Tier 1 (Full)**: JSON NDJSON parsing (nightly `--format json` or piped) or
//!   text state machine (nextest)
//! - **Tier 2 (Degraded)**: Regex on plain text `test result:` summary line
//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation
//!
//! NOTE: The libtest JSON format (`{"type":"test",...}`) requires nightly Rust with
//! `-Z unstable-options --format json`. On stable Rust, `cargo test` emits plain text
//! which we parse via tier 2. The JSON tier exists for piped nightly output and future
//! compatibility when libtest stabilizes the JSON format.

use std::collections::HashSet;
use std::process::ExitCode;
use std::sync::LazyLock;

use regex::Regex;

use crate::cmd::{
    OutputFormat, ParsedCommandConfig, inject_flag_before_separator, run_parsed_command_with_mode,
    should_read_stdin, user_has_flag,
};
use crate::output::ParseResult;
use crate::output::canonical::{TestEntry, TestOutcome, TestResult, TestSummary};
use crate::runner::CommandOutput;

use super::shared::{TestKind, scrape_failures};

// Static regex patterns compiled once via LazyLock (avoids per-call compilation).
static RE_PASSED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+passed").unwrap());
static RE_FAILED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+failed").unwrap());
static RE_SKIPPED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+skipped").unwrap());
static RE_CARGO_SUMMARY: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"test result: \w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored").unwrap()
});

/// Run `skim cargo test [args...]`.
///
/// Builds the cargo command, executes it, and parses the output using
/// three-tier degradation. For nextest, skips JSON injection entirely.
/// For standard cargo test, injects `--message-format=json` to get build
/// artifact JSON on stdout (test results still come as plain text).
pub(crate) fn run(
    args: &[String],
    show_stats: bool,
    rec: crate::analytics::RecordingContext<'_>,
) -> anyhow::Result<ExitCode> {
    let is_nextest = args.iter().any(|a| a == "nextest");

    // Build command args: start with "test", append all user args
    let mut cmd_args: Vec<String> = vec!["test".to_string()];
    cmd_args.extend(args.iter().cloned());

    // For standard cargo test (not nextest), inject --message-format=json
    // to suppress human-formatted build progress on stdout. This makes the
    // test harness text output cleaner to parse. Skip if user already set it.
    if !is_nextest && !user_has_flag(&cmd_args, &["--message-format"]) {
        inject_flag_before_separator(&mut cmd_args, "--message-format=json");
    }

    // Determine whether to read from stdin or execute the command.
    // Delegates to cmd::should_read_stdin for the same guard used by all
    // test parsers: stdin must be piped AND no user args provided.
    let use_stdin = should_read_stdin(args);

    run_parsed_command_with_mode(
        ParsedCommandConfig {
            program: "cargo",
            args: &cmd_args,
            env_overrides: &[("CARGO_TERM_COLOR", "never")],
            install_hint: "Install Rust via https://rustup.rs",
            use_stdin,
            show_stats,
            output_format: OutputFormat::default(),
            family: "test",
            skip_ansi_strip: false,
            rec,
        },
        move |output| parse_impl(output, is_nextest),
    )
}

/// Three-tier parse function.
///
/// Receives the `CommandOutput` and a pre-computed `is_nextest` flag (captured
/// from the original user args in `run()` to avoid re-detecting from modified args).
fn parse_impl(output: &CommandOutput, is_nextest: bool) -> ParseResult<TestResult> {
    if is_nextest {
        // Tier 1: nextest text state machine
        if let Some(result) = try_parse_nextest(&output.stdout) {
            return ParseResult::Full(result);
        }
    } else {
        // Tier 1: JSON NDJSON parsing
        if let Some(result) = try_parse_json(&output.stdout) {
            return ParseResult::Full(result);
        }
    }

    // Tier 2: regex fallback on combined output
    let combined = if output.stderr.is_empty() {
        output.stdout.clone()
    } else {
        format!("{}\n{}", output.stdout, output.stderr)
    };

    if let Some(result) = try_parse_regex(&combined) {
        return ParseResult::Degraded(
            result,
            vec!["cargo test: no libtest JSON events found, using regex".to_string()],
        );
    }

    // Tier 3: passthrough
    ParseResult::Passthrough(combined)
}

// ============================================================================
// Tier 1: JSON NDJSON parsing (standard cargo test --message-format=json)
// ============================================================================

/// Attempt to parse cargo test JSON (NDJSON) output.
///
/// Looks for lines with `"type": "test"` and `"type": "suite"` events.
/// Returns `None` if no suite summary event is found.
fn try_parse_json(stdout: &str) -> Option<TestResult> {
    let mut entries: Vec<TestEntry> = Vec::new();
    let mut suite_found = false;
    let mut passed: usize = 0;
    let mut failed: usize = 0;
    let mut ignored: usize = 0;
    let mut exec_time_ms: Option<u64> = None;

    for line in stdout.lines() {
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };

        let Some(type_field) = value.get("type").and_then(|v| v.as_str()) else {
            continue;
        };

        match type_field {
            "test" => {
                let Some(event) = value.get("event").and_then(|v| v.as_str()) else {
                    continue;
                };
                let name = value
                    .get("name")
                    .and_then(|v| v.as_str())
                    .unwrap_or("<unknown>")
                    .to_string();

                let outcome = match event {
                    "ok" => TestOutcome::Pass,
                    "failed" => TestOutcome::Fail,
                    "ignored" => TestOutcome::Skip,
                    _ => continue,
                };

                let detail = if outcome == TestOutcome::Fail {
                    value
                        .get("stdout")
                        .and_then(|v| v.as_str())
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                } else {
                    None
                };

                entries.push(TestEntry {
                    name,
                    outcome,
                    detail,
                });
            }
            "suite" => {
                let Some(event) = value.get("event").and_then(|v| v.as_str()) else {
                    continue;
                };

                // Only process terminal suite events (ok/failed), not "started"
                if event != "ok" && event != "failed" {
                    continue;
                }

                suite_found = true;
                passed = value.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
                failed = value.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
                ignored = value.get("ignored").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
                exec_time_ms = value
                    .get("exec_time")
                    .and_then(|v| v.as_f64())
                    .map(|s| (s * 1000.0) as u64);
            }
            _ => {}
        }
    }

    if !suite_found {
        return None;
    }

    let summary = TestSummary {
        pass: passed,
        fail: failed,
        skip: ignored,
        duration_ms: exec_time_ms,
    };

    Some(TestResult::new(summary, entries))
}

// ============================================================================
// Tier 1: nextest text state machine
// ============================================================================

/// Tracks whether the nextest parser is accumulating a STDOUT capture block.
///
/// Replaces the `in_stdout_block: bool` + `current_stdout_capture: Option<(String, String)>`
/// pair.  Both were semantically coupled — accumulation only happened when the
/// Option was `Some` and the bool was `true` — so a single enum expresses the
/// invariant directly.  The `flush` method centralises the three duplicated
/// "drain-and-attach" blocks into one location.
#[derive(Debug, Clone, PartialEq, Eq)]
enum CaptureState {
    /// No active capture block.
    Idle,
    /// Accumulating lines for the named test's STDOUT output.
    Capturing { test_name: String, output: String },
}

impl CaptureState {
    /// Flush accumulated output onto the matching entry, then return `Idle`.
    ///
    /// If `self` is `Idle`, or the accumulated output is blank, this is a
    /// no-op (returns `Idle` without modifying `entries`).
    fn flush(self, entries: &mut [TestEntry]) -> Self {
        if let CaptureState::Capturing { test_name, output } = self {
            let detail = output.trim().to_string();
            if !detail.is_empty() {
                for entry in entries.iter_mut() {
                    if entry.name == test_name && entry.detail.is_none() {
                        entry.detail = Some(detail);
                        break;
                    }
                }
            }
        }
        CaptureState::Idle
    }
}

/// Attempt to parse cargo-nextest text output.
///
/// Nextest output format:
/// ```text
///     Starting N tests across M test binaries
///         PASS [0.003s] crate::tests::test_name
///         FAIL [0.006s] crate::tests::test_name
///      Summary [0.010s] N tests run: N passed, N failed, N skipped
/// ```
///
/// Nextest prints failures both inline and in a recap section. We deduplicate
/// by tracking `(name, outcome)` pairs.
fn try_parse_nextest(stdout: &str) -> Option<TestResult> {
    let mut entries: Vec<TestEntry> = Vec::new();
    let mut seen: HashSet<(String, TestOutcome)> = HashSet::new();
    let mut summary_found = false;
    let mut total_passed: usize = 0;
    let mut total_failed: usize = 0;
    let mut total_skipped: usize = 0;
    let mut duration_ms: Option<u64> = None;

    // Track stdout capture blocks for failure detail.
    let mut capture = CaptureState::Idle;

    for line in stdout.lines() {
        let trimmed = line.trim();

        // Check for STDOUT capture block start: "--- STDOUT:              crate::tests::test_name ---"
        if trimmed.starts_with("--- STDOUT:") && trimmed.ends_with("---") {
            let inner = trimmed
                .strip_prefix("--- STDOUT:")
                .unwrap_or("")
                .strip_suffix("---")
                .unwrap_or("")
                .trim();
            if !inner.is_empty() {
                capture = CaptureState::Capturing {
                    test_name: inner.to_string(),
                    output: String::new(),
                };
                continue;
            }
        }

        // Check for STDERR capture block start (ends the STDOUT block)
        if trimmed.starts_with("--- STDERR:") && trimmed.ends_with("---") {
            // Finalize any pending STDOUT capture.
            capture = capture.flush(&mut entries);
            continue;
        }

        // If we're in a STDOUT capture block, accumulate lines.
        // Use trim_end() instead of trim() to preserve leading whitespace
        // in assertion messages and formatted output.
        if let CaptureState::Capturing { ref mut output, .. } = capture {
            if !output.is_empty() {
                output.push('\n');
            }
            output.push_str(line.trim_end());
            continue;
        }

        // Parse test result lines: "PASS [0.003s] crate::tests::test_name"
        if let Some(rest) = trimmed.strip_prefix("PASS") {
            push_nextest_entry(rest, TestOutcome::Pass, &mut seen, &mut entries);
            continue;
        }

        if let Some(rest) = trimmed.strip_prefix("FAIL") {
            push_nextest_entry(rest, TestOutcome::Fail, &mut seen, &mut entries);
            continue;
        }

        if let Some(rest) = trimmed.strip_prefix("SKIP") {
            push_nextest_entry(rest, TestOutcome::Skip, &mut seen, &mut entries);
            continue;
        }

        // Parse summary line: "Summary [0.010s] N tests run: N passed, N failed, N skipped"
        if trimmed.starts_with("Summary")
            && let Some(summary) = parse_nextest_summary(trimmed)
        {
            summary_found = true;
            total_passed = summary.0;
            total_failed = summary.1;
            total_skipped = summary.2;
            duration_ms = summary.3;
        }
    }

    // Finalize any pending stdout capture.
    capture.flush(&mut entries);

    if !summary_found {
        return None;
    }

    let summary = TestSummary {
        pass: total_passed,
        fail: total_failed,
        skip: total_skipped,
        duration_ms,
    };

    Some(TestResult::new(summary, entries))
}

/// Push a deduplicated nextest entry into `entries`.
///
/// Called for PASS, FAIL, and SKIP lines; the `outcome` discriminates the
/// variant. Duplicate `(name, outcome)` pairs are silently dropped via `seen`.
fn push_nextest_entry(
    rest: &str,
    outcome: TestOutcome,
    seen: &mut HashSet<(String, TestOutcome)>,
    entries: &mut Vec<TestEntry>,
) {
    if let Some(name) = extract_nextest_name(rest) {
        let key = (name.clone(), outcome);
        if seen.insert(key) {
            entries.push(TestEntry {
                name,
                outcome,
                detail: None,
            });
        }
    }
}

/// Extract the test name from a nextest result line remainder.
///
/// Input: ` [0.003s] rskim lib::tests::test_a`
/// Output: `Some("rskim lib::tests::test_a")`
fn extract_nextest_name(rest: &str) -> Option<String> {
    let rest = rest.trim();
    // Skip the duration bracket: [0.003s]
    if let Some(after_bracket) = rest.strip_prefix('[')
        && let Some(pos) = after_bracket.find(']')
    {
        let name = after_bracket[pos + 1..].trim();
        if !name.is_empty() {
            return Some(name.to_string());
        }
    }
    None
}

/// Parse a nextest summary line.
///
/// Input: `Summary [   0.010s] 3 tests run: 3 passed, 0 skipped`
/// Returns: `(passed, failed, skipped, duration_ms)`
fn parse_nextest_summary(line: &str) -> Option<(usize, usize, usize, Option<u64>)> {
    let mut passed: usize = 0;
    let mut failed: usize = 0;
    let mut skipped: usize = 0;
    let mut duration_ms: Option<u64> = None;

    // Extract duration from brackets
    if let Some(start) = line.find('[')
        && let Some(end) = line.find(']')
    {
        let dur_str = line[start + 1..end].trim();
        if let Some(secs_str) = dur_str.strip_suffix('s')
            && let Ok(secs) = secs_str.trim().parse::<f64>()
        {
            duration_ms = Some((secs * 1000.0) as u64);
        }
    }

    // Parse "N passed", "N failed", "N skipped" from the summary.
    // Uses static LazyLock regexes to avoid per-call compilation.
    let mut any_matched = false;

    if let Some(caps) = RE_PASSED.captures(line) {
        any_matched = true;
        passed = caps[1].parse().unwrap_or(0);
    }
    if let Some(caps) = RE_FAILED.captures(line) {
        any_matched = true;
        failed = caps[1].parse().unwrap_or(0);
    }
    if let Some(caps) = RE_SKIPPED.captures(line) {
        any_matched = true;
        skipped = caps[1].parse().unwrap_or(0);
    }

    // At least one count field must have matched for a valid summary.
    // This correctly handles zero-count summaries (e.g., "0 passed, 0 skipped"
    // when all tests are filtered out) by tracking regex matches, not values.
    if any_matched {
        Some((passed, failed, skipped, duration_ms))
    } else {
        None
    }
}

// ============================================================================
// Tier 2: regex fallback
// ============================================================================

/// Attempt to parse standard cargo test summary lines using regex.
///
/// Cargo runs multiple test binaries, each producing its own summary line:
/// `test result: ok. N passed; N failed; N ignored`
///
/// This function finds ALL such lines and aggregates the totals. When failures
/// are present, individual test names are scraped via [`scrape_failures`] so
/// LLMs receive a list of failing tests (AD-Commit2, 2026-04-11).
fn try_parse_regex(text: &str) -> Option<TestResult> {
    let mut total_passed: usize = 0;
    let mut total_failed: usize = 0;
    let mut total_ignored: usize = 0;
    let mut found = false;

    for caps in RE_CARGO_SUMMARY.captures_iter(text) {
        found = true;
        total_passed += caps[1].parse::<usize>().unwrap_or(0);
        total_failed += caps[2].parse::<usize>().unwrap_or(0);
        total_ignored += caps[3].parse::<usize>().unwrap_or(0);
    }

    if !found {
        return None;
    }

    let summary = TestSummary {
        pass: total_passed,
        fail: total_failed,
        skip: total_ignored,
        duration_ms: None,
    };

    // Scrape failing test names from the full text output so the Tier-2 result
    // includes individual test names rather than an empty entries list.
    let entries = if total_failed > 0 {
        scrape_failures(text, TestKind::Cargo)
    } else {
        vec![]
    };

    Some(TestResult::new(summary, entries))
}

// ============================================================================
// Unit tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cmd::test_utils::{load_fixture, make_output, make_output_full};

    // ========================================================================
    // Tier 1: JSON parsing
    // ========================================================================

    #[test]
    fn test_tier1_all_pass() {
        let input = load_fixture("test", "cargo_pass.json");
        let result = try_parse_json(&input);
        assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed");
        let result = result.unwrap();
        assert!(result.summary.pass > 0, "Expected at least one pass");
        assert_eq!(result.summary.fail, 0, "Expected zero failures");
    }

    #[test]
    fn test_tier1_with_failures() {
        let input = load_fixture("test", "cargo_fail.json");
        let result = try_parse_json(&input);
        assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed");
        let result = result.unwrap();
        assert!(result.summary.fail > 0, "Expected at least one failure");

        // Check that failure entries have detail
        let fail_entries: Vec<&TestEntry> = result
            .entries
            .iter()
            .filter(|e| e.outcome == TestOutcome::Fail)
            .collect();
        assert!(!fail_entries.is_empty(), "Expected failure entries");
        assert!(
            fail_entries[0].detail.is_some(),
            "Expected failure detail (stdout capture)"
        );
    }

    // ========================================================================
    // Tier 1: nextest parsing
    // ========================================================================

    #[test]
    fn test_tier1_nextest_pass() {
        let input = load_fixture("test", "cargo_nextest_pass.txt");
        let result = try_parse_nextest(&input);
        assert!(result.is_some(), "Expected Tier 1 nextest parse to succeed");
        let result = result.unwrap();
        assert_eq!(result.summary.pass, 3);
        assert_eq!(result.summary.fail, 0);
    }

    #[test]
    fn test_tier1_nextest_dedup() {
        let input = load_fixture("test", "cargo_nextest_fail.txt");
        let result = try_parse_nextest(&input);
        assert!(result.is_some(), "Expected Tier 1 nextest parse to succeed");
        let result = result.unwrap();

        // The fixture has test_b listed twice (inline + recap). Should be deduped.
        let fail_entries: Vec<&TestEntry> = result
            .entries
            .iter()
            .filter(|e| e.outcome == TestOutcome::Fail)
            .collect();
        assert_eq!(
            fail_entries.len(),
            1,
            "Expected exactly 1 failure entry (deduped), got {}",
            fail_entries.len()
        );
    }

    #[test]
    fn test_tier1_nextest_failure_detail_populated() {
        // Verify that CaptureState::flush attaches the STDOUT block to the
        // matching failure entry. The fixture's STDOUT block is delimited by:
        //   --- STDOUT: rskim lib::tests::test_b ---
        //   <content>
        //   --- STDERR: rskim lib::tests::test_b ---
        // Capture runs until the STDERR marker, which triggers the flush.
        let input = load_fixture("test", "cargo_nextest_fail.txt");
        let result = try_parse_nextest(&input).expect("nextest parse must succeed");

        let fail_entries: Vec<&TestEntry> = result
            .entries
            .iter()
            .filter(|e| e.outcome == TestOutcome::Fail)
            .collect();
        assert_eq!(fail_entries.len(), 1, "Expected exactly 1 failure entry");
        assert!(
            fail_entries[0].detail.is_some(),
            "Expected failure entry to have detail populated from STDOUT capture block"
        );
    }

    // ========================================================================
    // CaptureState::flush unit tests
    // ========================================================================

    #[test]
    fn test_capture_flush_idle_is_noop() {
        // Flush on Idle returns Idle and does not mutate entries.
        let mut entries = vec![TestEntry {
            name: "a::test".to_string(),
            outcome: TestOutcome::Fail,
            detail: None,
        }];
        let state = CaptureState::Idle.flush(&mut entries);
        assert!(
            matches!(state, CaptureState::Idle),
            "flush on Idle must return Idle"
        );
        assert!(
            entries[0].detail.is_none(),
            "Idle flush must not set detail"
        );
    }

    #[test]
    fn test_capture_flush_attaches_content_to_matching_entry() {
        // Flush with non-blank output sets detail on the matching entry.
        let mut entries = vec![TestEntry {
            name: "a::test".to_string(),
            outcome: TestOutcome::Fail,
            detail: None,
        }];
        let state = CaptureState::Capturing {
            test_name: "a::test".to_string(),
            output: "assertion failed: expected 1, got 2".to_string(),
        }
        .flush(&mut entries);
        assert!(
            matches!(state, CaptureState::Idle),
            "flush must return Idle"
        );
        assert_eq!(
            entries[0].detail.as_deref(),
            Some("assertion failed: expected 1, got 2"),
            "flush must attach non-blank output as detail"
        );
    }

    #[test]
    fn test_capture_flush_blank_output_is_noop() {
        // Flush with whitespace-only output does not set detail.
        let mut entries = vec![TestEntry {
            name: "a::test".to_string(),
            outcome: TestOutcome::Fail,
            detail: None,
        }];
        let state = CaptureState::Capturing {
            test_name: "a::test".to_string(),
            output: "   \n  ".to_string(),
        }
        .flush(&mut entries);
        assert!(
            matches!(state, CaptureState::Idle),
            "flush must return Idle"
        );
        assert!(
            entries[0].detail.is_none(),
            "blank output must not set detail"
        );
    }

    #[test]
    fn test_capture_block_delimited_by_stderr_marker() {
        // Confirm that capture accumulation is bounded by the STDERR marker.
        // Lines between the STDOUT and STDERR markers are captured; lines
        // after the STDERR marker are not included in detail.
        let input = "\
    Starting 1 tests across 1 test binary
        FAIL [   0.001s] crate::tests::test_x
--- STDOUT:              crate::tests::test_x ---
captured line 1
captured line 2
--- STDERR:              crate::tests::test_x ---
this line is stderr, not captured in stdout detail
     Summary [   0.001s] 1 tests run: 0 passed, 1 failed, 0 skipped
";
        let result = try_parse_nextest(input).expect("must parse");
        let fail = result
            .entries
            .iter()
            .find(|e| e.outcome == TestOutcome::Fail)
            .expect("must have failure entry");
        let detail = fail.detail.as_deref().expect("detail must be populated");
        assert!(
            detail.contains("captured line 1"),
            "detail must include stdout content"
        );
        assert!(
            detail.contains("captured line 2"),
            "detail must include all stdout content"
        );
        assert!(
            !detail.contains("this line is stderr"),
            "detail must not include lines after STDERR marker"
        );
    }

    // ========================================================================
    // Tier 2: regex fallback
    // ========================================================================

    #[test]
    fn test_tier2_regex_fallback() {
        let text = "running 10 tests\ntest result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out";
        let result = try_parse_regex(text);
        assert!(result.is_some(), "Expected Tier 2 regex parse to succeed");
        let result = result.unwrap();
        assert_eq!(result.summary.pass, 10);
        assert_eq!(result.summary.fail, 0);
        assert_eq!(result.summary.skip, 0);
    }

    #[test]
    fn test_tier2_regex_with_failures() {
        let text = "test result: FAILED. 8 passed; 2 failed; 1 ignored; 0 measured";
        let result = try_parse_regex(text);
        assert!(result.is_some(), "Expected Tier 2 regex parse to succeed");
        let result = result.unwrap();
        assert_eq!(result.summary.pass, 8);
        assert_eq!(result.summary.fail, 2);
        assert_eq!(result.summary.skip, 1);
    }

    // ========================================================================
    // Three-tier integration via parse_impl()
    // ========================================================================

    #[test]
    fn test_parse_json_produces_full() {
        let input = load_fixture("test", "cargo_pass.json");
        let output = make_output(&input);
        let result = parse_impl(&output, false);
        assert!(
            result.is_full(),
            "Expected Full parse result, got {}",
            result.tier_name()
        );
    }

    #[test]
    fn test_parse_plain_text_produces_degraded() {
        let output = make_output("test result: ok. 5 passed; 0 failed; 0 ignored");
        let result = parse_impl(&output, false);
        assert!(
            result.is_degraded(),
            "Expected Degraded parse result, got {}",
            result.tier_name()
        );
    }

    #[test]
    fn test_parse_garbage_produces_passthrough() {
        let output = make_output_full(
            "completely unparseable output\nno json, no regex match",
            "",
            Some(1),
        );
        let result = parse_impl(&output, false);
        assert!(
            result.is_passthrough(),
            "Expected Passthrough, got {}",
            result.tier_name()
        );
    }

    // ========================================================================
    // Flag inspection helpers
    // ========================================================================

    #[test]
    fn test_flag_injection_skipped() {
        // When user already has --message-format=json2, we should NOT inject another
        let args = vec![
            "test".to_string(),
            "--message-format=json2".to_string(),
            "--".to_string(),
            "--nocapture".to_string(),
        ];
        assert!(
            user_has_flag(&args, &["--message-format"]),
            "Should detect existing --message-format flag"
        );
    }

    #[test]
    fn test_flag_injection_not_triggered_for_different_flag() {
        let args = vec!["test".to_string(), "--release".to_string()];
        assert!(
            !user_has_flag(&args, &["--message-format"]),
            "Should not detect --message-format when only --release is present"
        );
    }

    #[test]
    fn test_separator_args_preserved() {
        let mut args = vec![
            "test".to_string(),
            "--".to_string(),
            "--nocapture".to_string(),
        ];
        inject_flag_before_separator(&mut args, "--message-format=json");
        // --message-format=json should be before --
        assert_eq!(args[1], "--message-format=json");
        assert_eq!(args[2], "--");
        assert_eq!(args[3], "--nocapture");
    }

    #[test]
    fn test_inject_flag_no_separator() {
        let mut args = vec!["test".to_string(), "--release".to_string()];
        inject_flag_before_separator(&mut args, "--message-format=json");
        // Should be appended at the end
        assert_eq!(args.last().unwrap(), "--message-format=json");
    }

    // ========================================================================
    // Tier-2 scrape_failures integration (AD-Commit2)
    // ========================================================================

    #[test]
    fn test_tier2_regex_scrapes_failing_test_names() {
        let input = load_fixture("test", "cargo_regex_fail.txt");
        let result = try_parse_regex(&input);
        assert!(
            result.is_some(),
            "regex parse must succeed on failure fixture"
        );
        let result = result.unwrap();
        assert!(result.summary.fail > 0, "must have failures");
        // Tier-2 entries should now include failing test names.
        assert!(
            !result.entries.is_empty(),
            "Tier-2 must list failing test names, got empty entries"
        );
        assert!(
            result.entries.iter().any(|e| e.name.contains("test_foo")),
            "must contain test_foo: {:?}",
            result.entries
        );
    }

    #[test]
    fn test_tier2_regression_tier1_json_still_populates_entries() {
        // Tier-1 JSON path must still populate entries (regression guard).
        let input = load_fixture("test", "cargo_fail.json");
        let result = try_parse_json(&input);
        assert!(result.is_some(), "Tier-1 JSON parse must succeed");
        let result = result.unwrap();
        assert!(
            !result.entries.is_empty(),
            "Tier-1 entries must not be empty after Tier-2 changes"
        );
    }
}
