//! OCI registry credentials and reference parsing.
//!
//! This module provides:
//! - [`RegistryConfig`] — per-registry credential storage with env-var resolution
//! - [`Reference`] — OCI image reference parsing (registry/repo:tag@digest)
//! - Registry mirrors for pull-through caching
//!
//! Configuration is stored in `~/.config/smolvm/config.toml` under the
//! `[machines]` and `[images]` sections. See [`crate::settings::SmolSettings`].

use serde::{Deserialize, Serialize};
use smolvm_registry::RegistryClient;
use std::collections::HashMap;

/// How a caller authorizes a registry pull.
///
/// [`PullAuth::FromConfig`] (the default) resolves credentials from the local
/// registry config — `docker login` / `smolvm.toml` — which is what the CLI
/// uses. The explicit variants are for programmatic callers: the smol SDK passes
/// [`PullAuth::Basic`] or [`PullAuth::Bearer`] from its `registryAuth` option,
/// and the cloud passes the control-plane per-tenant token as
/// [`PullAuth::Identity`] or [`PullAuth::Bearer`]. Every surface authorizes
/// through the one [`registry_client`] path below, so behavior is identical.
#[derive(Clone, Debug, Default)]
pub enum PullAuth {
    /// Resolve credentials from `RegistryConfig` (the CLI default).
    #[default]
    FromConfig,
    /// No credentials — anonymous/public pulls.
    Anonymous,
    /// Username + password, sent as Basic to the registry's auth service.
    Basic {
        /// Registry account username.
        username: String,
        /// Registry account password or personal access token.
        password: String,
    },
    /// A pre-minted OCI bearer token, sent directly on every request.
    Bearer(String),
    /// An identity/refresh token exchanged at the auth service for a scoped token.
    Identity(String),
}

/// Build a [`RegistryClient`] for `registry`, resolving its API endpoint (Docker
/// Hub's `registry-1.docker.io`, configured mirrors, local plaintext) and
/// applying `auth`: an explicit credential, or the ones from `config` when
/// [`PullAuth::FromConfig`].
///
/// This is the single credential-resolution path shared by the pack tooling and
/// the OCI image cache, so a pull authorizes the same way whoever drives it.
pub fn registry_client(registry: &str, config: &RegistryConfig, auth: &PullAuth) -> RegistryClient {
    // Resolve the config-key hostname to its Distribution API endpoint. The config
    // key stays the user-facing name so credential lookup is consistent — only the
    // HTTP endpoint changes:
    //   * Docker Hub is "docker.io" to users but serves the API at
    //     "registry-1.docker.io".
    //   * The smol registry's apex "smolmachines.com" (what the guest's
    //     `registry_pull_hosts` yields) serves the API at the "registry." subdomain;
    //     the already-canonical "registry.smolmachines.com" maps to itself.
    let effective = config.get_mirror(registry).unwrap_or(registry);
    let api_host = match effective {
        "docker.io" => "registry-1.docker.io",
        h if h.ends_with("smolmachines.com") => SMOLMACHINES_REGISTRY,
        h => h,
    };
    let base_url = if smolvm_registry::is_local_registry(api_host) {
        format!("http://{}", api_host)
    } else {
        format!("https://{}", api_host)
    };

    // Credential lookup must follow the SAME normalization as the endpoint, or a
    // caller-supplied alias silently resolves to no credentials. The guest's
    // `registry_pull_hosts` yields the apex `smolmachines.com`, but credentials
    // are stored under the canonical `registry.smolmachines.com` — looking up the
    // raw name there degrades an authorized user to an anonymous client, and
    // their private image 401s at the gate.
    let cred_key = match registry {
        h if h.ends_with("smolmachines.com") => SMOLMACHINES_REGISTRY,
        h => h,
    };

    let client = RegistryClient::new(base_url);
    match auth {
        PullAuth::Anonymous => client,
        PullAuth::Basic { username, password } => {
            client.with_basic_credentials(username.clone(), password.clone())
        }
        PullAuth::Bearer(token) => client.with_token(token.clone()),
        PullAuth::Identity(token) => client.with_identity_token(token.clone()),
        PullAuth::FromConfig => {
            let Some(entry) = config.registries.get(cred_key) else {
                return client;
            };
            if let Some(identity_token) = &entry.identity_token {
                client.with_identity_token(identity_token.clone())
            } else if let Some(cred) = config.get_credentials(cred_key) {
                // Legacy convention: username "token" means the password IS the
                // bearer token; otherwise it's standard Docker/OCI Basic auth.
                if cred.username == "token" {
                    client.with_token(cred.password)
                } else {
                    client.with_basic_credentials(cred.username, cred.password)
                }
            } else {
                client
            }
        }
    }
}

/// Registry credentials and defaults for a set of OCI registries.
///
/// Used as the `[machines]` and `[images]` sections within [`SmolSettings`](crate::settings::SmolSettings).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RegistryConfig {
    /// Per-registry configuration entries.
    #[serde(default)]
    pub registries: HashMap<String, RegistryEntry>,
    /// Default settings.
    #[serde(default)]
    pub defaults: RegistryDefaults,
}

/// Configuration for a single registry.
///
/// Two distinct auth paths — only one should be set per entry:
///
/// **Identity token path** (`identity_token`): an upstream credential (e.g. Auth0 JWT)
/// exchanged with a token service per-operation to obtain a short-lived OCI bearer token.
/// Used for smolmachines registries where a Cloudflare token service sits in front.
/// `RegistryClient::with_identity_token` implements the exchange.
///
/// **Direct bearer path** (`password` / `password_env`): an OCI bearer token sent
/// directly to the registry. Used for Docker Hub, GHCR, and other standard OCI
/// registries that accept static credentials.
///
/// `identity_token` takes precedence over `password`/`password_env` when both are set.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RegistryEntry {
    /// Username for authentication (direct bearer path only).
    pub username: Option<String>,
    /// Direct OCI bearer token or password (not recommended for secrets; use password_env).
    pub password: Option<String>,
    /// Environment variable containing the direct OCI bearer token or password.
    pub password_env: Option<String>,
    /// Mirror URL to use instead of this registry.
    pub mirror: Option<String>,
    /// Upstream identity credential (e.g. Auth0 JWT) exchanged with a token service
    /// to obtain a short-lived OCI bearer token per-operation.
    /// When set, takes precedence over password/password_env.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity_token: Option<String>,
    /// OAuth refresh token used to silently renew identity_token when it expires.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub refresh_token: Option<String>,
    /// Unix timestamp when identity_token expires.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<i64>,
}

/// Default registry settings.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RegistryDefaults {
    /// Default registry when none specified (defaults to docker.io).
    pub registry: Option<String>,
}

// Re-export RegistryAuth from protocol to avoid duplication
pub use smolvm_protocol::RegistryAuth;

impl RegistryConfig {
    /// Get credentials for a registry, resolving environment variables.
    ///
    /// Returns `Some((username, password))` if credentials are configured and available.
    /// Returns `None` if:
    /// - No entry for this registry
    /// - No username configured
    /// - Password not available (env var not set, no direct password)
    pub fn get_credentials(&self, registry: &str) -> Option<RegistryAuth> {
        let entry = self.registries.get(registry)?;
        let username = entry.username.as_ref()?;

        // Try password_env first, then fall back to direct password
        let password = entry
            .password_env
            .as_ref()
            .and_then(|env| {
                std::env::var(env).ok().or_else(|| {
                    tracing::debug!(
                        registry = %registry,
                        env_var = %env,
                        "password environment variable not set"
                    );
                    None
                })
            })
            .or_else(|| {
                if entry.password.is_some() {
                    tracing::warn!(
                        registry = %registry,
                        "using plaintext password from config — use password_env instead"
                    );
                }
                entry.password.clone()
            })?;

        Some(RegistryAuth {
            username: username.clone(),
            password,
        })
    }

    /// Get mirror URL for a registry if configured.
    pub fn get_mirror(&self, registry: &str) -> Option<&str> {
        self.registries.get(registry)?.mirror.as_deref()
    }

    /// Get the default registry (defaults to "docker.io").
    pub fn default_registry(&self) -> &str {
        self.defaults
            .registry
            .as_deref()
            .unwrap_or(DEFAULT_REGISTRY)
    }

    /// Check if any registries are configured.
    pub fn has_registries(&self) -> bool {
        !self.registries.is_empty()
    }

    /// Set credentials for a registry, creating or updating the entry.
    ///
    /// Clears `password_env` when a direct password is provided. Preserves any
    /// existing `mirror` setting so callers do not need to re-supply it.
    pub fn set_credentials(&mut self, registry: &str, username: String, password: String) {
        let entry = self.registries.entry(registry.to_string()).or_default();
        entry.username = Some(username);
        entry.password = Some(password);
        entry.password_env = None;
        // Clear all upstream-credential fields. identity_token takes precedence
        // over password in build_registry_client(); leaving a stale identity_token
        // would silently ignore the new direct credentials.
        entry.identity_token = None;
        entry.refresh_token = None;
        entry.expires_at = None;
    }

    /// Set a token for a registry using the `username="token"` convention.
    ///
    /// Suitable for API keys and short-lived JWTs produced by `smol login`.
    /// Delegates to [`Self::set_credentials`] so mirror is preserved.
    pub fn set_token(&mut self, registry: &str, token: &str) {
        self.set_credentials(registry, "token".to_string(), token.to_string());
    }

    /// Store an upstream identity credential (e.g. an Auth0 JWT) for the
    /// registry. Unlike [`Self::set_token`], the credential is NOT sent to the
    /// registry directly — `build_registry_client` exchanges it at the
    /// registry's token service per operation (the OCI challenge flow), which
    /// is the only form the smolmachines registry accepts. Clears any
    /// direct-bearer fields so the exchange path always wins; preserves mirror.
    pub fn set_identity_token(&mut self, registry: &str, token: &str) {
        let entry = self.registries.entry(registry.to_string()).or_default();
        entry.identity_token = Some(token.to_string());
        entry.username = None;
        entry.password = None;
        entry.password_env = None;
    }
}

/// Default registry when none specified in image reference.
pub const DEFAULT_REGISTRY: &str = "docker.io";

/// Extract the registry hostname from an image reference.
///
/// # Examples
///
/// ```ignore
/// extract_registry("alpine") == "docker.io"
/// extract_registry("library/alpine") == "docker.io"
/// extract_registry("docker.io/library/alpine") == "docker.io"
/// extract_registry("ghcr.io/owner/repo") == "ghcr.io"
/// extract_registry("registry.example.com:5000/image") == "registry.example.com:5000"
/// ```
pub fn extract_registry(image: &str) -> String {
    // Check if the image starts with a registry (contains . or : before first /)
    if let Some(slash_pos) = image.find('/') {
        let potential_registry = &image[..slash_pos];

        // A registry hostname contains a dot (.) or a port (:)
        // This distinguishes "ghcr.io/owner/repo" from "library/alpine"
        if potential_registry.contains('.') || potential_registry.contains(':') {
            return potential_registry.to_string();
        }
    }

    // No explicit registry - use default
    DEFAULT_REGISTRY.to_string()
}

/// Hosts to add to a hostname egress allow-list so a scoped machine can still
/// PULL this image in-guest on first boot.
///
/// The DNS filter matches exact-or-subdomain, so a single apex covers a
/// registry's sub-hosts. Docker Hub is the exception: its manifests live under
/// `docker.io` but blobs are served from a `docker.com` CDN, so both apexes are
/// needed. Every other registry needs only its own host.
///
/// Mirrors the control plane's `source_registry_hosts` so a machine's pull
/// behaves identically whether launched locally or via smolfleet.
pub fn registry_pull_hosts(image: &str) -> Vec<String> {
    match extract_registry(image).as_str() {
        "docker.io" | "index.docker.io" | "registry-1.docker.io" => {
            vec!["docker.io".to_string(), "docker.com".to_string()]
        }
        host if host.ends_with("smolmachines.com") => vec!["smolmachines.com".to_string()],
        host => vec![host.to_string()],
    }
}

/// Rewrite an image reference to use a different registry.
///
/// # Examples
///
/// ```ignore
/// rewrite_image_registry("alpine", "mirror.example.com") == "mirror.example.com/library/alpine"
/// rewrite_image_registry("docker.io/library/alpine", "mirror.example.com") == "mirror.example.com/library/alpine"
/// rewrite_image_registry("ghcr.io/owner/repo", "mirror.example.com") == "mirror.example.com/owner/repo"
/// ```
pub fn rewrite_image_registry(image: &str, new_registry: &str) -> String {
    let current_registry = extract_registry(image);

    if image.starts_with(&current_registry) {
        // Explicit registry - replace it
        format!("{}{}", new_registry, &image[current_registry.len()..])
    } else {
        // Implicit docker.io - need to add "library/" for single-name images
        if image.contains('/') {
            format!("{}/{}", new_registry, image)
        } else {
            format!("{}/library/{}", new_registry, image)
        }
    }
}

/// Default registry for smolmachines artifacts.
pub const SMOLMACHINES_REGISTRY: &str = "registry.smolmachines.com";

/// The control-plane API paired with [`SMOLMACHINES_REGISTRY`] — its OCI token
/// realm (`/v2/auth`) and identity endpoint (`/v1/me`) live here. Overridable
/// via the `SMOL_CLOUD_API` env var for self-hosted control planes.
pub const SMOLMACHINES_API: &str = "https://api.smolmachines.com";

/// Error parsing an artifact reference.
#[derive(Debug, Clone, PartialEq)]
pub struct ReferenceError {
    /// The original input that failed to parse.
    pub input: String,
    /// What went wrong.
    pub reason: String,
}

impl std::fmt::Display for ReferenceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "invalid reference '{}': {}", self.input, self.reason)
    }
}

impl std::error::Error for ReferenceError {}

/// A parsed OCI-style reference for smolmachine artifacts.
///
/// Supports both tag and digest references:
/// ```text
/// smolmachines.com/python-dev:latest              # tag
/// smolmachines.com/python-dev@sha256:abc123...    # digest (immutable)
/// smolmachines.com/binsquare/custom:v1            # user namespace
/// python-dev:latest                                # shorthand (default registry)
/// python-dev                                       # bare (default registry + "latest")
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Reference {
    /// Registry hostname (e.g., "registry.smolmachines.com").
    pub registry: String,
    /// User namespace, if present (e.g., "binsquare"). None for official.
    pub namespace: Option<String>,
    /// Machine name (e.g., "python-dev").
    pub name: String,
    /// Tag (e.g., "latest", "v1.0"). None if digest is set.
    pub tag: Option<String>,
    /// Digest (e.g., "sha256:abc123..."). None if tag is set.
    pub digest: Option<String>,
}

impl Reference {
    /// Parse a reference string.
    ///
    /// Returns a [`ReferenceError`] if the reference is empty or malformed.
    pub fn parse(input: &str) -> std::result::Result<Self, ReferenceError> {
        let raw_input = input;
        let input = input.trim();

        let err = |reason: &str| ReferenceError {
            input: raw_input.to_string(),
            reason: reason.to_string(),
        };

        if input.is_empty() {
            return Err(err("empty reference"));
        }

        // Split off digest (@sha256:...) or tag (:tag) from the end.
        // Digest takes precedence: `image@sha256:...` is a digest ref even if
        // there's a `:` in the name part.
        let (path, tag, digest) = if let Some(at_pos) = input.find('@') {
            let path = &input[..at_pos];
            let digest_str = &input[at_pos + 1..];
            validate_digest(raw_input, digest_str)?;
            (path, None, Some(digest_str.to_string()))
        } else {
            // No digest — check for tag after the last colon.
            // But we must not split on `:` inside a registry hostname with port
            // (e.g., "localhost:5000/image:tag").
            // Strategy: find the last `/`, then look for `:` after it.
            let tag_split_from = input.rfind('/').map(|p| p + 1).unwrap_or(0);
            let after_last_slash = &input[tag_split_from..];

            if let Some(colon_pos) = after_last_slash.find(':') {
                let abs_colon = tag_split_from + colon_pos;
                let path = &input[..abs_colon];
                let tag = &input[abs_colon + 1..];
                if tag.is_empty() {
                    return Err(err("empty tag"));
                }
                (path, Some(tag.to_string()), None)
            } else {
                (input, None, None)
            }
        };

        // Now parse the path into registry / namespace / name.
        let parts: Vec<&str> = path.split('/').collect();

        let (registry, namespace, name) = match parts.len() {
            1 => {
                // "python-dev" — bare name, default registry
                (
                    SMOLMACHINES_REGISTRY.to_string(),
                    None,
                    parts[0].to_string(),
                )
            }
            2 => {
                let first = parts[0];
                if first.contains('.') || first.contains(':') {
                    // "smolmachines.com/python-dev" — registry + name (official)
                    (first.to_string(), None, parts[1].to_string())
                } else {
                    // "binsquare/custom" — namespace + name (default registry)
                    (
                        SMOLMACHINES_REGISTRY.to_string(),
                        Some(first.to_string()),
                        parts[1].to_string(),
                    )
                }
            }
            3 => {
                // "smolmachines.com/binsquare/custom" — registry + namespace + name
                let first = parts[0];
                if !first.contains('.') && !first.contains(':') {
                    return Err(ReferenceError {
                        input: raw_input.to_string(),
                        reason: format!(
                            "first component '{}' doesn't look like a registry hostname",
                            first
                        ),
                    });
                }
                (
                    first.to_string(),
                    Some(parts[1].to_string()),
                    parts[2].to_string(),
                )
            }
            n => {
                // 4+ components — only valid when the first component is an explicit
                // registry hostname (contains '.' or ':'). All middle components
                // become the namespace, preserving arbitrary repository depth.
                // Examples:
                //   ghcr.io/org/team/machine:latest  → ns="org/team", name="machine"
                //   us-docker.pkg.dev/proj/repo/img  → ns="proj/repo", name="img"
                //   localhost:5000/a/b/c:dev         → ns="a/b",       name="c"
                let first = parts[0];
                if !first.contains('.') && !first.contains(':') {
                    return Err(ReferenceError {
                        input: raw_input.to_string(),
                        reason: format!(
                            "first component '{}' doesn't look like a registry hostname",
                            first
                        ),
                    });
                }
                let name = parts[n - 1].to_string();
                let namespace = parts[1..n - 1].join("/");
                (first.to_string(), Some(namespace), name)
            }
        };

        if name.is_empty() {
            return Err(err("empty name"));
        }

        // Reject empty path components (e.g. ghcr.io/org//machine from a double slash).
        if let Some(ns) = &namespace {
            for component in ns.split('/') {
                if component.is_empty() {
                    return Err(err("empty repository component (check for double slashes)"));
                }
            }
        }

        // Reject any repository component that could break out of the `/v2/<repo>/`
        // prefix when interpolated into a registry URL: a `.`/`..` traversal segment
        // or anything outside the OCI repository charset (notably `%`, which can
        // smuggle an encoded slash). Without this, `..` in a component turns
        // `/v2/<repo>/manifests/...` into a request to an unrelated path on the host.
        for component in namespace
            .iter()
            .flat_map(|ns| ns.split('/'))
            .chain(std::iter::once(name.as_str()))
        {
            if !is_valid_repo_component(component) {
                return Err(err(
                    "invalid repository component (path traversal or disallowed characters)",
                ));
            }
        }

        Ok(Reference {
            registry,
            namespace,
            name,
            tag,
            digest,
        })
    }

    /// The full repository path (namespace/name or just name).
    pub fn repository(&self) -> String {
        match &self.namespace {
            Some(ns) => format!("{}/{}", ns, self.name),
            None => self.name.clone(),
        }
    }
}

/// A single `/`-delimited repository path component is safe to interpolate into a
/// `/v2/<repo>/...` registry URL: non-empty, not a `.`/`..` traversal segment, and
/// limited to the OCI repository charset `[A-Za-z0-9._-]` (so no `%`, `/`, or other
/// URL metacharacters that could smuggle an encoded slash or escape the prefix).
fn is_valid_repo_component(c: &str) -> bool {
    !c.is_empty()
        && c != "."
        && c != ".."
        && c.bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}

impl std::fmt::Display for Reference {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let repo = self.repository();
        let suffix = if let Some(ref d) = self.digest {
            format!("@{}", d)
        } else if let Some(ref t) = self.tag {
            format!(":{}", t)
        } else {
            ":latest".to_string()
        };
        write!(f, "{}/{}{}", self.registry, repo, suffix)
    }
}

/// Validate a digest string: must be `sha256:` followed by exactly 64 hex chars.
fn validate_digest(raw_input: &str, digest: &str) -> std::result::Result<(), ReferenceError> {
    let hex = match digest.strip_prefix("sha256:") {
        Some(h) => h,
        None => {
            return Err(ReferenceError {
                input: raw_input.to_string(),
                reason: format!(
                    "unsupported digest algorithm in '{}': only sha256 is supported",
                    digest
                ),
            });
        }
    };

    if hex.len() != 64 {
        return Err(ReferenceError {
            input: raw_input.to_string(),
            reason: format!("digest has {} hex chars, expected 64", hex.len()),
        });
    }

    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err(ReferenceError {
            input: raw_input.to_string(),
            reason: "digest contains non-hex characters".to_string(),
        });
    }

    Ok(())
}

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

    #[test]
    fn test_extract_registry_implicit_dockerhub() {
        assert_eq!(extract_registry("alpine"), "docker.io");
        assert_eq!(extract_registry("alpine:latest"), "docker.io");
        assert_eq!(extract_registry("library/alpine"), "docker.io");
        assert_eq!(extract_registry("myuser/myimage"), "docker.io");
    }

    #[test]
    fn test_extract_registry_explicit() {
        assert_eq!(extract_registry("docker.io/library/alpine"), "docker.io");
        assert_eq!(extract_registry("ghcr.io/owner/repo"), "ghcr.io");
        assert_eq!(extract_registry("gcr.io/project/image"), "gcr.io");
        assert_eq!(
            extract_registry("registry.example.com/image"),
            "registry.example.com"
        );
        assert_eq!(extract_registry("localhost:5000/image"), "localhost:5000");
    }

    #[test]
    fn test_registry_pull_hosts_dockerhub_two_apexes() {
        // Docker Hub serves blobs from a docker.com CDN, so both apexes are
        // needed; a bare/library ref defaults to Docker Hub.
        let expect = vec!["docker.io".to_string(), "docker.com".to_string()];
        assert_eq!(registry_pull_hosts("alpine"), expect);
        assert_eq!(registry_pull_hosts("library/alpine:3.19"), expect);
        assert_eq!(registry_pull_hosts("docker.io/library/nginx"), expect);
        assert_eq!(registry_pull_hosts("registry-1.docker.io/foo/bar"), expect);
    }

    #[test]
    fn test_registry_pull_hosts_explicit_registry() {
        // A single apex covers the registry's sub-hosts via subdomain matching.
        assert_eq!(registry_pull_hosts("ghcr.io/acme/app:1"), vec!["ghcr.io"]);
        assert_eq!(
            registry_pull_hosts("registry.example.com:5000/img"),
            vec!["registry.example.com:5000"]
        );
    }

    #[test]
    fn test_registry_pull_hosts_smolmachines_apex() {
        assert_eq!(
            registry_pull_hosts("registry.smolmachines.com/lib/alpine"),
            vec!["smolmachines.com"]
        );
    }

    #[test]
    fn test_rewrite_image_registry() {
        // Implicit docker.io
        assert_eq!(
            rewrite_image_registry("alpine", "mirror.example.com"),
            "mirror.example.com/library/alpine"
        );
        assert_eq!(
            rewrite_image_registry("myuser/myimage", "mirror.example.com"),
            "mirror.example.com/myuser/myimage"
        );

        // Explicit registry
        assert_eq!(
            rewrite_image_registry("docker.io/library/alpine", "mirror.example.com"),
            "mirror.example.com/library/alpine"
        );
        assert_eq!(
            rewrite_image_registry("ghcr.io/owner/repo", "mirror.example.com"),
            "mirror.example.com/owner/repo"
        );
    }

    #[test]
    fn test_registry_config_default() {
        let config = RegistryConfig::default();
        assert!(config.registries.is_empty());
        assert_eq!(config.default_registry(), "docker.io");
    }

    #[test]
    fn test_get_credentials_with_direct_password() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "docker.io".to_string(),
            RegistryEntry {
                username: Some("testuser".to_string()),
                password: Some("testpass".to_string()),
                password_env: None,
                mirror: None,
                ..Default::default()
            },
        );

        let creds = config.get_credentials("docker.io");
        assert!(creds.is_some());
        let creds = creds.unwrap();
        assert_eq!(creds.username, "testuser");
        assert_eq!(creds.password, "testpass");
    }

    #[test]
    fn test_get_credentials_missing_username() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "docker.io".to_string(),
            RegistryEntry {
                username: None,
                password: Some("testpass".to_string()),
                password_env: None,
                mirror: None,
                ..Default::default()
            },
        );

        assert!(config.get_credentials("docker.io").is_none());
    }

    #[test]
    fn test_get_credentials_missing_password() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "docker.io".to_string(),
            RegistryEntry {
                username: Some("testuser".to_string()),
                password: None,
                password_env: None,
                mirror: None,
                ..Default::default()
            },
        );

        assert!(config.get_credentials("docker.io").is_none());
    }

    #[test]
    fn test_get_mirror() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "docker.io".to_string(),
            RegistryEntry {
                username: None,
                password: None,
                password_env: None,
                mirror: Some("mirror.example.com".to_string()),
                ..Default::default()
            },
        );

        assert_eq!(config.get_mirror("docker.io"), Some("mirror.example.com"));
        assert_eq!(config.get_mirror("ghcr.io"), None);
    }

    #[test]
    fn test_parse_config() {
        let toml_content = r#"
[defaults]
registry = "docker.io"

[registries."docker.io"]
username = "myuser"
password_env = "DOCKER_TOKEN"

[registries."ghcr.io"]
username = "github_user"
password = "direct_password"
mirror = "ghcr-mirror.example.com"
"#;

        let config: RegistryConfig = toml::from_str(toml_content).unwrap();
        assert_eq!(config.registries.len(), 2);
        assert_eq!(config.default_registry(), "docker.io");

        let docker_entry = config.registries.get("docker.io").unwrap();
        assert_eq!(docker_entry.username.as_deref(), Some("myuser"));
        assert_eq!(docker_entry.password_env.as_deref(), Some("DOCKER_TOKEN"));

        let ghcr_entry = config.registries.get("ghcr.io").unwrap();
        assert_eq!(ghcr_entry.username.as_deref(), Some("github_user"));
        assert_eq!(ghcr_entry.password.as_deref(), Some("direct_password"));
        assert_eq!(
            ghcr_entry.mirror.as_deref(),
            Some("ghcr-mirror.example.com")
        );
    }

    #[test]
    fn test_get_credentials_with_env_password() {
        // Set environment variable for this test
        std::env::set_var("SMOLVM_TEST_TOKEN", "env_password_123");

        let mut config = RegistryConfig::default();
        config.registries.insert(
            "test.io".to_string(),
            RegistryEntry {
                username: Some("envuser".to_string()),
                password: None,
                password_env: Some("SMOLVM_TEST_TOKEN".to_string()),
                mirror: None,
                ..Default::default()
            },
        );

        let creds = config.get_credentials("test.io");
        assert!(creds.is_some());
        let creds = creds.unwrap();
        assert_eq!(creds.username, "envuser");
        assert_eq!(creds.password, "env_password_123");

        // Clean up
        std::env::remove_var("SMOLVM_TEST_TOKEN");
    }

    #[test]
    fn test_get_credentials_env_var_not_set() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "test.io".to_string(),
            RegistryEntry {
                username: Some("user".to_string()),
                password: None,
                password_env: Some("SMOLVM_NONEXISTENT_VAR".to_string()),
                mirror: None,
                ..Default::default()
            },
        );

        // Should return None when env var is not set
        assert!(config.get_credentials("test.io").is_none());
    }

    #[test]
    fn test_has_registries() {
        let mut config = RegistryConfig::default();
        assert!(!config.has_registries());

        config
            .registries
            .insert("docker.io".to_string(), RegistryEntry::default());
        assert!(config.has_registries());
    }

    #[test]
    fn test_extract_registry_edge_cases() {
        // Image with tag containing colon (version)
        assert_eq!(extract_registry("alpine:3.18.0"), "docker.io");

        // Image with digest
        assert_eq!(extract_registry("alpine@sha256:abc123"), "docker.io");

        // Registry with port and path
        assert_eq!(
            extract_registry("registry.example.com:5000/myorg/myimage:latest"),
            "registry.example.com:5000"
        );
    }

    #[test]
    fn test_rewrite_image_registry_with_tag() {
        assert_eq!(
            rewrite_image_registry("alpine:3.18", "mirror.example.com"),
            "mirror.example.com/library/alpine:3.18"
        );

        assert_eq!(
            rewrite_image_registry("nginx:latest", "mirror.example.com"),
            "mirror.example.com/library/nginx:latest"
        );
    }

    #[test]
    fn test_default_registry_custom() {
        let mut config = RegistryConfig::default();
        config.defaults.registry = Some("custom.registry.io".to_string());
        assert_eq!(config.default_registry(), "custom.registry.io");
    }

    // --- Reference parser tests ---

    #[test]
    fn test_reference_bare_name() {
        let r = Reference::parse("python-dev").unwrap();
        assert_eq!(r.registry, SMOLMACHINES_REGISTRY);
        assert_eq!(r.namespace, None);
        assert_eq!(r.name, "python-dev");
        assert_eq!(r.tag, None);
        assert_eq!(r.digest, None);
    }

    #[test]
    fn test_reference_name_with_tag() {
        let r = Reference::parse("python-dev:latest").unwrap();
        assert_eq!(r.registry, SMOLMACHINES_REGISTRY);
        assert_eq!(r.namespace, None);
        assert_eq!(r.name, "python-dev");
        assert_eq!(r.tag, Some("latest".to_string()));
        assert_eq!(r.digest, None);
    }

    #[test]
    fn test_reference_registry_and_name() {
        let r = Reference::parse("smolmachines.com/python-dev:latest").unwrap();
        assert_eq!(r.registry, "smolmachines.com");
        assert_eq!(r.namespace, None);
        assert_eq!(r.name, "python-dev");
        assert_eq!(r.tag, Some("latest".to_string()));
    }

    #[test]
    fn test_reference_registry_namespace_name() {
        let r = Reference::parse("smolmachines.com/binsquare/custom:v1").unwrap();
        assert_eq!(r.registry, "smolmachines.com");
        assert_eq!(r.namespace, Some("binsquare".to_string()));
        assert_eq!(r.name, "custom");
        assert_eq!(r.tag, Some("v1".to_string()));
    }

    #[test]
    fn test_reference_namespace_without_registry() {
        let r = Reference::parse("binsquare/custom:v1").unwrap();
        assert_eq!(r.registry, SMOLMACHINES_REGISTRY);
        assert_eq!(r.namespace, Some("binsquare".to_string()));
        assert_eq!(r.name, "custom");
        assert_eq!(r.tag, Some("v1".to_string()));
    }

    #[test]
    fn test_reference_digest() {
        let digest = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
        let input = format!("python-dev@{}", digest);
        let r = Reference::parse(&input).unwrap();
        assert_eq!(r.registry, SMOLMACHINES_REGISTRY);
        assert_eq!(r.name, "python-dev");
        assert_eq!(r.tag, None);
        assert_eq!(r.digest, Some(digest.to_string()));
    }

    #[test]
    fn test_reference_registry_with_port() {
        let r = Reference::parse("localhost:5000/myimage:latest").unwrap();
        assert_eq!(r.registry, "localhost:5000");
        assert_eq!(r.namespace, None);
        assert_eq!(r.name, "myimage");
        assert_eq!(r.tag, Some("latest".to_string()));
    }

    #[test]
    fn test_reference_display() {
        let r = Reference::parse("python-dev:latest").unwrap();
        assert_eq!(
            r.to_string(),
            format!("{}/python-dev:latest", SMOLMACHINES_REGISTRY)
        );

        let r = Reference::parse("python-dev").unwrap();
        // Bare name gets :latest in display
        assert_eq!(
            r.to_string(),
            format!("{}/python-dev:latest", SMOLMACHINES_REGISTRY)
        );
    }

    #[test]
    fn test_reference_display_digest() {
        let digest = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
        let input = format!("smolmachines.com/python-dev@{}", digest);
        let r = Reference::parse(&input).unwrap();
        assert_eq!(
            r.to_string(),
            format!("smolmachines.com/python-dev@{}", digest)
        );
    }

    #[test]
    fn test_reference_error_empty() {
        let err = Reference::parse("").unwrap_err();
        assert_eq!(err.reason, "empty reference");
        assert!(Reference::parse("  ").is_err());
    }

    #[test]
    fn test_reference_error_invalid_digest_algorithm() {
        let err = Reference::parse("python-dev@md5:abc123").unwrap_err();
        assert!(err.reason.contains("unsupported digest algorithm"));
    }

    #[test]
    fn test_reference_error_digest_too_short() {
        let err = Reference::parse("python-dev@sha256:tooshort").unwrap_err();
        assert!(err.reason.contains("hex chars, expected 64"));
    }

    #[test]
    fn test_reference_error_digest_non_hex() {
        // 64 chars but contains 'g' which is not hex
        let bad = format!(
            "python-dev@sha256:{}",
            "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567g9"
        );
        let err = Reference::parse(&bad).unwrap_err();
        assert!(err.reason.contains("non-hex"));
    }

    #[test]
    fn test_reference_error_empty_tag() {
        let err = Reference::parse("python-dev:").unwrap_err();
        assert_eq!(err.reason, "empty tag");
    }

    #[test]
    fn test_reference_deep_path_ghcr() {
        // ghcr.io/org/team/machine:latest
        let r = Reference::parse("ghcr.io/org/team/machine:latest").unwrap();
        assert_eq!(r.registry, "ghcr.io");
        assert_eq!(r.namespace, Some("org/team".to_string()));
        assert_eq!(r.name, "machine");
        assert_eq!(r.tag, Some("latest".to_string()));
        assert_eq!(r.repository(), "org/team/machine");
    }

    #[test]
    fn test_reference_deep_path_gcr() {
        // us-docker.pkg.dev/project/repo/image:tag (GCR Artifact Registry style)
        let r = Reference::parse("us-docker.pkg.dev/project/repo/image:v2").unwrap();
        assert_eq!(r.registry, "us-docker.pkg.dev");
        assert_eq!(r.namespace, Some("project/repo".to_string()));
        assert_eq!(r.name, "image");
        assert_eq!(r.tag, Some("v2".to_string()));
        assert_eq!(r.repository(), "project/repo/image");
    }

    #[test]
    fn test_reference_deep_path_localhost() {
        // localhost:5000/a/b/c:dev
        let r = Reference::parse("localhost:5000/a/b/c:dev").unwrap();
        assert_eq!(r.registry, "localhost:5000");
        assert_eq!(r.namespace, Some("a/b".to_string()));
        assert_eq!(r.name, "c");
        assert_eq!(r.tag, Some("dev".to_string()));
    }

    #[test]
    fn test_reference_deep_path_explicit_registry_required() {
        // 4+ components without a registry hostname — must fail
        let err = Reference::parse("org/team/repo/image:latest").unwrap_err();
        assert!(
            err.reason.contains("doesn't look like a registry hostname"),
            "unexpected reason: {}",
            err.reason
        );
    }

    #[test]
    fn test_reference_rejects_empty_path_components() {
        // Double slashes produce empty components — must be caught
        let err = Reference::parse("ghcr.io/org//machine:latest").unwrap_err();
        assert!(
            err.reason.contains("empty repository component"),
            "unexpected reason: {}",
            err.reason
        );
    }

    #[test]
    fn test_reference_rejects_repository_path_traversal() {
        // A `..` component would turn `/v2/<repo>/...` into a request to an
        // unrelated path on the registry host — reject it (and `.`, and any
        // component carrying URL metacharacters like `%`).
        for bad in [
            "127.0.0.1:8081/../api/v1/machines:latest",
            "registry.example.com/org/../secret:latest",
            "registry.example.com/org/%2e%2e:latest",
            "registry.example.com/org/a%2fb:latest",
            "registry.example.com/./machine:latest",
        ] {
            let err = Reference::parse(bad).unwrap_err();
            assert!(
                err.reason.contains("invalid repository component"),
                "expected rejection for {bad}, got: {}",
                err.reason
            );
        }
    }

    #[test]
    fn test_reference_accepts_normal_repositories() {
        // The guard must not reject legitimate OCI repository paths.
        for ok in [
            "python-dev:latest",
            "binsquare/custom:v1",
            "ghcr.io/org/team/machine:latest",
            "registry.example.com/a.b_c-d/img:latest",
        ] {
            assert!(Reference::parse(ok).is_ok(), "should accept {ok}");
        }
    }

    // ── set_credentials / set_token / save ──────────────────────────────────

    #[test]
    fn test_set_credentials_new_entry() {
        let mut config = RegistryConfig::default();
        config.set_credentials("registry.example.com", "user".into(), "pass".into());

        let creds = config.get_credentials("registry.example.com").unwrap();
        assert_eq!(creds.username, "user");
        assert_eq!(creds.password, "pass");
    }

    #[test]
    fn test_set_credentials_overwrites_password_env() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "registry.example.com".to_string(),
            RegistryEntry {
                username: Some("old".to_string()),
                password: None,
                password_env: Some("OLD_TOKEN_ENV".to_string()),
                mirror: Some("mirror.example.com".to_string()),
                ..Default::default()
            },
        );

        config.set_credentials("registry.example.com", "new".into(), "newpass".into());

        let entry = config.registries.get("registry.example.com").unwrap();
        assert_eq!(entry.username.as_deref(), Some("new"));
        assert_eq!(entry.password.as_deref(), Some("newpass"));
        assert_eq!(entry.password_env, None, "password_env must be cleared");
        assert_eq!(
            entry.mirror.as_deref(),
            Some("mirror.example.com"),
            "mirror must be preserved"
        );
    }

    #[test]
    fn test_set_credentials_clears_identity_token() {
        // If an entry previously had an identity_token, switching to direct credentials
        // via set_credentials must clear it. identity_token takes precedence in
        // build_registry_client(); a stale one would silently ignore the new password.
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "registry.smolmachines.com".to_string(),
            RegistryEntry {
                identity_token: Some("eyJ_old_jwt".to_string()),
                refresh_token: Some("old_refresh".to_string()),
                expires_at: Some(9999999999),
                ..Default::default()
            },
        );

        config.set_credentials(
            "registry.smolmachines.com",
            "user".into(),
            "direct_bearer".into(),
        );

        let entry = config.registries.get("registry.smolmachines.com").unwrap();
        assert_eq!(entry.password.as_deref(), Some("direct_bearer"));
        assert_eq!(
            entry.identity_token, None,
            "stale identity_token must be cleared"
        );
        assert_eq!(
            entry.refresh_token, None,
            "stale refresh_token must be cleared"
        );
        assert_eq!(entry.expires_at, None, "stale expires_at must be cleared");
    }

    #[test]
    fn test_set_token_uses_token_username() {
        let mut config = RegistryConfig::default();
        config.set_token("registry.smolmachines.com", "eyJhbGci.test");

        let creds = config.get_credentials("registry.smolmachines.com").unwrap();
        assert_eq!(creds.username, "token");
        assert_eq!(creds.password, "eyJhbGci.test");
    }

    /// The guest's `registry_pull_hosts` yields the smol registry's APEX
    /// (`smolmachines.com`), while credentials are stored under the canonical
    /// `registry.smolmachines.com`. Credential lookup must normalize the same way
    /// the API endpoint does — otherwise an authorized user silently gets an
    /// anonymous client and their private image 401s at the auth gate.
    #[test]
    fn registry_client_finds_credentials_via_the_smol_registry_apex() {
        let mut config = RegistryConfig::default();
        config.registries.insert(
            "registry.smolmachines.com".to_string(),
            RegistryEntry {
                identity_token: Some("eyJ_upstream_jwt".to_string()),
                ..Default::default()
            },
        );

        // Canonical name resolves (the pre-existing behavior).
        let canonical =
            registry_client("registry.smolmachines.com", &config, &PullAuth::FromConfig);
        assert_eq!(canonical.identity_token(), Some("eyJ_upstream_jwt"));

        // The apex must resolve to the SAME credential, not silently to anonymous.
        let apex = registry_client("smolmachines.com", &config, &PullAuth::FromConfig);
        assert_eq!(
            apex.identity_token(),
            Some("eyJ_upstream_jwt"),
            "apex must resolve to the canonical registry's credentials"
        );

        // An unrelated host still gets no credentials.
        let other = registry_client("ghcr.io", &config, &PullAuth::FromConfig);
        assert_eq!(other.identity_token(), None);
    }

    #[test]
    fn test_save_roundtrip_preserves_all_fields() {
        let mut config = RegistryConfig::default();
        config.defaults.registry = Some("custom.io".to_string());
        config.registries.insert(
            "ghcr.io".to_string(),
            RegistryEntry {
                username: Some("gh_user".to_string()),
                password: Some("gh_pass".to_string()),
                password_env: None,
                mirror: Some("ghcr-mirror.example.com".to_string()),
                ..Default::default()
            },
        );

        let serialized = toml::to_string_pretty(&config).unwrap();
        let reloaded: RegistryConfig = toml::from_str(&serialized).unwrap();

        assert_eq!(reloaded.default_registry(), "custom.io");
        let entry = reloaded.registries.get("ghcr.io").unwrap();
        assert_eq!(entry.username.as_deref(), Some("gh_user"));
        assert_eq!(entry.password.as_deref(), Some("gh_pass"));
        assert_eq!(entry.mirror.as_deref(), Some("ghcr-mirror.example.com"));
    }
}
