//! Data model for the local MCP ecosystem corpus (WOR-1427).
//!
//! The corpus is a deterministic, diffable snapshot of public MCP project
//! metadata (MCPCorpus exports, the official registry, curated repo lists)
//! normalized into one shape. It never phones home: every field comes from a
//! file the user passed in, and every entry records where it came from so a
//! reader can audit provenance.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// Schema version stamped on every generated corpus manifest.
///
/// Bump when the on-disk shape changes so older manifests fail loudly rather
/// than silently misparse.
pub const CORPUS_SCHEMA_VERSION: &str = "mcptest.sh/ecosystem/v1";

/// Whether a project acts as an MCP server, a client, or is unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectRole {
    /// Exposes tools/resources/prompts over MCP.
    Server,
    /// Consumes MCP servers (an agent host, an inspector, an IDE).
    Client,
    /// The source did not say.
    #[default]
    Unknown,
}

/// The transport a server speaks, normalized across source spellings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TransportKind {
    /// Local stdio subprocess.
    Stdio,
    /// Streamable HTTP.
    Http,
    /// Legacy HTTP+SSE.
    Sse,
    /// The source did not say.
    #[default]
    Unknown,
}

impl TransportKind {
    /// Map a free-text transport label onto the normalized enum.
    ///
    /// Source datasets spell the same transport many ways ("streamable-http",
    /// "http", "sse", "stdio/local"); this collapses them so filters work.
    pub fn from_label(raw: &str) -> Self {
        let lower = raw.trim().to_ascii_lowercase();
        if lower.contains("stdio") || lower == "local" {
            TransportKind::Stdio
        } else if lower.contains("sse") {
            TransportKind::Sse
        } else if lower.contains("http") || lower.contains("streamable") || lower.contains("remote")
        {
            TransportKind::Http
        } else {
            TransportKind::Unknown
        }
    }
}

/// A coarse maintenance signal, derived from whatever recency the source gave.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Maintenance {
    /// Recent activity (a commit within the source's freshness window).
    Active,
    /// Stale: no recent activity reported.
    Dormant,
    /// Explicitly archived upstream.
    Archived,
    /// The source did not say.
    #[default]
    Unknown,
}

/// Where a single entry was imported from, for audit and reproducibility.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Provenance {
    /// The importer that produced this entry (`mcpcorpus`, `registry`, `repos`).
    pub source_kind: String,
    /// The file or label the entry was read from.
    pub source_ref: String,
}

/// One normalized MCP project in the corpus.
///
/// Every optional field is `None` when the source omitted it; the corpus never
/// invents data. Entries sort by [`Self::key`] so the manifest is stable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EcosystemEntry {
    /// Human-readable project name.
    pub name: String,
    /// Canonical source URL (a GitHub repo, usually).
    pub source_url: String,
    /// Registry identifier when the entry came from a registry export.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub registry_id: Option<String>,
    /// Server, client, or unknown.
    pub role: ProjectRole,
    /// Normalized transport.
    pub transport: TransportKind,
    /// Implementation language, lowercased, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    /// Auth mode label (oauth, bearer, none, ...) when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub auth_mode: Option<String>,
    /// Number of advertised tools, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_count: Option<u32>,
    /// Number of advertised resources, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resource_count: Option<u32>,
    /// Number of advertised prompts, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prompt_count: Option<u32>,
    /// SPDX license id, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    /// Maintenance signal.
    pub maintenance: Maintenance,
    /// Free-text security caveats carried from the source (sorted, deduped).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub security_caveats: Vec<String>,
    /// Category tags carried from the source (sorted, deduped).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub categories: Vec<String>,
    /// Where this entry was imported from.
    pub provenance: Provenance,
}

impl EcosystemEntry {
    /// The dedup/sort key: normalized source URL when present, else name.
    ///
    /// Two imports of the same repo collapse to one entry even if the casing
    /// or a trailing slash differs.
    pub fn key(&self) -> String {
        normalize_url(&self.source_url).unwrap_or_else(|| self.name.to_ascii_lowercase())
    }

    /// Whether this entry is usable as a fixture: it needs a real source URL.
    pub fn is_fixture_ready(&self) -> bool {
        !self.source_url.trim().is_empty()
    }
}

/// A record of one imported source file, with a content hash for reproducibility.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRecord {
    /// The importer kind (`mcpcorpus`, `registry`, `repos`).
    pub kind: String,
    /// The file or label imported.
    pub source_ref: String,
    /// SHA-256 of the raw source bytes, hex-encoded.
    pub content_sha256: String,
    /// How many entries this source contributed after normalization.
    pub entry_count: usize,
}

/// The full local corpus manifest: a stable, diffable set of entries.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EcosystemCorpus {
    /// Schema version (see [`CORPUS_SCHEMA_VERSION`]).
    pub schema_version: String,
    /// The imported sources, in import order.
    pub sources: Vec<SourceRecord>,
    /// Normalized entries, sorted by [`EcosystemEntry::key`].
    pub entries: Vec<EcosystemEntry>,
    /// Names of entries that were dropped, with a one-line reason each.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skipped: Vec<String>,
    /// SHA-256 over the canonical JSON of `entries`, for change detection.
    pub content_sha256: String,
}

/// Normalize a URL for dedup: lowercase host/path, strip scheme, `.git`,
/// trailing slash. Returns `None` for an empty string.
pub fn normalize_url(raw: &str) -> Option<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return None;
    }
    let no_scheme = trimmed
        .strip_prefix("https://")
        .or_else(|| trimmed.strip_prefix("http://"))
        .or_else(|| trimmed.strip_prefix("git+https://"))
        .unwrap_or(trimmed);
    let lowered = no_scheme.to_ascii_lowercase();
    let trimmed = lowered.trim_end_matches('/');
    let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
    Some(trimmed.to_string())
}

/// Sort, dedup, and tidy a vector of entries in place.
///
/// Later entries lose to earlier ones on a key collision but contribute any
/// fields the winner left blank, so a registry import can enrich a curated one.
pub(crate) fn merge_entries(raw: Vec<EcosystemEntry>) -> (Vec<EcosystemEntry>, Vec<String>) {
    let mut by_key: BTreeMap<String, EcosystemEntry> = BTreeMap::new();
    let mut skipped = Vec::new();
    for mut entry in raw {
        if entry.name.trim().is_empty() && entry.source_url.trim().is_empty() {
            skipped.push("entry with no name and no source url".to_string());
            continue;
        }
        for caveat in entry.security_caveats.iter_mut() {
            *caveat = caveat.trim().to_string();
        }
        entry.security_caveats.sort();
        entry.security_caveats.dedup();
        entry.categories.sort();
        entry.categories.dedup();
        let key = entry.key();
        match by_key.get_mut(&key) {
            Some(existing) => enrich(existing, entry),
            None => {
                by_key.insert(key, entry);
            }
        }
    }
    (by_key.into_values().collect(), skipped)
}

/// Fill blank fields on `existing` from a duplicate `other`.
fn enrich(existing: &mut EcosystemEntry, other: EcosystemEntry) {
    if existing.registry_id.is_none() {
        existing.registry_id = other.registry_id;
    }
    if existing.transport == TransportKind::Unknown {
        existing.transport = other.transport;
    }
    if existing.role == ProjectRole::Unknown {
        existing.role = other.role;
    }
    if existing.language.is_none() {
        existing.language = other.language;
    }
    if existing.auth_mode.is_none() {
        existing.auth_mode = other.auth_mode;
    }
    if existing.tool_count.is_none() {
        existing.tool_count = other.tool_count;
    }
    if existing.license.is_none() {
        existing.license = other.license;
    }
    if existing.maintenance == Maintenance::Unknown {
        existing.maintenance = other.maintenance;
    }
    for caveat in other.security_caveats {
        if !existing.security_caveats.contains(&caveat) {
            existing.security_caveats.push(caveat);
        }
    }
    existing.security_caveats.sort();
    for category in other.categories {
        if !existing.categories.contains(&category) {
            existing.categories.push(category);
        }
    }
    existing.categories.sort();
}
