//! Local MCP ecosystem corpus: ingest, normalize, select (WOR-1427).
//!
//! mcptest can test a target once you point it at a server, but it does not by
//! itself understand the wider public MCP ecosystem. This module turns offline
//! exports (an MCPCorpus dump, an official-registry server list, a curated repo
//! list) into one deterministic, diffable corpus manifest, then lets a
//! developer carve a stable fixture pack out of it with [`select`].
//!
//! Everything here is offline and pure: no network, no telemetry, no account.
//! Cross-developer aggregation and continuous public scorecards stay in
//! `mcptest-enterprise` (ADR-019); this module is the OSS local substrate they
//! build on, not a replacement for them.

mod import;
mod model;
mod select;

pub use import::{build_corpus, ImportError, ImportInput, SourceKind};
pub use model::{
    normalize_url, EcosystemCorpus, EcosystemEntry, Maintenance, ProjectRole, Provenance,
    SourceRecord, TransportKind, CORPUS_SCHEMA_VERSION,
};
pub use select::{select, FixturePack, SelectFilter};

#[cfg(test)]
mod tests {
    use super::*;

    fn mcpcorpus_json() -> &'static str {
        r#"[
            {"name": "weather", "url": "https://github.com/acme/weather", "type": "server",
             "transport": "stdio", "language": "Python", "tool_count": 4, "license": "MIT",
             "maintenance": "active", "categories": ["weather", "demo"]},
            {"name": "Weather", "repo": "https://github.com/acme/weather.git", "type": "server",
             "language": "python", "auth": "none"},
            {"full_name": "files-server", "html_url": "http://github.com/acme/files",
             "transport": "streamable-http", "tools": ["read", "write"], "archived": true},
            {"junk": "no name and no url"}
        ]"#
    }

    fn input(kind: SourceKind, json: &str) -> ImportInput {
        ImportInput {
            kind,
            source_ref: "test".to_string(),
            bytes: json.as_bytes().to_vec(),
        }
    }

    #[test]
    fn normalizes_mcpcorpus_records_into_entries() {
        let corpus = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        // weather (deduped from two records) + files-server = 2 entries.
        assert_eq!(corpus.entries.len(), 2);
        let weather = corpus
            .entries
            .iter()
            .find(|e| e.name == "weather")
            .expect("weather present");
        assert_eq!(weather.transport, TransportKind::Stdio);
        assert_eq!(weather.tool_count, Some(4));
        // The duplicate record enriched the blank auth_mode field.
        assert_eq!(weather.auth_mode.as_deref(), Some("none"));
    }

    #[test]
    fn deduplicates_by_normalized_url_across_casing_and_git_suffix() {
        let corpus = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        let weather_count = corpus
            .entries
            .iter()
            .filter(|e| e.key().contains("weather"))
            .count();
        assert_eq!(
            weather_count, 1,
            "casing and .git must collapse to one entry"
        );
    }

    #[test]
    fn records_invalid_entries_in_skipped_rather_than_failing() {
        let corpus = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        assert_eq!(corpus.skipped.len(), 1);
    }

    #[test]
    fn http_transport_label_normalizes() {
        let corpus = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        let files = corpus
            .entries
            .iter()
            .find(|e| e.name == "files-server")
            .unwrap();
        assert_eq!(files.transport, TransportKind::Http);
        assert_eq!(files.tool_count, Some(2));
        assert_eq!(files.maintenance, Maintenance::Archived);
    }

    #[test]
    fn output_is_reproducible_for_the_same_input() {
        let a = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        let b = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        assert_eq!(a.content_sha256, b.content_sha256);
        assert_eq!(
            serde_json::to_string(&a).unwrap(),
            serde_json::to_string(&b).unwrap()
        );
    }

    #[test]
    fn parses_registry_servers_wrapper() {
        let json = r#"{"servers": [
            {"name": "io.github.acme/db", "repository": {"url": "https://github.com/acme/db"},
             "packages": [{"identifier": "acme-db"}], "id": "reg-1"}
        ]}"#;
        let corpus = build_corpus(&[input(SourceKind::Registry, json)]).unwrap();
        assert_eq!(corpus.entries.len(), 1);
        let db = &corpus.entries[0];
        assert_eq!(db.registry_id.as_deref(), Some("reg-1"));
        assert_eq!(db.transport, TransportKind::Stdio);
    }

    #[test]
    fn parses_newline_repo_list() {
        let list = "# curated\nhttps://github.com/acme/one\nhttps://github.com/acme/two\n";
        let corpus = build_corpus(&[input(SourceKind::Repos, list)]).unwrap();
        assert_eq!(corpus.entries.len(), 2);
        assert!(corpus.entries.iter().any(|e| e.name == "one"));
    }

    #[test]
    fn non_array_source_is_a_clear_error() {
        let err = build_corpus(&[input(SourceKind::McpCorpus, "{\"not\": \"an array\"}")]);
        assert!(matches!(err, Err(ImportError::NotArray(_))));
    }

    #[test]
    fn select_filters_and_limits_deterministically() {
        let corpus = build_corpus(&[input(SourceKind::McpCorpus, mcpcorpus_json())]).unwrap();
        let filter = SelectFilter {
            transport: Some(TransportKind::Stdio),
            ..Default::default()
        };
        let pack = select(&corpus, &filter, Some(10));
        assert_eq!(pack.entries.len(), 1);
        assert_eq!(pack.entries[0].name, "weather");
        assert!(pack.filter_summary.contains("Stdio"));
    }
}
