//! Registry version existence checker.
//!
//! For each supported package registry, performs a lightweight HTTP lookup to
//! determine whether a specific version of a package is published.
//!
//! Replaces:
//! - `actions/check-registry/action.yml`
//! - `kreuzberg/scripts/publish/check_*.sh`

use anyhow::{Context, Result};
use serde_json::json;
use std::time::Duration;

/// Supported registries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Registry {
    Pypi,
    Npm,
    Wasm,
    Rubygems,
    Maven,
    Nuget,
    Packagist,
    Cratesio,
    Hex,
    Homebrew,
    GithubRelease,
    /// pub.dev (Dart packages).
    Pub,
    /// Zig: no central registry, checks GitHub release tag existence.
    Zig,
    /// Swift Package Index: no central registry, checks GitHub release tag existence
    /// (SPI auto-discovers new tags from Git).
    Swift,
}

impl std::fmt::Display for Registry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Registry::Pypi => write!(f, "pypi"),
            Registry::Npm | Registry::Wasm => write!(f, "npm"),
            Registry::Rubygems => write!(f, "rubygems"),
            Registry::Maven => write!(f, "maven"),
            Registry::Nuget => write!(f, "nuget"),
            Registry::Packagist => write!(f, "packagist"),
            Registry::Cratesio => write!(f, "cratesio"),
            Registry::Hex => write!(f, "hex"),
            Registry::Homebrew => write!(f, "homebrew"),
            Registry::GithubRelease => write!(f, "github-release"),
            Registry::Pub => write!(f, "pub"),
            Registry::Zig => write!(f, "zig"),
            Registry::Swift => write!(f, "swift"),
        }
    }
}

/// Check whether `package@version` exists in `registry`.
///
/// `extra` carries registry-specific parameters:
/// - Maven: `package` is `groupId:artifactId` (colon-separated).
/// - NuGet: `source_url` override (defaults to `https://api.nuget.org`).
/// - Homebrew: `tap_repo` in `owner/repo` form (e.g. `Homebrew/homebrew-core`).
/// - GitHub Release: `repo` in `owner/repo` form.
pub fn check(registry: Registry, package: &str, version: &str, extra: &ExtraParams, output_json: bool) -> Result<bool> {
    let exists = match registry {
        Registry::Pypi => check_pypi(package, version)?,
        Registry::Npm | Registry::Wasm => check_npm(package, version)?,
        Registry::Rubygems => check_rubygems(package, version)?,
        Registry::Maven => check_maven(package, version)?,
        Registry::Nuget => check_nuget(package, version, extra.nuget_source.as_deref())?,
        Registry::Packagist => check_packagist(package, version)?,
        Registry::Cratesio => check_cratesio(package, version)?,
        Registry::Hex => check_hex(package, version)?,
        Registry::Homebrew => check_homebrew(package, version, extra.tap_repo.as_deref())?,
        Registry::GithubRelease => check_github_release(
            package,
            version,
            extra.repo.as_deref(),
            extra.asset_prefix.as_deref(),
            &extra.required_assets,
        )?,
        Registry::Pub => check_pub(package, version)?,
        // Zig and Swift have no central registry — both consume packages
        // directly from GitHub release tags. The "version exists" check is
        // therefore "does the GH release tag exist on the configured repo".
        Registry::Zig | Registry::Swift => check_github_release(package, version, extra.repo.as_deref(), None, &[])?,
    };

    if output_json {
        let out = json!({
            "registry": registry.to_string(),
            "package": package,
            "version": version,
            "exists": exists,
        });
        println!("{}", serde_json::to_string_pretty(&out)?);
    } else {
        println!("exists={}", if exists { "true" } else { "false" });
    }

    Ok(exists)
}

/// Extra parameters for registry-specific checks.
#[derive(Debug, Default)]
pub struct ExtraParams {
    /// NuGet source URL override.
    pub nuget_source: Option<String>,
    /// Homebrew tap repository (`owner/repo`).
    pub tap_repo: Option<String>,
    /// GitHub repository (`owner/repo`) for GitHub Release check.
    pub repo: Option<String>,
    /// Asset name prefix (github-release): require at least one asset whose
    /// name starts with this prefix to consider the release "exists".
    pub asset_prefix: Option<String>,
    /// Required asset names (github-release): all must be present.
    pub required_assets: Vec<String>,
}

// ---- HTTP helper ----

/// Build a configured ureq agent with a 30-second global timeout.
fn build_agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(30)))
        .build()
        .new_agent()
}

/// Map a ureq v3 error to the boolean "exists" semantic. Returns `Ok(false)` on
/// 404, `Ok(true)` on any 2xx, and propagates other failures.
fn classify(result: std::result::Result<ureq::http::Response<ureq::Body>, ureq::Error>) -> Result<HttpOutcome> {
    match result {
        Ok(resp) => Ok(HttpOutcome::Ok(resp)),
        Err(ureq::Error::StatusCode(404)) => Ok(HttpOutcome::NotFound),
        Err(e) => Err(anyhow::anyhow!("HTTP request failed: {e}")),
    }
}

enum HttpOutcome {
    Ok(ureq::http::Response<ureq::Body>),
    NotFound,
}

/// GET `url` and return true if the response is 2xx, false if 404, error otherwise.
fn http_get_ok(url: &str) -> Result<bool> {
    let agent = build_agent();
    let response = agent.get(url).header("User-Agent", "alef-publish/1.0").call();
    match classify(response).with_context(|| format!("HTTP GET {url}"))? {
        HttpOutcome::Ok(_) => Ok(true),
        HttpOutcome::NotFound => Ok(false),
    }
}

/// GET `url`, parse the response as JSON. Returns None on 404.
fn http_get_json(url: &str) -> Result<Option<serde_json::Value>> {
    let agent = build_agent();
    let response = agent
        .get(url)
        .header("User-Agent", "alef-publish/1.0")
        .header("Accept", "application/json")
        .call();
    match classify(response).with_context(|| format!("HTTP GET {url}"))? {
        HttpOutcome::Ok(resp) => {
            let text = resp
                .into_body()
                .read_to_string()
                .with_context(|| format!("reading body from {url}"))?;
            let val: serde_json::Value =
                serde_json::from_str(&text).with_context(|| format!("parsing JSON from {url}"))?;
            Ok(Some(val))
        }
        HttpOutcome::NotFound => Ok(None),
    }
}

// ---- per-registry checks ----

fn check_pypi(package: &str, version: &str) -> Result<bool> {
    let url = format!("https://pypi.org/pypi/{package}/{version}/json");
    http_get_ok(&url)
}

fn check_npm(package: &str, version: &str) -> Result<bool> {
    let url = format!("https://registry.npmjs.org/{package}/{version}");
    http_get_ok(&url)
}

fn check_cratesio(package: &str, version: &str) -> Result<bool> {
    let url = format!("https://crates.io/api/v1/crates/{package}/{version}");
    let agent = build_agent();
    let response = agent
        .get(&url)
        // crates.io requires a descriptive User-Agent.
        .header("User-Agent", "alef-publish/1.0")
        .call();
    match classify(response).with_context(|| format!("HTTP GET {url}"))? {
        HttpOutcome::Ok(_) => Ok(true),
        HttpOutcome::NotFound => Ok(false),
    }
}

fn check_rubygems(package: &str, version: &str) -> Result<bool> {
    let url = format!("https://rubygems.org/api/v1/versions/{package}.json");
    match http_get_json(&url)? {
        None => Ok(false),
        Some(val) => {
            if let Some(versions) = val.as_array() {
                for v in versions {
                    if v["number"].as_str() == Some(version) {
                        return Ok(true);
                    }
                }
            }
            Ok(false)
        }
    }
}

fn check_hex(package: &str, version: &str) -> Result<bool> {
    let url = format!("https://hex.pm/api/packages/{package}/releases/{version}");
    http_get_ok(&url)
}

fn check_pub(package: &str, version: &str) -> Result<bool> {
    // pub.dev returns 200 with a version object, or 404 if the version is missing.
    let url = format!("https://pub.dev/api/packages/{package}/versions/{version}");
    http_get_ok(&url)
}

fn check_maven(package: &str, version: &str) -> Result<bool> {
    // package format: groupId:artifactId
    let (group_id, artifact_id) = if let Some(colon) = package.find(':') {
        (&package[..colon], &package[colon + 1..])
    } else {
        anyhow::bail!("Maven package must be 'groupId:artifactId', got: {package}");
    };
    let group_path = group_id.replace('.', "/");
    let url = format!("https://repo1.maven.org/maven2/{group_path}/{artifact_id}/{version}/");
    http_get_ok(&url)
}

fn check_nuget(package: &str, version: &str, source: Option<&str>) -> Result<bool> {
    let base = source.unwrap_or("https://api.nuget.org");
    let pkg_lower = package.to_lowercase();
    let url = format!("{base}/v3/registration5-gz-semver2/{pkg_lower}/{version}.json");
    http_get_ok(&url)
}

fn check_packagist(package: &str, version: &str) -> Result<bool> {
    let url = format!("https://repo.packagist.org/p2/{package}.json");
    match http_get_json(&url)? {
        None => Ok(false),
        Some(val) => {
            if let Some(packages) = val["packages"][package].as_array() {
                for pkg in packages {
                    if pkg["version"].as_str() == Some(version) || pkg["version_normalized"].as_str() == Some(version) {
                        return Ok(true);
                    }
                }
            }
            Ok(false)
        }
    }
}

fn check_homebrew(package: &str, version: &str, tap_repo: Option<&str>) -> Result<bool> {
    let repo = tap_repo.unwrap_or("Homebrew/homebrew-core");
    if repo == "Homebrew/homebrew-core" {
        // Homebrew core API exposes the resolved version in the JSON body.
        let url = format!("https://formulae.brew.sh/api/formula/{package}.json");
        let Some(json) = http_get_json(&url)? else {
            return Ok(false);
        };
        let formula_version = json
            .get("versions")
            .and_then(|v| v.get("stable"))
            .and_then(|v| v.as_str());
        return Ok(formula_version == Some(version));
    }
    // For third-party taps: fetch the formula source and look for the version.
    // We accept either a `version "<v>"` line or a `url ".../v<v>.tar.gz"` reference,
    // since formulae use one form or the other depending on download style.
    let url = format!("https://raw.githubusercontent.com/{repo}/HEAD/Formula/{package}.rb");
    let agent = build_agent();
    let response = agent.get(&url).header("User-Agent", "alef-publish/1.0").call();
    let resp = match classify(response).with_context(|| format!("HTTP GET {url}"))? {
        HttpOutcome::Ok(resp) => resp,
        HttpOutcome::NotFound => return Ok(false),
    };
    let body = resp
        .into_body()
        .read_to_string()
        .with_context(|| format!("reading body from {url}"))?;

    // Match `version "X"` or `version 'X'` (explicit version stanza).
    if body.contains(&format!("version \"{version}\"")) || body.contains(&format!("version '{version}'")) {
        return Ok(true);
    }
    // Otherwise scan for a top-level (non-bottle) `url` line that names this
    // version. We must NOT match `root_url` inside a `bottle do` block — a
    // freshly bumped formula often updates root_url to the new release while
    // leaving the source `url` pointing at an older tag, and treating that as
    // "version exists" causes false positives that skip subsequent bottle
    // builds.
    for line in body.lines() {
        let trimmed = line.trim_start();
        if !trimmed.starts_with("url ") && !trimmed.starts_with("url(") {
            continue;
        }
        if trimmed.contains(&format!("/v{version}.tar.gz"))
            || trimmed.contains(&format!("/v{version}.zip"))
            || trimmed.contains(&format!("/{version}.tar.gz"))
            || trimmed.contains(&format!("/{version}.zip"))
        {
            return Ok(true);
        }
    }
    Ok(false)
}

fn check_github_release(
    package: &str,
    version: &str,
    repo: Option<&str>,
    asset_prefix: Option<&str>,
    required_assets: &[String],
) -> Result<bool> {
    let repo = repo
        .filter(|r| !r.is_empty())
        .with_context(|| format!("--repo is required for github-release check of {package}"))?;
    let tag = if version.starts_with('v') {
        version.to_string()
    } else {
        format!("v{version}")
    };
    let url = format!("https://api.github.com/repos/{repo}/releases/tags/{tag}");
    let agent = build_agent();
    let response = agent
        .get(&url)
        .header("User-Agent", "alef-publish/1.0")
        .header("Accept", "application/vnd.github+json")
        .call();
    let resp = match classify(response).with_context(|| format!("GitHub API GET {url}"))? {
        HttpOutcome::Ok(resp) => resp,
        HttpOutcome::NotFound => return Ok(false),
    };

    let asset_prefix = asset_prefix.filter(|s| !s.is_empty());
    let has_asset_filter = asset_prefix.is_some() || !required_assets.is_empty();
    if !has_asset_filter {
        return Ok(true);
    }

    let body = resp
        .into_body()
        .read_to_string()
        .with_context(|| format!("reading body from {url}"))?;
    let json: serde_json::Value = serde_json::from_str(&body).with_context(|| format!("parsing JSON from {url}"))?;
    let asset_names: Vec<&str> = json["assets"]
        .as_array()
        .map(|arr| arr.iter().filter_map(|a| a["name"].as_str()).collect::<Vec<_>>())
        .unwrap_or_default();

    if let Some(prefix) = asset_prefix
        && !asset_names.iter().any(|n| n.starts_with(prefix))
    {
        return Ok(false);
    }
    for required in required_assets {
        if !asset_names.iter().any(|n| *n == required) {
            return Ok(false);
        }
    }
    Ok(true)
}

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

    #[test]
    fn registry_display() {
        assert_eq!(Registry::Pypi.to_string(), "pypi");
        assert_eq!(Registry::Npm.to_string(), "npm");
        assert_eq!(Registry::GithubRelease.to_string(), "github-release");
        assert_eq!(Registry::Pub.to_string(), "pub");
        assert_eq!(Registry::Zig.to_string(), "zig");
        assert_eq!(Registry::Swift.to_string(), "swift");
    }

    #[test]
    fn zig_swift_require_repo() {
        // Zig and Swift delegate to check_github_release, which requires --repo.
        let extra = ExtraParams::default();
        for registry in [Registry::Zig, Registry::Swift] {
            let result = check(registry, "alef", "1.0.0", &extra, false);
            assert!(
                result.is_err(),
                "{registry} should fail without a --repo because it delegates to github-release"
            );
        }
    }

    #[test]
    fn maven_package_parse_colon() {
        // Just verify the URL construction doesn't panic.
        let result = check_maven("com.example:my-lib", "1.0.0");
        // Network unavailable in CI — we just check it doesn't crash with wrong format.
        // It will fail with a network error, not a parse error.
        let _ = result; // ignore network errors in unit tests
    }

    #[test]
    fn maven_package_no_colon_errors() {
        let result = check_maven("invalid-package-name", "1.0.0");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("groupId:artifactId"));
    }

    #[test]
    fn extra_params_default() {
        let extra = ExtraParams::default();
        assert!(extra.nuget_source.is_none());
        assert!(extra.tap_repo.is_none());
        assert!(extra.repo.is_none());
        assert!(extra.asset_prefix.is_none());
        assert!(extra.required_assets.is_empty());
    }

    #[test]
    fn github_release_requires_repo() {
        let result = check_github_release("alef", "1.0.0", None, None, &[]);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("--repo"));
    }
}
