"""
Vulnerability Management Router

Endpoints for CVE management, prioritization, and AI analysis.
"""
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, func
from sqlalchemy.exc import IntegrityError
from pydantic import BaseModel, field_validator
import json
import os
import logging

logger = logging.getLogger(__name__)

from app.database import get_db
from app.models.user import User
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
from app.models.asset import Asset, AssetStatus
from app.models.ai_analysis import AIAnalysis
from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor
from app.integrations.wazuh_client import get_wazuh_client, WazuhClient
from app.models.setting import Setting
from app.integrations.infomaniak_ai_client import InfomaniakAIClient, get_infomaniak_ai_client, AIAnalysisService
from app.models.ai_report import AIReport
from app.services.enrichment_service import (
    enrich_vulnerabilities,
    enrich_vulnerability_by_id,
    enrich_all_open_vulnerabilities,
    fetch_kev_catalog,
    EnrichmentError,
)
from app.services.vuln_override_service import (
    correct_vulnerability_scores,
    VulnOverrideService,
)
from app.services.override_jobs import (
    start_vulnrichment_job,
    get_job as get_override_job,
    list_jobs as list_override_jobs,
)

# Settings key for Nessus configuration
SETTING_KEY = "nessus_config"

router = APIRouter(prefix="/api/v1/vulnerabilities", tags=["Vulnerabilities"])


# ============================================
# Pydantic Schemas
# ============================================

class VulnerabilityResponse(BaseModel):
    id: int
    cve_id: str
    asset_id: int
    asset_hostname: str
    cvss_score: Optional[float]
    severity: VulnerabilitySeverity
    status: VulnerabilityStatus
    title: Optional[str]
    package_name: Optional[str]
    package_vendor: Optional[str] = None
    package_version: Optional[str]
    fixed_version: Optional[str]
    exploitable: bool
    exploit_available: bool
    detected_at: datetime
    priority_score: float
    priority_breakdown: Optional[dict] = None
    cpr_score: Optional[float] = None
    epss_score: Optional[float] = None
    epss_percentile: Optional[float] = None
    kev_listed: bool = False
    kev_ransomware_use: bool = False
    kev_date_added: Optional[datetime] = None
    euvd_listed: bool = False
    euvd_critical: bool = False
    euvd_date_added: Optional[datetime] = None
    euvd_id: Optional[str] = None
    enrichment_sources: Optional[str] = None
    enrichment_updated_at: Optional[datetime] = None
    # Multi-scanner attribution (Nessus integration)
    sources: List[str] = []
    cross_confirmed: bool = False
    first_detected_by: Optional[str] = None
    nessus_plugin_id: Optional[str] = None
    nessus_vpr_score: Optional[float] = None
    exploitation_status: Optional[str] = None
    exploitation_source: Optional[str] = None     # vulnrichment | nvd | cvelistv5 | nessus
    ssvc_technical_impact: Optional[str] = None   # partial | total
    ssvc_automatable: Optional[str] = None        # yes | no
    is_pseudo_cve: bool = False
    is_eol_finding: bool = False         # endoflife.date pseudo-CVE
    # Plan M — public-exploit catalogs
    exploit_db_count: int = 0
    exploit_db_ids: Optional[List[str]] = None
    pocs_github_count: int = 0
    pocs_github_urls: Optional[List[str]] = None
    metasploit_module_count: int = 0
    metasploit_modules: Optional[List[str]] = None
    exploit_intel_updated_at: Optional[datetime] = None
    # Latest VULNERABILITY_UPDATED audit row for this vuln. Used by the
    # list page to render a hover-tooltip on the Status badge so the
    # operator can tell at a glance who patched / FP-marked / auto-patched
    # a row without opening the detail page.
    last_change: Optional[dict] = None
    assigned_user_id: Optional[int] = None
    assigned_user_name: Optional[str] = None
    assigned_group_id: Optional[int] = None
    assigned_group_name: Optional[str] = None
    deferred_until: Optional[datetime] = None
    defer_reason: Optional[str] = None
    notification_suppressed: bool = False
    # Per-package detail (1:N child rows). Empty for pseudo-CVEs.
    packages: List["PackageInfo"] = []
    has_fix_any: bool = False     # at least one package has a real fix

    class Config:
        from_attributes = True


class PackageInfo(BaseModel):
    package_name: str
    package_vendor: Optional[str] = None
    package_version: Optional[str] = None
    fixed_version: Optional[str] = None
    source: Optional[str] = None
    has_fix: bool = False

    class Config:
        from_attributes = True


class AssignRequest(BaseModel):
    user_id: Optional[int] = None
    group_id: Optional[int] = None


class DeferRequest(BaseModel):
    deferred_until: datetime
    reason: str


class VulnerabilityDetailResponse(VulnerabilityResponse):
    description: Optional[str]
    cvss_vector: Optional[str]
    exploit_maturity: Optional[str]
    published_date: Optional[datetime]
    last_modified_date: Optional[datetime]
    patched_at: Optional[datetime]
    references: Optional[str]
    remediation: Optional[str] = None
    cwe_id: Optional[str]
    ai_analysis: Optional[dict]


# Resolve forward ref — VulnerabilityResponse declares `packages: List["PackageInfo"]`
# above the PackageInfo class itself.
VulnerabilityResponse.model_rebuild()
VulnerabilityDetailResponse.model_rebuild()


class VulnerabilityUpdateRequest(BaseModel):
    status: VulnerabilityStatus
    # `reason` is status-agnostic — captures WHO/HOW for the audit trail
    # regardless of which status the row moves to (patched via WSUS,
    # FP because vendor confirmed N/A, accepted_risk per ITSM ticket).
    # `defer_reason` kept for backward compatibility on existing UI.
    reason: Optional[str] = None
    defer_reason: Optional[str] = None
    deferred_until: Optional[datetime] = None

    @field_validator('status', mode='before')
    @classmethod
    def normalize_status(cls, v):
        if isinstance(v, str):
            return v.lower()
        return v


class BulkVulnerabilityUpdateRequest(BaseModel):
    vulnerability_ids: List[int]
    assigned_user_id: Optional[int] = None
    assigned_group_id: Optional[int] = None
    status: Optional[VulnerabilityStatus] = None
    # Optional reason — when blank, backend auto-fills with
    # "Manually set to {status} by {username} on {datetime}" so the
    # audit trail always tells WHO + WHEN even on the bulk-action path.
    reason: Optional[str] = None

    @field_validator('status', mode='before')
    @classmethod
    def normalize_status(cls, v):
        if isinstance(v, str):
            return v.lower()
        return v


class VulnerabilityFilterParams(BaseModel):
    severity: Optional[VulnerabilitySeverity] = None
    status: Optional[VulnerabilityStatus] = None
    exploitable: Optional[bool] = None
    asset_id: Optional[int] = None
    search: Optional[str] = None  # Suche in CVE-ID, Package-Name, Title


class DashboardStatsResponse(BaseModel):
    total_vulnerabilities: int
    open_vulnerabilities: int
    critical_count: int
    high_count: int
    medium_count: int
    low_count: int
    exploitable_count: int
    avg_cvss_score: float
    affected_assets: int
    total_assets: int
    scanned_assets: int
    severity_history: List[dict]
    oldest_vulnerability_days: int


# ============================================
# Helper-Funktionen
# ============================================

def _auto_reason(new_status, user_name: str) -> str:
    """Fallback reason string when the user submitted no comment.

    Tester report: bulk + single status changes accepted blank
    comments → audit trail couldn't distinguish manual patches from
    scanner auto-patches. Auto-stamp ensures every manual change
    still carries who/when in the audit log.
    """
    ts = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
    label = new_status.value if hasattr(new_status, "value") else str(new_status)
    return f"Manually set to {label} by {user_name} on {ts}"


def log_vulnerability_change(
    db: Session,
    user_id: Optional[int],
    vuln_id: int,
    old_status,
    new_status,
    reason: Optional[str] = None,
    cve_id: Optional[str] = None,
    source: Optional[str] = None,
    hostname: Optional[str] = None,
):
    """Audit-Log entry for a vulnerability status change.

    `user_id=None` for automated transitions (Nessus / Wazuh sync
    auto-patching when a CVE disappears from the scan result).
    `reason` is a free-text rationale persisted as JSON in new_value
    alongside the new status — readable by the audit-log UI.
    `source` tags WHERE the change came from
    (`manual` / `nessus_sync` / `wazuh_sync` / `verify_patch_rescan`).
    `hostname` names the affected asset in the description so the entry can
    be attributed and FOUND by host in the audit-log search (tester: reopen
    rows said which CVE but not which system).
    """
    old_val = old_status.value if hasattr(old_status, "value") else str(old_status)
    new_val = new_status.value if hasattr(new_status, "value") else str(new_status)
    new_payload = json.dumps({
        "status": new_val,
        "reason": reason,
        "source": source or "manual",
        "cve_id": cve_id,
        "hostname": hostname,
    })
    audit_log = AuditLog(
        user_id=user_id,
        event_type=AuditEventType.VULNERABILITY_UPDATED,
        event_description=(
            f"Vulnerability {cve_id or ''} status changed: {old_val} → {new_val}"
            + (f" on {hostname}" if hostname else "")
            + (f" ({reason})" if reason else "")
        )[:500],
        resource_type="vulnerability",
        resource_id=str(vuln_id),
        old_value=old_val,
        new_value=new_payload,
        timestamp=datetime.now()
    )
    db.add(audit_log)
    db.commit()


def _sanitize_wazuh_fix(pkg_name, pkg_version, fix):
    """Drop a Wazuh-reported fix build that belongs to a DIFFERENT Windows
    release. Wazuh CTI sometimes attaches e.g. 6.2.9200.26079 (Server 2012) to
    an OS finding on a Server 2025 host (10.0.26100.x); a fix on another build
    line says nothing about this host — better no fix than misinformation (the
    MSRC remediation panel supplies the correct per-branch KB anyway)."""
    if not (fix and pkg_name and "microsoft windows" in str(pkg_name).lower()):
        return fix

    def _bl(v):
        try:
            t = [int(x) for x in str(v).split(".")]
            return tuple(t[:3]) if len(t) >= 4 else None
        except (ValueError, TypeError):
            return None

    inst_bl, fix_bl = _bl(pkg_version), _bl(fix)
    if inst_bl and fix_bl and inst_bl != fix_bl:
        return None
    return fix


def _build_vuln_response(vuln: Vulnerability) -> dict:
    """Build a vulnerability response dict with assigned user info"""
    breakdown = vuln.calculate_priority_breakdown()
    # Per-package detail (1:N). Cheaper than a JOIN in queries that
    # already loaded the parent — SQLAlchemy's default-lazy load is
    # one extra round-trip per vuln. Fine for detail; list endpoints
    # may want a joinedload upstream later.
    pkgs = getattr(vuln, "packages", []) or []
    # Provenance must never be blank: rows written before per-package source
    # tracking (and any path that only fills the parent summary) fall back to
    # the finding's own sources, so "via …" is present in EVERY constellation.
    fallback_src = ",".join(vuln.source_list or []) or vuln.first_detected_by
    packages_payload = [
        {
            "package_name": p.package_name,
            "package_version": p.package_version,
            "fixed_version": p.fixed_version,
            "source": p.source or fallback_src,
            "has_fix": p.has_fix,
        }
        for p in pkgs
    ]
    has_fix_any = any(p.has_fix for p in pkgs) if pkgs else bool(
        vuln.fixed_version and vuln.fixed_version != vuln.package_version
    )
    if not packages_payload and vuln.package_name:
        # Parent-summary-only finding (OS-level rows, pseudo-CVEs, legacy data):
        # render it through the same per-package view instead of a separate
        # block that carried no source line at all.
        packages_payload = [{
            "package_name": vuln.package_name,
            "package_version": vuln.package_version,
            "fixed_version": vuln.fixed_version,
            "source": fallback_src,
            "has_fix": has_fix_any,
        }]
    return {
        "id": vuln.id,
        "cve_id": vuln.cve_id,
        "asset_id": vuln.asset_id,
        "asset_hostname": vuln.asset.hostname if vuln.asset else "Unknown",
        "cvss_score": vuln.cvss_score,
        "severity": vuln.severity,
        "status": vuln.status,
        "title": vuln.title,
        "package_name": vuln.package_name,
        "package_vendor": vuln.package_vendor,
        "package_version": vuln.package_version,
        "fixed_version": vuln.fixed_version,
        "exploitable": vuln.exploitable,
        "exploit_available": vuln.exploit_available,
        "detected_at": vuln.detected_at,
        "priority_score": breakdown["total"],
        "priority_breakdown": breakdown,
        "cpr_score": breakdown.get("cpr"),
        "epss_score": vuln.epss_score,
        "epss_percentile": vuln.epss_percentile,
        "kev_listed": vuln.kev_listed,
        "kev_ransomware_use": vuln.kev_ransomware_use,
        "kev_date_added": vuln.kev_date_added,
        "euvd_listed": vuln.euvd_listed,
        "euvd_critical": vuln.euvd_critical,
        "euvd_date_added": vuln.euvd_date_added,
        "euvd_id": vuln.euvd_id,
        "enrichment_sources": vuln.enrichment_sources,
        "enrichment_updated_at": vuln.enrichment_updated_at,
        # Multi-scanner attribution (Nessus integration)
        "sources": vuln.source_list,
        "cross_confirmed": vuln.cross_confirmed,
        "first_detected_by": vuln.first_detected_by,
        "nessus_plugin_id": vuln.nessus_plugin_id,
        "nessus_vpr_score": vuln.nessus_vpr_score,
        # SSVC decision points from CISA Vulnrichment.
        # exploitation_status was previously missing from _build_vuln_response
        # — Pydantic schema declared the field but the dict never set it,
        # so the frontend POC/ACTIVE/WIDESPREAD pill never fired even when
        # the score breakdown showed +2.0 from Exploit (ssvc).
        "exploitation_status": vuln.exploitation_status,
        "exploitation_source": vuln.exploitation_source,
        "ssvc_technical_impact": vuln.ssvc_technical_impact,
        "ssvc_automatable": vuln.ssvc_automatable,
        "is_pseudo_cve": vuln.is_pseudo_cve,
        "is_eol_finding": vuln.is_eol_finding,
        # Plan M — public-exploit catalogs. Parse JSON-stored lists into
        # actual arrays for the frontend. Empty list when no exploit known.
        "exploit_db_count": vuln.exploit_db_count or 0,
        "exploit_db_ids": (json.loads(vuln.exploit_db_ids) if vuln.exploit_db_ids else None),
        "pocs_github_count": vuln.pocs_github_count or 0,
        "pocs_github_urls": (json.loads(vuln.pocs_github_urls) if vuln.pocs_github_urls else None),
        "metasploit_module_count": vuln.metasploit_module_count or 0,
        "metasploit_modules": (json.loads(vuln.metasploit_modules) if vuln.metasploit_modules else None),
        "exploit_intel_updated_at": vuln.exploit_intel_updated_at,
        "assigned_user_id": vuln.assigned_user_id,
        "assigned_user_name": vuln.assigned_user.username if vuln.assigned_user else None,
        "assigned_group_id": vuln.assigned_group_id,
        "assigned_group_name": vuln.group.name if vuln.group else None,
        "deferred_until": vuln.deferred_until,
        "defer_reason": vuln.defer_reason,
        "notification_suppressed": vuln.notification_suppressed,
        "packages": packages_payload,
        "has_fix_any": has_fix_any,
        # Fields for DetailResponse (will be None if not detail)
        "description": getattr(vuln, 'description', None),
        "cvss_vector": getattr(vuln, 'cvss_vector', None),
        "exploit_maturity": getattr(vuln, 'exploit_maturity', None),
        "published_date": getattr(vuln, 'published_date', None),
        "last_modified_date": getattr(vuln, 'last_modified_date', None),
        "patched_at": getattr(vuln, 'patched_at', None),
        "references": getattr(vuln, 'references', None),
        "remediation": getattr(vuln, 'remediation', None),
        "cwe_id": getattr(vuln, 'cwe_id', None),
    }


# ============================================
# Endpoints
# ============================================

@router.post("/bulk-update")
async def bulk_update_vulnerabilities(
    update_data: BulkVulnerabilityUpdateRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Updates multiple vulnerabilities at once (assignment, status)"""
    vulnerabilities = db.query(Vulnerability).filter(Vulnerability.id.in_(update_data.vulnerability_ids)).all()
    
    if not vulnerabilities:
        raise HTTPException(status_code=404, detail="No vulnerabilities found")

    update_fields = {}
    if update_data.assigned_user_id is not None:
        update_fields["assigned_user_id"] = update_data.assigned_user_id if update_data.assigned_user_id != -1 else None
        # Clear group when assigning user (mutually exclusive)
        update_fields["assigned_group_id"] = None
    if update_data.assigned_group_id is not None:
        update_fields["assigned_group_id"] = update_data.assigned_group_id if update_data.assigned_group_id != -1 else None
        # Clear user when assigning group (mutually exclusive)
        update_fields["assigned_user_id"] = None
    if update_data.status is not None:
        update_fields["status"] = update_data.status

    if not update_fields:
         raise HTTPException(status_code=400, detail="No update fields specified")

    count = 0
    now = datetime.now()
    # Status change in this bulk action? Used to drive per-vuln audit
    # entries so each row carries who/when/source even in bulk flow.
    new_status = update_fields.get("status")
    audit_reason = (
        update_data.reason
        or (_auto_reason(new_status, current_user.username) if new_status else None)
    )

    status_change_log: list[tuple[int, str, str, str]] = []  # (vuln_id, cve_id, old, new)
    for vuln in vulnerabilities:
        for field, value in update_fields.items():
            # If setting status to PATCHED, also set patched_at logic
            if field == "status" and value == VulnerabilityStatus.patched:
                if vuln.status != VulnerabilityStatus.patched:
                    vuln.patched_at = now
            if field == "status" and vuln.status != value:
                old_val = vuln.status.value if hasattr(vuln.status, "value") else str(vuln.status)
                new_val = value.value if hasattr(value, "value") else str(value)
                status_change_log.append((vuln.id, vuln.cve_id, old_val, new_val))
            setattr(vuln, field, value)
        count += 1

    db.commit()

    # Per-vuln audit entries so each row's Change History panel shows
    # WHO bulk-changed it + WHY (auto-reason when no comment supplied).
    for vuln_id, cve_id, old_val, new_val in status_change_log:
        try:
            log_vulnerability_change(
                db, current_user.id, vuln_id, old_val, new_val,
                reason=audit_reason, cve_id=cve_id, source="manual_bulk",
            )
        except Exception as e:
            logger.warning("bulk audit log failed for vuln_id=%s: %s", vuln_id, e)

    # Summary audit entry kept for the admin overview page.
    audit_log = AuditLog(
        user_id=current_user.id,
        event_type=AuditEventType.VULNERABILITY_UPDATED,
        event_description=(
            f"Bulk update performed for {count} vulnerabilities"
            + (f" (status → {new_status.value})" if new_status else "")
            + (f" — reason: {audit_reason}" if audit_reason else "")
        )[:500],
        resource_type="vulnerability",
        resource_id="multiple",
        timestamp=datetime.now()
    )
    db.add(audit_log)
    db.commit()

    return {"message": f"{count} vulnerabilities successfully updated", "count": count}


@router.get("")
async def list_vulnerabilities(
    severity: Optional[VulnerabilitySeverity] = Query(None, description="Filter nach Severity"),
    status: Optional[str] = Query(None, description="Filter nach Status (enum value or 'all')"),
    exploitable: Optional[bool] = Query(None, description="Nur exploitable CVEs"),
    kev_only: Optional[bool] = Query(None, description="Nur CISA-KEV-gelistete CVEs"),
    euvd_only: Optional[bool] = Query(None, description="Nur ENISA EUVD-gelistete CVEs"),
    eu_critical: Optional[bool] = Query(None, description="Nur ENISA-Critical-CVEs"),
    in_any_catalog: Optional[bool] = Query(None, description="CVE in mind. einem Catalog (KEV oder EUVD)"),
    in_both_catalogs: Optional[bool] = Query(None, description="CVE in BEIDEN Catalogs (KEV und EUVD)"),
    epss_min: Optional[float] = Query(None, description="Minimaler EPSS-Score (0.0-1.0)"),
    source: Optional[str] = Query(None, description="Filter by scanner source: wazuh, nessus, manual"),
    finding_type: Optional[str] = Query(None, description="Special finding group: 'mobile' = mobile device EOL/EOS + Android patch-level staleness"),
    cross_confirmed: Optional[bool] = Query(None, description="Only CVEs reported by 2+ scanners"),
    asset_id: Optional[int] = Query(None, description="Filter nach Asset"),
    search: Optional[str] = Query(None, description="Suche in CVE-ID, Package, Title"),
    include_inactive_assets: bool = Query(False, description="Include CVEs on INACTIVE/DECOMMISSIONED assets (off by default)"),
    distinct_cve: bool = Query(False, description="Collapse per-asset duplicates to one row per CVE-ID (dashboard 'newest N distinct CVEs' widgets)"),
    sort_by: str = Query("priority", description="Sortierung: priority, cvss, detected_at"),
    sort_order: str = Query("desc", description="Reihenfolge: asc, desc"),
    limit: int = Query(100, le=1000),
    offset: int = Query(0),
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    Liste aller Vulnerabilities mit Filterung und Sortierung

    Sortier-Optionen:
        - priority: Nach berechnetem Priority-Score (CVSS + Exploitability + Age)
        - cvss: Nach CVSS-Score
        - detected_at: Nach Erkennungsdatum
    """
    # Base Query mit OUTER JOIN zu Assets (damit Vulns auch ohne Asset-Match angezeigt werden)
    query = db.query(Vulnerability).outerjoin(Asset)
    # Hide CVEs on INACTIVE / DECOMMISSIONED assets by default — an asset
    # no longer in any synced scan should not contribute to dashboards.
    # Orphan vulnerabilities (asset_id NULL) are still returned.
    if not include_inactive_assets:
        query = query.filter(
            (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE)
        )

    # Filter anwenden
    if severity:
        query = query.filter(Vulnerability.severity == severity)

    # Status filter — supports literal enum values + two convenience groups.
    # Default (no value) = active states only (open + pending_verification +
    # patch_failed). 'all' returns every status. 'closed' returns operator-
    # closed states (false_positive + accepted_risk + deferred + patched).
    _ACTIVE_STATES = (
        VulnerabilityStatus.open,
        VulnerabilityStatus.pending_verification,
        VulnerabilityStatus.patch_failed,
    )
    _CLOSED_STATES = (
        VulnerabilityStatus.patched,
        VulnerabilityStatus.false_positive,
        VulnerabilityStatus.accepted_risk,
        VulnerabilityStatus.deferred,
    )
    if status == 'all':
        pass  # no filter
    elif status == 'active':
        query = query.filter(Vulnerability.status.in_(_ACTIVE_STATES))
    elif status == 'closed':
        query = query.filter(Vulnerability.status.in_(_CLOSED_STATES))
    elif status:
        # literal enum value (open / patched / false_positive / ...)
        query = query.filter(Vulnerability.status == status)
    else:
        # Default — active states only (SOC workflow default)
        query = query.filter(Vulnerability.status.in_(_ACTIVE_STATES))

    if exploitable is not None:
        query = query.filter(Vulnerability.exploitable == exploitable)

    if kev_only:
        query = query.filter(Vulnerability.kev_listed == True)

    if euvd_only:
        query = query.filter(Vulnerability.euvd_listed == True)

    if eu_critical:
        query = query.filter(Vulnerability.euvd_critical == True)

    if in_any_catalog:
        query = query.filter(
            (Vulnerability.kev_listed == True) | (Vulnerability.euvd_listed == True)
        )

    if in_both_catalogs:
        query = query.filter(
            Vulnerability.kev_listed == True,
            Vulnerability.euvd_listed == True,
        )

    if epss_min is not None:
        query = query.filter(Vulnerability.epss_score >= epss_min)

    # Source filter (sources column is a JSON list stored as text).
    # `wazuh_only` would be too brittle, use string contains.
    if source:
        src = source.strip().lower()
        if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune"}:
            query = query.filter(Vulnerability.sources.contains(f'"{src}"'))

    if finding_type == "mobile":
        # Mobile device findings: vendor EOL/EOS (EOL- pseudo-CVE on a phone/
        # tablet slug) + Android patch-level staleness. One group for the
        # dashboard "Mobile Security" widget + its View All.
        query = query.filter(
            Vulnerability.cve_id.like("ANDROID-PATCH-%")
            | Vulnerability.cve_id.like("EOL-IPHONE-%")
            | Vulnerability.cve_id.like("EOL-IPAD-%")
            | Vulnerability.cve_id.like("EOL-SAMSUNG-MOBILE-%")
            | Vulnerability.cve_id.like("EOL-SAMSUNG-GALAXY-TAB-%")
        )

    if cross_confirmed:
        # Pragmatic: rows with at least one comma in the JSON list have >=2 sources.
        # `["wazuh"]` has no comma, `["wazuh","nessus"]` does.
        query = query.filter(Vulnerability.sources.contains(','))

    if asset_id:
        query = query.filter(Vulnerability.asset_id == asset_id)

    if search:
        import re
        # Parse search terms, respecting quoted strings
        # Example: '!"Mozilla Thunderbird" kernel' -> ['!"Mozilla Thunderbird"', 'kernel']
        term_pattern = re.compile(r'!?"[^"]+"|[^\s]+')
        terms = term_pattern.findall(search.strip())
        
        for term in terms:
            is_negation = term.startswith('!')
            clean_term = term[1:] if is_negation else term
            
            # Remove surrounding quotes if present
            if clean_term.startswith('"') and clean_term.endswith('"'):
                clean_term = clean_term[1:-1]
            
            if not clean_term:
                continue
                
            search_pattern = f"%{clean_term}%"
            
            if is_negation:
                # Negation (Exclusion)
                query = query.filter(
                    ~((Vulnerability.cve_id.ilike(search_pattern)) |
                      (Vulnerability.package_name.ilike(search_pattern)) |
                      (Vulnerability.title.ilike(search_pattern)))
                )
            else:
                # Normal Search (Inclusion)
                query = query.filter(
                    (Vulnerability.cve_id.ilike(search_pattern)) |
                    (Vulnerability.package_name.ilike(search_pattern)) |
                    (Vulnerability.title.ilike(search_pattern))
                )

    # Collapse per-asset duplicates to one row per CVE-ID. Without this a CVE
    # sitting on many assets fills the page with identical-CVE rows, so the
    # dashboard's client-side dedup starves and a "newest 10" widget showed
    # only ~4 (tester feedback). Keep the newest-published (then most-recently-
    # detected) representative per CVE; composes with every sort_by below.
    if distinct_cve:
        from sqlalchemy import func as _wf
        _rn = _wf.row_number().over(
            partition_by=Vulnerability.cve_id,
            order_by=(
                Vulnerability.published_date.desc().nullslast(),
                Vulnerability.detected_at.desc().nullslast(),
                Vulnerability.id.desc(),
            ),
        ).label("rn")
        _sub = query.with_entities(Vulnerability.id.label("vid"), _rn).subquery()
        _keep_ids = db.query(_sub.c.vid).filter(_sub.c.rn == 1)
        query = query.filter(Vulnerability.id.in_(_keep_ids))

    # Get total count before pagination
    total_count = query.count()

    # Sortierung
    # Supported sort_by values map to SQL columns.
    # `priority` is special (needs Python-side breakdown), `cpr` is computed,
    # `published_date` and `cve_id` fall through to dedicated branches below.
    sort_map = {
        "cvss": Vulnerability.cvss_score,
        "cvss_score": Vulnerability.cvss_score,
        "detected_at": Vulnerability.detected_at,
        "published_date": Vulnerability.published_date,
        "updated_at": Vulnerability.updated_at,    # DB-write timestamp — "recently changed risk"
        "epss": Vulnerability.epss_score,
        "epss_score": Vulnerability.epss_score,
        "kev": Vulnerability.kev_listed,
        "kev_listed": Vulnerability.kev_listed,
        "euvd": Vulnerability.euvd_listed,
        "euvd_listed": Vulnerability.euvd_listed,
        "cve_id": Vulnerability.cve_id,
        "severity": Vulnerability.severity,
        "status": Vulnerability.status,
        # Source: groups rows by which scanner first reported the finding.
        # Cross-confirmed rows fall into the bucket of whichever saw it first.
        "source": Vulnerability.first_detected_by,
        "first_detected_by": Vulnerability.first_detected_by,
        # Asset is already outer-joined above, so these need no extra join.
        "host": Asset.hostname,
        "hostname": Asset.hostname,
        "package": Vulnerability.package_name,
        "package_name": Vulnerability.package_name,
        # Assignee sorts by name, not by id — an id order looks random to a
        # user. Needs its own outer join, added only for this sort.
        "assigned_to": User.username,
        "assigned_user": User.username,
    }

    order_fn = desc if sort_order == "desc" else asc

    def _python_sort_and_build(vulns, key_fn):
        ordered = sorted(vulns, key=key_fn, reverse=(sort_order == "desc"))
        return [_build_vuln_response(v) for v in ordered]

    if sort_by == "priority":
        # Sort by materialised priority_score (migration 024). NULLs go
        # last on desc, first on asc. Sync/enrich/override paths keep
        # the column current via Vulnerability.refresh_scores().
        if sort_order == "desc":
            query = query.order_by(
                Vulnerability.priority_score.is_(None),
                desc(Vulnerability.priority_score),
                desc(Vulnerability.id),
            )
        else:
            query = query.order_by(
                asc(Vulnerability.priority_score),
                asc(Vulnerability.id),
            )
        vulnerabilities = query.offset(offset).limit(limit).all()
        items_resp = [_build_vuln_response(v) for v in vulnerabilities]
        _attach_last_change(db, vulnerabilities, items_resp)
        return {"items": items_resp, "total": total_count}

    if sort_by == "cpr":
        # Sort by materialised cpr_score (migration 024). Old SQL proxy
        # `cvss * epss` was wrong vs the real `CVSS×10×0.6 + EPSS%×0.4`
        # formula and only sorted the current page anyway.
        if sort_order == "desc":
            query = query.order_by(
                Vulnerability.cpr_score.is_(None),
                desc(Vulnerability.cpr_score),
                desc(Vulnerability.id),
            )
        else:
            query = query.order_by(
                asc(Vulnerability.cpr_score),
                asc(Vulnerability.id),
            )
        vulnerabilities = query.offset(offset).limit(limit).all()
        items_resp = [_build_vuln_response(v) for v in vulnerabilities]
        _attach_last_change(db, vulnerabilities, items_resp)
        return {"items": items_resp, "total": total_count}

    if sort_by == "published_date":
        # "Newly Published" = sort by the CVE's OFFICIAL published_date.
        #
        # The old COALESCE(published_date, detected_at) fallback was wrong:
        # a Nessus sync imports old CVEs (2014-2023) with published_date
        # NULL but detected_at=today, so COALESCE made every freshly-
        # imported old CVE look like it was "published today" and flooded
        # the top of the list in arbitrary id order (tester: "komisch
        # durcheinander"). Now rows WITHOUT a real published_date sink to
        # the bottom (nulls-last) instead of masquerading as newest.
        search_eol = (search or "").upper().startswith("EOL")
        if not search_eol and finding_type != "mobile":
            query = query.filter(
                ~Vulnerability.cve_id.like("EOL-%"),
                ~Vulnerability.cve_id.like("NESSUS-PLUGIN-%"),
            )
        # Secondary key: until the NVD published_date backfill drains, most
        # rows have published_date NULL. Ordering those by id is meaningless
        # (tester: "danach wieder NICHT richtig sortiert"). Fall back to the
        # CVE's own year+sequence so "newest CVE number first" still holds.
        from sqlalchemy import func as _sf, case as _case, Integer as _Int
        _yr = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 2), '')
        _nm = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 3), '')
        _yr_i = _case((_yr.op('~')('^[0-9]+$'), _sf.cast(_yr, _Int)), else_=None)
        _nm_i = _case((_nm.op('~')('^[0-9]+$'), _sf.cast(_nm, _Int)), else_=None)
        if sort_order == "desc":
            query = query.order_by(
                Vulnerability.published_date.is_(None),   # nulls last
                desc(Vulnerability.published_date),
                desc(_yr_i), desc(_nm_i),
                desc(Vulnerability.id),
            )
        else:
            query = query.order_by(
                Vulnerability.published_date.is_(None),
                asc(Vulnerability.published_date),
                asc(_yr_i), asc(_nm_i),
                asc(Vulnerability.id),
            )
        vulnerabilities = query.offset(offset).limit(limit).all()
        items_resp = [_build_vuln_response(v) for v in vulnerabilities]
        _attach_last_change(db, vulnerabilities, items_resp)
        return {"items": items_resp, "total": total_count}

    if sort_by == "cve_id":
        # Natural-numeric sort: lex on "CVE-2026-8401" sorts BEFORE
        # "CVE-2026-35440" because "8" > "3" character-wise. Cast the
        # numeric suffix to int so 35440 wins.
        # Pseudo-CVEs (NESSUS-PLUGIN-*) fall back to lex at the end.
        from sqlalchemy import func as sa_func, case
        # Postgres: split_part(cve_id, '-', 2)::int as year, ('', 3)::int as num
        # Pseudo-CVEs ("NESSUS-PLUGIN-123456") have non-numeric year segment;
        # NULLIF + regexp catches the cast safely.
        year_part = sa_func.nullif(sa_func.split_part(Vulnerability.cve_id, '-', 2), '')
        num_part = sa_func.nullif(sa_func.split_part(Vulnerability.cve_id, '-', 3), '')
        # Cast only when both segments are pure digits (defensive vs pseudo-CVEs)
        year_int = case(
            (year_part.op('~')('^[0-9]+$'), sa_func.cast(year_part, __import__('sqlalchemy').Integer)),
            else_=None,
        )
        num_int = case(
            (num_part.op('~')('^[0-9]+$'), sa_func.cast(num_part, __import__('sqlalchemy').Integer)),
            else_=None,
        )
        if sort_order == "desc":
            query = query.order_by(
                year_int.is_(None),       # pseudo-CVEs to the end
                desc(year_int),
                desc(num_int),
                desc(Vulnerability.cve_id),  # tiebreak for pseudo
            )
        else:
            query = query.order_by(asc(year_int), asc(num_int), asc(Vulnerability.cve_id))
        vulnerabilities = query.offset(offset).limit(limit).all()
        items_resp = [_build_vuln_response(v) for v in vulnerabilities]
        _attach_last_change(db, vulnerabilities, items_resp)
        return {"items": items_resp, "total": total_count}

    sort_column = sort_map.get(sort_by, Vulnerability.cvss_score)
    if sort_by in ("assigned_to", "assigned_user"):
        query = query.outerjoin(User, Vulnerability.assigned_user_id == User.id)

    # NULL handling: put NULLs last on desc, first on asc (sensible UX)
    if sort_order == "desc":
        query = query.order_by(sort_column.is_(None), desc(sort_column))
    else:
        query = query.order_by(asc(sort_column))

    vulnerabilities = query.offset(offset).limit(limit).all()
    results = [_build_vuln_response(v) for v in vulnerabilities]
    _attach_last_change(db, vulnerabilities, results)
    return {"items": results, "total": total_count}


def _attach_last_change(db: Session, vulns, results: list[dict]) -> None:
    """Bulk-load latest VULNERABILITY_UPDATED audit row per vuln and
    glue a `last_change` dict onto each response item.

    Used by the list endpoint so the status badge can render a
    hover-tooltip with WHO/WHEN/HOW for the most recent state move
    (manual edit, scanner auto-patch, bulk-patch).

    One query for the whole page — O(N) rows, not N+1.
    """
    if not vulns:
        return
    try:
        from sqlalchemy import func as _func, and_ as _and
        ids = [str(v.id) for v in vulns]
        subq = (
            db.query(
                AuditLog.resource_id,
                _func.max(AuditLog.timestamp).label("latest_ts"),
            )
            .filter(
                AuditLog.resource_type == "vulnerability",
                AuditLog.resource_id.in_(ids),
                AuditLog.event_type == AuditEventType.VULNERABILITY_UPDATED,
            )
            .group_by(AuditLog.resource_id)
            .subquery()
        )
        latest_logs = (
            db.query(AuditLog)
            .join(subq, _and(
                AuditLog.resource_id == subq.c.resource_id,
                AuditLog.timestamp == subq.c.latest_ts,
            ))
            .filter(AuditLog.resource_type == "vulnerability")
            .all()
        )
        by_vid: dict[str, AuditLog] = {}
        for a in latest_logs:
            # If same vuln got multiple entries at the EXACT same
            # timestamp (rare — bulk update), keep the highest id.
            existing = by_vid.get(a.resource_id)
            if existing is None or a.id > existing.id:
                by_vid[a.resource_id] = a
        for vuln, item in zip(vulns, results):
            a = by_vid.get(str(vuln.id))
            if not a:
                continue
            parsed = None
            if a.new_value and a.new_value.startswith("{"):
                try:
                    parsed = json.loads(a.new_value)
                except json.JSONDecodeError:
                    parsed = None
            item["last_change"] = {
                "timestamp": a.timestamp.isoformat() if a.timestamp else None,
                "username": a.user.username if a.user else "System/Auto",
                "old_status": a.old_value,
                "new_status": (parsed or {}).get("status") if isinstance(parsed, dict) else None,
                "reason": (parsed or {}).get("reason") if isinstance(parsed, dict) else None,
                "source": (parsed or {}).get("source") if isinstance(parsed, dict) else "manual",
            }
    except Exception as e:
        logger.warning("last_change preload failed: %s", e)


@router.get("/ai-prioritization")
def get_ai_prioritization(
    limit: int = Query(20, le=50),
    severity: Optional[VulnerabilitySeverity] = Query(None, description="Nach Severity filtern"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
    ai_client: InfomaniakAIClient = Depends(get_infomaniak_ai_client)
):
    """
    Generiert KI-basierte Patch-Empfehlungen
    """
    ai_service = AIAnalysisService(db, ai_client)
    try:
        recommendations = ai_service.get_priority_recommendations(limit=limit, severity=severity)
        
        # Save Report
        if "recommendations" in recommendations and recommendations["recommendations"]:
             # Extract data safely
            strategy = recommendations.get("global_strategy", "No strategy provided.")
            recs_json = json.dumps(recommendations.get("recommendations", []))
            
            report = AIReport(
                created_by_id=current_user.id,
                global_strategy=strategy,
                recommendations=recs_json
            )
            db.add(report)
            db.commit()

        return recommendations
    except Exception as e:
        logger.error(f"AI prioritization failed: {e}")
        raise HTTPException(
            status_code=500,
            detail="AI prioritization failed. Check server logs for details."
        )


@router.get("/ai-history")
async def get_ai_history(
    limit: int = 10,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    Liefert die letzten AI-Scan-Berichte zurück
    """
    reports = db.query(AIReport).order_by(desc(AIReport.created_at)).limit(limit).all()
    
    result = []
    for r in reports:
        try:
            recs = json.loads(r.recommendations)
        except (json.JSONDecodeError, TypeError):
            recs = []
            
        result.append({
            "id": r.id,
            "created_at": r.created_at,
            "global_strategy": r.global_strategy,
            "recommendations_count": len(recs),
            "recommendations": recs # Optional: full data
        })
    return result


@router.get("/reports/dashboard", response_model=DashboardStatsResponse)
async def get_dashboard_statistics(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    Dashboard-Statistiken für Frontend

    Liefert aggregierte Daten:
        - Anzahl Vulnerabilities pro Severity
        - Offene vs. Geschlossene
        - Exploitable-Count
        - Durchschnittlicher CVSS-Score
        - Betroffene Assets
        - Älteste offene Vulnerability
    """
    # Base Query: Nur offene Vulnerabilities, scoped to ACTIVE assets so
    # that INACTIVE / DECOMMISSIONED assets do not inflate dashboard counts.
    # Orphan vulnerabilities (asset_id NULL) are kept.
    open_vulns = (
        db.query(Vulnerability)
        .outerjoin(Asset, Vulnerability.asset_id == Asset.id)
        .filter(
            Vulnerability.status == VulnerabilityStatus.open,
            (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
        )
    )

    # Counts pro Severity
    critical_count = open_vulns.filter(
        Vulnerability.severity == VulnerabilitySeverity.critical
    ).count()

    high_count = open_vulns.filter(
        Vulnerability.severity == VulnerabilitySeverity.high
    ).count()

    medium_count = open_vulns.filter(
        Vulnerability.severity == VulnerabilitySeverity.medium
    ).count()

    low_count = open_vulns.filter(
        Vulnerability.severity == VulnerabilitySeverity.low
    ).count()

    # Exploitable Count
    exploitable_count = open_vulns.filter(
        Vulnerability.exploit_available == True
    ).count()

    # Durchschnittlicher CVSS-Score
    avg_cvss = (
        db.query(func.avg(Vulnerability.cvss_score))
        .outerjoin(Asset, Vulnerability.asset_id == Asset.id)
        .filter(
            Vulnerability.status == VulnerabilityStatus.open,
            (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
        )
        .scalar()
    ) or 0.0

    # Betroffene Assets
    affected_assets = (
        db.query(func.count(func.distinct(Vulnerability.asset_id)))
        .outerjoin(Asset, Vulnerability.asset_id == Asset.id)
        .filter(
            Vulnerability.status == VulnerabilityStatus.open,
            (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
        )
        .scalar()
    ) or 0

    # Älteste Vulnerability
    oldest_vuln = open_vulns.order_by(asc(Vulnerability.detected_at)).first()
    oldest_days = 0
    if oldest_vuln:
        oldest_days = (datetime.now() - oldest_vuln.detected_at).days

    # Verlauf der letzten 7 Tage (Erkennungsdatum)
    severity_history = []
    from datetime import timedelta, time, datetime as dt_class
    now = datetime.now()
    for i in range(6, -1, -1):
        day_date = (now - timedelta(days=i)).date()
        start_of_day = dt_class.combine(day_date, time.min)
        end_of_day = dt_class.combine(day_date, time.max)
        
        count = (
            db.query(Vulnerability)
            .outerjoin(Asset, Vulnerability.asset_id == Asset.id)
            .filter(
                Vulnerability.detected_at >= start_of_day,
                Vulnerability.detected_at <= end_of_day,
                Vulnerability.status == VulnerabilityStatus.open,
                (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE),
            )
            .count()
        )
        severity_history.append({
            "day": day_date.strftime("%a"),
            "count": count
        })

    # Gesamt-Zahlen
    total_vulns = (
        db.query(Vulnerability)
        .outerjoin(Asset, Vulnerability.asset_id == Asset.id)
        .filter((Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE))
        .count()
    )
    open_vulns_count = open_vulns.count()

    total_assets = db.query(Asset).count()
    # Assets, die mindestens einmal gescannt wurden (last_scan is not null)
    scanned_assets = db.query(Asset).filter(Asset.last_scan != None).count()

    return {
        "total_vulnerabilities": total_vulns,
        "open_vulnerabilities": open_vulns_count,
        "critical_count": critical_count,
        "high_count": high_count,
        "medium_count": medium_count,
        "low_count": low_count,
        "exploitable_count": exploitable_count,
        "avg_cvss_score": round(avg_cvss, 2),
        "affected_assets": affected_assets,
        "total_assets": total_assets,
        "scanned_assets": scanned_assets,
        "severity_history": severity_history,
        "oldest_vulnerability_days": oldest_days
    }


@router.get("/{vuln_id}", response_model=VulnerabilityDetailResponse)
async def get_vulnerability_detail(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    Detail-Ansicht einer Vulnerability inkl. KI-Analyse (falls vorhanden)
    """
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()

    if not vuln:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Vulnerability not found"
        )

    # Hole KI-Analyse falls vorhanden
    ai_analysis = None
    if vuln.ai_analysis:
        ai_analysis = {
            "analysis_text": vuln.ai_analysis.analysis_text,
            "threat_level": vuln.ai_analysis.threat_level,
            "exploits_found": vuln.ai_analysis.exploits_found,
            "workarounds": vuln.ai_analysis.workarounds,
            "remediation_steps": vuln.ai_analysis.remediation_steps,
            "confidence_score": vuln.ai_analysis.confidence_score,
            "analysis_timestamp": vuln.ai_analysis.analysis_timestamp
        }

    resp = _build_vuln_response(vuln)
    resp["ai_analysis"] = ai_analysis
    return resp


@router.patch("/{vuln_id}", response_model=VulnerabilityResponse)
async def update_vulnerability_status(
    vuln_id: int,
    update_data: VulnerabilityUpdateRequest,
    background_tasks: BackgroundTasks,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)  # Editor/Admin-Berechtigung erforderlich
):
    """
    Ändert Status einer Vulnerability

    Patch-Verifizierungs-Workflow:
        1. User markiert CVE als "patched"
        2. Status wird auf "pending_verification" gesetzt
        3. Wazuh Syscollector-Scan wird getriggert (Background-Task)
        4. Nach Scan-Completion: Automatische Verifizierung
    """
    try:
        vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()

        if not vuln:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Vulnerability not found"
            )

        old_status = vuln.status
        new_status = update_data.status

        # Status-Änderung
        vuln.status = new_status
        # Persist reason in the matching column when it makes sense.
        # `reason` is the new status-agnostic field; `defer_reason` is
        # kept for the deferred-status legacy UI.
        if update_data.defer_reason is not None:
            vuln.defer_reason = update_data.defer_reason
        if update_data.deferred_until is not None:
            vuln.deferred_until = update_data.deferred_until

        # The audit-trail reason — accept either field for back-compat.
        # If the operator left both blank, auto-stamp "Manually set to X
        # by Y on Z" so the audit trail clearly tags this as a manual
        # action (vs scanner auto-patch) even without a user comment.
        audit_reason = (
            update_data.reason
            or update_data.defer_reason
            or _auto_reason(new_status, current_user.username)
        )

        # Wenn als "patched" markiert: Rescan triggern NUR wenn
        # Wazuh die vuln auch wirklich reportet. Nessus-only rows
        # (sources=["nessus"]) können nicht durch einen wazuh-syscollector
        # rescan verifiziert werden — der wazuh agent kennt das CVE nicht
        # → row blieb ewig in PENDING_VERIFICATION (tester report).
        if new_status == VulnerabilityStatus.patched:
            wazuh_can_verify = (
                vuln.asset
                and vuln.asset.wazuh_agent_id
                and "wazuh" in (vuln.source_list or [])
            )
            if wazuh_can_verify:
                vuln.status = VulnerabilityStatus.pending_verification
                vuln.patched_at = datetime.now()
                background_tasks.add_task(
                    verify_patch_with_rescan,
                    vuln_id,
                    vuln.asset.wazuh_agent_id
                )
            else:
                # Nessus-only or no wazuh agent → mark PATCHED directly.
                vuln.patched_at = datetime.now()

        db.commit()
        db.refresh(vuln)

        # Audit-Log — includes reason + cve_id for the revisionssicher view
        log_vulnerability_change(
            db, current_user.id, vuln_id, old_status, vuln.status,
            reason=audit_reason, cve_id=vuln.cve_id, source="manual",
        )

        return _build_vuln_response(vuln)
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Error updating vulnerability {vuln_id}: {e}", exc_info=True)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Internal Server Error: {str(e)}"
        )


def verify_patch_with_rescan(
    vuln_id: int,
    agent_id: str
):
    """
    Snychroner Background-Task für Patch-Verifizierung (läuft in eigenem Thread)

    1. Triggert Syscollector-Scan
    2. Wartet auf Completion (max. 10 Min)
    3. Re-fetcht Vulnerabilities
    4. Prüft ob CVE noch vorhanden
    5. Aktualisiert Status
    """
    from app.database import get_db_context
    from app.models.setting import Setting
    import json

    with get_db_context() as db:
        try:
            from app.auth.setting_crypto import read_setting_value
            raw_wazuh = read_setting_value(db, "wazuh_config")
            if not raw_wazuh:
                logger.error("Patch verification aborted: Wazuh configuration missing")
                return

            config = json.loads(raw_wazuh)
            wazuh = WazuhClient(
                base_url=config.get("api_url"),
                username=config.get("username"),
                password=config.get("password"),
                indexer_url=config.get("indexer_url"),
                indexer_username=config.get("indexer_username"),
                indexer_password=config.get("indexer_password"),
                verify_ssl=config.get("verify_ssl", True)
            )

            # 1. Trigger Rescan
            try:
                wazuh.trigger_syscollector_scan(agent_id)
            except Exception as e:
                logger.error(f"Could not trigger Syscollector scan for agent {agent_id}: {e}")
                # Wir machen trotzdem weiter, evtl. lief gerade schon einer

            # 2. Warte auf Scan-Completion (blockiert nur diesen Thread, nicht den Server)
            scan_completed = wazuh.wait_for_scan_completion(
                agent_id,
                timeout=600,  # 10 Min
                check_interval=30
            )

            if not scan_completed:
                # Timeout: Markiere als Patch-Failed
                vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
                if vuln:
                    vuln.status = VulnerabilityStatus.patch_failed
                    db.commit()
                return

            # 3. Re-fetch Vulnerabilities
            current_vulns = wazuh.get_vulnerabilities(agent_id)
            vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()

            if not vuln:
                return

            # 4. Prüfe ob CVE noch in Wazuh-Daten
            cve_still_exists = any(v.get("cve") == vuln.cve_id for v in current_vulns)

            old_status = vuln.status
            if cve_still_exists:
                # Patch fehlgeschlagen
                vuln.status = VulnerabilityStatus.patch_failed
                _reason = f"Patch verification rescan: CVE still reported by Wazuh on agent {agent_id}"
            else:
                # Patch erfolgreich verifiziert
                vuln.status = VulnerabilityStatus.patched
                vuln.patched_at = datetime.now()
                _reason = f"Patch verification rescan: CVE no longer reported by Wazuh on agent {agent_id}"

            # Revisionssicher: record the verify outcome (feeds audit log +
            # per-CVE Change History). Background task → user_id=None.
            if vuln.status != old_status:
                try:
                    log_vulnerability_change(
                        db, None, vuln.id, old_status, vuln.status,
                        reason=_reason, cve_id=vuln.cve_id, source="verify_patch_rescan",
                    )
                except Exception as e:
                    logger.warning("audit log for patch-verify failed (vuln_id=%s): %s", vuln.id, e)

            db.commit()

        except Exception as e:
            logger.error(f"Error in verify_patch_with_rescan: {e}")
            # Bei Fehler: Markiere als Patch-Failed
            vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
            if vuln:
                vuln.status = VulnerabilityStatus.patch_failed
                db.commit()


@router.post("/{vuln_id}/analyze")
def analyze_vulnerability_with_ai(
    vuln_id: int,
    force_refresh: bool = Query(False, description="Ignore cache"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
    ai_client: InfomaniakAIClient = Depends(get_infomaniak_ai_client)
):
    """
    Triggert KI-Analyse für eine Vulnerability

    Nutzt Infomaniak AI API mit Web-Search für:
        - Exploit-Detection
        - Threat-Intelligence
        - Workarounds
        - Remediation-Steps
    """
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()

    if not vuln:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Vulnerability not found"
        )

    # Service-Layer für DB-Integration
    ai_service = AIAnalysisService(db, ai_client)

    try:
        analysis = ai_service.get_or_create_analysis(vuln_id, force_refresh)

        # Audit-Log
        audit_log = AuditLog(
            user_id=current_user.id,
            event_type=AuditEventType.AI_ANALYSIS_REQUESTED,
            event_description=f"AI analysis requested for {vuln.cve_id}",
            resource_type="vulnerability",
            resource_id=str(vuln_id),
            timestamp=datetime.now()
        )
        db.add(audit_log)
        db.commit()

        return {
            "analysis_text": analysis.analysis_text,
            "threat_level": analysis.threat_level,
            "exploits_found": analysis.exploits_found,
            "workarounds": analysis.workarounds,
            "remediation_steps": analysis.remediation_steps,
            "threat_intel_sources": analysis.threat_intel_sources,
            "confidence_score": analysis.confidence_score,
            "model_version": analysis.model_version,
            "analysis_timestamp": analysis.analysis_timestamp,
            "cached": not force_refresh
        }

    except Exception as e:
        logger.error(f"AI analysis failed: {e}")
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="AI analysis failed. Check server logs for details."
        )








@router.post("/{vuln_id}/enrich")
def enrich_single_vulnerability(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Trigger EPSS + CISA KEV enrichment for one vulnerability."""
    try:
        stats = enrich_vulnerability_by_id(db, vuln_id)
    except EnrichmentError as e:
        raise HTTPException(status_code=404, detail=str(e))

    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()

    audit_log = AuditLog(
        user_id=current_user.id,
        event_type=AuditEventType.VULNERABILITY_UPDATED,
        event_description=f"Threat intel enrichment for {vuln.cve_id if vuln else vuln_id}",
        resource_type="vulnerability",
        resource_id=str(vuln_id),
        timestamp=datetime.now()
    )
    db.add(audit_log)
    db.commit()

    return {"stats": stats, "vulnerability": _build_vuln_response(vuln) if vuln else None}


class BulkEnrichRequest(BaseModel):
    vulnerability_ids: Optional[List[int]] = None
    only_open: bool = True


@router.post("/enrich/bulk")
# Sync def → worker threadpool (off the event loop); EPSS/KEV enrichment
# over many CVEs no longer freezes the web GUI.
def bulk_enrich_vulnerabilities(
    payload: BulkEnrichRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """
    Bulk enrich vulnerabilities with EPSS + CISA KEV.

    - If `vulnerability_ids` is provided, only those are enriched.
    - Otherwise enriches every open vulnerability (or all if only_open=false).
    """
    if payload.vulnerability_ids:
        vulns = db.query(Vulnerability).filter(
            Vulnerability.id.in_(payload.vulnerability_ids)
        ).all()
    else:
        q = db.query(Vulnerability)
        if payload.only_open:
            q = q.filter(Vulnerability.status == VulnerabilityStatus.open)
        vulns = q.all()

    if not vulns:
        return {"stats": {"epss_updated": 0, "kev_marked": 0, "kev_cleared": 0, "total": 0}}

    # NVD date backfill is rate-limited (up to 6.5s/CVE without a key) and
    # would hang this synchronous request for minutes. It runs in the
    # nightly enrichment job instead; the manual button stays fast.
    stats = enrich_vulnerabilities(db, vulns, use_nvd_dates=False)

    audit_log = AuditLog(
        user_id=current_user.id,
        event_type=AuditEventType.VULNERABILITY_UPDATED,
        event_description=f"Bulk threat intel enrichment for {stats['total']} CVEs",
        resource_type="vulnerability",
        resource_id="multiple",
        timestamp=datetime.now()
    )
    db.add(audit_log)
    db.commit()

    return {"stats": stats}


# Guard so a double-click / impatient retry doesn't stack concurrent
# 600 MB ZIP walks. Module-level flag is fine — single backend process.
_DATE_BACKFILL_RUNNING = False


def _run_date_backfill_threaded() -> dict:
    """Date-only enrichment with its own DB session (runs in a detached
    thread). Fills published_date/last_modified_date for every CVE row
    that lacks a published_date, via the cvelistV5 ZIP/raw cascade (NVD
    fallback). No EPSS/KEV/EUVD work — just dates."""
    global _DATE_BACKFILL_RUNNING
    from app.database import SessionLocal
    db = SessionLocal()
    try:
        vulns = (
            db.query(Vulnerability)
            .filter(
                Vulnerability.published_date.is_(None),
                Vulnerability.cve_id.like("CVE-%"),
            )
            .all()
        )
        if not vulns:
            logger.info("Date backfill: nothing to do (no undated CVEs)")
            return {"total": 0, "nvd_dates_set": 0}
        stats = enrich_vulnerabilities(
            db, vulns,
            use_epss=False, use_kev=False, use_euvd=False, use_nvd_dates=True,
        )
        logger.info("Date backfill done: %s", stats)
        return stats
    except Exception as e:
        logger.error("Date backfill failed: %s", e)
        return {"error": str(e)}
    finally:
        db.close()
        _DATE_BACKFILL_RUNNING = False


@router.post("/dates/backfill", status_code=202)
async def backfill_cve_dates(
    current_user: User = Depends(RequireEditor),
):
    """Kick off an on-demand CVE published/last-modified backfill.

    Fire-and-forget: launches the date-only fill (cvelistV5 ZIP/raw → NVD
    fallback) in a detached background thread and returns 202 immediately,
    so a slow ZIP walk over thousands of CVEs can't trip the reverse-proxy
    request timeout (was returning 503). Watch progress in the logs:
        docker compose logs -f backend | grep -E "CVE dates:|Date backfill"
    No NVD API key required — cvelistV5 is the primary, keyless source.
    """
    global _DATE_BACKFILL_RUNNING
    if _DATE_BACKFILL_RUNNING:
        return {"status": "already_running",
                "detail": "A date backfill is already in progress."}
    _DATE_BACKFILL_RUNNING = True
    import threading
    threading.Thread(target=_run_date_backfill_threaded, daemon=True).start()
    return {"status": "started",
            "detail": "Date backfill started in the background. "
                      "The Newly-Published order updates as it completes "
                      "(watch the backend logs)."}


@router.post("/enrich/kev/refresh")
def refresh_kev_catalog(
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Force-refresh the cached CISA KEV catalog from CISA feed."""
    try:
        kev_map = fetch_kev_catalog(db, force_refresh=True)
    except EnrichmentError as e:
        raise HTTPException(status_code=502, detail=str(e))
    return {"kev_entries": len(kev_map), "refreshed_at": datetime.now().isoformat()}


@router.patch("/{vuln_id}/assign")
async def assign_vulnerability(
    vuln_id: int,
    assign_data: AssignRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Assigns a user to a vulnerability"""
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(status_code=404, detail="Vulnerability not found")

    if assign_data.user_id is not None:
        user = db.query(User).filter(User.id == assign_data.user_id).first()
        if not user:
            raise HTTPException(status_code=404, detail="User not found")
    
    if assign_data.group_id is not None:
        from app.models.group import Group
        group = db.query(Group).filter(Group.id == assign_data.group_id).first()
        if not group:
            raise HTTPException(status_code=404, detail="Group not found")

    vuln.assigned_user_id = assign_data.user_id
    vuln.assigned_group_id = assign_data.group_id
    db.commit()
    db.refresh(vuln)
    return _build_vuln_response(vuln)


@router.patch("/{vuln_id}/defer")
async def defer_vulnerability(
    vuln_id: int,
    defer_data: DeferRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Defers a vulnerability with a target date and reason"""
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(status_code=404, detail="Vulnerability not found")

    old_status = vuln.status
    vuln.status = VulnerabilityStatus.deferred
    vuln.deferred_until = defer_data.deferred_until
    vuln.defer_reason = defer_data.reason
    vuln.notification_suppressed = True
    db.commit()
    db.refresh(vuln)

    log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
    return _build_vuln_response(vuln)


@router.patch("/{vuln_id}/undefer")
async def undefer_vulnerability(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Removes deferral, resets to OPEN"""
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")

    old_status = vuln.status
    vuln.status = VulnerabilityStatus.open
    vuln.deferred_until = None
    vuln.defer_reason = None
    vuln.notification_suppressed = False
    db.commit()
    db.refresh(vuln)

    log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
    return _build_vuln_response(vuln)


@router.patch("/{vuln_id}/suppress-notifications")
async def toggle_notification_suppression(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """Toggles notification suppression for a vulnerability"""
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")

    vuln.notification_suppressed = not vuln.notification_suppressed
    db.commit()
    db.refresh(vuln)
    return _build_vuln_response(vuln)


class FalsePositiveRequest(BaseModel):
    reason: Optional[str] = None


@router.patch("/{vuln_id}/false-positive")
async def mark_false_positive(
    vuln_id: int,
    payload: FalsePositiveRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """
    Mark a vulnerability as false positive. Bookmark used after manual
    review (e.g. confirming a Nessus finding is a false alarm). Sets
    status=false_positive and suppresses further notifications.
    """
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(status_code=404, detail="Vulnerability not found")

    old_status = vuln.status
    vuln.status = VulnerabilityStatus.false_positive
    vuln.notification_suppressed = True
    if payload.reason:
        vuln.defer_reason = payload.reason  # reuse existing field for context
    db.commit()
    db.refresh(vuln)

    log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
    return _build_vuln_response(vuln)


@router.patch("/{vuln_id}/unmark-false-positive")
async def unmark_false_positive(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Revert a false-positive marking back to open."""
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(status_code=404, detail="Vulnerability not found")
    if vuln.status != VulnerabilityStatus.false_positive:
        raise HTTPException(status_code=400, detail="Not currently marked as false positive")

    old_status = vuln.status
    vuln.status = VulnerabilityStatus.open
    vuln.notification_suppressed = False
    vuln.defer_reason = None
    db.commit()
    db.refresh(vuln)

    log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
    return _build_vuln_response(vuln)


@router.post("/sync/wazuh")
def sync_vulnerabilities_from_wazuh(
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """
    Synchronizes vulnerabilities from Wazuh (synchronous)
    
    Executes sync immediately and returns statistics.
    """
    try:
        result = run_wazuh_vulnerability_sync(db)
        return result
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


def run_wazuh_vulnerability_sync(db: Session) -> dict:
    """Synchronous Wazuh vulnerability sync with statistics return"""
    
    # 1. Get Wazuh Config from DB (transparently decrypted)
    from app.auth.setting_crypto import read_setting_value
    raw_wazuh = read_setting_value(db, "wazuh_config")
    if not raw_wazuh:
        raise HTTPException(status_code=400, detail="Wazuh configuration not found. Please configure Wazuh in Settings.")

    try:
        config = json.loads(raw_wazuh)
        api_url = config.get("api_url")
        username = config.get("username")
        password = config.get("password")
        indexer_url = config.get("indexer_url")
        indexer_username = config.get("indexer_username")
        indexer_password = config.get("indexer_password")
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="Invalid Wazuh configuration format.")

    if not all([api_url, username, password]):
        raise HTTPException(status_code=400, detail="Incomplete Wazuh configuration. Please configure API URL, username and password.")

    wazuh = WazuhClient(
        base_url=api_url, 
        username=username, 
        password=password, 
        indexer_url=indexer_url,
        indexer_username=indexer_username,
        indexer_password=indexer_password,
        verify_ssl=config.get("verify_ssl", False)
    )

    stats = {
        "agents_synced": 0,
        "vulns_created": 0,
        "vulns_updated": 0,
        "vulns_patched": 0,
        "total_vulns_from_wazuh": 0
    }
    # Agents that came back empty, plus how many CVEs the run saw in total —
    # the pair that decides whether an empty answer is a clean host or an
    # unreachable API. Judged after the loop, not inside it.
    run_stats: dict = {}
    # IDs of vulns created during this sync — handed to the digest dispatcher
    # after the full sync completes (one summary email per recipient instead
    # of one email per CVE).
    newly_created_vuln_ids: list[int] = []

    try:
        # Hole alle aktiven Agents
        agents = wazuh.get_agents(status="active")
        
        for agent in agents:
            agent_id = agent["id"]

            # Hole oder erstelle Asset
            asset = db.query(Asset).filter(
                Asset.wazuh_agent_id == agent_id
            ).first()

            if not asset:
                continue  # Skip if asset not in DB

            # Refresh OS name/build from the agent record. Only the separate
            # ASSET sync did this before, so a host running just the vuln sync
            # + app scan kept a stale os_version — and scan_asset_os judged
            # Windows-OS CVEs against the OLD build forever (tester: Server
            # 2016 host on .9339 = the fix build, findings stuck open showing
            # installed .9140).
            os_info = agent.get("os") or {}
            if os_info.get("name"):
                asset.operating_system = os_info["name"]
            if os_info.get("version"):
                asset.os_version = os_info["version"]

            # Record a Scan-Jobs entry per agent so the manual "Sync
            # Data (Wazuh)" button shows up in /scans the same way
            # the Nessus sync does (commit 4bca41e). Closed out at
            # the bottom of the agent loop.
            from app.models.scan import Scan, ScanType, ScanStatus
            scan_row = Scan(
                asset_id=asset.id,
                scan_type=ScanType.WAZUH,
                status=ScanStatus.RUNNING,
                started_at=datetime.now(),
            )
            db.add(scan_row)

            # Get vulns from Wazuh
            wazuh_vulns = wazuh.get_vulnerabilities(agent_id)
            stats["total_vulns_from_wazuh"] += len(wazuh_vulns)
            
            # 1. Deduplicate Wazuh vulns by CVE ID for this asset
            unique_cves = {}
            for vuln_data in wazuh_vulns:
                cve_id = vuln_data.get("cve")
                if not cve_id:
                    continue
                
                if cve_id not in unique_cves:
                    unique_cves[cve_id] = {
                        "data": vuln_data,
                        "packages": set(),
                        "versions": set(),
                        "vendor": None,
                    }

                pkg_name = vuln_data.get("name")
                pkg_version = vuln_data.get("version")
                if pkg_name:
                    unique_cves[cve_id]["packages"].add(pkg_name)
                if pkg_version:
                    unique_cves[cve_id]["versions"].add(pkg_version)
                # First non-empty vendor for this CVE (one package is the norm;
                # gives Wazuh findings the same Vendor column as app-scan/Intune/
                # Defender — homogeneous display).
                if not unique_cves[cve_id]["vendor"]:
                    _v = (vuln_data.get("vendor") or "").strip()
                    if _v:
                        unique_cves[cve_id]["vendor"] = _v[:255]

            active_cves = set(unique_cves.keys())
            
            # 2. Process unique CVEs
            for cve_id, cve_info in unique_cves.items():
                vuln_data = cve_info["data"]
                
                # Merge package strings
                merged_packages = ", ".join(sorted(cve_info["packages"]))
                merged_versions = ", ".join(sorted(cve_info["versions"]))
                
                # Truncate to fit DB columns
                if len(merged_packages) > 250:
                    merged_packages = merged_packages[:247] + "..."
                if len(merged_versions) > 95:
                    merged_versions = merged_versions[:92] + "..."
                
                cvss_data = vuln_data.get("cvss", {})
                cvss3 = cvss_data.get("cvss3", {})
                score = cvss3.get("base_score")
                wazuh_severity = vuln_data.get("severity")

                # Check if exists
                existing = db.query(Vulnerability).filter(
                    Vulnerability.cve_id == cve_id,
                    Vulnerability.asset_id == asset.id
                ).first()
                
                if existing:
                    # `changed` tracks whether this run actually altered the
                    # row. Before, vulns_updated counted ONLY a CVSS-score
                    # change, so a sync that reopened findings and refreshed
                    # versions still reported "Updated: 0" (tester).
                    changed = False
                    # Track wazuh as a source; reopen if Wazuh sees it again
                    # after it was marked patched.
                    existing.add_source("wazuh")
                    from app.services.audit_events import reopen_if_patched
                    # Reopen (audited) when it was patched; otherwise force back to
                    # open unless an operator deliberately froze the status.
                    if reopen_if_patched(
                            db, existing,
                            reason="Wazuh reports this CVE on the host again",
                            source="wazuh_sync"):
                        stats["vulns_reopened"] = stats.get("vulns_reopened", 0) + 1
                        changed = True
                    elif existing.status not in (
                        VulnerabilityStatus.pending_verification,
                        VulnerabilityStatus.accepted_risk,
                        VulnerabilityStatus.false_positive,
                        VulnerabilityStatus.deferred,
                    ):
                        if existing.status != VulnerabilityStatus.open:
                            changed = True
                        existing.status = VulnerabilityStatus.open

                    # Update package info (merged)
                    if (existing.package_name != merged_packages
                            or existing.package_version != merged_versions):
                        changed = True
                    existing.package_name = merged_packages
                    existing.package_version = merged_versions
                    if cve_info.get("vendor") and not existing.package_vendor:
                        existing.package_vendor = cve_info["vendor"]
                        changed = True
                    # Backfill fixed_version when missing (older syncs
                    # didn't extract it; new syncs do).
                    fv = _sanitize_wazuh_fix(merged_packages, merged_versions,
                                             vuln_data.get("fixed_version"))
                    if fv and not existing.fixed_version:
                        existing.fixed_version = fv
                        changed = True
                    elif (existing.fixed_version and _sanitize_wazuh_fix(
                            merged_packages, merged_versions,
                            existing.fixed_version) is None):
                        # Scrub a stale cross-build-line fix stored by a
                        # pre-sanitize sync (e.g. 6.2.9200.x Server-2012 build
                        # left on a 10.0.26100 Server-2025 host). Fill-only
                        # backfill never corrected these. MSRC panel supplies
                        # the right per-branch KB.
                        existing.fixed_version = None
                        changed = True

                    # Update score/severity if changed
                    if score and existing.cvss_score != score:
                        existing.cvss_score = score
                        existing.severity = map_severity(score, wazuh_severity)
                        changed = True

                    if changed:
                        stats["vulns_updated"] += 1
                    else:
                        stats["vulns_confirmed"] = stats.get("vulns_confirmed", 0) + 1
                else:
                    # Create new — savepoint so IntegrityError (race duplicate)
                    # only rolls back this insert, not the whole sync.
                    try:
                        severity = map_severity(score, wazuh_severity)
                        vuln = Vulnerability(
                            cve_id=cve_id,
                            asset_id=asset.id,
                            cvss_score=score,
                            severity=severity,
                            status=VulnerabilityStatus.open,
                            title=vuln_data.get("title"),
                            package_name=merged_packages,
                            package_version=merged_versions,
                            package_vendor=cve_info.get("vendor"),
                            fixed_version=_sanitize_wazuh_fix(
                                merged_packages, merged_versions,
                                vuln_data.get("fixed_version")),
                            detected_at=datetime.now(),
                            sources='["wazuh"]',
                            first_detected_by="wazuh",
                        )
                        with db.begin_nested():
                            db.add(vuln)
                            db.flush()
                        stats["vulns_created"] += 1
                        newly_created_vuln_ids.append(vuln.id)

                    except IntegrityError:
                        # Savepoint rolled back; outer transaction intact.
                        logger.debug(f"Skipping duplicate CVE {cve_id} for asset {asset.id}")
                        continue

            # Source-aware backfill: drop "wazuh" from vulns Wazuh no longer
            # reports. Only mark patched when ALL sources are gone so Nessus-
            # only findings are not accidentally closed here.
            #
            # Safety: when Wazuh returns 0 vulns for an agent (transient API
            # glitch, indexer warming up after a manager restart, etc.) the
            # naive backfill below would mark every existing finding patched.
            # Skip in that case — a real "everything fixed" state is
            # vanishingly rare and not worth the risk of wiping the inventory.
            if not active_cves and not wazuh_vulns:
                # Deferred, not skipped: whether this emptiness is real can
                # only be judged once the run is over — see
                # reconcile_empty_agents(). This is the collective sync behind
                # the "Sync Data" button, which is the one an operator reaches
                # for after patching a host (tester: a Windows host Wazuh
                # reported clean kept all 348 OS findings open).
                run_stats.setdefault("empty_agents", []).append(asset.id)
                logger.warning(
                    "Wazuh returned 0 vulns for asset %s — deferring backfill "
                    "to avoid mass-patching from a transient empty response.",
                    asset.hostname,
                )
                stats["agents_synced"] += 1
                scan_row.status = ScanStatus.COMPLETED
                scan_row.completed_at = datetime.now()
                scan_row.vulnerabilities_found = 0
                scan_row.error_message = "Wazuh returned 0 vulns (skipped backfill)"
                continue
            stale_wazuh = (
                db.query(Vulnerability)
                .filter(
                    Vulnerability.asset_id == asset.id,
                    Vulnerability.status == VulnerabilityStatus.open,
                    Vulnerability.sources.contains('"wazuh"'),
                )
                .all()
            )
            for v in stale_wazuh:
                if v.cve_id in active_cves:
                    continue
                v.remove_source("wazuh")
                if not v.source_list:
                    old_st = v.status
                    v.status = VulnerabilityStatus.patched
                    v.patched_at = datetime.now()
                    stats["vulns_patched"] += 1
                    try:
                        log_vulnerability_change(
                            db, None, v.id, old_st, v.status,
                            reason=f"Wazuh full sync: agent {agent_id} no longer reports this CVE on {asset.hostname}",
                            cve_id=v.cve_id,
                            source="wazuh_sync",
                        )
                    except Exception as e:
                        logger.warning("audit log for wazuh full-sync auto-patch failed (vuln_id=%s): %s", v.id, e)

            stats["agents_synced"] += 1
            # Close out the per-agent Scan row so the Scan-Jobs page
            # shows the manual sync run alongside Nessus runs.
            scan_row.status = ScanStatus.COMPLETED
            scan_row.completed_at = datetime.now()
            scan_row.vulnerabilities_found = len(active_cves)

        db.commit()
        wazuh.close()

        # Revisionssicher: initial VULNERABILITY_DETECTED audit event per
        # new finding (audit trail no longer starts at the first status
        # change only).
        if newly_created_vuln_ids:
            try:
                from app.services.audit_events import audit_new_vulnerabilities
                audit_new_vulnerabilities(db, newly_created_vuln_ids, source="wazuh")
                db.commit()
            except Exception as e:
                logger.warning(f"Wazuh sync: detected-audit failed (non-fatal): {e}")

        # Best-effort enrichment of newly created vulns (by ID, not a
        # broad scan of all un-enriched rows which could be very slow).
        if newly_created_vuln_ids:
            try:
                new_vulns = db.query(Vulnerability).filter(
                    Vulnerability.id.in_(newly_created_vuln_ids)
                ).all()
                if new_vulns:
                    # Skip NVD date backfill here — keeps the sync fast;
                    # the nightly enrichment job dates these rows.
                    enrich_stats = enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
                    stats["enrichment"] = enrich_stats
            except Exception as e:
                logger.warning(f"Wazuh sync: enrichment of new vulns failed: {e}")

        # Dispatch ONE digest email per recipient containing all new vulns
        # above the configured severity threshold. Replaces the per-CVE
        # inline send that used to spam SMTP at the rate of 1 mail per CVE
        # per recipient. Mode override via setting `notification_mode`.
        if newly_created_vuln_ids:
            try:
                from app.services.email_service import dispatch_new_vuln_notifications
                fresh_vulns = db.query(Vulnerability).filter(
                    Vulnerability.id.in_(newly_created_vuln_ids)
                ).all()
                notif_stats = dispatch_new_vuln_notifications(db, fresh_vulns)
                stats["notifications"] = notif_stats
                logger.info(
                    f"Wazuh sync notifications: {notif_stats.get('emails_sent', 0)} sent, "
                    f"{notif_stats.get('emails_failed', 0)} failed, "
                    f"{notif_stats.get('recipients', 0)} recipients"
                )
            except Exception as e:
                logger.warning(f"Wazuh sync: new-vuln notification dispatch failed: {e}")

        # Now the run is over, so the deferred agents can be judged.
        try:
            run_stats["cves_seen"] = stats.get("total_vulns_from_wazuh", 0)
            closed = reconcile_empty_agents(db, run_stats)
            if closed:
                stats["vulns_patched"] = stats.get("vulns_patched", 0) + closed
        except Exception as e:
            logger.error("Wazuh sync: empty-agent reconcile failed: %s", e)

        return {
            "message": (
                f"Synced {stats['agents_synced']} agents, "
                f"{stats.get('total_vulns_from_wazuh', 0)} CVE reports. "
                f"Created: {stats['vulns_created']}, "
                f"Updated: {stats['vulns_updated']}, "
                f"Reopened: {stats.get('vulns_reopened', 0)}, "
                f"Confirmed: {stats.get('vulns_confirmed', 0)}, "
                f"Patched: {stats['vulns_patched']}"
            ),
            **stats
        }
        
    except Exception as e:
        db.rollback()
        wazuh.close()
        logger.error(f"Wazuh sync failed: {e}")
        raise HTTPException(status_code=500, detail="Wazuh sync failed. Check server logs for details.")


async def sync_wazuh_vulnerabilities(db_session: Optional[Session] = None):
    """Background-Task for Wazuh-Sync (for Scheduler)"""
    from app.database import SessionLocal

    db = db_session if db_session is not None else SessionLocal()
    try:
        result = run_wazuh_vulnerability_sync(db)
        logger.info(f"Scheduled Sync: {result['message']}")
    except Exception as e:
        logger.error(f"Scheduled Sync Error: {e}")
    finally:
        if db_session is None:
            db.close()


def reconcile_empty_agents(db: Session, run_stats: dict) -> int:
    """Close Wazuh findings on agents that returned NO CVEs this run.

    Deferred out of the per-agent sync because the decision needs the whole
    run: an empty answer is only trustworthy once some OTHER agent has proven
    the API is answering. If every agent came back empty, that is an outage
    pattern, not an estate that patched itself overnight — leave it alone.
    """
    from app.models.vulnerability import Vulnerability, VulnerabilityStatus
    empty = run_stats.get("empty_agents") or []
    if not empty:
        return 0
    if not run_stats.get("cves_seen"):
        logger.warning("Wazuh sync: every agent returned 0 CVEs — treating as an "
                       "outage, not closing anything (%d agents affected)", len(empty))
        return 0
    rows = (db.query(Vulnerability)
              .filter(Vulnerability.asset_id.in_(empty),
                      Vulnerability.status == VulnerabilityStatus.open,
                      Vulnerability.sources.contains('"wazuh"'))
              .all())
    closed = 0
    for v in rows:
        v.remove_source("wazuh")
        if v.source_list:
            continue  # another scanner still reports it — not ours to close
        old_st = v.status
        v.status = VulnerabilityStatus.patched
        v.patched_at = datetime.now()
        closed += 1
        try:
            log_vulnerability_change(
                db, None, v.id, old_st, v.status,
                reason="Wazuh no longer reports any CVE on this host",
                cve_id=v.cve_id, source="wazuh_sync")
        except Exception as e:
            logger.warning("audit log for empty-agent patch failed (vuln_id=%s): %s", v.id, e)
    db.commit()
    logger.info("Wazuh sync: %d finding(s) closed on %d agent(s) that reported no CVEs",
                closed, len(empty))
    return closed


def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str,
                               asset: Asset, run_stats: Optional[dict] = None):
    """Sync vulnerabilities for a single agent from Wazuh.

    `run_stats` collects what only the whole run knows: how many CVEs the API
    returned overall, and which agents came back empty. Pass it to let
    reconcile_empty_agents() close findings on hosts that are genuinely clean;
    omit it and an empty answer is treated as untrustworthy, as before."""
    from app.models.vulnerability import Vulnerability, VulnerabilityStatus
    from app.models.vulnerability_package import VulnerabilityPackage
    from datetime import datetime

    logger.info(f"Sync: Fetching vulnerabilities for agent {agent_id} ({asset.hostname})...")
    raw_vulns = wazuh.get_vulnerabilities(agent_id)
    logger.info(f"Sync: Agent {agent_id} has {len(raw_vulns)} raw vulnerability entries.")
    if run_stats is not None:
        run_stats["cves_seen"] = run_stats.get("cves_seen", 0) + len(raw_vulns)

    # 1. Deduplicate by CVE ID + collect per-package detail.
    # `pkg_rows` is a dict keyed by package_name so we keep one row per
    # (cve, package) pair across the agent. fixed_version comes straight
    # from Wazuh (often empty — backfill via override service).
    unique_cves: dict = {}
    for vuln_data in raw_vulns:
        cve_id = vuln_data.get("cve")
        if not cve_id:
            continue

        if cve_id not in unique_cves:
            unique_cves[cve_id] = {
                "data": vuln_data,
                "pkg_rows": {},          # name → {version, fixed}
                "packages": set(),       # legacy summary
                "versions": set(),       # legacy summary
            }

        pkg_name = vuln_data.get("name")
        pkg_version = vuln_data.get("version")
        pkg_fix = vuln_data.get("fixed_version")
        pkg_fix = _sanitize_wazuh_fix(pkg_name, pkg_version, pkg_fix)
        if pkg_name:
            unique_cves[cve_id]["packages"].add(pkg_name)
            existing_pkg = unique_cves[cve_id]["pkg_rows"].get(pkg_name)
            if existing_pkg is None:
                unique_cves[cve_id]["pkg_rows"][pkg_name] = {
                    "version": pkg_version,
                    "fixed": pkg_fix,
                }
            else:
                # Prefer first non-empty fix
                if pkg_fix and not existing_pkg.get("fixed"):
                    existing_pkg["fixed"] = pkg_fix
        if pkg_version:
            unique_cves[cve_id]["versions"].add(pkg_version)

    active_cves = set(unique_cves.keys())
    logger.info(f"Sync: Agent {agent_id} has {len(active_cves)} unique CVEs after dedup.")

    # Log severity breakdown from Wazuh data
    severity_counts = {}
    for cve_id_tmp, cve_info_tmp in unique_cves.items():
        sev = (cve_info_tmp["data"].get("severity") or "unknown").lower()
        severity_counts[sev] = severity_counts.get(sev, 0) + 1
    logger.info(f"Sync: Agent {agent_id} Wazuh severity breakdown: {severity_counts}")

    new_count = 0
    updated_count = 0
    # Track new vuln IDs so we can send a single digest mail per recipient
    # at the end of this agent sync instead of one mail per CVE.
    new_vuln_ids: list[int] = []

    def _upsert_packages(vuln_id: int, rows: dict, sync_time: datetime) -> None:
        """Upsert one VulnerabilityPackage row per (vuln, package).
        Updates last_seen_at for existing entries so the next stale-
        package pass can prune ones Wazuh stopped reporting."""
        if not rows:
            return
        existing_pkgs = {
            p.package_name: p
            for p in db.query(VulnerabilityPackage)
                       .filter(VulnerabilityPackage.vulnerability_id == vuln_id)
                       .all()
        }
        for pkg_name, info in rows.items():
            row = existing_pkgs.get(pkg_name)
            if row is None:
                row = VulnerabilityPackage(
                    vulnerability_id=vuln_id,
                    package_name=pkg_name[:255],
                    package_version=(info.get("version") or None),
                    fixed_version=(info.get("fixed") or None),
                    source="wazuh",
                    first_detected_at=sync_time,
                    last_seen_at=sync_time,
                )
                db.add(row)
            else:
                row.last_seen_at = sync_time
                # Only fill fixed_version when empty (override service
                # may have written a better value from Vulnrichment).
                if info.get("fixed") and not row.fixed_version:
                    row.fixed_version = info["fixed"][:100]
                if info.get("version") and info["version"] != row.package_version:
                    row.package_version = info["version"][:100]
                if not row.source:
                    row.source = "wazuh"

    # 2. Process each unique CVE
    for cve_id, cve_info in unique_cves.items():
        vuln_data = cve_info["data"]

        # Merge package strings (legacy parent-row summary)
        merged_packages = ", ".join(sorted(cve_info["packages"]))
        merged_versions = ", ".join(sorted(cve_info["versions"]))
        
        # Truncate to fit DB columns
        if len(merged_packages) > 250:
            merged_packages = merged_packages[:247] + "..."
        if len(merged_versions) > 95:
            merged_versions = merged_versions[:92] + "..."
        
        score = vuln_data.get("cvss", {}).get("cvss3", {}).get("base_score")
        wazuh_severity = vuln_data.get("severity")

        # Check if already exists
        existing = db.query(Vulnerability).filter(
            Vulnerability.cve_id == cve_id,
            Vulnerability.asset_id == asset.id
        ).first()

        if existing:
            # Update existing entry — track wazuh as a source and reopen
            # if Wazuh sees a previously-patched vuln again.
            existing.add_source("wazuh")
            from app.services.audit_events import reopen_if_patched
            # Reopen (audited) when it was patched; otherwise force back to
            # open unless an operator deliberately froze the status.
            if not reopen_if_patched(
                    db, existing,
                    reason="Wazuh reports this CVE on the host again",
                    source="wazuh_sync") and existing.status not in (
                VulnerabilityStatus.pending_verification,
                VulnerabilityStatus.accepted_risk,
                VulnerabilityStatus.false_positive,
                VulnerabilityStatus.deferred,
            ):
                existing.status = VulnerabilityStatus.open

            existing.package_name = merged_packages
            existing.package_version = merged_versions
            fv = vuln_data.get("fixed_version")
            if fv and not existing.fixed_version:
                existing.fixed_version = fv
            if score:
                existing.cvss_score = score
            existing.severity = map_severity(score, wazuh_severity)
            _upsert_packages(existing.id, cve_info["pkg_rows"], datetime.now())
            existing.refresh_scores()
            updated_count += 1
        else:
            # Create new — use savepoint so an IntegrityError (duplicate race)
            # only rolls back this single insert, not the entire sync transaction.
            try:
                severity = map_severity(score, wazuh_severity)
                vuln = Vulnerability(
                    cve_id=cve_id,
                    asset_id=asset.id,
                    cvss_score=score,
                    severity=severity,
                    status=VulnerabilityStatus.open,
                    title=vuln_data.get("title"),
                    package_name=merged_packages,
                    package_version=merged_versions,
                    fixed_version=vuln_data.get("fixed_version"),
                    detected_at=datetime.now(),
                    sources='["wazuh"]',
                    first_detected_by="wazuh",
                )
                with db.begin_nested():
                    db.add(vuln)
                    db.flush()
                _upsert_packages(vuln.id, cve_info["pkg_rows"], datetime.now())
                # Inherit canonical CVE metadata from existing siblings
                # on other assets so the new row doesn't keep Wazuh's
                # placeholder 10.0 when an override-pinned value exists.
                try:
                    from app.services.vuln_override_service import apply_canonical_from_siblings
                    apply_canonical_from_siblings(db, vuln)
                except Exception as e:
                    logger.warning("canonical-inherit on new wazuh vuln %s failed: %s", vuln.cve_id, e)
                vuln.refresh_scores()
                new_count += 1
                new_vuln_ids.append(vuln.id)
                logger.info(f"Sync: New vulnerability {cve_id} on {asset.hostname} - severity={severity.value}, cvss={score}, wazuh_severity={wazuh_severity}")

            except IntegrityError:
                # Savepoint rolled back; outer transaction intact.
                logger.debug(f"Skipping duplicate CVE {cve_id} for asset {asset.id}")
                continue

    # 3. Source-aware backfill: remove "wazuh" from sources for any open vuln
    # on this asset that Wazuh no longer reports.  Only mark as PATCHED when
    # ALL sources are gone — Nessus-only findings must not be patched here.
    #
    # Safety: when Wazuh returns 0 vulns for an agent (transient API outage,
    # indexer warm-up after a manager restart, etc.) the loop below would
    # mass-patch the entire history for the agent. Skip in that case —
    # genuinely "everything fixed" is so rare that the false-positive risk
    # is not worth taking.
    if not active_cves and not raw_vulns:
        # An empty answer means one of two opposite things — the host really is
        # clean, or Wazuh could not answer — and this function cannot tell them
        # apart on its own. Hand it to the caller: a run in which OTHER agents
        # returned CVEs proves the API is healthy, so the emptiness is real.
        # Without that, a fully patched host kept every finding it ever had
        # (tester: Wazuh showed 0 CVEs for ise-dc01 while 348 sat open here).
        if run_stats is not None:
            run_stats.setdefault("empty_agents", []).append(asset.id)
        logger.warning(
            "Wazuh returned 0 vulns for agent %s (%s) — deferring the backfill "
            "to avoid mass-patching from a transient empty response.",
            agent_id, asset.hostname,
        )
        asset.last_scan = datetime.now()
        db.commit()
        return new_count, updated_count, new_vuln_ids
    stale_wazuh = (
        db.query(Vulnerability)
        .filter(
            Vulnerability.asset_id == asset.id,
            Vulnerability.status == VulnerabilityStatus.open,
            Vulnerability.sources.contains('"wazuh"'),
        )
        .all()
    )
    for v in stale_wazuh:
        if v.cve_id in active_cves:
            continue  # still seen by Wazuh — keep source
        v.remove_source("wazuh")
        if not v.source_list:
            old_st = v.status
            v.status = VulnerabilityStatus.patched
            v.patched_at = datetime.now()
            try:
                log_vulnerability_change(
                    db, None, v.id, old_st, v.status,
                    reason=f"Wazuh agent {agent_id} no longer reports this CVE on {asset.hostname}",
                    cve_id=v.cve_id,
                    source="wazuh_sync",
                )
            except Exception as e:
                logger.warning("audit log for wazuh auto-patch failed (vuln_id=%s): %s", v.id, e)

    # Update asset last_scan
    asset.last_scan = datetime.now()
    db.commit()
    logger.info(f"Sync: Completed for agent {agent_id} ({asset.hostname}): {new_count} new, {updated_count} updated, {len(active_cves)} total active CVEs")

    # Revisionssicher: initial VULNERABILITY_DETECTED audit event per new
    # finding. Written here (not in the callers) so the scheduler loop and
    # the per-asset rescan endpoint are both covered.
    if new_vuln_ids:
        try:
            from app.services.audit_events import audit_new_vulnerabilities
            audit_new_vulnerabilities(db, new_vuln_ids, source="wazuh")
            db.commit()
        except Exception as e:
            logger.warning(f"Sync: detected-audit failed for agent {agent_id} (non-fatal): {e}")

    # 4. Enrich newly created vulns with EPSS + KEV (best-effort, non-fatal)
    if new_count > 0:
        try:
            new_vulns = db.query(Vulnerability).filter(
                Vulnerability.asset_id == asset.id,
                Vulnerability.cve_id.in_(active_cves),
                Vulnerability.enrichment_updated_at.is_(None)
            ).all()
            if new_vulns:
                # NVD date backfill deferred to the nightly job (rate-limited).
                enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
        except Exception as e:
            logger.warning(f"Sync: Enrichment skipped for agent {agent_id}: {e}")

    # 5. Dispatch ONE digest mail per recipient for this agent's newly
    # discovered vulns (replaces per-CVE inline send). When this helper
    # is called from a multi-agent loop the caller may prefer to skip
    # per-agent dispatch and instead aggregate IDs and send a single
    # cross-agent digest at the very end — currently each per-agent call
    # produces its own digest.
    if new_vuln_ids:
        try:
            from app.services.email_service import dispatch_new_vuln_notifications
            fresh_vulns = db.query(Vulnerability).filter(
                Vulnerability.id.in_(new_vuln_ids)
            ).all()
            dispatch_new_vuln_notifications(db, fresh_vulns)
        except Exception as e:
            logger.warning(f"Sync: notification dispatch failed for agent {agent_id}: {e}")



def map_severity(cvss_score: Optional[float], wazuh_severity: Optional[str] = None) -> VulnerabilitySeverity:
    """Mappt CVSS-Score zu Severity-Enum, mit Wazuh-Severity als Fallback"""
    if cvss_score and cvss_score > 0:
        if cvss_score >= 9.0:
            return VulnerabilitySeverity.critical
        elif cvss_score >= 7.0:
            return VulnerabilitySeverity.high
        elif cvss_score >= 4.0:
            return VulnerabilitySeverity.medium
        else:
            return VulnerabilitySeverity.low

    # Fallback: Wazuh's eigene Severity-Einstufung verwenden
    if wazuh_severity:
        severity_lower = wazuh_severity.strip().lower()
        severity_map = {
            "critical": VulnerabilitySeverity.critical,
            "high": VulnerabilitySeverity.high,
            "medium": VulnerabilitySeverity.medium,
            "low": VulnerabilitySeverity.low,
        }
        mapped = severity_map.get(severity_lower)
        if mapped:
            logger.info(f"No CVSS score available, using Wazuh severity fallback: {wazuh_severity} -> {mapped.value}")
            return mapped

    return VulnerabilitySeverity.none


# ============================================
# CVSS Override Endpoints (CISA Vulnrichment)
# ============================================

class OverrideDryRunResponse(BaseModel):
    """Response for dry-run mode"""
    message: str
    would_be_updated: int
    checked: int
    placeholder_scores_found: int
    changes_preview: List[dict]


class OverrideStatsResponse(BaseModel):
    """Response after override execution"""
    message: str
    updated: int
    checked: int
    errors: int
    changes: List[dict]


@router.get("/override/check")
async def check_incorrect_scores(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    Findet alle Vulnerabilities mit wahrscheinlich falschen Scores.
    
    Erkennt:
    - Platzhalter-Scores (10.0) von Wazuh
    - Unplausible Kombinationen (CVSS 10 + LOW Severity)
    
    Gibt eine Liste mit Empfehlungen zurück.
    """
    service = VulnOverrideService(db)
    incorrect = service.find_incorrect_scores()
    
    return {
        "total_found": len(incorrect),
        "placeholder_scores": len([i for i in incorrect if i.get("issue") == "placeholder_score_10"]),
        "severity_mismatches": len([i for i in incorrect if i.get("issue") == "severity_mismatch"]),
        "details": incorrect
    }


@router.post("/override/nessus/{asset_id}")
def override_from_nessus(
    asset_id: int,
    scan_id: Optional[int] = None,
    dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """
    Korrigiert CVSS-Scores und Severity-Werte basierend auf Nessus-Daten.
    
    Verwendet Nessus Plugin Output für verifizierte Scores.
    
    - **asset_id**: Asset-DB-ID
    - **scan_id**: Nessus Scan-ID (optional, wird aus Config ermittelt)
    - **dry_run**: Wenn True, nur simulieren ohne Änderungen
    """
    # Load Nessus config (transparently decrypted)
    from app.auth.setting_crypto import read_setting_value
    raw_nessus = read_setting_value(db, SETTING_KEY)
    if not raw_nessus:
        raise HTTPException(
            status_code=400,
            detail="Nessus configuration not set. Please configure Nessus in Settings."
        )

    try:
        config = json.loads(raw_nessus)
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="Invalid Nessus configuration format.")
    
    from app.integrations.nessus_client import NessusClient
    
    try:
        nessus_client = NessusClient(
            base_url=config["base_url"],
            access_key=config["access_key"],
            secret_key=config["secret_key"],
            verify_ssl=bool(config.get("verify_ssl", True)),
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    
    service = VulnOverrideService(db)
    
    try:
        # Load verified data from Nessus
        if scan_id:
            verified_data = service.load_nessus_verified_data(asset_id, scan_id, nessus_client)
        else:
            # Use latest scan for this asset
            from app.models.asset import Asset
            asset = db.query(Asset).filter(Asset.id == asset_id).first()
            if not asset:
                raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
            
            # Get recent scan IDs
            scans = nessus_client.list_scans()
            if not scans:
                raise HTTPException(status_code=404, detail="No Nessus scans found")
            
            # Find scan for this asset's IP
            target_scan_id = None
            for scan in scans:
                targets = scan.get("targets", "")
                if asset.ip_address and asset.ip_address in targets:
                    target_scan_id = int(scan["id"])
                    break
            
            if not target_scan_id:
                target_scan_id = int(scans[0]["id"])  # Fallback to first scan
            
            verified_data = service.load_nessus_verified_data(asset_id, target_scan_id, nessus_client)
        
        # Apply overrides
        if not verified_data:
            return {
                "message": "No verified CVE data found in Nessus for this asset",
                "updated": 0,
                "checked": 0
            }
        
        stats = service.apply_overrides(asset_id, verified_data, dry_run=dry_run)
        
        # Audit log
        if not dry_run and stats["updated"] > 0:
            audit_log = AuditLog(
                user_id=current_user.id,
                event_type=AuditEventType.VULNERABILITY_UPDATED,
                event_description=f"CVSS override applied from Nessus for {stats['updated']} vulnerabilities",
                resource_type="vulnerability",
                resource_id=f"asset:{asset_id}",
                timestamp=datetime.now()
            )
            db.add(audit_log)
            db.commit()
        
        return {
            "message": "Dry run - no changes applied" if dry_run else "Override applied successfully",
            **stats
        }
        
    finally:
        nessus_client.close()


@router.post("/override/vulnrichment")
# Sync def → worker threadpool; the cvelistV5/Vulnrichment cascade (incl.
# ZIP download) runs off the event loop so the web GUI stays responsive.
def override_from_vulnrichment(
    cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
    asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs zum Korrigieren (leer = alle)"),
    dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor)
):
    """
    Korrigiert CVSS-Scores und Severity-Werte basierend auf CISA Vulnrichment.
    
    Verwendet den offiziellen CISA Vulnrichment JSON Feed für verifizierte
    CVSSv3.1 Base Scores und SSVC-Exploitation-Status.
    
    - **cve_ids**: Optionale CVE-Filterliste (leer = alle offenen CVEs)
    - **asset_ids**: Optionale Asset-Filterliste (leer = alle Assets)
    - **dry_run**: Wenn True, nur simulieren ohne Änderungen
    
    **Beispiel für CVE-2026-8390:**
    - Wazuh (falsch): CVSS 10.0, Severity CRITICAL
    - Vulnrichment (korrekt): CVSS 7.3, Severity HIGH, Exploitation "none"
    """
    result = correct_vulnerability_scores(
        db,
        cve_ids=cve_ids,
        asset_ids=asset_ids,
        dry_run=dry_run
    )
    
    # Audit log
    if not dry_run and result.get("updated", 0) > 0:
        audit_log = AuditLog(
            user_id=current_user.id,
            event_type=AuditEventType.VULNERABILITY_UPDATED,
            event_description=f"CVSS override from CISA Vulnrichment: {result['updated']} vulnerabilities corrected",
            resource_type="vulnerability",
            resource_id="bulk:vulnrichment",
            old_value=json.dumps({"method": "cisa_vulnrichment"}),
            new_value=json.dumps({"updated": result["updated"], "changes": result.get("changes", [])[:5]}),  # Limit to 5 for audit
            timestamp=datetime.now()
        )
        db.add(audit_log)
        db.commit()
    
    return result


@router.post("/override/vulnrichment/start")
async def start_vulnrichment_correction_job(
    cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
    asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"),
    dry_run: bool = Query(False, description="Nur simulieren ohne DB-Änderungen"),
    current_user: User = Depends(RequireEditor),
):
    """
    Async-Variante des Vulnrichment-Overrides.

    Spawnt einen Background-Thread und liefert sofort eine ``job_id``
    zurück. Fortschritt + Endergebnis via
    ``GET /override/vulnrichment/status/{job_id}``.

    Für Bulk-Korrekturen (Frontend "Correct CVSS" Button) verwenden —
    der synchrone Endpoint blockiert die Browser-Verbindung und löst
    bei großen Datenbanken den 23-Min-Hänger des Frontends aus.
    """
    job_id = start_vulnrichment_job(
        user_id=current_user.id,
        cve_ids=cve_ids,
        asset_ids=asset_ids,
        dry_run=dry_run,
    )
    return {
        "job_id": job_id,
        "status": "queued",
        "poll_url": f"/api/v1/vulnerabilities/override/vulnrichment/status/{job_id}",
    }


@router.get("/override/vulnrichment/status/{job_id}")
async def get_vulnrichment_correction_status(
    job_id: str,
    current_user: User = Depends(get_current_user),
):
    """
    Snapshot eines mit ``/override/vulnrichment/start`` gestarteten Jobs.

    Felder:
    - ``status``: queued | running | completed | failed
    - ``stage``: human-readable progress label (z.B. "downloading ZIP")
    - ``total`` / ``done``: numerische Progress-Hinweise
    - ``updated`` / ``checked`` / ``not_found``: End-Stats wenn fertig
    - ``error``: gesetzt bei status == "failed"
    """
    job = get_override_job(job_id)
    if not job:
        raise HTTPException(
            status_code=404,
            detail=f"Job {job_id} not found (may have expired after backend restart)",
        )
    return job


@router.get("/override/vulnrichment/jobs")
async def list_vulnrichment_correction_jobs(
    limit: int = Query(10, le=50),
    current_user: User = Depends(get_current_user),
):
    """Most-recent-first list of override jobs (for an admin overview)."""
    return list_override_jobs(limit=limit)


@router.post("/exploit-intel/refresh")
# Sync def → FastAPI runs it in a worker threadpool, off the event loop,
# so the catalog downloads don't freeze the web GUI.
def refresh_exploit_intel(
    only_open: bool = Query(True, description="Limit to status=open"),
    fetch_pocs: bool = Query(True, description="Also fetch PoC-in-GitHub (slow)"),
    fetch_msf: bool = Query(True, description="Also fetch Metasploit modules"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Refresh exploit-intel columns from Exploit-DB / PoC-in-GitHub /
    Metasploit. Sources cached on disk 24h."""
    from app.services.exploit_intel_service import refresh_all_exploit_intel
    stats = refresh_all_exploit_intel(
        db, only_open=only_open, fetch_pocs=fetch_pocs, fetch_msf=fetch_msf,
    )
    return stats


# Background EOL job. The synchronous endpoint below still exists (API
# clients, small installs), but the GUI uses start/status: a full EOL check
# walks every asset's package list against endoflife.date + the MS lifecycle
# export and can outlive the browser's patience (tester got "Backend
# connection failed" while the backend was still working).
_EOL_JOB: dict = {"running": False, "stage": None, "done": 0, "total": 0,
                  "result": None, "error": None, "finished_at": None}


def _eol_job_runner(asset_id: Optional[int], user):
    """Run the EOL check on its own DB session, recording progress."""
    import time as _time
    from app.database import SessionLocal
    db = SessionLocal()
    try:
        stats = run_eol_check(asset_id=asset_id, db=db, current_user=user)
        _EOL_JOB["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
        _EOL_JOB["error"] = None
        _EOL_JOB["stage"] = "completed"
    except Exception as e:
        logger.exception("EOL background job failed")
        _EOL_JOB["result"] = None
        _EOL_JOB["error"] = str(e)
        _EOL_JOB["stage"] = "failed"
    finally:
        db.close()
        _EOL_JOB["running"] = False
        _EOL_JOB["finished_at"] = _time.time()


@router.post("/eol-check/start", status_code=202)
def start_eol_check_job(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
    current_user: User = Depends(RequireEditor),
):
    """Start the EOL check in the background; poll /eol-check/status."""
    import threading
    if _EOL_JOB["running"]:
        return {"started": False, "message": "An EOL check is already running",
                "poll_url": "/api/v1/vulnerabilities/eol-check/status"}
    _EOL_JOB.update({"running": True, "stage": "starting", "done": 0, "total": 0,
                     "result": None, "error": None, "finished_at": None})
    threading.Thread(target=_eol_job_runner, args=(asset_id, current_user),
                     daemon=True).start()
    return {"started": True, "poll_url": "/api/v1/vulnerabilities/eol-check/status"}


@router.get("/eol-check/status")
def get_eol_check_status(current_user: User = Depends(get_current_user)):
    """Snapshot of the background EOL check (running / progress / result)."""
    s = _EOL_JOB
    return {
        "running": s["running"],
        "stage": s["stage"],
        "done": s["done"],
        "total": s["total"],
        "result": s["result"],
        "error": s["error"],
        "finished_at": s["finished_at"],
        "status": ("running" if s["running"]
                   else "failed" if s["error"]
                   else "completed" if s["result"] is not None else "idle"),
    }


@router.post("/eol-check")
# Sync def → runs in a worker threadpool (off the event loop) so the
# per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI.
def run_eol_check(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Trigger endoflife.date EOL detection per installed package.

    Closes the gap Wazuh has vs Nessus plugin 64784 (Microsoft SQL
    Server Unsupported Version Detection): walks each installed
    package from syscollector, resolves the product slug, and creates
    one pseudo-CVE (cve_id starts with `EOL-`) per EOL or out-of-
    active-support release on the asset.

    Idempotent — re-runs upsert existing EOL rows rather than
    duplicate them.
    """
    from app.integrations.wazuh_client import WazuhClient
    from app.auth.setting_crypto import read_setting_value
    from app.services import eol_service

    # Use read_setting_value (handles Fernet-encrypted values) +
    # WazuhClient(base_url=, ...) signature to match the other Wazuh
    # call sites (sync, rescan). Raw json.loads broke when the setting
    # row was encrypted.
    raw_wazuh = read_setting_value(db, "wazuh_config")
    if not raw_wazuh:
        raise HTTPException(400, "Wazuh is not configured (settings.wazuh_config missing).")
    try:
        wazuh_config = json.loads(raw_wazuh)
    except json.JSONDecodeError:
        raise HTTPException(400, "Invalid Wazuh configuration format.")
    api_url = wazuh_config.get("api_url")
    username = wazuh_config.get("username")
    password = wazuh_config.get("password")
    if not all([api_url, username, password]):
        raise HTTPException(400, "Incomplete Wazuh configuration (api_url/username/password missing).")
    wazuh = WazuhClient(
        base_url=api_url,
        username=username,
        password=password,
        indexer_url=wazuh_config.get("indexer_url"),
        indexer_username=wazuh_config.get("indexer_username"),
        indexer_password=wazuh_config.get("indexer_password"),
        # Default False matches the sync/rescan call sites — self-
        # signed Wazuh certs are the norm in lab/SMB deploys.
        verify_ssl=wazuh_config.get("verify_ssl", False),
    )

    q = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None))
    if asset_id is not None:
        q = q.filter(Asset.id == asset_id)
    assets = q.all()
    if not assets:
        return {"assets_scanned": 0, "eol_findings_total": 0}

    stats = {
        "assets_scanned": 0,
        "packages_checked": 0,
        "products_unmapped": 0,
        "eol_findings_total": 0,
        "eol_findings_new": 0,
        "errors": [],
    }
    stats["os_eol_findings"] = 0
    _EOL_JOB["total"] = len(assets)
    _EOL_JOB["done"] = 0
    _EOL_JOB["stage"] = f"scanning {len(assets)} asset(s)"
    for asset in assets:
        _EOL_JOB["done"] += 1
        _EOL_JOB["stage"] = f"asset {_EOL_JOB['done']}/{_EOL_JOB['total']}: {asset.hostname or asset.id}"
        # OS-level EOL — independent of package fetch so even an asset
        # whose syscollector package list errors still gets its OS
        # checked (Windows Server 2008 R2 etc.).
        try:
            os_status = eol_service.check_os_eol(
                db, asset.operating_system or "", asset.os_version or ""
            )
            if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
                os_label = (asset.operating_system or "Operating System").strip()
                _, os_created = eol_service.upsert_eol_vulnerability(
                    db,
                    asset_id=asset.id,
                    product_name=os_label,
                    installed_version=(asset.os_version or os_status.release_name or "unknown"),
                    status=os_status,
                )
                stats["eol_findings_total"] += 1
                stats["os_eol_findings"] += 1
                if os_created:
                    stats["eol_findings_new"] += 1
        except Exception as e:
            logger.warning("OS-EOL check failed for asset %s: %s", asset.id, e)

        try:
            from app.services.app_cve_scanner_service import filter_inventory
            # Published-app stubs carry a placeholder version ("1.0"), which
            # reads as ancient and therefore end-of-life to this check.
            pkgs = filter_inventory(wazuh.get_packages(asset.wazuh_agent_id) or [])
        except Exception as e:
            stats["errors"].append(f"asset {asset.id} ({asset.hostname}): {e}")
            continue
        stats["assets_scanned"] += 1
        seen_keys: set[tuple[str, str]] = set()
        for pkg in pkgs:
            name = (pkg.get("name") or "").strip()
            version = (pkg.get("version") or "").strip()
            if not name or not version:
                continue
            key = (name.lower(), version)
            if key in seen_keys:
                continue
            seen_keys.add(key)
            stats["packages_checked"] += 1
            status = None
            # 1) endoflife.date (when the name maps to a known slug).
            if eol_service.resolve_product_slug(name):
                try:
                    status = eol_service.check_eol(db, name, version)
                except Exception as e:
                    logger.warning("EOL check failed for %s %s: %s", name, version, e)
                    status = None
            actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
            # 2) MS-lifecycle export / hardcoded exotics (Plan O) whenever
            # endoflife.date had nothing actionable — this also covers
            # products endoflife.date maps to a slug but can't resolve a
            # release for (e.g. Visual C++ Redistributables → visual-cpp,
            # whose endoflife data doesn't match redistributable builds).
            if not actionable:
                try:
                    from app.services import ms_lifecycle_service
                    ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
                except Exception as e:
                    logger.debug("MS-lifecycle fallback failed for %s: %s", name, e)
                    ms_status = None
                if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
                    status = ms_status
                    actionable = True
                    stats.setdefault("ms_lifecycle_findings", 0)
                    stats["ms_lifecycle_findings"] += 1
            if not actionable:
                stats["products_unmapped"] += 1
                continue
            try:
                _, was_created = eol_service.upsert_eol_vulnerability(
                    db,
                    asset_id=asset.id,
                    product_name=name,
                    installed_version=version,
                    status=status,
                )
                stats["eol_findings_total"] += 1
                if was_created:
                    stats["eol_findings_new"] += 1
            except Exception as e:
                logger.warning("EOL upsert failed for %s on asset %s: %s", name, asset.id, e)
    db.commit()
    # Re-check open MS-lifecycle findings and close the ones a since-corrected
    # name match no longer produces (tester: Edge browser vs "Azure Stack Edge").
    try:
        stats["ms_lifecycle_closed"] = eol_service.revalidate_ms_lifecycle_findings(db)
    except Exception as e:
        logger.warning("MS-lifecycle revalidate failed: %s", e)
    logger.info(
        "EOL check done: %d assets, %d packages, %d EOL findings (%d new), "
        "%d via MS-lifecycle/exotics, %d OS, %d unmapped",
        stats.get("assets_scanned", 0), stats.get("packages_checked", 0),
        stats.get("eol_findings_total", 0), stats.get("eol_findings_new", 0),
        stats.get("ms_lifecycle_findings", 0), stats.get("os_eol_findings", 0),
        stats.get("products_unmapped", 0),
    )
    return stats


@router.post("/m365-check")
# Sync def → runs in a worker threadpool (off the event loop) so the
# cvelistV5/MSRC metadata work doesn't freeze the web GUI.
def run_m365_check_endpoint(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Detect Microsoft 365 Apps CVEs (Plan P).

    M365 Apps security fixes are not in NVD and are invisible to Wazuh's
    vulnerability detector. This parses the Microsoft 365 Apps security-
    updates page, compares the installed build (from syscollector) against
    the latest patched build for the matching update channel, and creates
    real-CVE rows for every monthly update the host is behind on.

    Idempotent — re-runs upsert existing rows.
    """
    from app.integrations.wazuh_client import WazuhClient
    from app.auth.setting_crypto import read_setting_value
    from app.services import m365_service

    raw_wazuh = read_setting_value(db, "wazuh_config")
    if not raw_wazuh:
        raise HTTPException(400, "Wazuh is not configured (settings.wazuh_config missing).")
    try:
        wazuh_config = json.loads(raw_wazuh)
    except json.JSONDecodeError:
        raise HTTPException(400, "Invalid Wazuh configuration format.")
    api_url = wazuh_config.get("api_url")
    username = wazuh_config.get("username")
    password = wazuh_config.get("password")
    if not all([api_url, username, password]):
        raise HTTPException(400, "Incomplete Wazuh configuration (api_url/username/password missing).")
    wazuh = WazuhClient(
        base_url=api_url,
        username=username,
        password=password,
        indexer_url=wazuh_config.get("indexer_url"),
        indexer_username=wazuh_config.get("indexer_username"),
        indexer_password=wazuh_config.get("indexer_password"),
        verify_ssl=wazuh_config.get("verify_ssl", False),
    )

    try:
        return m365_service.run_m365_check(db, wazuh, asset_id=asset_id)
    except m365_service.M365Error as e:
        raise HTTPException(502, f"MS365 detection failed: {e}")


# Background app-scan job — same reason as the EOL check: a full run walks
# every asset's inventory against NVD/OSV and rebuilds the cvelistV5 and MSRC
# indexes on first use, which outlives the browser's HTTP patience. The tester
# saw "Backend connection failed" while the scan ran happily to completion.
_APPSCAN_JOB: dict = {"running": False, "stage": None, "done": 0, "total": 0,
                      "result": None, "error": None, "finished_at": None}


def _appscan_job_runner(asset_id: Optional[int]):
    """Run the app CVE scan on its own DB session, recording progress."""
    import time as _time
    from app.database import SessionLocal
    from app.services import app_cve_scanner_service
    db = SessionLocal()
    try:
        stats = app_cve_scanner_service.run_app_cve_scan(db, asset_id=asset_id)
        _APPSCAN_JOB["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
        _APPSCAN_JOB["error"] = None
        _APPSCAN_JOB["stage"] = "completed"
    except Exception as e:
        logger.exception("App CVE scan background job failed")
        _APPSCAN_JOB["result"] = None
        _APPSCAN_JOB["error"] = str(e)
        _APPSCAN_JOB["stage"] = "failed"
    finally:
        db.close()
        _APPSCAN_JOB["running"] = False
        _APPSCAN_JOB["finished_at"] = _time.time()


@router.post("/app-cve-scan/start", status_code=202)
def start_app_cve_scan_job(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
    current_user: User = Depends(RequireEditor),
):
    """Start the app CVE scan in the background; poll /app-cve-scan/status."""
    import threading
    if _APPSCAN_JOB["running"]:
        return {"started": False, "message": "An app CVE scan is already running",
                "poll_url": "/api/v1/vulnerabilities/app-cve-scan/status"}
    _APPSCAN_JOB.update({"running": True, "stage": "starting", "done": 0, "total": 0,
                         "result": None, "error": None, "finished_at": None})
    threading.Thread(target=_appscan_job_runner, args=(asset_id,),
                     daemon=True).start()
    return {"started": True, "poll_url": "/api/v1/vulnerabilities/app-cve-scan/status"}


@router.get("/app-cve-scan/status")
def get_app_cve_scan_status(current_user: User = Depends(get_current_user)):
    """Snapshot of the background app CVE scan (running / result)."""
    s = _APPSCAN_JOB
    return {
        "running": s["running"],
        "stage": s["stage"],
        "result": s["result"],
        "error": s["error"],
        "finished_at": s["finished_at"],
        "status": ("running" if s["running"]
                   else "failed" if s["error"]
                   else "completed" if s["result"] is not None else "idle"),
    }


@router.post("/app-cve-scan")
# Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and
# must not freeze the web GUI. Kept for API clients; the GUI uses start/status.
def run_app_cve_scan_endpoint(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all assets with software inventory"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Built-in app→CVE scanner (curated + precise).

    Maps installed software (Wazuh syscollector packages + Intune
    detectedApps) to real CVEs via OSV / NVD-CPE with an own version-range
    check, so assets without a real scanner (Intune-only / mobile) still
    get findings. Source 'app-scan'; cross-confirms with existing scanners
    on the same (cve, asset). Idempotent.
    """
    from app.services import app_cve_scanner_service
    try:
        return app_cve_scanner_service.run_app_cve_scan(db, asset_id=asset_id)
    except Exception as e:
        raise HTTPException(502, f"App CVE scan failed: {e}")


@router.post("/msrc-scan")
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
def run_msrc_scan_endpoint(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
    rebuild_index: bool = Query(False, description="Re-pull the MSRC CVRF documents first (slow; the nightly job does this)"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """MSRC fixed-build scan for Windows Server OS CVEs.

    Compares the host's OS build against the FixedBuild MSRC publishes per CVE
    and product, on the host's own servicing branch. Patch-level accurate and
    available on Patch Tuesday — well before the CVE reaches the Wazuh CTI feed.
    NVD/cvelistV5 cannot do this: they carry no fixed build for MS products.
    Source 'msrc'; idempotent, and auto-resolves once a host catches up.
    """
    from app.services import msrc_scan_service
    try:
        if rebuild_index:
            msrc_scan_service.build_product_index(db)
        return msrc_scan_service.run_msrc_scan(db, asset_id=asset_id)
    except Exception as e:
        raise HTTPException(502, f"MSRC scan failed: {e}")


@router.post("/suppress-false-positives")
# Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O).
def suppress_false_positives_endpoint(
    asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Auto-flag Wazuh findings whose installed version is provably outside
    all cvelistV5 affected ranges for the matched product (loose-CPE false
    positives, e.g. a SQL 2019 host carrying a 16.x/17.x-only CVE). Sets
    status=false_positive (reversible via unmark). Conservative.
    """
    from app.services import cvelistv5_scan_service
    try:
        return cvelistv5_scan_service.suppress_false_positives(db, asset_id=asset_id)
    except Exception as e:
        raise HTTPException(502, f"FP suppression failed: {e}")


@router.get("/ai-remediation/status")
async def ai_remediation_status(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Whether AI remediation is configured (key present). UI hides the
    button when disabled."""
    from app.services import ai_service
    return {"enabled": ai_service.is_enabled(db)}


@router.post("/{vuln_id}/ai-remediation")
async def ai_remediation(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """Generate OS-aware remediation guidance for a vulnerability via
    OpenRouter. Runs OFF the event loop so the GUI stays responsive."""
    import asyncio
    from app.services import ai_service

    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(404, "Vulnerability not found")
    asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
    os_name = None
    if asset:
        os_name = (asset.operating_system or "").strip() or None
        if os_name and asset.os_version:
            os_name = f"{os_name} {asset.os_version}".strip()

    kwargs = dict(
        cve_id=vuln.cve_id,
        title=vuln.title,
        description=vuln.description,
        package=vuln.package_name,
        installed=vuln.package_version,
        fixed=vuln.fixed_version,
        os_name=os_name,
        scanner_remediation=getattr(vuln, "remediation", None),
    )
    try:
        result = await asyncio.to_thread(ai_service.generate_remediation, db, **kwargs)
    except ai_service.AIServiceError as e:
        raise HTTPException(502, str(e))
    return result


@router.get("/{vuln_id}/remediations")
async def get_vuln_remediations(
    vuln_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Remediation guidance for a finding from every source:
    the per-row scanner solution (Nessus) + CVE-level external sources
    (MSRC, later Ubuntu/CentOS) grouped by source. Pure DB read."""
    from app.models.cve_remediation import CveRemediation
    vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
    if not vuln:
        raise HTTPException(404, "Vulnerability not found")

    # On-demand non-Windows enrichment for this CVE, cached into
    # cve_remediations on first view: the matching vendor provider (Ubuntu
    # USN / RHEL-family errata) when the OS is known + OSV.dev aggregator
    # (Debian/SUSE/Alpine/Rocky/Alma/language ecosystems). MSRC is populated
    # by its own background job, not here.
    if vuln.cve_id and vuln.cve_id.upper().startswith("CVE-"):
        try:
            import asyncio
            from app.services import linux_remediation_service as lrs
            asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
            os_name = (asset.operating_system if asset else None) or ""
            is_windows = "windows" in os_name.lower()
            # OSV is the always-run source; if it's not cached yet, enrich.
            if not is_windows and not lrs.has_cached(db, vuln.cve_id, "osv"):
                await asyncio.to_thread(lrs.enrich_cve_linux, db, vuln.cve_id, os_name)
                db.commit()
        except Exception as e:
            logger.debug("Linux/OSV remediation enrich failed for %s: %s", vuln.cve_id, e)

    ext = (
        db.query(CveRemediation)
        .filter(CveRemediation.cve_id == vuln.cve_id)
        .all()
    )
    groups: dict = {}
    for r in ext:
        g = groups.setdefault(r.source, {"source": r.source, "items": [], "fetched_at": None})
        g["items"].append({
            "kind": r.kind, "title": r.title, "detail": r.detail,
            "kb": r.kb, "fixed_build": r.fixed_build, "url": r.url,
        })
        if r.fetched_at and (g["fetched_at"] is None or r.fetched_at > g["fetched_at"]):
            g["fetched_at"] = r.fetched_at

    # MSRC block tailoring for the affected host.
    msrc = groups.get("msrc")
    if msrc:
        a = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
        is_m365 = (vuln.first_detected_by == "m365_check")

        def _branch(v: str):
            # build "10.0.26100.32690" → branch "26100", rev 32690
            parts = [p for p in (v or "").split(".") if p.isdigit()]
            return (parts[2], int(parts[3])) if len(parts) >= 4 else (None, None)

        def _major(v):
            # first numeric segment: "8.0.12" → "8", "18.6.3" → "18"
            for p in (v or "").split("."):
                if p.isdigit():
                    return p
            return None

        items = msrc["items"]
        fixes = [i for i in items if i.get("kind") == "fix"]
        others = [i for i in items if i.get("kind") != "fix"]

        # Keep only the NEWEST KB per (build-branch + update-type). MS monthly
        # updates are cumulative, so older revisions of the same line are
        # superseded; distinct types (Security Update vs Security Hotpatch
        # Update) are kept separately. Non-Windows product builds (.NET / Visual
        # Studio / SQL have no 4-part Windows build → branch None) are keyed on
        # the build itself so DIFFERENT products aren't collapsed into one
        # (tester: a .NET finding showed a Visual Studio build because both had
        # branch None).
        # Collect ALL candidates per (build-branch + update-type) first — the
        # host-aware pick below needs the full set. Collapsing to the newest
        # rev up-front was wrong: one branch can carry SEVERAL revision
        # sequences (10.0.26100 is Windows 11 24H2 at rev ~8xxx AND Windows
        # Server 2025 at rev ~33xxx), so 'newest' handed a Win11 host the
        # Server KB (tester: KB5099536 build .33158 suggested at .8655).
        best: dict = {}
        by_branch: dict = {}
        for i in fixes:
            build = i.get("fixed_build") or ""
            br, rev = _branch(build)
            if br is None:
                key = ("nb", build or i.get("kb") or i.get("url") or i.get("title") or "", i.get("detail") or "")
                best.setdefault(key, i)
            else:
                key = (br, i.get("detail") or "")
                by_branch.setdefault(key, []).append((rev or 0, i))
                if key not in best or (rev or 0) > (_branch(best[key].get("fixed_build") or "")[1] or 0):
                    best[key] = i
        deduped = list(best.values())

        # Windows-OS finding: narrow to the host's own build branch. Within the
        # branch, pick per update-type the SMALLEST fix revision still above the
        # installed one — that's the host's own servicing sequence (a 24H2 box
        # at .8655 takes .8875, not Server 2025's .33158; a Server box at
        # .32690 skips .8875 because it's below installed and takes .33158).
        # MS servicing is cumulative, so smallest-above is the fixing update.
        # If the host is past every candidate (already patched), fall back to
        # the newest as reference. For Microsoft 365 Apps findings the MSRC KBs
        # are MSI-version builds that never match the installed Click-to-Run
        # channel build, so we don't branch-filter — we show the deduped set
        # plus the actionable channel-build hint below.
        host_branch, host_rev = _branch(a.os_version if a else "")
        shown = deduped
        if host_branch and not is_m365:
            matched = []
            for (br, _detail), cands in by_branch.items():
                if br != host_branch:
                    continue
                above = [c for c in cands if host_rev is not None and c[0] > host_rev]
                pick = min(above)[1] if above else max(cands)[1]
                matched.append(pick)
            if matched:
                shown = matched
        # Non-Windows Microsoft product finding (.NET / SQL / Visual Studio all
        # share one CVE across products, each with its own fix build): narrow
        # to the build line whose major matches the installed package version,
        # so a .NET 8.x finding shows the .NET 8.x fix — not a Visual Studio
        # 18.x build. Fallback to the full set if nothing matches (never hide
        # everything).
        if not is_m365 and shown is deduped:
            inst_major = _major(vuln.package_version)
            if inst_major:
                prod = [i for i in shown if _major(i.get("fixed_build")) == inst_major]
                if prod:
                    shown = prod
        shown.sort(key=lambda i: _branch(i.get("fixed_build") or "")[1] or 0, reverse=True)

        # M365 Apps: the real fix is "update the Office channel to build X".
        if is_m365 and vuln.fixed_version:
            others = [{
                "kind": "mitigation",
                "title": "Update via Office channel",
                "detail": (f"Update Microsoft 365 Apps to build {vuln.fixed_version} "
                           f"or later via the configured Office update channel "
                           f"(Click-to-Run). The KB list below is for perpetual/MSI "
                           f"Office versions and may not apply to this install."),
                "kb": None, "fixed_build": None, "url": None,
            }] + others

        link = {
            "kind": "advisory",
            "title": "MSRC update guide",
            "detail": None, "kb": None, "fixed_build": None,
            "url": f"https://msrc.microsoft.com/update-guide/vulnerability/{vuln.cve_id}",
        }
        msrc["items"] = [link] + others + shown

    return {
        "scanner": getattr(vuln, "remediation", None),
        "external": list(groups.values()),
    }


# Last/current MSRC-refresh state so the GUI can poll for completion (the
# refresh is fire-and-forget 202). ponytail: module state assumes the single
# uvicorn worker we ship with; move to the DB if --workers is ever added.
_MSRC_REFRESH: dict = {"running": False, "result": None, "error": None, "finished_at": None}


def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
    import time as _time
    from app.database import SessionLocal
    from app.services import msrc_service
    db = SessionLocal()
    try:
        stats = msrc_service.refresh_msrc(db, months_back=months_back)
        _MSRC_REFRESH["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
        _MSRC_REFRESH["error"] = None
    except Exception as e:
        _MSRC_REFRESH["result"] = None
        _MSRC_REFRESH["error"] = str(e)
        logger.error("MSRC refresh failed: %s", e)
    finally:
        db.close()
        _MSRC_REFRESH["running"] = False
        _MSRC_REFRESH["finished_at"] = _time.time()


@router.post("/msrc/refresh", status_code=202)
async def refresh_msrc_endpoint(
    months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
    current_user: User = Depends(RequireEditor),
):
    """Kick off an MSRC CVRF ingest in the background (fire-and-forget).

    Pulls the last N monthly Microsoft security-update documents and stores
    per-CVE fixes (KB + build + link), workarounds, and mitigations for the
    CVEs already in the DB. Returns 202 immediately — watch the logs
    ('MSRC refresh done')."""
    if _MSRC_REFRESH["running"]:
        return {"status": "already_running", "detail": "An MSRC refresh is already in progress."}
    _MSRC_REFRESH.update({"running": True, "result": None, "error": None, "finished_at": None})
    import threading
    threading.Thread(target=_run_msrc_refresh_threaded, args=(months_back,), daemon=True).start()
    return {"status": "started", "detail": "MSRC enrichment started in the background."}


@router.get("/msrc/refresh/status")
async def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
    """Poll target for the GUI: current/last MSRC-refresh state + stats."""
    s = _MSRC_REFRESH
    state = ("running" if s["running"] else "error" if s["error"]
             else "done" if s["result"] is not None else "idle")
    return {"state": state, "running": s["running"], "result": s["result"],
            "error": s["error"], "finished_at": s["finished_at"]}


@router.post("/recompute-scores")
def recompute_priority_scores(
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """One-off backfill of priority_score + cpr_score columns.

    Migration 024 adds the columns but leaves them NULL. Forward
    sync / enrich / override paths keep them current going forward.
    Existing rows need this endpoint once after the upgrade. Cheap —
    pure Python, ~5s for 15k rows.
    """
    updated = 0
    for vuln in db.query(Vulnerability).all():
        try:
            vuln.refresh_scores()
            updated += 1
        except Exception as e:
            logger.warning("refresh_scores failed for vuln_id=%s: %s", vuln.id, e)
    db.commit()
    return {"updated": updated}


@router.post("/canonicalize-cve-metadata")
async def canonicalize_cve_metadata(
    db: Session = Depends(get_db),
    current_user: User = Depends(RequireEditor),
):
    """One-off normalisation of canonical CVE fields across sibling rows.

    Tester report: "Same CVE shows different CVSS depending on sort
    order." Cause: per-asset rows of the same CVE drifted apart over
    time — override service touched some, not others.

    For every distinct CVE in the DB, pick the freshest authoritative
    source row (exploitation_source set, latest updated_at) and copy
    its canonical fields (cvss_score, severity, exploitation_status,
    SSVC, cvss_vector) onto all siblings. `fixed_version` is left
    alone because Plan I picks per-package fixes that legitimately
    differ between streams.

    Cheap — single pass over the cve_id → vulns mapping. Forward
    sync / enrich / override paths keep the canonical view current
    going forward.
    """
    from sqlalchemy import desc as _desc, nulls_last
    from app.services.vuln_override_service import (
        propagate_canonical_to_siblings, _CANONICAL_FIELDS,
    )

    cve_ids = [
        r[0] for r in db.query(Vulnerability.cve_id).distinct().all()
        if r[0]
    ]
    cves_changed = 0
    rows_changed = 0
    for cve_id in cve_ids:
        vulns = (
            db.query(Vulnerability)
            .filter(Vulnerability.cve_id == cve_id)
            .all()
        )
        if len(vulns) < 2:
            continue
        # Source-of-truth pick: pinned source first, then most recent.
        vulns.sort(
            key=lambda v: (v.exploitation_source is not None, v.updated_at or datetime.min),
            reverse=True,
        )
        src = vulns[0]
        # Skip when the source itself has no authoritative values to share.
        if all(getattr(src, f, None) is None for f in _CANONICAL_FIELDS):
            continue
        touched = propagate_canonical_to_siblings(db, src)
        if touched:
            cves_changed += 1
            rows_changed += touched
    db.commit()
    return {
        "distinct_cves_scanned": len(cve_ids),
        "cves_normalised": cves_changed,
        "rows_changed": rows_changed,
    }


@router.get("/override/stats")
async def get_override_stats(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    Gibt Statistiken über potenzielle Score-Fehler zurück.
    
    Zeigt:
    - Anzahl Platzhalter-Scores (10.0)
    - Anzahl Severity-Fehlanpassungen
    - Verteilung nach Severity-Level
    """
    from sqlalchemy import func
    
    # Count placeholder scores
    placeholder_count = db.query(func.count(Vulnerability.id)).filter(
        Vulnerability.cvss_score == 10.0
    ).scalar()
    
    # Count severity mismatches (CVSS >= 9 but not CRITICAL)
    mismatch_count = db.query(func.count(Vulnerability.id)).filter(
        Vulnerability.cvss_score >= 9.0,
        Vulnerability.severity != VulnerabilitySeverity.critical
    ).scalar()
    
    # Severity distribution
    severity_dist = db.query(
        Vulnerability.severity,
        func.count(Vulnerability.id)
    ).filter(
        Vulnerability.status == VulnerabilityStatus.open
    ).group_by(Vulnerability.severity).all()
    
    # CVSS distribution
    cvss_ranges = [
        ("0.0-3.9", 0.0, 3.9),
        ("4.0-6.9", 4.0, 6.9),
        ("7.0-8.9", 7.0, 8.9),
        ("9.0-10.0", 9.0, 10.0),
    ]
    cvss_dist = []
    for label, low, high in cvss_ranges:
        count = db.query(func.count(Vulnerability.id)).filter(
            Vulnerability.cvss_score >= low,
            Vulnerability.cvss_score < high if high < 10.0 else Vulnerability.cvss_score <= high
        ).scalar()
        cvss_dist.append({"range": label, "count": count})
    
    return {
        "placeholder_scores_10": placeholder_count,
        "severity_mismatches": mismatch_count,
        "severity_distribution": {str(k): v for k, v in severity_dist},
        "cvss_distribution": cvss_dist,
        "recommendation": "Run POST /api/v1/vulnerabilities/override/vulnrichment with dry_run=false to correct values"
    }
