"""
Microsoft Defender for Endpoint (TVM) → real per-device CVEs.

Phase 3 of the Intune integration. Reuses the Entra app from intune_config
(toggle `defender_tvm`) but talks to the Defender API. Maps each Defender
machine to an existing asset (by computerDnsName) and upserts REAL CVE
rows (source='defender'), which then enrich via the normal EPSS/KEV/
cvelistV5 + multi-source-remediation paths.
"""
import json
import logging
from datetime import datetime
from typing import Optional

from sqlalchemy.orm import Session

from app.models.asset import Asset

logger = logging.getLogger(__name__)

SOURCE_NAME = "defender"

_SEV_MAP = {
    "critical": "critical", "high": "high", "medium": "medium",
    "low": "low", "informational": "none", "none": "none",
}


def _severity(raw: Optional[str]):
    from app.models.vulnerability import VulnerabilitySeverity
    return {
        "critical": VulnerabilitySeverity.critical,
        "high": VulnerabilitySeverity.high,
        "medium": VulnerabilitySeverity.medium,
        "low": VulnerabilitySeverity.low,
        "none": VulnerabilitySeverity.none,
    }.get(_SEV_MAP.get((raw or "").lower(), "medium"), VulnerabilitySeverity.medium)


def _match_asset(db: Session, machine: dict):
    """Match a Defender machine to an asset by stable id
    (defender_machine_id → aad_device_id → computerDnsName). Matching on the
    Entra/AAD device id first merges the machine onto the SAME asset the Intune
    sync created (usually the cleaner name), instead of forking a second asset
    off Defender's management-name computerDnsName."""
    mid = (machine.get("id") or "").strip() or None
    aad_id = (machine.get("aadDeviceId") or "").strip() or None
    dns = (machine.get("computerDnsName") or "").strip()

    def _pin(a):
        if mid and not a.defender_machine_id:
            a.defender_machine_id = mid
        if aad_id and not a.aad_device_id:
            a.aad_device_id = aad_id

    if mid:
        a = db.query(Asset).filter(Asset.defender_machine_id == mid).first()
        if a:
            _pin(a)
            return a
    if aad_id:
        a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
        if a:
            _pin(a)
            return a
    short = dns.split(".")[0] if dns else ""
    for cand in [c for c in (dns, short) if c]:
        a = db.query(Asset).filter(Asset.hostname.ilike(cand)).first()
        if a:
            _pin(a)
            return a
    if short:
        a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
        if a:
            _pin(a)
            return a
    return None


def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None,
                vendor: Optional[str] = None) -> None:
    from app.models.vulnerability import Vulnerability, VulnerabilityStatus
    cve_id = (vuln.get("id") or "").strip().upper()
    if not cve_id.startswith("CVE-"):
        return
    cvss = vuln.get("cvssV3")
    try:
        cvss = float(cvss) if cvss is not None else None
    except (TypeError, ValueError):
        cvss = None
    sev = _severity(vuln.get("severity"))

    existing = (
        db.query(Vulnerability)
        .filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
        .first()
    )
    # Defender reports the CVE, not the build it sits on: the installed version
    # is never in the payload, and for an OS-level CVE the software label is
    # missing entirely — those rows showed an empty package and an empty
    # "Installed", which reads as "we know nothing" when the asset record has
    # had the OS and its version all along (tester, CVE-2026-64726 on iPhones).
    label = software or (asset.operating_system or None)
    installed = asset.os_version if not software else None

    if existing:
        existing.add_source(SOURCE_NAME)
        # Fill the affected-software/package column if it was empty.
        if label and not existing.package_name:
            existing.package_name = label[:255]
        if installed and not existing.package_version:
            existing.package_version = installed[:100]
        if vendor and not existing.package_vendor:
            existing.package_vendor = vendor[:255]
        from app.services.audit_events import reopen_if_patched
        reopen_if_patched(db, existing, reason="Defender TVM reports this CVE on the device again", source="defender")
        try:
            existing.refresh_scores()
        except Exception:
            pass
        return

    row = Vulnerability(
        cve_id=cve_id,
        asset_id=asset.id,
        cvss_score=cvss,
        severity=sev,
        status=VulnerabilityStatus.open,
        title=(vuln.get("name") or cve_id)[:500],
        description=(vuln.get("description") or None),
        package_name=(label[:255] if label else None),
        package_version=(installed[:100] if installed else None),
        package_vendor=(vendor[:255] if vendor else None),
        detected_at=datetime.now(),
        sources=json.dumps([SOURCE_NAME]),
        first_detected_by=SOURCE_NAME,
    )
    db.add(row)
    db.flush()
    try:
        row.refresh_scores()
    except Exception:
        pass
    new_ids.append(row.id)


def _resolve_stale(db: Session, asset, seen_cves: set) -> int:
    """Mark defender-only OPEN findings on this asset patched when Defender no
    longer reports them (device remediated). Leaves findings any other scanner
    still reports. Writes a revisionssicher status-change row per resolve."""
    from app.models.vulnerability import Vulnerability, VulnerabilityStatus
    rows = (db.query(Vulnerability)
              .filter(Vulnerability.asset_id == asset.id,
                      Vulnerability.status == VulnerabilityStatus.open,
                      Vulnerability.sources.contains('"defender"'))
              .all())
    resolved = 0
    for v in rows:
        if v.cve_id in seen_cves:
            continue
        # Drop OUR source; close only when nobody else still reports it (same
        # contract as the Nessus backfill — the old skip-if-cross-confirmed
        # rule deadlocked with the app-scan reconcile and left patched hosts
        # with permanently open cross-confirmed findings).
        v.remove_source(SOURCE_NAME)
        if v.source_list:
            continue
        old_status = v.status
        v.status = VulnerabilityStatus.patched
        v.patched_at = datetime.now()
        resolved += 1
        try:
            from app.routers.vulnerabilities import log_vulnerability_change
            log_vulnerability_change(
                db, None, v.id, old_status, v.status,
                reason=f"Defender TVM no longer reports this CVE on {asset.hostname} (device remediated)",
                cve_id=v.cve_id, source="defender_sync",
            )
        except Exception as e:
            logger.warning("audit log for defender auto-resolve failed (vuln_id=%s): %s", v.id, e)
    return resolved


def run_defender_sync(db: Session) -> dict:
    """Pull Defender TVM CVEs and upsert per matched asset. Returns stats."""
    from app.services.intune_service import load_intune_config
    from app.integrations.defender_client import DefenderClient

    cfg = load_intune_config(db)
    if not cfg or not cfg.get("defender_tvm"):
        return {"skipped": "defender_tvm disabled"}

    client = DefenderClient(cfg["tenant_id"], cfg["client_id"], cfg["client_secret"],
                            verify_ssl=cfg.get("verify_ssl", True))
    stats = {"machines": 0, "matched": 0, "unmatched": 0, "cve_rows": 0, "new": 0, "errors": []}
    new_ids: list = []
    try:
        machines = client.get_machines()
    except Exception as e:
        client.close()
        raise RuntimeError(f"Defender machines fetch failed: {e}") from e

    # (machineId, CVE) → affected software string. One tenant-wide export
    # call; the per-machine /vulnerabilities endpoint omits software.
    sw_map: dict = {}
    try:
        for r in client.get_software_vulnerabilities_by_machine():
            mid = (r.get("deviceId") or r.get("machineId") or "").strip()
            cve = (r.get("cveId") or "").strip().upper()
            if not mid or not cve:
                continue
            vendor = (r.get("softwareVendor") or r.get("productVendor") or "").strip()
            name = (r.get("softwareName") or r.get("productName") or "").strip()
            ver = (r.get("softwareVersion") or r.get("productVersion") or "").strip()
            label = " ".join(x for x in (vendor, name, ver) if x).strip()
            if label and (mid, cve) not in sw_map:
                sw_map[(mid, cve)] = {"label": label, "vendor": vendor or None}
    except Exception as e:
        logger.debug("defender software map build failed: %s", e)

    for m in machines:
        stats["machines"] += 1
        asset = _match_asset(db, m)
        if not asset:
            stats["unmatched"] += 1
            continue
        stats["matched"] += 1
        seen_cves: set = set()
        try:
            vulns = client.get_machine_vulnerabilities(m["id"])
            for v in vulns:
                cve = (v.get("id") or "").strip().upper()
                if cve:
                    seen_cves.add(cve)
                sw = sw_map.get((m.get("id", ""), cve)) or {}
                _upsert_cve(db, asset, v, new_ids,
                            software=sw.get("label"), vendor=sw.get("vendor"))
                stats["cve_rows"] += 1
            # Auto-resolve defender-only findings this machine no longer reports.
            # Guarded to non-empty responses so a transient/clean read can't
            # mass-close (same safety as the Nessus/app-scan backfills).
            if seen_cves:
                stats["resolved"] = stats.get("resolved", 0) + _resolve_stale(db, asset, seen_cves)
        except Exception as e:
            stats["errors"].append(f"machine {m.get('computerDnsName')}: {e}")
        db.commit()

    client.close()
    stats["new"] = len(new_ids)

    # Initial detected-audit + metric/date enrichment for the new CVEs.
    if new_ids:
        try:
            from app.services.audit_events import audit_new_vulnerabilities
            audit_new_vulnerabilities(db, new_ids, source="defender")
            db.commit()
        except Exception as e:
            logger.debug("defender 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). Same 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("defender enrichment/notify failed: %s", e)

    logger.info("Defender TVM sync: %s", {k: v for k, v in stats.items() if k != "errors"})
    return stats
