//! Main source cache implementation

use flate2::read::GzDecoder;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;

use crate::{
    builder::ProgressHandler,
    error::CacheError,
    index::{CacheEntry, CacheIndex, SourceType},
    lock::LockManager,
    source::{AttestationVerification, Checksum, GitSource, Source, UrlSource},
};
use rattler_build_networking::BaseClient;
use rattler_git::CheckoutOptions;
use rattler_git::resolver::GitResolver;

/// Result of fetching a source from the cache
#[derive(Debug, Clone)]
pub struct SourceResult {
    /// Path to the fetched source
    pub path: PathBuf,
    /// For git sources, the resolved commit SHA
    pub git_commit: Option<String>,
}

/// The main source cache that handles Git, URL, and Path sources
pub struct SourceCache {
    cache_dir: PathBuf,
    index: CacheIndex,
    lock_manager: LockManager,
    client: BaseClient,
    git_resolver: GitResolver,
    progress_handler: Option<Box<dyn ProgressHandler>>,
}

impl SourceCache {
    /// Create a new source cache
    pub async fn new(
        cache_dir: PathBuf,
        client: BaseClient,
        progress_handler: Option<Box<dyn ProgressHandler>>,
    ) -> Result<Self, CacheError> {
        let index = CacheIndex::new(cache_dir.clone()).await?;
        let lock_manager = LockManager::new(&cache_dir).await?;

        let cache = Self {
            cache_dir,
            index,
            lock_manager,
            client,
            git_resolver: GitResolver::default(),
            progress_handler,
        };

        Ok(cache)
    }

    /// Get a source from the cache or fetch it if not present
    pub async fn get_source(&self, source: &Source) -> Result<SourceResult, CacheError> {
        match source {
            Source::Git(git_source) => self.get_git_source(git_source).await,
            Source::Url(url_source) => self.get_url_source(url_source).await,
            Source::Path(path) => {
                // Path sources are not cached, just return the path
                Ok(SourceResult {
                    path: path.clone(),
                    git_commit: None,
                })
            }
        }
    }

    /// Get a Git source from the cache or clone it if not present
    async fn get_git_source(&self, source: &GitSource) -> Result<SourceResult, CacheError> {
        let git_url = source.to_git_url();
        let key =
            CacheIndex::generate_git_cache_key(source.url.as_ref(), &source.reference.to_string());

        // Acquire lock for this cache entry
        let _lock = self.lock_manager.acquire(&key).await?;

        // Check if we have it in cache
        if let Some(entry) = self.index.get(&key).await {
            let cache_path = self.index.get_cache_path(&entry);
            if cache_path.exists() {
                // Update access time
                self.index.touch(&key).await?;
                tracing::info!("Found git source in cache: {}", cache_path.display());
                return Ok(SourceResult {
                    path: cache_path,
                    git_commit: entry.git_commit.clone(),
                });
            }
        }

        // Use rattler_git to fetch the repository
        tracing::info!("Fetching git repository: {}", git_url);
        let git_cache = self.cache_dir.join("git");
        fs_err::tokio::create_dir_all(&git_cache).await?;

        let checkout_options = CheckoutOptions {
            update_submodules: source.submodules,
            // `Some(true)` fetches LFS objects into the git database and runs
            // the smudge filter during checkout; `Some(false)` force-skips the
            // smudge filter so checkouts contain pointer files only.
            lfs: Some(source.lfs),
        };

        let fetch_result = self
            .git_resolver
            .fetch(
                git_url.clone(),
                self.client.get_client().client().clone(),
                git_cache,
                None,
                checkout_options,
            )
            .await
            .map_err(|e| CacheError::Git(format!("Git fetch failed: {}", e)))?;

        let repo_path = fetch_result.path().to_path_buf();
        let commit_hash = fetch_result.commit().to_string();

        // Verify expected commit if specified
        if let Some(expected) = &source.expected_commit {
            if commit_hash != *expected {
                return Err(CacheError::GitCommitMismatch {
                    expected: expected.clone(),
                    actual: commit_hash,
                    rev: source.reference.to_string(),
                });
            }
            tracing::info!("Verified expected commit: {}", expected);
        }

        // LFS objects are fetched and validated by rattler_git itself (see
        // `CheckoutOptions::lfs` above); it only warns on failure, so turn an
        // unfulfilled LFS request into a hard error here.
        if source.lfs && !fetch_result.lfs_ready() {
            return Err(CacheError::Git(format!(
                "failed to fetch git-lfs files for {} — is `git-lfs` installed?",
                source.url
            )));
        }

        // Create cache entry
        let entry = CacheEntry {
            source_type: SourceType::Git,
            url: source.url.to_string(),
            checksum: None,
            checksum_type: None,
            actual_filename: None,
            git_commit: Some(commit_hash.clone()),
            git_rev: Some(source.reference.to_string()),
            cache_path: repo_path
                .strip_prefix(&self.cache_dir)
                .unwrap_or(&repo_path)
                .to_path_buf(),
            extracted_path: None,
            last_accessed: jiff::Timestamp::now(),
            created: jiff::Timestamp::now(),
            lock_file: Some(_lock.path().to_path_buf()),
            attestation_verified: false,
        };

        self.index.insert(key, entry).await?;

        Ok(SourceResult {
            path: repo_path,
            git_commit: Some(commit_hash),
        })
    }

    /// Get a URL source from the cache or download it if not present
    async fn get_url_source(&self, source: &UrlSource) -> Result<SourceResult, CacheError> {
        // Try each URL until one succeeds
        let mut last_error = None;

        for url in &source.urls {
            match self
                .try_url(
                    url,
                    &source.checksums,
                    source.file_name.as_deref(),
                    source.attestation.as_ref(),
                )
                .await
            {
                Ok(path) => {
                    return Ok(SourceResult {
                        path,
                        git_commit: None,
                    });
                }
                Err(e) => {
                    tracing::warn!("Failed to fetch from {}: {}", url, e);
                    last_error = Some(e);
                }
            }
        }

        Err(last_error.unwrap_or_else(|| CacheError::Other("No URLs provided".to_string())))
    }

    /// Try to get a single URL from cache or download it
    async fn try_url(
        &self,
        url: &url::Url,
        checksums: &[Checksum],
        file_name: Option<&str>,
        attestation: Option<&AttestationVerification>,
    ) -> Result<PathBuf, CacheError> {
        let key = CacheIndex::generate_cache_key(url, checksums);

        // Acquire lock for this cache entry
        let _lock = self.lock_manager.acquire(&key).await?;

        // Check if we have it in cache
        if let Some(entry) = self.index.get(&key).await {
            let cache_path = self.index.get_cache_path(&entry);

            // If extraction was done, return extracted path
            if let Some(extracted_path) = self.index.get_extracted_path(&entry)
                && extracted_path.exists()
            {
                // Re-verify attestation if configured but not yet verified for this entry
                if let Some(attestation_config) = attestation
                    && !entry.attestation_verified
                {
                    let archive_path = self.index.get_cache_path(&entry);
                    self.verify_attestation(&archive_path, url, attestation_config)
                        .await?;
                    self.index.set_attestation_verified(&key).await?;
                }
                self.index.touch(&key).await?;
                tracing::info!(
                    "Found extracted source in cache: {}",
                    extracted_path.display()
                );
                return Ok(extracted_path);
            }

            // Otherwise return the archive file
            if cache_path.exists() {
                // Validate all checksums if provided
                if !checksums.is_empty() {
                    let mismatch = checksums
                        .iter()
                        .find_map(|cs| cs.validate(&cache_path).err());
                    if mismatch.is_some() {
                        tracing::warn!("Checksum validation failed, re-downloading");
                        fs_err::tokio::remove_file(&cache_path).await?;
                    } else {
                        // Re-verify attestation if configured but not yet verified for this entry
                        if let Some(attestation_config) = attestation
                            && !entry.attestation_verified
                        {
                            self.verify_attestation(&cache_path, url, attestation_config)
                                .await?;
                            self.index.set_attestation_verified(&key).await?;
                        }
                        self.index.touch(&key).await?;
                        tracing::info!("Found source in cache: {}", cache_path.display());
                        return Ok(cache_path);
                    }
                } else {
                    // Re-verify attestation if configured but not yet verified for this entry
                    if let Some(attestation_config) = attestation
                        && !entry.attestation_verified
                    {
                        self.verify_attestation(&cache_path, url, attestation_config)
                            .await?;
                        self.index.set_attestation_verified(&key).await?;
                    }
                    self.index.touch(&key).await?;
                    tracing::info!("Found source in cache: {}", cache_path.display());
                    return Ok(cache_path);
                }
            }
        }

        // Download the file
        tracing::info!("Downloading from: {}", url);
        let (cache_path, actual_filename) = self.download_url(url, &key).await?;

        // Validate all checksums
        for cs in checksums {
            if let Err(mismatch) = cs.validate(&cache_path) {
                fs_err::tokio::remove_file(&cache_path).await?;
                return Err(CacheError::ValidationFailed {
                    path: cache_path,
                    expected: mismatch.expected,
                    actual: mismatch.actual,
                    kind: mismatch.kind.to_string(),
                });
            }
        }

        // Perform attestation verification if configured
        if let Some(attestation_config) = attestation {
            self.verify_attestation(&cache_path, url, attestation_config)
                .await?;
        }

        // Extract if needed and no explicit filename was provided
        let final_path = if file_name.is_none() && self.should_extract(&cache_path) {
            let extracted_dir = self.cache_dir.join(format!("{}_extracted", key));
            self.extract_archive(&cache_path, &extracted_dir).await?;
            Some(extracted_dir)
        } else {
            None
        };

        // Use the first checksum for the cache entry metadata
        let primary_checksum = checksums.first();

        // Create cache entry
        let entry = CacheEntry {
            source_type: SourceType::Url,
            url: url.to_string(),
            checksum: primary_checksum.map(|c| c.to_hex()),
            checksum_type: primary_checksum
                .map(|c| match c {
                    Checksum::Sha256(_) => "sha256",
                    Checksum::Md5(_) => "md5",
                })
                .map(String::from),
            actual_filename,
            git_commit: None,
            git_rev: None,
            cache_path: cache_path
                .strip_prefix(&self.cache_dir)
                .unwrap_or(&cache_path)
                .to_path_buf(),
            extracted_path: final_path
                .as_ref()
                .map(|p| p.strip_prefix(&self.cache_dir).unwrap_or(p).to_path_buf()),
            last_accessed: jiff::Timestamp::now(),
            created: jiff::Timestamp::now(),
            lock_file: Some(_lock.path().to_path_buf()),
            attestation_verified: attestation.is_some(),
        };

        self.index.insert(key, entry).await?;

        Ok(final_path.unwrap_or(cache_path))
    }

    /// Download a URL to the cache
    async fn download_url(
        &self,
        url: &url::Url,
        key: &str,
    ) -> Result<(PathBuf, Option<String>), CacheError> {
        // Determine filename
        let filename = url
            .path_segments()
            .and_then(|mut segments| segments.next_back())
            .unwrap_or("download");

        let cache_path = self.cache_dir.join(format!("{}_{}", key, filename));

        // Handle file:// URLs
        if url.scheme() == "file" {
            let source_path = url
                .to_file_path()
                .map_err(|_| CacheError::Other("Invalid file URL".to_string()))?;

            if !source_path.exists() {
                return Err(CacheError::FileNotFound(source_path));
            }

            fs_err::tokio::copy(&source_path, &cache_path).await?;
            return Ok((cache_path, Some(filename.to_string())));
        }

        // Download from HTTP/HTTPS - use the appropriate client based on SSL settings
        let response = self
            .client
            .for_host(url)
            .client()
            .get(url.clone())
            .send()
            .await?;

        if !response.status().is_success() {
            return Err(CacheError::Download(
                response.error_for_status().unwrap_err(),
            ));
        }

        // Get actual filename from Content-Disposition header if present
        let actual_filename = response
            .headers()
            .get("content-disposition")
            .and_then(|v| v.to_str().ok())
            .and_then(extract_filename_from_header);

        // Get content length for progress reporting
        let total_size = response
            .headers()
            .get("content-length")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.parse::<u64>().ok());

        // Notify progress handler
        if let Some(handler) = &self.progress_handler {
            handler.on_download_start(url.as_str(), total_size);
        }

        // Stream download to file
        let mut file = fs_err::tokio::File::create(&cache_path).await?;
        let mut stream = response.bytes_stream();
        let mut downloaded = 0u64;

        use futures::StreamExt;
        while let Some(chunk) = stream.next().await {
            let chunk = chunk?;
            downloaded += chunk.len() as u64;
            file.write_all(&chunk).await?;

            // Update progress
            if let Some(handler) = &self.progress_handler {
                handler.on_download_progress(url.as_str(), downloaded, total_size);
            }
        }

        file.flush().await?;

        // Notify completion
        if let Some(handler) = &self.progress_handler {
            handler.on_download_complete(url.as_str());
        }

        // If Content-Disposition provided a filename that differs from the URL's,
        // rename the cached file so downstream code can detect the archive format
        // from the file extension alone.
        let final_path = if let Some(ref actual) = actual_filename {
            let new_path = self.cache_dir.join(format!("{}_{}", key, actual));
            if new_path != cache_path {
                fs_err::tokio::rename(&cache_path, &new_path).await?;
                new_path
            } else {
                cache_path
            }
        } else {
            cache_path
        };

        Ok((final_path, actual_filename))
    }

    /// Check if a file should be extracted based on its filename extension
    pub(crate) fn should_extract(&self, path: &Path) -> bool {
        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
        is_archive(name)
    }

    /// Extract an archive to a directory
    async fn extract_archive(
        &self,
        archive_path: &Path,
        target_dir: &Path,
    ) -> Result<(), CacheError> {
        // Notify progress handler
        if let Some(handler) = &self.progress_handler {
            handler.on_extraction_start(archive_path);
        }

        // Create a temporary directory for extraction
        let temp_dir = tempfile::tempdir_in(&self.cache_dir)
            .map_err(|e| CacheError::Other(format!("Failed to create temp dir: {}", e)))?;

        let name = archive_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("");

        // Extract based on file type to temp directory
        if is_tarball(name) {
            extract_tar(archive_path, temp_dir.path())?;
        } else if name.ends_with(".zip") {
            extract_zip(archive_path, temp_dir.path())?;
        } else if name.ends_with(".7z") {
            extract_7z(archive_path, temp_dir.path())?;
        } else {
            return Err(CacheError::ExtractionError(format!(
                "Unsupported archive format: {}",
                name
            )));
        }

        // Strip root directory if needed and move to final location
        strip_and_move_extracted_dir(temp_dir.path(), target_dir).await?;

        // Notify completion
        if let Some(handler) = &self.progress_handler {
            handler.on_extraction_complete(archive_path);
        }

        Ok(())
    }

    /// Verify a downloaded file using attestation (sigstore-based).
    ///
    /// When the `sigstore` feature is enabled, delegates to the full verification
    /// implementation. When disabled, logs a warning and skips verification.
    async fn verify_attestation(
        &self,
        file_path: &Path,
        source_url: &url::Url,
        attestation_config: &AttestationVerification,
    ) -> Result<(), CacheError> {
        #[cfg(feature = "sigstore")]
        {
            crate::sigstore::verify_attestation(
                &self.client,
                file_path,
                source_url,
                attestation_config,
            )
            .await?;
        }
        #[cfg(not(feature = "sigstore"))]
        {
            let _ = (file_path, attestation_config);
            tracing::warn!(
                url = %source_url,
                "sigstore verification is disabled at compile time — \
                 attestation will NOT be verified"
            );
        }
        Ok(())
    }

    /// Clean up stale locks (manual cleanup only, cache entries are kept indefinitely)
    pub async fn cleanup_stale_locks(&self) -> Result<(), CacheError> {
        self.lock_manager.cleanup_stale_locks().await?;
        Ok(())
    }

    /// Get cache statistics
    pub async fn stats(&self) -> Result<CacheStats, CacheError> {
        let entries = self.index.list_entries().await;
        let total_entries = entries.len();
        let mut total_size = 0u64;
        let mut git_entries = 0;
        let mut url_entries = 0;

        for (_, entry) in entries {
            match entry.source_type {
                SourceType::Git => git_entries += 1,
                SourceType::Url => url_entries += 1,
            }

            let path = self.index.get_cache_path(&entry);
            if let Ok(metadata) = fs_err::tokio::metadata(&path).await {
                if metadata.is_file() {
                    total_size += metadata.len();
                } else if metadata.is_dir() {
                    // Calculate directory size
                    total_size += calculate_dir_size(&path).await?;
                }
            }
        }

        Ok(CacheStats {
            total_entries,
            total_size,
            git_entries,
            url_entries,
        })
    }
}

/// Cache statistics
#[derive(Debug, Clone)]
pub struct CacheStats {
    pub total_entries: usize,
    pub total_size: u64,
    pub git_entries: usize,
    pub url_entries: usize,
}

// Helper functions

pub(crate) fn extract_filename_from_header(header_value: &str) -> Option<String> {
    for part in header_value.split(';') {
        let part = part.trim();
        if part.starts_with("filename=") {
            let filename = part.strip_prefix("filename=")?;
            let filename = filename.trim_matches('"').trim_matches('\'');
            if !filename.is_empty() {
                // Strip any path components — only keep the base filename
                let filename = Path::new(filename)
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or(filename);
                return Some(filename.to_string());
            }
        }
    }
    None
}

pub(crate) fn is_archive(name: &str) -> bool {
    is_tarball(name) || name.ends_with(".zip") || name.ends_with(".7z")
}

/// Checks whether file has known tarball extension
pub fn is_tarball(file_name: &str) -> bool {
    [
        // Gzip
        ".tar.gz",
        ".tgz",
        ".taz",
        // Bzip2
        ".tar.bz2",
        ".tbz",
        ".tbz2",
        ".tz2",
        // Xz2
        ".tar.lzma",
        ".tlz",
        ".tar.xz",
        ".txz",
        // Zstd
        ".tar.zst",
        ".tzst",
        // Compress
        ".tar.Z",
        ".taZ",
        // Lzip
        ".tar.lz",
        // Lzop
        ".tar.lzo",
        // PlainTar
        ".tar",
    ]
    .iter()
    .any(|ext| file_name.ends_with(ext))
}

fn extract_tar(archive: &Path, target: &Path) -> Result<(), CacheError> {
    let file = fs_err::File::open(archive)
        .map_err(|e| CacheError::ExtractionError(format!("Failed to open archive: {}", e)))?;

    let name = archive.file_name().and_then(|n| n.to_str()).unwrap_or("");

    if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
        let mut archive = tar::Archive::new(GzDecoder::new(file));
        archive
            .unpack(target)
            .map_err(|e| CacheError::ExtractionError(format!("Failed to extract tar.gz: {}", e)))?;
    } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") {
        let mut archive = tar::Archive::new(bzip2::read::BzDecoder::new(file));
        archive.unpack(target).map_err(|e| {
            CacheError::ExtractionError(format!("Failed to extract tar.bz2: {}", e))
        })?;
    } else if name.ends_with(".tar.xz") || name.ends_with(".txz") {
        let mut archive = tar::Archive::new(lzma_rust2::XzReader::new(file, true));
        archive
            .unpack(target)
            .map_err(|e| CacheError::ExtractionError(format!("Failed to extract tar.xz: {}", e)))?;
    } else if name.ends_with(".tar.zst") {
        let decoder = zstd::stream::read::Decoder::new(file).map_err(|e| {
            CacheError::ExtractionError(format!("Failed to create zstd decoder: {}", e))
        })?;
        let mut archive = tar::Archive::new(decoder);
        archive.unpack(target).map_err(|e| {
            CacheError::ExtractionError(format!("Failed to extract tar.zst: {}", e))
        })?;
    } else {
        let mut archive = tar::Archive::new(file);
        archive
            .unpack(target)
            .map_err(|e| CacheError::ExtractionError(format!("Failed to extract tar: {}", e)))?;
    }

    Ok(())
}

fn extract_zip(archive: &Path, target: &Path) -> Result<(), CacheError> {
    let file = fs_err::File::open(archive)
        .map_err(|e| CacheError::ExtractionError(format!("Failed to open archive: {}", e)))?;

    let mut archive = zip::ZipArchive::new(file)
        .map_err(|e| CacheError::ExtractionError(format!("Failed to read zip: {}", e)))?;

    archive
        .extract(target)
        .map_err(|e| CacheError::ExtractionError(format!("Failed to extract zip: {}", e)))?;

    Ok(())
}

fn extract_7z(archive: &Path, target: &Path) -> Result<(), CacheError> {
    sevenz_rust2::decompress_file(archive, target)
        .map_err(|e| CacheError::ExtractionError(format!("Failed to extract 7z: {}", e)))?;

    Ok(())
}

async fn calculate_dir_size(dir: &Path) -> Result<u64, CacheError> {
    let mut total = 0u64;
    let mut entries = fs_err::tokio::read_dir(dir).await?;

    while let Some(entry) = entries.next_entry().await? {
        let metadata = entry.metadata().await?;
        if metadata.is_file() {
            total += metadata.len();
        } else if metadata.is_dir() {
            total += Box::pin(calculate_dir_size(&entry.path())).await?;
        }
    }

    Ok(total)
}

/// Strip root directory if the extracted archive contains only a single top-level directory
async fn strip_and_move_extracted_dir(src: &Path, dest: &Path) -> Result<(), CacheError> {
    use fs_err as fs;

    // Read entries from source directory
    let mut entries = fs::read_dir(src)?;

    // Check if there's only one entry and if it's a directory
    let first_entry = entries.next();
    let second_entry = entries.next();

    let src_dir = match (first_entry, second_entry) {
        (Some(Ok(entry)), None) if entry.file_type()?.is_dir() => {
            // Single directory - we'll extract from inside it
            entry.path()
        }
        _ => {
            // Multiple entries or not a directory - use the source as-is
            src.to_path_buf()
        }
    };

    // Create destination directory
    fs::create_dir_all(dest)?;

    // Move all files from source directory to destination
    for entry in fs::read_dir(&src_dir)? {
        let entry = entry?;
        let dest_path = dest.join(entry.file_name());
        fs::rename(entry.path(), dest_path)?;
    }

    Ok(())
}

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

    #[test]
    fn test_extract_filename_from_header() {
        assert_eq!(
            extract_filename_from_header("attachment; filename=\"test.tar.gz\""),
            Some("test.tar.gz".to_string())
        );
    }

    #[test]
    fn test_is_archive() {
        assert!(is_archive("test.tar.gz"));
        assert!(is_archive("test.zip"));
        assert!(is_archive("test.7z"));
        assert!(!is_archive("test.txt"));
    }
}
