//! HTML report generator.

use super::escape::{escape_html, escape_html_opt};
use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
use crate::model::NormalizedSbom;
use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
use std::fmt::Write;

/// HTML report generator
pub struct HtmlReporter {
    /// Include inline CSS
    include_styles: bool,
}

impl HtmlReporter {
    /// Create a new HTML reporter
    #[must_use]
    pub const fn new() -> Self {
        Self {
            include_styles: true,
        }
    }
}

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

// ============================================================================
// HTML building helpers
// ============================================================================

/// Write the HTML document head (doctype through opening body/container).
fn write_html_head(html: &mut String, title: &str, include_styles: bool) -> std::fmt::Result {
    writeln!(html, "<!DOCTYPE html>")?;
    writeln!(html, "<html lang=\"en\">")?;
    writeln!(html, "<head>")?;
    writeln!(html, "    <meta charset=\"UTF-8\">")?;
    writeln!(
        html,
        "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
    )?;
    writeln!(html, "    <title>{}</title>", escape_html(title))?;
    if include_styles {
        writeln!(html, "{HTML_STYLES}")?;
    }
    writeln!(html, "</head>")?;
    writeln!(html, "<body>")?;
    writeln!(html, "<div class=\"container\">")
}

/// Write the page header with title and generation info.
fn write_page_header(html: &mut String, title: &str, subtitle: Option<&str>) -> std::fmt::Result {
    writeln!(html, "<div class=\"header\" id=\"top\">")?;
    writeln!(html, "    <h1>{}</h1>", escape_html(title))?;
    if let Some(sub) = subtitle {
        writeln!(html, "    <p>{}</p>", escape_html(sub))?;
    }
    writeln!(
        html,
        "    <p>Generated by sbom-tools v{} on {}</p>",
        env!("CARGO_PKG_VERSION"),
        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
    )?;
    writeln!(html, "</div>")
}

/// Write a table of contents navigation.
fn write_toc(html: &mut String, sections: &[(&str, &str)]) -> std::fmt::Result {
    writeln!(html, "<nav class=\"toc\">")?;
    writeln!(html, "    <strong>Contents:</strong>")?;
    for (id, label) in sections {
        write!(html, "    <a href=\"#{id}\">{label}</a>")?;
    }
    writeln!(html)?;
    writeln!(html, "</nav>")
}

/// Write a single summary card.
fn write_card(html: &mut String, title: &str, value: &str, css_class: &str) -> std::fmt::Result {
    writeln!(html, "    <div class=\"card\">")?;
    writeln!(html, "        <div class=\"card-title\">{title}</div>")?;
    writeln!(
        html,
        "        <div class=\"card-value {css_class}\">{value}</div>"
    )?;
    writeln!(html, "    </div>")
}

/// Write the HTML document footer and closing tags.
fn write_html_footer(html: &mut String) -> std::fmt::Result {
    writeln!(html, "<div class=\"footer\">")?;
    writeln!(
        html,
        "    <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>"
    )?;
    writeln!(html, "</div>")?;
    writeln!(html, "</div>")?;
    writeln!(html, "</body>")?;
    writeln!(html, "</html>")
}

/// Write an EOL components section if any components have EOL data.
fn write_eol_section(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
    use crate::model::EolStatus;

    let eol_components: Vec<_> = sbom
        .components
        .values()
        .filter(|c| {
            c.eol.as_ref().is_some_and(|e| {
                matches!(
                    e.status,
                    EolStatus::EndOfLife | EolStatus::ApproachingEol | EolStatus::SecurityOnly
                )
            })
        })
        .collect();

    if eol_components.is_empty() {
        return Ok(());
    }

    writeln!(html, "<div class=\"section\" id=\"eol\">")?;
    writeln!(html, "    <h2>End-of-Life Components</h2>")?;
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(
        html,
        "            <tr><th>Component</th><th>Version</th><th>Status</th><th>Product</th><th>EOL Date</th></tr>"
    )?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    for comp in &eol_components {
        let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
        let badge_class = match eol.status {
            EolStatus::EndOfLife => "badge-critical",
            EolStatus::ApproachingEol => "badge-warning",
            EolStatus::SecurityOnly => "badge-info",
            _ => "",
        };
        let eol_date = eol
            .eol_date
            .map_or_else(|| "-".to_string(), |d| d.to_string());

        writeln!(
            html,
            "            <tr><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td><td>{}</td><td>{}</td></tr>",
            escape_html(&comp.name),
            escape_html(comp.version.as_deref().unwrap_or("-")),
            badge_class,
            escape_html(eol.status.label()),
            escape_html(&eol.product),
            escape_html(&eol_date),
        )?;
    }

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")?;
    writeln!(html, "</div>")
}

/// Write the component changes table for diff reports.
fn write_diff_component_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
    writeln!(html, "<div class=\"section\" id=\"component-changes\">")?;
    writeln!(html, "    <h2>Component Changes</h2>")?;
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(html, "            <tr>")?;
    writeln!(html, "                <th>Status</th>")?;
    writeln!(html, "                <th>Name</th>")?;
    writeln!(html, "                <th>Old Version</th>")?;
    writeln!(html, "                <th>New Version</th>")?;
    writeln!(html, "                <th>Ecosystem</th>")?;
    writeln!(html, "            </tr>")?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    for comp in &result.components.added {
        writeln!(html, "            <tr>")?;
        writeln!(
            html,
            "                <td><span class=\"badge badge-added\">Added</span></td>"
        )?;
        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
        writeln!(html, "                <td>-</td>")?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.new_version.as_deref())
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.ecosystem.as_deref())
        )?;
        writeln!(html, "            </tr>")?;
    }

    for comp in &result.components.removed {
        writeln!(html, "            <tr>")?;
        writeln!(
            html,
            "                <td><span class=\"badge badge-removed\">Removed</span></td>"
        )?;
        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.old_version.as_deref())
        )?;
        writeln!(html, "                <td>-</td>")?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.ecosystem.as_deref())
        )?;
        writeln!(html, "            </tr>")?;
    }

    for comp in &result.components.modified {
        writeln!(html, "            <tr>")?;
        writeln!(
            html,
            "                <td><span class=\"badge badge-modified\">Modified</span></td>"
        )?;
        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.old_version.as_deref())
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.new_version.as_deref())
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.ecosystem.as_deref())
        )?;
        writeln!(html, "            </tr>")?;
    }

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")?;
    writeln!(html, "</div>")
}

/// Write the introduced vulnerabilities table for diff reports.
fn write_diff_vuln_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
    writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
    writeln!(html, "    <h2>Introduced Vulnerabilities</h2>")?;
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(html, "            <tr>")?;
    writeln!(html, "                <th>ID</th>")?;
    writeln!(html, "                <th>Severity</th>")?;
    writeln!(html, "                <th>CVSS</th>")?;
    writeln!(html, "                <th>SLA</th>")?;
    writeln!(html, "                <th>Type</th>")?;
    writeln!(html, "                <th>Component</th>")?;
    writeln!(html, "                <th>Version</th>")?;
    writeln!(html, "                <th>VEX</th>")?;
    writeln!(html, "            </tr>")?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    for vuln in &result.vulnerabilities.introduced {
        let badge_class = match vuln.severity.to_lowercase().as_str() {
            "critical" => "badge-critical",
            "high" => "badge-high",
            "medium" => "badge-medium",
            _ => "badge-low",
        };
        let (depth_label, depth_class) = match vuln.component_depth {
            Some(1) => ("Direct", "badge-direct"),
            Some(_) => ("Transitive", "badge-transitive"),
            None => ("-", ""),
        };
        writeln!(html, "            <tr>")?;
        writeln!(html, "                <td>{}</td>", escape_html(&vuln.id))?;
        writeln!(
            html,
            "                <td><span class=\"badge {}\">{}</span></td>",
            badge_class,
            escape_html(&vuln.severity)
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            vuln.cvss_score
                .map(|s| format!("{s:.1}"))
                .as_deref()
                .unwrap_or("-")
        )?;
        // SLA cell
        let (sla_text, sla_class) = format_sla_html(vuln);
        if sla_class.is_empty() {
            writeln!(html, "                <td>{sla_text}</td>")?;
        } else {
            writeln!(
                html,
                "                <td><span class=\"{sla_class}\">{sla_text}</span></td>"
            )?;
        }
        if depth_class.is_empty() {
            writeln!(html, "                <td>{depth_label}</td>")?;
        } else {
            writeln!(
                html,
                "                <td><span class=\"badge {depth_class}\">{depth_label}</span></td>"
            )?;
        }
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html(&vuln.component_name)
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(vuln.version.as_deref())
        )?;
        // VEX status
        let vex_display = format_vex_html(vuln.vex_state.as_ref());
        writeln!(html, "                <td>{vex_display}</td>")?;
        writeln!(html, "            </tr>")?;
    }

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")?;
    writeln!(html, "</div>")
}

/// Write the components table for view reports.
fn write_view_component_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
    writeln!(html, "<div class=\"section\" id=\"components\">")?;
    writeln!(html, "    <h2>Components</h2>")?;
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(html, "            <tr>")?;
    writeln!(html, "                <th>Name</th>")?;
    writeln!(html, "                <th>Version</th>")?;
    writeln!(html, "                <th>Ecosystem</th>")?;
    writeln!(html, "                <th>License</th>")?;
    writeln!(html, "                <th>Vulnerabilities</th>")?;
    writeln!(html, "            </tr>")?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    // Sort components by name for consistent output
    let mut components: Vec<_> = sbom.components.values().collect();
    components.sort_by(|a, b| a.name.cmp(&b.name));

    for comp in components {
        let license_str = comp
            .licenses
            .declared
            .first()
            .map_or("-", |l| l.expression.as_str());
        let vuln_count = comp.vulnerabilities.len();
        let vuln_badge = if vuln_count > 0 {
            format!("<span class=\"badge badge-critical\">{vuln_count}</span>")
        } else {
            "0".to_string()
        };

        writeln!(html, "            <tr>")?;
        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(comp.version.as_deref())
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            comp.ecosystem
                .as_ref()
                .map(|e| escape_html(&format!("{e:?}")))
                .as_deref()
                .unwrap_or("-")
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html(license_str)
        )?;
        writeln!(html, "                <td>{vuln_badge}</td>")?;
        writeln!(html, "            </tr>")?;
    }

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")?;
    writeln!(html, "</div>")
}

/// A view vulnerability row with SLA info.
type ViewVulnRow<'a> = (
    &'a str,
    &'a Option<crate::model::Severity>,
    Option<f32>,
    &'a str,
    Option<&'a str>,
    Option<&'a crate::model::VulnerabilityRef>,
);

/// Write the vulnerabilities table for view reports (with SLA columns).
fn write_view_vuln_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
    writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
    writeln!(html, "    <h2>Vulnerabilities</h2>")?;
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(html, "            <tr>")?;
    writeln!(html, "                <th>ID</th>")?;
    writeln!(html, "                <th>Severity</th>")?;
    writeln!(html, "                <th>CVSS</th>")?;
    writeln!(html, "                <th>SLA</th>")?;
    writeln!(html, "                <th>Component</th>")?;
    writeln!(html, "                <th>Version</th>")?;
    writeln!(html, "                <th>VEX</th>")?;
    writeln!(html, "            </tr>")?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    // Collect all vulnerabilities with their component info
    let mut all_vulns: Vec<ViewVulnRow<'_>> = sbom
        .components
        .values()
        .flat_map(|comp| {
            comp.vulnerabilities.iter().map(move |v| {
                (
                    v.id.as_str(),
                    &v.severity,
                    v.cvss.first().map(|c| c.base_score),
                    comp.name.as_str(),
                    comp.version.as_deref(),
                    Some(v),
                )
            })
        })
        .collect();

    // Sort by severity (critical first)
    all_vulns.sort_by(|a, b| {
        let sev_order = |s: &Option<crate::model::Severity>| match s {
            Some(crate::model::Severity::Critical) => 0,
            Some(crate::model::Severity::High) => 1,
            Some(crate::model::Severity::Medium) => 2,
            Some(crate::model::Severity::Low) => 3,
            Some(crate::model::Severity::Info) => 4,
            _ => 5,
        };
        sev_order(a.1).cmp(&sev_order(b.1))
    });

    for &(id, severity, cvss, comp_name, version, vuln) in &all_vulns {
        let (badge_class, sev_str) = match severity {
            Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
            Some(crate::model::Severity::High) => ("badge-high", "High"),
            Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
            Some(crate::model::Severity::Low) => ("badge-low", "Low"),
            Some(crate::model::Severity::Info) => ("badge-low", "Info"),
            _ => ("badge-low", "Unknown"),
        };

        // Compute SLA from published date if available
        let (sla_text, sla_class) = if let Some(v) = vuln {
            compute_view_sla(v)
        } else {
            ("-".to_string(), "sla-unknown")
        };

        writeln!(html, "            <tr>")?;
        writeln!(html, "                <td>{}</td>", escape_html(id))?;
        writeln!(
            html,
            "                <td><span class=\"badge {badge_class}\">{sev_str}</span></td>"
        )?;
        writeln!(
            html,
            "                <td>{}</td>",
            cvss.map(|s| format!("{s:.1}")).as_deref().unwrap_or("-")
        )?;
        if sla_class.is_empty() {
            writeln!(html, "                <td>{sla_text}</td>")?;
        } else {
            writeln!(
                html,
                "                <td><span class=\"{sla_class}\">{sla_text}</span></td>"
            )?;
        }
        writeln!(html, "                <td>{}</td>", escape_html(comp_name))?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html_opt(version)
        )?;
        // VEX status from per-vuln
        let vex_state = vuln.and_then(|v| v.vex_status.as_ref().map(|vs| &vs.status));
        let vex_display = format_vex_html(vex_state);
        writeln!(html, "                <td>{vex_display}</td>")?;
        writeln!(html, "            </tr>")?;
    }

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")?;
    writeln!(html, "</div>")
}

/// Compute SLA display for a vulnerability in view mode (from published date).
fn compute_view_sla(vuln: &crate::model::VulnerabilityRef) -> (String, &'static str) {
    if let Some(published) = vuln.published {
        let delta: chrono::TimeDelta = chrono::Utc::now() - published;
        let days = delta.num_days();
        if days < 0 {
            return ("-".to_string(), "sla-unknown");
        }
        let days = days as u64;
        // Use standard SLA thresholds based on severity
        let sla_days: Option<u64> = match &vuln.severity {
            Some(crate::model::Severity::Critical) => Some(15),
            Some(crate::model::Severity::High) => Some(30),
            Some(crate::model::Severity::Medium) => Some(90),
            Some(crate::model::Severity::Low) => Some(180),
            _ => None,
        };
        if let Some(sla) = sla_days {
            if days > sla {
                (format!("{}d late", days - sla), "sla-overdue")
            } else if sla - days <= 7 {
                (format!("{}d left", sla - days), "sla-due-soon")
            } else {
                (format!("{}d left", sla - days), "sla-on-track")
            }
        } else {
            (format!("{days}d old"), "sla-unknown")
        }
    } else {
        ("-".to_string(), "sla-unknown")
    }
}

/// Format SLA status for HTML display.
fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
    match vuln.sla_status() {
        SlaStatus::Overdue(days) => (format!("{days}d late"), "sla-overdue"),
        SlaStatus::DueSoon(days) => (format!("{days}d left"), "sla-due-soon"),
        SlaStatus::OnTrack(days) => (format!("{days}d left"), "sla-on-track"),
        SlaStatus::NoDueDate => {
            let text = vuln
                .days_since_published
                .map_or_else(|| "-".to_string(), |d| format!("{d}d old"));
            (text, "sla-unknown")
        }
    }
}

/// Format VEX state as an HTML badge string.
fn format_vex_html(vex_state: Option<&crate::model::VexState>) -> String {
    match vex_state {
        Some(crate::model::VexState::NotAffected) => {
            "<span class=\"badge badge-added\">Not Affected</span>".to_string()
        }
        Some(crate::model::VexState::Fixed) => {
            "<span class=\"badge badge-added\">Fixed</span>".to_string()
        }
        Some(crate::model::VexState::Affected) => {
            "<span class=\"badge badge-removed\">Affected</span>".to_string()
        }
        Some(crate::model::VexState::UnderInvestigation) => {
            "<span class=\"badge badge-medium\">Under Investigation</span>".to_string()
        }
        None => "-".to_string(),
    }
}

/// Compute compliance score as percentage (0-100)
fn compliance_score_html(result: &ComplianceResult) -> u8 {
    let total = result.violations.len() + 1;
    let issues = result.error_count + result.warning_count;
    let score = if issues >= total {
        0
    } else {
        ((total - issues) * 100) / total
    };
    score.min(100) as u8
}

/// Generate an HTML trend badge for numeric delta
fn trend_badge(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
    if old_val == new_val {
        ""
    } else if (new_val < old_val) == lower_is_better {
        " <span class=\"badge badge-added\">improved</span>"
    } else {
        " <span class=\"badge badge-removed\">regressed</span>"
    }
}

/// Write a CRA compliance comparison section for diff reports.
fn write_cra_compliance_diff_html(
    html: &mut String,
    old: &ComplianceResult,
    new: &ComplianceResult,
) -> std::fmt::Result {
    writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
    writeln!(html, "    <h2>CRA Compliance</h2>")?;
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(
        html,
        "            <tr><th></th><th>Old SBOM</th><th>New SBOM</th><th>Trend</th></tr>"
    )?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    let old_badge = compliance_status_badge(old.is_compliant);
    let new_badge = compliance_status_badge(new.is_compliant);
    let old_score = compliance_score_html(old);
    let new_score = compliance_score_html(new);
    let err_trend = trend_badge(old.error_count, new.error_count, true);
    let warn_trend = trend_badge(old.warning_count, new.warning_count, true);
    let score_trend = trend_badge(old_score.into(), new_score.into(), false);

    writeln!(
        html,
        "            <tr><td><strong>Status</strong></td><td>{old_badge}</td><td>{new_badge}</td><td></td></tr>"
    )?;
    writeln!(
        html,
        "            <tr><td><strong>Score</strong></td><td>{old_score}%</td><td>{new_score}%</td><td>{score_trend}</td></tr>"
    )?;
    writeln!(
        html,
        "            <tr><td><strong>Level</strong></td><td>{}</td><td>{}</td><td></td></tr>",
        escape_html(old.level.name()),
        escape_html(new.level.name())
    )?;
    writeln!(
        html,
        "            <tr><td><strong>Errors</strong></td><td>{}</td><td>{}</td><td>{err_trend}</td></tr>",
        old.error_count, new.error_count
    )?;
    writeln!(
        html,
        "            <tr><td><strong>Warnings</strong></td><td>{}</td><td>{}</td><td>{warn_trend}</td></tr>",
        old.warning_count, new.warning_count
    )?;

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")?;

    if !new.violations.is_empty() {
        writeln!(html, "    <h3>Violations (New SBOM)</h3>")?;
        write_violation_table_html(html, &new.violations)?;
    }

    writeln!(html, "</div>")?;
    writeln!(
        html,
        "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
    )
}

/// Write a CRA compliance section for view reports.
fn write_cra_compliance_view_html(
    html: &mut String,
    result: &ComplianceResult,
) -> std::fmt::Result {
    writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
    writeln!(html, "    <h2>CRA Compliance</h2>")?;

    let badge = compliance_status_badge(result.is_compliant);
    let score = compliance_score_html(result);
    writeln!(html, "    <p><strong>Status:</strong> {badge} &nbsp; ")?;
    writeln!(html, "    <strong>Score:</strong> {score}% &nbsp; ")?;
    writeln!(
        html,
        "    <strong>Level:</strong> {} &nbsp; ",
        escape_html(result.level.name())
    )?;
    writeln!(
        html,
        "    <strong>Issues:</strong> {} errors, {} warnings</p>",
        result.error_count, result.warning_count
    )?;

    if !result.violations.is_empty() {
        write_violation_table_html(html, &result.violations)?;
    }

    writeln!(html, "</div>")?;
    writeln!(
        html,
        "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
    )
}

/// Aggregate violations by (severity, category, requirement) to reduce noise.
fn aggregate_violations_html(
    violations: &[crate::quality::Violation],
) -> Vec<AggregatedViolationHtml<'_>> {
    use std::collections::BTreeMap;

    let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
    for v in violations {
        let sev_ord = match v.severity {
            ViolationSeverity::Error => 0,
            ViolationSeverity::Warning => 1,
            ViolationSeverity::Info => 2,
        };
        groups
            .entry((sev_ord, v.category.name(), v.requirement.as_str()))
            .or_default()
            .push(v);
    }

    groups
        .into_values()
        .map(|group| {
            let message = if group.len() == 1 {
                group[0].message.clone()
            } else {
                let elements: Vec<&str> =
                    group.iter().filter_map(|v| v.element.as_deref()).collect();
                if elements.is_empty() {
                    group[0].message.clone()
                } else {
                    let preview: Vec<&str> = elements.iter().take(5).copied().collect();
                    let suffix = if elements.len() > 5 {
                        format!(", ... +{} more", elements.len() - 5)
                    } else {
                        String::new()
                    };
                    format!(
                        "{} components affected ({}{})",
                        elements.len(),
                        preview.join(", "),
                        suffix
                    )
                }
            };
            AggregatedViolationHtml {
                severity: group[0].severity,
                category: group[0].category.name(),
                requirement: &group[0].requirement,
                message,
                remediation: group[0].remediation_guidance(),
                count: group.len(),
            }
        })
        .collect()
}

struct AggregatedViolationHtml<'a> {
    severity: ViolationSeverity,
    category: &'a str,
    requirement: &'a str,
    message: String,
    remediation: &'static str,
    count: usize,
}

/// Write an HTML table of compliance violations (aggregated, with collapsible remediation).
fn write_violation_table_html(
    html: &mut String,
    violations: &[crate::quality::Violation],
) -> std::fmt::Result {
    let aggregated = aggregate_violations_html(violations);
    writeln!(html, "    <table>")?;
    writeln!(html, "        <thead>")?;
    writeln!(html, "            <tr>")?;
    writeln!(html, "                <th>Severity</th>")?;
    writeln!(html, "                <th>Category</th>")?;
    writeln!(html, "                <th>Requirement</th>")?;
    writeln!(html, "                <th>Message</th>")?;
    writeln!(html, "                <th>Remediation</th>")?;
    writeln!(html, "            </tr>")?;
    writeln!(html, "        </thead>")?;
    writeln!(html, "        <tbody>")?;

    for v in &aggregated {
        let (badge_class, label) = match v.severity {
            ViolationSeverity::Error => ("badge-critical", "Error"),
            ViolationSeverity::Warning => ("badge-medium", "Warning"),
            ViolationSeverity::Info => ("badge-low", "Info"),
        };
        let count_suffix = if v.count > 1 {
            format!(
                " <span class=\"badge badge-transitive\">x{}</span>",
                v.count
            )
        } else {
            String::new()
        };
        writeln!(html, "            <tr>")?;
        writeln!(
            html,
            "                <td><span class=\"badge {badge_class}\">{label}</span>{count_suffix}</td>"
        )?;
        writeln!(html, "                <td>{}</td>", escape_html(v.category))?;
        writeln!(
            html,
            "                <td>{}</td>",
            escape_html(v.requirement)
        )?;
        writeln!(html, "                <td>{}</td>", escape_html(&v.message))?;
        writeln!(
            html,
            "                <td><details><summary>View</summary>{}</details></td>",
            escape_html(v.remediation)
        )?;
        writeln!(html, "            </tr>")?;
    }

    writeln!(html, "        </tbody>")?;
    writeln!(html, "    </table>")
}

/// Generate an HTML badge for compliance status.
fn compliance_status_badge(is_compliant: bool) -> &'static str {
    if is_compliant {
        "<span class=\"badge badge-added\">Compliant</span>"
    } else {
        "<span class=\"badge badge-removed\">Non-compliant</span>"
    }
}

// ============================================================================
// Report generation (using helpers above)
// ============================================================================

impl ReportGenerator for HtmlReporter {
    fn generate_diff_report(
        &self,
        result: &DiffResult,
        old_sbom: &NormalizedSbom,
        new_sbom: &NormalizedSbom,
        config: &ReportConfig,
    ) -> Result<String, ReportError> {
        let mut html = String::new();
        let title = config
            .title
            .clone()
            .unwrap_or_else(|| "SBOM Diff Report".to_string());

        write_html_head(&mut html, &title, self.include_styles)?;
        write_page_header(&mut html, &title, None)?;

        // Build TOC entries based on what will render
        let has_components =
            config.includes(ReportType::Components) && !result.components.is_empty();
        let has_vulns = config.includes(ReportType::Vulnerabilities)
            && !result.vulnerabilities.introduced.is_empty();
        let mut toc_entries: Vec<(&str, &str)> = Vec::new();
        if has_components {
            toc_entries.push(("component-changes", "Components"));
        }
        if has_vulns {
            toc_entries.push(("vulnerabilities", "Vulnerabilities"));
        }
        toc_entries.push(("cra-compliance", "CRA Compliance"));
        write_toc(&mut html, &toc_entries)?;

        // Summary cards
        writeln!(html, "<div class=\"summary-cards\">")?;
        write_card(
            &mut html,
            "Components Added",
            &format!("+{}", result.summary.components_added),
            "added",
        )?;
        write_card(
            &mut html,
            "Components Removed",
            &format!("-{}", result.summary.components_removed),
            "removed",
        )?;
        write_card(
            &mut html,
            "Components Modified",
            &format!("~{}", result.summary.components_modified),
            "modified",
        )?;
        write_card(
            &mut html,
            "Vulns Introduced",
            &result.summary.vulnerabilities_introduced.to_string(),
            "critical",
        )?;
        write_card(
            &mut html,
            "Semantic Score",
            &format!("{:.1}", result.semantic_score),
            "",
        )?;
        writeln!(html, "</div>")?;

        // Component changes
        if has_components {
            write_diff_component_table(&mut html, result)?;
        }

        // Vulnerability changes
        if has_vulns {
            write_diff_vuln_table(&mut html, result)?;
        }

        // End-of-Life section
        write_eol_section(&mut html, new_sbom)?;

        // CRA Compliance
        {
            let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
            });
            let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
            });
            write_cra_compliance_diff_html(&mut html, &old_cra, &new_cra)?;
        }

        write_html_footer(&mut html)?;
        Ok(html)
    }

    fn generate_view_report(
        &self,
        sbom: &NormalizedSbom,
        config: &ReportConfig,
    ) -> Result<String, ReportError> {
        use std::collections::HashSet;

        let mut html = String::new();
        let title = config
            .title
            .clone()
            .unwrap_or_else(|| "SBOM Report".to_string());

        // Compute statistics
        let total_components = sbom.component_count();
        let vuln_component_count = sbom
            .components
            .values()
            .filter(|c| !c.vulnerabilities.is_empty())
            .count();
        let total_vulns: usize = sbom
            .components
            .values()
            .map(|c| c.vulnerabilities.len())
            .sum();
        let ecosystems: HashSet<_> = sbom
            .components
            .values()
            .filter_map(|c| c.ecosystem.as_ref())
            .collect();
        let licenses: HashSet<String> = sbom
            .components
            .values()
            .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
            .collect();

        let subtitle = sbom
            .document
            .name
            .as_deref()
            .map(|n| format!("Document: {n}"));
        write_html_head(&mut html, &title, self.include_styles)?;
        write_page_header(&mut html, &title, subtitle.as_deref())?;

        // Build TOC
        let has_components = config.includes(ReportType::Components) && total_components > 0;
        let has_vulns = config.includes(ReportType::Vulnerabilities) && total_vulns > 0;
        let mut toc_entries: Vec<(&str, &str)> = Vec::new();
        if has_components {
            toc_entries.push(("components", "Components"));
        }
        if has_vulns {
            toc_entries.push(("vulnerabilities", "Vulnerabilities"));
        }
        toc_entries.push(("cra-compliance", "CRA Compliance"));
        write_toc(&mut html, &toc_entries)?;

        // Summary cards
        writeln!(html, "<div class=\"summary-cards\">")?;
        write_card(
            &mut html,
            "Total Components",
            &total_components.to_string(),
            "",
        )?;
        let vuln_class = if vuln_component_count > 0 {
            "critical"
        } else {
            ""
        };
        write_card(
            &mut html,
            "Vulnerable Components",
            &vuln_component_count.to_string(),
            vuln_class,
        )?;
        let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
        write_card(
            &mut html,
            "Total Vulnerabilities",
            &total_vulns.to_string(),
            total_vuln_class,
        )?;
        write_card(&mut html, "Ecosystems", &ecosystems.len().to_string(), "")?;
        write_card(
            &mut html,
            "Unique Licenses",
            &licenses.len().to_string(),
            "",
        )?;
        writeln!(html, "</div>")?;

        // Components table
        if has_components {
            write_view_component_table(&mut html, sbom)?;
        }

        // Vulnerabilities table
        if has_vulns {
            write_view_vuln_table(&mut html, sbom)?;
        }

        // CRA Compliance
        {
            let cra = config
                .view_cra_compliance
                .clone()
                .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
            write_cra_compliance_view_html(&mut html, &cra)?;
        }

        write_html_footer(&mut html)?;
        Ok(html)
    }

    fn format(&self) -> ReportFormat {
        ReportFormat::Html
    }
}

// ============================================================================
// Inline CSS styles
// ============================================================================

const HTML_STYLES: &str = r"
        <style>
            :root {
                --bg-color: #1e1e2e;
                --text-color: #cdd6f4;
                --accent-color: #89b4fa;
                --success-color: #a6e3a1;
                --warning-color: #f9e2af;
                --error-color: #f38ba8;
                --border-color: #45475a;
                --card-bg: #313244;
            }

            body {
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                background-color: var(--bg-color);
                color: var(--text-color);
                margin: 0;
                padding: 20px;
                line-height: 1.6;
            }

            .container {
                max-width: 1200px;
                margin: 0 auto;
            }

            h1, h2, h3 {
                color: var(--accent-color);
            }

            .header {
                border-bottom: 2px solid var(--border-color);
                padding-bottom: 20px;
                margin-bottom: 30px;
            }

            .summary-cards {
                display: grid;
                grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
                gap: 20px;
                margin-bottom: 30px;
            }

            .card {
                background-color: var(--card-bg);
                border-radius: 8px;
                padding: 20px;
                border: 1px solid var(--border-color);
            }

            .card-title {
                font-size: 0.9em;
                color: #a6adc8;
                margin-bottom: 10px;
            }

            .card-value {
                font-size: 2em;
                font-weight: bold;
            }

            .card-value.added { color: var(--success-color); }
            .card-value.removed { color: var(--error-color); }
            .card-value.modified { color: var(--warning-color); }
            .card-value.critical { color: var(--error-color); }

            table {
                width: 100%;
                border-collapse: collapse;
                margin-bottom: 30px;
                background-color: var(--card-bg);
                border-radius: 8px;
                overflow: hidden;
            }

            th, td {
                padding: 12px 15px;
                text-align: left;
                border-bottom: 1px solid var(--border-color);
            }

            th {
                background-color: #45475a;
                font-weight: 600;
            }

            tr:hover {
                background-color: #3b3d4d;
            }

            .badge {
                display: inline-block;
                padding: 2px 8px;
                border-radius: 4px;
                font-size: 0.85em;
                font-weight: 500;
            }

            .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
            .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
            .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
            .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
            .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
            .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
            .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
            .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
            .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
            .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
            .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
            .sla-on-track { color: #8b949e; }
            .sla-unknown { color: #8b949e; }

            .section {
                margin-bottom: 40px;
            }

            .tabs {
                display: flex;
                border-bottom: 2px solid var(--border-color);
                margin-bottom: 20px;
            }

            .tab {
                padding: 10px 20px;
                cursor: pointer;
                border-bottom: 2px solid transparent;
                margin-bottom: -2px;
            }

            .tab:hover {
                color: var(--accent-color);
            }

            .tab.active {
                border-bottom-color: var(--accent-color);
                color: var(--accent-color);
            }

            .footer {
                margin-top: 40px;
                padding-top: 20px;
                border-top: 1px solid var(--border-color);
                font-size: 0.9em;
                color: #a6adc8;
            }

            .toc {
                background-color: var(--card-bg);
                border: 1px solid var(--border-color);
                border-radius: 8px;
                padding: 12px 20px;
                margin-bottom: 30px;
                display: flex;
                gap: 16px;
                align-items: center;
                flex-wrap: wrap;
            }

            .toc a {
                color: var(--accent-color);
                text-decoration: none;
                padding: 4px 8px;
                border-radius: 4px;
            }

            .toc a:hover {
                background-color: rgba(137, 180, 250, 0.1);
            }

            .back-to-top {
                display: inline-block;
                color: #a6adc8;
                text-decoration: none;
                font-size: 0.85em;
                margin-bottom: 20px;
            }

            .back-to-top:hover {
                color: var(--accent-color);
            }

            details summary {
                cursor: pointer;
                color: var(--accent-color);
                font-size: 0.85em;
            }

            details summary:hover {
                text-decoration: underline;
            }

            details[open] summary {
                margin-bottom: 6px;
            }
        </style>
";
