//! Vitest/Jest parser with three-tier degradation (#48)
//!
//! Parses vitest (and Jest-compatible) JSON output into structured `TestResult`.
//! Supports three degradation tiers:
//!
//! - **Tier 1 (JSON)**: Full JSON parse with brace-balance extraction to handle
//!   pnpm/dotenv prefix noise before the JSON payload.
//! - **Tier 2 (regex)**: Falls back to regex matching on summary lines when JSON
//!   parsing fails.
//! - **Tier 3 (passthrough)**: Returns raw output unchanged when nothing parses.

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

use regex::Regex;

use crate::cmd::user_has_flag;
use crate::output::ParseResult;
use crate::output::canonical::{TestEntry, TestOutcome, TestResult, TestSummary};

use super::shared::{ArgPreparation, TestKind, TestRunnerConfig, run_test_runner, scrape_failures};

// ============================================================================
// Public entry point
// ============================================================================

/// Run vitest/jest with the given args, or read piped stdin, and parse the output.
///
/// `program` is the runner binary name (e.g. `"vitest"` or `"jest"`), used when
/// stdin is not piped and we need to spawn the process directly.
pub(crate) fn run(
    program: &str,
    args: &[String],
    show_stats: bool,
    rec: crate::analytics::RecordingContext<'_>,
) -> anyhow::Result<ExitCode> {
    let config = TestRunnerConfig {
        program,
        install_hint: &format!("Install {program} locally (npm install -D {program}) or globally"),
        node_fallback: true,
        env_overrides: &[],
    };

    run_test_runner(
        &config,
        args,
        show_stats,
        rec,
        ArgPreparation {
            // Passthrough: no subcommand to prepend, no flags to inject.
            passthrough: |a: &[String]| a.to_vec(),
            // Normal: inject the JSON reporter flag for the normal spawn path.
            normal: |a: &[String]| {
                let mut final_args = a.to_vec();
                if program == "jest" {
                    if !user_has_flag(a, &["--json"]) {
                        final_args.push("--json".to_string());
                    }
                } else if !user_has_flag(a, &["--reporter"]) {
                    final_args.push("--reporter=json".to_string());
                }
                final_args
            },
        },
        parse,
    )
}

// ============================================================================
// Three-tier parser
// ============================================================================

/// Parse vitest/jest output through three degradation tiers.
pub(crate) fn parse(raw: &str) -> ParseResult<TestResult> {
    // Tier 1: Try JSON parse
    if let Some(result) = try_parse_json(raw) {
        return ParseResult::Full(result);
    }

    // Tier 2: Try regex fallback
    if let Some(result) = try_parse_regex(raw) {
        return ParseResult::Degraded(
            result,
            vec!["vitest: JSON parse failed, using regex".to_string()],
        );
    }

    // Tier 3: Passthrough
    ParseResult::Passthrough(raw.to_string())
}

// ============================================================================
// Tier 1: JSON parsing with brace-balance extraction
// ============================================================================

/// Vitest JSON output structure (subset we care about).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct VitestJson {
    #[serde(default)]
    num_passed_tests: usize,
    #[serde(default)]
    num_failed_tests: usize,
    #[serde(default)]
    num_pending_tests: usize,
    #[serde(default)]
    test_results: Vec<VitestTestSuite>,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct VitestTestSuite {
    #[serde(default)]
    assertion_results: Vec<VitestAssertion>,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct VitestAssertion {
    status: String,
    #[serde(default)]
    full_name: String,
    #[serde(default)]
    failure_messages: Vec<String>,
}

/// Try to extract and parse JSON from the raw output.
///
/// Handles pnpm workspace resolution lines, dotenv loading messages, and other
/// prefix noise by using brace-balance counting to find the outermost JSON object.
/// If the first balanced `{...}` candidate is not valid Vitest JSON (e.g., a
/// `{project}` tag in a pnpm log), subsequent candidates are tried.
fn try_parse_json(raw: &str) -> Option<TestResult> {
    let cleaned = crate::output::strip_ansi_cow(raw);

    // Iterate over brace-balanced candidates until one parses as Vitest JSON.
    let mut search_from = 0;
    let parsed: VitestJson = loop {
        let json_str = extract_json_by_brace_balance_from(&cleaned, search_from)?;
        if let Ok(v) = serde_json::from_str::<VitestJson>(json_str) {
            break v;
        }
        // Advance past this candidate's starting `{` and try the next one.
        let candidate_start = cleaned[search_from..]
            .find(json_str)
            .map(|off| search_from + off)
            .unwrap_or(search_from);
        search_from = candidate_start + 1;
    };

    let mut entries = Vec::new();
    for suite in &parsed.test_results {
        for assertion in &suite.assertion_results {
            let outcome = match assertion.status.as_str() {
                "passed" => TestOutcome::Pass,
                "failed" => TestOutcome::Fail,
                "pending" | "skipped" | "todo" => TestOutcome::Skip,
                _ => TestOutcome::Skip,
            };

            let detail = if assertion.failure_messages.is_empty() {
                None
            } else {
                Some(assertion.failure_messages.join("\n"))
            };

            entries.push(TestEntry {
                name: assertion.full_name.clone(),
                outcome,
                detail,
            });
        }
    }

    let summary = TestSummary {
        pass: parsed.num_passed_tests,
        fail: parsed.num_failed_tests,
        skip: parsed.num_pending_tests,
        duration_ms: None,
    };

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

/// Extract a brace-balanced JSON object from the input, starting the scan at
/// byte offset `search_from`.
///
/// Scans for each `{` from `search_from` onward, then counts braces (respecting
/// string literals to avoid being confused by `{` and `}` inside quoted strings)
/// to find the matching closing `}`. If a `{` does not produce a balanced pair,
/// scanning continues from the next `{` candidate.
fn extract_json_by_brace_balance_from(input: &str, search_from: usize) -> Option<&str> {
    let bytes = input.as_bytes();
    let mut pos = search_from;

    while pos < bytes.len() {
        let start = pos + bytes[pos..].iter().position(|&b| b == b'{')?;

        if let Some(end) = find_balanced_end(bytes, start) {
            return Some(&input[start..=end]);
        }

        // This `{` didn't balance -- advance past it and try the next one.
        pos = start + 1;
    }

    None
}

/// Convenience wrapper: extract from the beginning of the input.
#[cfg(test)]
fn extract_json_by_brace_balance(input: &str) -> Option<&str> {
    extract_json_by_brace_balance_from(input, 0)
}

/// Starting from `bytes[start]` (which must be `b'{'`), scan forward using
/// brace-balance counting with string-literal awareness. Returns the index of
/// the matching `}` if found, or `None` if the braces never balance.
fn find_balanced_end(bytes: &[u8], start: usize) -> Option<usize> {
    debug_assert_eq!(bytes[start], b'{');

    let mut depth: usize = 0;
    let mut in_string = false;
    let mut escape_next = false;
    let mut i = start;

    while i < bytes.len() {
        let b = bytes[i];

        if escape_next {
            escape_next = false;
            i += 1;
            continue;
        }

        if in_string {
            match b {
                b'\\' => escape_next = true,
                b'"' => in_string = false,
                _ => {}
            }
            i += 1;
            continue;
        }

        match b {
            b'"' => in_string = true,
            b'{' => depth += 1,
            b'}' => {
                depth -= 1;
                if depth == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }

        i += 1;
    }

    None
}

// ============================================================================
// Tier 2: Regex fallback
// ============================================================================

/// Vitest pipe-format summary regex (compiled once).
///
/// Matches: "Tests  3 passed | 0 failed | 3 total"
static PIPE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"Tests\s+(\d+)\s+passed\s*\|\s*(\d+)\s+failed\s*\|\s*(\d+)\s+total")
        .expect("PIPE_RE is a valid regex")
});

/// Jest comma-format summary regex (compiled once).
///
/// Matches: "Tests: 5 passed, 2 failed, 7 total"
static COMMA_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"Tests:\s+(\d+)\s+passed(?:,\s+(\d+)\s+failed)?(?:,\s+(\d+)\s+total)?")
        .expect("COMMA_RE is a valid regex")
});

/// Try to parse test summary from plain text output using regex patterns.
///
/// When failures are detected, individual test names are scraped via
/// [`scrape_failures`] so the Tier-2 result includes a list of failing
/// tests rather than an empty entries list (AD-Commit2, 2026-04-11).
fn try_parse_regex(raw: &str) -> Option<TestResult> {
    // Strip ANSI once here; pass `cleaned` into `scrape_failures` so it can
    // skip its own ANSI-strip pass (fast-path in shared::scrape_failures detects
    // the absence of ESC bytes and borrows the slice directly — no second scan).
    let cleaned = crate::output::strip_ansi(raw);

    // Pattern 1: "Tests  3 passed | 0 failed | 3 total"
    if let Some(caps) = PIPE_RE.captures(&cleaned) {
        let pass: usize = caps[1].parse().ok()?;
        let fail: usize = caps[2].parse().ok()?;
        let total: usize = caps[3].parse().ok()?;
        let skip = total.saturating_sub(pass + fail);

        let summary = TestSummary {
            pass,
            fail,
            skip,
            duration_ms: None,
        };
        let entries = if fail > 0 {
            scrape_failures(&cleaned, TestKind::Vitest)
        } else {
            vec![]
        };
        return Some(TestResult::new(summary, entries));
    }

    // Pattern 2: "Tests:\s+N passed(?:,\s+N failed)?(?:,\s+N total)?"
    if let Some(caps) = COMMA_RE.captures(&cleaned) {
        let pass: usize = caps[1].parse().ok()?;
        let fail: usize = caps
            .get(2)
            .and_then(|m| m.as_str().parse().ok())
            .unwrap_or(0);
        let total: usize = caps
            .get(3)
            .and_then(|m| m.as_str().parse().ok())
            .unwrap_or(pass + fail);
        let skip = total.saturating_sub(pass + fail);

        let summary = TestSummary {
            pass,
            fail,
            skip,
            duration_ms: None,
        };
        let entries = if fail > 0 {
            scrape_failures(&cleaned, TestKind::Vitest)
        } else {
            vec![]
        };
        return Some(TestResult::new(summary, entries));
    }

    None
}

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

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

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

    #[test]
    fn test_tier1_clean_json() {
        let input = load_fixture("test", "vitest_pass.json");
        let result = parse(&input);

        assert!(
            result.is_full(),
            "expected Full, got {}",
            result.tier_name()
        );
        if let ParseResult::Full(test_result) = result {
            assert_eq!(test_result.summary.pass, 3);
            assert_eq!(test_result.summary.fail, 0);
            assert_eq!(test_result.summary.skip, 0);
            assert_eq!(test_result.entries.len(), 3);
        }
    }

    #[test]
    fn test_tier1_with_failures() {
        let input = load_fixture("test", "vitest_fail.json");
        let result = parse(&input);

        assert!(
            result.is_full(),
            "expected Full, got {}",
            result.tier_name()
        );
        if let ParseResult::Full(test_result) = result {
            assert_eq!(test_result.summary.pass, 1);
            assert_eq!(test_result.summary.fail, 1);
            assert_eq!(test_result.summary.skip, 1);

            // Check failure details
            let failed_entry = test_result
                .entries
                .iter()
                .find(|e| e.outcome == TestOutcome::Fail)
                .expect("should have a failed entry");
            assert_eq!(failed_entry.name, "math > divides");
            assert!(
                failed_entry
                    .detail
                    .as_ref()
                    .unwrap()
                    .contains("Expected 0, received Infinity"),
                "failure detail should contain the error message"
            );

            // Check skip/pending
            let skipped_entry = test_result
                .entries
                .iter()
                .find(|e| e.outcome == TestOutcome::Skip)
                .expect("should have a skipped entry");
            assert_eq!(skipped_entry.name, "math > todo test");
        }
    }

    #[test]
    fn test_tier1_pnpm_prefix_noise() {
        let input = load_fixture("test", "vitest_pnpm_prefix.json");
        let result = parse(&input);

        assert!(
            result.is_full(),
            "expected Full despite pnpm prefix noise, got {}",
            result.tier_name()
        );
        if let ParseResult::Full(test_result) = result {
            assert_eq!(test_result.summary.pass, 2);
            assert_eq!(test_result.summary.fail, 0);
        }
    }

    // ========================================================================
    // Brace balance extraction tests
    // ========================================================================

    #[test]
    fn test_brace_balance_nested_objects() {
        let input = r#"noise {"outer": {"inner": {"deep": 1}}, "key": "val"} trailing"#;
        let extracted = extract_json_by_brace_balance(input).unwrap();
        assert_eq!(
            extracted,
            r#"{"outer": {"inner": {"deep": 1}}, "key": "val"}"#
        );

        // Verify it's valid JSON
        let parsed: serde_json::Value = serde_json::from_str(extracted).unwrap();
        assert!(parsed.is_object());
    }

    #[test]
    fn test_brace_balance_string_with_braces() {
        let input = r#"prefix {"value": "has { and } chars", "num": 42} suffix"#;
        let extracted = extract_json_by_brace_balance(input).unwrap();
        assert_eq!(extracted, r#"{"value": "has { and } chars", "num": 42}"#);

        // Verify it's valid JSON
        let parsed: serde_json::Value = serde_json::from_str(extracted).unwrap();
        assert_eq!(parsed["num"], 42);
    }

    #[test]
    fn test_brace_balance_escaped_quotes_in_string() {
        let input = r#"{"msg": "she said \"hello {world}\""}"#;
        let extracted = extract_json_by_brace_balance(input).unwrap();
        assert_eq!(extracted, input);
    }

    #[test]
    fn test_brace_balance_no_json() {
        let input = "no json here at all";
        assert!(extract_json_by_brace_balance(input).is_none());
    }

    #[test]
    fn test_brace_balance_unclosed_brace() {
        let input = r#"{"key": "value"#;
        assert!(extract_json_by_brace_balance(input).is_none());
    }

    #[test]
    fn test_brace_balance_deeply_nested() {
        // 20 levels of nesting -- verifies brace counter handles deep structures
        let mut json = String::new();
        for _ in 0..20 {
            json.push_str(r#"{"d":"#);
        }
        json.push('1');
        for _ in 0..20 {
            json.push('}');
        }

        let input = format!("prefix noise {json} trailing");
        let extracted = extract_json_by_brace_balance(&input).unwrap();
        assert_eq!(extracted, json);

        // Verify it's valid JSON
        let parsed: serde_json::Value = serde_json::from_str(extracted).unwrap();
        assert!(parsed.is_object());
    }

    #[test]
    fn test_brace_balance_prefix_noise_with_unbalanced_brace() {
        // Prefix contains a truly unbalanced `{` (no matching `}` before the JSON).
        // The retry logic should skip past the unbalanced brace and find the real JSON.
        let input = "some output { incomplete prefix\n\
                     {\"numPassedTests\":1,\"numFailedTests\":0,\"numPendingTests\":0,\"testResults\":[]}";
        let extracted = extract_json_by_brace_balance(input).unwrap();
        assert!(
            extracted.starts_with(r#"{"numPassedTests""#),
            "should skip the unbalanced prefix brace and find the JSON object, got: {extracted}"
        );

        // Verify it's valid JSON
        let parsed: serde_json::Value = serde_json::from_str(extracted).unwrap();
        assert_eq!(parsed["numPassedTests"], 1);
    }

    #[test]
    fn test_brace_balance_prefix_balanced_non_json() {
        // Prefix contains a balanced `{...}` that is not valid JSON (e.g., `{project}`).
        // The brace extractor returns it since it balances, but serde rejects it.
        // The `try_parse_json` retry loop then finds the real JSON as the next candidate.
        let input = r#"{project} output
{"numTotalTestSuites":1,"numPassedTestSuites":1,"numFailedTestSuites":0,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"testResults":[]}"#;

        // At the brace-balance level, `{project}` is the first balanced match.
        let extracted = extract_json_by_brace_balance(input).unwrap();
        assert_eq!(extracted, "{project}");

        // Through the full parser, serde rejects `{project}` and the retry loop
        // advances to find the real JSON as the next candidate.
        let result = parse(input);
        assert!(
            result.is_full(),
            "expected Full after serde rejects prefix, got {}",
            result.tier_name()
        );
        if let ParseResult::Full(test_result) = result {
            assert_eq!(test_result.summary.pass, 1);
            assert_eq!(test_result.summary.fail, 0);
        }
    }

    #[test]
    fn test_tier1_ansi_encoded_json() {
        // JSON wrapped in ANSI escape codes (e.g., vitest with colored output)
        let input = "\x1b[1m\x1b[32m{\"numTotalTestSuites\":1,\"numPassedTestSuites\":1,\"numFailedTestSuites\":0,\"numPassedTests\":2,\"numFailedTests\":0,\"numPendingTests\":0,\"testResults\":[{\"assertionResults\":[{\"status\":\"passed\",\"fullName\":\"test_a\",\"failureMessages\":[]},{\"status\":\"passed\",\"fullName\":\"test_b\",\"failureMessages\":[]}]}]}\x1b[0m";
        let result = parse(input);

        assert!(
            result.is_full(),
            "expected Full for ANSI-wrapped JSON, got {}",
            result.tier_name()
        );
        if let ParseResult::Full(test_result) = result {
            assert_eq!(test_result.summary.pass, 2);
            assert_eq!(test_result.summary.fail, 0);
            assert_eq!(test_result.entries.len(), 2);
        }
    }

    // ========================================================================
    // Tier 2: Regex fallback tests
    // ========================================================================

    #[test]
    fn test_tier2_regex_fallback_pipe_format() {
        let input = "Tests  3 passed | 0 failed | 3 total";
        let result = parse(input);

        assert!(
            result.is_degraded(),
            "expected Degraded, got {}",
            result.tier_name()
        );
        if let ParseResult::Degraded(test_result, markers) = result {
            assert_eq!(test_result.summary.pass, 3);
            assert_eq!(test_result.summary.fail, 0);
            assert_eq!(test_result.summary.skip, 0);
            assert!(markers.contains(&"vitest: JSON parse failed, using regex".to_string()));
        }
    }

    #[test]
    fn test_tier2_regex_fallback_comma_format() {
        let input = "Tests: 5 passed, 2 failed, 7 total";
        let result = parse(input);

        assert!(
            result.is_degraded(),
            "expected Degraded, got {}",
            result.tier_name()
        );
        if let ParseResult::Degraded(test_result, _) = result {
            assert_eq!(test_result.summary.pass, 5);
            assert_eq!(test_result.summary.fail, 2);
            assert_eq!(test_result.summary.skip, 0);
        }
    }

    #[test]
    fn test_tier2_regex_within_larger_output() {
        let input = "\
Some vitest output here
Running tests...
Tests  10 passed | 2 failed | 12 total
Duration: 1.5s";
        let result = parse(input);

        assert!(
            result.is_degraded(),
            "expected Degraded, got {}",
            result.tier_name()
        );
        if let ParseResult::Degraded(test_result, _) = result {
            assert_eq!(test_result.summary.pass, 10);
            assert_eq!(test_result.summary.fail, 2);
            assert_eq!(test_result.summary.skip, 0);
        }
    }

    /// AD-Commit2 integration: Tier-2 vitest path must scrape failing-test names
    /// from the real fixture and populate `entries` (not `vec![]`). Regression
    /// guard for the original scrape_failures wiring + the indented-line regex.
    #[test]
    fn test_tier2_regex_scrapes_failing_test_names_from_fixture() {
        let fixture = std::fs::read_to_string({
            let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
            p.push("tests/fixtures/cmd/test/vitest_regex_fail.txt");
            p
        })
        .expect("fixture must load");
        let result = try_parse_regex(&fixture).expect("must parse");
        assert!(result.summary.fail > 0, "fixture must declare failures");
        assert!(
            !result.entries.is_empty(),
            "Tier-2 must populate failing test names from vitest output, got empty entries"
        );
        assert!(
            result
                .entries
                .iter()
                .any(|e| e.name.contains("divides by zero")),
            "entries must include the failing test name 'divides by zero': {:?}",
            result.entries
        );
    }

    // ========================================================================
    // Tier 3: Passthrough tests
    // ========================================================================

    #[test]
    fn test_tier3_passthrough() {
        let input = "completely unparseable output with no test info";
        let result = parse(input);

        assert!(
            result.is_passthrough(),
            "expected Passthrough, got {}",
            result.tier_name()
        );
    }

    // ========================================================================
    // Flag injection tests
    // ========================================================================

    #[test]
    fn test_flag_injection_skipped_when_reporter_present() {
        let args = vec![
            "--reporter=verbose".to_string(),
            "--run".to_string(),
            "math".to_string(),
        ];
        assert!(
            user_has_flag(&args, &["--reporter"]),
            "should detect --reporter=verbose"
        );
    }

    #[test]
    fn test_flag_injection_skipped_bare_flag() {
        let args = vec!["--reporter".to_string(), "json".to_string()];
        assert!(
            user_has_flag(&args, &["--reporter"]),
            "should detect bare --reporter"
        );
    }

    #[test]
    fn test_flag_injection_needed_when_no_reporter() {
        let args = vec!["--run".to_string(), "math".to_string()];
        assert!(
            !user_has_flag(&args, &["--reporter"]),
            "should not detect --reporter when absent"
        );
    }
}
