//! Parsing for the `evals.scorers[]` block.
//!
//! Why this is hand-parsed off `serde_yaml::Value` rather than a derived
//! struct: `evals.scorers[].type` is open-vocabulary from day one (`exec`,
//! `http`, a vendor name). A closed `#[derive(Deserialize)]` enum would
//! reject a vendor type at parse time in the OSS build, but the contract is
//! that an unknown type parses fine and only fails at *resolution* with the
//! "not available in this build" diagnostic. So we parse the shared shape
//! (`name`, `type`) here, materialize the known `exec` config, and leave
//! unknown types as [`ScorerConfig::Unknown`] for the registry to reject.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde_yaml::Value as YamlValue;
use thiserror::Error;

/// Default per-call timeout for an `exec` scorer: 30 seconds.
///
/// Matches the  spec. External judges (an LLM round trip behind a
/// wrapper) routinely take several seconds, so the default is generous; a
/// scorer that needs longer sets `timeout_ms` explicitly.
pub const DEFAULT_EXEC_TIMEOUT_MS: u64 = 30_000;

/// A parsed entry from `evals.scorers[]`.
///
/// Known OSS types materialize into their typed config; an unknown `type`
/// is preserved verbatim so the registry can emit the "not available"
/// diagnostic instead of the parser swallowing it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScorerConfig {
    /// The OSS `exec` scorer.
    Exec(ExecScorerConfig),
    /// A `type` this build does not know. Carries the name and type so the
    /// registry surfaces the right diagnostic.
    Unknown {
        /// `evals.scorers[].name`.
        name: String,
        /// The unrecognized `type` string.
        scorer_type: String,
    },
}

impl ScorerConfig {
    /// The `type` string this entry declared.
    pub fn scorer_type(&self) -> &str {
        match self {
            ScorerConfig::Exec(_) => "exec",
            ScorerConfig::Unknown { scorer_type, .. } => scorer_type,
        }
    }
}

/// Typed config for an `exec` scorer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecScorerConfig {
    /// Scorer name from `evals.scorers[].name`, folded into the verdict
    /// cache key.
    pub name: String,
    /// Argv to spawn. First element is the executable; the rest are args.
    pub command: Vec<String>,
    /// Environment overlay. Values may contain `${VAR}` references that are
    /// resolved against the process environment at parse time.
    pub env: BTreeMap<String, String>,
    /// Working directory for the subprocess. Inherits the parent cwd when
    /// `None`.
    pub cwd: Option<PathBuf>,
    /// Per-call timeout in milliseconds.
    pub timeout_ms: u64,
}

/// Errors raised while parsing a scorer entry.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ScorerConfigError {
    /// A scorer entry was missing the required `name` field.
    #[error("scorer entry is missing the required `name` field")]
    MissingName,
    /// A scorer entry was missing the required `type` field.
    #[error("scorer `{name}` is missing the required `type` field")]
    MissingType {
        /// Name of the offending scorer.
        name: String,
    },
    /// An `exec` scorer had an empty or missing `command`.
    #[error("exec scorer `{name}` requires a non-empty `command` argv")]
    MissingCommand {
        /// Name of the offending scorer.
        name: String,
    },
    /// A `${VAR}` reference in an `env` value did not resolve.
    #[error("scorer `{name}` env `{key}` references unset variable `{var}`")]
    UnresolvedEnvVar {
        /// Name of the offending scorer.
        name: String,
        /// The `env` key whose value failed to resolve.
        key: String,
        /// The unset variable name.
        var: String,
    },
}

/// Locate the `scorers[]` array in a config.
///
/// Why two locations: the canonical place is `evals.scorers[]`, but YAML
/// forbids a mapping (`scorers:`) and a sequence (the legacy `evals:` test
/// list) sharing one key. So a config that still uses `evals:` as a
/// sequence declares scorers at the top level instead. We accept both:
/// `evals.scorers[]` when `evals` is an object, and a top-level `scorers[]`
/// otherwise.
pub fn scorers_node(root: &YamlValue) -> Option<&Vec<YamlValue>> {
    root.get("evals")
        .and_then(|e| e.get("scorers"))
        .and_then(YamlValue::as_sequence)
        .or_else(|| root.get("scorers").and_then(YamlValue::as_sequence))
}

/// Parse the `scorers[]` array from a parsed YAML config.
///
/// `${VAR}` references in `env` values are resolved against `env_lookup`,
/// indirected so tests inject a fake environment instead of mutating the
/// process env. Unknown `type` values parse into [`ScorerConfig::Unknown`]
/// rather than erroring, preserving the open-vocabulary contract.
pub fn parse_scorers(
    root: &YamlValue,
    env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Vec<ScorerConfig>, ScorerConfigError> {
    let Some(arr) = scorers_node(root) else {
        return Ok(Vec::new());
    };
    arr.iter().map(|node| parse_one(node, env_lookup)).collect()
}

fn parse_one(
    node: &YamlValue,
    env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<ScorerConfig, ScorerConfigError> {
    let name = node
        .get("name")
        .and_then(YamlValue::as_str)
        .ok_or(ScorerConfigError::MissingName)?
        .to_owned();
    let scorer_type = node
        .get("type")
        .and_then(YamlValue::as_str)
        .ok_or_else(|| ScorerConfigError::MissingType { name: name.clone() })?
        .to_owned();

    if scorer_type != "exec" {
        return Ok(ScorerConfig::Unknown { name, scorer_type });
    }

    let command = node
        .get("command")
        .and_then(YamlValue::as_sequence)
        .map(|seq| {
            seq.iter()
                .filter_map(|v| v.as_str().map(str::to_owned))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    if command.is_empty() {
        return Err(ScorerConfigError::MissingCommand { name });
    }

    let env = parse_env(node, &name, env_lookup)?;
    let cwd = node
        .get("cwd")
        .and_then(YamlValue::as_str)
        .map(PathBuf::from);
    let timeout_ms = node
        .get("timeout_ms")
        .and_then(YamlValue::as_u64)
        .unwrap_or(DEFAULT_EXEC_TIMEOUT_MS);

    Ok(ScorerConfig::Exec(ExecScorerConfig {
        name,
        command,
        env,
        cwd,
        timeout_ms,
    }))
}

fn parse_env(
    node: &YamlValue,
    name: &str,
    env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<BTreeMap<String, String>, ScorerConfigError> {
    let Some(map) = node.get("env").and_then(YamlValue::as_mapping) else {
        return Ok(BTreeMap::new());
    };
    let mut out = BTreeMap::new();
    for (k, v) in map {
        let (Some(key), Some(raw)) = (k.as_str(), v.as_str()) else {
            continue;
        };
        let resolved = substitute_vars(raw, key, name, env_lookup)?;
        out.insert(key.to_owned(), resolved);
    }
    Ok(out)
}

/// Substitute every `${VAR}` reference in `raw` against `env_lookup`.
///
/// A reference to an unset variable is an error rather than an empty
/// expansion, so a missing API key surfaces at config load instead of
/// reaching the scorer as a blank credential.
fn substitute_vars(
    raw: &str,
    key: &str,
    scorer_name: &str,
    env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<String, ScorerConfigError> {
    let mut out = String::with_capacity(raw.len());
    let mut rest = raw;
    while let Some(start) = rest.find("${") {
        out.push_str(&rest[..start]);
        let after = &rest[start + 2..];
        let Some(end) = after.find('}') else {
            // No closing brace; treat the remainder as a literal so a stray
            // `${` does not silently truncate the value.
            out.push_str(&rest[start..]);
            return Ok(out);
        };
        let var = &after[..end];
        let value = env_lookup(var).ok_or_else(|| ScorerConfigError::UnresolvedEnvVar {
            name: scorer_name.to_owned(),
            key: key.to_owned(),
            var: var.to_owned(),
        })?;
        out.push_str(&value);
        rest = &after[end + 1..];
    }
    out.push_str(rest);
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn yaml(text: &str) -> YamlValue {
        serde_yaml::from_str(text).unwrap()
    }

    fn no_env(_: &str) -> Option<String> {
        None
    }

    #[test]
    fn parses_minimal_exec_scorer_with_default_timeout() {
        let root = yaml(
            r#"
evals:
  scorers:
    - name: judge
      type: exec
      command: ["./judge.py"]
"#,
        );
        let scorers = parse_scorers(&root, &no_env).unwrap();
        assert_eq!(scorers.len(), 1);
        let ScorerConfig::Exec(cfg) = &scorers[0] else {
            panic!("expected exec scorer");
        };
        assert_eq!(cfg.name, "judge");
        assert_eq!(cfg.command, vec!["./judge.py".to_string()]);
        assert_eq!(cfg.timeout_ms, DEFAULT_EXEC_TIMEOUT_MS);
        assert!(cfg.cwd.is_none());
    }

    #[test]
    fn unknown_type_parses_without_error() {
        let root = yaml(
            r#"
evals:
  scorers:
    - name: bt
      type: braintrust
"#,
        );
        let scorers = parse_scorers(&root, &no_env).unwrap();
        assert_eq!(
            scorers[0],
            ScorerConfig::Unknown {
                name: "bt".into(),
                scorer_type: "braintrust".into(),
            }
        );
    }

    #[test]
    fn exec_without_command_is_an_error() {
        let root = yaml(
            r#"
evals:
  scorers:
    - name: judge
      type: exec
"#,
        );
        let err = parse_scorers(&root, &no_env).unwrap_err();
        assert_eq!(
            err,
            ScorerConfigError::MissingCommand {
                name: "judge".into()
            }
        );
    }

    #[test]
    fn env_var_substitution_resolves_against_lookup() {
        let root = yaml(
            r#"
evals:
  scorers:
    - name: judge
      type: exec
      command: ["./judge.py"]
      env:
        API_KEY: "${SECRET}"
        STATIC: "literal"
"#,
        );
        let lookup = |name: &str| {
            if name == "SECRET" {
                Some("sk-123".to_string())
            } else {
                None
            }
        };
        let scorers = parse_scorers(&root, &lookup).unwrap();
        let ScorerConfig::Exec(cfg) = &scorers[0] else {
            panic!("expected exec scorer");
        };
        assert_eq!(cfg.env.get("API_KEY").unwrap(), "sk-123");
        assert_eq!(cfg.env.get("STATIC").unwrap(), "literal");
    }

    #[test]
    fn unresolved_env_var_is_an_error() {
        let root = yaml(
            r#"
evals:
  scorers:
    - name: judge
      type: exec
      command: ["./judge.py"]
      env:
        API_KEY: "${MISSING}"
"#,
        );
        let err = parse_scorers(&root, &no_env).unwrap_err();
        assert_eq!(
            err,
            ScorerConfigError::UnresolvedEnvVar {
                name: "judge".into(),
                key: "API_KEY".into(),
                var: "MISSING".into(),
            }
        );
    }

    #[test]
    fn missing_type_is_an_error() {
        let root = yaml(
            r#"
evals:
  scorers:
    - name: judge
"#,
        );
        let err = parse_scorers(&root, &no_env).unwrap_err();
        assert_eq!(
            err,
            ScorerConfigError::MissingType {
                name: "judge".into()
            }
        );
    }

    #[test]
    fn no_scorers_block_yields_empty() {
        let root = yaml("evals: {}");
        assert!(parse_scorers(&root, &no_env).unwrap().is_empty());
    }

    #[test]
    fn substitute_handles_embedded_and_multiple_refs() {
        let lookup = |name: &str| match name {
            "A" => Some("x".to_string()),
            "B" => Some("y".to_string()),
            _ => None,
        };
        let out = substitute_vars("pre-${A}-mid-${B}-post", "k", "s", &lookup).unwrap();
        assert_eq!(out, "pre-x-mid-y-post");
    }
}
