//! Parse the `faults:` block and an agent test's `inject:` / `recovery:`.
//!
//! Lives alongside `suite::mod` so the parser file stays under the
//! production-module-size cap. The recovery executor synthesizes an injected
//! fault in virtual time (see [`crate::eval::recovery`]); this module is just
//! the load-time shape and the cross-reference check that every `inject:` name
//! resolves to a declared fault.

use serde_yaml::Value as YamlValue;

use super::agent_types::AgentTest;
use super::faults::{FaultKind, FaultSpec, FaultTarget};
use super::test_parse::require_str;
use super::ParseError;
use super::RecoveryGate;

/// Parse the optional `inject:` list on an agent test: the names of suite-level
/// faults this run injects. A missing key yields an empty list. A non-sequence
/// or a non-string entry is a load error so a typo never silently drops a fault.
pub(super) fn parse_inject(entry: &YamlValue, path: &str) -> Result<Vec<String>, ParseError> {
    let Some(node) = entry.get("inject") else {
        return Ok(Vec::new());
    };
    let seq = node.as_sequence().ok_or(ParseError::BadShape {
        path: format!("{path}/inject"),
        field: "inject",
        expected: "a sequence of fault names",
    })?;
    let mut out = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        let name = item.as_str().ok_or(ParseError::BadShape {
            path: format!("{path}/inject[{i}]"),
            field: "inject",
            expected: "a fault name string",
        })?;
        out.push(name.to_string());
    }
    Ok(out)
}

/// Parse the optional `recovery:` gate on an agent test. Returns `None` when
/// absent. `max_detection_ms` is required (the per-call timeout budget);
/// `max_recovery_ms` is optional (unbounded recovery time when omitted) and
/// `require_clean_timeout` defaults to false.
pub(super) fn parse_recovery_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<RecoveryGate>, ParseError> {
    let Some(node) = entry.get("recovery") else {
        return Ok(None);
    };
    let block_path = format!("{path}/recovery");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "recovery",
            expected: "an object with max_detection_ms",
        });
    }
    let max_detection_ms = node
        .get("max_detection_ms")
        .and_then(|v| v.as_u64())
        .ok_or(ParseError::BadShape {
            path: block_path.clone(),
            field: "max_detection_ms",
            expected: "a millisecond integer",
        })?;
    let max_recovery_ms = match node.get("max_recovery_ms") {
        None => None,
        Some(v) => Some(v.as_u64().ok_or(ParseError::BadShape {
            path: block_path.clone(),
            field: "max_recovery_ms",
            expected: "a millisecond integer",
        })?),
    };
    let require_clean_timeout = match node.get("require_clean_timeout") {
        None => false,
        Some(v) => v.as_bool().ok_or(ParseError::BadShape {
            path: block_path,
            field: "require_clean_timeout",
            expected: "a boolean",
        })?,
    };
    Ok(Some(RecoveryGate {
        max_detection_ms,
        max_recovery_ms,
        require_clean_timeout,
    }))
}

/// Parse the optional top-level `faults:` block: named fault declarations an
/// agent test references with `inject:`. A missing key yields an empty list.
pub(super) fn parse(root: &YamlValue) -> Result<Vec<FaultSpec>, ParseError> {
    let Some(node) = root.get("faults") else {
        return Ok(Vec::new());
    };
    let seq = node.as_sequence().ok_or(ParseError::BadShape {
        path: "/faults".to_string(),
        field: "faults",
        expected: "a sequence of fault objects",
    })?;
    let mut out = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        out.push(parse_one_fault(item, &format!("/faults/{i}"))?);
    }
    Ok(out)
}

/// Parse one `faults:` entry: `{ name, target: { tool?, server? }, kind, ... }`.
fn parse_one_fault(entry: &YamlValue, path: &str) -> Result<FaultSpec, ParseError> {
    let name = require_str(entry, path, "name")?.to_string();
    let target_node = entry.get("target").ok_or(ParseError::BadShape {
        path: format!("{path}/target"),
        field: "target",
        expected: "an object with a tool and/or server",
    })?;
    let tool = target_node
        .get("tool")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let server = target_node
        .get("server")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    if tool.is_none() && server.is_none() {
        return Err(ParseError::BadShape {
            path: format!("{path}/target"),
            field: "target",
            expected: "at least one of tool or server",
        });
    }
    let kind_str = require_str(entry, path, "kind")?;
    let kind = match kind_str {
        "hang" => FaultKind::Hang,
        "wedged" => FaultKind::Wedged,
        "slow" => {
            let delay_ms =
                entry
                    .get("delay_ms")
                    .and_then(|v| v.as_u64())
                    .ok_or(ParseError::BadShape {
                        path: format!("{path}/delay_ms"),
                        field: "delay_ms",
                        expected: "a millisecond integer (required for kind: slow)",
                    })?;
            FaultKind::Slow { delay_ms }
        }
        "recover_after" => {
            let failures =
                entry
                    .get("failures")
                    .and_then(|v| v.as_u64())
                    .ok_or(ParseError::BadShape {
                        path: format!("{path}/failures"),
                        field: "failures",
                        expected: "a count (required for kind: recover_after)",
                    })? as u32;
            FaultKind::RecoverAfter { failures }
        }
        _ => {
            return Err(ParseError::BadShape {
                path: format!("{path}/kind"),
                field: "kind",
                expected: "one of hang, wedged, slow, recover_after",
            });
        }
    };
    Ok(FaultSpec {
        name,
        target: FaultTarget { tool, server },
        kind,
    })
}

/// Validate that every `inject:` name on an agent test resolves to a declared
/// fault. A dangling reference is a load-time error (exit 2), mirroring how
/// `inject_error:` validates against `fixtures:`.
pub(super) fn validate_inject_names(
    agent: &AgentTest,
    faults: &[FaultSpec],
) -> Result<(), ParseError> {
    for name in &agent.inject {
        if !faults.iter().any(|f| &f.name == name) {
            return Err(ParseError::UnknownFault {
                agent: agent.name.clone(),
                name: name.clone(),
            });
        }
    }
    Ok(())
}
