//! Criterion validators promoted from the QA trace (ADR-049 / ADR-050 / D2).
//!
//! Each function registers into `inventory::collect!(ValidatorEntry)` via
//! `#[onto_validator]`. The NCL declarations live in
//! `.ontoref/catalog/validators/` — `adr-accepted-has-proof.ncl` is the
//! worked example; `adr-proposal-requires-description.ncl` is the structural
//! companion. The frozen signature is `fn(&Slice, &ValidationCtx) -> Verdict`.

use ontoref_derive::onto_validator;

use crate::validation::traits::{Slice, ValidationCtx};
use crate::validation::verdict::Verdict;

/// Every Accepted ADR is cited by at least one positioning Proof.
///
/// Slice envelope: `{ "adrs": [{"id": "…", "status": "…"}],
/// "proofs": [{"evidence_adr": "…"}] }`.
/// Returns `Unknown` when the slice was not loaded (slice loader not yet
/// wired).
#[onto_validator(
    id = "adr_accepted_has_proof",
    description = "Every Accepted ADR is cited by at least one positioning Proof.",
    category = "Contextual",
    slice_query = "adrs_and_proofs"
)]
fn adr_accepted_has_proof(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
    let Some(adrs) = slice.get("adrs").and_then(|v| v.as_array()) else {
        return Verdict::Unknown;
    };
    let proofs = slice
        .get("proofs")
        .and_then(|v| v.as_array())
        .map(|a| a.as_slice())
        .unwrap_or(&[]);

    let cited: std::collections::HashSet<&str> = proofs
        .iter()
        .filter_map(|p| p.get("evidence_adr").and_then(|v| v.as_str()))
        .collect();

    let uncited: Vec<&str> = adrs
        .iter()
        .filter(|a| {
            a.get("status")
                .and_then(|v| v.as_str())
                .map(|s| s.eq_ignore_ascii_case("accepted"))
                .unwrap_or(false)
        })
        .filter_map(|a| a.get("id").and_then(|v| v.as_str()))
        .filter(|id| !cited.contains(*id))
        .collect();

    if uncited.is_empty() {
        Verdict::Accept
    } else {
        Verdict::reject(
            format!(
                "{} Accepted ADR(s) lack a Proof citation: {}",
                uncited.len(),
                uncited.join(", ")
            ),
            "adr_accepted_has_proof",
        )
    }
}

/// An ADR being proposed must declare a non-empty description field.
///
/// Slice envelope: `{ "inputs": { "description": "…" } }`.
/// Structural — reads no state; only inspects the op's own input fields.
#[onto_validator(
    id = "adr_proposal_requires_description",
    description = "An ADR being proposed must declare a non-empty description field.",
    category = "Structural"
)]
fn adr_proposal_requires_description(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
    let description = slice
        .get("inputs")
        .and_then(|inputs| inputs.get("description"))
        .and_then(|v| v.as_str())
        .unwrap_or("");

    if description.trim().is_empty() {
        Verdict::reject(
            "ADR proposal missing required non-empty 'description' field",
            "adr_proposal_requires_description",
        )
    } else {
        Verdict::Accept
    }
}

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

    use super::*;

    fn ctx() -> ValidationCtx {
        ValidationCtx::synthetic("test", "op:adr-propose")
    }

    // ── adr_accepted_has_proof ────────────────────────────────────────────────

    #[test]
    fn aahp_accepts_when_all_adrs_cited() {
        let slice = json!({
            "adrs":   [{"id": "adr-001", "status": "Accepted"}],
            "proofs": [{"evidence_adr": "adr-001"}]
        });
        assert_eq!(adr_accepted_has_proof(&slice, &ctx()), Verdict::Accept);
    }

    #[test]
    fn aahp_accepts_when_no_accepted_adrs() {
        let slice = json!({
            "adrs":   [{"id": "adr-002", "status": "Proposed"}],
            "proofs": []
        });
        assert_eq!(adr_accepted_has_proof(&slice, &ctx()), Verdict::Accept);
    }

    #[test]
    fn aahp_rejects_uncited_accepted_adr() {
        let slice = json!({
            "adrs": [
                {"id": "adr-001", "status": "Accepted"},
                {"id": "adr-002", "status": "Accepted"}
            ],
            "proofs": [{"evidence_adr": "adr-001"}]
        });
        assert!(matches!(
            adr_accepted_has_proof(&slice, &ctx()),
            Verdict::Reject { reason, .. } if reason.contains("adr-002")
        ));
    }

    #[test]
    fn aahp_unknown_when_adrs_key_absent() {
        let slice = json!({"proofs": []});
        assert_eq!(adr_accepted_has_proof(&slice, &ctx()), Verdict::Unknown);
    }

    #[test]
    fn aahp_status_check_is_case_insensitive() {
        let slice = json!({
            "adrs":   [{"id": "adr-003", "status": "ACCEPTED"}],
            "proofs": [{"evidence_adr": "adr-003"}]
        });
        assert_eq!(adr_accepted_has_proof(&slice, &ctx()), Verdict::Accept);
    }

    // ── adr_proposal_requires_description ────────────────────────────────────

    #[test]
    fn aprd_accepts_non_empty_description() {
        let slice = json!({"inputs": {"description": "Explains why we chose X over Y."}});
        assert_eq!(
            adr_proposal_requires_description(&slice, &ctx()),
            Verdict::Accept
        );
    }

    #[test]
    fn aprd_rejects_empty_description() {
        let slice = json!({"inputs": {"description": ""}});
        assert!(matches!(
            adr_proposal_requires_description(&slice, &ctx()),
            Verdict::Reject { .. }
        ));
    }

    #[test]
    fn aprd_rejects_whitespace_only() {
        let slice = json!({"inputs": {"description": "   \t  "}});
        assert!(matches!(
            adr_proposal_requires_description(&slice, &ctx()),
            Verdict::Reject { .. }
        ));
    }

    #[test]
    fn aprd_rejects_missing_description_field() {
        let slice = json!({"inputs": {}});
        assert!(matches!(
            adr_proposal_requires_description(&slice, &ctx()),
            Verdict::Reject { .. }
        ));
    }

    #[test]
    fn aprd_rejects_missing_inputs_envelope() {
        let slice = json!({});
        assert!(matches!(
            adr_proposal_requires_description(&slice, &ctx()),
            Verdict::Reject { .. }
        ));
    }

    // ── inventory registration ────────────────────────────────────────────────

    #[test]
    fn both_validators_in_inventory() {
        use crate::validation::dispatch;
        let ids = dispatch::registered_ids();
        assert!(
            ids.contains(&"adr_accepted_has_proof"),
            "adr_accepted_has_proof must be registered; inventory: {ids:?}"
        );
        assert!(
            ids.contains(&"adr_proposal_requires_description"),
            "adr_proposal_requires_description must be registered; inventory: {ids:?}"
        );
    }

    #[test]
    fn dispatch_calls_aahp() {
        use crate::validation::dispatch;
        let slice = json!({
            "adrs":   [{"id": "adr-001", "status": "Accepted"}],
            "proofs": [{"evidence_adr": "adr-001"}]
        });
        let verdict = dispatch::dispatch("adr_accepted_has_proof", &slice, &ctx())
            .expect("must be registered");
        assert_eq!(verdict, Verdict::Accept);
    }

    #[test]
    fn dispatch_calls_aprd() {
        use crate::validation::dispatch;
        let slice = json!({"inputs": {"description": "not empty"}});
        let verdict = dispatch::dispatch("adr_proposal_requires_description", &slice, &ctx())
            .expect("must be registered");
        assert_eq!(verdict, Verdict::Accept);
    }

    // ── O6 coherence: every NCL predicate_ref resolves to a registered entry ─

    /// Walks `catalog/validators/*.ncl`, exports each to JSON via nickel,
    /// collects `predicate_ref` strings, and asserts each one appears in
    /// `dispatch::registered_ids()`. Fails the build if a validator is declared
    /// in NCL but its Rust fn was never registered via `#[onto_validator]`.
    #[test]
    fn ncl_predicate_refs_all_registered() {
        use std::path::PathBuf;
        use std::process::Command;

        use crate::validation::dispatch;

        // `CARGO_MANIFEST_DIR` = code/crates/ontoref-ops
        // Three parents up → constellation root (ontoref/)
        let catalog = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|p| p.parent())
            .and_then(|p| p.parent())
            .expect("constellation root resolvable")
            .join(".ontoref/catalog/validators");

        assert!(
            catalog.is_dir(),
            "catalog/validators dir not found at {}",
            catalog.display()
        );

        let registered = dispatch::registered_ids();

        for entry in std::fs::read_dir(&catalog).expect("readable catalog dir") {
            let path = entry.expect("readable entry").path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            if path.file_name().and_then(|n| n.to_str()) == Some("schema.ncl") {
                continue;
            }

            let out = Command::new("nickel")
                .args(["export", "--format", "json"])
                .arg(&path)
                .output()
                .expect("nickel binary required on PATH");

            assert!(
                out.status.success(),
                "nickel export failed for {}: {}",
                path.display(),
                String::from_utf8_lossy(&out.stderr)
            );

            let value: serde_json::Value =
                serde_json::from_slice(&out.stdout).expect("nickel output is JSON");

            let predicate_ref = value
                .get("predicate_ref")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| panic!("no predicate_ref in {}", path.display()));

            assert!(
                registered.contains(&predicate_ref),
                "NCL predicate_ref '{}' (from {}) is not registered in the Rust \
                 inventory.\nRegistered ids: {:?}",
                predicate_ref,
                path.display(),
                registered
            );
        }
    }
}
