"""
Built-in app→CVE scanner.

Maps installed software (Wazuh syscollector packages + Intune detectedApps)
to CVEs so assets without a real scanner (Intune-only / mobile, or any app
no scanner covers) still get findings.

Design: CURATED + PRECISE (low false-positives).
  - Only a curated product registry is matched — unknown app names are
    ignored (no CPE auto-guessing → no FP storm).
  - Per product: query OSV (language/OSS ecosystems, server-side version
    match) or NVD-CPE (desktop apps), then verify the installed version
    actually falls inside the CVE's affected version range ourselves.
  - Results cached per (product_key, version) in app_cve_cache (TTL) so the
    same Chrome version across N hosts = one query (and NVD rate limit).
  - Findings are upserted as source 'app-scan' with REAL CVE ids → the
    normal EPSS/KEV/CVSS enrichment + multi-source remediation apply, and
    they cross-confirm with Wazuh/Nessus/Defender on the same (cve, asset).
"""
from __future__ import annotations

import json
import logging
import os
import re
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple

import httpx
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

OSV_QUERY_URL = "https://api.osv.dev/v1/query"
NVD_CVE_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
HTTP_TIMEOUT = 30.0
# Was 7 days — but a (product,version) queried BEFORE a new CVE for that
# exact version is published stays cached empty for the whole window,
# hiding the CVE from every host on that version until it expires (tester:
# CVE-2026-14152 undetected while a same-day sibling CVE was). 24h still
# collapses most redundant NVD/OSV traffic (many hosts share a version).
CACHE_TTL = timedelta(hours=24)
NVD_SLEEP_NO_KEY = 6.5
NVD_SLEEP_WITH_KEY = 0.7

# Curated registry: (compiled name regex, entry). First match wins.
# entry: {"key", "kind": "cpe"|"osv", "cpe"? "a:vendor:product", "eco"?, "oname"?}
def _cpe(name_re: str, vendor_product: str, name_ver: bool = False) -> tuple:
    e = {"key": f"cpe:{vendor_product}", "kind": "cpe",
         "cpe": f"cpe:2.3:a:{vendor_product}"}
    if name_ver:
        # Take the version from the software NAME, not the package version
        # field. Some products (e.g. .NET Runtime) report an MSI build number
        # (48.x, 94.x) in the version field while the real semantic version
        # (6.0.16) lives in the display name — the field version never matches
        # NVD's 6.0.x/8.0.x ranges, so the CVE was silently missed.
        e["name_ver"] = True
    return (re.compile(name_re, re.I), e)


def _sap(name_re: str, vendor_product: str) -> tuple:
    rx, e = _cpe(name_re, vendor_product)
    e["sap"] = True  # match by patch level, not by version range
    return (rx, e)


_REGISTRY: List[tuple] = [
    # "Google Chrome" (Wazuh/Windows) + "com.android.chrome" (Intune Android
    # package id). Android Chrome shares version numbers AND security fixes with
    # Desktop (Google: "Android releases contain the same security fixes"), so
    # the same google:chrome ranges apply. iOS Chrome is WebKit-backed and
    # reported as bare "Chrome" — deliberately NOT matched (Blink CVEs N/A).
    _cpe(r"google chrome|com\.android\.chrome", "google:chrome"),
    _cpe(r"microsoft edge(?!.*webview)", "microsoft:edge_chromium"),
    _cpe(r"mozilla firefox|(?<!\w)firefox", "mozilla:firefox"),
    _cpe(r"thunderbird", "mozilla:thunderbird"),
    _cpe(r"acrobat reader|adobe acrobat reader", "adobe:acrobat_reader_dc"),
    _cpe(r"adobe acrobat(?!.*reader)", "adobe:acrobat_dc"),
    _cpe(r"7-?zip", "7-zip:7-zip"),
    _cpe(r"notepad\+\+", "notepad-plus-plus:notepad-plus-plus"),
    _cpe(r"vlc media player|videolan", "videolan:vlc_media_player"),
    _cpe(r"(?<!\w)putty", "putty:putty"),
    _cpe(r"winscp", "winscp:winscp"),
    _cpe(r"wireshark", "wireshark:wireshark"),
    # Exclude the FIPS provider/module builds (e.g. Veeam ships "OpenSSL v3.0.0
    # FIPS"): OpenSSL advisories explicitly carve the FIPS modules OUT of most
    # CVEs (the vulnerable code is outside the FIPS boundary), and they carry a
    # separate 4-part build version that doesn't map to NVD's ranges anyway →
    # matching them is a false positive (tester: CVE-2025-15467).
    _cpe(r"openssl(?!.*fips)", "openssl:openssl"),
    _cpe(r"openvpn", "openvpn:openvpn"),
    _cpe(r"node\.?js", "nodejs:node.js"),
    # Windows Python reports an MSI BUILD in the version field ("Python 3.13.7
    # (64-bit)" → version 3.13.7150.0) while the semantic version lives in the
    # NAME. Matching the build number against NVD's 3.13.x ranges finds nothing,
    # so real Python CVEs were silently missed (tester: CVE-2025-12781 et al.).
    _cpe(r"(?<!\w)python(?!.*launcher)", "python:python", name_ver=True),
    _cpe(r"teamviewer", "teamviewer:teamviewer"),
    _cpe(r"(?<!\w)zoom(?!\w)", "zoom:zoom"),
    _cpe(r"libreoffice", "libreoffice:libreoffice"),
    _cpe(r"filezilla", "filezilla:filezilla"),
    _cpe(r"(?<!\w)git(?: for windows| version control)", "git:git"),
    _cpe(r"oracle vm virtualbox|virtualbox", "oracle:vm_virtualbox"),
    _cpe(r"(?<!\w)gimp(?!\w)", "gimp:gimp"),
    # .NET (6/7/8/9) and .NET Framework share the SAME NVD CPE
    # (microsoft:.net) — NVD scopes each major version's fix range via its own
    # cpeMatch entry under that one product, so one registry row covers both.
    # The semantic version lives in the NAME ("… - 6.0.16 (x64)"), not the
    # version field (an MSI build number) → name_ver=True. The developer
    # reference packs (Targeting Pack / Multi-Targeting Pack / Framework SDK)
    # are NOT the runtime; excluded via lookahead so their version doesn't
    # false-positive against a runtime CVE range. (Bare ".NET Framework" the
    # runtime is a Windows feature, often not a syscollector package anyway.)
    _cpe(r"microsoft\s+\.net\s+(?:desktop\s+)?(?:runtime|host|sdk)\b"
         r"|microsoft\s+\.net\s+framework(?!.*(?:targeting|sdk|client))\s+[\d.]+",
         "microsoft:.net", name_ver=True),
    # Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the
    # machine-wide installer (separate products with their own versioning that
    # would false-positive against Teams-app CVE ranges).
    _cpe(r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"),
    # SAP desktop clients. cvelistV5 carries no CPE for these at all, so NVD is
    # the only usable source (Wazuh misses them too — wazuh/wazuh#30334). SAP
    # states the patch level in the CPE's UPDATE field, one entry per level, so
    # these are matched by patch level instead of by range — see _sap_affected.
    # Without a readable level nothing is reported: the base release alone
    # matches every CVE ever filed against it.
    _sap(r"sap gui for windows|sap\s+gui(?!.*java)", "sap:gui_for_windows"),
    _sap(r"sap business client", "sap:business_client"),
    # Analysis for Microsoft Office is deliberately NOT here. It carries no
    # patch level at all — it ships 2.8.x builds — so the patch-level match
    # can never fire for it, and NVD states the affected version as a bare
    # `…:analysis_for_microsoft_office:2.8:*:*` with no range, which would call
    # every 2.8 build affected forever. cvelistV5 has the honest bound
    # ("< 2.8", CVE-2021-38175), but the cvelistV5 path carries no SAP pairs
    # yet. Until it does, this product stays out rather than guessing.
    # OSV ecosystem examples (rarely in desktop inventory, but supported):
    (re.compile(r"^node-(.+)$", re.I), {"key": "osv:npm", "kind": "osv", "eco": "npm"}),
]


# OS-level CPEs. Apple ships the precise OS version (e.g. 18.1.2) and NVD
# carries proper version ranges for it → a clean CPE-range check, same as the
# desktop apps. Android is deliberately absent: NVD only lists the base
# version (13/14/15) without ranges, so it needs the Intune security-patch
# level + Android bulletin parsing — a separate feature.
# ponytail: iOS/iPadOS only; add Android when the patch-level path is built.
# Windows OS CVEs are handled by cvelistv5_scan_service.scan_asset_os(), not
# from here. Modern Microsoft CVE records DO carry real build ranges (e.g.
# CVE-2026-47291: 20/20 affected entries with a numeric lessThan) — but NVD
# flattens them to an END bound only (versionEndExcluding, no start), so a
# 1607 host (14393.x) would fall inside the 22H2 range (endExcluding
# 19045.7417) and false-positive. cvelistV5 keeps the range BOUNDED
# (version 10.0.22631.0 .. lessThan 10.0.22631.7219), and those bounds pick the
# host's release on their own — which is why the OS path lives there.
_OS_REGISTRY: List[tuple] = [
    (re.compile(r"ipad", re.I), {"key": "cpe:apple:ipados",
                                 "cpe": "cpe:2.3:o:apple:ipados", "label": "Apple iPadOS"}),
    (re.compile(r"ios|iphone", re.I), {"key": "cpe:apple:iphone_os",
                                       "cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
    (re.compile(r"mac ?os|macos|mac_os|os x", re.I), {"key": "cpe:apple:macos",
                                                      "cpe": "cpe:2.3:o:apple:macos", "label": "Apple macOS"}),
]


_NAME_VER_RE = re.compile(r"(\d+\.\d+(?:\.\d+)*)")


def _effective_version(name: str, version: str, entry: dict) -> str:
    """Version to scan with. For name_ver products the real semantic version
    is in the display name, not the (MSI-build) version field."""
    if entry.get("name_ver"):
        m = _NAME_VER_RE.search(name or "")
        if m:
            return m.group(1)
    return version


def _resolve_os(os_name: str) -> Optional[dict]:
    n = (os_name or "").lower()
    for rx, e in _OS_REGISTRY:
        if rx.search(n):
            return e
    return None


def _os_family(os_name: str) -> Optional[str]:
    """Map an asset OS string to a CPE target_sw token."""
    n = (os_name or "").lower()
    if "windows" in n:
        return "windows"
    if "ipad" in n:
        return "ipados"
    if "iphone" in n or "ios" in n:
        return "iphone_os"
    if "android" in n:
        return "android"
    if "mac" in n or "darwin" in n or "os x" in n:
        return "macos"
    if any(x in n for x in ("linux", "ubuntu", "debian", "centos", "red hat",
                            "rhel", "fedora", "suse", "alma", "rocky")):
        return "linux"
    return None


def _platform_ok(tsws, plat: Optional[str]) -> bool:
    """A CVE's matching cpeMatch target_sw must be platform-neutral (*) or
    name the asset's OS. Stops desktop Firefox-on-Windows matching the
    Firefox-for-iOS CPE (target_sw=iphone_os) and similar cross-platform FPs.
    """
    if not tsws:
        return True  # unknown (e.g. pre-fix cache) → keep
    if any(t in ("*", "-", "") for t in tsws):
        return True
    return bool(plat) and plat in tsws


def resolve_product(name: str) -> Optional[dict]:
    n = (name or "").strip().lower()
    if not n:
        return None
    for rx, entry in _REGISTRY:
        m = rx.search(n)
        if m:
            if entry.get("key") == "cpe:mozilla:firefox" and re.search(r"\besr\b", n):
                # ESR install: the plain mozilla:firefox CPE / cvelistV5 ranges
                # describe the release train, not ESR (NVD tracks ESR as its
                # own firefox_esr CPE) — matching produces FPs on CVEs whose
                # MFSA advisory has no ESR fix. See cvelistv5 resolve().
                return None
            e = dict(entry)
            if e["kind"] == "osv" and not e.get("oname"):
                # derive package name from the capture group when present
                e["oname"] = (m.group(1) if m.groups() else n)
                e["key"] = f"osv:{e['eco']}:{e['oname']}"
            return e
    return None


class _TransientNVD(Exception):
    """NVD was unreachable/throttled — caller must NOT cache the empty result."""


# ---------- version compare ----------
def _clean_version(v: str) -> Optional[str]:
    """Desktop version core, or None for distro/rpm/deb-style versions.

    Linux package versions (epoch `1:3.2`, release tags `4.6.5-3.el8`,
    `2.43.0.windows.1`) are Wazuh's job — pushing them at NVD produces
    noise (503 storms) and false-positives. Only clean dotted-numeric
    versions go to the scanner; a trailing build like `7.0.2 (34567)` is
    trimmed to its core.
    # ponytail: dotted-numeric only; if a real product ever uses a hyphenated
    # version we want, special-case it then, not now.
    """
    v = (v or "").strip()
    if not v or ":" in v:
        return None
    core = re.split(r"[ (]", v, 1)[0]
    return core if re.fullmatch(r"\d+(\.\d+)*", core) else None


def _vtuple(v: str) -> Optional[tuple]:
    if not v:
        return None
    nums = re.findall(r"\d+", v)
    if not nums:
        return None
    return tuple(int(x) for x in nums[:6])


def _vcmp(a: str, b: str) -> Optional[int]:
    """-1/0/1, or None when not comparable."""
    ta, tb = _vtuple(a), _vtuple(b)
    if ta is None or tb is None:
        return None
    n = max(len(ta), len(tb))
    ta += (0,) * (n - len(ta))
    tb += (0,) * (n - len(tb))
    return (ta > tb) - (ta < tb)


# SAP states the patch level in the CPE's UPDATE field, enumerated one entry
# per level (cpe:2.3:a:sap:gui_for_windows:7.70:patch_level4), never as a
# range — so "affected" is set membership, not a comparison. Verified against
# CVE-2023-32113 (15 entries) and CVE-2021-38150 (68).
_SAP_PL_RE = re.compile(r"(?:\bpl|\bpatch(?:\s*level)?)\s*[-_]?\s*(\d+)", re.I)
_SAP_CPE_PL_RE = re.compile(r"^patch_level(\d+)$", re.I)


def _sap_patch_level(name: str, version: str) -> Optional[int]:
    """Patch level from the inventory strings, or None if it isn't stated.

    None means "we cannot tell", and the caller must then report NOTHING: the
    base version alone (7.70) appears in every CVE ever filed against that
    release, so treating an unknown level as affected turns one unreadable
    string into a full page of false positives.
    """
    for s in (version or "", name or ""):
        m = _SAP_PL_RE.search(s)
        if m:
            return int(m.group(1))
    return None


def _sap_affected(m: dict, installed: str, pl: Optional[int]) -> bool:
    """True if this cpeMatch names the host's release AND its patch level.

    BOTH halves are required. The same patch-level number exists under every
    release — NVD lists gui_for_windows 7.70:patch_level17 next to
    8.0:patch_level1 — so matching on the level alone puts a 7.70 CVE on an
    8.00 host. The releases are also spelled differently on the two sides
    (Wazuh reports "8.00", NVD writes "8.0"), which is why this compares them
    numerically instead of as strings.
    """
    if pl is None:
        return False
    parts = (m.get("criteria") or "").split(":")
    ver = parts[5] if len(parts) > 5 else "*"
    upd = parts[6] if len(parts) > 6 else "*"
    if ver in ("*", "-", "") or _vcmp(installed, ver) != 0:
        return False
    hit = _SAP_CPE_PL_RE.match(upd or "")
    if hit:
        return int(hit.group(1)) == pl
    # ':-' is the base release with no patch applied — level 0.
    return upd == "-" and pl == 0


def _in_range(installed: str, m: dict) -> bool:
    """True if installed version satisfies one NVD cpeMatch entry."""
    sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding")
    eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding")
    if not any([sI, sE, eI, eE]):
        # exact version in the CPE criteria (criteria[5]); equal only.
        crit = m.get("criteria", "")
        parts = crit.split(":")
        ver = parts[5] if len(parts) > 5 else "*"
        if ver in ("*", "-", ""):
            return False  # wildcard → would match everything → skip (FP)
        return _vcmp(installed, ver) == 0
    if sI is not None and (_vcmp(installed, sI) is None or _vcmp(installed, sI) < 0):
        return False
    if sE is not None and (_vcmp(installed, sE) is None or _vcmp(installed, sE) <= 0):
        return False
    if eI is not None and (_vcmp(installed, eI) is None or _vcmp(installed, eI) > 0):
        return False
    if eE is not None and (_vcmp(installed, eE) is None or _vcmp(installed, eE) >= 0):
        return False
    return True


# ---------- sources ----------
def _query_osv(eco: str, name: str, version: str) -> List[dict]:
    try:
        with httpx.Client(timeout=HTTP_TIMEOUT, headers={"User-Agent": "TrueVuln/1.0"}) as c:
            r = c.post(OSV_QUERY_URL, json={"package": {"name": name, "ecosystem": eco}, "version": version})
            if r.status_code != 200:
                return []
            data = r.json()
    except (httpx.HTTPError, ValueError) as e:
        logger.debug("OSV query failed %s/%s@%s: %s", eco, name, version, e)
        return []
    out = []
    for v in data.get("vulns", []) or []:
        cid = v.get("id", "")
        aliases = v.get("aliases", []) or []
        cve = cid if cid.upper().startswith("CVE-") else next((a for a in aliases if a.upper().startswith("CVE-")), None)
        if not cve:
            continue
        out.append({"cve": cve.upper(), "cvss": None, "severity": None, "fixed": None})
    return out


def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List[dict]:
    api_key = os.getenv("NVD_API_KEY", "").strip()
    headers = {"apiKey": api_key} if api_key else None
    url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
    sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
    items = None
    for attempt in range(4):
        try:
            with httpx.Client(timeout=HTTP_TIMEOUT) as c:
                r = c.get(url, headers=headers)
        except httpx.HTTPError as e:
            logger.debug("NVD cpe query error %s@%s: %s", cpe, version, e)
        else:
            if r.status_code == 200:
                try:
                    items = r.json().get("vulnerabilities", []) or []
                except ValueError:
                    items = []
                break
            if r.status_code not in (429, 502, 503, 504):
                items = []  # permanent (e.g. 404/400) → genuinely empty, cacheable
                break
            logger.debug("NVD %s for %s@%s (try %d)", r.status_code, cpe, version, attempt + 1)
        # NVD 2.0 throws 503 under load even with a key → back off generously.
        time.sleep(max(3.0, sleep) * (attempt + 1))
    time.sleep(sleep)
    if items is None:
        raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")

    prod_token = ":".join(cpe.split(":")[3:5])  # vendor:product
    out = []
    for it in items:
        cve_obj = it.get("cve", {})
        cve_id = cve_obj.get("id", "")
        if not cve_id.upper().startswith("CVE-"):
            continue
        matched = False
        fixed = None
        tsws: set = set()
        for cfg in cve_obj.get("configurations", []) or []:
            for node in cfg.get("nodes", []) or []:
                for m in node.get("cpeMatch", []) or []:
                    crit = m.get("criteria") or ""
                    if prod_token not in crit:
                        continue
                    if not m.get("vulnerable", True):
                        continue
                    ok = (_sap_affected(m, version, sap_pl) if sap_pl is not None
                          else _in_range(version, m))
                    if ok:
                        matched = True
                        parts = crit.split(":")
                        tsws.add(parts[9] if len(parts) > 9 else "*")  # target_sw
                        fixed = fixed or m.get("versionEndExcluding")
        if not matched:
            continue
        cvss, sev = _nvd_cvss(cve_obj)
        out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
                    "fixed": fixed, "tsw": sorted(tsws)})
    return out


def _nvd_cvss(cve_obj: dict) -> Tuple[Optional[float], Optional[str]]:
    metrics = cve_obj.get("metrics", {}) or {}
    for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
        arr = metrics.get(key) or []
        if arr:
            d = arr[0].get("cvssData", {})
            score = d.get("baseScore")
            sev = (d.get("baseSeverity") or arr[0].get("baseSeverity") or "").lower() or None
            try:
                return (float(score) if score is not None else None), sev
            except (TypeError, ValueError):
                return None, sev
    return None, None


# ---------- cache ----------
def _cache_get(db: Session, product_key: str, version: str) -> Optional[List[dict]]:
    from app.models.app_cve_cache import AppCveCache
    row = (db.query(AppCveCache)
             .filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
             .first())
    if not row or not row.fetched_at:
        return None
    if datetime.now() - row.fetched_at > CACHE_TTL:
        return None
    try:
        return json.loads(row.cves or "[]")
    except json.JSONDecodeError:
        return None


def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) -> None:
    from app.models.app_cve_cache import AppCveCache
    row = (db.query(AppCveCache)
             .filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
             .first())
    if row:
        row.cves = json.dumps(cves)
        row.fetched_at = datetime.now()
    else:
        db.add(AppCveCache(product_key=product_key, version=version,
                           cves=json.dumps(cves), fetched_at=datetime.now()))
    db.commit()


CACHE_PREFIX = "v2:"  # bump → ignores pre-target_sw cache rows (stale FPs)


def lookup_cves(db: Session, entry: dict, version: str,
                sap_pl: Optional[int] = None) -> List[dict]:
    """Cached (product, version) → CVE list."""
    key = CACHE_PREFIX + entry["key"]
    # The patch level is part of what was asked, so it must be part of the
    # cache identity — otherwise PL 12 serves PL 3's answer.
    cache_ver = version if sap_pl is None else f"{version}+pl{sap_pl}"
    cached = _cache_get(db, key, cache_ver)
    if cached is not None:
        return cached
    try:
        if entry["kind"] == "osv":
            cves = _query_osv(entry["eco"], entry["oname"], version)
        else:
            cves = _query_nvd_cpe(entry["cpe"], version, sap_pl=sap_pl)
    except _TransientNVD as e:
        logger.warning("app-cve: %s — not caching, will retry next run", e)
        return []
    _cache_put(db, key, cache_ver, cves)
    return cves


# ---------- upsert + scan ----------
def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: list,
            touched: Optional[set] = None, vendor: Optional[str] = None) -> None:
    from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
    sev_map = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
               "medium": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low,
               "none": VulnerabilitySeverity.none}
    cve_id = c["cve"].upper()
    if touched is not None:
        touched.add(cve_id)
    existing = (db.query(Vulnerability)
                  .filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
                  .first())
    if existing:
        existing.add_source("app-scan")
        # Keep EVERY affected product visible: one finding is unique per
        # (cve, asset), so a CVE hitting two products on the same host (Chrome
        # AND Edge — different build schemes entirely) would otherwise show
        # only whichever scanner wrote first.
        from app.services.audit_events import record_affected_package
        record_affected_package(db, existing, name=pkg_name, version=version,
                                fixed_version=c.get("fixed"), source="app-scan")
        if not existing.package_name:
            existing.package_name = pkg_name[:255]
        if vendor and not existing.package_vendor:
            existing.package_vendor = vendor[:255]
        # Re-detected with the CURRENT inventory version → refresh it. Fill-only
        # left the first-ever version on the row (tester: Firefox showed
        # 'Installed: 150.0.3' while 152.0.5 was on the box).
        if version:
            existing.package_version = version[:100]
        # Backfill metric/fix data once better info arrives (e.g. cvelistV5 now
        # carries the CVSS the first import lacked) — don't overwrite existing.
        if existing.cvss_score is None and c.get("cvss") is not None:
            existing.cvss_score = c["cvss"]
        if c.get("fixed") and not existing.fixed_version:
            existing.fixed_version = c["fixed"]
        if c.get("severity") and existing.severity == VulnerabilitySeverity.medium:
            existing.severity = sev_map.get(c["severity"].lower(), existing.severity)
        from app.services.audit_events import reopen_if_patched
        reopen_if_patched(db, existing, reason="App CVE scan detects this CVE on the host again", source="app_scan")
        try:
            existing.refresh_scores()
        except Exception:
            pass
        return
    sev = sev_map.get((c.get("severity") or "").lower(), VulnerabilitySeverity.medium)
    row = Vulnerability(
        cve_id=cve_id, asset_id=asset.id,
        cvss_score=c.get("cvss"), severity=sev,
        status=VulnerabilityStatus.open,
        title=f"{pkg_name} {version} — {cve_id}"[:500],
        package_name=pkg_name[:255], package_version=version[:100],
        package_vendor=(vendor[:255] if vendor else None),
        fixed_version=(c.get("fixed") or None),
        detected_at=datetime.now(),
        sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
    )
    db.add(row)
    db.flush()
    from app.services.audit_events import record_affected_package
    record_affected_package(db, row, name=pkg_name, version=version,
                            fixed_version=c.get("fixed"), source="app-scan")
    # CVE metrics (CVSS/EPSS/KEV) are properties of the CVE, not of one host.
    # Only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, so an MSRC-only
    # finding often has no score of its own while the SAME CVE on another asset
    # already does (tester: CVE-2026-16423 showed 8.8 on the Chrome row and
    # '-' / priority 0 on the Edge row). Inherit from a sibling before scoring.
    try:
        if row.cvss_score is None:
            from app.services.vuln_override_service import apply_canonical_from_siblings
            apply_canonical_from_siblings(db, row)
    except Exception as e:
        logger.debug("sibling metric inherit failed (%s): %s", row.cve_id, e)
    try:
        row.refresh_scores()
    except Exception:
        pass
    new_ids.append(row.id)


def _is_citrix_shim(pkg: dict) -> bool:
    """Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
    vendor 'Delivered by Citrix') for software that is NOT installed on the
    box — matching it produced ancient-CVE false positives (tester:
    CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
    vendor = (pkg.get("vendor") or "").lower()
    return "citrix" in vendor


def filter_inventory(packages: list) -> list:
    """Drop entries that are not real local installs, ONCE, at the source.

    These stubs carry a made-up product name and a placeholder version ("SAP
    Business Client 1.0", "Firefox 1.0"), so every consumer that matches them
    finds ancient CVEs. Each scan path used to filter for itself, which meant
    every new path started out unfiltered — the SAP path did, and it reported
    a published-app stub as a finding. Filtering the list once, where the
    inventory is fetched, is the only version of this that stays true as paths
    are added.
    """
    keep, dropped = [], 0
    for p in packages or []:
        if _is_citrix_shim(p):
            dropped += 1
            continue
        keep.append(p)
    if dropped:
        logger.debug("inventory: dropped %d delivered/published stub(s)", dropped)
    return keep


def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[list] = None,
                        touched: Optional[set] = None) -> int:
    """Match an asset's installed software to CVEs (curated + precise).
    Returns findings upserted. Caller commits."""
    if new_ids is None:
        new_ids = []
    count = 0
    seen: set = set()
    for pkg in packages or []:
        name = (pkg.get("name") or "").strip()
        version = (pkg.get("version") or "").strip()
        if not name:
            continue
        if _is_citrix_shim(pkg):
            continue
        entry = resolve_product(name)
        if not entry:
            continue
        eff_ver = _effective_version(name, version, entry)
        if not eff_ver:
            continue
        cver = _clean_version(eff_ver)
        if not cver:
            continue  # distro/rpm version → Wazuh's domain, skip (no NVD noise)
        # SAP is versioned as release + patch level, and the level decides
        # everything — an unreadable one means we report nothing rather than
        # everything ever filed against the release.
        sap_pl = _sap_patch_level(name, version) if entry.get("sap") else None
        if entry.get("sap") and sap_pl is None:
            logger.debug("app-cve: no SAP patch level in %r / %r — skipping",
                         name, version)
            continue
        key = (entry["key"], cver if sap_pl is None else f"{cver}+pl{sap_pl}")
        if key in seen:
            continue
        seen.add(key)
        try:
            cves = lookup_cves(db, entry, cver, sap_pl=sap_pl)
        except Exception as e:
            logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
            continue
        plat = _os_family(asset.operating_system or "")
        for c in cves:
            if not _platform_ok(c.get("tsw"), plat):
                continue  # CVE is for a different OS platform (e.g. Firefox-iOS)
            try:
                before = len(new_ids)
                _upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
                        vendor=(pkg.get("vendor") or None))
                count += 1 if len(new_ids) > before else 0
            except Exception as e:
                logger.debug("app-cve upsert failed (%s on %s): %s", c.get("cve"), asset.id, e)
    return count


def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = None) -> int:
    """OS-level CVEs from asset.operating_system + asset.os_version
    (iOS/iPadOS). Returns findings. Caller commits."""
    e = _resolve_os(asset.operating_system or "")
    if not e:
        return 0
    cver = _clean_version(asset.os_version or "")
    if not cver:
        return 0
    try:
        cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"]}, cver)
    except Exception as ex:
        logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
        return 0
    plat = _os_family(asset.operating_system or "")
    count = 0
    for c in cves:
        if not _platform_ok(c.get("tsw"), plat):
            continue
        before = len(new_ids)
        try:
            _upsert(db, asset, e["label"], asset.os_version or cver, c, new_ids, touched=touched)
            count += 1 if len(new_ids) > before else 0
        except Exception as ex:
            logger.debug("app-cve OS upsert failed (%s on %s): %s", c.get("cve"), asset.id, ex)
    return count


def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
    """Reconcile OPEN app-scan findings not re-detected this run: DROP the
    app-scan source (same contract as the Nessus backfill), and mark patched
    only once no source is left. The old skip-if-cross-confirmed rule dead-
    locked: a Chrome CVE seen by app-scan AND Defender was never closed by
    either reconcile (each deferred to the other), so a patched host kept an
    open cross-confirmed finding forever (tester: Chrome 150.0.7871.125
    installed, fix .115, finding still open). Caller must have had a real
    inventory this run."""
    from sqlalchemy import or_
    from app.models.vulnerability import Vulnerability, VulnerabilityStatus
    rows = (db.query(Vulnerability)
              .filter(Vulnerability.asset_id == asset.id,
                      Vulnerability.status == VulnerabilityStatus.open,
                      Vulnerability.sources.contains('"app-scan"'))
              .all())
    resolved = 0
    for v in rows:
        if v.cve_id in touched_cves:
            continue
        v.remove_source("app-scan")
        reason = (f"App CVE scan no longer detects this CVE on {asset.hostname} "
                  f"(software updated/removed past the vulnerable version)")
        if v.source_list:
            # Another scanner still reports it → it stays open under their
            # claim. We only ever retract OUR OWN source. (An inventory-based
            # override that closed over a lone Wazuh claim used to live here;
            # it produced false negatives in four different ways — multi-stream
            # fixes, 2.x minor lines, MSI build numbers in the version field,
            # and stale inventory from disconnected agents — so it was removed.
            # A single stored fixed_version cannot validate an arbitrary
            # inventory version string.)
            continue
        old_status = v.status
        v.status = VulnerabilityStatus.patched
        v.patched_at = datetime.now()
        resolved += 1
        # Revisionssicher: one status-change row (feeds both the global audit
        # log AND the per-CVE Change History). user_id=None = automated.
        try:
            from app.routers.vulnerabilities import log_vulnerability_change
            log_vulnerability_change(
                db, None, v.id, old_status, v.status,
                reason=reason,
                cve_id=v.cve_id,
                source="app_scan",
            )
        except Exception as e:
            logger.warning("audit log for app-scan auto-resolve failed (vuln_id=%s): %s", v.id, e)
    return resolved


def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
    """Scan all assets with software inventory (Wazuh packages + Intune
    detectedApps) → app-scan CVEs, then enrich the new ones."""
    from app.models.asset import Asset, AssetSource
    stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
    new_ids: list = []
    logger.info("App CVE scan starting: NVD key %s",
                "present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")

    wazuh = None
    graph = None
    try:
        from app.integrations.wazuh_client import WazuhClient
        from app.auth.setting_crypto import read_setting_value
        raw = read_setting_value(db, "wazuh_config")
        if raw:
            cfg = json.loads(raw)
            if all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
                wazuh = WazuhClient(base_url=cfg.get("api_url"), username=cfg.get("username"),
                                    password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
                                    indexer_username=cfg.get("indexer_username"),
                                    indexer_password=cfg.get("indexer_password"),
                                    verify_ssl=cfg.get("verify_ssl", False))
    except Exception as e:
        logger.debug("app-cve: wazuh client unavailable: %s", e)
    try:
        from app.services.intune_service import load_intune_config, _build_client
        icfg = load_intune_config(db)
        if icfg:
            graph = _build_client(icfg)
    except Exception as e:
        logger.debug("app-cve: graph client unavailable: %s", e)

    # cvelistV5 reverse index (curated products) — catches CVEs NVD hasn't
    # CPE'd yet / filed under a different CPE product string. Cached; built by
    # the nightly job. Absent → that pass is skipped (logged).
    cve5_index = {}
    try:
        from app.services import cvelistv5_scan_service
        cve5_index = cvelistv5_scan_service.load_index(db) or {}
        if not cve5_index:
            # No cached index → build it now so a manual scan works the same
            # as the nightly job (downloads/walks the cvelistV5 ZIP — slow on
            # first run, then cached + refreshed nightly).
            logger.info("app-cve: cvelistV5 index missing — building now (one-time, may take a few minutes)")
            cve5_index = cvelistv5_scan_service.build_product_index(db) or {}
    except Exception as e:
        logger.warning("app-cve: cvelistV5 index build/load failed: %s", e)

    q = db.query(Asset)
    if asset_id is not None:
        q = q.filter(Asset.id == asset_id)
    for asset in q.all():
        touched = False
        touched_cves: set = set()  # every CVE re-detected this run → reconcile base
        # OS-level CVEs (iOS/iPadOS) — version already in the DB, no client needed.
        try:
            n = scan_asset_os(db, asset, new_ids, touched=touched_cves)
            if n:
                stats["findings"] += n
                touched = True
        except Exception as e:
            stats["errors"].append(f"asset {asset.id} os: {e}")

        # Windows OS CVEs from cvelistV5 — family-matched and release-bounded
        # (see scan_asset_os). Needs no software inventory, so it runs for every
        # Windows asset.
        if cve5_index:
            try:
                from app.services import cvelistv5_scan_service
                n = cvelistv5_scan_service.scan_asset_os(
                    db, asset, cve5_index, new_ids, touched=touched_cves)
                if n:
                    stats["findings"] += n
                    touched = True
            except Exception as e:
                stats["errors"].append(f"asset {asset.id} win-os: {e}")

            # Apple OS CVEs from cvelistV5. scan_asset_os above covers Apple's
            # CVEs from NVD only, and NVD's Apple enrichment lags by days —
            # those findings existed nowhere but Defender until now.
            try:
                from app.services import cvelistv5_scan_service
                n = cvelistv5_scan_service.scan_asset_os_apple(
                    db, asset, cve5_index, new_ids, touched=touched_cves)
                if n:
                    stats["findings"] += n
                    touched = True
            except Exception as e:
                stats["errors"].append(f"asset {asset.id} apple-os: {e}")

        # Package-level CVEs (Wazuh syscollector / Intune detectedApps).
        packages: list = []
        try:
            if asset.wazuh_agent_id and wazuh:
                packages = wazuh.get_packages(asset.wazuh_agent_id) or []
            elif asset.intune_device_id and graph:
                packages = graph.get_detected_apps(asset.intune_device_id) or []
        except Exception as e:
            stats["errors"].append(f"asset {asset.id}: {e}")
            packages = []
        # Filter once, here — every path below shares this list.
        packages = filter_inventory(packages)
        if packages:
            stats["findings"] += scan_asset_packages(db, asset, packages, new_ids, touched=touched_cves)
            if cve5_index:
                try:
                    from app.services import cvelistv5_scan_service
                    stats["findings"] += cvelistv5_scan_service.scan_asset(db, asset, packages, cve5_index, new_ids, touched=touched_cves)
                except Exception as e:
                    stats["errors"].append(f"asset {asset.id} cvelistv5: {e}")
                # SAP desktop clients — cvelistV5 states a patch-level RANGE, so
                # it also covers levels published after the record was written,
                # which NVD's per-level enumeration cannot.
                try:
                    from app.services import cvelistv5_scan_service
                    stats["findings"] += cvelistv5_scan_service.scan_asset_sap(
                        db, asset, packages, cve5_index, new_ids, touched=touched_cves)
                except Exception as e:
                    stats["errors"].append(f"asset {asset.id} sap: {e}")
            # GitHub REPO advisories (Notepad++ …): upstream-published CVEs that
            # reach neither NVD nor cvelistV5, so no other path can see them.
            # Writes through _upsert with the app-scan source → the reconcile
            # below closes them once the host updates.
            try:
                from app.services import github_repo_advisory_service
                stats["repo_advisory_findings"] = stats.get("repo_advisory_findings", 0) + \
                    github_repo_advisory_service.scan_asset(
                        db, asset, packages, new_ids, touched=touched_cves)
            except Exception as e:
                stats["errors"].append(f"asset {asset.id} repo-advisories: {e}")
            touched = True
            # MSRC fixed-build scan for installed MS software (SharePoint):
            # Microsoft ships no version ranges, so this is the only source that
            # knows the patch state. Separate source ('msrc') + its own reconcile.
            try:
                from app.services import msrc_scan_service
                m_index = msrc_scan_service.load_index(db)
                if not m_index:
                    # Absent (or retired by a version bump) → build it now so a
                    # manual scan behaves like the nightly job, instead of
                    # silently skipping every MSRC product.
                    logger.info("app-cve: MSRC index missing — building now")
                    m_index = msrc_scan_service.build_product_index(db) or {}
                if m_index:
                    m_touched: set = set()
                    stats["msrc_findings"] = stats.get("msrc_findings", 0) + \
                        msrc_scan_service.scan_asset_packages(
                            db, asset, packages, m_index, new_ids, touched=m_touched)
                    stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
                        msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
                    # OS-level MSRC findings too. Their scan+reconcile lived
                    # only in run_msrc_scan (nightly / its own button), so a
                    # patched Windows host kept its MSRC OS findings open until
                    # that separate job happened to run — the app scan looked
                    # like it had done nothing (tester).
                    os_touched: set = set()
                    stats["msrc_os_findings"] = stats.get("msrc_os_findings", 0) + \
                        msrc_scan_service.scan_asset(db, asset, m_index, new_ids,
                                                     touched=os_touched)
                    stats["msrc_os_resolved"] = stats.get("msrc_os_resolved", 0) + \
                        msrc_scan_service.resolve_stale_os(db, asset, os_touched)
            except Exception as e:
                stats["errors"].append(f"asset {asset.id} msrc: {e}")
            # Auto-resolve: an app-scan-only finding no longer re-detected means
            # the software was updated/removed past it. Only safe when we had a
            # real inventory this run (packages non-empty) — else we'd close
            # everything on a transient fetch failure.
            #
            # AND only on a LIVE asset. A disconnected Wazuh agent still serves
            # its last stored syscollector data, so the scan "succeeds" on data
            # that may be months old — closing findings from it is unprovable
            # (tester: a disconnected host ended up with zero CVEs while Wazuh
            # still listed 31 for it). Wazuh's own vuln sync skips inactive
            # agents too, so nothing would ever reopen them.
            from app.models.asset import AssetStatus
            if asset.status == AssetStatus.ACTIVE:
                stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_app_findings(
                    db, asset, touched_cves)
            else:
                stats["resolve_skipped_inactive"] = stats.get("resolve_skipped_inactive", 0) + 1

        if touched:
            stats["assets"] += 1
            db.commit()

    if graph:
        graph.close()
    stats["new"] = len(new_ids)

    # Enrich + audit the new app-scan CVEs.
    if new_ids:
        try:
            from app.services.audit_events import audit_new_vulnerabilities
            audit_new_vulnerabilities(db, new_ids, source="app-scan")
            db.commit()
        except Exception as e:
            logger.debug("app-cve detected-audit failed: %s", e)
        try:
            from app.models.vulnerability import Vulnerability
            from app.services.enrichment_service import enrich_vulnerabilities
            fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
            if fresh:
                enrich_vulnerabilities(db, fresh)
                # New-CVE email notifications (was Wazuh/Nessus-only — app-scan
                # findings silently skipped notifications). Same digest path.
                from app.services.email_service import dispatch_new_vuln_notifications
                stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
        except Exception as e:
            logger.debug("app-cve enrichment/notify failed: %s", e)

    logger.info("App CVE scan: %d assets, %d findings (%d new, %d auto-resolved)",
                stats["assets"], stats["findings"], stats["new"], stats.get("resolved", 0))
    return stats
