//! Shared clap value parsers used across subcommand argument structs.
//!
//! Each parser returns `Result<T, String>` because clap's
//! `value_parser` attribute expects that signature. Keeping them in one
//! file makes the parse-vs-render split visible and ensures every
//! subcommand uses the same `KEY=VALUE`, `NAME=VALUE`, and duration
//! shapes.

use std::time::Duration;

use crate::cli::args::run::{CacheFilter, ShardSpec};

/// Parse a `KEY=VALUE` argument into a `(String, String)` pair.
///
/// clap calls this for every `--var` occurrence. Keys must be non-empty;
/// values may be empty so `--var FOO=` is allowed (callers sometimes need
/// to clear an inherited variable).
pub(crate) fn parse_key_value(raw: &str) -> Result<(String, String), String> {
    let (key, value) = raw
        .split_once('=')
        .ok_or_else(|| format!("expected KEY=VALUE, got `{raw}`"))?;
    if key.is_empty() {
        return Err(format!("variable name is empty in `{raw}`"));
    }
    Ok((key.to_owned(), value.to_owned()))
}

/// Parse a duration in whole seconds for `--timeout`.
///
/// Accepts a bare integer. We deliberately do not accept "1m" / "500ms" yet:
/// the runner only needs whole-second granularity and a richer parser would
/// invite bug reports about edge cases.
pub(crate) fn parse_seconds(raw: &str) -> Result<Duration, String> {
    let secs: u64 = raw
        .parse()
        .map_err(|_| format!("expected a whole number of seconds, got `{raw}`"))?;
    Ok(Duration::from_secs(secs))
}

/// Parse a human-friendly duration string for `--wait-for-ready`.
///
/// Accepts a bare integer (treated as seconds) or a value with a unit suffix:
/// `s` (seconds), `m` (minutes), `h` (hours). Examples: `30`, `30s`, `2m`,
/// `1h`. We do not accept fractional values; preview-deploy waits are
/// always coarse-grained in practice.
pub(crate) fn parse_duration(raw: &str) -> Result<Duration, String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err("duration is empty".to_owned());
    }
    let (number_part, multiplier) = match trimmed.chars().last() {
        Some('s') => (&trimmed[..trimmed.len() - 1], 1u64),
        Some('m') => (&trimmed[..trimmed.len() - 1], 60u64),
        Some('h') => (&trimmed[..trimmed.len() - 1], 3600u64),
        Some(c) if c.is_ascii_digit() => (trimmed, 1u64),
        _ => {
            return Err(format!(
                "expected a duration like `60s`, `2m`, `1h`, got `{raw}`"
            ));
        }
    };
    let value: u64 = number_part
        .parse()
        .map_err(|_| format!("expected a duration like `60s`, `2m`, `1h`, got `{raw}`"))?;
    value
        .checked_mul(multiplier)
        .map(Duration::from_secs)
        .ok_or_else(|| format!("duration `{raw}` overflows"))
}

/// Parse `--header NAME=VALUE` into a `(name, value)` pair.
///
/// The value may be empty (`--header X-Probe=`). The name is checked at
/// runtime by [`mcptest_core::transport::HeaderValidator`]; this parser only
/// catches the missing-equals typo.
pub(crate) fn parse_header_literal(raw: &str) -> Result<(String, String), String> {
    let (name, value) = raw
        .split_once('=')
        .ok_or_else(|| format!("expected NAME=VALUE, got `{raw}`"))?;
    if name.is_empty() {
        return Err(format!("header name is empty in `{raw}`"));
    }
    Ok((name.to_owned(), value.to_owned()))
}

/// Parse `--header-env NAME=VAR_NAME` into a `(header_name, var_name)` pair.
///
/// `VAR_NAME` must be non-empty: an env-backed header with no env var is a
/// configuration mistake the runner cannot make sense of later.
pub(crate) fn parse_header_env(raw: &str) -> Result<(String, String), String> {
    let (name, var) = raw
        .split_once('=')
        .ok_or_else(|| format!("expected NAME=VAR_NAME, got `{raw}`"))?;
    if name.is_empty() {
        return Err(format!("header name is empty in `{raw}`"));
    }
    if var.is_empty() {
        return Err(format!("env var name is empty in `{raw}`"));
    }
    Ok((name.to_owned(), var.to_owned()))
}

/// Parse the `--cache-filter` argument into a [`CacheFilter`] variant.
///
/// Only `NEW` ships in v1.0; future selectors will land additively.
pub(crate) fn parse_cache_filter(raw: &str) -> Result<CacheFilter, String> {
    match raw.trim().to_ascii_uppercase().as_str() {
        "NEW" => Ok(CacheFilter::New),
        other => Err(format!(
            "unknown --filter value `{other}` (v1.0 accepts only NEW)"
        )),
    }
}

/// Parse `--shard INDEX/TOTAL` into a [`ShardSpec`].
///
/// Both fields are one-based. `0/N`, `N/0`, and `index > total` are
/// rejected at parse time so the runner never has to defend against
/// nonsensical partitions. The string `"1/1"` is accepted as a degenerate
/// no-op shard so a CI matrix template with a single worker still works.
pub(crate) fn parse_shard_spec(raw: &str) -> Result<ShardSpec, String> {
    let (idx_raw, total_raw) = raw
        .split_once('/')
        .ok_or_else(|| format!("expected INDEX/TOTAL like `1/3`, got `{raw}`"))?;
    let index: u32 = idx_raw
        .trim()
        .parse()
        .map_err(|_| format!("shard index must be a positive integer, got `{idx_raw}`"))?;
    let total: u32 = total_raw
        .trim()
        .parse()
        .map_err(|_| format!("shard total must be a positive integer, got `{total_raw}`"))?;
    if index == 0 || total == 0 {
        return Err(format!("shard index and total are one-based; got `{raw}`"));
    }
    if index > total {
        return Err(format!(
            "shard index must be between 1 and total, got `{raw}`"
        ));
    }
    Ok(ShardSpec { index, total })
}

/// Parse the `--coverage-threshold` argument via the library parser.
///
/// Surfaces [`mcptest_core::coverage::ParseError`] as a clap-friendly
/// `String` so the CLI message reads naturally without the
/// `ParseError(...)` debug wrapper.
pub(crate) fn parse_coverage_threshold(
    raw: &str,
) -> Result<mcptest_core::coverage::Thresholds, String> {
    mcptest_core::coverage::Thresholds::parse(raw).map_err(|e| e.to_string())
}

/// Parse `--sample-rate` into a fraction in `0.0..=1.0` (WOR-1386).
///
/// Rejects a non-number or an out-of-range value at parse time so a typo like
/// `--sample-rate 50` (meaning 50 percent) fails loudly instead of sampling
/// every test (any rate >= 1.0 runs all).
pub(crate) fn parse_sample_rate(raw: &str) -> Result<f64, String> {
    let rate: f64 = raw
        .parse()
        .map_err(|_| format!("expected a number in 0.0..=1.0, got `{raw}`"))?;
    if !(0.0..=1.0).contains(&rate) {
        return Err(format!("sample rate must be in 0.0..=1.0, got {rate}"));
    }
    Ok(rate)
}

/// Parse `--arg name=$.path` / `--bind name=$.path` into a (name, path) pair.
///
/// The path side may contain `=` (JSONPath filters do), so we split on the
/// first `=` only. The name must be non-empty; the path is validated when the
/// JSONPath is compiled at call time, not here.
pub(crate) fn parse_arg_path(raw: &str) -> Result<(String, String), String> {
    let (name, path) = raw
        .split_once('=')
        .ok_or_else(|| format!("expected NAME=PATH, got `{raw}`"))?;
    if name.is_empty() {
        return Err(format!("arg name is empty in `{raw}`"));
    }
    if path.is_empty() {
        return Err(format!("JSONPath is empty in `{raw}`"));
    }
    Ok((name.to_owned(), path.to_owned()))
}

/// Parse `--then-arg name=value` (or `name=:bound`) into a (name, value) pair.
///
/// Splits on the first `=` so values may themselves contain `=`. A leading
/// `:` on the value side (a bind reference) is preserved for the handler to
/// resolve; this parser only enforces the `NAME=` shape.
pub(crate) fn parse_then_arg(raw: &str) -> Result<(String, String), String> {
    let (name, value) = raw
        .split_once('=')
        .ok_or_else(|| format!("expected NAME=VALUE, got `{raw}`"))?;
    if name.is_empty() {
        return Err(format!("arg name is empty in `{raw}`"));
    }
    Ok((name.to_owned(), value.to_owned()))
}

/// Compiled form of the global `--filter` expression (WOR-1267).
///
/// Plain expressions match by literal substring so a quoted exact test
/// name from a repro line always selects that test. The `re:` prefix
/// opts into anchored-capable regex matching (`--filter "re:^create_"`)
/// for authors who need to separate `create_issue` from `recreate_db`.
#[derive(Debug, Clone)]
pub(crate) enum FilterExpr {
    /// Literal substring match against the test name.
    Substring(String),
    /// Regex match (the `re:` prefix form).
    Regex(regex::Regex),
}

impl FilterExpr {
    /// Compile a `--filter` value. A `re:` prefix switches from literal
    /// substring matching to regex; an invalid regex returns an
    /// actionable message the caller surfaces with the config-error
    /// exit code (2).
    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
        match raw.strip_prefix("re:") {
            Some(pattern) => regex::Regex::new(pattern)
                .map(FilterExpr::Regex)
                .map_err(|e| {
                    format!(
                        "invalid --filter regex `{pattern}`: {e}\n\
                     (the `re:` prefix switches --filter to regex matching; \
                     drop the prefix for plain substring matching)"
                    )
                }),
            None => Ok(FilterExpr::Substring(raw.to_string())),
        }
    }

    /// True when the candidate test name matches the expression.
    pub(crate) fn matches(&self, name: &str) -> bool {
        match self {
            FilterExpr::Substring(needle) => name.contains(needle),
            FilterExpr::Regex(re) => re.is_match(name),
        }
    }
}

/// Parse a dollar amount string from the CLI, accepting an optional `$`
/// prefix and rejecting negative or non-finite values.
pub fn parse_dollar_amount(raw: &str) -> Result<f64, String> {
    let stripped = raw.strip_prefix('$').unwrap_or(raw);
    let value: f64 = stripped
        .parse()
        .map_err(|e: std::num::ParseFloatError| format!("invalid dollar amount: {e}"))?;
    if !value.is_finite() || value < 0.0 {
        return Err(format!(
            "dollar amount must be a non-negative finite number, got {raw}"
        ));
    }
    Ok(value)
}

#[cfg(test)]
mod tests {
    use super::{parse_sample_rate, FilterExpr};

    #[test]
    fn sample_rate_accepts_the_unit_interval_and_rejects_outside() {
        assert_eq!(parse_sample_rate("0").unwrap(), 0.0);
        assert_eq!(parse_sample_rate("0.25").unwrap(), 0.25);
        assert_eq!(parse_sample_rate("1").unwrap(), 1.0);
        assert!(parse_sample_rate("1.5").is_err());
        assert!(parse_sample_rate("-0.1").is_err());
        assert!(parse_sample_rate("half").is_err());
    }

    #[test]
    fn plain_filter_matches_by_substring() {
        let f = FilterExpr::parse("create_").expect("parse");
        assert!(f.matches("create_issue"));
        assert!(f.matches("recreate_db"), "substring matches anywhere");
        assert!(!f.matches("delete_issue"));
    }

    #[test]
    fn re_prefix_filter_matches_anchored_regex() {
        let f = FilterExpr::parse("re:^create_").expect("parse");
        assert!(f.matches("create_issue"));
        assert!(!f.matches("recreate_db"), "anchor must hold");
    }

    #[test]
    fn re_prefix_filter_rejects_invalid_regex_with_actionable_message() {
        let err = FilterExpr::parse("re:^create_(").expect_err("invalid regex");
        assert!(err.contains("invalid --filter regex"), "got: {err}");
        assert!(err.contains("substring"), "hint present, got: {err}");
    }

    #[test]
    fn plain_filter_treats_regex_metacharacters_literally() {
        let f = FilterExpr::parse("status (prod)").expect("parse");
        assert!(f.matches("check status (prod) endpoint"));
        assert!(!f.matches("status prod"));
    }
}
