//! Verification engine for matching authored claims against observations.
//!
//! This module compares what developers have declared in `.aphoria/claims.toml`
//! against what extractors actually find in code. It produces a `VerifyReport`
//! with pass/conflict/missing/unclaimed verdicts for each claim.

use std::collections::HashMap;

use serde::Serialize;
use stemedb_core::types::ObjectValue;

use crate::types::authored_claim::{AuthoredClaim, ClaimStatus, ComparisonMode};
use crate::types::Observation;

/// Result of verifying a single claim against observations.
#[derive(Debug, Clone)]
pub struct VerifyResult {
    /// The claim being verified (None for unclaimed observations).
    pub claim: Option<AuthoredClaim>,
    /// The verdict: pass, conflict, missing, or unclaimed.
    pub verdict: AuditVerdict,
    /// Observations that matched this claim's tail-path.
    pub matching_observations: Vec<Observation>,
    /// Human-readable explanation of the verdict.
    pub explanation: String,
}

/// Verdict for a single claim verification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditVerdict {
    /// Observation matches the claim.
    Pass,
    /// Observation contradicts the claim.
    Conflict,
    /// No matching observation found for the claim.
    Missing,
    /// Observation exists but has no covering claim (only for unclaimed observations).
    Unclaimed,
}

impl std::fmt::Display for AuditVerdict {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AuditVerdict::Pass => write!(f, "PASS"),
            AuditVerdict::Conflict => write!(f, "CONFLICT"),
            AuditVerdict::Missing => write!(f, "MISSING"),
            AuditVerdict::Unclaimed => write!(f, "UNCLAIMED"),
        }
    }
}

/// Summary counts for a verification report.
#[derive(Debug, Clone, Default, Serialize)]
pub struct VerifySummary {
    /// Total number of active claims verified.
    pub total_claims: usize,
    /// Claims whose observations match.
    pub pass: usize,
    /// Claims contradicted by observations.
    pub conflict: usize,
    /// Claims with no matching observations.
    pub missing: usize,
    /// Observations with no covering claim.
    pub unclaimed: usize,
}

/// Full verification report: per-claim results plus summary.
#[derive(Debug, Clone)]
pub struct VerifyReport {
    /// Per-claim results.
    pub results: Vec<VerifyResult>,
    /// Aggregate counts.
    pub summary: VerifySummary,
}

/// Extract the tail path (last 2 segments) from a concept path.
///
/// Mirrors `ConceptIndex::make_key` logic but without the predicate suffix.
///
/// # Examples
/// - `"code://rust/myapp/tls/cert_verification"` → `Some("tls/cert_verification")`
/// - `"maxwell/wallet/atomics/ordering"` → `Some("atomics/ordering")`
/// - `"single"` → `None` (fewer than 2 segments)
pub fn tail_path(concept_path: &str) -> Option<String> {
    // Strip scheme if present
    let path = concept_path.find("://").map(|i| &concept_path[i + 3..]).unwrap_or(concept_path);

    let mut segments = path.rsplit('/').filter(|s| !s.is_empty());
    let tail2 = segments.next()?;
    let tail1 = segments.next()?;
    Some(format!("{tail1}/{tail2}"))
}

/// Verify authored claims against extracted observations.
///
/// For each active claim:
/// 1. Compute the tail-path from the claim's concept_path
/// 2. Look up observations with matching tail-paths
/// 3. Apply the claim's `ComparisonMode` to determine the verdict
///
/// Observations whose tail-paths are not covered by any claim are reported
/// as `Unclaimed`.
pub fn verify_claims(claims: &[AuthoredClaim], observations: &[Observation]) -> VerifyReport {
    // Index observations by tail-path
    let mut obs_by_tail: HashMap<String, Vec<&Observation>> = HashMap::new();
    for obs in observations {
        if let Some(tp) = tail_path(&obs.concept_path) {
            obs_by_tail.entry(tp).or_default().push(obs);
        }
    }

    let mut results = Vec::new();
    let mut claimed_tails: HashMap<String, bool> = HashMap::new();
    let mut summary = VerifySummary::default();

    // Verify each active claim
    for claim in claims {
        if claim.status != ClaimStatus::Active {
            continue;
        }

        // Check if claim path contains wildcard
        let has_wildcard = claim.concept_path.contains("/*");

        let matching: Vec<&Observation> = if has_wildcard {
            // Wildcard mode: match against observation full concept paths
            let mut matched_obs = Vec::new();
            for (obs_tail, obs_list) in &obs_by_tail {
                // Check each observation's full concept_path against the wildcard pattern
                for obs in obs_list.iter() {
                    // Strip scheme (e.g., "code://rust/" or "rfc://") from observation's concept_path
                    let obs_path = if let Some(scheme_end) = obs.concept_path.find("://") {
                        // Find the first '/' after the scheme
                        let after_scheme = &obs.concept_path[scheme_end + 3..];
                        if let Some(slash_pos) = after_scheme.find('/') {
                            &after_scheme[slash_pos + 1..]
                        } else {
                            after_scheme
                        }
                    } else {
                        &obs.concept_path
                    };

                    if wildcard_matches(&claim.concept_path, obs_path)
                        && obs.predicate == claim.predicate
                    {
                        // Mark this tail as claimed
                        claimed_tails.insert(obs_tail.clone(), true);
                        matched_obs.push(*obs);
                    }
                }
            }
            matched_obs
        } else {
            // Exact mode: use tail-path lookup
            let tp = match tail_path(&claim.concept_path) {
                Some(tp) => tp,
                None => {
                    results.push(VerifyResult {
                        claim: Some(claim.clone()),
                        verdict: AuditVerdict::Missing,
                        matching_observations: Vec::new(),
                        explanation: format!(
                            "Cannot compute tail-path from concept_path '{}'",
                            claim.concept_path
                        ),
                    });
                    summary.missing += 1;
                    summary.total_claims += 1;
                    continue;
                }
            };

            claimed_tails.insert(tp.clone(), true);

            obs_by_tail
                .get(&tp)
                .map(|v| v.as_slice())
                .unwrap_or(&[])
                .iter()
                .filter(|obs| {
                    // Filter by predicate
                    if obs.predicate != claim.predicate {
                        return false;
                    }

                    // Also check that the observation's full path matches the claim's path prefix
                    // Strip scheme from observation's concept_path
                    let obs_path = if let Some(scheme_end) = obs.concept_path.find("://") {
                        let after_scheme = &obs.concept_path[scheme_end + 3..];
                        if let Some(slash_pos) = after_scheme.find('/') {
                            &after_scheme[slash_pos + 1..]
                        } else {
                            after_scheme
                        }
                    } else {
                        &obs.concept_path
                    };

                    // Check if observation path starts with claim path (without the tail)
                    // E.g., claim "maxwell/core/imports/serde" should only match observations
                    // under "maxwell/core/", not "maxwell/hypervisor/"
                    let claim_segments: Vec<&str> = claim.concept_path.split('/').collect();
                    let obs_segments: Vec<&str> = obs_path.split('/').collect();

                    // Check if all claim segments (except last 2, which are the tail) match observation
                    if claim_segments.len() > 2 {
                        let claim_prefix = &claim_segments[..claim_segments.len() - 2];
                        let obs_prefix = if obs_segments.len() >= claim_prefix.len() {
                            &obs_segments[..claim_prefix.len()]
                        } else {
                            &obs_segments[..]
                        };

                        claim_prefix == obs_prefix
                    } else {
                        // Claim has no prefix (<=2 segments), so just match by tail
                        true
                    }
                })
                .copied()
                .collect()
        };

        let claim_obj_value = claim.value.to_object_value();
        let (verdict, explanation) = match claim.comparison {
            ComparisonMode::Equals => {
                if matching.is_empty() {
                    (AuditVerdict::Missing, "No matching observation found".to_string())
                } else if matching.iter().any(|o| o.value == claim_obj_value) {
                    (
                        AuditVerdict::Pass,
                        format!("Observation matches claim value: {}", claim.value),
                    )
                } else {
                    let found_values: Vec<String> =
                        matching.iter().map(|o| format!("{:?}", o.value)).collect();
                    (
                        AuditVerdict::Conflict,
                        format!("Expected {}, found: {}", claim.value, found_values.join(", ")),
                    )
                }
            }
            ComparisonMode::NotEquals => {
                if matching.is_empty() {
                    // No observations means no contradiction — pass
                    (AuditVerdict::Pass, "No observations found (no contradiction)".to_string())
                } else if matching.iter().any(|o| o.value == claim_obj_value) {
                    (
                        AuditVerdict::Conflict,
                        format!("Found observation with forbidden value: {}", claim.value),
                    )
                } else {
                    (AuditVerdict::Pass, "All observations differ from forbidden value".to_string())
                }
            }
            ComparisonMode::Present => {
                if matching.is_empty() {
                    (
                        AuditVerdict::Missing,
                        "Expected observation to be present, but none found".to_string(),
                    )
                } else {
                    (
                        AuditVerdict::Pass,
                        format!("Found {} matching observation(s)", matching.len()),
                    )
                }
            }
            ComparisonMode::Absent => {
                // Find observations that match the claim's specific value
                let matching_value: Vec<&Observation> =
                    matching.iter().filter(|obs| obs.value == claim_obj_value).copied().collect();

                if matching_value.is_empty() {
                    // The specific value is NOT present - this is what we want
                    (AuditVerdict::Pass, "Forbidden value not found (as expected)".to_string())
                } else {
                    // The specific value IS present - conflict
                    let locations: Vec<String> =
                        matching_value.iter().map(|o| format!("{}:{}", o.file, o.line)).collect();
                    (
                        AuditVerdict::Conflict,
                        format!(
                            "Expected value {} to be absent, but found at: {}",
                            claim.value,
                            locations.join(", ")
                        ),
                    )
                }
            }
            ComparisonMode::Contains => {
                if matching.is_empty() {
                    (AuditVerdict::Missing, "No observations found to check contains".to_string())
                } else {
                    // Check if ANY observation contains the claim value
                    let found_containing = matching.iter().any(|obs| {
                        match (&obs.value, &claim_obj_value) {
                            (ObjectValue::Text(obs_str), ObjectValue::Text(claim_str)) => {
                                // Check if claim value appears as:
                                // 1. Exact match (obs == claim)
                                // 2. Substring (obs.contains(claim))
                                // 3. List element (split on comma and check)
                                if obs_str == claim_str {
                                    return true;
                                }
                                if obs_str.contains(claim_str.as_str()) {
                                    return true;
                                }
                                // For comma-separated lists, check if it's a complete element
                                obs_str.split(',').any(|element| element.trim() == claim_str)
                            }
                            _ => obs.value == claim_obj_value, // Fallback to exact equality
                        }
                    });

                    if found_containing {
                        (
                            AuditVerdict::Pass,
                            format!("Found observation containing '{}'", claim.value),
                        )
                    } else {
                        let found_values: Vec<String> =
                            matching.iter().map(|o| format!("{:?}", o.value)).collect();
                        (
                            AuditVerdict::Conflict,
                            format!(
                                "Expected observation to contain '{}', found: {}",
                                claim.value,
                                found_values.join(", ")
                            ),
                        )
                    }
                }
            }
            ComparisonMode::NotContains => {
                // Find observations that contain the claim value
                let matching_containing: Vec<&Observation> = matching
                    .iter()
                    .filter(|obs| match (&obs.value, &claim_obj_value) {
                        (ObjectValue::Text(obs_str), ObjectValue::Text(claim_str)) => {
                            // Check substring or list element
                            if obs_str.contains(claim_str.as_str()) {
                                return true;
                            }
                            obs_str.split(',').any(|element| element.trim() == claim_str)
                        }
                        _ => obs.value == claim_obj_value,
                    })
                    .copied()
                    .collect();

                if matching_containing.is_empty() {
                    // The forbidden value is NOT present - this is what we want
                    (
                        AuditVerdict::Pass,
                        format!("Forbidden value '{}' not found (as expected)", claim.value),
                    )
                } else {
                    // The forbidden value IS present - conflict
                    let locations: Vec<String> = matching_containing
                        .iter()
                        .map(|o| format!("{}:{}", o.file, o.line))
                        .collect();
                    (
                        AuditVerdict::Conflict,
                        format!(
                            "Expected '{}' to not be present, but found at: {}",
                            claim.value,
                            locations.join(", ")
                        ),
                    )
                }
            }
        };

        match verdict {
            AuditVerdict::Pass => summary.pass += 1,
            AuditVerdict::Conflict => summary.conflict += 1,
            AuditVerdict::Missing => summary.missing += 1,
            AuditVerdict::Unclaimed => summary.unclaimed += 1,
        }
        summary.total_claims += 1;

        results.push(VerifyResult {
            claim: Some(claim.clone()),
            verdict,
            matching_observations: matching.into_iter().cloned().collect(),
            explanation,
        });
    }

    // Find unclaimed observations
    for (tp, obs_list) in &obs_by_tail {
        if !claimed_tails.contains_key(tp) {
            summary.unclaimed += obs_list.len();
            for obs in obs_list {
                results.push(VerifyResult {
                    claim: None,
                    verdict: AuditVerdict::Unclaimed,
                    matching_observations: vec![(*obs).clone()],
                    explanation: format!("Observation at {tp} has no covering claim"),
                });
            }
        }
    }

    VerifyReport { results, summary }
}

/// Entry in the extractor→claim map showing which extractors cover which claims.
#[derive(Debug, Clone, Serialize)]
pub struct ExtractorClaimMapping {
    /// Claim ID.
    pub claim_id: String,
    /// Claim tail-path.
    pub claim_tail_path: String,
    /// Extractor names that can verify this claim.
    pub covering_extractors: Vec<String>,
}

/// Entry for extractors that have no matching claims.
#[derive(Debug, Clone, Serialize)]
pub struct UnmatchedExtractor {
    /// Extractor name.
    pub name: String,
    /// Predicates the extractor declares.
    pub predicates: Vec<(String, String)>,
}

/// Full extractor↔claim map result.
#[derive(Debug, Clone, Serialize)]
pub struct ExtractorClaimMap {
    /// Per-claim coverage.
    pub claim_mappings: Vec<ExtractorClaimMapping>,
    /// Extractors without matching claims.
    pub unmatched_extractors: Vec<UnmatchedExtractor>,
}

/// Check if a wildcard pattern matches a tail-path suffix.
///
/// Supports `*` as a single-segment wildcard in two forms:
/// - `"imports/*"` matches `"imports/tokio"` but not `"imports/tokio/runtime"`
/// - `"message/*/derives"` matches `"message/agentmessage/derives"` but not `"message/a/b/derives"`
fn wildcard_matches(pattern: &str, target: &str) -> bool {
    if pattern == target {
        return true;
    }

    // Check for wildcard in pattern
    if let Some(wildcard_pos) = pattern.find("/*") {
        let before_wildcard = &pattern[..wildcard_pos];
        let after_wildcard = &pattern[wildcard_pos + 2..]; // Skip "/*"

        // Target must start with prefix
        if !target.starts_with(before_wildcard) {
            return false;
        }

        // Target must end with suffix (if suffix exists)
        if !after_wildcard.is_empty() && !target.ends_with(after_wildcard) {
            return false;
        }

        // Extract the middle part between prefix and suffix
        let middle_start = before_wildcard.len();
        let middle_end = if after_wildcard.is_empty() {
            target.len()
        } else {
            target.len() - after_wildcard.len()
        };

        if middle_end <= middle_start {
            return false;
        }

        let middle = &target[middle_start..middle_end];

        // Middle must be exactly one segment (starts with '/' and has no other '/')
        middle.starts_with('/') && !middle[1..].contains('/')
    } else {
        false
    }
}

/// Compute the mapping between extractors and authored claims.
///
/// For each active claim, finds extractors whose `verifiable_predicates()`
/// match the claim's tail-path. Reports claims with no covering extractor
/// and extractors with no matching claims.
pub fn compute_extractor_claim_map(
    claims: &[AuthoredClaim],
    extractors: &[Box<dyn crate::extractors::Extractor>],
) -> ExtractorClaimMap {
    let mut claim_mappings = Vec::new();
    let mut extractor_matched: HashMap<String, bool> = HashMap::new();

    // Initialize all extractors as unmatched
    for ext in extractors {
        extractor_matched.insert(ext.name().to_string(), false);
    }

    for claim in claims {
        if claim.status != ClaimStatus::Active {
            continue;
        }

        let claim_tp = match tail_path(&claim.concept_path) {
            Some(tp) => tp,
            None => continue,
        };

        let mut covering = Vec::new();

        for ext in extractors {
            let preds = ext.verifiable_predicates();
            for (tp_pattern, pred) in &preds {
                if wildcard_matches(tp_pattern, &claim_tp) && *pred == claim.predicate {
                    covering.push(ext.name().to_string());
                    extractor_matched.insert(ext.name().to_string(), true);
                    break;
                }
            }
        }

        claim_mappings.push(ExtractorClaimMapping {
            claim_id: claim.id.clone(),
            claim_tail_path: claim_tp,
            covering_extractors: covering,
        });
    }

    let unmatched_extractors = extractors
        .iter()
        .filter(|ext| {
            let preds = ext.verifiable_predicates();
            !preds.is_empty() && !extractor_matched.get(ext.name()).copied().unwrap_or(false)
        })
        .map(|ext| UnmatchedExtractor {
            name: ext.name().to_string(),
            predicates: ext
                .verifiable_predicates()
                .into_iter()
                .map(|(a, b)| (a.to_string(), b.to_string()))
                .collect(),
        })
        .collect();

    ExtractorClaimMap { claim_mappings, unmatched_extractors }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::authored_claim::AuthoredValue;
    use stemedb_core::types::ObjectValue;

    fn make_claim(
        id: &str,
        concept_path: &str,
        predicate: &str,
        value: AuthoredValue,
        comparison: ComparisonMode,
    ) -> AuthoredClaim {
        AuthoredClaim {
            id: id.to_string(),
            concept_path: concept_path.to_string(),
            predicate: predicate.to_string(),
            value,
            comparison,
            provenance: "test".to_string(),
            invariant: "test invariant".to_string(),
            consequence: "test consequence".to_string(),
            authority_tier: "expert".to_string(),
            evidence: vec![],
            category: "test".to_string(),
            status: ClaimStatus::Active,
            supersedes: None,
            created_by: "tester".to_string(),
            created_at: "2026-02-08T12:00:00Z".to_string(),
            updated_at: None,
        }
    }

    fn make_obs(concept_path: &str, predicate: &str, value: ObjectValue) -> Observation {
        Observation {
            concept_path: concept_path.to_string(),
            predicate: predicate.to_string(),
            value,
            file: "src/test.rs".to_string(),
            line: 42,
            matched_text: "test match".to_string(),
            confidence: 1.0,
            description: "test observation".to_string(),
        }
    }

    #[test]
    fn test_tail_path() {
        assert_eq!(
            tail_path("code://rust/myapp/tls/cert_verification"),
            Some("tls/cert_verification".to_string())
        );
        assert_eq!(
            tail_path("maxwell/wallet/atomics/ordering"),
            Some("atomics/ordering".to_string())
        );
        assert_eq!(tail_path("single"), None);
        assert_eq!(
            tail_path("rfc://5246/tls/cert_verification"),
            Some("tls/cert_verification".to_string())
        );
    }

    #[test]
    fn test_verify_pass_equals() {
        let claims = vec![make_claim(
            "c1",
            "project/atomics/ordering",
            "ordering",
            AuthoredValue::Text("SeqCst".to_string()),
            ComparisonMode::Equals,
        )];
        let obs = vec![make_obs(
            "code://rust/project/atomics/ordering",
            "ordering",
            ObjectValue::Text("SeqCst".to_string()),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.pass, 1);
        assert_eq!(report.summary.conflict, 0);
    }

    #[test]
    fn test_verify_conflict_equals() {
        let claims = vec![make_claim(
            "c1",
            "project/atomics/ordering",
            "ordering",
            AuthoredValue::Text("SeqCst".to_string()),
            ComparisonMode::Equals,
        )];
        let obs = vec![make_obs(
            "code://rust/project/atomics/ordering",
            "ordering",
            ObjectValue::Text("Relaxed".to_string()),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.conflict, 1);
        assert_eq!(report.summary.pass, 0);
    }

    #[test]
    fn test_verify_missing() {
        let claims = vec![make_claim(
            "c1",
            "project/config/timeout",
            "timeout_ms",
            AuthoredValue::Number(30.0),
            ComparisonMode::Equals,
        )];
        let obs: Vec<Observation> = vec![];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.missing, 1);
    }

    #[test]
    fn test_verify_unclaimed() {
        let claims: Vec<AuthoredClaim> = vec![];
        let obs = vec![make_obs(
            "code://rust/project/tls/cert_verification",
            "enabled",
            ObjectValue::Boolean(false),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.unclaimed, 1);
    }

    #[test]
    fn test_verify_absent_pass() {
        let claims = vec![make_claim(
            "c1",
            "core/imports/tokio",
            "imported",
            AuthoredValue::Bool(true),
            ComparisonMode::Absent,
        )];
        let obs: Vec<Observation> = vec![];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_absent_conflict() {
        let claims = vec![make_claim(
            "c1",
            "core/imports/tokio",
            "imported",
            AuthoredValue::Bool(true),
            ComparisonMode::Absent,
        )];
        let obs = vec![make_obs(
            "code://rust/core/imports/tokio",
            "imported",
            ObjectValue::Boolean(true),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.conflict, 1);
    }

    #[test]
    fn test_verify_present_pass() {
        let claims = vec![make_claim(
            "c1",
            "project/tls/cert_verification",
            "enabled",
            AuthoredValue::Bool(true),
            ComparisonMode::Present,
        )];
        let obs = vec![make_obs(
            "code://rust/project/tls/cert_verification",
            "enabled",
            ObjectValue::Boolean(true),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_present_missing() {
        let claims = vec![make_claim(
            "c1",
            "project/tls/cert_verification",
            "enabled",
            AuthoredValue::Bool(true),
            ComparisonMode::Present,
        )];
        let obs: Vec<Observation> = vec![];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.missing, 1);
    }

    #[test]
    fn test_verify_not_equals_pass() {
        let claims = vec![make_claim(
            "c1",
            "project/tls/min_version",
            "version",
            AuthoredValue::Text("1.0".to_string()),
            ComparisonMode::NotEquals,
        )];
        let obs = vec![make_obs(
            "code://rust/project/tls/min_version",
            "version",
            ObjectValue::Text("1.3".to_string()),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_not_equals_conflict() {
        let claims = vec![make_claim(
            "c1",
            "project/tls/min_version",
            "version",
            AuthoredValue::Text("1.0".to_string()),
            ComparisonMode::NotEquals,
        )];
        let obs = vec![make_obs(
            "code://rust/project/tls/min_version",
            "version",
            ObjectValue::Text("1.0".to_string()),
        )];

        let report = verify_claims(&claims, &obs);
        assert_eq!(report.summary.conflict, 1);
    }

    #[test]
    fn test_deprecated_claims_skipped() {
        let mut claim = make_claim(
            "c1",
            "project/atomics/ordering",
            "required_ordering",
            AuthoredValue::Text("SeqCst".to_string()),
            ComparisonMode::Equals,
        );
        claim.status = ClaimStatus::Deprecated;

        let obs = vec![make_obs(
            "code://rust/project/atomics/ordering",
            "ordering",
            ObjectValue::Text("Relaxed".to_string()),
        )];

        let report = verify_claims(&[claim], &obs);
        // Deprecated claim should not be verified — only unclaimed observation
        assert_eq!(report.summary.total_claims, 0);
        assert_eq!(report.summary.unclaimed, 1);
    }

    #[test]
    fn test_backward_compat_no_comparison_field() {
        // Simulate a TOML claim without the `comparison` field — should default to Equals
        let toml_str = r#"
[[claim]]
id = "old-claim"
concept_path = "test/concept/path"
predicate = "test_pred"
value = "test_value"
provenance = "Test"
invariant = "Test invariant"
consequence = "Test consequence"
authority_tier = "expert"
category = "safety"
created_by = "tester"
created_at = "2026-02-08T12:00:00Z"
"#;
        let claims_file: crate::claims_file::ClaimsFile =
            toml::from_str(toml_str).expect("parse TOML without comparison field");
        assert_eq!(claims_file.claims.len(), 1);
        assert_eq!(claims_file.claims[0].comparison, ComparisonMode::Equals);
    }

    #[test]
    fn test_wildcard_matches() {
        assert!(wildcard_matches("imports/*", "imports/tokio"));
        assert!(wildcard_matches("imports/*", "imports/serde"));
        assert!(!wildcard_matches("imports/*", "exports/tokio"));
        assert!(!wildcard_matches("imports/*", "imports/tokio/runtime"));
        assert!(wildcard_matches("tls/cert_verification", "tls/cert_verification"));
        assert!(!wildcard_matches("tls/cert_verification", "tls/min_version"));
    }

    #[test]
    fn test_compute_extractor_claim_map() {
        use crate::config::AphoriaConfig;
        use crate::extractors::ExtractorRegistry;

        let config = AphoriaConfig::default();
        let registry = ExtractorRegistry::new(&config);

        let claims = vec![
            make_claim(
                "tls-001",
                "project/tls/cert_verification",
                "enabled",
                AuthoredValue::Bool(true),
                ComparisonMode::Equals,
            ),
            make_claim(
                "import-001",
                "core/imports/tokio",
                "imported",
                AuthoredValue::Bool(true),
                ComparisonMode::Absent,
            ),
        ];

        let map = compute_extractor_claim_map(&claims, registry.extractors());

        // tls_verify should cover tls-001
        let tls_mapping = map.claim_mappings.iter().find(|m| m.claim_id == "tls-001");
        assert!(tls_mapping.is_some());
        assert!(tls_mapping
            .map(|m| m.covering_extractors.contains(&"tls_verify".to_string()))
            .unwrap_or(false));

        // import_graph should cover import-001 via wildcard
        let import_mapping = map.claim_mappings.iter().find(|m| m.claim_id == "import-001");
        assert!(import_mapping.is_some());
        assert!(import_mapping
            .map(|m| m.covering_extractors.contains(&"import_graph".to_string()))
            .unwrap_or(false));
    }

    #[test]
    fn test_verify_filters_by_predicate() {
        // Bug 1 fix: Path matching must respect predicates
        // Claim: core/imports/serde with predicate "imported" must be absent
        // Observation 1: core/imports/serde with predicate "imported" = true → CONFLICT
        // Observation 2: core/imports/serde with predicate "version" = "1.0" → ignore (different predicate)
        let claim = make_claim(
            "core-no-serde",
            "core/imports/serde",
            "imported",
            AuthoredValue::Bool(true),
            ComparisonMode::Absent,
        );
        let obs1 =
            make_obs("code://rust/core/imports/serde", "imported", ObjectValue::Boolean(true));
        let obs2 = make_obs(
            "code://rust/core/imports/serde",
            "version",
            ObjectValue::Text("1.0".to_string()),
        );

        // With obs1 (matching predicate): should CONFLICT
        let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs1));
        assert_eq!(report.summary.conflict, 1);

        // With obs2 (different predicate): should PASS (ignores obs2)
        let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs2));
        assert_eq!(report.summary.pass, 1);

        // With both: should CONFLICT (only obs1 matters)
        let report = verify_claims(&[claim], &[obs1, obs2]);
        assert_eq!(report.summary.conflict, 1);
    }

    #[test]
    fn test_verify_absent_checks_specific_value() {
        // Bug 2 fix: Absent mode must check the specific claim value
        // Claim: algorithm = "md5" must be absent
        // Observation: algorithm = "sha256" → PASS (md5 not present)
        // Observation: algorithm = "md5" → CONFLICT (md5 is present)
        let claim = make_claim(
            "no-md5",
            "project/crypto/hashing/algorithm",
            "algorithm",
            AuthoredValue::Text("md5".to_string()),
            ComparisonMode::Absent,
        );
        let obs_sha = make_obs(
            "code://rust/project/crypto/hashing/algorithm",
            "algorithm",
            ObjectValue::Text("sha256".to_string()),
        );
        let obs_md5 = make_obs(
            "code://rust/project/crypto/hashing/algorithm",
            "algorithm",
            ObjectValue::Text("md5".to_string()),
        );

        // With sha256: should PASS (md5 not found)
        let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs_sha));
        assert_eq!(report.summary.pass, 1);
        assert_eq!(report.summary.conflict, 0);

        // With md5: should CONFLICT (md5 found)
        let report = verify_claims(&[claim], &[obs_md5]);
        assert_eq!(report.summary.conflict, 1);
        assert_eq!(report.summary.pass, 0);
    }

    #[test]
    fn test_verify_wildcard_pattern() {
        // Bug 3 fix: Wildcard patterns must be supported in verification
        // Claim: message/*/derives with predicate "traits" must include "Serialize"
        // Observation 1: message/agentmessage/derives with "Clone,Debug,Serialize"
        // Observation 2: message/daemonmessage/derives with "Clone,Debug,Serialize"
        // Expected: Both observations match the wildcard → PASS
        let claim = make_claim(
            "vsock-serialize",
            "project/message/*/derives",
            "traits",
            AuthoredValue::Text("Serialize".to_string()),
            ComparisonMode::Present,
        );
        let obs1 = make_obs(
            "code://rust/project/message/agentmessage/derives",
            "traits",
            ObjectValue::Text("Clone,Debug,Serialize".to_string()),
        );
        let obs2 = make_obs(
            "code://rust/project/message/daemonmessage/derives",
            "traits",
            ObjectValue::Text("Clone,Debug,Serialize".to_string()),
        );

        let report = verify_claims(&[claim], &[obs1, obs2]);
        assert_eq!(report.summary.pass, 1);
        assert_eq!(report.summary.missing, 0);
        assert_eq!(report.results[0].matching_observations.len(), 2); // Both matched
    }

    #[test]
    fn test_verify_contains_substring() {
        // Test 1: Single value contains substring
        let claim = make_claim(
            "contains-test",
            "project/message/content",
            "text",
            AuthoredValue::Text("error".to_string()),
            ComparisonMode::Contains,
        );
        let obs = make_obs(
            "code://rust/project/message/content",
            "text",
            ObjectValue::Text("This is an error message".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_contains_list_element() {
        // Test 2: Comma-separated list contains element
        let claim = make_claim(
            "serialize-present",
            "project/message/derives",
            "traits",
            AuthoredValue::Text("Serialize".to_string()),
            ComparisonMode::Contains,
        );
        let obs = make_obs(
            "code://rust/project/message/derives",
            "traits",
            ObjectValue::Text("Clone,Debug,Serialize".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_contains_missing() {
        // Test 3: Value not found in observation
        let claim = make_claim(
            "serialize-present",
            "project/message/derives",
            "traits",
            AuthoredValue::Text("Serialize".to_string()),
            ComparisonMode::Contains,
        );
        let obs = make_obs(
            "code://rust/project/message/derives",
            "traits",
            ObjectValue::Text("Clone,Debug".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.conflict, 1);
    }

    #[test]
    fn test_verify_not_contains_pass() {
        // Test 4: Forbidden value NOT present (PASS)
        let claim = make_claim(
            "no-clone",
            "project/wallet/derives",
            "traits",
            AuthoredValue::Text("Clone".to_string()),
            ComparisonMode::NotContains,
        );
        let obs = make_obs(
            "code://rust/project/wallet/derives",
            "traits",
            ObjectValue::Text("Debug".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_not_contains_conflict() {
        // Test 5: Forbidden value IS present (CONFLICT)
        let claim = make_claim(
            "no-clone",
            "project/wallet/derives",
            "traits",
            AuthoredValue::Text("Clone".to_string()),
            ComparisonMode::NotContains,
        );
        let obs = make_obs(
            "code://rust/project/wallet/derives",
            "traits",
            ObjectValue::Text("Clone,Debug".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.conflict, 1);
    }

    #[test]
    fn test_verify_not_contains_substring() {
        // Test 6: Forbidden substring present (CONFLICT)
        let claim = make_claim(
            "no-hardcoded",
            "project/config/password",
            "value",
            AuthoredValue::Text("hardcoded".to_string()),
            ComparisonMode::NotContains,
        );
        let obs = make_obs(
            "code://rust/project/config/password",
            "value",
            ObjectValue::Text("my_hardcoded_password".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.conflict, 1);
    }

    #[test]
    fn test_verify_contains_with_whitespace() {
        // Test 7: List with spaces around commas
        let claim = make_claim(
            "serialize-present",
            "project/message/derives",
            "traits",
            AuthoredValue::Text("Serialize".to_string()),
            ComparisonMode::Contains,
        );
        let obs = make_obs(
            "code://rust/project/message/derives",
            "traits",
            ObjectValue::Text("Clone, Debug, Serialize".to_string()),
        );

        let report = verify_claims(&[claim], &[obs]);
        assert_eq!(report.summary.pass, 1);
    }

    #[test]
    fn test_verify_not_contains_no_observation() {
        // Test 8: No observation (vacuously true - PASS)
        let claim = make_claim(
            "no-clone",
            "project/wallet/derives",
            "traits",
            AuthoredValue::Text("Clone".to_string()),
            ComparisonMode::NotContains,
        );

        let report = verify_claims(&[claim], &[]);
        assert_eq!(report.summary.pass, 1);
    }
}
