// SPDX-License-Identifier: MIT

use crate::cache::CacheEntry;
use crate::types::{PackageName, Version};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use tracing::debug;

use super::retry::{is_http_error_retryable, retry_with_backoff};
use crate::{
    AuditCache, AuditError, Result, Severity, VersionRange, Vulnerability, VulnerabilityDatabase,
    VulnerabilityProvider,
};

/// URL for downloading the `PyPA` advisory database
const PYPA_ADVISORY_DB_URL: &str = "https://github.com/pypa/advisory-database/archive/main.zip";

/// `PyPA` Advisory Database YAML record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaAdvisory {
    /// Unique vulnerability identifier
    pub id: String,

    /// Detailed description (`PyPA` uses 'details' instead of 'summary')
    pub details: String,

    /// Affected packages and versions
    pub affected: Vec<PypaAffected>,

    /// Reference URLs
    pub references: Vec<PypaReference>,

    /// CVE aliases and other identifiers
    #[serde(default)]
    pub aliases: Vec<String>,

    /// Publication timestamp
    pub published: Option<String>,

    /// Last modification timestamp
    pub modified: Option<String>,

    /// Withdrawn status
    #[serde(default)]
    pub withdrawn: Option<String>,

    /// Summary (optional, some `PyPA` records may have it)
    pub summary: Option<String>,

    /// Additional severity information
    #[serde(default)]
    pub severity: Vec<PypaSeverity>,

    /// Database-specific fields
    #[serde(default)]
    pub database_specific: Option<serde_json::Value>,
}

/// Affected package information in `PyPA` format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaAffected {
    /// Package information
    pub package: PypaPackage,

    /// Version ranges affected
    #[serde(default)]
    pub ranges: Vec<PypaRange>,

    /// Specific versions affected
    pub versions: Option<Vec<String>>,

    /// Ecosystem-specific database information
    #[serde(default)]
    pub database_specific: Option<serde_json::Value>,

    /// Ecosystem-specific fields
    #[serde(default)]
    pub ecosystem_specific: Option<serde_json::Value>,
}

/// Package information in `PyPA` format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaPackage {
    /// Package ecosystem (e.g., "PyPI")
    pub ecosystem: String,

    /// Package name
    pub name: String,

    /// Package URL if available
    pub purl: Option<String>,
}

/// Version range specification in `PyPA` format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaRange {
    /// Range type (e.g., "ECOSYSTEM")
    #[serde(rename = "type")]
    pub range_type: String,

    /// Repository URL for version control ranges
    pub repo: Option<String>,

    /// Events defining the range (introduced, fixed, etc.)
    pub events: Vec<PypaEvent>,

    /// Database-specific information
    #[serde(default)]
    pub database_specific: Option<serde_json::Value>,
}

/// A version event in a `PyPA` range
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaEvent {
    /// Version where event occurs
    pub introduced: Option<String>,

    /// Version where issue is fixed
    pub fixed: Option<String>,

    /// Last affected version
    pub last_affected: Option<String>,

    /// Version limit
    pub limit: Option<String>,
}

/// Reference information in `PyPA` format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaReference {
    /// Reference type (e.g., "ADVISORY", "FIX", "WEB", "ARTICLE")
    #[serde(rename = "type")]
    pub ref_type: String,

    /// Reference URL
    pub url: String,
}

/// Severity information in `PyPA` format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypaSeverity {
    /// Severity type (e.g., `CVSS_V3`)
    #[serde(rename = "type")]
    pub severity_type: String,

    /// Severity score
    pub score: String,
}

/// Client for downloading `PyPA` advisory database
pub(super) struct PypaClient {
    client: Client,
    http_config: crate::config::HttpConfig,
}

impl PypaClient {
    /// Create a new `PyPA` client with HTTP configuration
    pub(super) fn new(http_config: crate::config::HttpConfig) -> Self {
        let client = Client::builder()
            .user_agent(format!("pysentry/{}", env!("CARGO_PKG_VERSION")))
            .timeout(std::time::Duration::from_secs(http_config.timeout))
            .connect_timeout(std::time::Duration::from_secs(http_config.connect_timeout))
            .build()
            .unwrap_or_else(|_| Client::new());

        Self {
            client,
            http_config,
        }
    }

    /// Download the `PyPA` advisory database ZIP file with retries and progress indication
    pub(super) async fn download_advisory_database(&self) -> Result<Vec<u8>> {
        retry_with_backoff(
            self.http_config.max_retries,
            self.http_config.retry_initial_backoff,
            self.http_config.retry_max_backoff,
            is_http_error_retryable,
            || self.download_with_progress(),
            "PyPA advisory database download",
        )
        .await
        .map_err(|err| AuditError::DatabaseDownloadDetailed {
            resource: "PyPA Advisory Database".to_string(),
            url: PYPA_ADVISORY_DB_URL.to_string(),
            source: Box::new(err),
        })
    }

    /// Download the database with progress indication
    async fn download_with_progress(&self) -> Result<Vec<u8>> {
        use futures::StreamExt;

        let response = self.client.get(PYPA_ADVISORY_DB_URL).send().await?;

        if !response.status().is_success() {
            return Err(AuditError::DatabaseDownload(Box::new(
                response.error_for_status().unwrap_err(),
            )));
        }

        let total_size = response.content_length();

        let pb = if self.http_config.show_progress {
            if let Some(size) = total_size {
                let bar = ProgressBar::new(size);
                bar.set_style(
                    ProgressStyle::default_bar()
                        .template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")
                        .unwrap()
                        .progress_chars("#>-"),
                );
                bar.set_message("Downloading PyPA advisory database");
                Some(bar)
            } else {
                let bar = ProgressBar::new_spinner();
                bar.set_message("Downloading PyPA advisory database...");
                Some(bar)
            }
        } else {
            None
        };

        let mut downloaded: u64 = 0;
        let mut stream = response.bytes_stream();
        let mut bytes = Vec::new();

        while let Some(chunk) = stream.next().await {
            let chunk = chunk?;
            bytes.extend_from_slice(&chunk);
            downloaded += chunk.len() as u64;

            if let Some(ref bar) = pb {
                bar.set_position(downloaded);
            }
        }

        if let Some(bar) = pb {
            bar.finish_with_message("Downloaded PyPA advisory database");
        }

        Ok(bytes)
    }
}

impl Default for PypaClient {
    fn default() -> Self {
        Self::new(crate::config::HttpConfig::default())
    }
}

/// `PyPA` Advisory Database source for vulnerability data
pub struct PypaSource {
    cache: AuditCache,
    client: PypaClient,
    no_cache: bool,
    vulnerability_ttl: u64,
}

impl PypaSource {
    /// Create a new `PyPA` source with HTTP configuration
    pub fn new(
        cache: AuditCache,
        no_cache: bool,
        http_config: crate::config::HttpConfig,
        vulnerability_ttl: u64,
    ) -> Self {
        Self {
            cache,
            client: PypaClient::new(http_config),
            no_cache,
            vulnerability_ttl,
        }
    }

    /// Get cache entry for `PyPA` database
    fn cache_entry(&self) -> CacheEntry {
        self.cache.database_entry("pypa")
    }

    /// Download and parse `PyPA` advisory database
    async fn download_and_parse_database(&self) -> Result<Vec<PypaAdvisory>> {
        use crate::cache::Freshness;
        use std::time::Duration;

        let cache_entry = self.cache_entry();
        let ttl = Duration::from_secs(self.vulnerability_ttl * 3600);

        // Check cache freshness first unless no_cache is set
        let cache_is_fresh = if self.no_cache {
            false
        } else {
            matches!(cache_entry.freshness(ttl), Ok(Freshness::Fresh))
        };

        let zip_data = if cache_is_fresh {
            debug!(
                "Using cached PyPA database (TTL: {} hours)",
                self.vulnerability_ttl
            );
            fs_err::read(cache_entry.path())?
        } else {
            debug!("Downloading PyPA advisory database (cache stale or missing)");
            let data = self.client.download_advisory_database().await?;

            // Cache the downloaded data
            if !self.no_cache {
                // Directory creation handled by cache entry write
                cache_entry.write(&data).await?;
            }

            data
        };

        self.parse_zip_database(&zip_data).await
    }

    /// Parse `PyPA` advisory database from ZIP data
    async fn parse_zip_database(&self, zip_data: &[u8]) -> Result<Vec<PypaAdvisory>> {
        use std::io::{Cursor, Read};
        use zip::ZipArchive;

        let cursor = Cursor::new(zip_data);
        let mut archive =
            ZipArchive::new(cursor).map_err(|e| AuditError::Cache(anyhow::Error::from(e)))?;
        let mut advisories = Vec::new();
        let mut parsed_count = 0;
        let mut error_count = 0;

        debug!("Processing ZIP file with {} entries", archive.len());

        for i in 0..archive.len() {
            let mut file = archive
                .by_index(i)
                .map_err(|e| AuditError::Cache(anyhow::Error::from(e)))?;
            let filename = file.name().to_string();

            // Skip directories and non-YAML files
            if file.is_dir()
                || !std::path::Path::new(&filename)
                    .extension()
                    .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
            {
                continue;
            }

            // Only process PyPI advisories (skip other ecosystems)
            if !filename.contains("vulns/") {
                continue;
            }

            let mut content = String::new();
            file.read_to_string(&mut content)
                .map_err(|e| AuditError::Cache(anyhow::Error::from(e)))?;

            // Parse as PyPA YAML format
            match Self::parse_advisory(&content) {
                Ok(pypa_advisory) => {
                    // Only include PyPI advisories that have affected packages
                    let package_names = Self::extract_package_names(&pypa_advisory);
                    if !package_names.is_empty() {
                        advisories.push(pypa_advisory);
                        parsed_count += 1;
                    }
                }
                Err(e) => {
                    debug!("Failed to parse advisory from {}: {}", filename, e);
                    error_count += 1;
                }
            }
        }

        debug!(
            "Parsed {} PyPA advisories ({} errors) from ZIP file",
            parsed_count, error_count
        );

        Ok(advisories)
    }

    /// Parse a `PyPA` YAML advisory from string content
    pub fn parse_advisory(content: &str) -> Result<PypaAdvisory> {
        serde_yaml::from_str(content).map_err(|e| {
            AuditError::PypaAdvisoryParse("PyPA YAML parse error".to_string(), Box::new(e))
        })
    }

    /// Convert `PyPA` advisories to vulnerability database
    fn convert_advisories(advisories: Vec<PypaAdvisory>) -> Result<VulnerabilityDatabase> {
        let mut package_vulnerabilities: HashMap<String, Vec<Vulnerability>> = HashMap::new();

        for advisory in advisories {
            let package_names = Self::extract_package_names(&advisory);

            for package_name in package_names {
                let vulnerability = Self::to_vulnerability(&advisory, &package_name)?;
                package_vulnerabilities
                    .entry(package_name.to_string())
                    .or_default()
                    .push(vulnerability);
            }
        }

        Ok(VulnerabilityDatabase::from_package_map(
            package_vulnerabilities,
        ))
    }

    /// Convert `PyPA` advisory directly to internal Vulnerability format
    pub fn to_vulnerability(
        pypa: &PypaAdvisory,
        package_name: &PackageName,
    ) -> Result<Vulnerability> {
        let cvss_info = Self::extract_cvss_info(pypa);
        let severity = match cvss_info {
            Some((score, _)) => Severity::from_cvss_score(score),
            None => Severity::Unknown,
        };
        let cvss_score = cvss_info.map(|(score, _)| score);
        let cvss_version = cvss_info.map(|(_, version)| version);

        // Extract affected version ranges for the specific package
        let affected_versions = Self::extract_version_ranges(pypa, package_name)?;

        // Extract fixed versions for the specific package
        let fixed_versions = Self::extract_fixed_versions(pypa, package_name)?;

        // Extract reference URLs
        let references = pypa.references.iter().map(|r| r.url.clone()).collect();

        // Parse timestamps
        let published = pypa.published.as_ref().and_then(|s| {
            DateTime::parse_from_rfc3339(s)
                .ok()
                .map(|dt| dt.with_timezone(&Utc))
        });

        let modified = pypa.modified.as_ref().and_then(|s| {
            DateTime::parse_from_rfc3339(s)
                .ok()
                .map(|dt| dt.with_timezone(&Utc))
        });

        let withdrawn = pypa.withdrawn.as_ref().and_then(|s| {
            DateTime::parse_from_rfc3339(s)
                .ok()
                .map(|dt| dt.with_timezone(&Utc))
        });

        Ok(Vulnerability {
            id: pypa.id.clone(),
            summary: pypa.summary.clone().unwrap_or_else(|| {
                // Truncate details to create a summary if none exists
                let details = &pypa.details;
                let truncated = super::truncate_chars(details, 117);
                if truncated.len() < details.len() {
                    format!("{truncated}...")
                } else {
                    details.clone()
                }
            }),
            description: Some(pypa.details.clone()),
            severity,
            affected_versions,
            fixed_versions,
            references,
            cvss_score,
            cvss_version,
            published,
            modified,
            source: Some("pypa".to_string()),
            withdrawn,
            aliases: pypa.aliases.clone(),
        })
    }

    /// Extract CVSS score and version from `PyPA` advisory
    fn extract_cvss_info(pypa: &PypaAdvisory) -> Option<(f32, u8)> {
        super::extract_best_cvss_score(
            pypa.severity
                .iter()
                .filter(|sev| sev.severity_type.contains("CVSS"))
                .map(|sev| (sev.severity_type.as_str(), sev.score.as_str())),
        )
    }

    /// Extract version ranges from `PyPA` advisory for a specific package
    fn extract_version_ranges(
        pypa: &PypaAdvisory,
        package_name: &PackageName,
    ) -> Result<Vec<VersionRange>> {
        let mut ranges = Vec::new();

        for affected in &pypa.affected {
            // Check if this affected entry is for our package
            if affected.package.ecosystem != "PyPI" {
                continue;
            }

            let affected_package_name =
                PackageName::from_str(&affected.package.name).map_err(|e| {
                    AuditError::InvalidDependency(format!(
                        "Invalid package name {}: {}",
                        affected.package.name, e
                    ))
                })?;

            if affected_package_name != *package_name {
                continue;
            }

            // Add explicit versions as individual ranges
            if let Some(versions) = &affected.versions {
                for version_str in versions {
                    if let Ok(version) = Version::from_str(version_str) {
                        ranges.push(VersionRange {
                            min: Some(version.clone()),
                            max: Some(version.clone()),
                            constraint: format!("=={version}"),
                            max_inclusive: true,
                        });
                    }
                }
            }

            // Process version ranges
            for range in &affected.ranges {
                if range.range_type == "ECOSYSTEM" {
                    let version_range = Self::parse_pypa_range(range);
                    ranges.push(version_range);
                }
            }
        }

        Ok(ranges)
    }

    /// Parse a `PyPA` range into our internal format
    fn parse_pypa_range(range: &PypaRange) -> VersionRange {
        let mut min_version: Option<Version> = None;
        let mut max_version: Option<Version> = None;
        let mut has_fixed = false;

        for event in &range.events {
            if let Some(introduced) = &event.introduced {
                if introduced == "0" {
                    min_version = Some(Version::new([0, 0, 0]));
                } else if let Ok(version) = Version::from_str(introduced) {
                    min_version = Some(version);
                }
            }

            if let Some(fixed) = &event.fixed {
                if let Ok(version) = Version::from_str(fixed) {
                    max_version = Some(version);
                    has_fixed = true;
                }
            }

            if let Some(last) = &event.last_affected {
                if !has_fixed {
                    if let Ok(version) = Version::from_str(last) {
                        max_version = Some(version);
                    }
                }
            }
        }

        let max_inclusive = !has_fixed && max_version.is_some();

        // Build constraint string
        let constraint = match (&min_version, &max_version, max_inclusive) {
            (Some(min), Some(max), true) => format!(">={min},<={max}"),
            (Some(min), Some(max), false) => format!(">={min},<{max}"),
            (Some(min), None, _) => format!(">={min}"),
            (None, Some(max), true) => format!("<={max}"),
            (None, Some(max), false) => format!("<{max}"),
            (None, None, _) => "*".to_string(),
        };

        VersionRange {
            min: min_version,
            max: max_version,
            constraint,
            max_inclusive,
        }
    }

    /// Extract fixed versions from `PyPA` advisory for a specific package
    fn extract_fixed_versions(
        pypa: &PypaAdvisory,
        package_name: &PackageName,
    ) -> Result<Vec<Version>> {
        let mut fixed_versions = Vec::new();

        for affected in &pypa.affected {
            // Check if this affected entry is for our package
            if affected.package.ecosystem != "PyPI" {
                continue;
            }

            let affected_package_name =
                PackageName::from_str(&affected.package.name).map_err(|e| {
                    AuditError::InvalidDependency(format!(
                        "Invalid package name {}: {}",
                        affected.package.name, e
                    ))
                })?;

            if affected_package_name != *package_name {
                continue;
            }

            for range in &affected.ranges {
                for event in &range.events {
                    if let Some(fixed) = &event.fixed {
                        if let Ok(version) = Version::from_str(fixed) {
                            fixed_versions.push(version);
                        }
                    }
                }
            }
        }

        // Deduplicate and sort fixed versions
        fixed_versions.sort();
        fixed_versions.dedup();

        Ok(fixed_versions)
    }

    /// Extract package names from `PyPA` advisory
    pub fn extract_package_names(pypa: &PypaAdvisory) -> Vec<PackageName> {
        pypa.affected
            .iter()
            .filter_map(|affected| {
                if affected.package.ecosystem == "PyPI" {
                    PackageName::from_str(&affected.package.name).ok()
                } else {
                    None
                }
            })
            .collect()
    }

    /// Check if `PyPA` advisory affects a specific package
    pub fn affects_package(pypa: &PypaAdvisory, package_name: &PackageName) -> bool {
        pypa.affected.iter().any(|affected| {
            affected.package.ecosystem == "PyPI"
                && PackageName::from_str(&affected.package.name)
                    .map(|name| name == *package_name)
                    .unwrap_or(false)
        })
    }
}

#[async_trait]
impl VulnerabilityProvider for PypaSource {
    fn name(&self) -> &'static str {
        "pypa"
    }

    async fn fetch_vulnerabilities(
        &self,
        _packages: &[(String, String)],
    ) -> Result<VulnerabilityDatabase> {
        // Download and parse the entire PyPA database
        let advisories = self.download_and_parse_database().await?;

        // Convert PyPA advisories to vulnerability database
        Self::convert_advisories(advisories)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Datelike;
    use std::str::FromStr;
    use tempfile::TempDir;
    fn create_test_cache() -> AuditCache {
        let temp_dir = TempDir::new().unwrap();
        AuditCache::new(temp_dir.path().to_path_buf())
    }

    fn create_test_http_config() -> crate::config::HttpConfig {
        crate::config::HttpConfig::default()
    }

    #[test]
    fn test_pypa_advisory_parsing() {
        let yaml = r#"
id: PYSEC-2007-1
details: The internationalization (i18n) framework in Django 0.91, 0.95, 0.95.1, and 0.96, and as used in other products such as PyLucid, when the USE_I18N option and the i18n component are enabled, allows remote attackers to cause a denial of service (memory consumption) via many HTTP requests with large Accept-Language headers.
affected:
- package:
    name: django
    ecosystem: PyPI
    purl: pkg:pypi/django
  ranges:
  - type: ECOSYSTEM
    events:
    - introduced: "0"
    - fixed: "1.1"
  versions:
  - 1.0.1
  - 1.0.2
  - 1.0.3
  - 1.0.4
references:
- type: ARTICLE
  url: http://www.djangoproject.com/weblog/2007/oct/26/security-fix
- type: ADVISORY
  url: http://secunia.com/advisories/27435
aliases:
- CVE-2007-5712
modified: "2021-07-15T02:22:07.728618Z"
published: "2007-10-30T19:46:00Z"
"#;

        let _source = PypaSource::new(create_test_cache(), true, create_test_http_config(), 48);

        let advisory = PypaSource::parse_advisory(yaml).unwrap();
        assert_eq!(advisory.id, "PYSEC-2007-1");
        assert!(advisory.details.contains("Django"));
        assert_eq!(advisory.affected.len(), 1);
        assert_eq!(advisory.aliases.len(), 1);
        assert_eq!(advisory.aliases[0], "CVE-2007-5712");
    }

    #[test]
    fn test_package_name_extraction() {
        let yaml = r"
id: TEST-1
details: Test
affected:
- package:
    ecosystem: PyPI
    name: django
- package:
    ecosystem: npm
    name: not-python
- package:
    ecosystem: PyPI
    name: flask
references: []
";

        let _source = PypaSource::new(create_test_cache(), true, create_test_http_config(), 48);

        let pypa_advisory = PypaSource::parse_advisory(yaml).unwrap();
        let package_names = PypaSource::extract_package_names(&pypa_advisory);

        assert_eq!(package_names.len(), 2);
        assert!(package_names.contains(&PackageName::from_str("django").unwrap()));
        assert!(package_names.contains(&PackageName::from_str("flask").unwrap()));
    }

    #[test]
    fn test_package_name_extraction_with_invalid_names() {
        let yaml = r"
id: TEST-INVALID
details: Test with invalid package names
affected:
- package:
    ecosystem: PyPI
    name: valid-package
- package:
    ecosystem: PyPI
    name: invalid-package-with-@#$%^&*()
- package:
    ecosystem: PyPI
    name: another-valid-package
references: []
";

        let _source = PypaSource::new(create_test_cache(), true, create_test_http_config(), 48);

        let pypa_advisory = PypaSource::parse_advisory(yaml).unwrap();
        let package_names = PypaSource::extract_package_names(&pypa_advisory);

        // Should only extract valid package names, ignoring invalid ones
        assert_eq!(package_names.len(), 2);
        assert!(package_names.contains(&PackageName::from_str("valid-package").unwrap()));
        assert!(package_names.contains(&PackageName::from_str("another-valid-package").unwrap()));
    }

    #[test]
    fn test_package_name_extraction_filters_non_pypi() {
        let yaml = r"
id: TEST-ECOSYSTEMS
details: Test with multiple ecosystems
affected:
- package:
    ecosystem: PyPI
    name: django
- package:
    ecosystem: npm
    name: express
- package:
    ecosystem: Go
    name: gorilla/mux
- package:
    ecosystem: PyPI
    name: flask
- package:
    ecosystem: Maven
    name: org.springframework:spring-core
references: []
";

        let _source = PypaSource::new(create_test_cache(), true, create_test_http_config(), 48);

        let pypa_advisory = PypaSource::parse_advisory(yaml).unwrap();
        let package_names = PypaSource::extract_package_names(&pypa_advisory);

        // Should only extract PyPI packages
        assert_eq!(package_names.len(), 2);
        assert!(package_names.contains(&PackageName::from_str("django").unwrap()));
        assert!(package_names.contains(&PackageName::from_str("flask").unwrap()));
    }

    #[test]
    fn test_to_vulnerability_conversion() {
        let yaml = "
id: PYSEC-2025-TEST
details: Test vulnerability conversion
affected:
- package:
    ecosystem: PyPI
    name: requests
  ranges:
  - type: ECOSYSTEM
    events:
    - introduced: \"2.0.0\"
    - fixed: \"2.31.0\"
references:
- type: ADVISORY
  url: https://example.com/advisory
aliases:
- CVE-2024-12345
modified: \"2025-01-14T21:22:18.665005Z\"
published: \"2025-01-14T19:15:32Z\"
";

        let _source = PypaSource::new(create_test_cache(), true, create_test_http_config(), 48);

        let pypa_advisory = PypaSource::parse_advisory(yaml).unwrap();
        let package_name = PackageName::from_str("requests").unwrap();
        let vulnerability = PypaSource::to_vulnerability(&pypa_advisory, &package_name).unwrap();

        // Verify conversion
        assert_eq!(vulnerability.id, "PYSEC-2025-TEST");
        assert_eq!(vulnerability.summary, "Test vulnerability conversion");
        assert_eq!(
            vulnerability.description,
            Some("Test vulnerability conversion".to_string())
        );
        assert_eq!(vulnerability.source, Some("pypa".to_string()));
        assert_eq!(vulnerability.references.len(), 1);
        assert_eq!(vulnerability.withdrawn, None);
    }

    #[test]
    fn test_withdrawn_vulnerability_parsing() {
        let yaml = r#"
id: PYSEC-2022-43059
details: Test withdrawn vulnerability
affected:
- package:
    ecosystem: PyPI
    name: aiohttp
  ranges:
  - type: ECOSYSTEM
    events:
    - introduced: "0"
    - fixed: "3.8.3"
references:
- type: ADVISORY
  url: https://github.com/pypa/advisory-database/pull/169/files
modified: "2023-11-08T01:00:00Z"
published: "2022-12-07T00:00:00Z"
withdrawn: "2023-11-08T00:54:24Z"
"#;

        let _source = PypaSource::new(create_test_cache(), true, create_test_http_config(), 48);

        let pypa_advisory = PypaSource::parse_advisory(yaml).unwrap();
        let package_name = PackageName::from_str("aiohttp").unwrap();
        let vulnerability = PypaSource::to_vulnerability(&pypa_advisory, &package_name).unwrap();

        // Verify withdrawal parsing
        assert_eq!(vulnerability.id, "PYSEC-2022-43059");
        assert!(vulnerability.withdrawn.is_some());

        let withdrawn_date = vulnerability.withdrawn.unwrap();
        assert_eq!(withdrawn_date.year(), 2023);
        assert_eq!(withdrawn_date.month(), 11);
        assert_eq!(withdrawn_date.day(), 8);
    }

    #[test]
    fn test_explicit_version_range_matches_exact_version() {
        let range = VersionRange {
            min: Some(Version::from_str("1.0.3").unwrap()),
            max: Some(Version::from_str("1.0.3").unwrap()),
            constraint: "==1.0.3".to_string(),
            max_inclusive: true,
        };
        assert!(range.contains(&Version::from_str("1.0.3").unwrap()));
    }

    #[test]
    fn test_explicit_version_range_does_not_match_adjacent() {
        let range = VersionRange {
            min: Some(Version::from_str("1.0.3").unwrap()),
            max: Some(Version::from_str("1.0.3").unwrap()),
            constraint: "==1.0.3".to_string(),
            max_inclusive: true,
        };
        assert!(!range.contains(&Version::from_str("1.0.2").unwrap()));
        assert!(!range.contains(&Version::from_str("1.0.4").unwrap()));
    }

    #[test]
    fn test_pypa_last_affected_range() {
        let range = PypaRange {
            range_type: "ECOSYSTEM".to_string(),
            repo: None,
            events: vec![
                PypaEvent {
                    introduced: Some("1.0.0".to_string()),
                    fixed: None,
                    last_affected: None,
                    limit: None,
                },
                PypaEvent {
                    introduced: None,
                    fixed: None,
                    last_affected: Some("1.8.0".to_string()),
                    limit: None,
                },
            ],
            database_specific: None,
        };

        let version_range = PypaSource::parse_pypa_range(&range);
        assert!(version_range.max_inclusive);
        assert!(version_range.constraint.contains("<="));
        assert!(version_range.contains(&Version::from_str("1.8.0").unwrap()));
        assert!(!version_range.contains(&Version::from_str("1.8.1").unwrap()));
    }
}
