//! `mcptest conformance refresh` handler (WOR-945).
//!
//! Fetches the upstream conformance corpus as a tarball from
//! `codeload.github.com/<owner>/<repo>/tar.gz/<ref>`, extracts the
//! files matching `<source-path>/**/*.{yaml,yml,json}` in memory,
//! and writes them to `<corpus-dir>/<spec-version>/`. No `git`
//! runtime dependency.

use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use flate2::read::GzDecoder;
use tar::Archive;

use crate::cli::args::ConformanceRefreshArgs;
use mcptest_core::conformance::releases;

pub fn refresh_command(args: ConformanceRefreshArgs) -> Result<i32> {
    let (spec_version, upstream_ref) = resolve_version_and_ref(&args)?;
    let destination = match &args.corpus_dir {
        Some(dir) => dir.clone(),
        None => super::conformance_paths::user_cache_dir()
            .ok_or_else(|| anyhow!("could not resolve the user cache directory"))?,
    };
    let version_dir = destination.join(&spec_version);

    let (owner, repo) = parse_github_url(&args.url)?;
    let tarball_url = format!("https://codeload.github.com/{owner}/{repo}/tar.gz/{upstream_ref}");

    if args.dry_run {
        println!("would fetch  : {tarball_url}");
        println!(
            "would write  : {} (under {})",
            spec_version,
            destination.display()
        );
        println!("source path  : {}", args.source_path);
        return Ok(0);
    }

    println!("fetching {tarball_url} ...");
    let bytes = fetch_tarball(&tarball_url)?;
    let entries = extract_corpus_files(&bytes, &args.source_path)?;
    if entries.is_empty() {
        return Err(anyhow!(
            "no files matching `{}/**/*.{{yaml,yml,json}}` found in {tarball_url}",
            args.source_path
        ));
    }

    // Compare against existing files for the added / removed /
    // unchanged summary the bash script prints.
    let (added, unchanged) = write_entries(&version_dir, &entries)?;
    let removed = removed_entries(&version_dir, &entries)?;

    println!(
        "wrote {} ({}/{}): {} added, {} unchanged, {} removed.",
        spec_version,
        upstream_ref,
        args.url,
        added,
        unchanged,
        removed.len()
    );
    Ok(0)
}

fn resolve_version_and_ref(args: &ConformanceRefreshArgs) -> Result<(String, String)> {
    if args.target_version == "latest" && args.r#ref.is_none() {
        let (v, r) = releases::latest();
        return Ok((v.to_string(), r.to_string()));
    }
    if let Some(r) = &args.r#ref {
        // An explicit --ref bypasses the version table. The
        // destination uses the user-supplied target_version verbatim
        // (or "custom" when the user wrote "latest" and pinned the
        // ref by hand).
        let v = if args.target_version == "latest" {
            "custom".to_string()
        } else {
            args.target_version.clone()
        };
        return Ok((v, r.clone()));
    }
    match releases::ref_for(&args.target_version) {
        Some(r) => Ok((args.target_version.clone(), r.to_string())),
        None => Err(anyhow!(
            "spec version `{}` is not in KNOWN_RELEASES; pin --ref to the upstream SHA you want or add a release entry.",
            args.target_version
        )),
    }
}

fn parse_github_url(url: &str) -> Result<(String, String)> {
    // Accept https://github.com/<owner>/<repo>(.git)? plus
    // git@github.com:<owner>/<repo>.git for parity with the bash
    // script.
    let cleaned = url
        .strip_suffix(".git")
        .unwrap_or(url)
        .trim_end_matches('/');
    if let Some(rest) = cleaned.strip_prefix("https://github.com/") {
        let mut parts = rest.splitn(2, '/');
        let owner = parts.next().unwrap_or_default();
        let repo = parts.next().unwrap_or_default();
        if owner.is_empty() || repo.is_empty() {
            return Err(anyhow!("could not parse GitHub url `{url}`"));
        }
        return Ok((owner.to_string(), repo.to_string()));
    }
    if let Some(rest) = cleaned.strip_prefix("git@github.com:") {
        let mut parts = rest.splitn(2, '/');
        let owner = parts.next().unwrap_or_default();
        let repo = parts.next().unwrap_or_default();
        if owner.is_empty() || repo.is_empty() {
            return Err(anyhow!("could not parse GitHub url `{url}`"));
        }
        return Ok((owner.to_string(), repo.to_string()));
    }
    Err(anyhow!(
        "unsupported url `{url}`; only github.com is supported in v1"
    ))
}

fn fetch_tarball(url: &str) -> Result<Vec<u8>> {
    let client = reqwest::blocking::Client::builder()
        .user_agent(concat!("mcptest/", env!("CARGO_PKG_VERSION")))
        .build()
        .context("building reqwest client")?;
    let mut req = client.get(url);
    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
        if !token.is_empty() {
            req = req.bearer_auth(token);
        }
    }
    let resp = req.send().with_context(|| format!("GET {url}"))?;
    if resp.status().as_u16() == 403 {
        let body = resp
            .text()
            .unwrap_or_else(|_| "<empty 403 body>".to_string());
        return Err(anyhow!(
            "GitHub returned 403 (anonymous rate limit is 60/hr; set GITHUB_TOKEN to lift it). Body: {body}"
        ));
    }
    if !resp.status().is_success() {
        return Err(anyhow!(
            "GET {url} returned HTTP {}",
            resp.status().as_u16()
        ));
    }
    let bytes = resp.bytes().context("reading response body")?;
    Ok(bytes.to_vec())
}

fn extract_corpus_files(tarball: &[u8], source_path: &str) -> Result<Vec<(String, Vec<u8>)>> {
    // codeload tarballs nest everything under <repo>-<ref>/. Strip
    // the top dir, then keep entries whose path starts with
    // source_path/ and whose extension matches yaml|yml|json.
    let dec = GzDecoder::new(tarball);
    let mut ar = Archive::new(dec);
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
    for entry in ar.entries().context("opening tar entries")? {
        let mut entry = entry.context("reading tar entry")?;
        let path = entry.path().context("entry path")?.into_owned();
        let rel = strip_top_dir(&path);
        let Some(rel) = rel else {
            continue;
        };
        let with_prefix = format!("{}/", source_path.trim_end_matches('/'));
        let Some(stripped) = rel.strip_prefix(&with_prefix) else {
            continue;
        };
        let ext_ok = matches!(
            Path::new(stripped).extension().and_then(|e| e.to_str()),
            Some("yaml") | Some("yml") | Some("json")
        );
        if !ext_ok {
            continue;
        }
        let mut buf = Vec::new();
        entry
            .read_to_end(&mut buf)
            .context("reading tar entry body")?;
        out.push((stripped.to_string(), buf));
    }
    Ok(out)
}

fn strip_top_dir(path: &Path) -> Option<String> {
    let mut iter = path.components();
    iter.next()?; // discard the top dir
    let rest: PathBuf = iter.collect();
    Some(rest.to_string_lossy().into_owned())
}

fn write_entries(version_dir: &Path, entries: &[(String, Vec<u8>)]) -> Result<(usize, usize)> {
    fs::create_dir_all(version_dir)
        .with_context(|| format!("create_dir_all {}", version_dir.display()))?;
    let mut added = 0usize;
    let mut unchanged = 0usize;
    for (rel, bytes) in entries {
        let dest = version_dir.join(rel);
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("create_dir_all {}", parent.display()))?;
        }
        let existed = fs::read(&dest).ok();
        if existed.as_deref() == Some(bytes) {
            unchanged += 1;
        } else {
            fs::write(&dest, bytes).with_context(|| format!("writing {}", dest.display()))?;
            added += 1;
        }
    }
    Ok((added, unchanged))
}

fn removed_entries(version_dir: &Path, entries: &[(String, Vec<u8>)]) -> Result<Vec<PathBuf>> {
    let kept: std::collections::HashSet<PathBuf> = entries
        .iter()
        .map(|(rel, _)| version_dir.join(rel))
        .collect();
    let mut removed = Vec::new();
    if !version_dir.is_dir() {
        return Ok(removed);
    }
    walk_files(version_dir, &mut |path| {
        if !kept.contains(path) {
            if let Err(e) = fs::remove_file(path) {
                tracing::warn!(
                    target: "mcptest::conformance::refresh",
                    path = %path.display(),
                    error = %e,
                    "could not remove stale file"
                );
            } else {
                removed.push(path.to_path_buf());
            }
        }
    })?;
    Ok(removed)
}

fn walk_files(root: &Path, on_file: &mut dyn FnMut(&Path)) -> Result<()> {
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            walk_files(&path, on_file)?;
        } else if path.is_file() {
            on_file(&path);
        }
    }
    Ok(())
}

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

    #[test]
    fn parse_github_url_accepts_https() {
        let (o, r) =
            parse_github_url("https://github.com/modelcontextprotocol/conformance").unwrap();
        assert_eq!(o, "modelcontextprotocol");
        assert_eq!(r, "conformance");
    }

    #[test]
    fn parse_github_url_accepts_https_dot_git() {
        let (o, r) = parse_github_url("https://github.com/owner/repo.git").unwrap();
        assert_eq!(o, "owner");
        assert_eq!(r, "repo");
    }

    #[test]
    fn parse_github_url_accepts_ssh_form() {
        let (o, r) = parse_github_url("git@github.com:owner/repo.git").unwrap();
        assert_eq!(o, "owner");
        assert_eq!(r, "repo");
    }

    #[test]
    fn parse_github_url_rejects_non_github() {
        assert!(parse_github_url("https://gitlab.com/owner/repo").is_err());
    }

    #[test]
    fn resolve_version_and_ref_returns_latest_by_default() {
        let args = ConformanceRefreshArgs {
            target_version: "latest".into(),
            r#ref: None,
            ..Default::default()
        };
        let (v, _r) = resolve_version_and_ref(&args).unwrap();
        assert!(v.starts_with("2026-") || v == "custom");
    }

    #[test]
    fn resolve_version_and_ref_honors_explicit_ref() {
        let args = ConformanceRefreshArgs {
            target_version: "2099-01-01".into(),
            r#ref: Some("abc123".into()),
            ..Default::default()
        };
        let (v, r) = resolve_version_and_ref(&args).unwrap();
        assert_eq!(v, "2099-01-01");
        assert_eq!(r, "abc123");
    }

    #[test]
    fn resolve_version_and_ref_errors_for_unknown_version_no_ref() {
        let args = ConformanceRefreshArgs {
            target_version: "2099-01-01".into(),
            r#ref: None,
            ..Default::default()
        };
        let err = resolve_version_and_ref(&args).unwrap_err();
        assert!(err.to_string().contains("KNOWN_RELEASES"));
    }
}
