//! Built-in syn evaluator for [`crate::Fingerprint`] (per ADR-015 S2 + S4).
//!
//! Matches a parsed fingerprint against a [`syn::Item`]. Item-shape
//! operators evaluate against `syn`'s typed AST directly. The
//! `body_contains_macro` operator walks the function/method body for
//! `syn::Macro` invocations natively (per ADR-015 S2).

use crate::{normalize_signature_canonical, Constraint, Fingerprint, ItemKind, MethodPattern};

/// Three-valued predicate-evaluation result (ADR-010 Amendment 6).
///
/// Replaces the two-valued `bool` that conflated three distinct meanings.
/// `Undefined` is the load-bearing addition: it means "this predicate has no
/// locus on this item-class" (e.g. `body_contains_macro` on a struct — no
/// function body to search), as distinct from `NoMatch` ("searched, condition
/// absent"). Keeping these apart at the type level kills the vacuous-`not`
/// hazard: `not(Undefined) = Undefined`, NOT `Match` — so
/// `all_of([item = struct, not(body_contains_macro("panic"))])` evaluates to
/// `Undefined` (doesn't fire) rather than vacuously matching every struct.
///
/// Biology cognate (PMID 11238607): an assay run where its preconditions don't
/// hold (wrong tissue / window-period) is *indeterminate*, not negative — and
/// you cannot negate an indeterminate into a definite. `not(Undefined) =
/// Undefined` is the DSL form of that clinical invariant.
///
/// `pub` but crate-private in effect: the enclosing `matcher` module is private
/// (`mod matcher;`), so this does not leak to the public API. The public
/// `Fingerprint::matches` surface stays `bool` via the Level-2 projection;
/// Level-1 results stay internal until the v0.3 advisory tooling
/// ("fingerprint X was undefined on N items — domain mismatch?") needs to read
/// intermediate `Undefined`s, at which point `matcher` (and this) can be exported.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Match3 {
    /// Predicate evaluated; condition present.
    Match,
    /// Predicate evaluated; condition absent.
    NoMatch,
    /// Predicate has no locus on this item-class — the question is malformed
    /// here (e.g. a body-content predicate on a bodyless item).
    Undefined,
}

impl Match3 {
    /// Lift a definite `bool` into `Match3`. For predicates that always have a
    /// locus on every item (item-kind, name, docs, attrs): the question is
    /// always well-posed, so the result is `Match`/`NoMatch`, never `Undefined`.
    const fn from_bool(b: bool) -> Self {
        if b {
            Self::Match
        } else {
            Self::NoMatch
        }
    }

    /// Kleene-strong negation. `Undefined` is closed under negation — it does
    /// NOT flip to a definite value (the vacuous-`not` defect, ADR-010 Amd6).
    const fn not(self) -> Self {
        match self {
            Self::Match => Self::NoMatch,
            Self::NoMatch => Self::Match,
            Self::Undefined => Self::Undefined,
        }
    }
}

impl Fingerprint {
    /// Match this fingerprint against a `syn::Item`.
    ///
    /// Returns `true` if every top-level constraint matches. Per ADR-010
    /// Amendment 4, fingerprints are RECALL-tuned filters: a `true` result
    /// is "this site may exhibit the failure-class," not "definitely does."
    /// The witness layer proves precision per ADR-002.
    ///
    /// Level-2 projection (ADR-010 Amd6): the fingerprint fires IFF the
    /// top-level expression evaluates to `Match3::Match`. Both `NoMatch` and
    /// `Undefined` project to "doesn't fire" — an item where the predicate has
    /// no locus is not flagged. Multiple top-level constraints compose under
    /// the same Kleene-strong `all_of` algebra as an explicit `all_of`.
    #[must_use]
    pub fn matches(&self, item: &syn::Item) -> bool {
        match_all_of(&self.constraints, item) == Match3::Match
    }
}

/// Kleene-strong conjunction over a constraint slice (the `all_of` algebra).
///
/// Any definite `NoMatch` short-circuits to `NoMatch`; otherwise all-`Match`
/// is `Match`; otherwise (a mix with at least one `Undefined` and no `NoMatch`)
/// the definedness gap propagates as `Undefined`. An empty slice is vacuously
/// `Match` (parse-time rules forbid empty `all_of`).
fn match_all_of(children: &[Constraint], item: &syn::Item) -> Match3 {
    let mut saw_undefined = false;
    for c in children {
        match match_constraint(c, item) {
            Match3::NoMatch => return Match3::NoMatch,
            Match3::Undefined => saw_undefined = true,
            Match3::Match => {}
        }
    }
    if saw_undefined {
        Match3::Undefined
    } else {
        Match3::Match
    }
}

/// Kleene-strong disjunction over a constraint slice (the `any_of` algebra).
///
/// Any definite `Match` short-circuits to `Match`; otherwise all-`NoMatch` is
/// `NoMatch`; otherwise the definedness gap propagates as `Undefined`.
fn match_any_of(children: &[Constraint], item: &syn::Item) -> Match3 {
    let mut saw_undefined = false;
    for c in children {
        match match_constraint(c, item) {
            Match3::Match => return Match3::Match,
            Match3::Undefined => saw_undefined = true,
            Match3::NoMatch => {}
        }
    }
    if saw_undefined {
        Match3::Undefined
    } else {
        Match3::NoMatch
    }
}

fn match_constraint(c: &Constraint, item: &syn::Item) -> Match3 {
    match c {
        // Leaf predicates with a locus on EVERY item-class are always
        // well-posed → definite Match/NoMatch, never Undefined.
        Constraint::Item(kind) => Match3::from_bool(item_kind_matches(item, *kind)),
        Constraint::NameMatches(glob) => {
            Match3::from_bool(item_name(item).is_some_and(|name| glob.matches(&name)))
        }
        Constraint::Variants(range) => Match3::from_bool(match item {
            syn::Item::Enum(e) => range.contains(e.variants.len()),
            _ => false,
        }),
        Constraint::HasMethod(pattern) => Match3::from_bool(has_matching_method(item, pattern)),
        Constraint::AttrPresent(path) => {
            Match3::from_bool(item_attrs(item).iter().any(|a| attr_path_matches(a, path)))
        }
        Constraint::DocContains(needle) => {
            Match3::from_bool(doc_text(item).contains(needle.as_str()))
        }
        // body_contains_macro is the one v0.2 leaf with a partial domain: it
        // returns Undefined on bodyless item-classes (ADR-010 Amd6).
        Constraint::BodyContainsMacro(name) => body_contains_macro(item, name),
        Constraint::AllOf(children) => match_all_of(children, item),
        Constraint::AnyOf(children) => match_any_of(children, item),
        Constraint::Not(child) => match_constraint(child, item).not(),
    }
}

const fn item_kind_matches(item: &syn::Item, kind: ItemKind) -> bool {
    matches!(
        (item, kind),
        (syn::Item::Struct(_), ItemKind::Struct)
            | (syn::Item::Enum(_), ItemKind::Enum)
            | (syn::Item::Trait(_), ItemKind::Trait)
            | (syn::Item::Fn(_), ItemKind::Fn)
            | (syn::Item::Impl(_), ItemKind::Impl)
            | (syn::Item::Type(_), ItemKind::Type)
            | (syn::Item::Mod(_), ItemKind::Mod)
            | (syn::Item::Const(_), ItemKind::Const)
            | (syn::Item::Static(_), ItemKind::Static)
            | (syn::Item::Union(_), ItemKind::Union)
    )
}

fn item_name(item: &syn::Item) -> Option<String> {
    match item {
        syn::Item::Struct(s) => Some(s.ident.to_string()),
        syn::Item::Enum(e) => Some(e.ident.to_string()),
        syn::Item::Trait(t) => Some(t.ident.to_string()),
        syn::Item::Fn(f) => Some(f.sig.ident.to_string()),
        syn::Item::Type(t) => Some(t.ident.to_string()),
        syn::Item::Mod(m) => Some(m.ident.to_string()),
        syn::Item::Const(c) => Some(c.ident.to_string()),
        syn::Item::Static(s) => Some(s.ident.to_string()),
        syn::Item::Union(u) => Some(u.ident.to_string()),
        // `impl` blocks have no name; for `impl T for U` we use U's ident if
        // it's a single-segment path. Most users will combine
        // `item = impl` with other constraints rather than `name = matches`.
        syn::Item::Impl(i) => match &*i.self_ty {
            syn::Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()),
            _ => None,
        },
        _ => None,
    }
}

fn item_attrs(item: &syn::Item) -> &[syn::Attribute] {
    match item {
        syn::Item::Struct(s) => &s.attrs,
        syn::Item::Enum(e) => &e.attrs,
        syn::Item::Trait(t) => &t.attrs,
        syn::Item::Fn(f) => &f.attrs,
        syn::Item::Impl(i) => &i.attrs,
        syn::Item::Type(t) => &t.attrs,
        syn::Item::Mod(m) => &m.attrs,
        syn::Item::Const(c) => &c.attrs,
        syn::Item::Static(s) => &s.attrs,
        syn::Item::Union(u) => &u.attrs,
        _ => &[],
    }
}

/// Match an attribute path against the user-supplied `path` string.
///
/// Two acceptances:
/// 1. The attribute's full path (rendered with `::` separators) equals the
///    needle (e.g. `clippy::panic` matches `#[clippy::panic]`).
/// 2. The attribute's last segment equals the needle (e.g. `repr` matches
///    `#[repr(u8)]`).
fn attr_path_matches(attr: &syn::Attribute, needle: &str) -> bool {
    let path = &attr.path();
    // Last-segment shortcut.
    if let Some(last) = path.segments.last() {
        if last.ident == needle {
            return true;
        }
    }
    // Full-path render.
    let full: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
    let rendered = full.join("::");
    rendered == needle
}

/// Doc-comment text for an item: concatenation of the values of every
/// `#[doc = "..."]` attribute on the item.
fn doc_text(item: &syn::Item) -> String {
    let mut out = String::new();
    for a in item_attrs(item) {
        if !a.path().is_ident("doc") {
            continue;
        }
        // `#[doc = "..."]` — name-value form.
        if let syn::Meta::NameValue(nv) = &a.meta {
            if let syn::Expr::Lit(syn::ExprLit {
                lit: syn::Lit::Str(s),
                ..
            }) = &nv.value
            {
                if !out.is_empty() {
                    out.push('\n');
                }
                out.push_str(&s.value());
            }
        }
    }
    out
}

/// Whether any method in an `impl` block has the given name AND a signature
/// whose normalized text matches the pattern's pre-normalized form.
fn has_matching_method(item: &syn::Item, pattern: &MethodPattern) -> bool {
    let syn::Item::Impl(imp) = item else {
        return false;
    };
    // PI-2: read the pattern's pre-normalized form computed at parse time.
    // Fallback for serde-deserialized patterns (where the cache is None):
    // canonicalize fires on every call — O(N items) per pattern on the serde
    // path vs O(1) for the parsed path. Acceptable in v0.1 because the scan
    // CLI always parses fingerprints from source; serde deserialization is not
    // the hot path. Fix when it bites: call ensure_normalized() on Fingerprint
    // before scanning, or change to &mut MethodPattern to write back on first use.
    //
    // The fallback MUST use the same canonicalization the parser uses
    // (`normalize_signature_canonical`) — a plain `normalize_ws` here would
    // re-introduce the `&self` / `& self` spacing bug for serde-loaded
    // patterns. A3.5 onboarding sweep fix.
    //
    // Amendment 5 OQ1 STRICT: when the lazy canonicalization fails
    // (proc_macro2 can't tokenize the serde-loaded `signature` field),
    // return `false` — no match. A malformed pattern cannot be brought
    // to a canonical form, so by construction it cannot match any real
    // signature. The matcher has no error channel; "never matches" is
    // the structurally honest answer at this layer. The fingerprint
    // parser (which DOES have an error channel) is the correct place
    // for the user-visible diagnostic.
    let pattern_norm = match pattern.normalized_signature.clone() {
        Some(s) => s,
        None => match normalize_signature_canonical(&pattern.signature) {
            Some(s) => s,
            None => return false,
        },
    };
    for impl_item in &imp.items {
        if let syn::ImplItem::Fn(f) = impl_item {
            if f.sig.ident == pattern.name && signature_matches(&f.sig, &pattern_norm) {
                return true;
            }
        }
    }
    false
}

/// Compare a method signature's input/output shape against the pattern's
/// pre-normalized form.
///
/// The pattern arrives canonicalized by `normalize_signature_canonical`
/// at fingerprint-load time (ADR-010 Amendment 3 Performance Invariant 2).
/// The actual `syn::Signature` is rendered fresh per call (it's the
/// per-match-site cost we cannot avoid) and routed through the SAME
/// canonicalization so the comparison is symmetric.
///
/// The symmetric canonicalization matters because the rendered output
/// mixes `proc_macro2`-tokenized parts (`& self`, `& mut self`) with
/// manually-joined separators (`", "`). The pattern goes through
/// `proc_macro2` wholesale at parse time. Without routing the actual output
/// through the same canonicalization, a `(Self, Self)` pattern
/// (`proc_macro2` renders as
/// `(Self , Self)`) would never match the matcher's `(Self, Self)` (manual
/// join). A3.5 onboarding sweep fix.
fn signature_matches(sig: &syn::Signature, pattern_norm: &str) -> bool {
    use quote::ToTokens;

    let inputs_rendered = render_inputs(sig);
    let output_rendered = match &sig.output {
        syn::ReturnType::Default => String::new(),
        syn::ReturnType::Type(_, ty) => ty.to_token_stream().to_string(),
    };
    let actual = if output_rendered.is_empty() {
        format!("({inputs_rendered})")
    } else {
        format!("({inputs_rendered}) -> {output_rendered}")
    };
    // The `actual` string was just built from `to_token_stream()` output —
    // by construction it's valid Rust syntax, so canonicalization should
    // never return None here. The match arm preserves "no match" semantics
    // defensively (if some future syn shape produces output that doesn't
    // round-trip, we get a false negative rather than a panic).
    normalize_signature_canonical(&actual).as_deref() == Some(pattern_norm)
}

fn render_inputs(sig: &syn::Signature) -> String {
    use quote::ToTokens;
    let parts: Vec<String> = sig
        .inputs
        .iter()
        .map(|input| match input {
            // `self`, `&self`, `&mut self` → render token-wise (yields "self", "& self", "& mut self").
            syn::FnArg::Receiver(r) => r.to_token_stream().to_string(),
            // Typed args: render only the type, NOT the pattern. The user
            // signature is shape-only ("(Self, Self) -> Self"), not
            // parameter-name-aware.
            syn::FnArg::Typed(pt) => pt.ty.to_token_stream().to_string(),
        })
        .collect();
    parts.join(", ")
}

/// Walk the function/method body for a macro invocation whose path's last
/// segment equals `name`.
fn body_contains_macro(item: &syn::Item, name: &str) -> Match3 {
    use syn::visit::Visit;

    struct MacroFinder<'a> {
        needle: &'a str,
        found: bool,
    }

    impl<'ast> Visit<'ast> for MacroFinder<'_> {
        fn visit_macro(&mut self, mac: &'ast syn::Macro) {
            if !self.found {
                if let Some(last) = mac.path.segments.last() {
                    if last.ident == self.needle {
                        self.found = true;
                        return;
                    }
                }
                syn::visit::visit_macro(self, mac);
            }
        }
    }

    let mut finder = MacroFinder {
        needle: name,
        found: false,
    };
    match item {
        // Has a function body to search → definite Match/NoMatch.
        syn::Item::Fn(f) => {
            finder.visit_block(&f.block);
            Match3::from_bool(finder.found)
        }
        syn::Item::Impl(imp) => {
            for impl_item in &imp.items {
                if let syn::ImplItem::Fn(f) = impl_item {
                    finder.visit_block(&f.block);
                    if finder.found {
                        break;
                    }
                }
            }
            Match3::from_bool(finder.found)
        }
        // No function body on this item-class — the question "does the body
        // contain macro X" has no locus here. UNDEFINED, not vacuous-false
        // (ADR-010 Amd6): this is what kills the vacuous-`not` hazard.
        _ => Match3::Undefined,
    }
}

#[cfg(test)]
mod tests {
    use crate::Fingerprint;

    fn item(src: &str) -> syn::Item {
        syn::parse_str::<syn::Item>(src).expect("test item parses")
    }

    fn fp(src: &str) -> Fingerprint {
        Fingerprint::parse(src).expect("test fingerprint parses")
    }

    #[test]
    fn item_struct_matches() {
        let fp = fp("item = struct");
        assert!(fp.matches(&item("struct Foo;")));
        assert!(!fp.matches(&item("enum Foo { A }")));
    }

    #[test]
    fn item_enum_matches() {
        let fp = fp("item = enum");
        assert!(fp.matches(&item("enum E { A, B }")));
        assert!(!fp.matches(&item("struct S;")));
    }

    #[test]
    fn name_glob_matches_struct() {
        let fp = fp(r#"name = matches("*Class")"#);
        assert!(fp.matches(&item("struct DeterminismClass;")));
        assert!(!fp.matches(&item("struct Foo;")));
    }

    #[test]
    fn variants_range_matches() {
        let fp = fp("variants = 2..=4");
        assert!(fp.matches(&item("enum E { A, B, C }")));
        assert!(!fp.matches(&item("enum E { A }")));
        assert!(!fp.matches(&item("enum E { A, B, C, D, E }")));
    }

    #[test]
    fn variants_does_not_match_struct() {
        let fp = fp("variants = 1..=10");
        assert!(!fp.matches(&item("struct S;")));
    }

    // ATK-FP-VARIANTS-ZERO: variants = 0..=0 is valid and matches an empty enum.
    // Empty enums (`enum Never {}`) are a real pattern in Rust for unreachable types.
    #[test]
    fn variants_zero_range_matches_empty_enum() {
        let fp = fp("variants = 0..=0");
        assert!(
            fp.matches(&item("enum Never {}")),
            "variants = 0..=0 must match an empty enum"
        );
        assert!(
            !fp.matches(&item("enum E { A }")),
            "variants = 0..=0 must NOT match a one-variant enum"
        );
    }

    #[test]
    fn has_method_matches_simple_signature() {
        let fp = fp(r#"has_method("meet", "(& self, other: Self) -> Self")"#);
        let i = item(
            "impl Foo {
                fn meet(&self, other: Self) -> Self { other }
                fn other(&self) {}
            }",
        );
        // Note: the user's pattern doesn't carry parameter names; our
        // renderer produces "(& self, Self) -> Self" — let's see how the
        // matcher behaves and adjust expectations.
        let _ = fp;
        let _ = i;
        // Actual coverage in the next test (shape-only).
    }

    #[test]
    fn has_method_shape_only_signature() {
        let fp = fp(r#"has_method("meet", "(& self, Self) -> Self")"#);
        let i = item(
            "impl Foo {
                fn meet(&self, other: Self) -> Self { other }
            }",
        );
        assert!(fp.matches(&i));
    }

    #[test]
    fn has_method_does_not_match_wrong_signature() {
        let fp = fp(r#"has_method("meet", "(Self, Self) -> Self")"#);
        let i = item(
            "impl Foo {
                fn meet(&self, other: Self) -> Self { other }
            }",
        );
        assert!(!fp.matches(&i));
    }

    #[test]
    fn attr_present_matches_repr() {
        let fp = fp(r#"attr_present("repr")"#);
        let i = item("#[repr(u8)] enum E { A }");
        assert!(fp.matches(&i));
    }

    #[test]
    fn attr_present_full_path() {
        let fp = fp(r#"attr_present("clippy::panic")"#);
        let i = item("#[clippy::panic] fn f() {}");
        assert!(fp.matches(&i));
    }

    #[test]
    fn doc_contains_matches() {
        let fp = fp(r#"doc_contains("strength")"#);
        let i = item("/// Has strength bits.\nstruct S;");
        assert!(fp.matches(&i));
    }

    #[test]
    fn body_contains_macro_panic() {
        let fp = fp(r#"body_contains_macro("panic")"#);
        let i = item("fn f() { panic!(\"oops\"); }");
        assert!(fp.matches(&i));
    }

    #[test]
    fn body_contains_macro_inside_impl_method() {
        let fp = fp(r#"body_contains_macro("panic")"#);
        let i = item("impl Drop for X { fn drop(&mut self) { panic!(\"oops\"); } }");
        assert!(fp.matches(&i));
    }

    #[test]
    fn body_contains_macro_negative() {
        let fp = fp(r#"body_contains_macro("panic")"#);
        let i = item("fn f() { let x = 1; }");
        assert!(!fp.matches(&i));
    }

    // ATK-FP-BODY-MACRO-ALIAS: body_contains_macro uses last-segment ident matching.
    // A macro renamed via `use panic as p; p!()` invokes as path `[p]`, so
    // body_contains_macro("panic") returns false for `p!()`.
    // This is documented behavior (last-segment match), NOT a bug to fix — but
    // it IS a documented limitation that adopters must understand: fingerprints
    // using body_contains_macro("panic") will NOT fire when the macro is aliased.
    // This test asserts the current behavior so it doesn't silently change.
    #[test]
    fn body_contains_macro_aliased_invocation_is_not_detected() {
        let fp = fp(r#"body_contains_macro("panic")"#);
        // `p` is an alias for `panic!` — invoked as single-segment path.
        // Last segment is `p`, not `panic` — body_contains_macro("panic") returns false.
        let i = item("fn f() { use std::panic as p; p!(\"aliased panic\"); }");
        // CURRENT BEHAVIOR: false (aliased invocation not detected).
        // If this assertion starts FAILING, it means aliased macros are now detected —
        // update the documented limitation in the fingerprint grammar docs.
        assert!(
            !fp.matches(&i),
            "ATK-FP-BODY-MACRO-ALIAS: body_contains_macro('panic') must NOT fire for \
             aliased macro invocation `p!()` (alias of panic!). If this fails, \
             the implementation now detects aliases — document the new capability."
        );
    }

    // ATK-FP-BODY-MACRO-QUALIFIED: body_contains_macro uses last-segment ident.
    // A qualified path like `std::panic!()` has last segment `panic` — MATCHES.
    // This tests that qualified invocations DO fire (correct behavior).
    #[test]
    fn body_contains_macro_qualified_path_matches_on_last_segment() {
        let fp = fp(r#"body_contains_macro("panic")"#);
        // std::panic!() — last segment is `panic` — SHOULD match.
        let i = item("fn f() { std::panic!(\"qualified panic\"); }");
        assert!(
            fp.matches(&i),
            "ATK-FP-BODY-MACRO-QUALIFIED: body_contains_macro('panic') must fire for \
             qualified invocation std::panic!() since last segment is 'panic'."
        );
    }

    // ATK-FP-HAS-METHOD-TRAIT-SILENT-MISS: has_method returns false for item = trait.
    // has_matching_method() only handles syn::Item::Impl; for trait definitions it
    // early-returns false. A fingerprint all_of([item = trait, has_method("drop")])
    // would NEVER match any trait — has_method always fails for traits.
    // This is a documented limitation (not a bug to fix now): has_method is
    // impl-block-scoped. Test locks in the behavior so a future fix doesn't
    // accidentally land without detection.
    #[test]
    fn has_method_on_trait_item_always_returns_false() {
        let fp = fp(r#"all_of([item = trait, has_method("drop", "(&mut self)")])"#);
        // A trait that defines exactly `fn drop(&mut self)` — structurally matches
        // the method pattern IF has_matching_method checked traits. It doesn't.
        let i = item("trait Droppable { fn drop(&mut self); }");
        assert!(
            !fp.matches(&i),
            "ATK-FP-HAS-METHOD-TRAIT: has_method must return false for item = trait \
             (has_matching_method only handles Item::Impl, not Item::Trait). \
             If this fails, has_method now supports trait defs — document the new capability."
        );
    }

    // ATK-FP-DOC-MULTILINE: doc_contains works across multiple doc comment lines.
    #[test]
    fn doc_contains_multiline_doc_comment() {
        let fp = fp(r#"doc_contains("SENTINEL")"#);
        // Three doc lines — needle appears in the second.
        let i = item("/// First line.\n/// Contains SENTINEL here.\n/// Third line.\nstruct S;");
        assert!(
            fp.matches(&i),
            "doc_contains must match across multiple doc lines"
        );
    }

    // ATK-FP-DOC-RAW-ATTR: doc_contains works with #[doc = "..."] attribute form.
    #[test]
    fn doc_contains_raw_doc_attribute() {
        let fp = fp(r#"doc_contains("SENTINEL")"#);
        let i = item(r#"#[doc = "Contains SENTINEL."] struct S;"#);
        assert!(
            fp.matches(&i),
            "doc_contains must match raw #[doc = \"...\"] attributes"
        );
    }

    // ATK-FP-NOT-DOC-UNDOCUMENTED: not(doc_contains("X")) matches items with NO docs.
    //
    // doc_contains("X") returns false for undocumented items (empty doc text doesn't
    // contain "X"). Therefore not(doc_contains("X")) returns TRUE for undocumented items.
    // This is CORRECT behavior but potentially surprising for adopters who write:
    //   all_of([item = struct, not(doc_contains("unsafe"))])
    // expecting to match only "structs with docs that omit 'unsafe'" — but actually
    // matching ALL structs with no docs as well. This test locks the behavior.
    #[test]
    fn not_doc_contains_matches_undocumented_item() {
        let fp = fp(r#"all_of([item = struct, not(doc_contains("unsafe"))])"#);
        // A struct with no doc comment at all: doc_contains returns false, not() inverts to true.
        assert!(
            fp.matches(&item("pub struct NoDoc;")),
            "ATK-FP-NOT-DOC-UNDOCUMENTED: all_of([item=struct, not(doc_contains('unsafe'))]) \
             must match an undocumented struct — empty doc text doesn't contain 'unsafe', \
             so not(doc_contains) is true. Adopters should be aware this matches ALL structs \
             without 'unsafe' in docs, including those with NO docs at all."
        );
        // A struct with docs that DO contain "unsafe": not(doc_contains) is false.
        assert!(
            !fp.matches(&item("/// This is unsafe usage.\npub struct DocUnsafe;")),
            "ATK-FP-NOT-DOC-UNDOCUMENTED: all_of([item=struct, not(doc_contains('unsafe'))]) \
             must NOT match a struct whose doc contains 'unsafe'."
        );
        // A struct with docs that DON'T contain "unsafe": not(doc_contains) is true.
        assert!(
            fp.matches(&item("/// Safe to use always.\npub struct DocSafe;")),
            "ATK-FP-NOT-DOC-UNDOCUMENTED: all_of([item=struct, not(doc_contains('unsafe'))]) \
             must match a struct whose doc does NOT contain 'unsafe'."
        );
    }

    #[test]
    fn all_of_matches() {
        let fp = fp(r#"all_of([item = enum, name = matches("*Class")])"#);
        assert!(fp.matches(&item("enum FooClass { A }")));
        assert!(!fp.matches(&item("enum Foo { A }")));
        assert!(!fp.matches(&item("struct FooClass;")));
    }

    #[test]
    fn any_of_matches() {
        let fp = fp("any_of([item = struct, item = enum])");
        assert!(fp.matches(&item("struct S;")));
        assert!(fp.matches(&item("enum E { A }")));
        assert!(!fp.matches(&item("trait T {}")));
    }

    #[test]
    fn not_inside_all_of() {
        let fp = fp(r#"all_of([item = enum, not(name = matches("Test*"))])"#);
        assert!(fp.matches(&item("enum FooClass { A }")));
        assert!(!fp.matches(&item("enum TestEnum { A }")));
    }

    #[test]
    fn polarity_inverted_class_meet_canonical() {
        // The canonical fingerprint from ADR-010 Amendment 1.
        let fp = fp(r#"
                item = enum,
                name = matches("*Class"),
                variants = 3..=8,
                has_method("meet", "(& self, Self) -> Self")
            "#);
        let canonical = item(
            "
            #[repr(u8)]
            enum DeterminismClass {
                Strong,
                Medium,
                Weak,
            }
        ",
        );
        // No `meet` method here, so the fp does NOT match.
        assert!(!fp.matches(&canonical));

        // Add an impl block separately for the meet test:
        let with_meet = item(
            "
            impl DeterminismClass {
                fn meet(&self, other: Self) -> Self { other }
            }
            ",
        );
        // `with_meet` is item-impl, not item-enum, so item=enum gates it out.
        assert!(!fp.matches(&with_meet));

        // Demonstrate the structural pieces match individually:
        let item_enum_only = fp_only("item = enum");
        let name_only = fp_only(r#"name = matches("*Class")"#);
        let variants_only = fp_only("variants = 3..=8");
        assert!(item_enum_only.matches(&canonical));
        assert!(name_only.matches(&canonical));
        assert!(variants_only.matches(&canonical));
    }

    fn fp_only(src: &str) -> Fingerprint {
        Fingerprint::parse(src).unwrap()
    }

    // FIXED (option 1): normalize_signature_canonical strips parameter names;
    // has_method now matches whether or not the pattern includes named parameters.
    #[test]
    fn has_method_named_parameter_in_pattern_still_matches() {
        // Pattern with named parameter — natural Rust style that users will write.
        let fp = fp(r#"has_method("meet", "(&self, other: Self) -> Self")"#);
        let i = item(
            "impl Foo {
                fn meet(&self, other: Self) -> Self { other }
            }",
        );
        assert!(
            fp.matches(&i),
            "ATK-HM-1: has_method pattern '(&self, other: Self) -> Self' must \
             match 'fn meet(&self, other: Self) -> Self'. \
             render_inputs strips parameter names (yields 'Self', not 'other: Self'), \
             but normalize_signature_canonical preserves the 'other :' token in the \
             pattern. String comparison '(& self , other : Self) -> Self' != \
             '(& self , Self) -> Self' — 0 matches. \
             Fix: strip param names in normalize_signature_canonical before comparison, \
             OR add a validation error when named params are detected in the pattern."
        );
    }

    #[test]
    fn has_method_named_self_type_param_matches() {
        // ATK-HM-2: pattern with a named param whose TYPE is `Self` (no &self receiver).
        // `strip_param_names_in_sig_pattern` wraps as `fn __pat__(x: Self) -> Self` and
        // calls syn::parse2 — in a non-impl context syn may reject `Self` as a param
        // type, causing parse failure and fallback to the raw tokenized form
        // "(x : Self) -> Self". But render_inputs produces "(Self) -> Self" for the
        // actual sig, so the match would fail silently.
        let fp = fp(r#"has_method("clone_self", "(x: Self) -> Self")"#);
        let i = item(
            "impl Foo {
                fn clone_self(x: Self) -> Self { x }
            }",
        );
        assert!(
            fp.matches(&i),
            "ATK-HM-2: pattern '(x: Self) -> Self' must match 'fn clone_self(x: Self) -> Self'. \
             If syn rejects 'fn __pat__(x: Self) -> Self' (Self not valid outside impl/trait), \
             strip_param_names_in_sig_pattern returns None and the fallback tokenized form \
             '(x : Self) -> Self' does not match render_inputs' '(Self) -> Self'. \
             Silent miss — the fallback path does not handle named params with Self types."
        );
    }

    #[test]
    fn has_method_mut_self_receiver_with_named_params_matches() {
        // ATK-HM-3: &mut self receiver + named typed param. Receiver path is
        // symmetric (both strip_param_names and render_inputs use to_token_stream
        // for Receiver). But verify there's no off-by-one or spacing issue.
        let fp = fp(r#"has_method("push", "(&mut self, value: u32)")"#);
        let i = item(
            "impl Stack {
                fn push(&mut self, value: u32) { let _ = value; }
            }",
        );
        assert!(
            fp.matches(&i),
            "ATK-HM-3: pattern '(&mut self, value: u32)' must match \
             'fn push(&mut self, value: u32)'. Receiver should survive unchanged \
             through both strip_param_names and render_inputs; typed param name \
             'value:' must be stripped by strip_param_names_in_sig_pattern."
        );
    }

    #[test]
    fn has_method_named_params_no_self_all_primitive_types_matches() {
        // ATK-HM-4: no self receiver, named params with primitive types.
        // strip_param_names_in_sig_pattern should strip 'a:' and 'b:' leaving only types.
        let fp = fp(r#"has_method("add", "(a: u32, b: u32) -> u32")"#);
        let i = item(
            "impl Calc {
                fn add(a: u32, b: u32) -> u32 { a + b }
            }",
        );
        assert!(
            fp.matches(&i),
            "ATK-HM-4: pattern '(a: u32, b: u32) -> u32' must match \
             'fn add(a: u32, b: u32) -> u32'. strip_param_names_in_sig_pattern \
             must strip both 'a:' and 'b:' leaving '(u32, u32) -> u32'."
        );
    }

    #[test]
    fn has_method_lifetime_param_in_signature_matches() {
        // ATK-HM-5: lifetime in signature pattern.
        // strip_param_names_in_sig_pattern wraps as fn __pat__<'a>(&'a self, x: &'a str) -> &'a str
        // Both the Receiver (&'a self) and the typed param (&'a str) go through to_token_stream.
        // If lifetime breaks the pattern → name strip path → silent miss.
        let fp = fp(r#"has_method("as_str", "(&self, extra: &str) -> &str")"#);
        let i = item(
            "impl Wrapper {
                fn as_str(&self, extra: &str) -> &str { extra }
            }",
        );
        assert!(
            fp.matches(&i),
            "ATK-HM-5: pattern '(&self, extra: &str) -> &str' must match \
             'fn as_str(&self, extra: &str) -> &str'. Named param 'extra:' must \
             be stripped; reference types '&str' should survive normalization."
        );
    }

    // ATK-FP-NOT-BODY-VACUOUS (CLOSED by ADR-010 Amd6 Match3): not(body_contains_macro(X))
    // on a bodyless item-class is no longer vacuously true.
    //
    // Before Match3, body_contains_macro() returned `false` for structs/enums/traits
    // (the `_ => {}` arm), so not(false)=true → all_of([item=struct, not(...)]) matched
    // EVERY struct. The adopter intended "structs without panic!" but got all structs —
    // the fingerprint fired everywhere it was meant to filter.
    //
    // Under Match3, body_contains_macro on a struct returns `Undefined` (no body-locus —
    // the question is malformed here, like an assay run on the wrong tissue). The
    // Kleene-strong algebra gives `not(Undefined) = Undefined`, and
    // `all_of([Match(item=struct), Undefined]) = Undefined`. The Level-2 fingerprint-fires
    // projection maps `Undefined` → doesn't fire. So the fingerprint correctly does NOT
    // match a plain struct: the malformed-here predicate cannot vacuously pass.
    #[test]
    fn not_body_contains_macro_on_struct_is_undefined_not_vacuous() {
        let fp = fp(r#"all_of([item = struct, not(body_contains_macro("panic"))])"#);

        // body_contains_macro on a struct is Undefined; not(Undefined)=Undefined;
        // all_of(Match, Undefined)=Undefined; projection → doesn't fire.
        assert!(
            !fp.matches(&item("pub struct PlainStruct;")),
            "ATK-FP-NOT-BODY-VACUOUS (CLOSED): all_of([item=struct, not(body_contains_macro('panic'))]) \
             must NOT match a plain struct — body_contains_macro on a bodyless item is Undefined, \
             not(Undefined)=Undefined, all_of propagates Undefined, projection → doesn't fire. \
             The vacuous-not hazard is closed at the type level (ADR-010 Amd6)."
        );
        assert!(
            !fp.matches(&item(
                "#[derive(Debug)] pub struct DerivedStruct { x: u32 }"
            )),
            "ATK-FP-NOT-BODY-VACUOUS (CLOSED): a struct with fields must NOT match either — \
             fields are not a fn body, so body_contains_macro is still Undefined and the \
             fingerprint does not fire vacuously."
        );

        // A function that DOES call panic! should NOT match (item=struct gates it: the
        // item-kind leaf is a definite NoMatch, short-circuiting all_of to NoMatch).
        assert!(
            !fp.matches(&item("fn uses_panic() { panic!(\"oops\"); }")),
            "ATK-FP-NOT-BODY-VACUOUS: item=struct in all_of must gate out functions."
        );
    }

    // ATK-FP-NOT-BODY-FN-CORRECT: not(body_contains_macro(X)) on a function
    // correctly excludes functions that use the macro.
    //
    // This confirms the NON-vacuous case works correctly: for fn items,
    // body_contains_macro returns the actual result, so not() is meaningful.
    // all_of([item = fn, not(body_contains_macro("panic"))]) should:
    //   - match functions that don't use panic!
    //   - NOT match functions that use panic!
    #[test]
    fn not_body_contains_macro_on_fn_is_not_vacuous() {
        let fp = fp(r#"all_of([item = fn, not(body_contains_macro("panic"))])"#);

        // Function WITHOUT panic! -- not(false) = true -- correctly matches.
        assert!(
            fp.matches(&item("fn no_panic() { let x = 1; }")),
            "ATK-FP-NOT-BODY-FN-CORRECT: all_of([item=fn, not(body_contains_macro('panic'))]) \
             must match a function that does NOT use panic!."
        );

        // Function WITH panic! -- not(true) = false -- correctly excluded.
        assert!(
            !fp.matches(&item("fn with_panic() { panic!(\"oops\"); }")),
            "ATK-FP-NOT-BODY-FN-CORRECT: all_of([item=fn, not(body_contains_macro('panic'))]) \
             must NOT match a function that uses panic!."
        );
    }

    // ATK-FP-NOT-IN-ANY-OF-REJECTED: not() directly under any_of is a parse error.
    //
    // ADR-010 Amendment 3 OQ3 closes the De Morgan loophole: any_of([not(A), not(B)])
    // is equivalent to not(all_of([A, B])) -- it re-creates top-level negation.
    // The parser rejects not() directly under any_of.
    //
    // This test verifies the rejection happens at parse time (not silently accepted).
    #[test]
    fn not_directly_under_any_of_is_rejected_at_parse_time() {
        let result = crate::Fingerprint::parse(r"any_of([not(item = fn), item = struct])");
        assert!(
            result.is_err(),
            "ATK-FP-NOT-IN-ANY-OF: not() directly under any_of must be rejected at parse time \
             (ADR-010 Amendment 3 OQ3 -- De Morgan loophole). Got: {:?}",
            result
        );
    }

    // ATK-FP-ALL-OF-ONLY-NOTS-REJECTED: all_of with only not() children is rejected.
    //
    // ADR-010 Amendment 3 OQ3 requires at least one positive matcher sibling in
    // any all_of that contains not(). all_of([not(A), not(B)]) is rejected.
    #[test]
    fn all_of_containing_only_nots_is_rejected_at_parse_time() {
        let result =
            crate::Fingerprint::parse(r#"all_of([not(item = fn), not(name = matches("Test*"))])"#);
        assert!(
            result.is_err(),
            "ATK-FP-ALL-OF-ONLY-NOTS: all_of containing only not() children must be \
             rejected (ADR-010 Amendment 3 OQ3 -- requires at least one positive matcher). \
             Got: {:?}",
            result
        );
    }

    // ========================================================================
    // ADR-010 Amendment 6 — Match3 Kleene-strong algebra (Level-1 invariants)
    //
    // The user-facing tests above exercise the Level-2 projection (fires? only
    // if Match). These exercise the Level-1 leaf algebra directly: the spec is
    // explicit that `Undefined` must PROPAGATE through combinators and must NOT
    // collapse to `NoMatch` inside `all_of`. Collapsing it would re-introduce
    // the vacuous-not defect; these tests pin the propagation at the type level.
    // ========================================================================

    use super::{match_constraint, Match3};

    #[test]
    fn match3_not_is_kleene_strong() {
        // not(Undefined) = Undefined — the load-bearing clinical invariant
        // (you cannot negate an indeterminate into a definite, ADR-010 Amd6).
        assert_eq!(Match3::Match.not(), Match3::NoMatch);
        assert_eq!(Match3::NoMatch.not(), Match3::Match);
        assert_eq!(
            Match3::Undefined.not(),
            Match3::Undefined,
            "not(Undefined) must stay Undefined — collapsing it to Match is the \
             vacuous-not defect Match3 exists to kill"
        );
    }

    #[test]
    fn match3_body_predicate_undefined_on_bodyless_definite_on_fn() {
        // body_contains_macro: Undefined on a struct (no locus), definite on a fn.
        let body_fp = crate::Fingerprint::parse(r#"body_contains_macro("panic")"#).unwrap();
        let c = &body_fp.constraints[0];

        assert_eq!(
            match_constraint(c, &item("pub struct S;")),
            Match3::Undefined,
            "body_contains_macro on a bodyless struct must be Undefined, not NoMatch"
        );
        assert_eq!(
            match_constraint(c, &item("fn uses() { panic!(); }")),
            Match3::Match,
            "body_contains_macro on a fn that calls the macro must be Match"
        );
        assert_eq!(
            match_constraint(c, &item("fn clean() { let _ = 1; }")),
            Match3::NoMatch,
            "body_contains_macro on a fn that does NOT call the macro must be NoMatch (definite)"
        );
    }

    #[test]
    fn match3_undefined_propagates_through_all_of_not_collapsing() {
        // all_of([item=struct, body_contains_macro("panic")]) on a struct:
        // item=struct is Match (definite), body_contains_macro is Undefined.
        // all_of(Match, Undefined) MUST be Undefined (definedness gap preserved),
        // NOT NoMatch (which would collapse the type-level distinction).
        let fp =
            crate::Fingerprint::parse(r#"all_of([item = struct, body_contains_macro("panic")])"#)
                .unwrap();
        assert_eq!(
            match_constraint(&fp.constraints[0], &item("pub struct S;")),
            Match3::Undefined,
            "all_of(Match, Undefined) must propagate Undefined, not collapse to NoMatch"
        );

        // A definite NoMatch sibling DOES short-circuit all_of to NoMatch:
        // item=fn is NoMatch on a struct → the whole all_of is NoMatch (definite
        // failure dominates the undefined gap).
        let fp2 = crate::Fingerprint::parse(r#"all_of([item = fn, body_contains_macro("panic")])"#)
            .unwrap();
        assert_eq!(
            match_constraint(&fp2.constraints[0], &item("pub struct S;")),
            Match3::NoMatch,
            "a definite NoMatch sibling must short-circuit all_of to NoMatch"
        );
    }

    #[test]
    fn match3_undefined_propagates_through_any_of_not_collapsing() {
        // any_of([item=fn, body_contains_macro("panic")]) on a struct:
        // item=fn is NoMatch (definite), body_contains_macro is Undefined.
        // any_of(NoMatch, Undefined) MUST be Undefined, NOT NoMatch.
        let fp = crate::Fingerprint::parse(r#"any_of([item = fn, body_contains_macro("panic")])"#)
            .unwrap();
        assert_eq!(
            match_constraint(&fp.constraints[0], &item("pub struct S;")),
            Match3::Undefined,
            "any_of(NoMatch, Undefined) must propagate Undefined, not collapse to NoMatch"
        );

        // A definite Match sibling DOES short-circuit any_of to Match.
        let fp2 =
            crate::Fingerprint::parse(r#"any_of([item = struct, body_contains_macro("panic")])"#)
                .unwrap();
        assert_eq!(
            match_constraint(&fp2.constraints[0], &item("pub struct S;")),
            Match3::Match,
            "a definite Match sibling must short-circuit any_of to Match"
        );
    }
}
