//! Test profile selector.
//!
//! A profile is a named bundle of compliance rule IDs. Users pick a profile
//! ("quick", "standard", "full", "enterprise") instead of stringing together
//! include and exclude filters. This module owns the data model and the
//! pure resolver function. CLI wiring lives in a follow-up ticket so this
//! crate stays transport-free and the resolver is callable from a future
//! Python or JavaScript host (see ADR 0018).
//!
//! ## Profile shape
//!
//! | Profile    | Scope                                                              |
//! | ---------- | ------------------------------------------------------------------ |
//! | quick      | lifecycle MUSTs plus 5 curated SHOULD checks (dev-loop)            |
//! | standard   | every MUST and SHOULD in the registry (CI gate)                    |
//! | full       | every MUST, SHOULD, and MAY (pre-release audit)                    |
//! | enterprise | full plus auth and sandbox and multi-server scenarios (deferred)   |
//!
//! The four built-ins satisfy the subset invariants
//! `quick subset_of standard subset_of full subset_of enterprise`, which the
//! tests pin. Future custom profiles loaded from `.mcptest.toml` will reuse
//! the same [`Profile::resolve`] entry point through a `Profile::Custom`
//! variant; that wiring is out of scope here and lives with .
//!
//! ## Why pure-function resolution
//!
//! The resolver consumes a [`RuleRegistry`] reference and returns an owned
//! [`BTreeSet<String>`] of rule IDs. No filesystem, no async, no logging.
//! The runner crate composes this with whatever I/O it needs. Keeping the
//! shape pure lets the rubric tests run in microseconds and avoids feature
//! drift between embeddings.
//!
//! ## Curated quick SHOULDs
//!
//! The five SHOULD rule IDs that join the lifecycle MUSTs in the quick
//! profile are listed in [`QUICK_CRITICAL_SHOULDS`]. They are picked for the
//! developer feedback loop: ping latency, lifecycle ordering, lifecycle
//! shutdown edge case, tool description quality, and the tools sequencing
//! contract. If any of the listed IDs disappear from the registry the
//! resolver surfaces a typed [`ProfileError::UnknownRule`] instead of
//! panicking.

use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::compliance::registry::{RuleRegistry, RuleSeverity};

/// Named bundle of rule IDs the runner should execute.
///
/// `Profile` is `Copy` because each variant carries no payload and the
/// resolver borrows the registry by reference. Custom user-defined
/// profiles will land as a separate variant on a future revision
/// of this enum, at which point `Copy` goes away.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Profile {
    /// Lifecycle MUSTs plus five curated SHOULDs. Optimized for the
    /// developer feedback loop and watch mode (under two seconds).
    Quick,
    /// Every MUST plus every SHOULD in the registry. Default for CI gates
    /// on every pull request.
    Standard,
    /// Every MUST plus every SHOULD plus every MAY. Pre-release audit and
    /// grade calibration.
    Full,
    /// Full plus auth, sandbox, and multi-server scenarios. The extra
    /// categories live behind dedicated rule prefixes that are not yet
    /// authored, so today this resolves to the same set as `Full`. The
    /// distinct variant exists so the public surface is stable when the
    /// deferred rules ship.
    Enterprise,
}

/// SHOULD rules that join the lifecycle MUSTs in the quick profile.
///
/// Five IDs picked for the dev-loop: each one fires fast, exercises a
/// foundational behavior, and tends to catch regressions during local
/// iteration. The set is deliberately small to keep the watch-mode
/// runtime well under two seconds.
pub const QUICK_CRITICAL_SHOULDS: &[&str] = &[
    "PROTO-003", // ping latency
    "SEQ-002",   // lifecycle ordering sequence
    "EDGE-006",  // lifecycle shutdown edge case
    "TOOL-002",  // tool description quality
    "SEQ-003",   // tools sequencing contract
];

/// Errors raised by the profile resolver.
///
/// Distinct from [`crate::Error`] so callers can match on profile-specific
/// failures without pulling the whole core error enum into scope. The CLI
/// boundary converts these to `anyhow::Error` like every other error in the
/// crate.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ProfileError {
    /// A profile referenced a rule ID that the registry does not know
    /// about. Surfaces typed (not a panic) so a corpus rename does not
    /// crash a runner mid-sweep.
    #[error("profile references unknown rule id: {0}")]
    UnknownRule(String),

    /// The resolver produced an empty set for a profile that should never
    /// be empty. Guards against a corrupt registry quietly running zero
    /// checks and reporting success.
    #[error("profile {profile} resolved to an empty rule set")]
    EmptyResolution {
        /// Name of the profile that resolved empty.
        profile: &'static str,
    },
}

impl Profile {
    /// Short ASCII label used by reporters and the JSON wire shape.
    pub fn as_str(self) -> &'static str {
        match self {
            Profile::Quick => "quick",
            Profile::Standard => "standard",
            Profile::Full => "full",
            Profile::Enterprise => "enterprise",
        }
    }

    /// Every built-in profile, in dev-loop to enterprise order.
    ///
    /// Useful for `mcptest profiles list` (future CLI ticket) and for the
    /// subset-invariant tests in this module. Order is stable so callers
    /// can index into it without parsing variant names.
    pub fn all() -> &'static [Profile] {
        &[
            Profile::Quick,
            Profile::Standard,
            Profile::Full,
            Profile::Enterprise,
        ]
    }

    /// Resolve the profile against the registry, returning the rule IDs
    /// that should execute.
    ///
    /// Returns a [`BTreeSet`] so callers iterate in deterministic order and
    /// can cheaply intersect or diff two resolutions. Errors when a curated
    /// ID (currently only [`QUICK_CRITICAL_SHOULDS`]) is missing from the
    /// registry, and when the resolution is empty.
    pub fn resolve(self, registry: &RuleRegistry) -> Result<BTreeSet<String>, ProfileError> {
        let ids = match self {
            Profile::Quick => resolve_quick(registry)?,
            Profile::Standard => resolve_by_severity(registry, includes_should),
            Profile::Full => resolve_by_severity(registry, includes_may),
            Profile::Enterprise => resolve_enterprise(registry),
        };

        if ids.is_empty() {
            return Err(ProfileError::EmptyResolution {
                profile: self.as_str(),
            });
        }
        Ok(ids)
    }
}

/// Quick = every lifecycle MUST plus the curated SHOULDs.
///
/// Lifecycle is identified by the rubric `section` field on each rule.
/// Curated SHOULDs are looked up by ID; an unknown ID surfaces as a typed
/// [`ProfileError::UnknownRule`] so the runner can tell the operator
/// exactly which curated rule rotted out of the registry.
fn resolve_quick(registry: &RuleRegistry) -> Result<BTreeSet<String>, ProfileError> {
    let mut ids: BTreeSet<String> = registry
        .rules
        .iter()
        .filter(|r| r.severity == RuleSeverity::Must && r.section == "lifecycle")
        .map(|r| r.id.clone())
        .collect();

    for curated in QUICK_CRITICAL_SHOULDS {
        match registry.get(curated) {
            Some(rule) => {
                ids.insert(rule.id.clone());
            }
            None => return Err(ProfileError::UnknownRule((*curated).to_string())),
        }
    }
    Ok(ids)
}

/// Standard / full helper: collect every rule whose severity matches the
/// predicate. Standard takes MUST and SHOULD; full takes all three.
fn resolve_by_severity(
    registry: &RuleRegistry,
    predicate: fn(RuleSeverity) -> bool,
) -> BTreeSet<String> {
    registry
        .rules
        .iter()
        .filter(|r| predicate(r.severity))
        .map(|r| r.id.clone())
        .collect()
}

/// Enterprise resolves to the full set today. The deferred categories
/// (auth, sandbox, multi-server) will join here once their rule prefixes
/// are authored under `compliance/` (see  follow-ups). We keep the
/// enum variant distinct so adding those rules does not break the public
/// API.
fn resolve_enterprise(registry: &RuleRegistry) -> BTreeSet<String> {
    resolve_by_severity(registry, includes_may)
}

/// Standard predicate: MUST or SHOULD.
fn includes_should(severity: RuleSeverity) -> bool {
    matches!(severity, RuleSeverity::Must | RuleSeverity::Should)
}

/// Full / enterprise predicate: any severity.
fn includes_may(severity: RuleSeverity) -> bool {
    matches!(
        severity,
        RuleSeverity::Must | RuleSeverity::Should | RuleSeverity::May
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compliance::registry::RuleMetadata;

    fn metadata(id: &str, category: &str, severity: RuleSeverity, section: &str) -> RuleMetadata {
        RuleMetadata {
            id: id.to_string(),
            name: format!("{id} name"),
            category: category.to_string(),
            severity,
            applies_to: vec!["stdio".to_string()],
            spec_versions: vec!["v2024-11-05".to_string()],
            description: "desc".to_string(),
            references: vec![],
            section: section.to_string(),
            applicability: "true".to_string(),
            spec_citation: "MCP spec".to_string(),
        }
    }

    /// Build an in-memory registry that contains every curated quick ID so
    /// the dev-loop profile can resolve. Severities are chosen so the
    /// subset invariants are non-trivial.
    fn fixture_registry() -> RuleRegistry {
        RuleRegistry {
            version: 1,
            rules: vec![
                // Lifecycle MUSTs are the spine of quick.
                metadata("PROTO-001", "PROTO", RuleSeverity::Must, "lifecycle"),
                metadata("PROTO-002", "PROTO", RuleSeverity::Must, "lifecycle"),
                // Curated SHOULDs that join in the quick profile.
                metadata("PROTO-003", "PROTO", RuleSeverity::Should, "lifecycle"),
                metadata("SEQ-002", "SEQ", RuleSeverity::Should, "lifecycle"),
                metadata("EDGE-006", "EDGE", RuleSeverity::Should, "lifecycle"),
                metadata("TOOL-002", "TOOL", RuleSeverity::Should, "tools"),
                metadata("SEQ-003", "SEQ", RuleSeverity::Should, "tools"),
                // Other MUSTs and SHOULDs that join standard, plus a MAY.
                metadata("TOOL-003", "TOOL", RuleSeverity::Must, "tools"),
                metadata("RES-002", "RES", RuleSeverity::Should, "resources"),
                metadata("PROTO-006", "PROTO", RuleSeverity::May, "transport-auth"),
            ],
        }
    }

    fn registry_missing_curated() -> RuleRegistry {
        RuleRegistry {
            version: 1,
            rules: vec![metadata(
                "PROTO-001",
                "PROTO",
                RuleSeverity::Must,
                "lifecycle",
            )],
        }
    }

    #[test]
    fn each_profile_resolves_to_a_non_empty_set() {
        let registry = fixture_registry();
        for profile in Profile::all() {
            let resolved = profile
                .resolve(&registry)
                .unwrap_or_else(|e| panic!("{profile:?} failed to resolve: {e}"));
            assert!(
                !resolved.is_empty(),
                "{profile:?} resolved to an empty rule set"
            );
        }
    }

    #[test]
    fn quick_is_a_subset_of_standard() {
        let registry = fixture_registry();
        let quick = Profile::Quick.resolve(&registry).unwrap();
        let standard = Profile::Standard.resolve(&registry).unwrap();
        for id in &quick {
            assert!(
                standard.contains(id),
                "quick rule {id} missing from standard"
            );
        }
    }

    #[test]
    fn standard_is_a_subset_of_full() {
        let registry = fixture_registry();
        let standard = Profile::Standard.resolve(&registry).unwrap();
        let full = Profile::Full.resolve(&registry).unwrap();
        for id in &standard {
            assert!(full.contains(id), "standard rule {id} missing from full");
        }
    }

    #[test]
    fn full_is_a_subset_of_enterprise() {
        let registry = fixture_registry();
        let full = Profile::Full.resolve(&registry).unwrap();
        let enterprise = Profile::Enterprise.resolve(&registry).unwrap();
        for id in &full {
            assert!(
                enterprise.contains(id),
                "full rule {id} missing from enterprise"
            );
        }
    }

    #[test]
    fn quick_includes_every_lifecycle_must() {
        let registry = fixture_registry();
        let quick = Profile::Quick.resolve(&registry).unwrap();
        assert!(quick.contains("PROTO-001"));
        assert!(quick.contains("PROTO-002"));
    }

    #[test]
    fn quick_includes_every_curated_should() {
        let registry = fixture_registry();
        let quick = Profile::Quick.resolve(&registry).unwrap();
        for curated in QUICK_CRITICAL_SHOULDS {
            assert!(
                quick.contains(*curated),
                "quick is missing curated SHOULD {curated}"
            );
        }
    }

    #[test]
    fn quick_excludes_unrelated_shoulds_and_mays() {
        let registry = fixture_registry();
        let quick = Profile::Quick.resolve(&registry).unwrap();
        // RES-002 is a SHOULD that is not in the curated list and is not
        // a lifecycle MUST. PROTO-006 is a MAY. Neither belongs in quick.
        assert!(!quick.contains("RES-002"));
        assert!(!quick.contains("PROTO-006"));
    }

    #[test]
    fn standard_includes_must_and_should_but_not_may() {
        let registry = fixture_registry();
        let standard = Profile::Standard.resolve(&registry).unwrap();
        assert!(standard.contains("PROTO-001"));
        assert!(standard.contains("RES-002"));
        assert!(!standard.contains("PROTO-006"));
    }

    #[test]
    fn full_includes_every_severity() {
        let registry = fixture_registry();
        let full = Profile::Full.resolve(&registry).unwrap();
        assert!(full.contains("PROTO-001"));
        assert!(full.contains("RES-002"));
        assert!(full.contains("PROTO-006"));
    }

    #[test]
    fn enterprise_matches_full_until_deferred_rules_ship() {
        // Until  follow-ups add auth and sandbox rule prefixes, the
        // enterprise profile is intentionally equal to full. Locking this
        // contract here surfaces an accidental drift the moment the
        // deferred rules land.
        let registry = fixture_registry();
        let full = Profile::Full.resolve(&registry).unwrap();
        let enterprise = Profile::Enterprise.resolve(&registry).unwrap();
        assert_eq!(full, enterprise);
    }

    #[test]
    fn missing_curated_rule_surfaces_as_typed_error_not_panic() {
        // Acceptance criterion: when a curated SHOULD id is not in the
        // registry, the resolver returns ProfileError::UnknownRule rather
        // than panicking. We expect the first curated id (PROTO-003) to
        // be the one reported since the loop is order-preserving.
        let registry = registry_missing_curated();
        let err = Profile::Quick.resolve(&registry).unwrap_err();
        assert_eq!(err, ProfileError::UnknownRule("PROTO-003".to_string()));
    }

    #[test]
    fn empty_registry_resolves_to_typed_error_for_standard() {
        let registry = RuleRegistry {
            version: 1,
            rules: vec![],
        };
        let err = Profile::Standard.resolve(&registry).unwrap_err();
        assert!(matches!(err, ProfileError::EmptyResolution { .. }));
    }

    #[test]
    fn profile_serializes_as_lowercase_string() {
        // Reporters and the JSON wire shape want lowercase ASCII names
        // (matches the CLI flag values picked in the follow-up ticket).
        let yaml = serde_yaml::to_string(&Profile::Standard).unwrap();
        assert!(yaml.contains("standard"), "got: {yaml}");
    }

    #[test]
    fn all_returns_every_built_in_profile_in_stable_order() {
        let profiles = Profile::all();
        assert_eq!(profiles.len(), 4);
        assert_eq!(profiles[0], Profile::Quick);
        assert_eq!(profiles[3], Profile::Enterprise);
    }

    #[test]
    fn as_str_matches_serde_name() {
        for profile in Profile::all() {
            let yaml = serde_yaml::to_string(profile).unwrap();
            assert!(
                yaml.contains(profile.as_str()),
                "as_str {} did not match yaml {yaml}",
                profile.as_str()
            );
        }
    }

    #[test]
    fn resolves_against_the_repo_registry() {
        // Smoke test against the shipped corpus: every built-in profile
        // resolves without error and produces a non-empty set. Locks in
        // that the curated SHOULDs still exist in the real registry.
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|p| p.parent())
            .map(|root| root.join("compliance").join("registry.yml"))
            .expect("workspace root above crates/mcptest-core");
        let registry = RuleRegistry::load(&path).expect("registry parses");
        for profile in Profile::all() {
            let resolved = profile
                .resolve(&registry)
                .unwrap_or_else(|e| panic!("{profile:?} failed on repo registry: {e}"));
            assert!(!resolved.is_empty());
        }
    }
}
