//! Sprint contract schema shared across all harness layers.
//!
//! A [`SprintContract`] is a machine-readable acceptance specification
//! generated by the Plan phase before implementation begins. The
//! [`EvalResult`] is produced by the evaluator after each implement round
//! to determine whether the contract criteria are satisfied.

use serde::{Deserialize, Serialize};

/// The kind of check used to validate a criterion.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CriterionKind {
    /// Validated by running the project's automated tests.
    Test,
    /// Validated by an LLM judge reviewing the implementation.
    LlmJudge,
    /// Requires human review; the evaluator marks it as passed by default.
    ManualCheck,
}

/// A single acceptance criterion within a [`SprintContract`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Criterion {
    /// Short stable identifier (e.g. `"c1"`, `"auth-check"`).
    pub id: String,
    /// Human-readable description of what must be true.
    pub description: String,
    /// How this criterion is evaluated.
    pub kind: CriterionKind,
    /// Concrete condition that constitutes a pass (e.g. `"cargo test passes"`).
    pub pass_condition: String,
}

/// Machine-readable acceptance specification for a single implement cycle.
///
/// Produced by the Plan phase via [`sprint_contract_prompt`][harness_core::prompts::sprint_contract_prompt]
/// and consumed by the evaluator loop after each Implement round.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SprintContract {
    /// One-sentence summary of what this sprint must deliver.
    pub goal: String,
    /// Ordered list of acceptance criteria.
    pub criteria: Vec<Criterion>,
}

impl SprintContract {
    /// Returns `true` when there are no criteria — the evaluator passes immediately.
    pub fn is_empty(&self) -> bool {
        self.criteria.is_empty()
    }

    /// Parse a [`SprintContract`] from agent markdown output.
    ///
    /// Expects a fenced ` ```sprint-contract ` block containing YAML, e.g.:
    ///
    /// ````text
    /// ```sprint-contract
    /// goal: "Add checkpoint recovery"
    /// criteria:
    ///   - id: c1
    ///     description: "Unit test for checkpoint save/load"
    ///     kind: test
    ///     pass_condition: "cargo test passes"
    /// ```
    /// ````
    ///
    /// Returns an error (not a panic) when the block is missing or the YAML is
    /// malformed, so the task runner can fall back to an empty contract.
    pub fn from_markdown(md: &str) -> anyhow::Result<Self> {
        let block = extract_fenced_block(md, "sprint-contract")
            .ok_or_else(|| anyhow::anyhow!("no sprint-contract fenced block found in output"))?;
        serde_yaml::from_str(block)
            .map_err(|e| anyhow::anyhow!("failed to parse sprint-contract YAML: {e}"))
    }
}

/// Description of a single criterion that failed evaluation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CriterionFailure {
    /// ID of the failed criterion.
    pub id: String,
    /// Explanation of why it failed.
    pub reason: String,
}

/// Outcome produced by the evaluator after one Implement round.
///
/// Parsed from a fenced ` ```eval-result ` block in the evaluator agent output.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum EvalResult {
    /// All criteria passed.
    Pass { criteria_ids: Vec<String> },
    /// Some criteria passed; others failed — another implement round is needed.
    Partial {
        #[serde(default)]
        passed: Vec<String>,
        failed: Vec<CriterionFailure>,
    },
    /// All criteria failed.
    Fail { failed: Vec<CriterionFailure> },
}

impl EvalResult {
    /// Parse an [`EvalResult`] from agent markdown output.
    ///
    /// Expects a fenced ` ```eval-result ` block containing YAML.
    pub fn from_markdown(md: &str) -> anyhow::Result<Self> {
        let block = extract_fenced_block(md, "eval-result")
            .ok_or_else(|| anyhow::anyhow!("no eval-result fenced block found in output"))?;
        serde_yaml::from_str(block)
            .map_err(|e| anyhow::anyhow!("failed to parse eval-result YAML: {e}"))
    }

    /// Returns `true` when the result means the task may advance to Review.
    pub fn is_pass(&self) -> bool {
        matches!(self, EvalResult::Pass { .. })
    }

    /// Return the list of failed criterion IDs, if any.
    pub fn failed_ids(&self) -> Vec<&str> {
        match self {
            EvalResult::Pass { .. } => vec![],
            EvalResult::Partial { failed, .. } | EvalResult::Fail { failed } => {
                failed.iter().map(|f| f.id.as_str()).collect()
            }
        }
    }
}

/// Extract the raw content of a fenced code block with the given language tag.
///
/// Matches ` ```<lang>\n...\n``` ` and returns the inner content slice.
/// Returns `None` when no matching block is found.
pub(crate) fn extract_fenced_block<'a>(text: &'a str, lang: &str) -> Option<&'a str> {
    let open = format!("```{lang}");
    // Use rfind so that if the model echoes or re-states an earlier block with the same
    // language tag, we always parse the *last* occurrence — which is where the prompts
    // place the authoritative result block.
    let start = text.rfind(&open)?;
    let after_open = start + open.len();
    // Skip the newline immediately after the opening fence.
    let newline_offset = text[after_open..].find('\n')?;
    let content_start = after_open + newline_offset + 1;
    let rest = &text[content_start..];
    let close_offset = rest.find("\n```")?;
    Some(&rest[..close_offset])
}

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

    // --- SprintContract::from_markdown ---

    #[test]
    fn parse_sprint_contract_from_valid_fenced_block() -> anyhow::Result<()> {
        let md = r#"
Some preamble text.

```sprint-contract
goal: "Add checkpoint recovery to task runner"
criteria:
  - id: c1
    description: "Checkpoint save/load roundtrip works"
    kind: test
    pass_condition: "cargo test passes with no failures"
  - id: c2
    description: "Stale checkpoints are rejected"
    kind: llm_judge
    pass_condition: "Evaluator confirms stale path returns failed status"
```

Some epilogue.
"#;
        let contract = SprintContract::from_markdown(md)?;
        assert_eq!(contract.goal, "Add checkpoint recovery to task runner");
        assert_eq!(contract.criteria.len(), 2);
        assert_eq!(contract.criteria[0].id, "c1");
        assert_eq!(contract.criteria[0].kind, CriterionKind::Test);
        assert_eq!(contract.criteria[1].kind, CriterionKind::LlmJudge);
        Ok(())
    }

    #[test]
    fn parse_sprint_contract_returns_error_when_block_missing() {
        let err = SprintContract::from_markdown("no fenced block here").unwrap_err();
        assert!(err.to_string().contains("no sprint-contract fenced block"));
    }

    #[test]
    fn parse_sprint_contract_returns_error_for_malformed_yaml() {
        let md = "```sprint-contract\n{: not yaml\n```";
        let err = SprintContract::from_markdown(md).unwrap_err();
        assert!(err
            .to_string()
            .contains("failed to parse sprint-contract YAML"));
    }

    #[test]
    fn empty_criteria_list_reports_is_empty() -> anyhow::Result<()> {
        let contract = SprintContract {
            goal: "trivial".to_string(),
            criteria: vec![],
        };
        assert!(contract.is_empty());
        Ok(())
    }

    // --- EvalResult::from_markdown ---

    #[test]
    fn parse_eval_result_pass() -> anyhow::Result<()> {
        let md = r#"
```eval-result
outcome: pass
criteria_ids:
  - c1
  - c2
```
"#;
        let result = EvalResult::from_markdown(md)?;
        assert!(result.is_pass());
        assert!(result.failed_ids().is_empty());
        Ok(())
    }

    #[test]
    fn parse_eval_result_fail() -> anyhow::Result<()> {
        let md = r#"
```eval-result
outcome: fail
failed:
  - id: c1
    reason: "Tests did not pass"
```
"#;
        let result = EvalResult::from_markdown(md)?;
        assert!(!result.is_pass());
        assert_eq!(result.failed_ids(), vec!["c1"]);
        Ok(())
    }

    #[test]
    fn parse_eval_result_partial() -> anyhow::Result<()> {
        let md = r#"
```eval-result
outcome: partial
passed:
  - c1
failed:
  - id: c2
    reason: "LLM judge found drift"
```
"#;
        let result = EvalResult::from_markdown(md)?;
        assert!(!result.is_pass());
        assert_eq!(result.failed_ids(), vec!["c2"]);
        Ok(())
    }

    #[test]
    fn parse_eval_result_partial_without_passed_field() -> anyhow::Result<()> {
        // LLM may follow the prompt example and omit `passed` for partial outcomes.
        // `#[serde(default)]` on the field must prevent deserialization failure.
        let md = r#"
```eval-result
outcome: partial
failed:
  - id: c2
    reason: "LLM judge found drift"
```
"#;
        let result = EvalResult::from_markdown(md)?;
        let EvalResult::Partial { passed, failed } = result else {
            panic!("Expected EvalResult::Partial, got {:?}", result);
        };
        assert!(passed.is_empty());
        assert_eq!(failed.len(), 1);
        assert_eq!(failed[0].id, "c2");
        assert_eq!(failed[0].reason, "LLM judge found drift");
        Ok(())
    }

    #[test]
    fn parse_eval_result_returns_error_when_block_missing() {
        let err = EvalResult::from_markdown("no block").unwrap_err();
        assert!(err.to_string().contains("no eval-result fenced block"));
    }

    // --- extract_fenced_block ---

    #[test]
    fn extract_fenced_block_returns_none_for_missing_lang() {
        assert!(extract_fenced_block("```other\nfoo\n```", "sprint-contract").is_none());
    }

    #[test]
    fn extract_fenced_block_handles_content_before_and_after() {
        let text = "before\n```sprint-contract\ncontent here\n```\nafter";
        assert_eq!(
            extract_fenced_block(text, "sprint-contract"),
            Some("content here")
        );
    }
}
