// SPDX-License-Identifier: MIT

use crate::types::{PackageName, Version};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use tracing::warn;

/// A security vulnerability affecting a Python package.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vulnerability {
    /// Unique vulnerability identifier (GHSA-*, CVE-*, etc.)
    pub id: String,

    /// Vulnerability summary/title
    pub summary: String,

    /// Detailed description
    pub description: Option<String>,

    /// Severity level
    pub severity: Severity,

    /// Affected package versions
    pub affected_versions: Vec<VersionRange>,

    /// Fixed versions
    pub fixed_versions: Vec<Version>,

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

    /// CVSS score if available
    pub cvss_score: Option<f32>,

    /// CVSS version used for the score (2, 3, or 4; 0 = unknown version)
    pub cvss_version: Option<u8>,

    /// Date when vulnerability was published
    pub published: Option<DateTime<Utc>>,

    /// Date when vulnerability was last modified
    pub modified: Option<DateTime<Utc>>,

    /// Source of the vulnerability data (e.g., "pypa", "pypi", "osv")
    pub source: Option<String>,

    /// Date when vulnerability was withdrawn (if applicable)
    pub withdrawn: Option<DateTime<Utc>>,

    /// Aliases for this vulnerability (e.g., CVE-*, GHSA-*, PYSEC-*)
    #[serde(default)]
    pub aliases: Vec<String>,
}

/// Severity levels for vulnerabilities.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    #[serde(alias = "Unknown")]
    Unknown,
    #[serde(alias = "Low")]
    Low,
    #[serde(alias = "Medium")]
    Medium,
    #[serde(alias = "High")]
    High,
    #[serde(alias = "Critical")]
    Critical,
}

impl Severity {
    pub fn from_cvss_score(score: f32) -> Self {
        match score {
            s if s >= 9.0 => Self::Critical,
            s if s >= 7.0 => Self::High,
            s if s >= 4.0 => Self::Medium,
            // CVSS 0.0 ("None" per FIRST.org) maps to Low: it represents a known
            // assessment of zero impact, not an unknown severity.
            _ => Self::Low,
        }
    }
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Critical => write!(f, "CRITICAL"),
            Self::High => write!(f, "HIGH"),
            Self::Medium => write!(f, "MEDIUM"),
            Self::Low => write!(f, "LOW"),
            Self::Unknown => write!(f, "UNKNOWN"),
        }
    }
}

/// A version range constraint for vulnerability matching.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VersionRange {
    /// Minimum version (inclusive)
    pub min: Option<Version>,
    /// Maximum version (exclusive by default, inclusive when `max_inclusive` is true)
    pub max: Option<Version>,
    /// Version constraint string
    pub constraint: String,
    /// Whether the upper bound is inclusive (`<=` for `last_affected`) or exclusive (`<` for `fixed`)
    #[serde(default)]
    pub max_inclusive: bool,
}

impl VersionRange {
    /// Check if a version is within this range
    pub fn contains(&self, version: &Version) -> bool {
        let min_satisfied = self.min.as_ref().map_or(true, |min| version >= min);
        let max_satisfied = self.max.as_ref().map_or(true, |max| {
            if self.max_inclusive {
                version <= max
            } else {
                version < max
            }
        });
        min_satisfied && max_satisfied
    }

    /// Create a new version range
    pub fn new(
        min: Option<Version>,
        max: Option<Version>,
        constraint: String,
        max_inclusive: bool,
    ) -> Self {
        Self {
            min,
            max,
            constraint,
            max_inclusive,
        }
    }
}

/// A vulnerability match found during scanning.
#[derive(Debug, Clone, Serialize)]
pub struct VulnerabilityMatch {
    /// The package that has the vulnerability
    pub package_name: PackageName,
    /// The installed version
    pub installed_version: Version,
    /// The vulnerability details (flattened so all fields appear at the top level in JSON)
    #[serde(flatten)]
    pub vulnerability: Vulnerability,
    /// Whether this is a direct or transitive dependency
    pub is_direct: bool,
}

/// A vulnerability database containing advisories and indexed lookups.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilityDatabase {
    /// All advisories in the database
    pub advisories: Vec<Vulnerability>,
    /// Index mapping package names to advisory indices
    pub package_index: HashMap<PackageName, Vec<usize>>,
}

impl Default for VulnerabilityDatabase {
    fn default() -> Self {
        Self::new()
    }
}

impl VulnerabilityDatabase {
    /// Create a new empty vulnerability database
    pub fn new() -> Self {
        Self {
            advisories: Vec::new(),
            package_index: HashMap::new(),
        }
    }

    /// Get advisories for a specific package
    #[cfg_attr(feature = "hotpath", hotpath::measure)]
    pub fn get_advisories_for_package(&self, package_name: &PackageName) -> Vec<&Vulnerability> {
        if let Some(indices) = self.package_index.get(package_name) {
            indices
                .iter()
                .filter_map(|&index| self.advisories.get(index))
                .collect()
        } else {
            Vec::new()
        }
    }

    /// Get the total number of advisories
    pub fn len(&self) -> usize {
        self.advisories.len()
    }

    /// Check if the database is empty
    pub fn is_empty(&self) -> bool {
        self.advisories.is_empty()
    }

    /// Create a database from a package-to-vulnerabilities mapping
    pub fn from_package_map(map: HashMap<String, Vec<Vulnerability>>) -> Self {
        let mut advisories = Vec::new();
        let mut package_index = HashMap::new();

        for (package_name, vulns) in map {
            let package = match PackageName::from_str(&package_name) {
                Ok(p) => p,
                Err(err) => {
                    warn!("Skipping invalid package name '{}': {}", package_name, err);
                    continue;
                }
            };
            let start_idx = advisories.len();
            for vuln in vulns {
                advisories.push(vuln);
            }
            let end_idx = advisories.len();
            if start_idx < end_idx {
                package_index.insert(package, (start_idx..end_idx).collect());
            }
        }

        Self {
            advisories,
            package_index,
        }
    }

    /// Merge multiple vulnerability databases into a single database.
    /// Vulnerabilities with the same ID **or shared aliases** are merged
    /// only when they belong to the **same package**. Advisories for
    /// different packages that share a CVE alias stay separate.
    pub fn merge(databases: Vec<Self>) -> Self {
        if databases.is_empty() {
            return Self::new();
        }

        // Maps any known ID or alias to the canonical ID for that vulnerability group
        let mut canonical_map: HashMap<String, String> = HashMap::new();
        // Composite key: (package_name, canonical_id) → merged Vulnerability
        let mut vuln_by_key: HashMap<(PackageName, String), Vulnerability> = HashMap::new();

        for database in &databases {
            for (package_name, indices) in &database.package_index {
                for &index in indices {
                    if let Some(vuln) = database.advisories.get(index) {
                        let canonical =
                            Self::resolve_canonical(&mut canonical_map, &vuln.id, &vuln.aliases);

                        let key = (package_name.clone(), canonical.clone());

                        if let Some(existing) = vuln_by_key.get_mut(&key) {
                            Self::merge_into(existing, vuln);
                            Self::register_aliases(
                                &mut canonical_map,
                                &canonical,
                                &vuln.id,
                                &vuln.aliases,
                            );
                        } else {
                            vuln_by_key.insert(key, vuln.clone());
                        }
                    }
                }
            }
        }

        // Build the final database grouped by package
        let mut by_package: HashMap<PackageName, Vec<Vulnerability>> = HashMap::new();
        for ((package_name, _canonical_id), vuln) in vuln_by_key {
            by_package.entry(package_name).or_default().push(vuln);
        }

        let mut all_advisories = Vec::new();
        let mut package_index = HashMap::new();

        for (package_name, vulns) in by_package {
            let start_index = all_advisories.len();
            all_advisories.extend(vulns);
            let end_index = all_advisories.len();
            if start_index < end_index {
                package_index.insert(package_name, (start_index..end_index).collect());
            }
        }

        Self {
            advisories: all_advisories,
            package_index,
        }
    }

    /// Resolve a vulnerability's canonical ID via alias lookup.
    /// If the ID or any alias is already known, returns the existing canonical.
    /// Otherwise creates a new canonical entry and registers all aliases.
    fn resolve_canonical(
        canonical_map: &mut HashMap<String, String>,
        id: &str,
        aliases: &[String],
    ) -> String {
        // Fast path: exact ID match
        if let Some(canonical) = canonical_map.get(id) {
            return canonical.clone();
        }

        // Check aliases for an existing canonical
        for alias in aliases {
            if let Some(canonical) = canonical_map.get(alias.as_str()) {
                let canonical = canonical.clone();
                canonical_map.insert(id.to_string(), canonical.clone());
                return canonical;
            }
        }

        // New group — this ID becomes canonical
        let canonical = id.to_string();
        canonical_map.insert(id.to_string(), canonical.clone());
        for alias in aliases {
            canonical_map
                .entry(alias.clone())
                .or_insert_with(|| canonical.clone());
        }
        canonical
    }

    /// Register additional aliases for an existing canonical ID.
    fn register_aliases(
        canonical_map: &mut HashMap<String, String>,
        canonical: &str,
        id: &str,
        aliases: &[String],
    ) {
        canonical_map
            .entry(id.to_string())
            .or_insert_with(|| canonical.to_string());
        for alias in aliases {
            canonical_map
                .entry(alias.clone())
                .or_insert_with(|| canonical.to_string());
        }
    }

    /// Merge `incoming` vulnerability data into `existing`.
    fn merge_into(existing: &mut Vulnerability, incoming: &Vulnerability) {
        // Merge sources
        if let (Some(existing_source), Some(new_source)) = (&existing.source, &incoming.source) {
            let mut sources: Vec<&str> = existing_source.split(',').collect();
            if !sources.contains(&new_source.as_str()) {
                sources.push(new_source);
                sources.sort();
                existing.source = Some(sources.join(","));
            }
        } else if existing.source.is_none() && incoming.source.is_some() {
            existing.source = incoming.source.clone();
        }

        // Merge CVSS: prefer highest version, then highest score
        let existing_ver = existing.cvss_version.unwrap_or(0);
        let new_ver = incoming.cvss_version.unwrap_or(0);

        let replace_cvss = match (existing.cvss_score, incoming.cvss_score) {
            (_, None) => false,
            (None, Some(_)) => true,
            (Some(existing_score), Some(new_score)) => match new_ver.cmp(&existing_ver) {
                std::cmp::Ordering::Greater => true,
                std::cmp::Ordering::Equal => {
                    new_score.total_cmp(&existing_score) == std::cmp::Ordering::Greater
                }
                std::cmp::Ordering::Less => false,
            },
        };

        if replace_cvss {
            existing.cvss_score = incoming.cvss_score;
            existing.cvss_version = incoming.cvss_version;
        }

        // Re-derive severity from CVSS score for consistency
        match existing.cvss_score {
            Some(score) => {
                existing.severity = Severity::from_cvss_score(score);
            }
            None => {
                if incoming.severity > existing.severity {
                    existing.severity = incoming.severity;
                }
            }
        }

        // Merge other fields (prefer non-empty values)
        if existing.description.is_none() && incoming.description.is_some() {
            existing.description = incoming.description.clone();
        }
        if existing.published.is_none() && incoming.published.is_some() {
            existing.published = incoming.published;
        }
        if existing.modified.is_none() && incoming.modified.is_some() {
            existing.modified = incoming.modified;
        }

        // Merge references (remove duplicates)
        for reference in &incoming.references {
            if !existing.references.contains(reference) {
                existing.references.push(reference.clone());
            }
        }
        existing.references.sort();

        // Merge affected and fixed versions (remove duplicates)
        for affected_version in &incoming.affected_versions {
            if !existing.affected_versions.contains(affected_version) {
                existing.affected_versions.push(affected_version.clone());
            }
        }
        for fixed_version in &incoming.fixed_versions {
            if !existing.fixed_versions.contains(fixed_version) {
                existing.fixed_versions.push(fixed_version.clone());
            }
        }

        // Merge aliases (union of both sets, excluding the existing ID itself)
        for alias in &incoming.aliases {
            if alias != &existing.id && !existing.aliases.contains(alias) {
                existing.aliases.push(alias.clone());
            }
        }
        // The incoming vuln's own ID becomes an alias of the canonical entry
        if incoming.id != existing.id && !existing.aliases.contains(&incoming.id) {
            existing.aliases.push(incoming.id.clone());
        }
    }
}

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

    fn create_test_vulnerability(id: &str, source: Option<&str>) -> Vulnerability {
        Vulnerability {
            id: id.to_string(),
            summary: format!("Test vulnerability {id}"),
            description: Some(format!("Description for {id}")),
            severity: Severity::Medium,
            affected_versions: vec![],
            fixed_versions: vec![],
            references: vec![],
            cvss_score: None,
            cvss_version: None,
            published: Some(Utc::now()),
            modified: None,
            source: source.map(|s| s.to_string()),
            withdrawn: None,
            aliases: vec![],
        }
    }

    #[test]
    fn test_merge_empty_databases() {
        let databases = vec![];
        let merged = VulnerabilityDatabase::merge(databases);
        assert!(merged.is_empty());
    }

    #[test]
    fn test_merge_single_database() {
        let mut db = VulnerabilityDatabase::new();
        let vuln = create_test_vulnerability("GHSA-1234", Some("test"));
        db.advisories.push(vuln.clone());

        let package = PackageName::from("test-package");
        db.package_index.insert(package, vec![0]);

        let databases = vec![db.clone()];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        assert_eq!(merged.advisories[0].id, "GHSA-1234");
        assert_eq!(merged.advisories[0].source, Some("test".to_string()));
    }

    #[test]
    fn test_merge_different_vulnerabilities() {
        let mut db1 = VulnerabilityDatabase::new();
        let vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        db1.advisories.push(vuln1);
        let package1 = PackageName::from("package1");
        db1.package_index.insert(package1, vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let vuln2 = create_test_vulnerability("GHSA-5678", Some("source2"));
        db2.advisories.push(vuln2);
        let package2 = PackageName::from("package2");
        db2.package_index.insert(package2, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 2);

        let ids: Vec<_> = merged.advisories.iter().map(|v| &v.id).collect();
        assert!(ids.contains(&&"GHSA-1234".to_string()));
        assert!(ids.contains(&&"GHSA-5678".to_string()));
    }

    #[test]
    fn test_merge_duplicate_vulnerability_ids() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.references = vec!["ref1".to_string()];
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.references = vec!["ref2".to_string()];
        vuln2.cvss_score = Some(7.5);
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];

        assert_eq!(merged_vuln.id, "GHSA-1234");
        assert_eq!(merged_vuln.source, Some("source1,source2".to_string()));
        assert_eq!(merged_vuln.references.len(), 2);
        assert!(merged_vuln.references.contains(&"ref1".to_string()));
        assert!(merged_vuln.references.contains(&"ref2".to_string()));
        assert_eq!(merged_vuln.cvss_score, Some(7.5));
    }

    #[test]
    fn test_merge_source_deduplication() {
        let mut db1 = VulnerabilityDatabase::new();
        let vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let vuln2 = create_test_vulnerability("GHSA-1234", Some("source1")); // Same source
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];

        assert_eq!(merged_vuln.id, "GHSA-1234");
        assert_eq!(merged_vuln.source, Some("source1".to_string())); // No duplication
    }

    #[test]
    fn test_merge_preserves_package_associations() {
        let mut db1 = VulnerabilityDatabase::new();
        let vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        db1.advisories.push(vuln1);
        let package1 = PackageName::from("package1");
        db1.package_index.insert(package1.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let vuln2 = create_test_vulnerability("GHSA-5678", Some("source2"));
        db2.advisories.push(vuln2);
        let package2 = PackageName::from("package2");
        db2.package_index.insert(package2.clone(), vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.package_index.len(), 2);
        assert!(merged.package_index.contains_key(&package1));
        assert!(merged.package_index.contains_key(&package2));

        let package1_vulns = merged.get_advisories_for_package(&package1);
        let package2_vulns = merged.get_advisories_for_package(&package2);

        assert_eq!(package1_vulns.len(), 1);
        assert_eq!(package2_vulns.len(), 1);
        assert_eq!(package1_vulns[0].id, "GHSA-1234");
        assert_eq!(package2_vulns[0].id, "GHSA-5678");
    }

    #[test]
    fn test_merge_prefers_higher_severity() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.severity = Severity::Low;
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.severity = Severity::Critical;
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.severity, Severity::Critical);
    }

    #[test]
    fn test_merge_prefers_higher_cvss_score() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.cvss_score = Some(5.0);
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.cvss_score = Some(9.8);
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.cvss_score, Some(9.8));
    }

    #[test]
    fn test_merge_keeps_cvss_when_other_is_none() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.cvss_score = Some(7.5);
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.cvss_score = None;
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.cvss_score, Some(7.5));
    }

    #[test]
    fn test_merge_fills_cvss_from_none() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.cvss_score = None;
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.cvss_score = Some(8.5);
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.cvss_score, Some(8.5));
    }

    #[test]
    fn test_merge_known_severity_overrides_unknown() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.severity = Severity::Unknown;
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.severity = Severity::High;
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        assert_eq!(merged.advisories[0].severity, Severity::High);
    }

    #[test]
    fn test_merge_unknown_does_not_override_known() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.severity = Severity::High;
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.severity = Severity::Unknown;
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        assert_eq!(merged.advisories[0].severity, Severity::High);
    }

    #[test]
    fn test_merge_prefers_higher_cvss_version() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.cvss_score = Some(9.0);
        vuln1.cvss_version = Some(2);
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.cvss_score = Some(8.5);
        vuln2.cvss_version = Some(3);
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        // CVSS v3 wins over v2 even with lower score
        assert_eq!(merged_vuln.cvss_score, Some(8.5));
        assert_eq!(merged_vuln.cvss_version, Some(3));
    }

    #[test]
    fn test_merge_rederives_severity_from_cvss() {
        let mut db1 = VulnerabilityDatabase::new();
        let mut vuln1 = create_test_vulnerability("GHSA-1234", Some("source1"));
        vuln1.severity = Severity::Critical;
        vuln1.cvss_score = Some(5.0);
        vuln1.cvss_version = Some(3);
        db1.advisories.push(vuln1);
        let package = PackageName::from("test-package");
        db1.package_index.insert(package.clone(), vec![0]);

        let mut db2 = VulnerabilityDatabase::new();
        let mut vuln2 = create_test_vulnerability("GHSA-1234", Some("source2"));
        vuln2.severity = Severity::Low;
        vuln2.cvss_score = None;
        db2.advisories.push(vuln2);
        db2.package_index.insert(package, vec![0]);

        let databases = vec![db1, db2];
        let merged = VulnerabilityDatabase::merge(databases);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        // Severity re-derived from CVSS 5.0 → Medium, not the original Critical
        assert_eq!(merged_vuln.severity, Severity::Medium);
        assert_eq!(merged_vuln.cvss_score, Some(5.0));
    }

    // --- Alias-based deduplication tests ---

    #[test]
    fn test_merge_deduplicates_by_alias_pysec_and_ghsa() {
        // Simulates: OSV returns PYSEC-123 (no CVSS, aliases GHSA-456) and
        // GHSA-456 (has CVSS) for the same package
        let mut db = VulnerabilityDatabase::new();

        let mut pysec = create_test_vulnerability("PYSEC-123", Some("osv"));
        pysec.severity = Severity::Unknown;
        pysec.cvss_score = None;
        pysec.aliases = vec!["CVE-2023-99999".to_string(), "GHSA-456".to_string()];
        db.advisories.push(pysec);

        let mut ghsa = create_test_vulnerability("GHSA-456", Some("osv"));
        ghsa.severity = Severity::Critical;
        ghsa.cvss_score = Some(9.8);
        ghsa.cvss_version = Some(3);
        ghsa.aliases = vec!["CVE-2023-99999".to_string(), "PYSEC-123".to_string()];
        db.advisories.push(ghsa);

        let package = PackageName::from("test-package");
        db.package_index.insert(package, vec![0, 1]);

        let merged = VulnerabilityDatabase::merge(vec![db]);

        assert_eq!(
            merged.len(),
            1,
            "Alias duplicates should be merged into one"
        );
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.cvss_score, Some(9.8));
        assert_eq!(merged_vuln.severity, Severity::Critical);
    }

    #[test]
    fn test_merge_deduplicates_across_databases_via_shared_cve() {
        // OSV returns GHSA-456 (with CVSS), PyPA returns PYSEC-123 (no CVSS)
        // Both share CVE-2023-99999 as an alias
        let mut db_osv = VulnerabilityDatabase::new();
        let mut ghsa = create_test_vulnerability("GHSA-456", Some("osv"));
        ghsa.cvss_score = Some(7.5);
        ghsa.cvss_version = Some(3);
        ghsa.severity = Severity::High;
        ghsa.aliases = vec!["CVE-2023-99999".to_string()];
        db_osv.advisories.push(ghsa);
        let package = PackageName::from("test-package");
        db_osv.package_index.insert(package.clone(), vec![0]);

        let mut db_pypa = VulnerabilityDatabase::new();
        let mut pysec = create_test_vulnerability("PYSEC-123", Some("pypa"));
        pysec.severity = Severity::Unknown;
        pysec.cvss_score = None;
        pysec.aliases = vec!["CVE-2023-99999".to_string()];
        db_pypa.advisories.push(pysec);
        db_pypa.package_index.insert(package, vec![0]);

        let merged = VulnerabilityDatabase::merge(vec![db_osv, db_pypa]);

        assert_eq!(
            merged.len(),
            1,
            "Cross-database alias duplicates should merge"
        );
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.cvss_score, Some(7.5));
        assert_eq!(merged_vuln.severity, Severity::High);
        let source = merged_vuln.source.as_deref().unwrap();
        assert!(source.contains("osv"));
        assert!(source.contains("pypa"));
    }

    #[test]
    fn test_merge_no_aliases_stays_separate() {
        let mut db = VulnerabilityDatabase::new();
        let vuln1 = create_test_vulnerability("GHSA-aaa", Some("osv"));
        let vuln2 = create_test_vulnerability("GHSA-bbb", Some("osv"));
        db.advisories.push(vuln1);
        db.advisories.push(vuln2);
        let package = PackageName::from("test-package");
        db.package_index.insert(package, vec![0, 1]);

        let merged = VulnerabilityDatabase::merge(vec![db]);

        assert_eq!(merged.len(), 2, "Unrelated vulns must stay separate");
    }

    #[test]
    fn test_merge_three_way_alias_dedup() {
        // OSV PYSEC (no CVSS), OSV GHSA (has CVSS), PyPA PYSEC (no CVSS)
        // All three refer to the same CVE
        let mut db_osv = VulnerabilityDatabase::new();

        let mut pysec_osv = create_test_vulnerability("PYSEC-100", Some("osv"));
        pysec_osv.severity = Severity::Unknown;
        pysec_osv.cvss_score = None;
        pysec_osv.aliases = vec!["CVE-2024-11111".to_string(), "GHSA-xyz".to_string()];
        db_osv.advisories.push(pysec_osv);

        let mut ghsa_osv = create_test_vulnerability("GHSA-xyz", Some("osv"));
        ghsa_osv.cvss_score = Some(8.1);
        ghsa_osv.cvss_version = Some(3);
        ghsa_osv.severity = Severity::High;
        ghsa_osv.aliases = vec!["CVE-2024-11111".to_string(), "PYSEC-100".to_string()];
        db_osv.advisories.push(ghsa_osv);

        let package = PackageName::from("test-package");
        db_osv.package_index.insert(package.clone(), vec![0, 1]);

        let mut db_pypa = VulnerabilityDatabase::new();
        let mut pysec_pypa = create_test_vulnerability("PYSEC-100", Some("pypa"));
        pysec_pypa.severity = Severity::Unknown;
        pysec_pypa.cvss_score = None;
        pysec_pypa.aliases = vec!["CVE-2024-11111".to_string()];
        db_pypa.advisories.push(pysec_pypa);
        db_pypa.package_index.insert(package, vec![0]);

        let merged = VulnerabilityDatabase::merge(vec![db_osv, db_pypa]);

        assert_eq!(
            merged.len(),
            1,
            "Three-way alias merge should produce one entry"
        );
        let merged_vuln = &merged.advisories[0];
        assert_eq!(merged_vuln.cvss_score, Some(8.1));
        assert_eq!(merged_vuln.severity, Severity::High);
        let source = merged_vuln.source.as_deref().unwrap();
        assert!(source.contains("osv"));
        assert!(source.contains("pypa"));
    }

    #[test]
    fn test_merge_aliases_are_collected() {
        let mut db = VulnerabilityDatabase::new();

        let mut vuln1 = create_test_vulnerability("PYSEC-1", Some("osv"));
        vuln1.aliases = vec!["CVE-2024-1".to_string()];
        db.advisories.push(vuln1);

        let mut vuln2 = create_test_vulnerability("GHSA-a", Some("osv"));
        vuln2.aliases = vec!["CVE-2024-1".to_string(), "PYSEC-1".to_string()];
        vuln2.cvss_score = Some(5.0);
        vuln2.cvss_version = Some(3);
        db.advisories.push(vuln2);

        let package = PackageName::from("pkg");
        db.package_index.insert(package, vec![0, 1]);

        let merged = VulnerabilityDatabase::merge(vec![db]);

        assert_eq!(merged.len(), 1);
        let merged_vuln = &merged.advisories[0];
        // The merged entry should list the other ID and CVE as aliases
        assert!(
            merged_vuln.aliases.contains(&"GHSA-a".to_string())
                || merged_vuln.aliases.contains(&"PYSEC-1".to_string()),
            "Merged aliases should include the other ID"
        );
        assert!(
            merged_vuln.aliases.contains(&"CVE-2024-1".to_string()),
            "Merged aliases should include shared CVE"
        );
    }

    #[test]
    fn test_severity_display() {
        assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
        assert_eq!(format!("{}", Severity::High), "HIGH");
        assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
        assert_eq!(format!("{}", Severity::Low), "LOW");
        assert_eq!(format!("{}", Severity::Unknown), "UNKNOWN");
    }

    #[test]
    fn test_version_range_contains_exclusive_upper_bound() {
        let range = VersionRange {
            min: Some(Version::from_str("1.0.0").unwrap()),
            max: Some(Version::from_str("2.0.0").unwrap()),
            constraint: ">=1.0.0,<2.0.0".to_string(),
            max_inclusive: false,
        };
        assert!(range.contains(&Version::from_str("1.0.0").unwrap()));
        assert!(range.contains(&Version::from_str("1.5.0").unwrap()));
        assert!(!range.contains(&Version::from_str("2.0.0").unwrap()));
        assert!(!range.contains(&Version::from_str("2.0.1").unwrap()));
        assert!(!range.contains(&Version::from_str("0.9.0").unwrap()));
    }

    #[test]
    fn test_version_range_contains_inclusive_upper_bound() {
        let range = VersionRange {
            min: Some(Version::from_str("1.0.0").unwrap()),
            max: Some(Version::from_str("2.0.0").unwrap()),
            constraint: ">=1.0.0,<=2.0.0".to_string(),
            max_inclusive: true,
        };
        assert!(range.contains(&Version::from_str("1.0.0").unwrap()));
        assert!(range.contains(&Version::from_str("1.5.0").unwrap()));
        assert!(range.contains(&Version::from_str("2.0.0").unwrap()));
        assert!(!range.contains(&Version::from_str("2.0.1").unwrap()));
        assert!(!range.contains(&Version::from_str("0.9.0").unwrap()));
    }

    #[test]
    fn test_version_range_deserialize_without_max_inclusive() {
        let json = r#"{"min":"1.0.0","max":"2.0.0","constraint":">=1.0.0,<2.0.0"}"#;
        let range: VersionRange = serde_json::from_str(json).unwrap();
        assert!(!range.max_inclusive);
        assert!(!range.contains(&Version::from_str("2.0.0").unwrap()));
    }

    /// Regression test for GitHub issue #148: PYSEC-2023-162 (langchain) and
    /// PYSEC-2023-163 (numexpr) share CVE-2023-39631 as an alias. Before the
    /// fix, the merge combined their version ranges, causing langchain 1.2.10
    /// to be flagged with numexpr's range and suggested to upgrade to 2.8.5.
    #[test]
    fn test_merge_cross_package_shared_alias_stays_separate() {
        let mut langchain_vuln = create_test_vulnerability("PYSEC-2023-162", Some("pypa"));
        langchain_vuln.aliases = vec!["CVE-2023-39631".to_string()];
        langchain_vuln.affected_versions = vec![VersionRange {
            min: None,
            max: Some(Version::from_str("0.0.308").unwrap()),
            constraint: "<0.0.308".to_string(),
            max_inclusive: false,
        }];
        langchain_vuln.fixed_versions = vec![Version::from_str("0.0.308").unwrap()];

        let mut numexpr_vuln = create_test_vulnerability("PYSEC-2023-163", Some("pypa"));
        numexpr_vuln.aliases = vec!["CVE-2023-39631".to_string()];
        numexpr_vuln.affected_versions = vec![VersionRange {
            min: None,
            max: Some(Version::from_str("2.8.5").unwrap()),
            constraint: "<2.8.5".to_string(),
            max_inclusive: false,
        }];
        numexpr_vuln.fixed_versions = vec![Version::from_str("2.8.5").unwrap()];

        let db = VulnerabilityDatabase::from_package_map(HashMap::from([
            ("langchain".to_string(), vec![langchain_vuln]),
            ("numexpr".to_string(), vec![numexpr_vuln]),
        ]));

        let merged = VulnerabilityDatabase::merge(vec![db]);

        // langchain's advisory should only have langchain's range and fix
        let langchain_indices = merged
            .package_index
            .get(&PackageName::from_str("langchain").unwrap())
            .expect("langchain should be in merged DB");
        assert_eq!(langchain_indices.len(), 1);
        let langchain_entry = &merged.advisories[langchain_indices[0]];
        assert_eq!(langchain_entry.fixed_versions.len(), 1);
        assert_eq!(
            langchain_entry.fixed_versions[0],
            Version::from_str("0.0.308").unwrap()
        );
        // langchain 1.2.10 must NOT be in any of langchain's affected ranges
        let v_1_2_10 = Version::from_str("1.2.10").unwrap();
        assert!(
            !langchain_entry
                .affected_versions
                .iter()
                .any(|r| r.contains(&v_1_2_10)),
            "langchain 1.2.10 must not be flagged"
        );

        // numexpr should have its own separate entry
        let numexpr_indices = merged
            .package_index
            .get(&PackageName::from_str("numexpr").unwrap())
            .expect("numexpr should be in merged DB");
        assert_eq!(numexpr_indices.len(), 1);
        let numexpr_entry = &merged.advisories[numexpr_indices[0]];
        assert_eq!(numexpr_entry.fixed_versions.len(), 1);
        assert_eq!(
            numexpr_entry.fixed_versions[0],
            Version::from_str("2.8.5").unwrap()
        );
    }

    #[test]
    fn test_merge_same_package_shared_alias_still_merges() {
        let mut osv_vuln = create_test_vulnerability("GHSA-abc", Some("osv"));
        osv_vuln.aliases = vec!["CVE-2024-99999".to_string()];
        osv_vuln.cvss_score = Some(8.0);
        osv_vuln.cvss_version = Some(3);

        let mut pypa_vuln = create_test_vulnerability("PYSEC-xyz", Some("pypa"));
        pypa_vuln.aliases = vec!["CVE-2024-99999".to_string()];

        let db1 = VulnerabilityDatabase::from_package_map(HashMap::from([(
            "requests".to_string(),
            vec![osv_vuln],
        )]));
        let db2 = VulnerabilityDatabase::from_package_map(HashMap::from([(
            "requests".to_string(),
            vec![pypa_vuln],
        )]));

        let merged = VulnerabilityDatabase::merge(vec![db1, db2]);

        let indices = merged
            .package_index
            .get(&PackageName::from_str("requests").unwrap())
            .expect("requests should be in merged DB");
        // Same package, shared alias → should merge into 1 entry
        assert_eq!(indices.len(), 1);
        let entry = &merged.advisories[indices[0]];
        // Should have merged sources
        let source = entry.source.as_ref().unwrap();
        assert!(source.contains("osv"));
        assert!(source.contains("pypa"));
    }
}
