//! Conformance-corpus loader.
//!
//! Loads a vendored corpus of SEP YAMLs + `traceability.json` into
//! typed structs. Resolves the corpus directory according to the
//! `docs/conformance-cli.md` precedence: explicit override wins,
//! else the XDG user cache, else the embedded fallback baked into
//! the binary at compile time via `include_dir!`.
//!
//! The loader is read-only: it never writes to disk. The
//! `conformance refresh` subcommand writes to the same directory the
//! loader reads from; the two operations share the path-resolution
//! rule but the loader does not depend on the refresh code.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

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

/// Embedded fallback corpus.
///
/// Baked at compile time from the crate-local `seps/` directory (kept in
/// crate so `cargo publish` packages it; an out-of-crate include_dir! path
/// fails the publish verify build). Refreshed from upstream by
/// `scripts/refresh-conformance-corpus.sh`. A `cargo install mcptest` user
/// gets the same SEP shapes the source clone has, so the `run` subcommand
/// works offline on first launch.
pub static EMBEDDED_CORPUS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/seps");

/// Where the corpus loader picked up its data.
///
/// Surfaced on the run report's `corpus_source` field so a reader
/// can tell whether the run used a freshly refreshed cache, the
/// embedded fallback, or an explicit `--corpus-dir`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CorpusSource {
    /// The user supplied `--corpus-dir`; the loader read from there.
    Override,
    /// The loader read from the XDG user cache (a previous `refresh`
    /// landed there).
    Cache,
    /// Neither override nor cache were present; the loader fell back
    /// to the corpus baked into the binary.
    Embedded,
}

/// One requirement entry as parsed off `traceability.json`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Requirement {
    /// Optional check id when the requirement is wire-observable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub check: Option<String>,
    /// `tested` or `excluded`; `tested` is implied when `check` is
    /// present and `status` is missing.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// The literal text from the spec.
    #[serde(default)]
    pub text: String,
}

/// One SEP entry: the `requirements` array plus the `excluded`
/// array from `traceability.json`. Models the per-SEP shape in
/// upstream's traceability manifest.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SepEntry {
    /// Numeric SEP id parsed from the surrounding key (`"837"`).
    pub sep: String,
    /// Path the manifest pointed at (`"src/seps/sep-837.yaml"`).
    pub yaml: String,
    /// Optional spec URL the manifest carried.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spec_url: Option<String>,
    /// Requirement entries with a `check:` id (tested).
    #[serde(default)]
    pub requirements: Vec<Requirement>,
    /// Requirement entries without a `check:` id (excluded by design).
    #[serde(default)]
    pub excluded: Vec<Requirement>,
}

/// Top-level traceability shape (`traceability.json`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Traceability {
    /// File schema version.
    pub schema_version: u32,
    /// Documentation URL the manifest points at.
    #[serde(default)]
    pub docs: String,
    /// Source SDK + version (informational).
    #[serde(default)]
    pub source: String,
    /// Per-SEP entries keyed by SEP id (string form).
    pub seps: BTreeMap<String, SepRaw>,
}

/// Raw `seps[<id>]` value as serde sees it. We re-shape into
/// `SepEntry` (id + yaml + requirements + excluded) once we know
/// which key the entry was under.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SepRaw {
    /// Path the manifest pointed at.
    pub yaml: String,
    /// Optional spec URL.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spec_url: Option<String>,
    /// Requirements (each either tested or excluded).
    #[serde(default)]
    pub requirements: Vec<Requirement>,
    /// Requirements marked excluded out of band.
    #[serde(default)]
    pub excluded: Vec<Requirement>,
}

/// Loaded corpus for one spec version. The loader returns this
/// after resolving the directory + parsing the manifest.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoadedCorpus {
    /// Spec version this corpus covers (`"2026-07-28-rc"`).
    pub spec_version: String,
    /// Where the corpus was read from.
    pub source: CorpusSource,
    /// Per-SEP entries in id order.
    pub seps: Vec<SepEntry>,
}

/// Reasons the loader refused to produce a corpus.
#[derive(Debug, Error)]
pub enum CorpusError {
    /// I/O failed while reading a corpus file.
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    /// `traceability.json` failed to parse.
    #[error("could not parse traceability.json: {0}")]
    BadTraceability(#[from] serde_json::Error),
    /// The requested spec version was not present in the resolved
    /// corpus location. Surfaced with a pointer to
    /// `mcptest conformance refresh`.
    #[error("spec version `{requested}` not present in corpus at {searched}")]
    VersionNotFound {
        /// What the user asked for.
        requested: String,
        /// One-line summary of where the loader looked.
        searched: String,
    },
    /// The resolved corpus location had no spec version directories.
    #[error("no spec versions found in corpus at {searched}")]
    Empty {
        /// One-line summary of where the loader looked.
        searched: String,
    },
}

/// Resolve the corpus directory according to the spec's precedence
/// rule. `override_dir` wins; else `cache_dir` (when it exists);
/// else `None` so the caller falls back to the embedded corpus.
pub fn resolve_corpus_dir(
    override_dir: Option<&Path>,
    cache_dir: Option<&Path>,
) -> Option<(PathBuf, CorpusSource)> {
    if let Some(path) = override_dir {
        return Some((path.to_path_buf(), CorpusSource::Override));
    }
    if let Some(path) = cache_dir {
        if path.is_dir() {
            return Some((path.to_path_buf(), CorpusSource::Cache));
        }
    }
    None
}

/// List the spec versions present in a corpus directory by walking
/// its top-level entries. Sorts newest-first lexicographically
/// (matches `YYYY-MM-DD[-suffix]`).
pub fn list_versions_in_dir(dir: &Path) -> Result<Vec<String>, CorpusError> {
    let mut out = Vec::new();
    if !dir.is_dir() {
        return Ok(out);
    }
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                if path.join("traceability.json").is_file() {
                    out.push(name.to_string());
                }
            }
        }
    }
    out.sort();
    out.reverse();
    Ok(out)
}

/// List the spec versions present in the embedded corpus.
pub fn list_versions_embedded() -> Vec<String> {
    let mut out: Vec<String> = EMBEDDED_CORPUS
        .dirs()
        .filter_map(|d| {
            d.path()
                .file_name()
                .and_then(|n| n.to_str())
                .filter(|_| {
                    d.get_file(format!("{}/traceability.json", d.path().display()))
                        .is_some()
                })
                .map(str::to_string)
        })
        .collect();
    out.sort();
    out.reverse();
    out
}

/// Load the corpus for `spec_version` from a filesystem directory.
pub fn load_from_dir(
    spec_version: &str,
    dir: &Path,
    source: CorpusSource,
) -> Result<LoadedCorpus, CorpusError> {
    let version_dir = dir.join(spec_version);
    let manifest_path = version_dir.join("traceability.json");
    if !manifest_path.is_file() {
        return Err(CorpusError::VersionNotFound {
            requested: spec_version.to_string(),
            searched: dir.display().to_string(),
        });
    }
    let body = fs::read_to_string(&manifest_path)?;
    let traceability: Traceability = serde_json::from_str(&body)?;
    let mut seps: Vec<SepEntry> = traceability
        .seps
        .into_iter()
        .map(|(sep, raw)| SepEntry {
            sep,
            yaml: raw.yaml,
            spec_url: raw.spec_url,
            requirements: raw.requirements,
            excluded: raw.excluded,
        })
        .collect();
    seps.sort_by(|a, b| a.sep.cmp(&b.sep));
    Ok(LoadedCorpus {
        spec_version: spec_version.to_string(),
        source,
        seps,
    })
}

/// Load the corpus for `spec_version` from the embedded fallback.
pub fn load_from_embedded(spec_version: &str) -> Result<LoadedCorpus, CorpusError> {
    let manifest_path = format!("{}/traceability.json", spec_version);
    let file =
        EMBEDDED_CORPUS
            .get_file(&manifest_path)
            .ok_or_else(|| CorpusError::VersionNotFound {
                requested: spec_version.to_string(),
                searched: "<embedded>".to_string(),
            })?;
    let body = std::str::from_utf8(file.contents())
        .map_err(|e| CorpusError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
    let traceability: Traceability = serde_json::from_str(body)?;
    let mut seps: Vec<SepEntry> = traceability
        .seps
        .into_iter()
        .map(|(sep, raw)| SepEntry {
            sep,
            yaml: raw.yaml,
            spec_url: raw.spec_url,
            requirements: raw.requirements,
            excluded: raw.excluded,
        })
        .collect();
    seps.sort_by(|a, b| a.sep.cmp(&b.sep));
    Ok(LoadedCorpus {
        spec_version: spec_version.to_string(),
        source: CorpusSource::Embedded,
        seps,
    })
}

/// Pick the latest spec version available across the resolved
/// directory and the embedded fallback.
///
/// `override_dir` and `cache_dir` are consulted via
/// [`resolve_corpus_dir`]; the embedded fallback is used when neither
/// path has the version directory.
pub fn latest_available(
    override_dir: Option<&Path>,
    cache_dir: Option<&Path>,
) -> Result<String, CorpusError> {
    if let Some((dir, _)) = resolve_corpus_dir(override_dir, cache_dir) {
        let versions = list_versions_in_dir(&dir)?;
        if let Some(latest) = versions.first().cloned() {
            return Ok(latest);
        }
    }
    let embedded = list_versions_embedded();
    embedded.first().cloned().ok_or(CorpusError::Empty {
        searched: "<embedded>".to_string(),
    })
}

/// Load a corpus by walking the precedence rule. `override_dir`
/// wins; else `cache_dir` (if it has the version); else embedded.
pub fn load(
    spec_version: &str,
    override_dir: Option<&Path>,
    cache_dir: Option<&Path>,
) -> Result<LoadedCorpus, CorpusError> {
    if let Some(path) = override_dir {
        return load_from_dir(spec_version, path, CorpusSource::Override);
    }
    if let Some(path) = cache_dir {
        if path.join(spec_version).join("traceability.json").is_file() {
            return load_from_dir(spec_version, path, CorpusSource::Cache);
        }
    }
    load_from_embedded(spec_version)
}

/// Collect every check id present across the corpus's SEP entries.
pub fn check_ids(corpus: &LoadedCorpus) -> Vec<String> {
    let mut out: Vec<String> = corpus
        .seps
        .iter()
        .flat_map(|sep| sep.requirements.iter().filter_map(|r| r.check.clone()))
        .collect();
    out.sort();
    out.dedup();
    out
}

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

    #[test]
    fn embedded_corpus_contains_the_pinned_spec_version() {
        let versions = list_versions_embedded();
        assert!(
            versions.contains(&"2026-07-28-rc".to_string()),
            "embedded corpus missing 2026-07-28-rc; got {versions:?}"
        );
    }

    #[test]
    fn embedded_load_returns_a_typed_corpus_with_sep_entries() {
        let corpus = load_from_embedded("2026-07-28-rc").expect("embedded load");
        assert_eq!(corpus.spec_version, "2026-07-28-rc");
        assert_eq!(corpus.source, CorpusSource::Embedded);
        assert!(!corpus.seps.is_empty(), "embedded corpus has SEP entries");
        let sep_ids: Vec<&str> = corpus.seps.iter().map(|s| s.sep.as_str()).collect();
        assert!(sep_ids.contains(&"837"), "expected SEP-837 in corpus");
    }

    #[test]
    fn check_ids_lists_every_tested_requirement() {
        let corpus = load_from_embedded("2026-07-28-rc").expect("embedded load");
        let ids = check_ids(&corpus);
        assert!(!ids.is_empty());
        assert!(ids.iter().all(|id| id.starts_with("sep-")));
    }

    #[test]
    fn load_falls_back_to_embedded_when_neither_override_nor_cache_has_version() {
        let corpus = load("2026-07-28-rc", None, None).expect("falls back to embedded");
        assert_eq!(corpus.source, CorpusSource::Embedded);
    }

    #[test]
    fn load_returns_version_not_found_when_override_is_missing_the_version() {
        let dir = tempfile::tempdir().unwrap();
        let err = load("2099-01-01", Some(dir.path()), None).unwrap_err();
        assert!(matches!(err, CorpusError::VersionNotFound { .. }));
    }

    #[test]
    fn list_versions_in_dir_walks_only_directories_with_traceability_json() {
        let dir = tempfile::tempdir().unwrap();
        // Create one valid version + one stray file at the top level.
        let v = dir.path().join("2026-07-28-rc");
        std::fs::create_dir_all(&v).unwrap();
        std::fs::write(
            v.join("traceability.json"),
            b"{\"schemaVersion\":1,\"seps\":{}}",
        )
        .unwrap();
        std::fs::write(dir.path().join("stray.txt"), b"hi").unwrap();
        let versions = list_versions_in_dir(dir.path()).unwrap();
        assert_eq!(versions, vec!["2026-07-28-rc".to_string()]);
    }

    #[test]
    fn resolve_corpus_dir_prefers_override_then_cache_then_none() {
        let dir = tempfile::tempdir().unwrap();
        // Override wins.
        let r = resolve_corpus_dir(Some(dir.path()), None);
        assert!(matches!(r, Some((_, CorpusSource::Override))));
        // Cache when no override.
        let r = resolve_corpus_dir(None, Some(dir.path()));
        assert!(matches!(r, Some((_, CorpusSource::Cache))));
        // Cache that does not exist is rejected.
        let r = resolve_corpus_dir(None, Some(Path::new("/nonexistent-mcptest-xyz")));
        assert!(r.is_none());
    }
}
