//! Run tests and load results into the repository

use crate::commands::utils::open_or_init_repository;
use crate::commands::Command;
use crate::config::TimeoutSetting;
use crate::error::Result;
use crate::ordering::{apply_order, OrderingContext, TestOrder};
use crate::test_executor::{self, TestExecutor, TestExecutorConfig};
use crate::testcommand::TestCommand;
use crate::ui::UI;
use std::path::Path;
use std::time::Duration;

/// Result of running tests via the CLI, including the run ID for callers that need it.
pub struct CliRunOutput {
    /// Process exit code (0 = success, non-zero = failure).
    pub exit_code: i32,
    /// The run ID assigned to this execution, if any.
    pub run_id: Option<crate::repository::RunId>,
}

impl CliRunOutput {
    /// Outcome for paths that finished without allocating a run (e.g. no
    /// failing tests to re-run, hit the iteration cap, auto-config failed).
    fn no_run(exit_code: i32) -> Self {
        Self {
            exit_code,
            run_id: None,
        }
    }
}

/// Command to run tests and load results into the repository.
///
/// All fields default to false/None/Disabled, so callers only need to set
/// the fields they care about.
#[derive(Default)]
pub struct RunCommand {
    /// Repository path (defaults to current directory)
    pub base_path: Option<String>,
    /// Only run previously failing tests
    pub failing_only: bool,
    /// Initialize the repository if it doesn't exist
    pub force_init: bool,
    /// Add/update failing tests without clearing previous failures
    pub partial: bool,
    /// Auto-detect and generate config if missing
    pub auto: bool,
    /// Path to a file containing test IDs to run
    pub load_list: Option<String>,
    /// Pre-computed list of tests to run, in the order they should run.
    /// Bypasses discovery and `--load-list` parsing. Used by `inq rerun` to
    /// reproduce the exact test set and order from a previous run.
    pub test_ids_override: Option<Vec<crate::repository::TestId>>,
    /// Number of parallel test workers
    pub concurrency: Option<usize>,
    /// Run tests repeatedly until they fail
    pub until_failure: bool,
    /// Maximum number of iterations for until_failure mode
    pub max_iterations: Option<usize>,
    /// Run each test in a separate process
    pub isolated: bool,
    /// Output in subunit format instead of showing progress
    pub subunit: bool,
    /// Show all test output instead of just failures
    pub all_output: bool,
    /// Test patterns to filter
    pub test_filters: Option<Vec<String>>,
    /// Tag filters used when summarising counts. `None` means fall back to
    /// the `filter_tags` value in the config; `Some(_)` overrides it (an
    /// empty `Vec` clears the filter entirely). Each entry may be a positive
    /// tag (e.g. `worker-0`) or an exclusion (`!slow`).
    pub filter_tags: Option<Vec<String>>,
    /// `-s`/`--starting-with` prefixes (possibly abbreviated; expanded
    /// against the discovered test list before matching).
    pub starting_with: Option<Vec<String>>,
    /// Additional arguments to pass to the test command
    pub test_args: Option<Vec<String>>,
    /// Per-test timeout setting
    pub test_timeout: TimeoutSetting,
    /// Overall run timeout setting
    pub max_duration: TimeoutSetting,
    /// Kill test if no output for this duration
    pub no_output_timeout: Option<Duration>,
    /// Maximum number of restarts on timeout or crash (None = default).
    pub max_restarts: Option<usize>,
    /// Optional explicit ordering strategy. If `None`, the strategy comes
    /// from the configuration file (defaulting to discovery order).
    pub test_order: Option<TestOrder>,
    /// Optional shared buffer for capturing child-process stderr in addition
    /// to terminal forwarding. Used by the MCP server to surface stderr in
    /// the failure response.
    pub stderr_capture: Option<std::sync::Arc<std::sync::Mutex<Vec<u8>>>>,
    /// Optional slot that receives the run ID as soon as it is allocated,
    /// before the tests finish executing. Used by the MCP server's
    /// `background_after` feature to return a run handle to callers while
    /// the run continues in the background.
    pub run_id_slot: Option<std::sync::Arc<std::sync::Mutex<Option<crate::repository::RunId>>>>,
    /// Optional cancellation token forwarded to the test executor. Lets
    /// the MCP server's `inq_cancel` tool stop a run that was handed off
    /// to the caller via `background_after`.
    pub cancellation_token: Option<crate::test_executor::CancellationToken>,
    /// Active profile name from `--profile` / `INQ_PROFILE` (None = base
    /// only or `default_profile` from the config file).
    pub profile: Option<String>,
    /// True when invoked as bare `inq` (no subcommand). Tweaks the
    /// concurrency-resolution messaging so the user is told how to opt out
    /// of the auto-parallel default. Has no effect on actual concurrency.
    pub implicit_run: bool,
    /// When true, print details about the ETA prediction: the calibration
    /// factor and the inputs that produced it (before the run), and the
    /// predicted-vs-actual comparison (after the run). Wired from the
    /// global `--eta-debug` CLI flag.
    pub eta_debug: bool,
    /// Niceness increment for spawned test processes (unix only). See
    /// [`crate::test_executor::TestExecutorConfig::nice`].
    pub nice: Option<i32>,
}

impl RunCommand {
    /// Creates a new run command with default settings.
    pub fn new(base_path: Option<String>) -> Self {
        RunCommand {
            base_path,
            ..Default::default()
        }
    }

    /// Creates a run command that only runs previously failing tests.
    pub fn with_failing_only(base_path: Option<String>) -> Self {
        RunCommand {
            base_path,
            failing_only: true,
            partial: true,
            ..Default::default()
        }
    }

    fn executor_config(
        &self,
        stderr_capture: Option<std::sync::Arc<std::sync::Mutex<Vec<u8>>>>,
        display_prefix: Option<String>,
    ) -> TestExecutorConfig {
        TestExecutorConfig {
            base_path: self.base_path.clone(),
            all_output: self.all_output,
            test_args: self.test_args.clone(),
            cancellation_token: self.cancellation_token.clone(),
            max_restarts: self.max_restarts,
            stderr_capture,
            result_callback: None,
            display_prefix,
            nice: self.nice,
        }
    }
}

impl Command for RunCommand {
    fn execute(&self, ui: &mut dyn UI) -> Result<i32> {
        let output = self.execute_returning_run_id(ui)?;
        Ok(output.exit_code)
    }

    fn name(&self) -> &str {
        "run"
    }

    fn help(&self) -> &str {
        "Run tests and load results into the repository"
    }
}

impl RunCommand {
    /// If a `run_id_slot` was supplied, store the newly allocated run ID in it
    /// so external observers (e.g. the MCP server's `background_after` logic)
    /// can see the ID before the run finishes.
    fn publish_run_id(&self, run_id: &crate::repository::RunId) {
        if let Some(ref slot) = self.run_id_slot {
            if let Ok(mut guard) = slot.lock() {
                *guard = Some(run_id.clone());
            }
        }
    }

    /// Persist the executor output into the repository, display the summary,
    /// and repack the result as a `CliRunOutput`.
    ///
    /// If a `stderr_capture` buffer is supplied, its contents are persisted
    /// alongside the run and the buffer is drained so the next iteration
    /// starts clean.
    #[allow(clippy::too_many_arguments)]
    fn persist(
        &self,
        ui: &mut dyn UI,
        repo: &mut dyn crate::repository::Repository,
        output: crate::test_executor::RunOutput,
        historical_times: &std::collections::HashMap<crate::repository::TestId, Duration>,
        filter_tags: &[String],
        stderr_capture: Option<&std::sync::Arc<std::sync::Mutex<Vec<u8>>>>,
        active_profile: Option<&str>,
    ) -> Result<CliRunOutput> {
        let (exit_code, run_id) = crate::commands::utils::persist_and_display_run(
            ui,
            repo,
            output,
            self.partial,
            historical_times,
            filter_tags,
            active_profile.map(|s| s.to_string()),
            self.eta_debug,
        )?;
        if let Some(buf) = stderr_capture {
            let bytes = match buf.lock() {
                Ok(mut guard) => std::mem::take(&mut *guard),
                Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
            };
            repo.set_run_stderr(&run_id, &bytes)?;
        }
        Ok(CliRunOutput {
            exit_code,
            run_id: Some(run_id),
        })
    }

    /// Execute tests and return both the exit code and the run ID.
    pub fn execute_returning_run_id(&self, ui: &mut dyn UI) -> Result<CliRunOutput> {
        let base = Path::new(self.base_path.as_deref().unwrap_or("."));

        if self.auto && crate::config::ConfigFile::find_in_directory(base).is_err() {
            let auto_cmd = crate::commands::auto::AutoCommand::new(self.base_path.clone());
            let exit_code = auto_cmd.execute(ui)?;
            if exit_code != 0 {
                return Ok(CliRunOutput::no_run(exit_code));
            }
        }

        let mut repo =
            open_or_init_repository(self.base_path.as_deref(), self.force_init || self.auto, ui)?;

        let (config_file, _config_path) = crate::config::ConfigFile::find_in_directory(base)?;
        let (resolved, active_profile) = config_file.resolve(self.profile.as_deref())?;
        let test_cmd = TestCommand::new(resolved, base.to_path_buf());
        if let Some(ref name) = active_profile {
            ui.output(&format!("Using profile: {}", name))?;
        }

        // CLI overrides config; fall back to whatever `filter_tags` is set to
        // in the config file when no CLI flag was given.
        let filter_tags: Vec<String> = match &self.filter_tags {
            Some(tags) => tags.clone(),
            None => test_cmd.config().parsed_filter_tags(),
        };

        let (test_timeout, max_duration, no_output_timeout) = test_executor::resolve_timeouts(
            &self.test_timeout,
            &self.max_duration,
            self.no_output_timeout,
            &test_cmd,
        )?;

        let mut test_ids = if let Some(ref override_ids) = self.test_ids_override {
            Some(override_ids.clone())
        } else if self.failing_only {
            let failing = repo.get_failing_tests()?;
            if failing.is_empty() {
                ui.output("No failing tests to run")?;
                return Ok(CliRunOutput::no_run(0));
            }
            Some(failing)
        } else {
            None
        };

        if let Some(ref load_list_path) = self.load_list {
            let load_list_ids = crate::testlist::parse_list_file(Path::new(load_list_path))?;

            if let Some(existing_ids) = test_ids {
                let load_list_set: std::collections::HashSet<_> = load_list_ids.iter().collect();
                test_ids = Some(
                    existing_ids
                        .into_iter()
                        .filter(|id| load_list_set.contains(id))
                        .collect(),
                );
            } else {
                test_ids = Some(load_list_ids);
            }
        }

        if let Some(ref filters) = self.test_filters {
            use regex::Regex;

            let compiled_filters: Result<Vec<Regex>> = filters
                .iter()
                .map(|pattern| {
                    Regex::new(pattern).map_err(|e| {
                        crate::error::Error::Config(format!(
                            "Invalid test filter regex '{}': {}",
                            pattern, e
                        ))
                    })
                })
                .collect();
            let compiled_filters = compiled_filters?;

            let all_test_ids = if let Some(ids) = test_ids {
                ids
            } else {
                test_cmd.list_tests()?
            };

            let prefix = crate::grouping::common_group_prefix(
                &all_test_ids,
                test_cmd.config().group_regex.as_deref(),
            );

            let filtered_ids: Vec<_> = all_test_ids
                .into_iter()
                .filter(|test_id| {
                    let full = test_id.as_str();
                    let stripped = prefix
                        .as_deref()
                        .map(|p| crate::grouping::strip_prefix(full, p));
                    compiled_filters
                        .iter()
                        .any(|re| re.is_match(full) || stripped.is_some_and(|s| re.is_match(s)))
                })
                .collect();

            test_ids = Some(filtered_ids);
        }

        if let Some(ref starting_with) = self.starting_with {
            let all_test_ids = if let Some(ids) = test_ids {
                ids
            } else {
                test_cmd.list_tests()?
            };

            let known: Vec<&str> = all_test_ids.iter().map(|id| id.as_str()).collect();
            let common_prefix = crate::grouping::common_group_prefix(
                &all_test_ids,
                test_cmd.config().group_regex.as_deref(),
            );
            let mut prefixes: Vec<String> = Vec::with_capacity(starting_with.len());
            for s in starting_with {
                let expanded = crate::abbreviation::expand_abbreviation(s, &known)?;
                if expanded != *s {
                    ui.output(&format!("Expanded '{}' to '{}'", s, expanded))?;
                }
                // Also accept the selector with the common group prefix
                // prepended, so users can leave it off. The filter below is a
                // disjunction, so the extra candidate never excludes a match.
                if let Some(ref cp) = common_prefix {
                    let prefixed = crate::grouping::apply_prefix(&expanded, cp);
                    if prefixed != expanded {
                        // The bare and prefix-relative forms can match disjoint
                        // sets of tests (e.g. selector `b` matching both a
                        // top-level `b::` module and `<prefix>b::`). When both
                        // match different tests the selector is ambiguous, so
                        // warn rather than silently running the union.
                        if starting_with_is_ambiguous(&known, &expanded, &prefixed) {
                            ui.output(&format!(
                                "Warning: '{}' is ambiguous; it matches both '{}' and '{}'. \
                                 Running both - qualify the selector to disambiguate.",
                                s, expanded, prefixed
                            ))?;
                        }
                        prefixes.push(prefixed);
                    }
                }
                prefixes.push(expanded);
            }

            let filtered_ids: Vec<_> = all_test_ids
                .into_iter()
                .filter(|test_id| {
                    let id = test_id.as_str();
                    prefixes.iter().any(|p| id_starts_with_segment(id, p))
                })
                .collect();

            test_ids = Some(filtered_ids);
        }

        let historical_times = repo.get_test_times().unwrap_or_default();
        let calibration_samples = crate::eta::load_calibration_samples(repo.as_ref());
        let max_duration_value =
            test_executor::compute_max_duration(&max_duration, &historical_times);
        let test_timeout_fn =
            test_executor::build_test_timeout_fn(&test_timeout, &historical_times);

        // Resolve the ordering strategy: CLI override > config > Discovery.
        let mut resolved_order = match &self.test_order {
            Some(o) => o.clone(),
            None => test_cmd.config().parsed_test_order()?,
        };

        // Resolve Auto: pick FrequentFailingFirst when we have meaningful
        // failure history, else Spread. We compute the counts here so the
        // Auto resolver and the FrequentFailingFirst path can share them.
        let mut failure_counts: std::collections::HashMap<crate::repository::TestId, u32> =
            std::collections::HashMap::new();
        if resolved_order == TestOrder::Auto {
            failure_counts = compute_failure_counts(repo.as_ref());
            resolved_order = crate::ordering::resolve_auto(&failure_counts);
            ui.output(&format!(
                "Auto-selected test order: {}",
                resolved_order.as_str()
            ))?;
        } else if resolved_order == TestOrder::FrequentFailingFirst {
            failure_counts = compute_failure_counts(repo.as_ref());
        }

        // Apply ordering. If the strategy is non-discovery we need a concrete
        // list of tests to reorder, so materialise via discovery if the
        // earlier filtering didn't already produce one.
        if resolved_order != TestOrder::Discovery {
            let materialised = match test_ids.take() {
                Some(ids) => ids,
                None => test_cmd.list_tests()?,
            };
            let failing_for_order =
                if matches!(resolved_order, TestOrder::FailingFirst) && !self.failing_only {
                    repo.get_failing_tests().unwrap_or_default()
                } else {
                    Vec::new()
                };
            let ctx = OrderingContext {
                failing_tests: &failing_for_order,
                historical_times: &historical_times,
                failure_counts: &failure_counts,
                group_regex: test_cmd.config().group_regex.as_deref(),
            };
            test_ids = Some(apply_order(materialised, &resolved_order, &ctx)?);
        }

        // Always capture stderr so `inq last` / `inq info` can surface it
        // when a runner crashes mid-run. Callers (e.g. the MCP server) may
        // pre-supply a buffer to also receive a copy.
        let stderr_capture = self
            .stderr_capture
            .clone()
            .unwrap_or_else(|| std::sync::Arc::new(std::sync::Mutex::new(Vec::new())));
        let display_prefix = test_executor::display_prefix_for(test_ids.as_deref(), &test_cmd);
        let config = self.executor_config(Some(stderr_capture.clone()), display_prefix);
        let executor = TestExecutor::new(&config);

        if self.subunit {
            let (run_id, writer) = repo.begin_test_run_raw()?;
            self.publish_run_id(&run_id);
            let output =
                executor.run_subunit(ui, &test_cmd, test_ids.as_deref(), run_id, writer)?;
            return self.persist(
                ui,
                repo.as_mut(),
                output,
                &historical_times,
                &filter_tags,
                Some(&stderr_capture),
                active_profile.as_deref(),
            );
        }

        let (concurrency, concurrency_source) = test_cmd.resolve_concurrency(self.concurrency)?;
        match &concurrency_source {
            crate::testcommand::ConcurrencySource::AutoDetected(cpus) => {
                if self.implicit_run {
                    ui.output(&format!(
                        "Running in parallel mode across {} CPUs (auto-detected). \
                         Run `inq run` to disable parallel mode, or `inq run -j N` to set a specific worker count.",
                        cpus
                    ))?;
                } else {
                    ui.output(&format!(
                        "Auto-detected {} CPUs for parallel execution",
                        cpus
                    ))?;
                }
            }
            crate::testcommand::ConcurrencySource::ConfigCallout(c) => {
                ui.output(&format!(
                    "Using concurrency from test_run_concurrency: {}",
                    c
                ))?;
            }
            crate::testcommand::ConcurrencySource::Default => {
                suggest_parallel_for_explicit_run(ui, &test_ids, &historical_times)?;
            }
            crate::testcommand::ConcurrencySource::Explicit => {}
        }

        let calibration_debug =
            crate::eta::calibration_debug(&calibration_samples, concurrency as u32);
        let calibration_factor = calibration_debug.factor;
        if self.eta_debug {
            for line in crate::eta::format_calibration_debug(&calibration_debug) {
                ui.output(&line)?;
            }
            // `test_ids` is `Some` whenever filtering, ordering, or
            // `--load-list` resolved a concrete list. When `None`, the
            // executor will discover; the per-test breakdown isn't worth
            // the duplicate discovery cost.
            let known_tests: &[crate::repository::TestId] = test_ids.as_deref().unwrap_or(&[]);
            for line in crate::eta::format_prediction_debug(
                known_tests,
                &historical_times,
                concurrency as u32,
                calibration_factor,
            ) {
                ui.output(&line)?;
            }
        }

        if self.isolated {
            let all_tests = if let Some(ids) = test_ids {
                ids
            } else {
                test_cmd.list_tests()?
            };

            if all_tests.is_empty() {
                ui.output("No tests to run")?;
                return Ok(CliRunOutput::no_run(0));
            }

            let run_id = repo.get_next_run_id()?;
            if !self.until_failure {
                // until_failure publishes per-iteration below.
                self.publish_run_id(&run_id);
            }

            if self.until_failure {
                let mut iteration = 1;
                loop {
                    if let Some(max) = self.max_iterations {
                        if iteration > max {
                            ui.output(&format!(
                                "\nReached maximum iteration limit ({}), stopping",
                                max
                            ))?;
                            return Ok(CliRunOutput::no_run(0));
                        }
                    }
                    ui.output(&format!("\n=== Iteration {} ===", iteration))?;
                    let iter_run_id = if iteration == 1 {
                        run_id.clone()
                    } else {
                        repo.get_next_run_id()?
                    };
                    self.publish_run_id(&iter_run_id);
                    let output = executor.run_isolated(
                        ui,
                        &test_cmd,
                        &all_tests,
                        test_timeout_fn.as_ref(),
                        max_duration_value,
                        iter_run_id,
                    )?;
                    let result = self.persist(
                        ui,
                        repo.as_mut(),
                        output,
                        &historical_times,
                        &filter_tags,
                        Some(&stderr_capture),
                        active_profile.as_deref(),
                    )?;

                    if result.exit_code != 0 {
                        ui.output(&format!("\nTests failed on iteration {}", iteration))?;
                        return Ok(result);
                    }

                    iteration += 1;
                }
            } else {
                let output = executor.run_isolated(
                    ui,
                    &test_cmd,
                    &all_tests,
                    test_timeout_fn.as_ref(),
                    max_duration_value,
                    run_id,
                )?;
                self.persist(
                    ui,
                    repo.as_mut(),
                    output,
                    &historical_times,
                    &filter_tags,
                    Some(&stderr_capture),
                    active_profile.as_deref(),
                )
            }
        } else if self.until_failure {
            let mut iteration = 1;
            loop {
                if let Some(max) = self.max_iterations {
                    if iteration > max {
                        ui.output(&format!(
                            "\nReached maximum iteration limit ({}), stopping",
                            max
                        ))?;
                        return Ok(CliRunOutput::no_run(0));
                    }
                }
                ui.output(&format!("\n=== Iteration {} ===", iteration))?;

                let output = if concurrency > 1 {
                    let (run_id, outer_writer) = repo.begin_test_run_raw()?;
                    // The outer writer's file gets overwritten with the
                    // aggregate at persist time; we don't write to it here.
                    drop(outer_writer);
                    self.publish_run_id(&run_id);
                    let worker_counter = std::sync::atomic::AtomicUsize::new(0);
                    let run_id_for_workers = run_id.clone();
                    executor.run_parallel(
                        ui,
                        &test_cmd,
                        test_ids.as_deref(),
                        concurrency,
                        max_duration_value,
                        no_output_timeout,
                        test_timeout_fn.as_ref(),
                        run_id,
                        &historical_times,
                        calibration_factor,
                        || {
                            let idx =
                                worker_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                            let suffix = crate::repository::worker_suffix(idx);
                            repo.begin_sub_run_raw(&run_id_for_workers, &suffix)
                                .map(|(_, w)| w)
                        },
                    )?
                } else {
                    let (run_id, writer) = repo.begin_test_run_raw()?;
                    self.publish_run_id(&run_id);
                    executor.run_serial(
                        ui,
                        &test_cmd,
                        test_ids.as_deref(),
                        max_duration_value,
                        no_output_timeout,
                        test_timeout_fn.as_ref(),
                        run_id,
                        writer,
                        &historical_times,
                        calibration_factor,
                    )?
                };

                let result = self.persist(
                    ui,
                    repo.as_mut(),
                    output,
                    &historical_times,
                    &filter_tags,
                    Some(&stderr_capture),
                    active_profile.as_deref(),
                )?;

                if result.exit_code != 0 {
                    ui.output(&format!("\nTests failed on iteration {}", iteration))?;
                    return Ok(result);
                }

                iteration += 1;
            }
        } else if concurrency > 1 {
            let (run_id, outer_writer) = repo.begin_test_run_raw()?;
            drop(outer_writer);
            self.publish_run_id(&run_id);
            let worker_counter = std::sync::atomic::AtomicUsize::new(0);
            let run_id_for_workers = run_id.clone();
            let output = executor.run_parallel(
                ui,
                &test_cmd,
                test_ids.as_deref(),
                concurrency,
                max_duration_value,
                no_output_timeout,
                test_timeout_fn.as_ref(),
                run_id,
                &historical_times,
                calibration_factor,
                || {
                    let idx = worker_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                    let suffix = crate::repository::worker_suffix(idx);
                    repo.begin_sub_run_raw(&run_id_for_workers, &suffix)
                        .map(|(_, w)| w)
                },
            )?;
            self.persist(
                ui,
                repo.as_mut(),
                output,
                &historical_times,
                &filter_tags,
                Some(&stderr_capture),
                active_profile.as_deref(),
            )
        } else {
            let (run_id, writer) = repo.begin_test_run_raw()?;
            self.publish_run_id(&run_id);
            let output = executor.run_serial(
                ui,
                &test_cmd,
                test_ids.as_deref(),
                max_duration_value,
                no_output_timeout,
                test_timeout_fn.as_ref(),
                run_id,
                writer,
                &historical_times,
                calibration_factor,
            )?;
            self.persist(
                ui,
                repo.as_mut(),
                output,
                &historical_times,
                &filter_tags,
                Some(&stderr_capture),
                active_profile.as_deref(),
            )
        }
    }
}

/// Print a hint suggesting `--parallel` when an explicit `inq run` is about
/// to execute serially. Includes a wall-clock estimate based on historical
/// test durations divided across the available CPUs (with a small overhead
/// factor) so the user can judge whether enabling parallel is worth it.
///
/// No-op when historical_times is empty (no useful estimate to give) or
/// when test_ids is empty (nothing to estimate).
fn suggest_parallel_for_explicit_run(
    ui: &mut dyn UI,
    test_ids: &Option<Vec<crate::repository::TestId>>,
    historical_times: &std::collections::HashMap<crate::repository::TestId, Duration>,
) -> Result<()> {
    if historical_times.is_empty() {
        return Ok(());
    }
    let cpu_count = num_cpus::get();
    if cpu_count <= 1 {
        return Ok(());
    }

    let (serial_secs, sample_size) = match test_ids {
        Some(ids) if !ids.is_empty() => {
            let total: Duration = ids
                .iter()
                .filter_map(|id| historical_times.get(id).copied())
                .sum();
            (total.as_secs_f64(), ids.len())
        }
        _ => {
            // No materialized list — base the estimate on the full history.
            let total: Duration = historical_times.values().sum();
            (total.as_secs_f64(), historical_times.len())
        }
    };

    if serial_secs < 5.0 || sample_size == 0 {
        return Ok(());
    }

    let parallel_secs = serial_secs / cpu_count as f64;
    ui.output(&format!(
        "Hint: pass --parallel (or -j {}) to run across {} CPUs. \
         Estimated wall time: ~{} parallel vs ~{} serial (based on history).",
        cpu_count,
        cpu_count,
        format_short_duration_secs(parallel_secs),
        format_short_duration_secs(serial_secs),
    ))?;
    Ok(())
}

/// Format a duration in seconds as a short human-readable string
/// (e.g. "45s", "1m 30s", "2h 05m").
fn format_short_duration_secs(secs: f64) -> String {
    let secs = secs.max(0.0).round() as u64;
    if secs >= 3600 {
        let hours = secs / 3600;
        let mins = (secs % 3600) / 60;
        format!("{}h {:02}m", hours, mins)
    } else if secs >= 60 {
        let mins = secs / 60;
        let remaining_secs = secs % 60;
        format!("{}m {:02}s", mins, remaining_secs)
    } else {
        format!("{}s", secs)
    }
}

/// Walk every recorded run and tally how often each test produced a
/// failing status. Used by [`TestOrder::FrequentFailingFirst`].
pub(crate) fn compute_failure_counts(
    repo: &dyn crate::repository::Repository,
) -> std::collections::HashMap<crate::repository::TestId, u32> {
    let mut counts: std::collections::HashMap<crate::repository::TestId, u32> =
        std::collections::HashMap::new();
    let Ok(run_ids) = repo.list_run_ids() else {
        return counts;
    };
    for run_id in &run_ids {
        let Ok(run) = repo.get_test_run(run_id) else {
            continue;
        };
        for (test_id, result) in &run.results {
            if result.status.is_failure() {
                *counts.entry(test_id.clone()).or_insert(0) += 1;
            }
        }
    }
    counts
}

/// True when a `-s` selector is ambiguous: both its bare form and its
/// prefix-prepended form match a (necessarily disjoint) non-empty set of known
/// tests. In that case the user cannot tell which group they meant.
fn starting_with_is_ambiguous(known: &[&str], bare: &str, prefixed: &str) -> bool {
    known.iter().any(|id| id_starts_with_segment(id, bare))
        && known.iter().any(|id| id_starts_with_segment(id, prefixed))
}

/// True when `id` equals `prefix`, or `prefix` is a segment-aligned prefix of
/// `id` (i.e. immediately followed by a `.` or `:` path separator). This avoids
/// matching `foo_bar` for the selector `foo`.
fn id_starts_with_segment(id: &str, prefix: &str) -> bool {
    if id == prefix {
        return true;
    }
    if !id.starts_with(prefix) {
        return false;
    }
    matches!(
        id.as_bytes().get(prefix.len()).copied(),
        Some(b'.') | Some(b':')
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::repository::inquest::InquestRepositoryFactory;
    use crate::repository::RepositoryFactory;
    use crate::ui::test_ui::TestUI;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_run_command_no_config() {
        let temp = TempDir::new().unwrap();

        let factory = InquestRepositoryFactory;
        factory.initialise(temp.path()).unwrap();

        let mut ui = TestUI::new();
        let cmd = RunCommand::new(Some(temp.path().to_string_lossy().to_string()));
        let result = cmd.execute(&mut ui);

        assert!(result.is_err());
    }

    #[test]
    fn test_run_command_with_failing_only_no_failures() {
        let temp = TempDir::new().unwrap();

        let factory = InquestRepositoryFactory;
        let mut repo = factory.initialise(temp.path()).unwrap();

        let mut test_run = crate::repository::TestRun::new(crate::repository::RunId::new("0"));
        test_run.timestamp = chrono::DateTime::from_timestamp(1000000000, 0).unwrap();
        test_run.add_result(crate::repository::TestResult {
            test_id: crate::repository::TestId::new("test1"),
            status: crate::repository::TestStatus::Success,
            duration: None,
            message: None,
            details: None,
            tags: vec![],
        });
        repo.insert_test_run(test_run).unwrap();

        let config = r#"
[DEFAULT]
test_command=echo "test1"
"#;
        fs::write(temp.path().join(".testr.conf"), config).unwrap();

        let mut ui = TestUI::new();
        let cmd = RunCommand::with_failing_only(Some(temp.path().to_string_lossy().to_string()));
        let result = cmd.execute(&mut ui);

        assert_eq!(result.unwrap(), 0);
        assert_eq!(ui.output.len(), 1);
        assert_eq!(ui.output[0], "No failing tests to run");
    }

    #[test]
    fn test_run_command_name() {
        let cmd = RunCommand::new(None);
        assert_eq!(cmd.name(), "run");
    }

    #[test]
    fn id_starts_with_segment_exact_and_boundaries() {
        // Exact match.
        assert!(id_starts_with_segment("a::b", "a::b"));
        // Rust `::` boundary.
        assert!(id_starts_with_segment("a::b::test_x", "a::b"));
        // Python/Go `.` boundary.
        assert!(id_starts_with_segment("a.b.test_x", "a.b"));
    }

    #[test]
    fn starting_with_ambiguous_when_both_forms_match() {
        // Selector `b` (prefix `a::`): a top-level `b::` module exists AND
        // `a::b::` exists, so the bare and prefixed forms each match.
        let known = ["a::b::test_x", "b::test_y"];
        assert!(starting_with_is_ambiguous(&known, "b", "a::b"));
    }

    #[test]
    fn starting_with_unambiguous_when_only_prefixed_matches() {
        // The common case: the bare form matches nothing, only the prefixed
        // form does, so there is no ambiguity to warn about.
        let known = ["a::b::test_x", "a::c::test_y"];
        assert!(!starting_with_is_ambiguous(&known, "b", "a::b"));
    }

    #[test]
    fn starting_with_unambiguous_when_only_bare_matches() {
        let known = ["b::test_y"];
        assert!(!starting_with_is_ambiguous(&known, "b", "a::b"));
    }

    #[test]
    fn id_starts_with_segment_rejects_mid_segment() {
        // Must not match a longer segment that merely starts with the prefix.
        assert!(!id_starts_with_segment("foo_bar::test_x", "foo"));
        assert!(!id_starts_with_segment("foobar", "foo"));
    }

    #[test]
    fn format_short_duration_secs_seconds() {
        assert_eq!(format_short_duration_secs(0.0), "0s");
        assert_eq!(format_short_duration_secs(45.0), "45s");
    }

    #[test]
    fn format_short_duration_secs_minutes() {
        assert_eq!(format_short_duration_secs(90.0), "1m 30s");
    }

    #[test]
    fn format_short_duration_secs_hours() {
        assert_eq!(format_short_duration_secs(3661.0), "1h 01m");
    }

    #[test]
    fn format_short_duration_secs_negative_clamps_to_zero() {
        assert_eq!(format_short_duration_secs(-10.0), "0s");
    }

    #[test]
    fn suggest_parallel_no_history_is_silent() {
        let mut ui = TestUI::new();
        let history = std::collections::HashMap::new();
        suggest_parallel_for_explicit_run(&mut ui, &None, &history).unwrap();
        assert!(ui.output.is_empty());
    }

    #[test]
    fn suggest_parallel_short_run_is_silent() {
        let mut ui = TestUI::new();
        let mut history = std::collections::HashMap::new();
        history.insert(crate::repository::TestId::new("a"), Duration::from_secs(1));
        history.insert(crate::repository::TestId::new("b"), Duration::from_secs(2));
        // Total < 5s threshold, so no hint.
        suggest_parallel_for_explicit_run(&mut ui, &None, &history).unwrap();
        assert!(ui.output.is_empty());
    }

    #[test]
    fn suggest_parallel_emits_hint_with_history() {
        let mut ui = TestUI::new();
        let mut history = std::collections::HashMap::new();
        // Sum well over the 5s threshold.
        for i in 0..10 {
            history.insert(
                crate::repository::TestId::new(format!("test{}", i)),
                Duration::from_secs(5),
            );
        }
        suggest_parallel_for_explicit_run(&mut ui, &None, &history).unwrap();
        if num_cpus::get() > 1 {
            assert_eq!(ui.output.len(), 1);
            assert!(ui.output[0].contains("--parallel"), "got: {:?}", ui.output);
            assert!(
                ui.output[0].contains("Estimated wall time"),
                "got: {:?}",
                ui.output
            );
        } else {
            assert!(ui.output.is_empty());
        }
    }
}
