//! Cargo test framework implementation using `cargo nextest` for discovery.

use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

use async_trait::async_trait;
use serde::Deserialize;

use super::{
    FrameworkError, FrameworkResult, TestFramework, TestInstance, TestRecord,
    discovery_error_detail,
};
use crate::config::CargoFrameworkConfig;
use crate::provider::Command;
use crate::report::junit::TestsuiteXml;

/// Minimal representation of `cargo nextest list --message-format json` output.
#[derive(Deserialize)]
struct NextestListOutput {
    #[serde(rename = "rust-suites")]
    rust_suites: HashMap<String, NextestSuite>,
}

#[derive(Deserialize)]
struct NextestSuite {
    #[serde(rename = "binary-id")]
    binary_id: String,
    testcases: HashMap<String, NextestTestcase>,
}

#[derive(Deserialize)]
struct NextestTestcase {
    #[serde(rename = "filter-match")]
    filter_match: Option<FilterMatch>,
}

#[derive(Deserialize)]
struct FilterMatch {
    status: String,
}

/// Test framework for Rust projects using `cargo nextest`.
///
/// Uses `cargo nextest list` for test discovery and generates commands
/// with JUnit XML output for structured result parsing.
///
/// # Configuration
///
/// See [`CargoFrameworkConfig`] for available options including:
/// - `package`: Package to test (for workspaces)
/// - `features`: Cargo features to enable
/// - `bin`: Binary target name
/// - `include_ignored`: Include `#[ignore]` tests
pub struct CargoFramework {
    config: CargoFrameworkConfig,
}

impl CargoFramework {
    /// Creates a new cargo test framework with the given configuration.
    pub fn new(config: CargoFrameworkConfig) -> Self {
        Self { config }
    }

    /// Parse `cargo nextest list --message-format json` output.
    ///
    /// Returns test IDs in the format `binary_id test_name` to match
    /// JUnit XML output where classname=binary and name=test_name.
    ///
    /// When filters are applied, nextest includes all tests in the output but marks
    /// each with a `filter-match` field. Only tests with `status: "matches"` (or no
    /// filter-match field) are included in the result.
    fn parse_json_output(&self, json: &str, group: &str) -> FrameworkResult<Vec<TestRecord>> {
        let listing: NextestListOutput = serde_json::from_str(json)
            .map_err(|e| FrameworkError::DiscoveryFailed(format!("Failed to parse JSON: {}", e)))?;

        let mut tests = Vec::new();
        for suite in listing.rust_suites.values() {
            for (test_name, testcase) in &suite.testcases {
                // Include test if:
                // - filter-match is not present (no filter applied), or
                // - filter-match.status == "matches"
                let include = testcase
                    .filter_match
                    .as_ref()
                    .map(|fm| fm.status == "matches")
                    .unwrap_or(true);

                if include {
                    let test_id = format!("{} {}", suite.binary_id, test_name);
                    tests.push(TestRecord::new(&test_id, group));
                }
            }
        }
        Ok(tests)
    }
}

#[async_trait]
impl TestFramework for CargoFramework {
    async fn discover(
        &self,
        _paths: &[PathBuf],
        filters: &str,
        group: &str,
    ) -> FrameworkResult<Vec<TestRecord>> {
        let mut cmd_args = vec![
            "nextest".to_string(),
            "list".to_string(),
            "--message-format".to_string(),
            "json".to_string(),
        ];

        if let Some(package) = &self.config.package {
            cmd_args.push("-p".to_string());
            cmd_args.push(package.clone());
        }

        if !self.config.features.is_empty() {
            cmd_args.push("--features".to_string());
            cmd_args.push(self.config.features.join(","));
        }

        if let Some(bin) = &self.config.bin {
            cmd_args.push("--bin".to_string());
            cmd_args.push(bin.clone());
        }

        if self.config.include_ignored {
            cmd_args.push("--run-ignored".to_string());
            cmd_args.push("only".to_string());
        }

        // Add filters if provided
        if !filters.is_empty() {
            let args = shell_words::split(filters).map_err(|e| {
                FrameworkError::DiscoveryFailed(format!(
                    "Invalid filter string '{}': {}",
                    filters, e
                ))
            })?;
            cmd_args.extend(args);
        }

        let cmd_display = format!("cargo {}", cmd_args.join(" "));

        let output = tokio::process::Command::new("cargo")
            .args(&cmd_args)
            .output()
            .await
            .map_err(|e| FrameworkError::DiscoveryFailed(e.to_string()))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        if !output.status.success() {
            let detail = discovery_error_detail(&stderr, &stdout);
            return Err(FrameworkError::DiscoveryFailed(format!(
                "cargo nextest list failed ({}):\n  command: {}\n  {}",
                output.status, cmd_display, detail
            )));
        }

        let tests = self.parse_json_output(&stdout, group)?;

        if tests.is_empty() {
            tracing::warn!(
                "No tests discovered. Output: {}",
                discovery_error_detail(&stderr, &stdout)
            );
        } else {
            tracing::info!("Discovered {} tests", tests.len());
        }

        Ok(tests)
    }

    fn produce_test_execution_command(
        &self,
        tests: &[TestInstance],
        result_path: &str,
        fail_fast: bool,
    ) -> Command {
        // Nextest configures JUnit output via config file, not CLI flags.
        // Write a temporary config that sets the JUnit path, then run nextest
        // with --config-file pointing to it. This ensures each sandbox writes
        // to a unique path, avoiding collisions with the local provider.
        let config_path = format!("{}.nextest.toml", result_path);

        let fail_fast_flag = if fail_fast {
            "--fail-fast"
        } else {
            "--no-fail-fast"
        };

        let mut args = vec![
            "nextest".to_string(),
            "run".to_string(),
            fail_fast_flag.to_string(),
            "--config-file".to_string(),
            config_path.clone(),
        ];

        if let Some(package) = &self.config.package {
            args.push("-p".to_string());
            args.push(package.clone());
        }

        if !self.config.features.is_empty() {
            args.push("--features".to_string());
            args.push(self.config.features.join(","));
        }

        if let Some(bin) = &self.config.bin {
            args.push("--bin".to_string());
            args.push(bin.clone());
        }

        if self.config.include_ignored {
            args.push("--run-ignored".to_string());
            args.push("only".to_string());
        }

        // Build filter expression: (binary(=b1) & test(=t1)) | (binary(=b2) & test(=t2)) | ...
        // Test IDs are in format "binary_name test::path", we need both to uniquely identify tests
        let filter_expr: String = tests
            .iter()
            .map(|t| {
                // Split into binary and test path; fall back to just test filter if no space
                if let Some((binary, test_path)) = t.id().split_once(' ') {
                    format!("(binary_id(={}) & test(={}))", binary, test_path)
                } else {
                    format!("test(={})", t.id())
                }
            })
            .collect::<Vec<_>>()
            .join(" | ");

        args.push("-E".to_string());
        args.push(filter_expr);

        let cargo_args = args
            .iter()
            .map(|a| shell_words::quote(a).into_owned())
            .collect::<Vec<_>>()
            .join(" ");

        // Write a nextest config with the unique JUnit path, then run cargo nextest
        let shell_cmd = format!(
            "cat > {config_path} << 'NEXTEST_EOF'\n\
             [profile.default.junit]\n\
             path = \"{result_path}\"\n\
             NEXTEST_EOF\n\
             cargo {cargo_args}",
        );

        Command::new("sh").arg("-c").arg(&shell_cmd)
    }

    fn resolve_test_ids(
        &self,
        testsuites: &mut [TestsuiteXml],
        batch_test_ids: &[String],
    ) -> FrameworkResult<()> {
        let batch_set: HashSet<&str> = batch_test_ids.iter().map(|s| s.as_str()).collect();
        for testsuite in testsuites.iter_mut() {
            for testcase in &mut testsuite.testcases {
                let canonical = format!(
                    "{} {}",
                    testcase.classname.as_deref().unwrap_or(""),
                    &testcase.name
                );
                if !batch_set.contains(canonical.as_str()) {
                    return Err(FrameworkError::Other(anyhow::anyhow!(
                        "JUnit testcase '{}' not found in batch test IDs",
                        canonical
                    )));
                }
                testcase.name = canonical;
                testcase.classname = None;
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::framework::{TestInstance, TestRecord};

    #[test]
    fn test_execution_command_fail_fast() {
        let config = CargoFrameworkConfig::default();
        let fw = CargoFramework::new(config);
        let record = TestRecord::new("my-bin test::foo", "grp");
        let tests = vec![TestInstance::new(&record)];

        let cmd = fw.produce_test_execution_command(&tests, "/tmp/junit.xml", true);
        // cargo produces a sh -c command; check the shell string in args[1]
        let shell_str = &cmd.args[1];
        assert!(
            shell_str.contains("--fail-fast"),
            "fail_fast=true should produce --fail-fast. Got: {}",
            shell_str
        );
        assert!(
            !shell_str.contains("--no-fail-fast"),
            "fail_fast=true should not produce --no-fail-fast. Got: {}",
            shell_str
        );

        let cmd_no = fw.produce_test_execution_command(&tests, "/tmp/junit.xml", false);
        let shell_str_no = &cmd_no.args[1];
        assert!(
            shell_str_no.contains("--no-fail-fast"),
            "fail_fast=false should produce --no-fail-fast. Got: {}",
            shell_str_no
        );
    }
}
