//! Layer B advisory judge for the subjective TDQS dimensions.
//!
//! Two of Glama's six dimensions, Purpose Clarity and Behavioral Transparency,
//! ask a reader to judge whether a description communicates intent and behavior.
//! That is meaning, not pattern, so it cannot be a deterministic signal. Per
//! ADR 0041 it ships as an advisory: an LLM judge emits findings with a
//! confidence band, and they never flip the deterministic [`super::TdqsReport`].
//!
//! This mirrors `crate::security::advisory`: a closure-injected judge so prompt
//! building and parsing are deterministic and cassette-replayable, a malformed
//! or absent response degrades to fewer findings rather than an error, and the
//! advisory report has no scoring or gating method by construction. It is a
//! sibling module to keep the security advisory untouched (file ownership) while
//! reusing the same shape.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::eval::jury::agreement::ConfidenceBand;

/// A subjective TDQS dimension the advisory judge assesses. These are the two
/// Glama dimensions a deterministic check cannot grade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SubjectiveDimension {
    /// Does the description make the tool's purpose clear to a reader?
    PurposeClarity,
    /// Does the description make the tool's runtime behavior and effects clear?
    BehavioralTransparency,
}

impl SubjectiveDimension {
    /// Kebab-case label, matching the serde representation.
    pub fn as_str(self) -> &'static str {
        match self {
            SubjectiveDimension::PurposeClarity => "purpose-clarity",
            SubjectiveDimension::BehavioralTransparency => "behavioral-transparency",
        }
    }

    fn parse(label: &str) -> Option<SubjectiveDimension> {
        match label {
            "purpose-clarity" => Some(SubjectiveDimension::PurposeClarity),
            "behavioral-transparency" => Some(SubjectiveDimension::BehavioralTransparency),
            _ => None,
        }
    }
}

/// One advisory finding on a subjective dimension for one tool.
///
/// Advisory only: it informs a human, it does not score. There is no numeric
/// quality value, because this is a judgement carrying its own confidence, not a
/// deterministic signal that could feed the aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QualityAdvisory {
    /// The subjective dimension this finding is about.
    pub dimension: SubjectiveDimension,
    /// Tool the finding is about.
    pub tool: String,
    /// What the judge flagged, in one line.
    pub message: String,
    /// The judge's reasoning. Advisory context for a human, never a verdict.
    pub rationale: String,
    /// Confidence band, reused from the jury calibration.
    pub confidence: ConfidenceBand,
    /// True when the finding is low-confidence and should go to a human rather
    /// than be acted on directly.
    pub escalate: bool,
}

/// The advisory half of a TDQS run. A separate type from
/// [`super::TdqsReport`] on purpose: it has no score and no aggregate, so
/// advisory findings cannot move the deterministic grade no matter how a caller
/// uses them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QualityAdvisoryReport {
    /// Advisory findings, in tool-then-dimension order.
    pub findings: Vec<QualityAdvisory>,
    /// Tools the judge was asked about.
    pub tools_examined: usize,
    /// Tools where the judge call failed or returned nothing parseable. A judge
    /// outage degrades to fewer findings, never to a changed grade.
    pub judge_errors: usize,
}

impl QualityAdvisoryReport {
    /// The findings flagged for human review (low confidence).
    pub fn escalations(&self) -> Vec<&QualityAdvisory> {
        self.findings.iter().filter(|f| f.escalate).collect()
    }

    /// True when the judge produced no advisory findings.
    pub fn is_empty(&self) -> bool {
        self.findings.is_empty()
    }
}

/// Build the judge prompt for one tool.
///
/// The prompt names the two subjective dimensions and pins the JSON response
/// shape so the parser has a contract. It passes the tool verbatim, because the
/// judge must read the observable artifact, not a summary of it.
pub fn build_prompt(tool: &Value) -> String {
    let tool_json = serde_json::to_string_pretty(tool).unwrap_or_else(|_| tool.to_string());
    format!(
        "You are reviewing one MCP tool definition for two qualities a static \
check cannot grade. purpose-clarity: does the description make the tool's \
purpose clear enough that an agent knows when to reach for it? \
behavioral-transparency: does the description make the tool's runtime behavior \
and side effects clear (what it changes, what it returns, what it will not do)? \
Judge only what the definition states.\n\n\
Tool:\n{tool_json}\n\n\
Respond with JSON of the form: {{\"findings\": [{{\"dimension\": \
\"purpose-clarity\", \"message\": \"...\", \"rationale\": \"...\", \
\"confidence\": \"high\"}}]}}. Use an empty findings array when both \
dimensions are well covered. confidence is one of high, medium, low.",
        tool_json = tool_json,
    )
}

/// Parse the judge's JSON response into advisory findings for one tool.
///
/// A malformed response, an unknown dimension label, or a missing field yields
/// no finding for that entry rather than an error: the advisory lane degrades
/// safely. A low confidence sets the escalation flag.
pub fn parse_findings(tool_name: &str, raw: &str) -> Vec<QualityAdvisory> {
    let Ok(parsed) = serde_json::from_str::<Value>(raw) else {
        return Vec::new();
    };
    let Some(entries) = parsed.get("findings").and_then(|v| v.as_array()) else {
        return Vec::new();
    };
    entries
        .iter()
        .filter_map(|entry| finding_from_entry(tool_name, entry))
        .collect()
}

fn finding_from_entry(tool_name: &str, entry: &Value) -> Option<QualityAdvisory> {
    let dimension = SubjectiveDimension::parse(entry.get("dimension")?.as_str()?)?;
    let confidence = entry
        .get("confidence")
        .and_then(|v| v.as_str())
        .map(confidence_from_str)
        .unwrap_or(ConfidenceBand::Medium);
    Some(QualityAdvisory {
        dimension,
        tool: tool_name.to_string(),
        message: entry
            .get("message")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        rationale: entry
            .get("rationale")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        confidence,
        escalate: confidence == ConfidenceBand::Low,
    })
}

fn confidence_from_str(label: &str) -> ConfidenceBand {
    match label {
        "high" => ConfidenceBand::High,
        "low" => ConfidenceBand::Low,
        _ => ConfidenceBand::Medium,
    }
}

/// Run the advisory judge over a `tools/list`-style catalog.
///
/// `judge` is the live LLM call, injected: it takes a prompt and returns the
/// judge's raw response, or `None` when the call failed. A `None` counts as a
/// judge error and contributes no findings, so an outage shrinks the advisory
/// output without touching the deterministic grade. Tests pass a stub.
pub fn quality_advisory<F>(catalog: &Value, judge: F) -> QualityAdvisoryReport
where
    F: Fn(&str) -> Option<String>,
{
    let tools = catalog
        .get("tools")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let mut findings = Vec::new();
    let mut judge_errors = 0;
    for tool in &tools {
        let name = tool
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("<unknown>");
        match judge(&build_prompt(tool)) {
            Some(raw) => findings.extend(parse_findings(name, &raw)),
            None => judge_errors += 1,
        }
    }
    QualityAdvisoryReport {
        findings,
        tools_examined: tools.len(),
        judge_errors,
    }
}

/// Render an advisory report, labelled so a reader never confuses it with the
/// deterministic TDQS score.
pub fn render_pretty(report: &QualityAdvisoryReport) -> String {
    let mut out = String::from("ADVISORY (LLM judge, not part of the deterministic TDQS score):\n");
    if report.findings.is_empty() {
        out.push_str(&format!(
            "  no advisory findings across {} tool(s)",
            report.tools_examined
        ));
        if report.judge_errors > 0 {
            out.push_str(&format!(" ({} judge errors)", report.judge_errors));
        }
        out.push('\n');
        return out;
    }
    for finding in &report.findings {
        out.push_str(&format!(
            "  {:<24} {} [{}] {}\n",
            finding.dimension.as_str(),
            finding.tool,
            confidence_label(finding.confidence),
            finding.message
        ));
        if finding.escalate {
            out.push_str("           escalate: low confidence, send to a human\n");
        }
    }
    out
}

fn confidence_label(band: ConfidenceBand) -> &'static str {
    match band {
        ConfidenceBand::High => "high-confidence",
        ConfidenceBand::Medium => "medium-confidence",
        ConfidenceBand::Low => "low-confidence",
    }
}

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

    fn catalog() -> Value {
        json!({
            "tools": [{
                "name": "sync_data",
                "description": "Handle the data.",
                "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } } }
            }]
        })
    }

    #[test]
    fn prompt_names_both_subjective_dimensions_and_pins_the_shape() {
        let tool = &catalog()["tools"][0];
        let prompt = build_prompt(tool);
        assert!(prompt.contains("sync_data"));
        assert!(prompt.contains("purpose-clarity"));
        assert!(prompt.contains("behavioral-transparency"));
        assert!(prompt.contains("\"findings\""));
    }

    #[test]
    fn parses_a_judge_finding_with_confidence_and_escalation() {
        let raw = json!({
            "findings": [{
                "dimension": "purpose-clarity",
                "message": "description does not say when to use this tool",
                "rationale": "'Handle the data' names no purpose",
                "confidence": "low"
            }]
        })
        .to_string();
        let findings = parse_findings("sync_data", &raw);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].dimension, SubjectiveDimension::PurposeClarity);
        assert_eq!(findings[0].confidence, ConfidenceBand::Low);
        assert!(findings[0].escalate);
        assert_eq!(findings[0].tool, "sync_data");
    }

    #[test]
    fn malformed_or_unknown_judge_output_yields_no_findings() {
        assert!(parse_findings("sync_data", "not json at all").is_empty());
        let unknown =
            json!({ "findings": [{ "dimension": "vibes", "confidence": "high" }] }).to_string();
        assert!(parse_findings("sync_data", &unknown).is_empty());
    }

    #[test]
    fn high_confidence_finding_does_not_escalate() {
        let raw = json!({
            "findings": [{ "dimension": "behavioral-transparency", "message": "m", "rationale": "r", "confidence": "high" }]
        }).to_string();
        let findings = parse_findings("sync_data", &raw);
        assert_eq!(findings[0].confidence, ConfidenceBand::High);
        assert!(!findings[0].escalate);
    }

    #[test]
    fn advisory_run_collects_findings_from_the_injected_judge() {
        let judge = |_prompt: &str| {
            Some(
                json!({
                    "findings": [{ "dimension": "purpose-clarity", "message": "unclear", "rationale": "vague", "confidence": "medium" }]
                })
                .to_string(),
            )
        };
        let report = quality_advisory(&catalog(), judge);
        assert_eq!(report.tools_examined, 1);
        assert_eq!(report.findings.len(), 1);
        assert_eq!(report.judge_errors, 0);
        assert!(report.escalations().is_empty());
    }

    #[test]
    fn judge_outage_is_recorded_and_produces_no_findings() {
        let report = quality_advisory(&catalog(), |_| None);
        assert_eq!(report.judge_errors, 1);
        assert!(report.is_empty());
    }

    #[test]
    fn advisory_report_has_no_score_or_gate_by_construction() {
        // This test documents the boundary: QualityAdvisoryReport exposes
        // escalations and is_empty, but no score and no fails_at. If someone
        // adds a gate or a score to the advisory lane, they confront ADR 0041
        // here first.
        let report = QualityAdvisoryReport {
            findings: vec![],
            tools_examined: 0,
            judge_errors: 0,
        };
        assert!(report.is_empty());
        assert!(report.escalations().is_empty());
    }

    #[test]
    fn pretty_labels_the_output_advisory_and_flags_escalations() {
        let raw = json!({
            "findings": [{ "dimension": "purpose-clarity", "message": "unclear purpose", "rationale": "vague", "confidence": "low" }]
        }).to_string();
        let report = QualityAdvisoryReport {
            findings: parse_findings("sync_data", &raw),
            tools_examined: 1,
            judge_errors: 0,
        };
        let text = render_pretty(&report);
        assert!(text.contains("ADVISORY"));
        assert!(text.contains("not part of the deterministic TDQS score"));
        assert!(text.contains("escalate: low confidence"));
    }

    #[test]
    fn report_round_trips_through_json() {
        let raw = json!({
            "findings": [{ "dimension": "behavioral-transparency", "message": "m", "rationale": "r", "confidence": "medium" }]
        }).to_string();
        let report = QualityAdvisoryReport {
            findings: parse_findings("sync_data", &raw),
            tools_examined: 1,
            judge_errors: 0,
        };
        let encoded = serde_json::to_string(&report).expect("serialize");
        let decoded: QualityAdvisoryReport = serde_json::from_str(&encoded).expect("deserialize");
        assert_eq!(decoded, report);
    }
}
