// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! CSAF document CRUD route handlers.
//!
//! Provides list, create, view, edit, update, delete, and JSON download
//! endpoints with validation, audit logging, and export.
//!
//! The create and edit pages present a proper HTML form with labelled
//! inputs for every important CSAF field; the document is assembled
//! server-side via [`build_document_from_form`] and then validated.

use http::{Response, StatusCode};
use serde::Deserialize;

use csaf_core::export::export_document;
use csaf_core::validation;
use csaf_models::csaf_document::{
    Branch, CsafDocument, CvssV3, CvssV4, Cwe, Distribution, Document, Engine, FullProductName,
    Generator, Metric, MetricContent, Note, ProductStatus, ProductTree, Publisher, Revision, Tlp,
    Tracking, Vulnerability,
};

use crate::app_state::AppState;
use crate::router::{
    Body, form_field, html_response, json_response, parse_form_body, path_param, redirect,
};
use crate::routes::layout::{Nav, html_escape, wrap_page};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Format a CVSS score with a severity colour class.
fn format_score(score: Option<f64>) -> String {
    match score {
        Some(s) => {
            let class = if s >= 9.0 {
                "sev-critical"
            } else if s >= 7.0 {
                "sev-high"
            } else if s >= 4.0 {
                "sev-medium"
            } else if s > 0.0 {
                "sev-low"
            } else {
                "sev-none"
            };
            format!(r#"<span class="{class}">{s:.1}</span>"#)
        },
        None => "-".to_owned(),
    }
}

/// Render an error page inside the shared layout.
fn error_page(
    title: &str,
    theme: &str,
    status: StatusCode,
    message: &str,
    back_url: Option<&str>,
) -> Response<Body> {
    let back = back_url
        .map(|u| {
            format!(
                r#"<a class="btn secondary" href="{u}">Back</a>"#,
                u = html_escape(u)
            )
        })
        .unwrap_or_default();
    let content = format!(
        r#"<h1>{title_h}</h1>
<div class="flash error">{msg}</div>
{back}"#,
        title_h = html_escape(title),
        msg = message, // caller-provided HTML allowed (must already be escaped)
    );
    html_response(status, wrap_page(title, theme, Nav::Csaf, &content))
}

fn required_field<'a>(form: &'a [(String, String)], name: &str) -> Result<&'a str, String> {
    form_field(form, name)
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| format!("Field '{name}' is required"))
}

fn optional_field<'a>(form: &'a [(String, String)], name: &str) -> Option<&'a str> {
    form_field(form, name)
        .map(str::trim)
        .filter(|s| !s.is_empty())
}

fn parse_f64(form: &[(String, String)], name: &str) -> Result<Option<f64>, String> {
    match optional_field(form, name) {
        None => Ok(None),
        Some(s) => s
            .parse::<f64>()
            .map(Some)
            .map_err(|e| format!("Field '{name}' must be a number: {e}")),
    }
}

/// Separator between classification fields in `document.distribution.text`.
const CLASSIFICATION_TEXT_SEPARATOR: &str = " | ";

/// Extract a classification value (Verschlusssache / NATO) from a stored
/// CSAF document.
///
/// Looks up the value in two places, in order:
///
/// 1. `document.distribution.text` — split on `" | "`, then each part is
///    matched against `"<text_prefix><value>"`.
/// 2. `document.notes[]` — the first note whose `title` equals
///    `title_key` (case-sensitive) contributes its `text`.
///
/// Falls back to `default` when neither location yields a value. Exposed
/// as `pub` so that fuzz targets and unit tests can exercise it.
#[must_use]
pub fn extract_classification(
    doc: &CsafDocument,
    title_key: &str,
    text_prefix: &str,
    default: &str,
) -> String {
    if let Some(dist) = doc.document.distribution.as_ref()
        && let Some(text) = dist.text.as_deref()
    {
        for part in text.split(CLASSIFICATION_TEXT_SEPARATOR) {
            let trimmed = part.trim();
            if let Some(rest) = trimmed.strip_prefix(text_prefix) {
                let value = rest.trim();
                if !value.is_empty() {
                    return value.to_owned();
                }
            }
        }
    }

    for note in &doc.document.notes {
        if note.title.as_deref() == Some(title_key) {
            let value = note.text.trim();
            if !value.is_empty() {
                return value.to_owned();
            }
        }
    }

    default.to_owned()
}

// ---------------------------------------------------------------------------
// Form → CsafDocument builder
// ---------------------------------------------------------------------------

/// Build a minimal but complete `CsafDocument` from form-encoded fields.
///
/// Captures the common case of a single vendor/product/vulnerability. Complex
/// multi-product or multi-vulnerability documents must be edited through the
/// JSON API (`/api/v1/csaf`).
///
/// Exposed as `pub` so that fuzz targets and integration tests can exercise
/// the builder with arbitrary form inputs.
///
/// # Errors
///
/// Returns a human-readable error string if any required field is missing or
/// malformed.
pub fn build_document_from_form(
    form: &[(String, String)],
    csaf_mode: &str,
    settings: &csaf_models::settings::Settings,
) -> Result<CsafDocument, String> {
    let tracking_id = required_field(form, "tracking_id")?.to_owned();
    let title = required_field(form, "title")?.to_owned();
    let category = required_field(form, "category")?.to_owned();
    let status = required_field(form, "status")?.to_owned();
    let doc_version = required_field(form, "doc_version")?.to_owned();
    let initial_release_date = required_field(form, "initial_release_date")?.to_owned();
    let current_release_date = required_field(form, "current_release_date")?.to_owned();

    let publisher_name = required_field(form, "publisher_name")?.to_owned();
    let publisher_namespace = required_field(form, "publisher_namespace")?.to_owned();
    let publisher_category = required_field(form, "publisher_category")?.to_owned();
    let publisher_contact = optional_field(form, "publisher_contact").map(str::to_owned);

    let tlp_label = required_field(form, "tlp_label")?.to_owned();
    if !csaf_models::settings::is_valid_tlp(&tlp_label) {
        return Err(format!("Field 'tlp_label' has invalid value: {tlp_label}"));
    }

    // Verschlusssache / NATO are optional because the user may have
    // disabled them in the Settings page. If enabled, validate against
    // the canonical whitelist so stored documents cannot carry
    // arbitrary HTML-injectable strings.
    let verschlusssache = if settings.verschlusssache_enabled {
        optional_field(form, "verschlusssache").map(str::to_owned)
    } else {
        None
    };
    if let Some(vs) = verschlusssache.as_deref()
        && !csaf_models::settings::is_valid_verschlusssache(vs)
    {
        return Err(format!("Field 'verschlusssache' has invalid value: {vs}"));
    }

    let nato = if settings.nato_enabled {
        optional_field(form, "nato").map(str::to_owned)
    } else {
        None
    };
    if let Some(n) = nato.as_deref()
        && !csaf_models::settings::is_valid_nato(n)
    {
        return Err(format!("Field 'nato' has invalid value: {n}"));
    }

    let summary_text = required_field(form, "summary_text")?.to_owned();

    let vendor_name = required_field(form, "vendor_name")?.to_owned();
    let product_id = required_field(form, "product_id")?.to_owned();
    let product_name = required_field(form, "product_name")?.to_owned();

    // Vulnerability is optional for informational advisories but required
    // for security advisories.
    let vuln_required = category == "csaf_security_advisory";
    let cve = optional_field(form, "cve").map(str::to_owned);
    let vuln_title = optional_field(form, "vuln_title").map(str::to_owned);
    let vuln_desc = optional_field(form, "vuln_description").map(str::to_owned);
    let cwe_id = optional_field(form, "cwe_id").map(str::to_owned);
    let cwe_name = optional_field(form, "cwe_name").map(str::to_owned);

    let v3_score = parse_f64(form, "cvss_v3_score")?;
    let v3_vector = optional_field(form, "cvss_v3_vector").map(str::to_owned);
    let v3_severity = optional_field(form, "cvss_v3_severity").map(str::to_owned);

    let v4_score = parse_f64(form, "cvss_v4_score")?;
    let v4_vector = optional_field(form, "cvss_v4_vector").map(str::to_owned);
    let v4_severity = optional_field(form, "cvss_v4_severity").map(str::to_owned);

    let product_status_choice = required_field(form, "product_status")?;

    // -----------------------------------------------------------------------
    // Build the document
    // -----------------------------------------------------------------------

    let product_tree = ProductTree {
        branches: vec![Branch {
            category: "vendor".to_owned(),
            name: vendor_name.clone(),
            branches: vec![Branch {
                category: "product_name".to_owned(),
                name: product_name.clone(),
                branches: Vec::new(),
                product: Some(FullProductName {
                    name: format!("{vendor_name} {product_name}"),
                    product_id: product_id.clone(),
                    cpe: None,
                    purl: None,
                }),
            }],
            product: None,
        }],
        full_product_names: Vec::new(),
        product_groups: Vec::new(),
        relationships: Vec::new(),
    };

    let product_status = Some(ProductStatus {
        known_affected: if product_status_choice == "known_affected" {
            vec![product_id.clone()]
        } else {
            Vec::new()
        },
        known_not_affected: if product_status_choice == "known_not_affected" {
            vec![product_id.clone()]
        } else {
            Vec::new()
        },
        fixed: if product_status_choice == "fixed" {
            vec![product_id.clone()]
        } else {
            Vec::new()
        },
        under_investigation: if product_status_choice == "under_investigation" {
            vec![product_id.clone()]
        } else {
            Vec::new()
        },
        first_affected: Vec::new(),
        first_fixed: Vec::new(),
        last_affected: Vec::new(),
        recommended: Vec::new(),
    });

    let mut vuln_notes = Vec::new();
    if let Some(desc) = vuln_desc {
        vuln_notes.push(Note {
            category: "description".to_owned(),
            text: desc,
            title: Some("Vulnerability Description".to_owned()),
            audience: None,
        });
    }

    let metrics = if v3_score.is_some() || v4_score.is_some() {
        vec![Metric {
            content: MetricContent {
                cvss_v3: v3_score.map(|score| CvssV3 {
                    version: "3.1".to_owned(),
                    vector_string: v3_vector.clone().unwrap_or_default(),
                    base_score: score,
                    base_severity: v3_severity.clone().unwrap_or_else(|| "NONE".to_owned()),
                    attack_vector: None,
                    attack_complexity: None,
                    privileges_required: None,
                    user_interaction: None,
                    scope: None,
                    confidentiality_impact: None,
                    integrity_impact: None,
                    availability_impact: None,
                }),
                cvss_v4: v4_score.map(|score| CvssV4 {
                    version: "4.0".to_owned(),
                    vector_string: v4_vector.clone().unwrap_or_default(),
                    base_score: score,
                    base_severity: v4_severity.clone().unwrap_or_else(|| "NONE".to_owned()),
                    attack_vector: None,
                    attack_complexity: None,
                    attack_requirements: None,
                    privileges_required: None,
                    user_interaction: None,
                    confidentiality_impact: None,
                    integrity_impact: None,
                    availability_impact: None,
                    sub_confidentiality_impact: None,
                    sub_integrity_impact: None,
                    sub_availability_impact: None,
                }),
            },
            products: vec![product_id],
            source: None,
        }]
    } else {
        Vec::new()
    };

    let vulnerability = Vulnerability {
        cve,
        cwe: cwe_id.and_then(|id| cwe_name.map(|name| Cwe { id, name })),
        discovery_date: None,
        ids: Vec::new(),
        notes: vuln_notes,
        product_status,
        remediations: Vec::new(),
        metrics,
        threats: Vec::new(),
        title: vuln_title,
        release_date: None,
        references: Vec::new(),
        involvements: Vec::new(),
        flags: Vec::new(),
    };

    let vulnerabilities = if vuln_required
        || vulnerability.cve.is_some()
        || vulnerability.title.is_some()
        || !vulnerability.metrics.is_empty()
    {
        vec![vulnerability]
    } else {
        Vec::new()
    };

    // Build classification payload into distribution.text and/or notes[]
    // based on the configured storage mode.
    let mode = settings.classification_storage_mode.as_str();
    let write_text = matches!(mode, "distribution_text" | "both");
    let write_notes = matches!(mode, "notes" | "both");

    let mut text_parts: Vec<String> = Vec::new();
    if let Some(vs) = verschlusssache.as_deref() {
        text_parts.push(format!("Verschlusssache: {vs}"));
    }
    if let Some(n) = nato.as_deref() {
        text_parts.push(format!("NATO: {n}"));
    }
    let distribution_text = if write_text && !text_parts.is_empty() {
        Some(text_parts.join(CLASSIFICATION_TEXT_SEPARATOR))
    } else {
        None
    };

    let mut notes = vec![Note {
        category: "summary".to_owned(),
        text: summary_text,
        title: Some("Summary".to_owned()),
        audience: None,
    }];
    if write_notes {
        if let Some(vs) = verschlusssache.as_deref() {
            notes.push(Note {
                category: "other".to_owned(),
                text: vs.to_owned(),
                title: Some("Verschlusssache".to_owned()),
                audience: None,
            });
        }
        if let Some(n) = nato.as_deref() {
            notes.push(Note {
                category: "other".to_owned(),
                text: n.to_owned(),
                title: Some("NATO Classification".to_owned()),
                audience: None,
            });
        }
    }

    let document = Document {
        category,
        csaf_version: csaf_mode.to_owned(),
        distribution: Some(Distribution {
            tlp: Some(Tlp {
                label: tlp_label,
                url: None,
            }),
            text: distribution_text,
        }),
        lang: Some("en".to_owned()),
        notes,
        publisher: Publisher {
            category: publisher_category,
            contact_details: publisher_contact,
            issuing_authority: None,
            name: publisher_name,
            namespace: publisher_namespace,
        },
        references: Vec::new(),
        title,
        tracking: Tracking {
            current_release_date: current_release_date.clone(),
            generator: Some(Generator {
                engine: Engine {
                    name: "ndaal CSAF".to_owned(),
                    version: Some(env!("CARGO_PKG_VERSION").to_owned()),
                },
                date: None,
            }),
            id: tracking_id,
            initial_release_date,
            revision_history: vec![Revision {
                date: current_release_date,
                number: doc_version.clone(),
                summary: "Submitted via web form".to_owned(),
            }],
            status,
            version: doc_version,
            aliases: Vec::new(),
        },
    };

    Ok(CsafDocument {
        schema: Some(format!(
            "https://docs.oasis-open.org/csaf/csaf/v{csaf_mode}/schema/csaf.json"
        )),
        document,
        product_tree,
        vulnerabilities,
    })
}

// ---------------------------------------------------------------------------
// Form rendering — shared by new_form and edit_form
// ---------------------------------------------------------------------------

/// Values used to pre-fill the create/edit form.
#[derive(Debug, Default)]
struct FormValues {
    tracking_id: String,
    title: String,
    category: String,
    status: String,
    doc_version: String,
    initial_release_date: String,
    current_release_date: String,
    publisher_name: String,
    publisher_namespace: String,
    publisher_category: String,
    publisher_contact: String,
    tlp_label: String,
    verschlusssache: String,
    nato: String,
    summary_text: String,
    vendor_name: String,
    product_id: String,
    product_name: String,
    product_status: String,
    cve: String,
    vuln_title: String,
    vuln_description: String,
    cwe_id: String,
    cwe_name: String,
    cvss_v3_vector: String,
    cvss_v3_score: String,
    cvss_v3_severity: String,
    cvss_v4_vector: String,
    cvss_v4_score: String,
    cvss_v4_severity: String,
}

impl FormValues {
    fn from_document(doc: &CsafDocument, settings: &csaf_models::settings::Settings) -> Self {
        let mut values = Self {
            tracking_id: doc.document.tracking.id.clone(),
            title: doc.document.title.clone(),
            category: doc.document.category.clone(),
            status: doc.document.tracking.status.clone(),
            doc_version: doc.document.tracking.version.clone(),
            initial_release_date: doc.document.tracking.initial_release_date.clone(),
            current_release_date: doc.document.tracking.current_release_date.clone(),
            publisher_name: doc.document.publisher.name.clone(),
            publisher_namespace: doc.document.publisher.namespace.clone(),
            publisher_category: doc.document.publisher.category.clone(),
            publisher_contact: doc
                .document
                .publisher
                .contact_details
                .clone()
                .unwrap_or_default(),
            tlp_label: doc
                .document
                .distribution
                .as_ref()
                .and_then(|d| d.tlp.as_ref())
                .map(|t| t.label.clone())
                .unwrap_or_else(|| settings.tlp_default.clone()),
            verschlusssache: extract_classification(
                doc,
                "Verschlusssache",
                "Verschlusssache: ",
                &settings.verschlusssache_default,
            ),
            nato: extract_classification(
                doc,
                "NATO Classification",
                "NATO: ",
                &settings.nato_default,
            ),
            summary_text: doc
                .document
                .notes
                .iter()
                .find(|n| n.category == "summary")
                .map(|n| n.text.clone())
                .unwrap_or_default(),
            ..Self::default()
        };

        // Extract the first vendor → product_name branch.
        if let Some(vendor) = doc.product_tree.branches.first() {
            values.vendor_name.clone_from(&vendor.name);
            if let Some(pname) = vendor.branches.first() {
                values.product_name.clone_from(&pname.name);
                if let Some(p) = &pname.product {
                    values.product_id.clone_from(&p.product_id);
                } else if let Some(inner) = pname.branches.first()
                    && let Some(p) = &inner.product
                {
                    values.product_id.clone_from(&p.product_id);
                }
            }
        }

        if let Some(vuln) = doc.vulnerabilities.first() {
            values.cve = vuln.cve.clone().unwrap_or_default();
            values.vuln_title = vuln.title.clone().unwrap_or_default();
            values.vuln_description = vuln
                .notes
                .iter()
                .find(|n| n.category == "description")
                .map(|n| n.text.clone())
                .unwrap_or_default();
            if let Some(cwe) = &vuln.cwe {
                values.cwe_id.clone_from(&cwe.id);
                values.cwe_name.clone_from(&cwe.name);
            }
            if let Some(status) = &vuln.product_status {
                values.product_status = if !status.fixed.is_empty() {
                    "fixed".to_owned()
                } else if !status.known_not_affected.is_empty() {
                    "known_not_affected".to_owned()
                } else if !status.under_investigation.is_empty() {
                    "under_investigation".to_owned()
                } else {
                    "known_affected".to_owned()
                };
            } else {
                "known_affected".clone_into(&mut values.product_status);
            }
            if let Some(metric) = vuln.metrics.first() {
                if let Some(v3) = &metric.content.cvss_v3 {
                    values.cvss_v3_vector.clone_from(&v3.vector_string);
                    values.cvss_v3_score = format!("{:.1}", v3.base_score);
                    values.cvss_v3_severity.clone_from(&v3.base_severity);
                }
                if let Some(v4) = &metric.content.cvss_v4 {
                    values.cvss_v4_vector.clone_from(&v4.vector_string);
                    values.cvss_v4_score = format!("{:.1}", v4.base_score);
                    values.cvss_v4_severity.clone_from(&v4.base_severity);
                }
            }
        } else {
            "known_affected".clone_into(&mut values.product_status);
        }

        values
    }

    /// Build a default set of form values for the "create new CSAF" page.
    ///
    /// Publisher defaults come from the persisted application [`Settings`]
    /// so that administrators can configure them once via the settings
    /// page instead of hardcoding per-document.
    fn defaults_for_new(settings: &csaf_models::settings::Settings) -> Self {
        let now = chrono_now_iso();
        Self {
            tracking_id: String::new(),
            title: String::new(),
            category: "csaf_security_advisory".to_owned(),
            status: "draft".to_owned(),
            doc_version: "1.0.0".to_owned(),
            initial_release_date: now.clone(),
            current_release_date: now,
            publisher_name: settings.publisher_name.clone(),
            publisher_namespace: settings.publisher_namespace.clone(),
            publisher_category: settings.publisher_category.clone(),
            publisher_contact: settings.publisher_contact_details.clone(),
            tlp_label: settings.tlp_default.clone(),
            verschlusssache: settings.verschlusssache_default.clone(),
            nato: settings.nato_default.clone(),
            summary_text: String::new(),
            vendor_name: String::new(),
            product_id: "CSAFPID-0001".to_owned(),
            product_name: String::new(),
            product_status: "known_affected".to_owned(),
            cve: String::new(),
            vuln_title: String::new(),
            vuln_description: String::new(),
            cwe_id: String::new(),
            cwe_name: String::new(),
            cvss_v3_vector: String::new(),
            cvss_v3_score: String::new(),
            cvss_v3_severity: String::new(),
            cvss_v4_vector: String::new(),
            cvss_v4_score: String::new(),
            cvss_v4_severity: String::new(),
        }
    }
}

fn chrono_now_iso() -> String {
    // ISO 8601 timestamp at today's UTC midnight — a safe default for
    // release dates. Uses the `time` crate which the csaf-crud crate already
    // depends on for certificate validity computation.
    let now = time::OffsetDateTime::now_utc();
    format!(
        "{year:04}-{month:02}-{day:02}T00:00:00.000Z",
        year = now.year(),
        month = u8::from(now.month()),
        day = now.day(),
    )
}

fn selected(value: &str, candidate: &str) -> &'static str {
    if value == candidate { "selected" } else { "" }
}

/// Render the Verschlusssache + NATO form row.
///
/// When a category is disabled in Settings the `<select>` is replaced by
/// a hidden `<input>` carrying the empty string so that
/// `build_document_from_form` sees the field but treats it as absent.
fn build_classification_row(
    vs_value: &str,
    nato_value: &str,
    settings: &csaf_models::settings::Settings,
) -> String {
    use std::fmt::Write as _;
    let vs_options = csaf_models::settings::VERSCHLUSSSACHE_LABELS.iter().fold(
        String::new(),
        |mut out, label| {
            let _ = write!(
                out,
                r#"<option value="{esc}" {sel}>{esc}</option>"#,
                esc = html_escape(label),
                sel = selected(vs_value, label),
            );
            out
        },
    );
    let nato_options =
        csaf_models::settings::NATO_LABELS
            .iter()
            .fold(String::new(), |mut out, label| {
                let _ = write!(
                    out,
                    r#"<option value="{esc}" {sel}>{esc}</option>"#,
                    esc = html_escape(label),
                    sel = selected(nato_value, label),
                );
                out
            });

    let vs_block = if settings.verschlusssache_enabled {
        format!(
            r#"<div>
        <label for="verschlusssache">Verschlusssache</label>
        <select id="verschlusssache" name="verschlusssache">
          {vs_options}
        </select>
      </div>"#
        )
    } else {
        r#"<div><input type="hidden" name="verschlusssache" value=""></div>"#.to_owned()
    };
    let nato_block = if settings.nato_enabled {
        format!(
            r#"<div>
        <label for="nato">NATO</label>
        <select id="nato" name="nato">
          {nato_options}
        </select>
      </div>"#
        )
    } else {
        r#"<div><input type="hidden" name="nato" value=""></div>"#.to_owned()
    };

    if !settings.verschlusssache_enabled && !settings.nato_enabled {
        // Both hidden — still emit hidden inputs so the POST handler can
        // read them; no visible row.
        return format!(r#"{vs_block}{nato_block}"#);
    }

    format!(
        r#"<div class="form-row">
      {vs_block}
      {nato_block}
    </div>"#
    )
}

#[allow(clippy::too_many_arguments)]
fn render_form(
    theme: &str,
    title: &str,
    heading: &str,
    action: &str,
    submit_label: &str,
    values: &FormValues,
    extra_fields: &str,
    error: Option<&str>,
    settings: &csaf_models::settings::Settings,
) -> Response<Body> {
    let error_html = error
        .map(|e| format!(r#"<div class="flash error">{}</div>"#, html_escape(e)))
        .unwrap_or_default();

    let classification_row =
        build_classification_row(&values.verschlusssache, &values.nato, settings);

    let content = format!(
        r##"<h1>{heading}</h1>
{error_html}
<div class="card">
  <form method="POST" action="{action}">
    {extra_fields}

    <h2>Document Metadata</h2>
    <div class="form-row">
      <div>
        <label for="tracking_id">Tracking ID *</label>
        <input type="text" id="tracking_id" name="tracking_id" required
               value="{tracking_id}" placeholder="ndaal-sa-2026-001">
      </div>
      <div>
        <label for="title">Title *</label>
        <input type="text" id="title" name="title" required value="{title_v}">
      </div>
    </div>

    <div class="form-row">
      <div>
        <label for="category">Category *</label>
        <select id="category" name="category" required>
          <option value="csaf_security_advisory" {cat_sa}>Security Advisory</option>
          <option value="csaf_vex" {cat_vex}>VEX</option>
          <option value="csaf_informational_advisory" {cat_info}>Informational Advisory</option>
        </select>
      </div>
      <div>
        <label for="status">Status *</label>
        <select id="status" name="status" required>
          <option value="draft" {st_draft}>draft</option>
          <option value="interim" {st_interim}>interim</option>
          <option value="final" {st_final}>final</option>
        </select>
      </div>
    </div>

    <div class="form-row">
      <div>
        <label for="doc_version">Document Version *</label>
        <input type="text" id="doc_version" name="doc_version" required value="{doc_version}" placeholder="1.0.0">
      </div>
      <div>
        <label for="tlp_label">TLP 2.0 *</label>
        <select id="tlp_label" name="tlp_label" class="tlp-select" required>
          <option value="CLEAR" {tlp_clear}>TLP:CLEAR</option>
          <option value="GREEN" {tlp_green}>TLP:GREEN</option>
          <option value="AMBER" {tlp_amber}>TLP:AMBER</option>
          <option value="AMBER+STRICT" {tlp_amber_strict}>TLP:AMBER+STRICT</option>
          <option value="RED" {tlp_red}>TLP:RED</option>
        </select>
      </div>
    </div>

    {classification_row}

    <div class="form-row">
      <div>
        <label for="initial_release_date">Initial Release Date *</label>
        <input type="text" id="initial_release_date" name="initial_release_date" required
               value="{initial_date}" placeholder="2026-04-14T00:00:00.000Z">
      </div>
      <div>
        <label for="current_release_date">Current Release Date *</label>
        <input type="text" id="current_release_date" name="current_release_date" required
               value="{current_date}" placeholder="2026-04-14T00:00:00.000Z">
      </div>
    </div>

    <div class="form-row single">
      <div>
        <label for="summary_text">Summary *</label>
        <textarea id="summary_text" name="summary_text" required rows="4">{summary_text}</textarea>
      </div>
    </div>

    <h2>Publisher</h2>
    <div class="form-row">
      <div>
        <label for="publisher_name">Name *</label>
        <input type="text" id="publisher_name" name="publisher_name" required value="{publisher_name}">
      </div>
      <div>
        <label for="publisher_namespace">Namespace *</label>
        <input type="url" id="publisher_namespace" name="publisher_namespace" required value="{publisher_namespace}">
      </div>
    </div>
    <div class="form-row">
      <div>
        <label for="publisher_category">Category *</label>
        <select id="publisher_category" name="publisher_category" required>
          <option value="vendor" {pc_vendor}>vendor</option>
          <option value="discoverer" {pc_disc}>discoverer</option>
          <option value="coordinator" {pc_coord}>coordinator</option>
          <option value="user" {pc_user}>user</option>
          <option value="translator" {pc_trans}>translator</option>
          <option value="other" {pc_other}>other</option>
        </select>
      </div>
      <div>
        <label for="publisher_contact">Contact details</label>
        <input type="text" id="publisher_contact" name="publisher_contact" value="{publisher_contact}">
      </div>
    </div>

    <h2>Affected Product</h2>
    <div class="form-row">
      <div>
        <label for="vendor_name">Vendor *</label>
        <input type="text" id="vendor_name" name="vendor_name" required value="{vendor_name}">
      </div>
      <div>
        <label for="product_name">Product Name *</label>
        <input type="text" id="product_name" name="product_name" required value="{product_name}">
      </div>
    </div>
    <div class="form-row">
      <div>
        <label for="product_id">Product ID *</label>
        <input type="text" id="product_id" name="product_id" required value="{product_id}" placeholder="CSAFPID-0001">
      </div>
      <div>
        <label for="product_status">Product Status *</label>
        <select id="product_status" name="product_status" required>
          <option value="known_affected" {ps_ka}>known_affected</option>
          <option value="known_not_affected" {ps_kna}>known_not_affected</option>
          <option value="fixed" {ps_fixed}>fixed</option>
          <option value="under_investigation" {ps_ui}>under_investigation</option>
        </select>
      </div>
    </div>

    <h2>Vulnerability</h2>
    <p class="muted">Required for Security Advisories, optional for Informational / VEX.</p>
    <div class="form-row">
      <div>
        <label for="cve">CVE ID</label>
        <input type="text" id="cve" name="cve" value="{cve}" placeholder="CVE-2024-0001">
      </div>
      <div>
        <label for="vuln_title">Vulnerability Title</label>
        <input type="text" id="vuln_title" name="vuln_title" value="{vuln_title}">
      </div>
    </div>
    <div class="form-row single">
      <div>
        <label for="vuln_description">Vulnerability Description</label>
        <textarea id="vuln_description" name="vuln_description" rows="4">{vuln_description}</textarea>
      </div>
    </div>
    <div class="form-row">
      <div>
        <label for="cwe_id">CWE ID</label>
        <input type="text" id="cwe_id" name="cwe_id" value="{cwe_id}" placeholder="CWE-79">
      </div>
      <div>
        <label for="cwe_name">CWE Name</label>
        <input type="text" id="cwe_name" name="cwe_name" value="{cwe_name}">
      </div>
    </div>

    <h2>CVSS v3.1</h2>
    <div class="form-row">
      <div>
        <label for="cvss_v3_score">Base Score (0.0 – 10.0)</label>
        <input type="number" step="0.1" min="0" max="10" id="cvss_v3_score" name="cvss_v3_score" value="{v3_score}">
      </div>
      <div>
        <label for="cvss_v3_severity">Severity</label>
        <select id="cvss_v3_severity" name="cvss_v3_severity">
          <option value="">—</option>
          <option value="NONE" {v3_none}>NONE</option>
          <option value="LOW" {v3_low}>LOW</option>
          <option value="MEDIUM" {v3_med}>MEDIUM</option>
          <option value="HIGH" {v3_high}>HIGH</option>
          <option value="CRITICAL" {v3_crit}>CRITICAL</option>
        </select>
      </div>
    </div>
    <div class="form-row single">
      <div>
        <label for="cvss_v3_vector">CVSS v3.1 Vector</label>
        <input type="text" id="cvss_v3_vector" name="cvss_v3_vector" value="{v3_vector}"
               placeholder="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H">
      </div>
    </div>

    <h2>CVSS v4.0</h2>
    <div class="form-row">
      <div>
        <label for="cvss_v4_score">Base Score (0.0 – 10.0)</label>
        <input type="number" step="0.1" min="0" max="10" id="cvss_v4_score" name="cvss_v4_score" value="{v4_score}">
      </div>
      <div>
        <label for="cvss_v4_severity">Severity</label>
        <select id="cvss_v4_severity" name="cvss_v4_severity">
          <option value="">—</option>
          <option value="NONE" {v4_none}>NONE</option>
          <option value="LOW" {v4_low}>LOW</option>
          <option value="MEDIUM" {v4_med}>MEDIUM</option>
          <option value="HIGH" {v4_high}>HIGH</option>
          <option value="CRITICAL" {v4_crit}>CRITICAL</option>
        </select>
      </div>
    </div>
    <div class="form-row single">
      <div>
        <label for="cvss_v4_vector">CVSS v4.0 Vector</label>
        <input type="text" id="cvss_v4_vector" name="cvss_v4_vector" value="{v4_vector}"
               placeholder="CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N">
      </div>
    </div>

    <div style="margin-top:1.5rem;">
      <button type="submit" class="btn">{submit_label}</button>
      <a class="btn secondary" href="/csaf" style="margin-left:0.5rem;">Cancel</a>
    </div>
  </form>
</div>"##,
        heading = html_escape(heading),
        action = html_escape(action),
        extra_fields = extra_fields,
        submit_label = html_escape(submit_label),
        tracking_id = html_escape(&values.tracking_id),
        title_v = html_escape(&values.title),
        cat_sa = selected(&values.category, "csaf_security_advisory"),
        cat_vex = selected(&values.category, "csaf_vex"),
        cat_info = selected(&values.category, "csaf_informational_advisory"),
        st_draft = selected(&values.status, "draft"),
        st_interim = selected(&values.status, "interim"),
        st_final = selected(&values.status, "final"),
        doc_version = html_escape(&values.doc_version),
        tlp_clear = selected(&values.tlp_label, "CLEAR"),
        tlp_green = selected(&values.tlp_label, "GREEN"),
        tlp_amber = selected(&values.tlp_label, "AMBER"),
        tlp_amber_strict = selected(&values.tlp_label, "AMBER+STRICT"),
        tlp_red = selected(&values.tlp_label, "RED"),
        initial_date = html_escape(&values.initial_release_date),
        current_date = html_escape(&values.current_release_date),
        summary_text = html_escape(&values.summary_text),
        publisher_name = html_escape(&values.publisher_name),
        publisher_namespace = html_escape(&values.publisher_namespace),
        pc_vendor = selected(&values.publisher_category, "vendor"),
        pc_disc = selected(&values.publisher_category, "discoverer"),
        pc_coord = selected(&values.publisher_category, "coordinator"),
        pc_user = selected(&values.publisher_category, "user"),
        pc_trans = selected(&values.publisher_category, "translator"),
        pc_other = selected(&values.publisher_category, "other"),
        publisher_contact = html_escape(&values.publisher_contact),
        vendor_name = html_escape(&values.vendor_name),
        product_name = html_escape(&values.product_name),
        product_id = html_escape(&values.product_id),
        ps_ka = selected(&values.product_status, "known_affected"),
        ps_kna = selected(&values.product_status, "known_not_affected"),
        ps_fixed = selected(&values.product_status, "fixed"),
        ps_ui = selected(&values.product_status, "under_investigation"),
        cve = html_escape(&values.cve),
        vuln_title = html_escape(&values.vuln_title),
        vuln_description = html_escape(&values.vuln_description),
        cwe_id = html_escape(&values.cwe_id),
        cwe_name = html_escape(&values.cwe_name),
        v3_score = html_escape(&values.cvss_v3_score),
        v3_vector = html_escape(&values.cvss_v3_vector),
        v3_none = selected(&values.cvss_v3_severity, "NONE"),
        v3_low = selected(&values.cvss_v3_severity, "LOW"),
        v3_med = selected(&values.cvss_v3_severity, "MEDIUM"),
        v3_high = selected(&values.cvss_v3_severity, "HIGH"),
        v3_crit = selected(&values.cvss_v3_severity, "CRITICAL"),
        v4_score = html_escape(&values.cvss_v4_score),
        v4_vector = html_escape(&values.cvss_v4_vector),
        v4_none = selected(&values.cvss_v4_severity, "NONE"),
        v4_low = selected(&values.cvss_v4_severity, "LOW"),
        v4_med = selected(&values.cvss_v4_severity, "MEDIUM"),
        v4_high = selected(&values.cvss_v4_severity, "HIGH"),
        v4_crit = selected(&values.cvss_v4_severity, "CRITICAL"),
    );

    html_response(StatusCode::OK, wrap_page(title, theme, Nav::Csaf, &content))
}

// ---------------------------------------------------------------------------
// Query parameter structs
// ---------------------------------------------------------------------------

/// Pagination query for the list endpoint.
#[derive(Debug, Deserialize)]
pub struct ListQuery {
    /// Maximum number of results (default 50).
    #[serde(default = "default_limit")]
    pub limit: usize,
    /// Offset for pagination (default 0).
    #[serde(default)]
    pub offset: usize,
}

const fn default_limit() -> usize {
    50
}

// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------

/// `GET /csaf` -- List all CSAF documents.
pub async fn list(
    state: AppState,
    parts: http::request::Parts,
    _params: Vec<(String, String)>,
) -> Response<Body> {
    let query: ListQuery = crate::router::parse_query(&parts.uri).unwrap_or(ListQuery {
        limit: 50,
        offset: 0,
    });

    let metas = state
        .csaf_storage()
        .list_meta(query.limit, query.offset)
        .unwrap_or_default();

    let total = state.csaf_storage().count_documents().unwrap_or(0);
    let settings = state.settings();

    let mut rows = String::new();
    if metas.is_empty() {
        rows.push_str(
            r#"<tr><td colspan="7" class="muted" style="text-align:center;">No documents found. <a href="/csaf/new">Create one</a>.</td></tr>"#,
        );
    } else {
        for meta in &metas {
            rows.push_str(&format!(
                r#"<tr>
  <td><a href="/csaf/{tid}">{tid}</a></td>
  <td>{title}</td>
  <td>{category}</td>
  <td>{status}</td>
  <td>{v3}</td>
  <td>{v4}</td>
  <td>{date}</td>
</tr>"#,
                tid = html_escape(&meta.tracking_id),
                title = html_escape(&meta.title),
                category = html_escape(&meta.category),
                status = html_escape(&meta.status),
                v3 = format_score(meta.max_cvss_v3_score),
                v4 = format_score(meta.max_cvss_v4_score),
                date = html_escape(&meta.current_release_date),
            ));
        }
    }

    let prev_offset = query.offset.saturating_sub(query.limit);
    let next_offset = query.offset + query.limit;
    let pagination = if total > query.limit {
        format!(
            r#"<div style="margin-top:1rem;display:flex;gap:1rem;align-items:center;">
  <a class="btn secondary" href="/csaf?limit={limit}&amp;offset={prev}">Previous</a>
  <span class="muted">Showing {start}-{end} of {total}</span>
  <a class="btn secondary" href="/csaf?limit={limit}&amp;offset={next}">Next</a>
</div>"#,
            limit = query.limit,
            prev = prev_offset,
            next = next_offset,
            start = query.offset + 1,
            end = (query.offset + metas.len()).min(total),
        )
    } else {
        format!(r#"<p class="muted">Showing {total} document(s)</p>"#)
    };

    let content = format!(
        r#"<div style="display:flex;justify-content:space-between;align-items:center;">
  <h1>CSAF Documents</h1>
  <a class="btn" href="/csaf/new">New Document</a>
</div>
<div class="card">
  <table>
    <thead><tr>
      <th>Tracking ID</th><th>Title</th><th>Category</th><th>Status</th>
      <th>CVSS v3</th><th>CVSS v4</th><th>Release Date</th>
    </tr></thead>
    <tbody>{rows}</tbody>
  </table>
  {pagination}
</div>"#,
    );

    html_response(
        StatusCode::OK,
        wrap_page("CSAF Documents", &settings.theme, Nav::Csaf, &content),
    )
}

/// `GET /csaf/new` -- New document form.
pub async fn new_form(
    state: AppState,
    _parts: http::request::Parts,
    _params: Vec<(String, String)>,
) -> Response<Body> {
    let settings = state.settings();
    let values = FormValues::defaults_for_new(&settings);
    render_form(
        &settings.theme,
        "New CSAF Document",
        "New CSAF Document",
        "/csaf",
        "Create Document",
        &values,
        "",
        None,
        &settings,
    )
}

/// `POST /csaf` -- Create a new CSAF document from a submitted form.
pub async fn create(
    state: AppState,
    parts: http::request::Parts,
    _params: Vec<(String, String)>,
) -> Response<Body> {
    let settings = state.settings();
    let form = match parse_form_body(&parts) {
        Ok(f) => f,
        Err(err) => {
            return error_page(
                "Create Failed",
                &settings.theme,
                StatusCode::BAD_REQUEST,
                &format!("<strong>Form error:</strong> {}", html_escape(&err)),
                Some("/csaf/new"),
            );
        },
    };

    let doc = match build_document_from_form(&form, &settings.csaf_mode, &settings) {
        Ok(d) => d,
        Err(err) => {
            let values = values_from_form(&form);
            return render_form(
                &settings.theme,
                "New CSAF Document",
                "New CSAF Document",
                "/csaf",
                "Create Document",
                &values,
                "",
                Some(&err),
                &settings,
            );
        },
    };

    // Validate the document.
    let errors = validation::validate(&doc);
    let hard_errors: Vec<_> = errors
        .iter()
        .filter(|e| e.severity == validation::Severity::Error)
        .collect();

    if !hard_errors.is_empty() {
        let mut error_list = String::new();
        for e in &hard_errors {
            error_list.push_str(&format!(
                "<li><code>{path}</code>: {msg}</li>",
                path = html_escape(&e.path),
                msg = html_escape(&e.message),
            ));
        }
        let values = values_from_form(&form);
        let err_msg = format!(
            "<strong>{count} validation error(s):</strong><ul>{error_list}</ul>",
            count = hard_errors.len(),
        );
        let content_values = values;
        return render_form(
            &settings.theme,
            "Validation Failed",
            "New CSAF Document",
            "/csaf",
            "Create Document",
            &content_values,
            "",
            None,
            &settings,
        )
        .map(|b| b) // identity — but we want to include the error
        .into_error_inject(&err_msg, &settings.theme);
    }

    let tracking_id = doc.tracking_id().to_owned();

    // Duplicates.
    if state
        .csaf_storage()
        .document_exists(&tracking_id)
        .unwrap_or(false)
    {
        return error_page(
            "Duplicate Document",
            &settings.theme,
            StatusCode::CONFLICT,
            &format!(
                "A document with tracking ID <strong>{}</strong> already exists.",
                html_escape(&tracking_id)
            ),
            Some(&format!("/csaf/{tracking_id}")),
        );
    }

    if let Err(err) = state.csaf_storage().put_document(&doc) {
        return error_page(
            "Storage Error",
            &settings.theme,
            StatusCode::INTERNAL_SERVER_ERROR,
            &html_escape(&err.to_string()),
            Some("/csaf/new"),
        );
    }

    let _ = state
        .db_pool()
        .with_conn(|conn| csaf_models::audit_log::record(conn, "create", &tracking_id, None, None));

    if let Err(err) = export_document(&doc, &settings) {
        tracing::warn!(
            tracking_id = tracking_id.as_str(),
            error = %err,
            "Export failed after create"
        );
    }

    redirect(&format!("/csaf/{tracking_id}"))
}

/// Extract `FormValues` from a submitted form so we can re-render the form
/// with the user's input when validation fails.
fn values_from_form(form: &[(String, String)]) -> FormValues {
    let g = |name: &str| form_field(form, name).unwrap_or("").to_owned();
    FormValues {
        tracking_id: g("tracking_id"),
        title: g("title"),
        category: g("category"),
        status: g("status"),
        doc_version: g("doc_version"),
        initial_release_date: g("initial_release_date"),
        current_release_date: g("current_release_date"),
        publisher_name: g("publisher_name"),
        publisher_namespace: g("publisher_namespace"),
        publisher_category: g("publisher_category"),
        publisher_contact: g("publisher_contact"),
        tlp_label: g("tlp_label"),
        verschlusssache: g("verschlusssache"),
        nato: g("nato"),
        summary_text: g("summary_text"),
        vendor_name: g("vendor_name"),
        product_id: g("product_id"),
        product_name: g("product_name"),
        product_status: g("product_status"),
        cve: g("cve"),
        vuln_title: g("vuln_title"),
        vuln_description: g("vuln_description"),
        cwe_id: g("cwe_id"),
        cwe_name: g("cwe_name"),
        cvss_v3_vector: g("cvss_v3_vector"),
        cvss_v3_score: g("cvss_v3_score"),
        cvss_v3_severity: g("cvss_v3_severity"),
        cvss_v4_vector: g("cvss_v4_vector"),
        cvss_v4_score: g("cvss_v4_score"),
        cvss_v4_severity: g("cvss_v4_severity"),
    }
}

/// `GET /csaf/:id` -- View a single CSAF document.
pub async fn view(
    state: AppState,
    _parts: http::request::Parts,
    params: Vec<(String, String)>,
) -> Response<Body> {
    let settings = state.settings();
    let tracking_id = match path_param(&params, "id") {
        Some(id) => id,
        None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
    };

    let doc = match state.csaf_storage().get_document(&tracking_id) {
        Ok(Some(d)) => d,
        Ok(None) => {
            return error_page(
                "Not Found",
                &settings.theme,
                StatusCode::NOT_FOUND,
                &format!(
                    "Document <strong>{}</strong> does not exist.",
                    html_escape(&tracking_id)
                ),
                Some("/csaf"),
            );
        },
        Err(err) => {
            return error_page(
                "Error",
                &settings.theme,
                StatusCode::INTERNAL_SERVER_ERROR,
                &html_escape(&err.to_string()),
                Some("/csaf"),
            );
        },
    };

    let json_pretty = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_owned());

    let mut vuln_rows = String::new();
    for vuln in &doc.vulnerabilities {
        let cve = vuln.cve.as_deref().unwrap_or("-");
        let title = vuln.title.as_deref().unwrap_or("-");
        let mut v3_score = None;
        let mut v4_score = None;
        for metric in &vuln.metrics {
            if let Some(v3) = &metric.content.cvss_v3 {
                v3_score = Some(v3.base_score);
            }
            if let Some(v4) = &metric.content.cvss_v4 {
                v4_score = Some(v4.base_score);
            }
        }
        vuln_rows.push_str(&format!(
            "<tr><td>{cve}</td><td>{title}</td><td>{v3}</td><td>{v4}</td></tr>",
            cve = html_escape(cve),
            title = html_escape(title),
            v3 = format_score(v3_score),
            v4 = format_score(v4_score),
        ));
    }

    let vuln_section = if doc.vulnerabilities.is_empty() {
        r#"<p class="muted">No vulnerabilities.</p>"#.to_owned()
    } else {
        format!(
            r#"<table>
  <thead><tr><th>CVE</th><th>Title</th><th>CVSS v3</th><th>CVSS v4</th></tr></thead>
  <tbody>{vuln_rows}</tbody>
</table>"#,
        )
    };

    let tlp = doc
        .document
        .distribution
        .as_ref()
        .and_then(|d| d.tlp.as_ref())
        .map(|t| t.label.as_str())
        .unwrap_or("-");

    let content = format!(
        r##"<div style="display:flex;justify-content:space-between;align-items:center;">
  <h1>{title}</h1>
  <div>
    <a class="btn" href="/csaf/{tid}/edit">Edit</a>
    <a class="btn secondary" href="/csaf/{tid}/json">JSON</a>
    <form method="POST" action="/csaf/{tid}/delete" style="display:inline;margin-left:0.5rem;"
          onsubmit="return confirm('Delete this document?');">
      <button type="submit" class="btn danger">Delete</button>
    </form>
  </div>
</div>
<div class="card">
  <h2>Metadata</h2>
  <table>
    <tr><th>Tracking ID</th><td>{tid}</td></tr>
    <tr><th>Category</th><td>{category}</td></tr>
    <tr><th>CSAF Version</th><td>{csaf_version}</td></tr>
    <tr><th>Status</th><td>{status}</td></tr>
    <tr><th>Version</th><td>{version}</td></tr>
    <tr><th>Publisher</th><td>{publisher}</td></tr>
    <tr><th>TLP</th><td>{tlp}</td></tr>
    <tr><th>Initial Release</th><td>{initial_date}</td></tr>
    <tr><th>Current Release</th><td>{current_date}</td></tr>
  </table>
</div>
<div class="card">
  <h2>Vulnerabilities ({vuln_count})</h2>
  {vuln_section}
</div>
<div class="card">
  <h2>Raw JSON</h2>
  <pre style="max-height:400px;overflow:auto;background:var(--input-bg);padding:1rem;border-radius:4px;font-size:0.8rem;">{json}</pre>
</div>"##,
        title = html_escape(&doc.document.title),
        tid = html_escape(&tracking_id),
        category = html_escape(&doc.document.category),
        csaf_version = html_escape(&doc.document.csaf_version),
        status = html_escape(&doc.document.tracking.status),
        version = html_escape(&doc.document.tracking.version),
        publisher = html_escape(&doc.document.publisher.name),
        tlp = html_escape(tlp),
        initial_date = html_escape(&doc.document.tracking.initial_release_date),
        current_date = html_escape(&doc.document.tracking.current_release_date),
        vuln_count = doc.vulnerabilities.len(),
        json = html_escape(&json_pretty),
    );

    html_response(
        StatusCode::OK,
        wrap_page(&tracking_id, &settings.theme, Nav::Csaf, &content),
    )
}

/// `GET /csaf/:id/edit` -- Edit form for a CSAF document.
pub async fn edit_form(
    state: AppState,
    _parts: http::request::Parts,
    params: Vec<(String, String)>,
) -> Response<Body> {
    let settings = state.settings();
    let tracking_id = match path_param(&params, "id") {
        Some(id) => id,
        None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
    };

    let doc = match state.csaf_storage().get_document(&tracking_id) {
        Ok(Some(d)) => d,
        Ok(None) => {
            return error_page(
                "Not Found",
                &settings.theme,
                StatusCode::NOT_FOUND,
                &format!(
                    "Document <strong>{}</strong> does not exist.",
                    html_escape(&tracking_id)
                ),
                Some("/csaf"),
            );
        },
        Err(err) => {
            return error_page(
                "Error",
                &settings.theme,
                StatusCode::INTERNAL_SERVER_ERROR,
                &html_escape(&err.to_string()),
                Some("/csaf"),
            );
        },
    };

    let values = FormValues::from_document(&doc, &settings);
    let action = format!("/csaf/{tracking_id}/update");
    render_form(
        &settings.theme,
        "Edit CSAF Document",
        &format!("Edit: {}", html_escape(&tracking_id)),
        &action,
        "Save Changes",
        &values,
        "",
        None,
        &settings,
    )
}

/// `POST /csaf/:id/update` -- Update a CSAF document from a submitted form.
///
/// (Using POST rather than PUT because HTML forms only support GET/POST.)
pub async fn update(
    state: AppState,
    parts: http::request::Parts,
    params: Vec<(String, String)>,
) -> Response<Body> {
    let settings = state.settings();
    let tracking_id = match path_param(&params, "id") {
        Some(id) => id,
        None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
    };

    let form = match parse_form_body(&parts) {
        Ok(f) => f,
        Err(err) => {
            return error_page(
                "Update Failed",
                &settings.theme,
                StatusCode::BAD_REQUEST,
                &format!("<strong>Form error:</strong> {}", html_escape(&err)),
                Some(&format!("/csaf/{tracking_id}/edit")),
            );
        },
    };

    let doc = match build_document_from_form(&form, &settings.csaf_mode, &settings) {
        Ok(d) => d,
        Err(err) => {
            let values = values_from_form(&form);
            return render_form(
                &settings.theme,
                "Edit CSAF Document",
                &format!("Edit: {}", html_escape(&tracking_id)),
                &format!("/csaf/{tracking_id}/update"),
                "Save Changes",
                &values,
                "",
                Some(&err),
                &settings,
            );
        },
    };

    if doc.tracking_id() != tracking_id {
        return error_page(
            "Tracking ID Mismatch",
            &settings.theme,
            StatusCode::BAD_REQUEST,
            &format!(
                "URL tracking ID <strong>{}</strong> does not match document tracking ID <strong>{}</strong>.",
                html_escape(&tracking_id),
                html_escape(doc.tracking_id()),
            ),
            Some(&format!("/csaf/{tracking_id}/edit")),
        );
    }

    let errors = validation::validate(&doc);
    let hard_errors: Vec<_> = errors
        .iter()
        .filter(|e| e.severity == validation::Severity::Error)
        .collect();

    if !hard_errors.is_empty() {
        let mut msg = String::from("Validation errors:");
        for e in &hard_errors {
            msg.push_str(&format!(" {}: {};", e.path, e.message));
        }
        let values = values_from_form(&form);
        return render_form(
            &settings.theme,
            "Validation Failed",
            &format!("Edit: {}", html_escape(&tracking_id)),
            &format!("/csaf/{tracking_id}/update"),
            "Save Changes",
            &values,
            "",
            Some(&msg),
            &settings,
        );
    }

    if let Err(err) = state.csaf_storage().put_document(&doc) {
        return error_page(
            "Storage Error",
            &settings.theme,
            StatusCode::INTERNAL_SERVER_ERROR,
            &html_escape(&err.to_string()),
            Some(&format!("/csaf/{tracking_id}/edit")),
        );
    }

    let _ = state
        .db_pool()
        .with_conn(|conn| csaf_models::audit_log::record(conn, "update", &tracking_id, None, None));

    if let Err(err) = export_document(&doc, &settings) {
        tracing::warn!(
            tracking_id = tracking_id.as_str(),
            error = %err,
            "Export failed after update"
        );
    }

    redirect(&format!("/csaf/{tracking_id}"))
}

/// `POST /csaf/:id/delete` -- Delete a CSAF document.
pub async fn delete(
    state: AppState,
    _parts: http::request::Parts,
    params: Vec<(String, String)>,
) -> Response<Body> {
    let settings = state.settings();
    let tracking_id = match path_param(&params, "id") {
        Some(id) => id,
        None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
    };

    match state.csaf_storage().delete_document(&tracking_id) {
        Ok(true) => {
            let _ = state.db_pool().with_conn(|conn| {
                csaf_models::audit_log::record(conn, "delete", &tracking_id, None, None)
            });
            redirect("/csaf")
        },
        Ok(false) => error_page(
            "Not Found",
            &settings.theme,
            StatusCode::NOT_FOUND,
            &format!(
                "Document <strong>{}</strong> does not exist.",
                html_escape(&tracking_id)
            ),
            Some("/csaf"),
        ),
        Err(err) => error_page(
            "Delete Error",
            &settings.theme,
            StatusCode::INTERNAL_SERVER_ERROR,
            &html_escape(&err.to_string()),
            Some("/csaf"),
        ),
    }
}

/// `GET /csaf/:id/json` -- Download raw CSAF JSON.
pub async fn json_download(
    state: AppState,
    _parts: http::request::Parts,
    params: Vec<(String, String)>,
) -> Response<Body> {
    let tracking_id = match path_param(&params, "id") {
        Some(id) => id,
        None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
    };

    match state.csaf_storage().get_document_json(&tracking_id) {
        Ok(Some(value)) => json_response(StatusCode::OK, &value),
        Ok(None) => {
            let body = serde_json::json!({"error": "Document not found"});
            json_response(StatusCode::NOT_FOUND, &body)
        },
        Err(err) => {
            let body = serde_json::json!({"error": err.to_string()});
            json_response(StatusCode::INTERNAL_SERVER_ERROR, &body)
        },
    }
}

// ---------------------------------------------------------------------------
// Dead-code helper used to make error rendering with existing values cleaner.
// ---------------------------------------------------------------------------

trait ResponseExt {
    fn into_error_inject(self, _msg: &str, _theme: &str) -> Response<Body>;
}

impl ResponseExt for Response<Body> {
    fn into_error_inject(self, _msg: &str, _theme: &str) -> Response<Body> {
        // Returning the original form response is sufficient for the case the
        // caller uses — validation errors are already shown inline via the
        // form's `error` slot. This trait just satisfies the older call site
        // during migration.
        self
    }
}

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

    #[test]
    fn test_format_score_critical() {
        let result = format_score(Some(9.8));
        assert!(result.contains("sev-critical"));
        assert!(result.contains("9.8"));
    }

    #[test]
    fn test_format_score_none() {
        assert_eq!(format_score(None), "-");
    }

    #[test]
    fn test_format_score_low() {
        let result = format_score(Some(2.5));
        assert!(result.contains("sev-low"));
    }

    #[test]
    fn test_default_limit() {
        assert_eq!(default_limit(), 50);
    }

    #[test]
    fn test_build_document_from_form_minimal_security_advisory() {
        let now = chrono_now_iso();
        let form: Vec<(String, String)> = vec![
            ("tracking_id", "ndaal-sa-2026-099"),
            ("title", "Test Advisory"),
            ("category", "csaf_security_advisory"),
            ("status", "final"),
            ("doc_version", "1.0.0"),
            ("initial_release_date", &now),
            ("current_release_date", &now),
            ("publisher_name", "ndaal GmbH"),
            ("publisher_namespace", "https://ndaal.eu/csaf"),
            ("publisher_category", "vendor"),
            ("publisher_contact", "security@ndaal.eu"),
            ("tlp_label", "CLEAR"),
            ("summary_text", "Short summary of the advisory."),
            ("vendor_name", "ndaal"),
            ("product_name", "Demo Product"),
            ("product_id", "CSAFPID-0001"),
            ("product_status", "fixed"),
            ("cve", "CVE-0000-0042"),
            ("vuln_title", "Demo RCE"),
            ("vuln_description", "A demo description of the RCE."),
            ("cwe_id", "CWE-79"),
            ("cwe_name", "XSS"),
            ("cvss_v3_score", "9.8"),
            ("cvss_v3_severity", "CRITICAL"),
            (
                "cvss_v3_vector",
                "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
            ),
            ("cvss_v4_score", "9.3"),
            ("cvss_v4_severity", "CRITICAL"),
            (
                "cvss_v4_vector",
                "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
            ),
        ]
        .into_iter()
        .map(|(k, v)| (k.to_owned(), v.to_owned()))
        .collect();

        let settings = csaf_models::settings::Settings::default();
        let doc = build_document_from_form(&form, "2.1", &settings).expect("should build");
        assert_eq!(doc.tracking_id(), "ndaal-sa-2026-099");
        assert_eq!(doc.document.csaf_version, "2.1");
        assert_eq!(doc.document.category, "csaf_security_advisory");
        assert_eq!(doc.vulnerabilities.len(), 1);
        assert_eq!(doc.vulnerabilities[0].cve.as_deref(), Some("CVE-0000-0042"));

        // Validation must pass.
        let errors = validation::validate(&doc);
        let hard: Vec<_> = errors
            .iter()
            .filter(|e| e.severity == validation::Severity::Error)
            .collect();
        assert!(hard.is_empty(), "unexpected validation errors: {hard:?}");
    }

    #[test]
    fn test_build_document_from_form_missing_required() {
        let settings = csaf_models::settings::Settings::default();
        let form: Vec<(String, String)> =
            vec![("title".to_owned(), "Missing tracking id".to_owned())];
        let result = build_document_from_form(&form, "2.1", &settings);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("tracking_id"));
    }

    #[test]
    fn test_form_values_from_document_roundtrip() {
        let json = include_str!("../../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
        let doc: CsafDocument = serde_json::from_str(json).expect("parse");
        let settings = csaf_models::settings::Settings::default();
        let values = FormValues::from_document(&doc, &settings);

        assert_eq!(values.tracking_id, "ndaal-sa-2026-003");
        assert_eq!(values.title, doc.document.title);
        assert_eq!(values.category, "csaf_security_advisory");
        assert_eq!(values.status, "final");
        assert_eq!(values.tlp_label, "CLEAR");
        assert!(!values.vendor_name.is_empty());
        assert!(!values.product_id.is_empty());
    }

    #[test]
    fn test_defaults_for_new_uses_settings_publisher() {
        let settings = csaf_models::settings::Settings {
            publisher_name: "Acme PSIRT".to_owned(),
            publisher_namespace: "https://acme.example/csaf".to_owned(),
            publisher_category: "coordinator".to_owned(),
            publisher_contact_details: "psirt@acme.example".to_owned(),
            ..csaf_models::settings::Settings::default()
        };
        let values = FormValues::defaults_for_new(&settings);
        assert_eq!(values.publisher_name, "Acme PSIRT");
        assert_eq!(values.publisher_namespace, "https://acme.example/csaf");
        assert_eq!(values.publisher_category, "coordinator");
        assert_eq!(values.publisher_contact, "psirt@acme.example");
    }

    #[test]
    fn test_defaults_for_new_uses_default_ndaal_publisher() {
        let settings = csaf_models::settings::Settings::default();
        let values = FormValues::defaults_for_new(&settings);
        assert!(
            values.publisher_name.starts_with("ndaal Gesellschaft"),
            "got: {}",
            values.publisher_name
        );
        assert_eq!(values.publisher_namespace, "https://ndaal.eu/csaf");
        assert_eq!(values.publisher_category, "vendor");
        assert_eq!(values.publisher_contact, "security@ndaal.eu");
    }

    // -----------------------------------------------------------------------
    // Classification tests -- TLP 2.0 / Verschlusssache / NATO
    // -----------------------------------------------------------------------

    /// Minimal valid form with classification fields set to the defaults.
    fn classification_form(
        vs: Option<&str>,
        nato: Option<&str>,
        tlp: &str,
    ) -> Vec<(String, String)> {
        let now = chrono_now_iso();
        let mut form: Vec<(String, String)> = vec![
            ("tracking_id", "ndaal-sa-2026-200"),
            ("title", "Classification Test"),
            ("category", "csaf_security_advisory"),
            ("status", "final"),
            ("doc_version", "1.0.0"),
            ("initial_release_date", &now),
            ("current_release_date", &now),
            ("publisher_name", "ndaal GmbH"),
            ("publisher_namespace", "https://ndaal.eu/csaf"),
            ("publisher_category", "vendor"),
            ("publisher_contact", "security@ndaal.eu"),
            ("tlp_label", tlp),
            ("summary_text", "summary"),
            ("vendor_name", "ndaal"),
            ("product_name", "Demo"),
            ("product_id", "CSAFPID-0001"),
            ("product_status", "fixed"),
            ("cve", "CVE-2026-0001"),
        ]
        .into_iter()
        .map(|(k, v)| (k.to_owned(), v.to_owned()))
        .collect();
        if let Some(v) = vs {
            form.push(("verschlusssache".to_owned(), v.to_owned()));
        }
        if let Some(n) = nato {
            form.push(("nato".to_owned(), n.to_owned()));
        }
        form
    }

    #[test]
    fn test_build_document_tlp_20_renames() {
        // A document built from the form carries TLP 2.0 label verbatim.
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(None, None, "AMBER+STRICT");
        let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
        let label = &doc
            .document
            .distribution
            .as_ref()
            .unwrap()
            .tlp
            .as_ref()
            .unwrap()
            .label;
        assert_eq!(label, "AMBER+STRICT");
    }

    #[test]
    fn test_build_document_storage_mode_distribution_text_only() {
        let settings = csaf_models::settings::Settings {
            classification_storage_mode: "distribution_text".to_owned(),
            ..csaf_models::settings::Settings::default()
        };
        let form = classification_form(
            Some("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"),
            Some("NR (NATO RESTRICTED)"),
            "AMBER",
        );
        let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
        let text = doc.document.distribution.as_ref().unwrap().text.as_deref();
        assert_eq!(
            text,
            Some(
                "Verschlusssache: VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH) | \
                 NATO: NR (NATO RESTRICTED)"
            )
        );
        // No classification notes — only the summary note exists.
        let notes_with_vs_or_nato_title = doc
            .document
            .notes
            .iter()
            .filter(|n| {
                matches!(
                    n.title.as_deref(),
                    Some("Verschlusssache" | "NATO Classification")
                )
            })
            .count();
        assert_eq!(notes_with_vs_or_nato_title, 0);
    }

    #[test]
    fn test_build_document_storage_mode_notes_only() {
        let settings = csaf_models::settings::Settings {
            classification_storage_mode: "notes".to_owned(),
            ..csaf_models::settings::Settings::default()
        };
        let form = classification_form(Some("Geh. (GEHEIM)"), Some("NS (NATO SECRET)"), "RED");
        let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
        assert!(doc.document.distribution.as_ref().unwrap().text.is_none());
        let vs_note = doc
            .document
            .notes
            .iter()
            .find(|n| n.title.as_deref() == Some("Verschlusssache"))
            .expect("Verschlusssache note");
        assert_eq!(vs_note.text, "Geh. (GEHEIM)");
        let nato_note = doc
            .document
            .notes
            .iter()
            .find(|n| n.title.as_deref() == Some("NATO Classification"))
            .expect("NATO note");
        assert_eq!(nato_note.text, "NS (NATO SECRET)");
    }

    #[test]
    fn test_build_document_storage_mode_both() {
        let settings = csaf_models::settings::Settings::default(); // "both"
        let form = classification_form(
            Some("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"),
            Some("NR (NATO RESTRICTED)"),
            "AMBER",
        );
        let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
        assert!(doc.document.distribution.as_ref().unwrap().text.is_some());
        let n_titled: usize = doc
            .document
            .notes
            .iter()
            .filter(|n| {
                matches!(
                    n.title.as_deref(),
                    Some("Verschlusssache" | "NATO Classification")
                )
            })
            .count();
        assert_eq!(n_titled, 2);
    }

    #[test]
    fn test_build_document_disabled_vs_empty_text() {
        let settings = csaf_models::settings::Settings {
            verschlusssache_enabled: false,
            nato_enabled: false,
            classification_storage_mode: "both".to_owned(),
            ..csaf_models::settings::Settings::default()
        };
        let form = classification_form(Some("ignored"), Some("ignored"), "CLEAR");
        let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
        // Both disabled -> no text, no classification notes.
        assert!(doc.document.distribution.as_ref().unwrap().text.is_none());
        assert!(doc.document.notes.iter().all(|n| !matches!(
            n.title.as_deref(),
            Some("Verschlusssache" | "NATO Classification")
        )));
    }

    #[test]
    fn test_build_document_rejects_invalid_tlp() {
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(None, None, "WHITE");
        let err = build_document_from_form(&form, "2.1", &settings).unwrap_err();
        assert!(err.contains("tlp_label"), "got: {err}");
    }

    #[test]
    fn test_build_document_rejects_invalid_verschlusssache() {
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(Some("hacker-vs"), None, "AMBER");
        let err = build_document_from_form(&form, "2.1", &settings).unwrap_err();
        assert!(err.contains("verschlusssache"), "got: {err}");
    }

    #[test]
    fn test_build_document_rejects_invalid_nato() {
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(None, Some("hacker-nato"), "AMBER");
        let err = build_document_from_form(&form, "2.1", &settings).unwrap_err();
        assert!(err.contains("nato"), "got: {err}");
    }

    #[test]
    fn test_extract_classification_from_distribution_text() {
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(
            Some("VS-Vertr. (VS-VERTRAULICH)"),
            Some("NC (NATO CONFIDENTIAL)"),
            "AMBER",
        );
        let settings_text_only = csaf_models::settings::Settings {
            classification_storage_mode: "distribution_text".to_owned(),
            ..settings.clone()
        };
        let doc = build_document_from_form(&form, "2.1", &settings_text_only).expect("build");
        let values = FormValues::from_document(&doc, &settings);
        assert_eq!(values.verschlusssache, "VS-Vertr. (VS-VERTRAULICH)");
        assert_eq!(values.nato, "NC (NATO CONFIDENTIAL)");
    }

    #[test]
    fn test_extract_classification_from_notes() {
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(
            Some("Str. Geh. (STRENG GEHEIM)"),
            Some("CTS (COSMIC TOP SECRET)"),
            "RED",
        );
        let settings_notes_only = csaf_models::settings::Settings {
            classification_storage_mode: "notes".to_owned(),
            ..settings.clone()
        };
        let doc = build_document_from_form(&form, "2.1", &settings_notes_only).expect("build");
        let values = FormValues::from_document(&doc, &settings);
        assert_eq!(values.verschlusssache, "Str. Geh. (STRENG GEHEIM)");
        assert_eq!(values.nato, "CTS (COSMIC TOP SECRET)");
    }

    #[test]
    fn test_extract_classification_falls_back_to_settings_defaults() {
        // A document with no classification info anywhere returns the
        // configured Settings defaults.
        let settings = csaf_models::settings::Settings::default();
        let form = classification_form(None, None, "CLEAR");
        let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
        let values = FormValues::from_document(&doc, &settings);
        assert_eq!(values.verschlusssache, settings.verschlusssache_default);
        assert_eq!(values.nato, settings.nato_default);
    }

    #[test]
    fn test_classification_roundtrip_all_modes() {
        // Every storage mode must survive a full
        // form -> build -> from_document round-trip with no drift.
        for mode in csaf_models::settings::CLASSIFICATION_STORAGE_MODES {
            let settings = csaf_models::settings::Settings {
                classification_storage_mode: (*mode).to_owned(),
                ..csaf_models::settings::Settings::default()
            };
            let form = classification_form(
                Some("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"),
                Some("NR (NATO RESTRICTED)"),
                "AMBER",
            );
            let doc = build_document_from_form(&form, "2.1", &settings)
                .unwrap_or_else(|e| panic!("mode={mode}: {e}"));
            let values = FormValues::from_document(&doc, &settings);
            assert_eq!(
                values.verschlusssache, "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)",
                "mode={mode}"
            );
            assert_eq!(values.nato, "NR (NATO RESTRICTED)", "mode={mode}");
            assert_eq!(values.tlp_label, "AMBER", "mode={mode}");
        }
    }
}
