//! Formal verification of attenuation soundness via property-based testing.
//!
//! Three properties are verified:
//!
//! 1. **Monotonicity**: For all values v, if validate_attenuation(parent, child) = Ok,
//!    then child.matches(v) = true implies parent.matches(v) = true.
//!    A child constraint can never accept a value the parent rejects.
//!
//! 2. **Normalization idempotence**: Serializing a constraint (or full warrant) to
//!    CBOR and deserializing it back produces an object whose re-serialization is
//!    byte-identical. This guarantees signatures remain valid after round-trips.
//!
//! 3. **Constraint soundness (mint vs enforce)**: Minting a warrant with constraints,
//!    encoding to wire format, decoding, then enforcing with `check_constraints`
//!    produces the same accept/reject decision as calling `matches()` on the
//!    original in-memory constraint. No semantic drift across the wire boundary.

use proptest::prelude::*;
use std::collections::HashMap;
use std::time::Duration;
use tenuo::constraints::*;
use tenuo::crypto::SigningKey;
use tenuo::warrant::Warrant;
use tenuo::wire;

// ============================================================================
// Universe of test values (small enough for exhaustive checking)
// ============================================================================

const ATOMS: &[&str] = &["a", "b", "c", "d", "e"];

fn atom_strategy() -> impl Strategy<Value = String> {
    prop::sample::select(ATOMS).prop_map(|s| s.to_string())
}

fn string_value_strategy() -> impl Strategy<Value = ConstraintValue> {
    prop_oneof![
        atom_strategy().prop_map(ConstraintValue::String),
        // Include some values outside the atom universe to test boundary behavior
        "[a-z]{1,8}".prop_map(ConstraintValue::String),
    ]
}

fn numeric_value_strategy() -> impl Strategy<Value = ConstraintValue> {
    prop_oneof![
        (-1000i64..1000).prop_map(ConstraintValue::Integer),
        (-1000.0f64..1000.0).prop_map(ConstraintValue::Float),
    ]
}

fn list_value_strategy() -> impl Strategy<Value = ConstraintValue> {
    prop::collection::vec(atom_strategy().prop_map(ConstraintValue::String), 0..5)
        .prop_map(ConstraintValue::List)
}

fn command_value_strategy() -> impl Strategy<Value = ConstraintValue> {
    prop_oneof![
        prop::sample::select(
            &[
                "ls",
                "cat /etc/passwd",
                "echo hello",
                "grep -r foo",
                "wc -l"
            ][..]
        )
        .prop_map(|s| ConstraintValue::String(s.to_string())),
        // Include values with shell metacharacters (Shlex should reject these)
        prop::sample::select(&["ls; rm -rf /", "cat | grep", "echo $(whoami)", "ls && cat"][..])
            .prop_map(|s| ConstraintValue::String(s.to_string())),
    ]
}

fn url_value_strategy() -> impl Strategy<Value = ConstraintValue> {
    prop_oneof![prop::sample::select(
        &[
            "https://api.example.com/data",
            "http://localhost/admin",
            "https://10.0.0.1/internal",
            "ftp://files.example.com/pub",
            "https://169.254.169.254/latest/meta-data/",
        ][..]
    )
    .prop_map(|s| ConstraintValue::String(s.to_string())),]
}

fn value_strategy() -> impl Strategy<Value = ConstraintValue> {
    prop_oneof![
        3 => string_value_strategy(),
        1 => numeric_value_strategy(),
        1 => list_value_strategy(),
        1 => command_value_strategy(),
        1 => url_value_strategy(),
    ]
}

// ============================================================================
// Constraint generators
// ============================================================================

fn exact_strategy() -> impl Strategy<Value = Constraint> {
    prop_oneof![
        atom_strategy().prop_map(|s| Constraint::Exact(Exact::new(&*s))),
        (-100i64..100).prop_map(|n| Constraint::Exact(Exact::new(n))),
    ]
}

fn oneof_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::hash_set(atom_strategy(), 1..=ATOMS.len())
        .prop_map(|set| Constraint::OneOf(OneOf::new(set.into_iter().collect::<Vec<_>>())))
}

fn notoneof_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::hash_set(atom_strategy(), 1..=ATOMS.len())
        .prop_map(|set| Constraint::NotOneOf(NotOneOf::new(set.into_iter().collect::<Vec<_>>())))
}

fn range_strategy() -> impl Strategy<Value = Constraint> {
    (
        prop::option::of(-100.0f64..100.0),
        prop::option::of(-100.0f64..100.0),
        any::<bool>(),
        any::<bool>(),
    )
        .prop_filter("at least one bound, not inverted", |(min, max, _, _)| {
            (min.is_some() || max.is_some()) && min.is_none_or(|mn| max.is_none_or(|mx| mn <= mx))
        })
        .prop_map(|(min, max, min_inc, max_inc)| {
            Constraint::Range(Range {
                min,
                max,
                min_inclusive: min_inc,
                max_inclusive: max_inc,
            })
        })
}

fn pattern_strategy() -> impl Strategy<Value = Constraint> {
    prop_oneof![
        atom_strategy()
            .prop_map(|s| Constraint::Pattern(Pattern::new(&format!("{}*", s)).unwrap())),
        atom_strategy()
            .prop_map(|s| Constraint::Pattern(Pattern::new(&format!("*{}", s)).unwrap())),
        Just(Constraint::Pattern(Pattern::new("*").unwrap())),
    ]
}

fn contains_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::hash_set(atom_strategy(), 1..=3)
        .prop_map(|set| Constraint::Contains(Contains::new(set.into_iter().collect::<Vec<_>>())))
}

fn subset_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::hash_set(atom_strategy(), 1..=ATOMS.len())
        .prop_map(|set| Constraint::Subset(Subset::new(set.into_iter().collect::<Vec<_>>())))
}

fn subpath_strategy() -> impl Strategy<Value = Constraint> {
    prop::sample::select(&["/data", "/data/reports", "/data/reports/q3", "/tmp", "/"][..])
        .prop_map(|s| Constraint::Subpath(Subpath::new(s).unwrap()))
}

fn wildcard_strategy() -> impl Strategy<Value = Constraint> {
    Just(Constraint::Wildcard(Wildcard::new()))
}

fn shlex_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::vec(
        prop::sample::select(&["ls", "cat", "echo", "grep", "wc"][..]),
        1..=3,
    )
    .prop_map(|cmds| {
        Constraint::Shlex(Shlex::new(
            cmds.into_iter().map(String::from).collect::<Vec<_>>(),
        ))
    })
}

fn urlsafe_strategy() -> impl Strategy<Value = Constraint> {
    prop_oneof![
        // No restrictions beyond the secure defaults
        Just(Constraint::UrlSafe(UrlSafe::new())),
        // Domain allowlist only
        Just(Constraint::UrlSafe(UrlSafe::with_domains(vec![
            "api.example.com",
        ]))),
        Just(Constraint::UrlSafe(UrlSafe::with_domains(vec![
            "*.example.com",
            "api.other.com",
        ]))),
        // deny_domains only (child must preserve all parent deny entries)
        Just(Constraint::UrlSafe(UrlSafe {
            deny_domains: Some(vec!["evil.com".to_string()]),
            ..UrlSafe::new()
        })),
        Just(Constraint::UrlSafe(UrlSafe {
            deny_domains: Some(vec!["evil.com".to_string(), "*.malware.org".to_string(),]),
            ..UrlSafe::new()
        })),
        // allow_domains + deny_domains: deny wins over the allowlist
        Just(Constraint::UrlSafe(UrlSafe {
            allow_domains: Some(vec!["*.example.com".to_string()]),
            deny_domains: Some(vec!["evil.example.com".to_string()]),
            ..UrlSafe::new()
        })),
    ]
}

fn all_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::vec(leaf_constraint_strategy(), 1..=3)
        .prop_map(|cs| Constraint::All(All::new(cs)))
}

fn not_strategy() -> impl Strategy<Value = Constraint> {
    leaf_constraint_strategy().prop_map(|c| Constraint::Not(Not::new(c)))
}

fn any_of_strategy() -> impl Strategy<Value = Constraint> {
    prop::collection::vec(leaf_constraint_strategy(), 1..=3)
        .prop_map(|cs| Constraint::Any(Any::new(cs)))
}

/// Leaf constraints (no recursion) for use inside All/Not/AnyOf.
fn leaf_constraint_strategy() -> impl Strategy<Value = Constraint> {
    prop_oneof![
        3 => exact_strategy(),
        2 => oneof_strategy(),
        2 => notoneof_strategy(),
        2 => range_strategy(),
        2 => pattern_strategy(),
        1 => contains_strategy(),
        1 => subset_strategy(),
        1 => subpath_strategy(),
        1 => wildcard_strategy(),
        1 => shlex_strategy(),
        1 => urlsafe_strategy(),
    ]
}

/// Full constraint strategy including composites.
fn constraint_strategy() -> impl Strategy<Value = Constraint> {
    prop_oneof![
        3 => exact_strategy(),
        2 => oneof_strategy(),
        2 => notoneof_strategy(),
        2 => range_strategy(),
        2 => pattern_strategy(),
        1 => contains_strategy(),
        1 => subset_strategy(),
        1 => subpath_strategy(),
        1 => wildcard_strategy(),
        1 => shlex_strategy(),
        1 => urlsafe_strategy(),
        1 => all_strategy(),
        1 => not_strategy(),
        1 => any_of_strategy(),
    ]
}

// ============================================================================
// Core soundness property
// ============================================================================

/// The fundamental monotonicity invariant.
///
/// If attenuation is accepted, then for ANY value, child accepting it
/// implies parent also accepts it. A single counterexample is a
/// privilege escalation bug.
fn assert_monotonicity(parent: &Constraint, child: &Constraint, value: &ConstraintValue) {
    let attenuation_ok = parent.validate_attenuation(child).is_ok();
    if !attenuation_ok {
        return; // Attenuation rejected, nothing to check
    }

    let child_accepts = match child.matches(value) {
        Ok(b) => b,
        Err(_) => return, // Evaluation error (e.g., type mismatch), skip
    };

    if !child_accepts {
        return; // Child rejects this value, implication trivially true
    }

    // child.matches(v) = true AND attenuation was accepted
    // => parent.matches(v) MUST be true
    // Err is treated as reject (fail-closed): if parent can't evaluate
    // but child accepted, that's a soundness violation.
    let parent_accepts = parent.matches(value).unwrap_or_default();

    assert!(
        parent_accepts,
        "SOUNDNESS VIOLATION: validate_attenuation(parent, child) = Ok, \
         child.matches(v) = true, but parent.matches(v) = false!\n\
         parent: {:?}\n\
         child:  {:?}\n\
         value:  {:?}",
        parent, child, value
    );
}

// ============================================================================
// Property-based tests (randomized)
// ============================================================================

proptest! {
    #![proptest_config(ProptestConfig::with_cases(5000))]

    /// Fuzz all constraint type pairs with random values.
    #[test]
    fn prop_attenuation_soundness(
        parent in constraint_strategy(),
        child in constraint_strategy(),
        values in prop::collection::vec(value_strategy(), 10..=30),
    ) {
        for value in &values {
            assert_monotonicity(&parent, &child, value);
        }
    }

    /// Specifically target same-type pairs (most attenuation paths).
    #[test]
    fn prop_same_type_soundness(
        (parent, child) in prop_oneof![
            (oneof_strategy(), oneof_strategy()),
            (notoneof_strategy(), notoneof_strategy()),
            (range_strategy(), range_strategy()),
            (exact_strategy(), exact_strategy()),
            (pattern_strategy(), pattern_strategy()),
            (contains_strategy(), contains_strategy()),
            (subset_strategy(), subset_strategy()),
            (subpath_strategy(), subpath_strategy()),
            (shlex_strategy(), shlex_strategy()),
            (urlsafe_strategy(), urlsafe_strategy()),
            (wildcard_strategy(), constraint_strategy()),
        ],
        values in prop::collection::vec(value_strategy(), 20..=40),
    ) {
        for value in &values {
            assert_monotonicity(&parent, &child, value);
        }
    }

    /// Cross-type attenuation: types that can narrow to Exact.
    #[test]
    fn prop_cross_type_to_exact_soundness(
        parent in prop_oneof![
            oneof_strategy(),
            pattern_strategy(),
            range_strategy(),
            subpath_strategy(),
            shlex_strategy(),
            contains_strategy(),
            subset_strategy(),
            wildcard_strategy(),
        ],
        child in exact_strategy(),
        values in prop::collection::vec(value_strategy(), 20..=40),
    ) {
        for value in &values {
            assert_monotonicity(&parent, &child, value);
        }
    }

    /// Wildcard parent with any child type.
    #[test]
    fn prop_wildcard_parent_soundness(
        child in constraint_strategy(),
        values in prop::collection::vec(value_strategy(), 20..=40),
    ) {
        let parent = Constraint::Wildcard(Wildcard::new());
        for value in &values {
            assert_monotonicity(&parent, &child, value);
        }
    }

    /// Verify rejected attenuations: if child is strictly wider, attenuation
    /// SHOULD be rejected. This tests the contrapositive direction.
    #[test]
    fn prop_wider_child_rejected(
        parent in oneof_strategy(),
    ) {
        // Wildcard is always wider than any non-wildcard
        let child = Constraint::Wildcard(Wildcard::new());
        assert!(
            parent.validate_attenuation(&child).is_err(),
            "Wildcard child from non-wildcard parent must be rejected: {:?}",
            parent
        );

        // For OneOf parents: a child with extra values should be rejected
        if let Constraint::OneOf(ref parent_oneof) = parent {
            let mut wider_values: Vec<String> = parent_oneof.values.iter()
                .filter_map(|v| v.as_str().map(|s| s.to_string()))
                .collect();
            wider_values.push("zzz_extra_value".to_string());
            let wider_child = Constraint::OneOf(OneOf::new(wider_values));
            assert!(
                parent.validate_attenuation(&wider_child).is_err(),
                "OneOf child with extra values must be rejected"
            );
        }

    }
}

// ============================================================================
// Exhaustive verification for finite-domain types
// ============================================================================

/// Generate all subsets of ATOMS.
fn all_subsets() -> Vec<Vec<String>> {
    let n = ATOMS.len();
    let mut subsets = Vec::new();
    for mask in 0..(1u32 << n) {
        let mut subset = Vec::new();
        for (i, atom) in ATOMS.iter().enumerate() {
            if mask & (1 << i) != 0 {
                subset.push(atom.to_string());
            }
        }
        subsets.push(subset);
    }
    subsets
}

/// For OneOf x OneOf: exhaustively verify every pair of subsets.
#[test]
fn exhaustive_oneof_oneof_soundness() {
    let subsets = all_subsets();
    let non_empty: Vec<_> = subsets.iter().filter(|s| !s.is_empty()).collect();
    let mut checked = 0u64;

    for parent_set in &non_empty {
        let parent = Constraint::OneOf(OneOf::new(parent_set.to_vec()));
        for child_set in &non_empty {
            let child = Constraint::OneOf(OneOf::new(child_set.to_vec()));
            // Check against every atom
            for atom in ATOMS {
                let value = ConstraintValue::String(atom.to_string());
                assert_monotonicity(&parent, &child, &value);
                checked += 1;
            }
        }
    }
    eprintln!(
        "exhaustive_oneof_oneof: checked {} (parent, child, value) triples",
        checked
    );
}

/// For NotOneOf x NotOneOf: exhaustively verify.
#[test]
fn exhaustive_notoneof_notoneof_soundness() {
    let subsets = all_subsets();
    let non_empty: Vec<_> = subsets.iter().filter(|s| !s.is_empty()).collect();
    let mut checked = 0u64;

    for parent_set in &non_empty {
        let parent = Constraint::NotOneOf(NotOneOf::new(parent_set.to_vec()));
        for child_set in &non_empty {
            let child = Constraint::NotOneOf(NotOneOf::new(child_set.to_vec()));
            for atom in ATOMS {
                let value = ConstraintValue::String(atom.to_string());
                assert_monotonicity(&parent, &child, &value);
                checked += 1;
            }
        }
    }
    eprintln!("exhaustive_notoneof_notoneof: checked {} triples", checked);
}

/// For OneOf x NotOneOf (cross-type): exhaustively verify it's ALWAYS rejected.
#[test]
fn exhaustive_oneof_notoneof_always_rejected() {
    let subsets = all_subsets();
    let non_empty: Vec<_> = subsets.iter().filter(|s| !s.is_empty()).collect();

    for parent_set in &non_empty {
        let parent = Constraint::OneOf(OneOf::new(parent_set.to_vec()));
        for child_set in &non_empty {
            let child = Constraint::NotOneOf(NotOneOf::new(child_set.to_vec()));
            assert!(
                parent.validate_attenuation(&child).is_err(),
                "OneOf -> NotOneOf must ALWAYS be rejected\n\
                 parent: OneOf({:?})\n\
                 child:  NotOneOf({:?})",
                parent_set,
                child_set
            );
        }
    }
}

/// For Subset x Subset: exhaustively verify.
#[test]
fn exhaustive_subset_subset_soundness() {
    let subsets = all_subsets();
    let non_empty: Vec<_> = subsets.iter().filter(|s| !s.is_empty()).collect();
    let mut checked = 0u64;

    for parent_set in &non_empty {
        let parent = Constraint::Subset(Subset::new(parent_set.to_vec()));
        for child_set in &non_empty {
            let child = Constraint::Subset(Subset::new(child_set.to_vec()));
            // Test with every possible list value (all subsets of atoms)
            for test_set in &subsets {
                let value = ConstraintValue::List(
                    test_set
                        .iter()
                        .map(|s| ConstraintValue::String(s.clone()))
                        .collect(),
                );
                assert_monotonicity(&parent, &child, &value);
                checked += 1;
            }
        }
    }
    eprintln!("exhaustive_subset_subset: checked {} triples", checked);
}

/// For Contains x Contains: exhaustively verify.
#[test]
fn exhaustive_contains_contains_soundness() {
    let subsets = all_subsets();
    let non_empty: Vec<_> = subsets.iter().filter(|s| !s.is_empty()).collect();
    let mut checked = 0u64;

    for parent_set in &non_empty {
        let parent = Constraint::Contains(Contains::new(parent_set.to_vec()));
        for child_set in &non_empty {
            let child = Constraint::Contains(Contains::new(child_set.to_vec()));
            for test_set in &subsets {
                let value = ConstraintValue::List(
                    test_set
                        .iter()
                        .map(|s| ConstraintValue::String(s.clone()))
                        .collect(),
                );
                assert_monotonicity(&parent, &child, &value);
                checked += 1;
            }
        }
    }
    eprintln!("exhaustive_contains_contains: checked {} triples", checked);
}

/// Verify that Not -> Not attenuation is always rejected (code is sound,
/// spec was wrong before we fixed it).
#[test]
fn exhaustive_not_not_always_rejected() {
    for atom in ATOMS {
        let inner = Constraint::Exact(Exact::new(*atom));
        let parent = Constraint::Not(Not::new(inner.clone()));
        let child = Constraint::Not(Not::new(inner));
        assert!(
            parent.validate_attenuation(&child).is_err(),
            "Not -> Not must always be rejected"
        );
    }

    // Also test with OneOf inners
    let subsets = all_subsets();
    for s in subsets.iter().filter(|s| !s.is_empty()) {
        let inner = Constraint::OneOf(OneOf::new(s.to_vec()));
        let parent = Constraint::Not(Not::new(inner.clone()));
        let child = Constraint::Not(Not::new(inner));
        assert!(
            parent.validate_attenuation(&child).is_err(),
            "Not -> Not must always be rejected"
        );
    }
}

/// Verify that AnyOf -> AnyOf attenuation is always rejected.
#[test]
fn exhaustive_anyof_anyof_always_rejected() {
    let constraints: Vec<Constraint> = ATOMS
        .iter()
        .map(|a| Constraint::Exact(Exact::new(*a)))
        .collect();

    // Try all pairs of non-empty subsets of these constraints
    let n = constraints.len();
    for pmask in 1..(1u32 << n) {
        let parent_clauses: Vec<_> = (0..n)
            .filter(|i| pmask & (1 << i) != 0)
            .map(|i| constraints[i].clone())
            .collect();
        let parent = Constraint::Any(Any::new(parent_clauses));

        for cmask in 1..(1u32 << n) {
            let child_clauses: Vec<_> = (0..n)
                .filter(|i| cmask & (1 << i) != 0)
                .map(|i| constraints[i].clone())
                .collect();
            let child = Constraint::Any(Any::new(child_clauses));

            assert!(
                parent.validate_attenuation(&child).is_err(),
                "AnyOf -> AnyOf must always be rejected"
            );
        }
    }
}

// ============================================================================
// Regression tests: validate the harness catches known pre-fix bugs
// ============================================================================

/// Bug #1 (pre-fix): OneOf -> NotOneOf was accepted, allowing privilege escalation.
/// OneOf(["a","b"]) -> NotOneOf(["b"]) would accept "e", which the parent rejects.
#[test]
fn regression_oneof_to_notoneof_escalation() {
    let parent = Constraint::OneOf(OneOf::new(vec!["a", "b"]));
    let child = Constraint::NotOneOf(NotOneOf::new(vec!["b"]));

    // Attenuation must be rejected
    assert!(parent.validate_attenuation(&child).is_err());

    // The value "e" proves why: child accepts it, parent rejects it
    let escalation_value = ConstraintValue::String("e".to_string());
    assert!(child.matches(&escalation_value).unwrap());
    assert!(!parent.matches(&escalation_value).unwrap());
}

/// Bug #2 (pre-fix): CEL conjunction bypass via operator precedence.
/// "(x > 0)" attenuated to "(x > 0) && false || true" would accept everything
/// because `&&` binds tighter than `||`, making it `((x > 0) && false) || true`.
/// The fix requires the extra predicate to be parenthesized: "(x > 0) && (pred)".
#[test]
#[cfg(feature = "cel")]
fn regression_cel_conjunction_bypass() {
    let parent = CelConstraint::new("x > 0");
    let parent_c = Constraint::Cel(parent);

    // Attack: bare `false || true` could bypass conjunction
    let attack = CelConstraint::new("(x > 0) && false || true");
    let attack_c = Constraint::Cel(attack);
    assert!(
        parent_c.validate_attenuation(&attack_c).is_err(),
        "CEL conjunction bypass must be rejected"
    );

    // Valid: properly parenthesized predicate
    let valid = CelConstraint::new("(x > 0) && (x < 100)");
    let valid_c = Constraint::Cel(valid);
    assert!(
        parent_c.validate_attenuation(&valid_c).is_ok(),
        "Parenthesized CEL conjunction must be accepted"
    );
}

/// Structural CEL test (no CEL feature required - tests prefix/paren checking only).
#[test]
#[cfg(not(feature = "cel"))]
fn regression_cel_conjunction_bypass() {
    let parent = CelConstraint::new("x > 0");
    let parent_c = Constraint::Cel(parent);

    // Attack: bare predicate without parens must be rejected
    let attack = CelConstraint::new("(x > 0) && false || true");
    let attack_c = Constraint::Cel(attack);
    assert!(
        parent_c.validate_attenuation(&attack_c).is_err(),
        "CEL conjunction bypass must be rejected"
    );

    // Without CEL feature, the valid case also fails (can't compile expression),
    // so just verify the structural rejection works.
}

/// Bug #3 (pre-fix): Not -> Not was accepted but can't guarantee monotonicity
/// for arbitrary inner constraints without deep semantic analysis.
#[test]
fn regression_not_not_unsound() {
    // Not(Exact("a")) -> Not(Exact("b")) would mean:
    //   parent accepts everything except "a"
    //   child accepts everything except "b"
    //   child accepts "a" but parent rejects "a" -> escalation!
    let parent = Constraint::Not(Not::new(Constraint::Exact(Exact::new("a"))));
    let child = Constraint::Not(Not::new(Constraint::Exact(Exact::new("b"))));

    assert!(parent.validate_attenuation(&child).is_err());

    let escalation = ConstraintValue::String("a".to_string());
    assert!(child.matches(&escalation).unwrap());
    assert!(!parent.matches(&escalation).unwrap());
}

// ============================================================================
// Property 2: Normalization idempotence
//
// serialize(deserialize(serialize(x))) == serialize(x)
//
// If this fails, signatures break after a decode/re-encode round-trip,
// or two nodes disagree on a warrant's canonical form.
// ============================================================================

proptest! {
    #![proptest_config(ProptestConfig::with_cases(2000))]

    /// Constraint CBOR round-trip produces byte-identical output.
    #[test]
    fn prop_constraint_cbor_idempotent(
        constraint in leaf_constraint_strategy(),
    ) {
        let bytes1 = wire::to_vec(&constraint).unwrap();
        let decoded: Constraint = ciborium::de::from_reader(&bytes1[..]).unwrap();
        let bytes2 = wire::to_vec(&decoded).unwrap();
        prop_assert_eq!(
            &bytes1, &bytes2,
            "CBOR normalization not idempotent!\n\
             original:     {:?}\n\
             round-tripped: {:?}",
            constraint, decoded
        );
    }

    /// ConstraintSet CBOR round-trip produces byte-identical output.
    #[test]
    fn prop_constraint_set_cbor_idempotent(
        constraints in prop::collection::vec(
            (atom_strategy(), leaf_constraint_strategy()), 1..=4
        ),
    ) {
        let mut cs = ConstraintSet::new();
        for (field, constraint) in constraints {
            cs.insert(field, constraint);
        }

        let bytes1 = wire::to_vec(&cs).unwrap();
        let decoded: ConstraintSet = ciborium::de::from_reader(&bytes1[..]).unwrap();
        let bytes2 = wire::to_vec(&decoded).unwrap();
        prop_assert_eq!(&bytes1, &bytes2, "ConstraintSet CBOR not idempotent");
    }

    /// Full warrant wire format round-trip: encode -> decode -> re-encode
    /// produces byte-identical CBOR.
    #[test]
    fn prop_warrant_wire_idempotent(
        tool_name in "[a-z_]{1,12}",
        field_name in "[a-z]{1,6}",
        constraint in leaf_constraint_strategy(),
        ttl_secs in 60u64..3600,
    ) {
        let kp = SigningKey::generate();
        let mut cs = ConstraintSet::new();
        cs.insert(field_name, constraint);

        let warrant = Warrant::builder()
            .capability(&tool_name, cs)
            .ttl(Duration::from_secs(ttl_secs))
            .holder(kp.public_key())
            .build(&kp)
            .unwrap();

        let bytes1 = wire::encode(&warrant).unwrap();
        let decoded = wire::decode(&bytes1).unwrap();
        let bytes2 = wire::encode(&decoded).unwrap();
        prop_assert_eq!(
            &bytes1, &bytes2,
            "Warrant wire format not idempotent (len {} vs {})",
            bytes1.len(), bytes2.len()
        );
    }
}

/// ConstraintValue CBOR idempotence (exhaustive for small universe).
#[test]
fn exhaustive_constraint_value_cbor_idempotent() {
    let values = vec![
        ConstraintValue::String("hello".to_string()),
        ConstraintValue::String("".to_string()),
        ConstraintValue::Integer(0),
        ConstraintValue::Integer(-1),
        ConstraintValue::Integer(i64::MAX),
        ConstraintValue::Float(0.0),
        ConstraintValue::Float(-1.5),
        ConstraintValue::Float(f64::MAX),
        ConstraintValue::Boolean(true),
        ConstraintValue::Boolean(false),
        ConstraintValue::List(vec![
            ConstraintValue::String("a".to_string()),
            ConstraintValue::Integer(42),
        ]),
        ConstraintValue::List(vec![]),
    ];

    for v in &values {
        let bytes1 = wire::to_vec(v).unwrap();
        let decoded: ConstraintValue = ciborium::de::from_reader(&bytes1[..]).unwrap();
        let bytes2 = wire::to_vec(&decoded).unwrap();
        assert_eq!(
            bytes1, bytes2,
            "ConstraintValue CBOR not idempotent for {:?}",
            v
        );
    }
}

// ============================================================================
// Property 3: Constraint soundness (mint-time vs enforcement-time)
//
// For a warrant minted with constraint C on field F:
//   original C.matches(v) == deserialized_warrant.check_constraints(tool, {F: v})
//
// Any divergence means the enforcement layer silently accepts (or rejects)
// values that the minted policy disagrees with.
// ============================================================================

proptest! {
    #![proptest_config(ProptestConfig::with_cases(3000))]

    /// Constraint semantics survive the wire round-trip: mint -> encode ->
    /// decode -> enforce must agree with in-memory matches().
    #[test]
    fn prop_enforcement_agrees_with_matches(
        constraint in leaf_constraint_strategy(),
        values in prop::collection::vec(value_strategy(), 10..=20),
    ) {
        let tool = "test_tool";
        let field = "arg";
        let kp = SigningKey::generate();

        let mut cs = ConstraintSet::new();
        cs.insert(field, constraint.clone());

        let warrant = Warrant::builder()
            .capability(tool, cs)
            .ttl(Duration::from_secs(3600))
            .holder(kp.public_key())
            .build(&kp)
            .unwrap();

        // Wire round-trip
        let bytes = wire::encode(&warrant).unwrap();
        let deserialized = wire::decode(&bytes).unwrap();

        for value in &values {
            let in_memory_result = constraint.matches(value);

            let mut args = HashMap::new();
            args.insert(field.to_string(), value.clone());
            let enforcement_result = deserialized.check_constraints(tool, &args);

            match in_memory_result {
                Ok(true) => {
                    prop_assert!(
                        enforcement_result.is_ok(),
                        "ENFORCEMENT DRIFT: constraint.matches(v) = true, \
                         but check_constraints rejected after wire round-trip!\n\
                         constraint: {:?}\n\
                         value: {:?}\n\
                         error: {:?}",
                        constraint, value, enforcement_result.err()
                    );
                }
                Ok(false) => {
                    prop_assert!(
                        enforcement_result.is_err(),
                        "ENFORCEMENT DRIFT: constraint.matches(v) = false, \
                         but check_constraints accepted after wire round-trip!\n\
                         constraint: {:?}\n\
                         value: {:?}",
                        constraint, value
                    );
                }
                Err(_) => {
                    // In-memory evaluation error (type mismatch, etc.)
                    // Enforcement may also error or reject; both are fine
                }
            }
        }
    }

    /// Same property but with multiple constraints per tool, testing
    /// ConstraintSet-level interaction across the wire boundary.
    #[test]
    fn prop_multi_field_enforcement_agrees(
        constraints in prop::collection::vec(
            (atom_strategy(), leaf_constraint_strategy()), 1..=3
        ),
        values in prop::collection::vec(
            prop::collection::vec(
                (atom_strategy(), value_strategy()), 1..=5
            ), 5..=10
        ),
    ) {
        let tool = "multi_tool";
        let kp = SigningKey::generate();

        let mut cs = ConstraintSet::new();
        for (field, constraint) in &constraints {
            cs.insert(field.clone(), constraint.clone());
        }

        let warrant = Warrant::builder()
            .capability(tool, cs.clone())
            .ttl(Duration::from_secs(3600))
            .holder(kp.public_key())
            .build(&kp)
            .unwrap();

        let bytes = wire::encode(&warrant).unwrap();
        let deserialized = wire::decode(&bytes).unwrap();

        for value_set in &values {
            let mut args: HashMap<String, ConstraintValue> = HashMap::new();
            for (k, v) in value_set {
                args.insert(k.clone(), v.clone());
            }

            let original_result = cs.matches(&args);
            let deserialized_result = deserialized.check_constraints(tool, &args);

            match (&original_result, &deserialized_result) {
                (Ok(()), Ok(())) => {}
                (Err(_), Err(_)) => {}
                (Ok(()), Err(e)) => {
                    prop_assert!(
                        false,
                        "ENFORCEMENT DRIFT: ConstraintSet.matches() = Ok, \
                         but check_constraints rejected!\n\
                         args: {:?}\n\
                         error: {:?}",
                        args, e
                    );
                }
                (Err(_), Ok(())) => {
                    prop_assert!(
                        false,
                        "ENFORCEMENT DRIFT: ConstraintSet.matches() = Err, \
                         but check_constraints accepted!\n\
                         args: {:?}",
                        args
                    );
                }
            }
        }
    }
}

// ============================================================================
// Property 4: Closed-world (zero-trust) semantics
//
// When a ConstraintSet has constraints and allow_unknown=false (default),
// any argument key not explicitly listed MUST be rejected.
// When allow_unknown=true, extra keys are permitted.
// During attenuation, allow_unknown=false -> allow_unknown=true is forbidden.
// ============================================================================

proptest! {
    #![proptest_config(ProptestConfig::with_cases(2000))]

    /// Closed-world rejects unknown fields when constraints exist.
    /// Uses Wildcard for the known field so it always passes, isolating
    /// the test to unknown-field rejection specifically.
    #[test]
    fn prop_closed_world_rejects_unknown_fields(
        known_field in atom_strategy(),
        unknown_field in "[f-z]{2,6}",
        known_value in value_strategy(),
        unknown_value in value_strategy(),
    ) {
        let mut cs = ConstraintSet::new();
        cs.insert(known_field.clone(), Constraint::Wildcard(Wildcard::new()));

        // Sanity: known field alone passes
        let known_only: HashMap<String, ConstraintValue> =
            [(known_field.clone(), known_value.clone())].into_iter().collect();
        prop_assert!(cs.matches(&known_only).is_ok(), "known field alone must pass");

        // Adding an unknown field must cause rejection
        let mut args_with_unknown = known_only;
        args_with_unknown.insert(unknown_field.clone(), unknown_value);

        let result = cs.matches(&args_with_unknown);
        prop_assert!(
            result.is_err(),
            "Closed-world ConstraintSet must reject unknown field '{}'\n\
             known fields: [{}]",
            unknown_field, known_field
        );
    }

    /// allow_unknown=true permits extra fields.
    #[test]
    fn prop_open_world_allows_unknown_fields(
        known_field in atom_strategy(),
        unknown_field in "[f-z]{2,6}",
        constraint in leaf_constraint_strategy(),
        value in value_strategy(),
        known_value in value_strategy(),
    ) {
        let mut cs = ConstraintSet::new();
        cs.insert(known_field.clone(), constraint.clone());
        cs.set_allow_unknown(true);

        // The unknown field alone shouldn't cause rejection
        // (known field may still reject based on its constraint)
        let mut args: HashMap<String, ConstraintValue> = HashMap::new();
        args.insert(known_field.clone(), known_value.clone());
        args.insert(unknown_field.clone(), value);

        let result = cs.matches(&args);
        // If it fails, it must be because the known constraint rejected its
        // value, NOT because of the unknown field
        if result.is_err() {
            let known_only: HashMap<String, ConstraintValue> =
                [(known_field.clone(), known_value)].into_iter().collect();
            prop_assert!(
                cs.matches(&known_only).is_err(),
                "allow_unknown=true rejected due to unknown field, not constraint"
            );
        }
    }

    /// Attenuation cannot widen allow_unknown from false to true.
    #[test]
    fn prop_allow_unknown_cannot_widen(
        field in atom_strategy(),
        parent_constraint in leaf_constraint_strategy(),
        child_constraint in leaf_constraint_strategy(),
    ) {
        let mut parent_cs = ConstraintSet::new();
        parent_cs.insert(field.clone(), parent_constraint);
        // parent: allow_unknown = false (default)

        let mut child_cs = ConstraintSet::new();
        child_cs.insert(field, child_constraint);
        child_cs.set_allow_unknown(true);

        prop_assert!(
            parent_cs.validate_attenuation(&child_cs).is_err(),
            "allow_unknown=false -> true must be rejected during attenuation"
        );
    }

    /// Attenuation can narrow allow_unknown from true to false.
    #[test]
    fn prop_allow_unknown_can_narrow(
        field in atom_strategy(),
        constraint in leaf_constraint_strategy(),
    ) {
        let mut parent_cs = ConstraintSet::new();
        parent_cs.insert(field.clone(), constraint.clone());
        parent_cs.set_allow_unknown(true);

        let mut child_cs = ConstraintSet::new();
        child_cs.insert(field, constraint);
        // child: allow_unknown = false (default, more restrictive)

        prop_assert!(
            parent_cs.validate_attenuation(&child_cs).is_ok(),
            "allow_unknown=true -> false should be accepted (narrowing)"
        );
    }

    /// Keyset identity (I4, IETF draft Section 4.5): adding an argument
    /// key to a non-empty parent constraint map must be rejected.
    /// Under closed-world semantics, an added key produces invocations
    /// whose shape the parent's closed-world check would reject (the
    /// extra argument is unknown), so the derived invocation set is
    /// disjoint from the parent's, not a subset.
    #[test]
    fn prop_keyset_add_rejected_for_nonempty_parent(
        field in atom_strategy(),
        extra_field in "[f-z]{2,6}",
        constraint in leaf_constraint_strategy(),
        extra_constraint in leaf_constraint_strategy(),
    ) {
        let mut parent_cs = ConstraintSet::new();
        parent_cs.insert(field.clone(), constraint.clone());

        let mut child_cs = ConstraintSet::new();
        child_cs.insert(field, constraint);
        child_cs.insert(extra_field.clone(), extra_constraint);

        prop_assert!(
            parent_cs.validate_attenuation(&child_cs).is_err(),
            "Adding key '{}' to non-empty parent constraint map must be rejected \
             (keyset identity, I4)",
            extra_field
        );
    }

    /// Keyset identity: adding keys to an empty parent map is valid
    /// (open-world to closed-world transition).
    #[test]
    fn prop_keyset_add_allowed_for_empty_parent(
        field in atom_strategy(),
        constraint in leaf_constraint_strategy(),
    ) {
        let parent_cs = ConstraintSet::new();

        let mut child_cs = ConstraintSet::new();
        child_cs.insert(field, constraint);

        prop_assert!(
            parent_cs.validate_attenuation(&child_cs).is_ok(),
            "Adding keys to empty parent map should be accepted (open-world to closed-world)"
        );
    }

    /// Keyset identity: dropping a key from a non-empty parent map
    /// must be rejected. Dropping reopens that argument under
    /// closed-world semantics, which is a privilege escalation.
    #[test]
    fn prop_keyset_drop_rejected(
        field_a in atom_strategy(),
        field_b in "[f-z]{2,6}",
        constraint_a in leaf_constraint_strategy(),
        constraint_b in leaf_constraint_strategy(),
    ) {
        let mut parent_cs = ConstraintSet::new();
        parent_cs.insert(field_a.clone(), constraint_a.clone());
        parent_cs.insert(field_b.clone(), constraint_b);

        let mut child_cs = ConstraintSet::new();
        child_cs.insert(field_a, constraint_a);
        // field_b dropped

        prop_assert!(
            parent_cs.validate_attenuation(&child_cs).is_err(),
            "Dropping key '{}' from non-empty parent must be rejected (keyset identity, I4)",
            field_b
        );
    }
}

/// Closed-world property survives wire round-trip.
#[test]
fn closed_world_survives_wire_roundtrip() {
    let kp = SigningKey::generate();

    // Build warrant with closed-world ConstraintSet
    let mut cs = ConstraintSet::new();
    cs.insert(
        "path",
        Constraint::Pattern(Pattern::new("/data/*").unwrap()),
    );
    // allow_unknown=false (default)

    let warrant = Warrant::builder()
        .capability("read_file", cs)
        .ttl(Duration::from_secs(3600))
        .holder(kp.public_key())
        .build(&kp)
        .unwrap();

    let bytes = wire::encode(&warrant).unwrap();
    let decoded = wire::decode(&bytes).unwrap();

    // Known field, valid value: should pass
    let mut good_args = HashMap::new();
    good_args.insert(
        "path".to_string(),
        ConstraintValue::String("/data/report.csv".to_string()),
    );
    assert!(decoded.check_constraints("read_file", &good_args).is_ok());

    // Unknown field: must be rejected (closed-world)
    let mut bad_args = HashMap::new();
    bad_args.insert(
        "path".to_string(),
        ConstraintValue::String("/data/report.csv".to_string()),
    );
    bad_args.insert(
        "extra_field".to_string(),
        ConstraintValue::String("injected".to_string()),
    );
    assert!(
        decoded.check_constraints("read_file", &bad_args).is_err(),
        "Closed-world must reject unknown fields after wire round-trip"
    );
}

/// allow_unknown=true survives wire round-trip.
#[test]
fn allow_unknown_survives_wire_roundtrip() {
    let kp = SigningKey::generate();

    let mut cs = ConstraintSet::new();
    cs.insert(
        "path",
        Constraint::Pattern(Pattern::new("/data/*").unwrap()),
    );
    cs.set_allow_unknown(true);

    let warrant = Warrant::builder()
        .capability("read_file", cs)
        .ttl(Duration::from_secs(3600))
        .holder(kp.public_key())
        .build(&kp)
        .unwrap();

    let bytes = wire::encode(&warrant).unwrap();
    let decoded = wire::decode(&bytes).unwrap();

    // Unknown field with allow_unknown=true: should pass
    let mut args = HashMap::new();
    args.insert(
        "path".to_string(),
        ConstraintValue::String("/data/report.csv".to_string()),
    );
    args.insert(
        "extra_field".to_string(),
        ConstraintValue::String("allowed".to_string()),
    );
    assert!(
        decoded.check_constraints("read_file", &args).is_ok(),
        "allow_unknown=true must survive wire round-trip"
    );
}

// ============================================================================
// Property 5: TTL / expiration survives wire round-trip
//
// A warrant's expires_at timestamp must be identical after encode -> decode.
// Combined with the existing invariant that child.expires_at <= parent.expires_at,
// this guarantees temporal monotonicity is preserved across the wire boundary.
// ============================================================================

proptest! {
    #![proptest_config(ProptestConfig::with_cases(1000))]

    /// expires_at is preserved exactly through wire round-trip.
    #[test]
    fn prop_ttl_survives_wire_roundtrip(
        ttl_secs in 1u64..86400,
        tool in "[a-z_]{1,10}",
    ) {
        let kp = SigningKey::generate();

        let warrant = Warrant::builder()
            .capability(&tool, ConstraintSet::new())
            .ttl(Duration::from_secs(ttl_secs))
            .holder(kp.public_key())
            .build(&kp)
            .unwrap();

        let original_expires = warrant.expires_at();

        let bytes = wire::encode(&warrant).unwrap();
        let decoded = wire::decode(&bytes).unwrap();

        prop_assert_eq!(
            decoded.expires_at(),
            original_expires,
            "expires_at must survive wire round-trip"
        );
    }

    /// TTL monotonicity after attenuation + wire round-trip: the child's
    /// expiration (after decode) never exceeds the parent's expiration.
    #[test]
    fn prop_ttl_monotonicity_survives_wire(
        parent_ttl in 600u64..3600,
        child_ttl in 1u64..7200,
    ) {
        let parent_kp = SigningKey::generate();
        let child_kp = SigningKey::generate();

        let parent = Warrant::builder()
            .capability("tool", ConstraintSet::new())
            .ttl(Duration::from_secs(parent_ttl))
            .holder(child_kp.public_key())
            .build(&parent_kp)
            .unwrap();

        let child = parent
            .attenuate()
            .inherit_all()
            .ttl(Duration::from_secs(child_ttl))
            .holder(child_kp.public_key())
            .build(&child_kp)
            .unwrap();

        // Wire round-trip both
        let parent_decoded = wire::decode(&wire::encode(&parent).unwrap()).unwrap();
        let child_decoded = wire::decode(&wire::encode(&child).unwrap()).unwrap();

        prop_assert!(
            child_decoded.expires_at() <= parent_decoded.expires_at(),
            "child.expires_at ({}) must <= parent.expires_at ({}) after wire round-trip",
            child_decoded.expires_at(),
            parent_decoded.expires_at()
        );
    }
}

/// Expired warrant is still expired after wire round-trip (no time travel).
#[test]
fn expired_warrant_stays_expired_after_roundtrip() {
    let kp = SigningKey::generate();

    let warrant = Warrant::builder()
        .capability("tool", ConstraintSet::new())
        .ttl(Duration::from_secs(1))
        .holder(kp.public_key())
        .build(&kp)
        .unwrap();

    // Wait for expiration
    std::thread::sleep(Duration::from_secs(2));
    assert!(warrant.is_expired(), "warrant should be expired");

    let bytes = wire::encode(&warrant).unwrap();
    let decoded = wire::decode(&bytes).unwrap();

    assert!(
        decoded.is_expired(),
        "expired warrant must remain expired after wire round-trip"
    );
}

// ============================================================================
// Property 6: Constants enforcement survives wire boundary
//
// MAX_CONSTRAINT_DEPTH and MAX_WARRANT_SIZE are checked during decode.
// ============================================================================

/// Oversized payloads are rejected at decode time.
#[test]
fn oversized_warrant_rejected_on_decode() {
    let huge = vec![0u8; wire::MAX_WARRANT_SIZE + 1];
    assert!(
        wire::decode(&huge).is_err(),
        "Payloads exceeding MAX_WARRANT_SIZE must be rejected"
    );
}

/// Deeply nested constraints are rejected at decode time.
#[test]
fn deep_constraint_nesting_rejected_on_decode() {
    let kp = SigningKey::generate();

    // Build a constraint nested beyond MAX_CONSTRAINT_DEPTH
    let mut c = Constraint::Exact(Exact::new("leaf"));
    for _ in 0..40 {
        c = Constraint::Not(Not::new(c));
    }

    let mut cs = ConstraintSet::new();
    cs.insert("field", c);

    let warrant = Warrant::builder()
        .capability("tool", cs)
        .ttl(Duration::from_secs(3600))
        .holder(kp.public_key())
        .build(&kp);

    // Either build rejects it, or encode+decode rejects it
    match warrant {
        Err(_) => {} // Build caught it
        Ok(w) => {
            let bytes = wire::encode(&w).unwrap();
            assert!(
                wire::decode(&bytes).is_err(),
                "Deeply nested constraints must be rejected on decode"
            );
        }
    }
}
