//! Diff result structures.

use crate::model::{CanonicalId, Component, ComponentRef, DependencyEdge, VulnerabilityRef};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Map a severity string to a numeric rank for comparison.
///
/// Higher values indicate more severe vulnerabilities.
/// Returns 0 for unrecognized severity strings.
fn severity_rank(s: &str) -> u8 {
    match s.to_lowercase().as_str() {
        "critical" => 4,
        "high" => 3,
        "medium" => 2,
        "low" => 1,
        _ => 0,
    }
}

/// Complete result of an SBOM diff operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
pub struct DiffResult {
    /// Summary statistics
    pub summary: DiffSummary,
    /// Component changes
    pub components: ChangeSet<ComponentChange>,
    /// Dependency changes
    pub dependencies: ChangeSet<DependencyChange>,
    /// License changes
    pub licenses: LicenseChanges,
    /// Vulnerability changes
    pub vulnerabilities: VulnerabilityChanges,
    /// Total semantic score
    pub semantic_score: f64,
    /// Graph structural changes (only populated if graph diffing is enabled)
    #[serde(default)]
    pub graph_changes: Vec<DependencyGraphChange>,
    /// Summary of graph changes
    #[serde(default)]
    pub graph_summary: Option<GraphChangeSummary>,
    /// Number of custom matching rules applied
    #[serde(default)]
    pub rules_applied: usize,
    /// Quality impact of this diff (computed post-diff)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub quality_delta: Option<QualityDelta>,
    /// Matching quality metrics (populated during diff)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub match_metrics: Option<MatchMetrics>,
}

impl DiffResult {
    /// Create a new empty diff result
    pub fn new() -> Self {
        Self {
            summary: DiffSummary::default(),
            components: ChangeSet::new(),
            dependencies: ChangeSet::new(),
            licenses: LicenseChanges::default(),
            vulnerabilities: VulnerabilityChanges::default(),
            semantic_score: 0.0,
            graph_changes: Vec::new(),
            graph_summary: None,
            rules_applied: 0,
            quality_delta: None,
            match_metrics: None,
        }
    }

    /// Calculate and update summary statistics
    pub fn calculate_summary(&mut self) {
        self.summary.components_added = self.components.added.len();
        self.summary.components_removed = self.components.removed.len();
        self.summary.components_modified = self.components.modified.len();

        self.summary.dependencies_added = self.dependencies.added.len();
        self.summary.dependencies_removed = self.dependencies.removed.len();
        self.summary.graph_changes_count = self.graph_changes.len();

        self.summary.total_changes = self.summary.components_added
            + self.summary.components_removed
            + self.summary.components_modified
            + self.summary.dependencies_added
            + self.summary.dependencies_removed
            + self.summary.graph_changes_count;

        self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
        self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
        self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();

        self.summary.licenses_added = self.licenses.new_licenses.len();
        self.summary.licenses_removed = self.licenses.removed_licenses.len();
    }

    /// Check if there are any changes.
    ///
    /// Checks both the pre-computed summary and the source-of-truth fields to be
    /// safe regardless of whether `calculate_summary()` was called.
    #[must_use]
    pub fn has_changes(&self) -> bool {
        self.summary.total_changes > 0
            || !self.components.is_empty()
            || !self.dependencies.is_empty()
            || !self.graph_changes.is_empty()
            || !self.vulnerabilities.introduced.is_empty()
            || !self.vulnerabilities.resolved.is_empty()
    }

    /// Find a component change by canonical ID
    #[must_use]
    pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
        let id_str = id.value();
        self.components
            .added
            .iter()
            .chain(self.components.removed.iter())
            .chain(self.components.modified.iter())
            .find(|c| c.id == id_str)
    }

    /// Find a component change by ID string
    #[must_use]
    pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
        self.components
            .added
            .iter()
            .chain(self.components.removed.iter())
            .chain(self.components.modified.iter())
            .find(|c| c.id == id_str)
    }

    /// Get all component changes as a flat list with their indices for navigation
    #[must_use]
    pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
        self.components
            .added
            .iter()
            .chain(self.components.removed.iter())
            .chain(self.components.modified.iter())
            .collect()
    }

    /// Find vulnerabilities affecting a specific component by ID
    #[must_use]
    pub fn find_vulns_for_component(
        &self,
        component_id: &CanonicalId,
    ) -> Vec<&VulnerabilityDetail> {
        let id_str = component_id.value();
        self.vulnerabilities
            .introduced
            .iter()
            .chain(self.vulnerabilities.resolved.iter())
            .chain(self.vulnerabilities.persistent.iter())
            .filter(|v| v.component_id == id_str)
            .collect()
    }

    /// Build an index of component IDs to their changes for fast lookup
    #[must_use]
    pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
        self.components
            .added
            .iter()
            .chain(&self.components.removed)
            .chain(&self.components.modified)
            .map(|c| (c.id.clone(), c))
            .collect()
    }

    /// Filter vulnerabilities by minimum severity level
    pub fn filter_by_severity(&mut self, min_severity: &str) {
        let min_sev = severity_rank(min_severity);

        self.vulnerabilities
            .introduced
            .retain(|v| severity_rank(&v.severity) >= min_sev);
        self.vulnerabilities
            .resolved
            .retain(|v| severity_rank(&v.severity) >= min_sev);
        self.vulnerabilities
            .persistent
            .retain(|v| severity_rank(&v.severity) >= min_sev);

        // Recalculate summary
        self.calculate_summary();
    }

    /// Filter out vulnerabilities where VEX status is `NotAffected` or `Fixed`.
    ///
    /// Keeps vulnerabilities that are `Affected`, `UnderInvestigation`, or have no VEX status.
    pub fn filter_by_vex(&mut self) {
        self.vulnerabilities
            .introduced
            .retain(VulnerabilityDetail::is_vex_actionable);
        self.vulnerabilities
            .resolved
            .retain(VulnerabilityDetail::is_vex_actionable);
        self.vulnerabilities
            .persistent
            .retain(VulnerabilityDetail::is_vex_actionable);

        self.calculate_summary();
    }
}

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

/// Quality and compliance impact of the diff.
///
/// Computed by comparing quality scores of old vs new SBOMs.
/// Enables tracking whether a change improves or degrades SBOM quality.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QualityDelta {
    /// Overall score change (positive = improvement)
    pub overall_score_delta: f32,
    /// Old grade
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub old_grade: Option<crate::quality::QualityGrade>,
    /// New grade
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub new_grade: Option<crate::quality::QualityGrade>,
    /// Per-category score deltas
    pub category_deltas: Vec<CategoryDelta>,
    /// Categories that regressed (score decreased by >1 point)
    pub regressions: Vec<String>,
    /// Categories that improved (score increased by >1 point)
    pub improvements: Vec<String>,
    /// Compliance violation count change (positive = more violations)
    pub violation_count_delta: i32,
}

/// Score delta for a specific quality category.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CategoryDelta {
    /// Category name (e.g., "Completeness", "Identifiers")
    pub category: String,
    /// Score in old SBOM
    pub old_score: f32,
    /// Score in new SBOM
    pub new_score: f32,
    /// Change (new - old)
    pub delta: f32,
}

impl QualityDelta {
    /// Compute quality delta by comparing two quality reports.
    #[must_use]
    pub fn from_reports(
        old: &crate::quality::QualityReport,
        new: &crate::quality::QualityReport,
    ) -> Self {
        let categories = [
            (
                "Completeness",
                old.completeness_score,
                new.completeness_score,
            ),
            ("Identifiers", old.identifier_score, new.identifier_score),
            ("Licenses", old.license_score, new.license_score),
            ("Dependencies", old.dependency_score, new.dependency_score),
            ("Integrity", old.integrity_score, new.integrity_score),
            ("Provenance", old.provenance_score, new.provenance_score),
        ];

        let mut category_deltas: Vec<CategoryDelta> = categories
            .iter()
            .map(|(name, old_s, new_s)| CategoryDelta {
                category: (*name).to_string(),
                old_score: *old_s,
                new_score: *new_s,
                delta: new_s - old_s,
            })
            .collect();

        // Handle optional categories (VulnDocs and Lifecycle)
        if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
            category_deltas.push(CategoryDelta {
                category: "VulnDocs".to_string(),
                old_score: old_v,
                new_score: new_v,
                delta: new_v - old_v,
            });
        }
        if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
            category_deltas.push(CategoryDelta {
                category: "Lifecycle".to_string(),
                old_score: old_l,
                new_score: new_l,
                delta: new_l - old_l,
            });
        }

        let regressions: Vec<String> = category_deltas
            .iter()
            .filter(|d| d.delta < -1.0)
            .map(|d| d.category.clone())
            .collect();

        let improvements: Vec<String> = category_deltas
            .iter()
            .filter(|d| d.delta > 1.0)
            .map(|d| d.category.clone())
            .collect();

        // Compute compliance violation delta
        let old_violations = old.compliance.error_count + old.compliance.warning_count;
        let new_violations = new.compliance.error_count + new.compliance.warning_count;

        Self {
            overall_score_delta: new.overall_score - old.overall_score,
            old_grade: Some(old.grade),
            new_grade: Some(new.grade),
            category_deltas,
            regressions,
            improvements,
            violation_count_delta: new_violations as i32 - old_violations as i32,
        }
    }
}

/// Metrics about the component matching process.
///
/// Provides visibility into matching quality for debugging and tuning.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MatchMetrics {
    /// Number of exact matches (PURL, CPE, or canonical ID)
    pub exact_matches: usize,
    /// Number of fuzzy matches (below exact threshold)
    pub fuzzy_matches: usize,
    /// Number of custom rule matches
    pub rule_matches: usize,
    /// Components in old SBOM with no match
    pub unmatched_old: usize,
    /// Components in new SBOM with no match
    pub unmatched_new: usize,
    /// Average match confidence score
    pub avg_match_score: f64,
    /// Minimum match confidence score
    pub min_match_score: f64,
}

/// Summary statistics for the diff
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiffSummary {
    pub total_changes: usize,
    pub components_added: usize,
    pub components_removed: usize,
    pub components_modified: usize,
    pub dependencies_added: usize,
    pub dependencies_removed: usize,
    pub graph_changes_count: usize,
    pub vulnerabilities_introduced: usize,
    pub vulnerabilities_resolved: usize,
    pub vulnerabilities_persistent: usize,
    pub licenses_added: usize,
    pub licenses_removed: usize,
}

/// Generic change set for added/removed/modified items
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeSet<T> {
    pub added: Vec<T>,
    pub removed: Vec<T>,
    pub modified: Vec<T>,
}

impl<T> ChangeSet<T> {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            added: Vec::new(),
            removed: Vec::new(),
            modified: Vec::new(),
        }
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
    }

    #[must_use]
    pub fn total(&self) -> usize {
        self.added.len() + self.removed.len() + self.modified.len()
    }
}

impl<T> Default for ChangeSet<T> {
    fn default() -> Self {
        Self::new()
    }
}

/// Information about how a component was matched.
///
/// Included in JSON output to explain why components were correlated.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchInfo {
    /// Match confidence score (0.0 - 1.0)
    pub score: f64,
    /// Matching method used (`ExactIdentifier`, Alias, Fuzzy, etc.)
    pub method: String,
    /// Human-readable explanation
    pub reason: String,
    /// Detailed score breakdown (optional)
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub score_breakdown: Vec<MatchScoreComponent>,
    /// Normalizations applied during matching
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub normalizations: Vec<String>,
    /// Confidence interval for the match score
    #[serde(skip_serializing_if = "Option::is_none")]
    pub confidence_interval: Option<ConfidenceInterval>,
}

/// Confidence interval for match score.
///
/// Provides uncertainty bounds around the match score, useful for
/// understanding match reliability.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfidenceInterval {
    /// Lower bound of confidence (0.0 - 1.0)
    pub lower: f64,
    /// Upper bound of confidence (0.0 - 1.0)
    pub upper: f64,
    /// Confidence level (e.g., 0.95 for 95% CI)
    pub level: f64,
}

impl ConfidenceInterval {
    /// Create a new confidence interval.
    #[must_use]
    pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
        Self {
            lower: lower.clamp(0.0, 1.0),
            upper: upper.clamp(0.0, 1.0),
            level,
        }
    }

    /// Create a 95% confidence interval from a score and standard error.
    ///
    /// Uses ±1.96 × SE for 95% CI.
    #[must_use]
    pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
        let margin = 1.96 * std_error;
        Self::new(score - margin, score + margin, 0.95)
    }

    /// Create a simple confidence interval based on the matching tier.
    ///
    /// Exact matches have tight intervals, fuzzy matches have wider intervals.
    #[must_use]
    pub fn from_tier(score: f64, tier: &str) -> Self {
        let margin = match tier {
            "ExactIdentifier" => 0.0,
            "Alias" => 0.02,
            "EcosystemRule" => 0.03,
            "CustomRule" => 0.05,
            "Fuzzy" => 0.08,
            _ => 0.10,
        };
        Self::new(score - margin, score + margin, 0.95)
    }

    /// Get the width of the interval.
    #[must_use]
    pub fn width(&self) -> f64 {
        self.upper - self.lower
    }
}

/// A component of the match score for JSON output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchScoreComponent {
    /// Name of this score component
    pub name: String,
    /// Weight applied
    pub weight: f64,
    /// Raw score
    pub raw_score: f64,
    /// Weighted contribution
    pub weighted_score: f64,
    /// Description
    pub description: String,
}

/// Component change information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentChange {
    /// Component canonical ID (string for serialization)
    pub id: String,
    /// Typed canonical ID for navigation (skipped in JSON output for backward compat)
    #[serde(skip)]
    pub canonical_id: Option<CanonicalId>,
    /// Component reference with ID and name together
    #[serde(skip)]
    pub component_ref: Option<ComponentRef>,
    /// Old component ID (for modified components)
    #[serde(skip)]
    pub old_canonical_id: Option<CanonicalId>,
    /// Component name
    pub name: String,
    /// Old version (if existed)
    pub old_version: Option<String>,
    /// New version (if exists)
    pub new_version: Option<String>,
    /// Ecosystem
    pub ecosystem: Option<String>,
    /// Change type
    pub change_type: ChangeType,
    /// Detailed field changes
    pub field_changes: Vec<FieldChange>,
    /// Associated cost
    pub cost: u32,
    /// Match information (for modified components, explains how old/new were correlated)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub match_info: Option<MatchInfo>,
}

impl ComponentChange {
    /// Create a new component addition
    pub fn added(component: &Component, cost: u32) -> Self {
        Self {
            id: component.canonical_id.to_string(),
            canonical_id: Some(component.canonical_id.clone()),
            component_ref: Some(ComponentRef::from_component(component)),
            old_canonical_id: None,
            name: component.name.clone(),
            old_version: None,
            new_version: component.version.clone(),
            ecosystem: component
                .ecosystem
                .as_ref()
                .map(std::string::ToString::to_string),
            change_type: ChangeType::Added,
            field_changes: Vec::new(),
            cost,
            match_info: None,
        }
    }

    /// Create a new component removal
    pub fn removed(component: &Component, cost: u32) -> Self {
        Self {
            id: component.canonical_id.to_string(),
            canonical_id: Some(component.canonical_id.clone()),
            component_ref: Some(ComponentRef::from_component(component)),
            old_canonical_id: Some(component.canonical_id.clone()),
            name: component.name.clone(),
            old_version: component.version.clone(),
            new_version: None,
            ecosystem: component
                .ecosystem
                .as_ref()
                .map(std::string::ToString::to_string),
            change_type: ChangeType::Removed,
            field_changes: Vec::new(),
            cost,
            match_info: None,
        }
    }

    /// Create a component modification
    pub fn modified(
        old: &Component,
        new: &Component,
        field_changes: Vec<FieldChange>,
        cost: u32,
    ) -> Self {
        Self {
            id: new.canonical_id.to_string(),
            canonical_id: Some(new.canonical_id.clone()),
            component_ref: Some(ComponentRef::from_component(new)),
            old_canonical_id: Some(old.canonical_id.clone()),
            name: new.name.clone(),
            old_version: old.version.clone(),
            new_version: new.version.clone(),
            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
            change_type: ChangeType::Modified,
            field_changes,
            cost,
            match_info: None,
        }
    }

    /// Create a component modification with match explanation
    pub fn modified_with_match(
        old: &Component,
        new: &Component,
        field_changes: Vec<FieldChange>,
        cost: u32,
        match_info: MatchInfo,
    ) -> Self {
        Self {
            id: new.canonical_id.to_string(),
            canonical_id: Some(new.canonical_id.clone()),
            component_ref: Some(ComponentRef::from_component(new)),
            old_canonical_id: Some(old.canonical_id.clone()),
            name: new.name.clone(),
            old_version: old.version.clone(),
            new_version: new.version.clone(),
            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
            change_type: ChangeType::Modified,
            field_changes,
            cost,
            match_info: Some(match_info),
        }
    }

    /// Add match information to an existing change
    #[must_use]
    pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
        self.match_info = Some(match_info);
        self
    }

    /// Get the typed canonical ID, falling back to parsing from string if needed
    #[must_use]
    pub fn get_canonical_id(&self) -> CanonicalId {
        self.canonical_id.clone().unwrap_or_else(|| {
            CanonicalId::from_name_version(
                &self.name,
                self.new_version.as_deref().or(self.old_version.as_deref()),
            )
        })
    }

    /// Get a `ComponentRef` for this change
    #[must_use]
    pub fn get_component_ref(&self) -> ComponentRef {
        self.component_ref.clone().unwrap_or_else(|| {
            ComponentRef::with_version(
                self.get_canonical_id(),
                &self.name,
                self.new_version
                    .clone()
                    .or_else(|| self.old_version.clone()),
            )
        })
    }
}

impl MatchInfo {
    /// Create from a `MatchExplanation`
    #[must_use]
    pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
        let method = format!("{:?}", explanation.tier);
        let ci = ConfidenceInterval::from_tier(explanation.score, &method);
        Self {
            score: explanation.score,
            method,
            reason: explanation.reason.clone(),
            score_breakdown: explanation
                .score_breakdown
                .iter()
                .map(|c| MatchScoreComponent {
                    name: c.name.clone(),
                    weight: c.weight,
                    raw_score: c.raw_score,
                    weighted_score: c.weighted_score,
                    description: c.description.clone(),
                })
                .collect(),
            normalizations: explanation.normalizations_applied.clone(),
            confidence_interval: Some(ci),
        }
    }

    /// Create a simple match info without detailed breakdown
    #[must_use]
    pub fn simple(score: f64, method: &str, reason: &str) -> Self {
        let ci = ConfidenceInterval::from_tier(score, method);
        Self {
            score,
            method: method.to_string(),
            reason: reason.to_string(),
            score_breakdown: Vec::new(),
            normalizations: Vec::new(),
            confidence_interval: Some(ci),
        }
    }

    /// Create a match info with a custom confidence interval
    #[must_use]
    pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
        self.confidence_interval = Some(ci);
        self
    }
}

/// Type of change
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChangeType {
    Added,
    Removed,
    Modified,
    Unchanged,
}

/// Individual field change
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldChange {
    pub field: String,
    pub old_value: Option<String>,
    pub new_value: Option<String>,
}

/// Dependency change information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyChange {
    /// Source component
    pub from: String,
    /// Target component
    pub to: String,
    /// Relationship type
    pub relationship: String,
    /// Dependency scope
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scope: Option<String>,
    /// Change type
    pub change_type: ChangeType,
}

impl DependencyChange {
    #[must_use]
    pub fn added(edge: &DependencyEdge) -> Self {
        Self {
            from: edge.from.to_string(),
            to: edge.to.to_string(),
            relationship: edge.relationship.to_string(),
            scope: edge.scope.as_ref().map(std::string::ToString::to_string),
            change_type: ChangeType::Added,
        }
    }

    #[must_use]
    pub fn removed(edge: &DependencyEdge) -> Self {
        Self {
            from: edge.from.to_string(),
            to: edge.to.to_string(),
            relationship: edge.relationship.to_string(),
            scope: edge.scope.as_ref().map(std::string::ToString::to_string),
            change_type: ChangeType::Removed,
        }
    }
}

/// License change information
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LicenseChanges {
    /// Newly introduced licenses
    pub new_licenses: Vec<LicenseChange>,
    /// Removed licenses
    pub removed_licenses: Vec<LicenseChange>,
    /// License conflicts
    pub conflicts: Vec<LicenseConflict>,
    /// Components with license changes
    pub component_changes: Vec<ComponentLicenseChange>,
}

/// Individual license change
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseChange {
    /// License expression
    pub license: String,
    /// Components using this license
    pub components: Vec<String>,
    /// License family
    pub family: String,
}

/// License conflict information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseConflict {
    pub license_a: String,
    pub license_b: String,
    pub component: String,
    pub description: String,
}

/// Component-level license change
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentLicenseChange {
    pub component_id: String,
    pub component_name: String,
    pub old_licenses: Vec<String>,
    pub new_licenses: Vec<String>,
}

/// A VEX state change for a vulnerability between old and new SBOMs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VexStatusChange {
    /// Vulnerability ID (e.g., "CVE-2023-1234")
    pub vuln_id: String,
    /// Affected component name
    pub component_name: String,
    /// Old VEX state (None = no VEX in old SBOM)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub old_state: Option<crate::model::VexState>,
    /// New VEX state (None = no VEX in new SBOM)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub new_state: Option<crate::model::VexState>,
}

/// Vulnerability change information
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VulnerabilityChanges {
    /// Newly introduced vulnerabilities
    pub introduced: Vec<VulnerabilityDetail>,
    /// Resolved vulnerabilities
    pub resolved: Vec<VulnerabilityDetail>,
    /// Persistent vulnerabilities (present in both)
    pub persistent: Vec<VulnerabilityDetail>,
    /// VEX state transitions detected across persistent vulnerabilities
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub vex_changes: Vec<VexStatusChange>,
}

impl VulnerabilityChanges {
    /// Count vulnerabilities by severity
    #[must_use]
    pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
        // Pre-allocate for typical severity levels (critical, high, medium, low, unknown)
        let mut counts = HashMap::with_capacity(5);
        for vuln in &self.introduced {
            *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
        }
        counts
    }

    /// Get critical and high severity introduced vulnerabilities
    #[must_use]
    pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
        self.introduced
            .iter()
            .filter(|v| v.severity == "Critical" || v.severity == "High")
            .collect()
    }

    /// Compute VEX coverage summary across all vulnerability categories.
    pub fn vex_summary(&self) -> VexCoverageSummary {
        let all_vulns: Vec<&VulnerabilityDetail> = self
            .introduced
            .iter()
            .chain(&self.resolved)
            .chain(&self.persistent)
            .collect();

        let total = all_vulns.len();
        let mut with_vex = 0;
        let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
        let mut actionable = 0;

        for vuln in &all_vulns {
            if let Some(ref state) = vuln.vex_state {
                with_vex += 1;
                *by_state.entry(state.clone()).or_insert(0) += 1;
            }
            if vuln.is_vex_actionable() {
                actionable += 1;
            }
        }

        // Vulns without VEX (gaps) — both introduced and persistent are flagged
        let introduced_without_vex = self
            .introduced
            .iter()
            .filter(|v| v.vex_state.is_none())
            .count();

        let persistent_without_vex = self
            .persistent
            .iter()
            .filter(|v| v.vex_state.is_none())
            .count();

        VexCoverageSummary {
            total_vulns: total,
            with_vex,
            without_vex: total - with_vex,
            actionable,
            coverage_pct: if total > 0 {
                (with_vex as f64 / total as f64) * 100.0
            } else {
                100.0
            },
            by_state,
            introduced_without_vex,
            persistent_without_vex,
        }
    }
}

/// VEX coverage summary for vulnerability changes.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
pub struct VexCoverageSummary {
    /// Total vulnerabilities across all categories
    pub total_vulns: usize,
    /// Vulnerabilities with a VEX statement
    pub with_vex: usize,
    /// Vulnerabilities without a VEX statement
    pub without_vex: usize,
    /// Vulnerabilities that are VEX-actionable (no NotAffected/Fixed)
    pub actionable: usize,
    /// VEX coverage percentage (0.0-100.0)
    pub coverage_pct: f64,
    /// Breakdown by VEX state
    pub by_state: HashMap<crate::model::VexState, usize>,
    /// Introduced vulnerabilities without VEX (gaps requiring attention)
    pub introduced_without_vex: usize,
    /// Persistent vulnerabilities without VEX (ongoing gaps)
    pub persistent_without_vex: usize,
}

/// SLA status for vulnerability remediation tracking
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SlaStatus {
    /// Past SLA deadline by N days
    Overdue(i64),
    /// Due within 3 days (N days remaining)
    DueSoon(i64),
    /// Within SLA window (N days remaining)
    OnTrack(i64),
    /// No SLA deadline applicable
    NoDueDate,
}

impl SlaStatus {
    /// Format for display (e.g., "3d late", "2d left", "45d old")
    #[must_use]
    pub fn display(&self, days_since_published: Option<i64>) -> String {
        match self {
            Self::Overdue(days) => format!("{days}d late"),
            Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
            Self::NoDueDate => {
                days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
            }
        }
    }

    /// Check if this is an overdue status
    #[must_use]
    pub const fn is_overdue(&self) -> bool {
        matches!(self, Self::Overdue(_))
    }

    /// Check if this is due soon (approaching deadline)
    #[must_use]
    pub const fn is_due_soon(&self) -> bool {
        matches!(self, Self::DueSoon(_))
    }
}

/// Detailed vulnerability information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilityDetail {
    /// Vulnerability ID
    pub id: String,
    /// Source database
    pub source: String,
    /// Severity level
    pub severity: String,
    /// CVSS score
    pub cvss_score: Option<f32>,
    /// Affected component ID (string for serialization)
    pub component_id: String,
    /// Typed canonical ID for the component (skipped in JSON for backward compat)
    #[serde(skip)]
    pub component_canonical_id: Option<CanonicalId>,
    /// Component reference with ID and name together
    #[serde(skip)]
    pub component_ref: Option<ComponentRef>,
    /// Affected component name
    pub component_name: String,
    /// Affected version
    pub version: Option<String>,
    /// CWE identifiers
    pub cwes: Vec<String>,
    /// Description
    pub description: Option<String>,
    /// Remediation info
    pub remediation: Option<String>,
    /// Whether this vulnerability is in CISA's Known Exploited Vulnerabilities catalog
    #[serde(default)]
    pub is_kev: bool,
    /// Dependency depth (1 = direct, 2+ = transitive, None = unknown)
    #[serde(default)]
    pub component_depth: Option<u32>,
    /// Date vulnerability was published (ISO 8601)
    #[serde(default)]
    pub published_date: Option<String>,
    /// KEV due date (CISA mandated remediation deadline)
    #[serde(default)]
    pub kev_due_date: Option<String>,
    /// Days since published (positive = past)
    #[serde(default)]
    pub days_since_published: Option<i64>,
    /// Days until KEV due date (negative = overdue)
    #[serde(default)]
    pub days_until_due: Option<i64>,
    /// VEX state for this vulnerability's component (if available)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vex_state: Option<crate::model::VexState>,
    /// VEX justification (from per-vuln or component-level VEX)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vex_justification: Option<crate::model::VexJustification>,
    /// VEX impact statement (from per-vuln or component-level VEX)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vex_impact_statement: Option<String>,
}

impl VulnerabilityDetail {
    /// Whether this vulnerability is VEX-actionable (not resolved by vendor analysis).
    ///
    /// Returns `true` if the VEX state is `Affected`, `UnderInvestigation`, or absent.
    /// Returns `false` if the VEX state is `NotAffected` or `Fixed`.
    #[must_use]
    pub const fn is_vex_actionable(&self) -> bool {
        !matches!(
            self.vex_state,
            Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
        )
    }

    /// Create from a vulnerability reference and component
    pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
        // Calculate days since published (published is DateTime<Utc>)
        let days_since_published = vuln.published.map(|dt| {
            let today = chrono::Utc::now().date_naive();
            (today - dt.date_naive()).num_days()
        });

        // Format published date as string for serialization
        let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());

        // Get KEV info if present
        let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
            (
                Some(kev.due_date.format("%Y-%m-%d").to_string()),
                Some(kev.days_until_due()),
            )
        });

        Self {
            id: vuln.id.clone(),
            source: vuln.source.to_string(),
            severity: vuln
                .severity
                .as_ref()
                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
            cvss_score: vuln.max_cvss_score(),
            component_id: component.canonical_id.to_string(),
            component_canonical_id: Some(component.canonical_id.clone()),
            component_ref: Some(ComponentRef::from_component(component)),
            component_name: component.name.clone(),
            version: component.version.clone(),
            cwes: vuln.cwes.clone(),
            description: vuln.description.clone(),
            remediation: vuln.remediation.as_ref().map(|r| {
                format!(
                    "{}: {}",
                    r.remediation_type,
                    r.description.as_deref().unwrap_or("")
                )
            }),
            is_kev: vuln.is_kev,
            component_depth: None,
            published_date,
            kev_due_date,
            days_since_published,
            days_until_due,
            vex_state: {
                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
                vex_source.map(|v| v.status.clone())
            },
            vex_justification: {
                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
                vex_source.and_then(|v| v.justification.clone())
            },
            vex_impact_statement: {
                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
                vex_source.and_then(|v| v.impact_statement.clone())
            },
        }
    }

    /// Create from a vulnerability reference and component with known depth
    #[must_use]
    pub fn from_ref_with_depth(
        vuln: &VulnerabilityRef,
        component: &Component,
        depth: Option<u32>,
    ) -> Self {
        let mut detail = Self::from_ref(vuln, component);
        detail.component_depth = depth;
        detail
    }

    /// Calculate SLA status based on KEV due date or severity-based policy
    ///
    /// Priority order:
    /// 1. KEV due date (CISA mandated deadline)
    /// 2. Severity-based SLA (Critical=1d, High=7d, Medium=30d, Low=90d)
    #[must_use]
    pub fn sla_status(&self) -> SlaStatus {
        // KEV due date takes priority
        if let Some(days) = self.days_until_due {
            if days < 0 {
                return SlaStatus::Overdue(-days);
            } else if days <= 3 {
                return SlaStatus::DueSoon(days);
            }
            return SlaStatus::OnTrack(days);
        }

        // Fall back to severity-based SLA
        if let Some(age_days) = self.days_since_published {
            let sla_days = match self.severity.to_lowercase().as_str() {
                "critical" => 1,
                "high" => 7,
                "medium" => 30,
                "low" => 90,
                _ => return SlaStatus::NoDueDate,
            };
            let remaining = sla_days - age_days;
            if remaining < 0 {
                return SlaStatus::Overdue(-remaining);
            } else if remaining <= 3 {
                return SlaStatus::DueSoon(remaining);
            }
            return SlaStatus::OnTrack(remaining);
        }

        SlaStatus::NoDueDate
    }

    /// Get the typed component canonical ID
    #[must_use]
    pub fn get_component_id(&self) -> CanonicalId {
        self.component_canonical_id.clone().unwrap_or_else(|| {
            CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
        })
    }

    /// Get a `ComponentRef` for the affected component
    #[must_use]
    pub fn get_component_ref(&self) -> ComponentRef {
        self.component_ref.clone().unwrap_or_else(|| {
            ComponentRef::with_version(
                self.get_component_id(),
                &self.component_name,
                self.version.clone(),
            )
        })
    }
}

// ============================================================================
// Graph-Aware Diffing Types
// ============================================================================

/// Represents a structural change in the dependency graph
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyGraphChange {
    /// The component involved in the change
    pub component_id: CanonicalId,
    /// Human-readable component name
    pub component_name: String,
    /// The type of structural change
    pub change: DependencyChangeType,
    /// Assessed impact of this change
    pub impact: GraphChangeImpact,
}

/// Types of dependency graph structural changes
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DependencyChangeType {
    /// A new dependency link was added
    DependencyAdded {
        dependency_id: CanonicalId,
        dependency_name: String,
    },

    /// A dependency link was removed
    DependencyRemoved {
        dependency_id: CanonicalId,
        dependency_name: String,
    },

    /// Dependency relationship or scope changed (same endpoints, different attributes)
    RelationshipChanged {
        dependency_id: CanonicalId,
        dependency_name: String,
        old_relationship: String,
        new_relationship: String,
        old_scope: Option<String>,
        new_scope: Option<String>,
    },

    /// A dependency was reparented (had exactly one parent in both, but different)
    Reparented {
        dependency_id: CanonicalId,
        dependency_name: String,
        old_parent_id: CanonicalId,
        old_parent_name: String,
        new_parent_id: CanonicalId,
        new_parent_name: String,
    },

    /// Dependency depth changed (e.g., transitive became direct)
    DepthChanged {
        old_depth: u32, // 1 = root, 2 = direct, 3+ = transitive
        new_depth: u32,
    },
}

impl DependencyChangeType {
    /// Get a short description of the change type
    #[must_use]
    pub const fn kind(&self) -> &'static str {
        match self {
            Self::DependencyAdded { .. } => "added",
            Self::DependencyRemoved { .. } => "removed",
            Self::RelationshipChanged { .. } => "relationship_changed",
            Self::Reparented { .. } => "reparented",
            Self::DepthChanged { .. } => "depth_changed",
        }
    }
}

/// Impact level of a graph change
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum GraphChangeImpact {
    /// Internal reorganization, no functional change
    Low,
    /// Depth or type change, may affect build/runtime
    Medium,
    /// Security-relevant component relationship changed
    High,
    /// Vulnerable component promoted to direct dependency
    Critical,
}

impl GraphChangeImpact {
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Low => "low",
            Self::Medium => "medium",
            Self::High => "high",
            Self::Critical => "critical",
        }
    }

    /// Parse from a string label. Returns Low for unrecognized values.
    #[must_use]
    pub fn from_label(s: &str) -> Self {
        match s.to_lowercase().as_str() {
            "critical" => Self::Critical,
            "high" => Self::High,
            "medium" => Self::Medium,
            _ => Self::Low,
        }
    }
}

impl std::fmt::Display for GraphChangeImpact {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// Summary statistics for graph changes
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GraphChangeSummary {
    pub total_changes: usize,
    pub dependencies_added: usize,
    pub dependencies_removed: usize,
    pub relationship_changed: usize,
    pub reparented: usize,
    pub depth_changed: usize,
    pub by_impact: GraphChangesByImpact,
}

impl GraphChangeSummary {
    /// Build summary from a list of changes
    #[must_use]
    pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
        let mut summary = Self {
            total_changes: changes.len(),
            ..Default::default()
        };

        for change in changes {
            match &change.change {
                DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
                DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
                DependencyChangeType::RelationshipChanged { .. } => {
                    summary.relationship_changed += 1;
                }
                DependencyChangeType::Reparented { .. } => summary.reparented += 1,
                DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
            }

            match change.impact {
                GraphChangeImpact::Low => summary.by_impact.low += 1,
                GraphChangeImpact::Medium => summary.by_impact.medium += 1,
                GraphChangeImpact::High => summary.by_impact.high += 1,
                GraphChangeImpact::Critical => summary.by_impact.critical += 1,
            }
        }

        summary
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GraphChangesByImpact {
    pub low: usize,
    pub medium: usize,
    pub high: usize,
    pub critical: usize,
}
