//! Pack installer module for downloading and extracting packs from various sources
//!
//! This module provides functionality for:
//! - Cloning git repositories
//! - Downloading and extracting archives (zip, tar.gz)
//! - Copying local directories
//! - Verifying checksums
//! - Resolving registry references to install sources
//! - Progress reporting during installation

use super::{Checksum, InstallSource, PackIndexEntry, RegistryClient};
use crate::config::PackRegistryConfig;
use crate::error::{Error, Result};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::net::lookup_host;
use tokio::process::Command;
use url::Url;

/// Progress callback type
pub type ProgressCallback = Arc<dyn Fn(ProgressEvent) + Send + Sync>;

/// Progress event during pack installation
#[derive(Debug, Clone)]
pub enum ProgressEvent {
    /// Started a new step
    StepStarted { step: String, message: String },
    /// Step completed
    StepCompleted { step: String, message: String },
    /// Download progress
    Downloading {
        url: String,
        downloaded_bytes: u64,
        total_bytes: Option<u64>,
    },
    /// Extraction progress
    Extracting { file: String },
    /// Verification progress
    Verifying { message: String },
    /// Warning message
    Warning { message: String },
    /// Info message
    Info { message: String },
}

/// Pack installer for handling various installation sources
pub struct PackInstaller {
    /// Temporary directory for downloads
    temp_dir: PathBuf,

    /// Registry client for resolving pack references
    registry_client: Option<RegistryClient>,

    /// Whether to verify checksums
    verify_checksums: bool,

    /// Whether HTTP remote sources are allowed
    allow_http: bool,

    /// Remote hosts allowed for archive/git installs
    allowed_remote_hosts: Option<HashSet<String>>,

    /// Progress callback (optional)
    progress_callback: Option<ProgressCallback>,
}

/// Information about an installed pack
#[derive(Debug, Clone)]
pub struct InstalledPack {
    /// Path to the pack directory
    pub path: PathBuf,

    /// Installation source
    pub source: PackSource,

    /// Checksum (if available and verified)
    pub checksum: Option<String>,
}

/// Pack installation source type
#[derive(Debug, Clone)]
pub enum PackSource {
    /// Git repository
    Git {
        url: String,
        git_ref: Option<String>,
    },

    /// Archive URL (zip, tar.gz, tgz)
    Archive { url: String },

    /// Local directory
    LocalDirectory { path: PathBuf },

    /// Local archive file
    LocalArchive { path: PathBuf },

    /// Registry reference
    Registry {
        pack_ref: String,
        version: Option<String>,
    },
}

impl PackInstaller {
    /// Create a new pack installer
    pub async fn new(
        temp_base_dir: impl AsRef<Path>,
        registry_config: Option<PackRegistryConfig>,
    ) -> Result<Self> {
        let temp_dir = temp_base_dir.as_ref().join("pack-installs");
        fs::create_dir_all(&temp_dir)
            .await
            .map_err(|e| Error::internal(format!("Failed to create temp directory: {}", e)))?;

        let (registry_client, verify_checksums, allow_http, allowed_remote_hosts) =
            if let Some(config) = registry_config {
                let verify_checksums = config.verify_checksums;
                let allow_http = config.allow_http;
                let allowed_remote_hosts = collect_allowed_remote_hosts(&config)?;
                let allowed_remote_hosts = if allowed_remote_hosts.is_empty() {
                    None
                } else {
                    Some(allowed_remote_hosts)
                };
                (
                    Some(RegistryClient::new(config)?),
                    verify_checksums,
                    allow_http,
                    allowed_remote_hosts,
                )
            } else {
                (None, false, false, None)
            };

        Ok(Self {
            temp_dir,
            registry_client,
            verify_checksums,
            allow_http,
            allowed_remote_hosts,
            progress_callback: None,
        })
    }

    /// Set progress callback
    pub fn with_progress_callback(mut self, callback: ProgressCallback) -> Self {
        self.progress_callback = Some(callback);
        self
    }

    /// Report progress event
    fn report_progress(&self, event: ProgressEvent) {
        if let Some(ref callback) = self.progress_callback {
            callback(event);
        }
    }

    /// Install a pack from the given source
    pub async fn install(&self, source: PackSource) -> Result<InstalledPack> {
        match source {
            PackSource::Git { url, git_ref } => {
                self.install_from_git(&url, git_ref.as_deref()).await
            }
            PackSource::Archive { url } => self.install_from_archive_url(&url, None).await,
            PackSource::LocalDirectory { path } => self.install_from_local_directory(&path).await,
            PackSource::LocalArchive { path } => self.install_from_local_archive(&path).await,
            PackSource::Registry { pack_ref, version } => {
                self.install_from_registry(&pack_ref, version.as_deref())
                    .await
            }
        }
    }

    /// Install from git repository
    async fn install_from_git(&self, url: &str, git_ref: Option<&str>) -> Result<InstalledPack> {
        self.validate_git_source(url).await?;
        tracing::info!("Installing pack from git: {} (ref: {:?})", url, git_ref);

        self.report_progress(ProgressEvent::StepStarted {
            step: "clone".to_string(),
            message: format!("Cloning git repository: {}", url),
        });

        // Create unique temp directory for this installation
        let install_dir = self.create_temp_dir().await?;

        // Clone the repository
        let mut clone_cmd = Command::new("git");
        clone_cmd.arg("clone");

        // Add depth=1 for faster cloning if no specific ref
        if git_ref.is_none() {
            clone_cmd.arg("--depth").arg("1");
        }

        clone_cmd.arg(url).arg(&install_dir);

        let output = clone_cmd
            .output()
            .await
            .map_err(|e| Error::internal(format!("Failed to execute git clone: {}", e)))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(Error::internal(format!("Git clone failed: {}", stderr)));
        }

        // Checkout specific ref if provided
        if let Some(ref_spec) = git_ref {
            let checkout_output = Command::new("git")
                .arg("-C")
                .arg(&install_dir)
                .arg("checkout")
                .arg(ref_spec)
                .output()
                .await
                .map_err(|e| Error::internal(format!("Failed to execute git checkout: {}", e)))?;

            if !checkout_output.status.success() {
                let stderr = String::from_utf8_lossy(&checkout_output.stderr);
                return Err(Error::internal(format!("Git checkout failed: {}", stderr)));
            }
        }

        // Find pack.yaml (could be at root or in pack/ subdirectory)
        let pack_dir = self.find_pack_directory(&install_dir).await?;

        Ok(InstalledPack {
            path: pack_dir,
            source: PackSource::Git {
                url: url.to_string(),
                git_ref: git_ref.map(String::from),
            },
            checksum: None,
        })
    }

    /// Install from archive URL
    async fn install_from_archive_url(
        &self,
        url: &str,
        expected_checksum: Option<&str>,
    ) -> Result<InstalledPack> {
        tracing::info!("Installing pack from archive: {}", url);

        // Download the archive
        let archive_path = self.download_archive(url).await?;

        // Verify checksum if provided
        if let Some(checksum_str) = expected_checksum {
            if self.verify_checksums {
                self.verify_archive_checksum(&archive_path, checksum_str)
                    .await?;
            }
        }

        // Extract the archive
        let extract_dir = self.extract_archive(&archive_path).await?;

        // Find pack.yaml
        let pack_dir = self.find_pack_directory(&extract_dir).await?;

        // Clean up archive file
        let _ = fs::remove_file(&archive_path).await;

        Ok(InstalledPack {
            path: pack_dir,
            source: PackSource::Archive {
                url: url.to_string(),
            },
            checksum: expected_checksum.map(String::from),
        })
    }

    /// Install from local directory
    async fn install_from_local_directory(&self, source_path: &Path) -> Result<InstalledPack> {
        tracing::info!("Installing pack from local directory: {:?}", source_path);

        // Verify source exists and is a directory
        if !source_path.exists() {
            return Err(Error::not_found(
                "directory",
                "path",
                source_path.display().to_string(),
            ));
        }

        if !source_path.is_dir() {
            return Err(Error::validation(format!(
                "Path is not a directory: {}",
                source_path.display()
            )));
        }

        // Create temp directory
        let install_dir = self.create_temp_dir().await?;

        // Copy directory contents
        self.copy_directory(source_path, &install_dir).await?;

        // Find pack.yaml
        let pack_dir = self.find_pack_directory(&install_dir).await?;

        Ok(InstalledPack {
            path: pack_dir,
            source: PackSource::LocalDirectory {
                path: source_path.to_path_buf(),
            },
            checksum: None,
        })
    }

    /// Install from local archive file
    async fn install_from_local_archive(&self, archive_path: &Path) -> Result<InstalledPack> {
        tracing::info!("Installing pack from local archive: {:?}", archive_path);

        // Verify file exists
        if !archive_path.exists() {
            return Err(Error::not_found(
                "file",
                "path",
                archive_path.display().to_string(),
            ));
        }

        if !archive_path.is_file() {
            return Err(Error::validation(format!(
                "Path is not a file: {}",
                archive_path.display()
            )));
        }

        // Extract the archive
        let extract_dir = self.extract_archive(archive_path).await?;

        // Find pack.yaml
        let pack_dir = self.find_pack_directory(&extract_dir).await?;

        Ok(InstalledPack {
            path: pack_dir,
            source: PackSource::LocalArchive {
                path: archive_path.to_path_buf(),
            },
            checksum: None,
        })
    }

    /// Install from registry reference
    async fn install_from_registry(
        &self,
        pack_ref: &str,
        version: Option<&str>,
    ) -> Result<InstalledPack> {
        tracing::info!(
            "Installing pack from registry: {} (version: {:?})",
            pack_ref,
            version
        );

        let registry_client = self
            .registry_client
            .as_ref()
            .ok_or_else(|| Error::configuration("Registry client not configured"))?;

        // Search for the pack
        let (pack_entry, _registry_url) = registry_client
            .search_pack(pack_ref)
            .await?
            .ok_or_else(|| Error::not_found("pack", "ref", pack_ref))?;

        // Validate version if specified
        if let Some(requested_version) = version {
            if requested_version != "latest" && pack_entry.version != requested_version {
                return Err(Error::validation(format!(
                    "Pack {} version {} not found (available: {})",
                    pack_ref, requested_version, pack_entry.version
                )));
            }
        }

        // Get the preferred install source (try git first, then archive)
        let install_source = self.select_install_source(&pack_entry)?;

        // Install from the selected source
        match install_source {
            InstallSource::Git {
                url,
                git_ref,
                checksum,
            } => {
                let mut installed = self.install_from_git(&url, git_ref.as_deref()).await?;
                installed.checksum = Some(checksum);
                Ok(installed)
            }
            InstallSource::Archive { url, checksum } => {
                self.install_from_archive_url(&url, Some(&checksum)).await
            }
        }
    }

    /// Select the best install source from a pack entry
    fn select_install_source(&self, pack_entry: &PackIndexEntry) -> Result<InstallSource> {
        if pack_entry.install_sources.is_empty() {
            return Err(Error::validation(format!(
                "Pack {} has no install sources",
                pack_entry.pack_ref
            )));
        }

        // Prefer git sources for development
        for source in &pack_entry.install_sources {
            if matches!(source, InstallSource::Git { .. }) {
                return Ok(source.clone());
            }
        }

        // Fall back to first archive source
        for source in &pack_entry.install_sources {
            if matches!(source, InstallSource::Archive { .. }) {
                return Ok(source.clone());
            }
        }

        // Return first source if no preference matched
        Ok(pack_entry.install_sources[0].clone())
    }

    /// Download an archive from a URL
    async fn download_archive(&self, url: &str) -> Result<PathBuf> {
        let parsed_url = self.validate_remote_url(url).await?;
        let client = reqwest::Client::new();

        // nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- Remote source URLs are restricted to configured allowlisted hosts, HTTPS, and public IPs before request execution.
        let response = client
            .get(parsed_url.clone())
            .send()
            .await
            .map_err(|e| Error::internal(format!("Failed to download archive: {}", e)))?;

        if !response.status().is_success() {
            return Err(Error::internal(format!(
                "Failed to download archive: HTTP {}",
                response.status()
            )));
        }

        // Determine filename from URL
        let filename = archive_filename_from_url(&parsed_url);

        let archive_path = self.temp_dir.join(&filename);

        // Download to file
        let bytes = response
            .bytes()
            .await
            .map_err(|e| Error::internal(format!("Failed to read archive bytes: {}", e)))?;

        fs::write(&archive_path, &bytes)
            .await
            .map_err(|e| Error::internal(format!("Failed to write archive: {}", e)))?;

        Ok(archive_path)
    }

    async fn validate_remote_url(&self, raw_url: &str) -> Result<Url> {
        let parsed = Url::parse(raw_url)
            .map_err(|e| Error::validation(format!("Invalid remote URL '{}': {}", raw_url, e)))?;

        if parsed.scheme() != "https" && !(self.allow_http && parsed.scheme() == "http") {
            return Err(Error::validation(format!(
                "Remote URL must use https{}: {}",
                if self.allow_http {
                    " or http when pack_registry.allow_http is enabled"
                } else {
                    ""
                },
                raw_url
            )));
        }

        if !parsed.username().is_empty() || parsed.password().is_some() {
            return Err(Error::validation(
                "Remote URLs with embedded credentials are not allowed".to_string(),
            ));
        }

        let host = parsed.host_str().ok_or_else(|| {
            Error::validation(format!("Remote URL is missing a host: {}", raw_url))
        })?;
        let normalized_host = host.to_ascii_lowercase();

        // Whether the host is explicitly trusted via the allowlist. Explicitly allowlisted hosts
        // bypass private-IP checks so that local/private registries (e.g. a self-hosted Gitea)
        // can be used in development or air-gapped environments.
        let host_is_allowlisted = self
            .allowed_remote_hosts
            .as_ref()
            .map(|set| set.contains(&normalized_host))
            .unwrap_or(false);

        if normalized_host == "localhost" && !host_is_allowlisted {
            return Err(Error::validation(format!(
                "Remote URL host is not allowed: {}",
                host
            )));
        }

        if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
            if !allowed_remote_hosts.contains(&normalized_host) {
                return Err(Error::validation(format!(
                    "Remote URL host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
                    host
                )));
            }
        }

        if !host_is_allowlisted {
            if let Some(ip) = parsed.host().and_then(|host| match host {
                url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
                url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
                url::Host::Domain(_) => None,
            }) {
                ensure_public_ip(ip)?;
            }
        }

        let port = parsed.port_or_known_default().ok_or_else(|| {
            Error::validation(format!("Remote URL is missing a usable port: {}", raw_url))
        })?;

        let resolved = lookup_host((host, port))
            .await
            .map_err(|e| Error::validation(format!("Failed to resolve host '{}': {}", host, e)))?;

        let mut saw_address = false;
        for addr in resolved {
            saw_address = true;
            if !host_is_allowlisted {
                ensure_public_ip(addr.ip())?;
            }
        }

        if !saw_address {
            return Err(Error::validation(format!(
                "Remote URL host did not resolve to any addresses: {}",
                host
            )));
        }

        Ok(parsed)
    }

    async fn validate_git_source(&self, raw_url: &str) -> Result<()> {
        if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
            self.validate_remote_url(raw_url).await?;
            return Ok(());
        }

        if let Some(host) = extract_git_host(raw_url) {
            self.validate_remote_host(&host)?;
        }

        Ok(())
    }

    fn validate_remote_host(&self, host: &str) -> Result<()> {
        let normalized_host = host.to_ascii_lowercase();

        let host_is_allowlisted = self
            .allowed_remote_hosts
            .as_ref()
            .map(|set| set.contains(&normalized_host))
            .unwrap_or(false);

        if normalized_host == "localhost" && !host_is_allowlisted {
            return Err(Error::validation(format!(
                "Remote host is not allowed: {}",
                host
            )));
        }

        if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
            if !allowed_remote_hosts.contains(&normalized_host) {
                return Err(Error::validation(format!(
                    "Remote host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
                    host
                )));
            }
        }

        Ok(())
    }

    /// Extract an archive (zip or tar.gz)
    async fn extract_archive(&self, archive_path: &Path) -> Result<PathBuf> {
        let extract_dir = self.create_temp_dir().await?;

        let extension = archive_path
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");

        match extension {
            "zip" => self.extract_zip(archive_path, &extract_dir).await?,
            "gz" | "tgz" => self.extract_tar_gz(archive_path, &extract_dir).await?,
            _ => {
                return Err(Error::validation(format!(
                    "Unsupported archive format: {}",
                    extension
                )));
            }
        }

        Ok(extract_dir)
    }

    /// Extract a zip archive
    async fn extract_zip(&self, archive_path: &Path, extract_dir: &Path) -> Result<()> {
        let output = Command::new("unzip")
            .arg("-q") // Quiet
            .arg(archive_path)
            .arg("-d")
            .arg(extract_dir)
            .output()
            .await
            .map_err(|e| Error::internal(format!("Failed to execute unzip: {}", e)))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(Error::internal(format!(
                "Failed to extract zip: {}",
                stderr
            )));
        }

        Ok(())
    }

    /// Extract a tar.gz archive
    async fn extract_tar_gz(&self, archive_path: &Path, extract_dir: &Path) -> Result<()> {
        let output = Command::new("tar")
            .arg("xzf")
            .arg(archive_path)
            .arg("-C")
            .arg(extract_dir)
            .output()
            .await
            .map_err(|e| Error::internal(format!("Failed to execute tar: {}", e)))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(Error::internal(format!(
                "Failed to extract tar.gz: {}",
                stderr
            )));
        }

        Ok(())
    }

    /// Verify archive checksum
    async fn verify_archive_checksum(&self, archive_path: &Path, checksum_str: &str) -> Result<()> {
        let checksum = Checksum::parse(checksum_str)
            .map_err(|e| Error::validation(format!("Invalid checksum: {}", e)))?;

        let computed = self
            .compute_checksum(archive_path, &checksum.algorithm)
            .await?;

        if computed != checksum.hash {
            return Err(Error::validation(format!(
                "Checksum mismatch: expected {}, got {}",
                checksum.hash, computed
            )));
        }

        tracing::info!("Checksum verified: {}", checksum_str);
        Ok(())
    }

    /// Compute checksum of a file
    async fn compute_checksum(&self, path: &Path, algorithm: &str) -> Result<String> {
        let command = match algorithm {
            "sha256" => "sha256sum",
            "sha512" => "sha512sum",
            "sha1" => "sha1sum",
            "md5" => "md5sum",
            _ => {
                return Err(Error::validation(format!(
                    "Unsupported hash algorithm: {}",
                    algorithm
                )));
            }
        };

        let output = Command::new(command)
            .arg(path)
            .output()
            .await
            .map_err(|e| Error::internal(format!("Failed to compute checksum: {}", e)))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(Error::internal(format!(
                "Checksum computation failed: {}",
                stderr
            )));
        }

        let stdout = String::from_utf8_lossy(&output.stdout);
        let hash = stdout
            .split_whitespace()
            .next()
            .ok_or_else(|| Error::internal("Failed to parse checksum output"))?;

        Ok(hash.to_lowercase())
    }

    /// Find pack directory (pack.yaml location)
    async fn find_pack_directory(&self, base_dir: &Path) -> Result<PathBuf> {
        // Check if pack.yaml exists at root
        let root_pack_yaml = base_dir.join("pack.yaml");
        if root_pack_yaml.exists() {
            return Ok(base_dir.to_path_buf());
        }

        // Check in pack/ subdirectory
        let pack_subdir = base_dir.join("pack");
        let pack_subdir_yaml = pack_subdir.join("pack.yaml");
        if pack_subdir_yaml.exists() {
            return Ok(pack_subdir);
        }

        // Check in first subdirectory (common for GitHub archives)
        // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Archive inspection is limited to the temporary extraction directory created by this installer.
        let mut entries = fs::read_dir(base_dir)
            .await
            .map_err(|e| Error::internal(format!("Failed to read directory: {}", e)))?;

        while let Some(entry) = entries
            .next_entry()
            .await
            .map_err(|e| Error::internal(format!("Failed to read directory entry: {}", e)))?
        {
            let path = entry.path();
            if path.is_dir() {
                let subdir_pack_yaml = path.join("pack.yaml");
                if subdir_pack_yaml.exists() {
                    return Ok(path);
                }
            }
        }

        Err(Error::validation(format!(
            "pack.yaml not found in {}",
            base_dir.display()
        )))
    }

    /// Copy directory recursively
    #[async_recursion::async_recursion]
    async fn copy_directory(&self, src: &Path, dst: &Path) -> Result<()> {
        use tokio::fs;

        // Create destination directory if it doesn't exist
        fs::create_dir_all(dst).await.map_err(|e| {
            Error::internal(format!("Failed to create destination directory: {}", e))
        })?;

        // Read source directory
        // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Directory copy operates on installer-managed local paths, not request-derived paths.
        let mut entries = fs::read_dir(src)
            .await
            .map_err(|e| Error::internal(format!("Failed to read source directory: {}", e)))?;

        // Copy each entry
        while let Some(entry) = entries
            .next_entry()
            .await
            .map_err(|e| Error::internal(format!("Failed to read directory entry: {}", e)))?
        {
            let path = entry.path();
            let file_name = entry.file_name();
            let dest_path = dst.join(&file_name);

            let metadata = entry
                .metadata()
                .await
                .map_err(|e| Error::internal(format!("Failed to read entry metadata: {}", e)))?;

            if metadata.is_dir() {
                // Recursively copy subdirectory
                self.copy_directory(&path, &dest_path).await?;
            } else {
                // Copy file
                fs::copy(&path, &dest_path)
                    .await
                    .map_err(|e| Error::internal(format!("Failed to copy file: {}", e)))?;
            }
        }

        Ok(())
    }

    /// Create a unique temporary directory
    async fn create_temp_dir(&self) -> Result<PathBuf> {
        let uuid = uuid::Uuid::new_v4();
        let dir = self.temp_dir.join(uuid.to_string());

        fs::create_dir_all(&dir)
            .await
            .map_err(|e| Error::internal(format!("Failed to create temp directory: {}", e)))?;

        Ok(dir)
    }

    /// Clean up temporary directory
    pub async fn cleanup(&self, pack_path: &Path) -> Result<()> {
        if pack_path.starts_with(&self.temp_dir) {
            fs::remove_dir_all(pack_path)
                .await
                .map_err(|e| Error::internal(format!("Failed to cleanup temp directory: {}", e)))?;
        }
        Ok(())
    }
}

fn collect_allowed_remote_hosts(config: &PackRegistryConfig) -> Result<HashSet<String>> {
    let mut hosts = HashSet::new();

    for index in &config.indices {
        if !index.enabled {
            continue;
        }

        let parsed = Url::parse(&index.url).map_err(|e| {
            Error::validation(format!("Invalid registry index URL '{}': {}", index.url, e))
        })?;

        let host = parsed.host_str().ok_or_else(|| {
            Error::validation(format!(
                "Registry index URL '{}' is missing a host",
                index.url
            ))
        })?;

        hosts.insert(host.to_ascii_lowercase());
    }

    for host in &config.allowed_source_hosts {
        let normalized = host.trim().to_ascii_lowercase();
        if !normalized.is_empty() {
            hosts.insert(normalized);
        }
    }

    Ok(hosts)
}

fn extract_git_host(raw_url: &str) -> Option<String> {
    if let Ok(parsed) = Url::parse(raw_url) {
        return parsed.host_str().map(|host| host.to_ascii_lowercase());
    }

    raw_url.split_once('@').and_then(|(_, rest)| {
        rest.split_once(':')
            .map(|(host, _)| host.to_ascii_lowercase())
    })
}

fn archive_filename_from_url(url: &Url) -> String {
    let raw_name = url
        .path_segments()
        .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
        .unwrap_or("archive.bin");

    let sanitized: String = raw_name
        .chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => ch,
            _ => '_',
        })
        .collect();

    let filename = sanitized.trim_matches('.');
    if filename.is_empty() {
        "archive.bin".to_string()
    } else {
        filename.to_string()
    }
}

fn ensure_public_ip(ip: IpAddr) -> Result<()> {
    let is_blocked = match ip {
        IpAddr::V4(ip) => {
            let octets = ip.octets();
            let is_documentation_range = matches!(
                octets,
                [192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]
            );
            ip.is_private()
                || ip.is_loopback()
                || ip.is_link_local()
                || ip.is_multicast()
                || ip.is_broadcast()
                || is_documentation_range
                || ip.is_unspecified()
                || octets[0] == 0
        }
        IpAddr::V6(ip) => {
            let segments = ip.segments();
            let is_documentation_range = segments[0] == 0x2001 && segments[1] == 0x0db8;
            ip.is_loopback()
                || ip.is_unspecified()
                || ip.is_multicast()
                || ip.is_unique_local()
                || ip.is_unicast_link_local()
                || is_documentation_range
                || ip == Ipv6Addr::LOCALHOST
        }
    };

    if is_blocked {
        return Err(Error::validation(format!(
            "Remote URL resolved to a non-public address: {}",
            ip
        )));
    }

    Ok(())
}

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

    #[tokio::test]
    async fn test_checksum_parsing() {
        let checksum = Checksum::parse("sha256:abc123def456").unwrap();
        assert_eq!(checksum.algorithm, "sha256");
        assert_eq!(checksum.hash, "abc123def456");
    }

    #[tokio::test]
    async fn test_select_install_source_prefers_git() {
        let entry = PackIndexEntry {
            pack_ref: "test".to_string(),
            label: "Test".to_string(),
            description: "Test pack".to_string(),
            version: "1.0.0".to_string(),
            author: "Test".to_string(),
            email: None,
            homepage: None,
            repository: None,
            license: "MIT".to_string(),
            keywords: vec![],
            runtime_deps: vec![],
            install_sources: vec![
                InstallSource::Archive {
                    url: "https://example.com/archive.zip".to_string(),
                    checksum: "sha256:abc123".to_string(),
                },
                InstallSource::Git {
                    url: "https://github.com/example/pack".to_string(),
                    git_ref: Some("v1.0.0".to_string()),
                    checksum: "sha256:def456".to_string(),
                },
            ],
            contents: Default::default(),
            dependencies: None,
            meta: None,
        };

        let temp_dir = std::env::temp_dir().join("attune-test");
        let installer = PackInstaller::new(&temp_dir, None).await.unwrap();
        let source = installer.select_install_source(&entry).unwrap();

        assert!(matches!(source, InstallSource::Git { .. }));
    }

    #[test]
    fn test_archive_filename_from_url_sanitizes_path_segments() {
        let url = Url::parse("https://example.com/releases/../../pack.zip?token=x").unwrap();
        assert_eq!(archive_filename_from_url(&url), "pack.zip");
    }

    #[test]
    fn test_ensure_public_ip_rejects_private_ipv4() {
        let err = ensure_public_ip(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))).unwrap_err();
        assert!(err.to_string().contains("non-public"));
    }

    #[test]
    fn test_collect_allowed_remote_hosts_includes_indices_and_overrides() {
        let config = PackRegistryConfig {
            indices: vec![crate::config::RegistryIndexConfig {
                url: "https://registry.example.com/index.json".to_string(),
                priority: 1,
                enabled: true,
                name: None,
                headers: std::collections::HashMap::new(),
            }],
            allowed_source_hosts: vec!["github.com".to_string(), "cdn.example.com".to_string()],
            ..Default::default()
        };

        let hosts = collect_allowed_remote_hosts(&config).unwrap();
        assert!(hosts.contains("registry.example.com"));
        assert!(hosts.contains("github.com"));
        assert!(hosts.contains("cdn.example.com"));
    }

    #[tokio::test]
    async fn test_validate_remote_url_allows_allowlisted_localhost() {
        let temp_dir = std::env::temp_dir().join("attune-test");
        let config = PackRegistryConfig {
            allowed_source_hosts: vec!["localhost".to_string()],
            allow_http: true,
            ..Default::default()
        };
        let installer = PackInstaller::new(&temp_dir, Some(config)).await.unwrap();

        installer
            .validate_remote_url("http://localhost:3000/example/repo.git")
            .await
            .unwrap();
    }

    #[test]
    fn test_validate_remote_host_allows_allowlisted_localhost() {
        let installer = PackInstaller {
            temp_dir: std::env::temp_dir().join("attune-test"),
            registry_client: None,
            verify_checksums: false,
            allow_http: false,
            allowed_remote_hosts: Some(HashSet::from(["localhost".to_string()])),
            progress_callback: None,
        };

        installer.validate_remote_host("localhost").unwrap();
    }

    #[test]
    fn test_extract_git_host_from_scp_style_source() {
        assert_eq!(
            extract_git_host("git@github.com:org/repo.git"),
            Some("github.com".to_string())
        );
    }

    #[test]
    fn test_extract_git_host_from_git_scheme_source() {
        assert_eq!(
            extract_git_host("git://github.com/org/repo.git"),
            Some("github.com".to_string())
        );
    }
}
