//! Strict JSON extraction and parsing for `llm-judge` replies (WOR-1297).
//!
//! Why this is split out of `llm_judge`: the balanced-brace scanner and the
//! reply shape are the parse half of the judge's prompt contract, and they
//! carry their own test corpus. Keeping them in one small module keeps
//! `llm_judge` focused on dispatch and verdict folding, and keeps both
//! files inside the AGENTS.md module ceiling.

use serde::Deserialize;

/// Strict JSON reply shape the judge prompt asks the model to emit.
///
/// The prompt requests the fields in the order evidence, reason, score,
/// pass (the verdict comes last so the model commits to its analysis
/// first), but parsing is field-order independent because serde matches
/// by name.
#[derive(Debug, Deserialize)]
pub(crate) struct JudgeReply {
    /// Explicit verdict, when the model returned one.
    pub(crate) pass: Option<bool>,
    /// Model-reported score in `[0, 1]`, when it returned one.
    #[serde(default)]
    pub(crate) score: Option<f64>,
    /// One-sentence rationale, when present.
    #[serde(default)]
    pub(crate) reason: Option<String>,
    /// A verbatim quote or span from the candidate that justifies the verdict.
    /// Requested only when evidence is required.
    #[serde(default)]
    pub(crate) evidence: Option<String>,
}

/// Parse a judge reply into a [`JudgeReply`], or a human-readable message
/// describing why it failed.
///
/// The message feeds the retry nudge and the `judge-error:` assertion note,
/// so it is a plain `String` rather than a typed error: nothing programmatic
/// branches on it.
pub(crate) fn parse_reply_strict(text: &str) -> Result<JudgeReply, String> {
    let Some(object) = extract_json_object(text) else {
        return Err(format!("no JSON object found in judge reply: {text}"));
    };
    serde_json::from_str(object).map_err(|e| format!("judge reply was not valid JSON: {e}"))
}

/// Return the first complete top-level JSON object in `text`.
///
/// Why a balanced-brace scan instead of the old first-`{` to last-`}`
/// slice: models often append commentary or even a second object after the
/// verdict, and the old slice swallowed the junk and failed the parse.
/// Braces inside string literals (including behind escaped quotes) do not
/// count toward the balance.
pub(crate) fn extract_json_object(text: &str) -> Option<&str> {
    let start = text.find('{')?;
    let mut depth = 0usize;
    let mut in_string = false;
    let mut escaped = false;
    for (offset, byte) in text.as_bytes().iter().enumerate().skip(start) {
        if in_string {
            if escaped {
                escaped = false;
            } else if *byte == b'\\' {
                escaped = true;
            } else if *byte == b'"' {
                in_string = false;
            }
            continue;
        }
        match byte {
            b'"' => in_string = true,
            b'{' => depth += 1,
            b'}' => {
                depth -= 1;
                if depth == 0 {
                    // `start` and `offset` both sit on single-byte ASCII
                    // characters, so the slice cannot split a code point.
                    return Some(&text[start..=offset]);
                }
            }
            _ => {}
        }
    }
    None
}

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

    #[test]
    fn extractor_stops_at_first_balanced_object_despite_trailing_junk() {
        assert_eq!(
            extract_json_object(r#"verdict: {"pass": true} hope that helps!"#),
            Some(r#"{"pass": true}"#)
        );
    }

    #[test]
    fn extractor_takes_the_first_of_two_objects() {
        assert_eq!(
            extract_json_object(r#"{"a": 1} {"b": 2}"#),
            Some(r#"{"a": 1}"#)
        );
    }

    #[test]
    fn extractor_ignores_braces_inside_string_literals() {
        let text = r#"{"reason": "uses {curly} and } braces freely", "pass": true}"#;
        assert_eq!(extract_json_object(text), Some(text));
    }

    #[test]
    fn extractor_handles_escaped_quotes_inside_strings() {
        let text = r#"{"reason": "she said \"}{\" loudly", "pass": false}"#;
        assert_eq!(extract_json_object(text), Some(text));
        let reply = parse_reply_strict(text).unwrap();
        assert_eq!(reply.pass, Some(false));
    }

    #[test]
    fn extractor_keeps_nested_objects_inside_one_extraction() {
        let text = r#"{"outer": {"inner": 1}} trailing"#;
        assert_eq!(
            extract_json_object(text),
            Some(r#"{"outer": {"inner": 1}}"#)
        );
    }

    #[test]
    fn extractor_returns_none_when_no_object_is_present() {
        assert_eq!(extract_json_object("no json here"), None);
    }

    #[test]
    fn extractor_returns_none_for_an_unterminated_object() {
        assert_eq!(extract_json_object(r#"{"a": 1"#), None);
    }

    #[test]
    fn strict_parse_reports_invalid_json_in_the_message() {
        let err = parse_reply_strict("{not json}").unwrap_err();
        assert!(err.contains("not valid JSON"), "{err}");
    }

    #[test]
    fn strict_parse_reports_missing_object_in_the_message() {
        let err = parse_reply_strict("PASS, score 0.9").unwrap_err();
        assert!(err.contains("no JSON object found"), "{err}");
    }
}
