use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Duration;

use anyhow::{Context, Result, bail};
use log::{debug, warn};

use crate::git_utils::{detect_remote_name, git_cmd};

/// HTTP request timeout for remote includes and API calls.
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);

/// Maximum response body size for YAML/JSON API responses (10 MB).
pub(crate) const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024;

/// Maximum response body size for binary downloads like tarballs (100 MB).
pub(crate) const MAX_DOWNLOAD_SIZE: usize = 100 * 1024 * 1024;

/// Read a response body as text, enforcing a size limit.
/// Returns an error if the body exceeds `max_bytes`.
pub(crate) fn read_response_text(
    response: reqwest::blocking::Response,
    max_bytes: usize,
) -> Result<String> {
    if let Some(len) = response.content_length()
        && len > max_bytes as u64
    {
        bail!(
            "response body too large ({} bytes, limit {})",
            len,
            max_bytes
        );
    }
    let bytes = read_limited(response, max_bytes)?;
    String::from_utf8(bytes).context("response body is not valid UTF-8")
}

/// Read a response body as bytes, enforcing a size limit.
/// Returns an error if the body exceeds `max_bytes`.
pub(crate) fn read_response_bytes(
    response: reqwest::blocking::Response,
    max_bytes: usize,
) -> Result<Vec<u8>> {
    if let Some(len) = response.content_length()
        && len > max_bytes as u64
    {
        bail!(
            "response body too large ({} bytes, limit {})",
            len,
            max_bytes
        );
    }
    read_limited(response, max_bytes)
}

/// Read up to `max_bytes` from a reader. Returns an error if the stream
/// exceeds the limit.
fn read_limited(reader: impl std::io::Read, max_bytes: usize) -> Result<Vec<u8>> {
    use std::io::Read;
    let mut buf = Vec::new();
    reader
        .take((max_bytes as u64) + 1)
        .read_to_end(&mut buf)
        .context("failed to read response body")?;
    if buf.len() > max_bytes {
        bail!(
            "response body too large (>{} bytes, limit {})",
            max_bytes,
            max_bytes
        );
    }
    Ok(buf)
}

/// Build a reusable HTTP client with the standard timeout.
fn build_http_client() -> reqwest::blocking::Client {
    reqwest::blocking::Client::builder()
        .timeout(HTTP_TIMEOUT)
        .user_agent(concat!("cui-cui/", env!("CARGO_PKG_VERSION")))
        .build()
        .expect("Failed to build HTTP client")
}

/// Shared client for requests that don't go through `GitlabConfig`
/// (e.g. `include:remote` and `include:template` fetches).
static SHARED_CLIENT: LazyLock<reqwest::blocking::Client> = LazyLock::new(build_http_client);

/// Holds the resolved GitLab instance URL (e.g. `https://gitlab.freedesktop.org`).
#[derive(Debug, Clone)]
pub struct GitlabConfig {
    pub base_url: String,
    pub token: Option<String>,
    client: reqwest::blocking::Client,
}

impl GitlabConfig {
    /// Try to detect the GitLab instance from the git remote of the repo
    /// containing the given file path.
    pub fn detect(file_path: &Path, cli_url: Option<&str>) -> Option<Self> {
        let base_url = if let Some(url) = cli_url {
            url.trim_end_matches('/').to_string()
        } else {
            detect_from_git_remote(file_path)?
        };

        // Check for a token in environment variables
        let token = std::env::var("GITLAB_TOKEN")
            .or_else(|_| std::env::var("GITLAB_PRIVATE_TOKEN"))
            .or_else(|_| std::env::var("CI_JOB_TOKEN"))
            .ok();

        if base_url.starts_with("http://") {
            if token.is_some() {
                warn!(
                    "GitLab URL uses plain HTTP ({}). Refusing to send token in cleartext. \
                     Use HTTPS or unset GITLAB_TOKEN/GITLAB_PRIVATE_TOKEN/CI_JOB_TOKEN.",
                    base_url
                );
                return Some(GitlabConfig {
                    base_url,
                    token: None,
                    client: build_http_client(),
                });
            }
            warn!(
                "GitLab URL uses plain HTTP ({}). Consider using HTTPS.",
                base_url
            );
        }

        Some(GitlabConfig {
            base_url,
            token,
            client: build_http_client(),
        })
    }

    /// Return the shared HTTP client (for use by other modules like artifacts).
    pub fn client(&self) -> &reqwest::blocking::Client {
        &self.client
    }

    /// Fetch a file from a GitLab project via the API.
    /// `project` is the full path like `freedesktop/ci-templates`.
    /// `file_path` is the file within the project like `/templates/alpine.yml`.
    /// `git_ref` is the branch, tag, or commit SHA.
    pub fn fetch_project_file(
        &self,
        project: &str,
        file_path: &str,
        git_ref: &str,
    ) -> Result<String> {
        let cache_key = format!(
            "project/{}/{}/{}",
            project,
            git_ref,
            file_path.trim_start_matches('/')
        );

        // Check cache first
        if let Some(cached) = cache_read(&cache_key) {
            debug!("cache hit for project='{}' file='{}'", project, file_path);
            return Ok(cached);
        }

        debug!(
            "fetching project='{}' file='{}' ref='{}'",
            project, file_path, git_ref
        );
        let encoded_project = urlencoded(project);
        let encoded_path = urlencoded(file_path.trim_start_matches('/'));

        let encoded_ref = urlencoded(git_ref);
        let url = format!(
            "{}/api/v4/projects/{}/repository/files/{}/raw?ref={}",
            self.base_url, encoded_project, encoded_path, encoded_ref
        );

        let mut request = self.client.get(&url);
        if let Some(ref token) = self.token {
            request = request.header("PRIVATE-TOKEN", token);
        }

        let response = request
            .send()
            .with_context(|| format!("Failed to fetch {}", url))?;

        if !response.status().is_success() {
            bail!(
                "Failed to fetch {} (HTTP {}). Set GITLAB_TOKEN env var for private projects.",
                url,
                response.status()
            );
        }

        let content = read_response_text(response, MAX_RESPONSE_SIZE)
            .with_context(|| format!("Failed to read response body from {}", url))?;

        cache_write(&cache_key, &content);
        Ok(content)
    }

    /// List file paths in a project directory via the repository tree API.
    /// Returns paths relative to the repo root (e.g. `.gitlab-ci/container/debian/gitlab-ci.yml`).
    pub fn list_project_tree(
        &self,
        project: &str,
        path: &str,
        git_ref: &str,
        recursive: bool,
    ) -> Result<Vec<String>> {
        let cache_key = format!(
            "tree/{}/{}/{}/{}",
            project,
            git_ref,
            path.trim_start_matches('/'),
            if recursive { "recursive" } else { "flat" }
        );

        if let Some(cached) = cache_read(&cache_key) {
            debug!("cache hit for tree project='{}' path='{}'", project, path);
            return Ok(cached.lines().map(String::from).collect());
        }

        debug!(
            "listing tree project='{}' path='{}' ref='{}'",
            project, path, git_ref
        );
        let encoded_project = urlencoded(project);
        let trimmed_path = path.trim_start_matches('/');

        let mut all_paths = Vec::new();
        let mut page = 1u32;

        loop {
            let encoded_ref = urlencoded(git_ref);
            let url = format!(
                "{}/api/v4/projects/{}/repository/tree?path={}&ref={}&per_page=100&page={}&recursive={}",
                self.base_url,
                encoded_project,
                urlencoded(trimmed_path),
                encoded_ref,
                page,
                recursive,
            );

            let mut request = self.client.get(&url);
            if let Some(ref token) = self.token {
                request = request.header("PRIVATE-TOKEN", token);
            }

            let response = request
                .send()
                .with_context(|| format!("Failed to fetch tree {}", url))?;

            if !response.status().is_success() {
                bail!("Failed to list tree {} (HTTP {})", url, response.status());
            }

            let text = read_response_text(response, MAX_RESPONSE_SIZE)
                .with_context(|| format!("Failed to read tree response from {}", url))?;
            let entries: Vec<serde_json::Value> = serde_json::from_str(&text)
                .with_context(|| format!("Failed to parse tree response from {}", url))?;

            if entries.is_empty() {
                break;
            }

            for entry in &entries {
                if entry.get("type").and_then(|t| t.as_str()) == Some("blob")
                    && let Some(p) = entry.get("path").and_then(|p| p.as_str())
                {
                    all_paths.push(p.to_string());
                }
            }

            page += 1;
        }

        cache_write(&cache_key, &all_paths.join("\n"));
        Ok(all_paths)
    }
}

/// Fetch a file from an arbitrary URL (for `include:remote`).
pub fn fetch_remote_url(url: &str) -> Result<String> {
    let cache_key = format!("remote/{}", url_to_cache_path(url));

    // Check cache first
    if let Some(cached) = cache_read(&cache_key) {
        debug!("cache hit for remote url '{}'", url);
        return Ok(cached);
    }

    debug!("fetching remote url '{}'", url);
    let response = SHARED_CLIENT
        .get(url)
        .send()
        .with_context(|| format!("Failed to fetch remote URL {}", url))?;

    if !response.status().is_success() {
        bail!(
            "Failed to fetch remote URL {} (HTTP {})",
            url,
            response.status()
        );
    }

    let content = read_response_text(response, MAX_RESPONSE_SIZE)
        .with_context(|| format!("Failed to read response body from {}", url))?;

    cache_write(&cache_key, &content);
    Ok(content)
}

// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------

/// Return the cache directory: `$XDG_CACHE_HOME/cui-cui` or `~/.cache/cui-cui`.
pub(crate) fn cache_dir() -> Option<PathBuf> {
    let base = std::env::var("XDG_CACHE_HOME")
        .map(PathBuf::from)
        .ok()
        .filter(|p| p.is_absolute())
        .or_else(|| {
            std::env::var("HOME")
                .ok()
                .map(|h| PathBuf::from(h).join(".cache"))
        })?;
    Some(base.join("cui-cui"))
}

/// Read a cached file by key (a relative path under the cache dir).
fn cache_read(key: &str) -> Option<String> {
    cache_read_from(&cache_dir()?, key)
}

/// Write content to a cache file by key.
fn cache_write(key: &str, content: &str) {
    let Some(dir) = cache_dir() else { return };
    cache_write_to(&dir, key, content);
}

fn cache_read_from(base: &Path, key: &str) -> Option<String> {
    let path = base.join(sanitize_cache_key(key));
    fs::read_to_string(path).ok()
}

fn cache_write_to(base: &Path, key: &str, content: &str) {
    let path = base.join(sanitize_cache_key(key));
    if let Some(parent) = path.parent() {
        let _ = fs::create_dir_all(parent);
    }
    let _ = fs::write(path, content);
}

/// Sanitize a cache key by replacing `..` path components to prevent
/// path traversal outside the cache directory.
fn sanitize_cache_key(key: &str) -> String {
    use std::path::Component;
    let path = Path::new(key);
    let safe: PathBuf = path
        .components()
        .filter_map(|c| match c {
            Component::Normal(s) => Some(s),
            _ => None, // strip RootDir, ParentDir, CurDir, Prefix
        })
        .collect();
    safe.to_string_lossy().into_owned()
}

/// Convert a URL to a safe file-system path for caching.
fn url_to_cache_path(url: &str) -> String {
    // Strip scheme, replace unsafe chars
    let stripped = url
        .strip_prefix("https://")
        .or_else(|| url.strip_prefix("http://"))
        .unwrap_or(url);
    stripped.replace(['?', '&', '=', '#'], "/")
}

// ---------------------------------------------------------------------------
// Git remote detection
// ---------------------------------------------------------------------------

/// Detect GitLab base URL from git remote origin.
fn detect_from_git_remote(file_path: &Path) -> Option<String> {
    let dir = if file_path.is_dir() {
        file_path
    } else {
        file_path.parent()?
    };

    let remote_name = detect_remote_name(dir);
    let remote_url = git_cmd(dir, &["remote", "get-url", &remote_name])?;
    extract_gitlab_url(&remote_url)
}

/// Extract the base GitLab URL from a git remote URL.
/// Supports:
///   - `git@gitlab.example.com:group/project.git`
///   - `ssh://git@ssh.gitlab.example.com/group/project.git`  (note ssh. prefix)
///   - `https://gitlab.example.com/group/project.git`
fn extract_gitlab_url(remote: &str) -> Option<String> {
    if remote.starts_with("https://") || remote.starts_with("http://") {
        let url = url::Url::parse(remote).ok()?;
        Some(format!("{}://{}", url.scheme(), url.host_str()?))
    } else if remote.starts_with("ssh://") {
        let url = url::Url::parse(remote).ok()?;
        let host = url.host_str()?;
        let api_host = host.strip_prefix("ssh.").unwrap_or(host);
        Some(format!("https://{}", api_host))
    } else if let Some(at_pos) = remote.find('@') {
        let after_at = &remote[at_pos + 1..];
        let host = after_at.split(':').next()?;
        let api_host = host.strip_prefix("ssh.").unwrap_or(host);
        Some(format!("https://{}", api_host))
    } else {
        None
    }
}

/// Percent-encode a string for use in a URL path segment (e.g. GitLab project paths).
pub(crate) fn urlencoded(s: &str) -> String {
    percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}

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

    #[test]
    fn test_extract_gitlab_url_ssh() {
        assert_eq!(
            extract_gitlab_url(
                "git@ssh.gitlab.freedesktop.org:freedesktop/helm-gitlab-deployment.git"
            ),
            Some("https://gitlab.freedesktop.org".to_string())
        );
    }

    #[test]
    fn test_extract_gitlab_url_ssh_no_prefix() {
        assert_eq!(
            extract_gitlab_url("git@gitlab.com:group/project.git"),
            Some("https://gitlab.com".to_string())
        );
    }

    #[test]
    fn test_extract_gitlab_url_https() {
        assert_eq!(
            extract_gitlab_url("https://gitlab.freedesktop.org/group/project.git"),
            Some("https://gitlab.freedesktop.org".to_string())
        );
    }

    #[test]
    fn test_extract_gitlab_url_ssh_scheme() {
        assert_eq!(
            extract_gitlab_url("ssh://git@ssh.gitlab.freedesktop.org/group/project.git"),
            Some("https://gitlab.freedesktop.org".to_string())
        );
    }

    #[test]
    fn test_cache_key_for_project() {
        let key = format!(
            "project/{}/{}/{}",
            "group/proj", "abc123", "templates/ci.yml"
        );
        assert_eq!(key, "project/group/proj/abc123/templates/ci.yml");
    }

    #[test]
    fn test_url_to_cache_path() {
        assert_eq!(
            url_to_cache_path("https://gitlab.com/org/repo/-/raw/main/file.yml"),
            "gitlab.com/org/repo/-/raw/main/file.yml"
        );
        assert_eq!(
            url_to_cache_path("https://example.com/api?ref=abc&file=x.yml"),
            "example.com/api/ref/abc/file/x.yml"
        );
    }

    #[test]
    fn test_url_to_cache_path_http() {
        assert_eq!(
            url_to_cache_path("http://example.com/file.yml"),
            "example.com/file.yml"
        );
    }

    #[test]
    fn test_url_to_cache_path_no_scheme() {
        assert_eq!(
            url_to_cache_path("example.com/file.yml"),
            "example.com/file.yml"
        );
    }

    #[test]
    fn test_cache_write_and_read() {
        let dir = tempfile::tempdir().unwrap();
        let base = dir.path();

        let key = "test/cache_roundtrip.yml";
        let content = "stages:\n  - build\n";

        cache_write_to(base, key, content);
        let read_back = cache_read_from(base, key);
        assert_eq!(read_back, Some(content.to_string()));

        // Verify file is at expected path
        assert!(base.join(key).exists());
    }

    #[test]
    fn test_cache_read_missing() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(cache_read_from(dir.path(), "nonexistent/file.yml"), None);
    }

    #[test]
    fn test_cache_write_creates_nested_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let base = dir.path();

        let key = "project/group/subgroup/proj/abc123/deeply/nested/file.yml";
        cache_write_to(base, key, "content");
        assert_eq!(cache_read_from(base, key), Some("content".to_string()));
    }

    /// Helper to build a GitlabConfig without needing a real git repo.
    fn make_config(base_url: &str) -> GitlabConfig {
        GitlabConfig {
            base_url: base_url.to_string(),
            token: None,
            client: build_http_client(),
        }
    }

    #[test]
    fn test_client_is_reused_across_calls() {
        let config = make_config("https://gitlab.example.com");
        // client() returns the same Arc-backed handle each time
        let c1 = config.client().clone();
        let c2 = config.client().clone();
        // Both should work identically — reqwest::Client is Arc-based,
        // so clones share the connection pool.
        // We can't compare pointers directly, but we can verify both are functional.
        assert_eq!(format!("{:?}", c1), format!("{:?}", c2));
    }

    #[test]
    fn test_shared_client_is_initialized() {
        // SHARED_CLIENT should be lazily initialized and reusable.
        let c1 = &*SHARED_CLIENT;
        let c2 = &*SHARED_CLIENT;
        // Same static reference — same client.
        assert!(std::ptr::eq(c1, c2));
    }

    #[test]
    fn test_build_http_client_succeeds() {
        // Should not panic.
        let _client = build_http_client();
    }

    #[test]
    fn test_sanitize_cache_key_normal() {
        assert_eq!(
            sanitize_cache_key("project/group/proj/abc123/file.yml"),
            "project/group/proj/abc123/file.yml"
        );
    }

    #[test]
    fn test_sanitize_cache_key_strips_parent_dir() {
        assert_eq!(
            sanitize_cache_key("project/../../etc/passwd"),
            "project/etc/passwd"
        );
    }

    #[test]
    fn test_sanitize_cache_key_strips_absolute() {
        assert_eq!(sanitize_cache_key("/etc/passwd"), "etc/passwd");
    }

    #[test]
    fn test_sanitize_cache_key_strips_dot() {
        assert_eq!(
            sanitize_cache_key("project/./subdir/file.yml"),
            "project/subdir/file.yml"
        );
    }

    #[test]
    fn test_sanitize_cache_key_multiple_traversals() {
        assert_eq!(sanitize_cache_key("a/../../../b/../c"), "a/b/c");
    }

    #[test]
    fn test_cache_traversal_stays_inside_base() {
        let dir = tempfile::tempdir().unwrap();
        let base = dir.path();

        // An attacker-controlled project path with traversal
        let malicious_key = "project/../../etc/evil";
        cache_write_to(base, malicious_key, "pwned");

        // The file should end up inside the cache dir, not at /etc/evil
        assert!(!Path::new("/etc/evil").exists());
        // It should be written as project/etc/evil inside base
        assert!(base.join("project/etc/evil").exists());
        assert_eq!(
            cache_read_from(base, malicious_key),
            Some("pwned".to_string())
        );
    }

    #[test]
    fn test_cache_absolute_key_stays_inside_base() {
        let dir = tempfile::tempdir().unwrap();
        let base = dir.path();

        cache_write_to(base, "/etc/passwd", "nope");
        assert!(base.join("etc/passwd").exists());
        assert_eq!(
            cache_read_from(base, "/etc/passwd"),
            Some("nope".to_string())
        );
    }

    #[test]
    fn test_urlencoded_git_ref_prevents_injection() {
        // A malicious ref like "main&private_token=STOLEN" must be encoded
        // so it doesn't inject extra query parameters.
        let encoded = urlencoded("main&private_token=STOLEN");
        assert!(
            !encoded.contains('&'),
            "encoded ref must not contain raw '&'"
        );
        assert_eq!(encoded, "main%26private%5Ftoken%3DSTOLEN");
    }

    #[test]
    fn test_urlencoded_git_ref_special_chars() {
        // Refs with #, =, ?, spaces
        let encoded = urlencoded("v1.0#fragment");
        assert!(!encoded.contains('#'));

        let encoded = urlencoded("ref with spaces");
        assert!(!encoded.contains(' '));

        let encoded = urlencoded("ref?key=val");
        assert!(!encoded.contains('?'));
        assert!(!encoded.contains('='));
    }

    /// Reconstruct the file API URL the same way fetch_project_file does,
    /// and verify the ref is properly encoded.
    #[test]
    fn test_project_file_url_encodes_ref() {
        let base_url = "https://gitlab.example.com";
        let project = "group/project";
        let file_path = "ci/config.yml";
        let git_ref = "main&private_token=EVIL";

        let encoded_project = urlencoded(project);
        let encoded_path = urlencoded(file_path);
        let encoded_ref = urlencoded(git_ref);
        let url = format!(
            "{}/api/v4/projects/{}/repository/files/{}/raw?ref={}",
            base_url, encoded_project, encoded_path, encoded_ref
        );

        // The URL should not contain a raw & from the ref value
        // (only the one separating ?ref= from any other params)
        let query = url.split_once("?ref=").unwrap().1;
        assert!(
            !query.contains('&'),
            "ref value must not inject extra query params: {}",
            query
        );
    }

    /// Reconstruct the tree API URL the same way list_project_tree does,
    /// and verify the ref is properly encoded.
    #[test]
    fn test_tree_url_encodes_ref() {
        let base_url = "https://gitlab.example.com";
        let project = "group/project";
        let path = "ci/";
        let git_ref = "main&admin=true";

        let encoded_project = urlencoded(project);
        let encoded_ref = urlencoded(git_ref);
        let url = format!(
            "{}/api/v4/projects/{}/repository/tree?path={}&ref={}&per_page=100&page=1&recursive=true",
            base_url,
            encoded_project,
            urlencoded(path),
            encoded_ref
        );

        // Extract the ref= value: it's between "ref=" and the next "&"
        let after_ref = url.split("&ref=").nth(1).unwrap();
        let ref_value = after_ref.split('&').next().unwrap();
        // The encoded ref must not contain raw & or =
        assert!(!ref_value.contains('&'), "ref leaked raw &: {}", ref_value);
        assert!(!ref_value.contains('='), "ref leaked raw =: {}", ref_value);
    }

    #[test]
    fn test_read_limited_within_limit() {
        let data = b"hello world";
        let result = read_limited(std::io::Cursor::new(data), 100);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), b"hello world");
    }

    #[test]
    fn test_read_limited_exact_limit() {
        let data = b"12345";
        let result = read_limited(std::io::Cursor::new(data), 5);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), b"12345");
    }

    #[test]
    fn test_read_limited_exceeds_limit() {
        let data = b"123456";
        let result = read_limited(std::io::Cursor::new(data), 5);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("too large"));
    }

    #[test]
    fn test_read_limited_empty() {
        let data = b"";
        let result = read_limited(std::io::Cursor::new(data), 100);
        assert!(result.is_ok());
        assert!(result.unwrap().is_empty());
    }

    #[test]
    fn test_read_limited_zero_limit() {
        // Even 1 byte should fail with a 0-byte limit
        let data = b"x";
        let result = read_limited(std::io::Cursor::new(data), 0);
        assert!(result.is_err());

        // Empty data should succeed even with 0-byte limit
        let empty = b"";
        let result = read_limited(std::io::Cursor::new(empty), 0);
        assert!(result.is_ok());
    }

    #[test]
    fn test_read_limited_large_data() {
        // 1 MB of data with a 512 KB limit
        let data = vec![0u8; 1024 * 1024];
        let result = read_limited(std::io::Cursor::new(data), 512 * 1024);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("too large"));
    }

    #[test]
    fn test_max_response_size_constants() {
        assert_eq!(MAX_RESPONSE_SIZE, 10 * 1024 * 1024);
        assert_eq!(MAX_DOWNLOAD_SIZE, 100 * 1024 * 1024);
        assert!(MAX_DOWNLOAD_SIZE > MAX_RESPONSE_SIZE);
    }

    #[test]
    fn test_url_to_cache_path_with_traversal() {
        // Even if the URL contains .., sanitize_cache_key handles it
        let cache_path = url_to_cache_path("https://evil.com/../../etc/passwd");
        // url_to_cache_path strips scheme: "evil.com/../../etc/passwd"
        assert_eq!(cache_path, "evil.com/../../etc/passwd");
        // But sanitize_cache_key (applied in cache_write_to) makes it safe
        assert_eq!(
            sanitize_cache_key(&format!("remote/{}", cache_path)),
            "remote/evil.com/etc/passwd"
        );
    }
}
