"""
Microsoft 365 Apps CVE detection (Plan P).

Microsoft 365 Apps (formerly Office 365 ProPlus) security fixes are NOT
published to NVD and are NOT detected by Wazuh's vulnerability detector —
they only live on one human-readable Microsoft Learn page:

    https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates

There is no Microsoft API. So we parse that page, learn the latest patched
build per update channel, compare it against the build Wazuh's syscollector
reports as installed, and create real-CVE vulnerability rows for every
monthly update the host is behind on.

Build logic (verified against the tester's example):
  installed 16.0.19929.20172  vs  Monthly Enterprise Channel 19929.20162
  -> 20172 >= 20162  -> UNAFFECTED  (no CVEs)
  installed < a section's channel build -> AFFECTED -> attach that
  section's CVEs (union across every section the host is behind on).

Channel mapping (tester's rule): the deployed channel isn't in the
syscollector name, so we approximate it from the product name —
"...enterprise..." -> Monthly Enterprise Channel, else Current Channel.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple

import httpx
import lxml.html
from sqlalchemy.orm import Session

from app.models.setting import Setting

logger = logging.getLogger(__name__)

M365_SECURITY_URL = (
    "https://learn.microsoft.com/en-us/officeupdates/"
    "microsoft365-apps-security-updates"
)

# ---------- cache (settings table) ----------
M365_CACHE_KEY = "m365_security_cache"
M365_CACHE_TS_KEY = "m365_security_cache_updated_at"
M365_TTL_HOURS = 24

# ---------- toggle ----------
SETTING_M365_ENABLED = "m365_detection_enabled"

HTTP_TIMEOUT = 30.0

# Build line: "Monthly Enterprise Channel: Version 2604 (Build 19929.20162)"
_BUILD_RE = re.compile(
    r"([A-Za-z0-9()/ \-]+?):\s*Version\s+(\d{3,4})\s*\(\s*Build\s+(\d+\.\d+)\s*\)"
)
# Month-day-year heading that delimits each monthly section.
_DATE_RE = re.compile(
    r"\b(January|February|March|April|May|June|July|August|September|"
    r"October|November|December)\s+(\d{1,2}),\s+(\d{4})\b"
)
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)

# Product-name -> channel approximation.
CHANNEL_MONTHLY_ENTERPRISE = "Monthly Enterprise Channel"
CHANNEL_CURRENT = "Current Channel"


class M365Error(Exception):
    """Raised when the M365 security page cannot be fetched/parsed."""


# ============================================================
# build helpers
# ============================================================

def parse_build(version: str) -> Optional[Tuple[int, int]]:
    """'16.0.19929.20172' or '19929.20172' -> (19929, 20172).

    Microsoft 365 build numbers are the last two dotted segments
    (BBBBB.RRRRR). The leading '16.0.' is the Office major and is
    constant, so we ignore it.
    """
    if not version:
        return None
    nums = re.findall(r"\d+", version)
    if len(nums) < 2:
        return None
    try:
        return int(nums[-2]), int(nums[-1])
    except ValueError:
        return None


def channel_for_product(product_name: str) -> str:
    """Tester's rule: name contains 'enterprise' -> MEC, else Current."""
    return (
        CHANNEL_MONTHLY_ENTERPRISE
        if "enterprise" in (product_name or "").lower()
        else CHANNEL_CURRENT
    )


def is_m365_apps(product_name: str) -> bool:
    """True for syscollector entries like 'Microsoft 365 Apps for enterprise'."""
    n = (product_name or "").lower()
    return "microsoft 365 apps" in n or "office 365 proplus" in n


# ============================================================
# page fetch + parse
# ============================================================

def _parse_security_page(html: str) -> List[dict]:
    """Parse the MS365 security page into a list of monthly releases.

    Each release: {
        "date": "May 12, 2026",
        "channel_builds": {channel_name: [(major, rev), ...]},  # max = newest
        "cves": ["CVE-2026-40361", ...],   # every CVE in the section
    }
    Releases are returned in page order (newest first).
    """
    # Flatten to text in document order. The page is a linear sequence of
    # date headings -> channel/build lines -> product headings -> CVE
    # bullets, so segmenting the flattened text by date heading is robust
    # against markup churn.
    doc = lxml.html.fromstring(html)
    for bad in doc.xpath("//script | //style | //nav | //header | //footer"):
        bad.getparent().remove(bad)
    body = doc.xpath("//main") or [doc]
    text = body[0].text_content()

    # Find date-heading anchors and slice between them.
    matches = list(_DATE_RE.finditer(text))
    releases: List[dict] = []
    for i, m in enumerate(matches):
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        section = text[start:end]
        date_label = f"{m.group(1)} {m.group(2)}, {m.group(3)}"

        channel_builds: Dict[str, List[Tuple[int, int]]] = {}
        for bm in _BUILD_RE.finditer(section):
            channel = bm.group(1).strip()
            build = parse_build(bm.group(3))
            if build:
                channel_builds.setdefault(channel, []).append(build)
        if not channel_builds:
            # Not a real release section (e.g. intro paragraph mentioning
            # a date) — skip.
            continue

        cves = sorted({c.upper() for c in _CVE_RE.findall(section)})
        if not cves:
            continue

        releases.append({
            "date": date_label,
            "channel_builds": channel_builds,
            "cves": cves,
        })
    return releases


def _load_cache(db: Session) -> Optional[List[dict]]:
    ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first()
    cache = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first()
    if not ts or not cache or not cache.value:
        return None
    try:
        if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=M365_TTL_HOURS):
            return None
        return json.loads(cache.value)
    except (ValueError, json.JSONDecodeError):
        return None


def _store_cache(db: Session, releases: List[dict]) -> None:
    s = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first()
    if s:
        s.value = json.dumps(releases)
    else:
        db.add(Setting(key=M365_CACHE_KEY, value=json.dumps(releases),
                       description="MS365 Apps security-updates parse cache (24h)"))
    ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first()
    if ts:
        ts.value = datetime.now().isoformat()
    else:
        db.add(Setting(key=M365_CACHE_TS_KEY, value=datetime.now().isoformat(),
                       description="Timestamp of last MS365 page parse"))
    db.commit()


def fetch_security_data(db: Session, force_refresh: bool = False) -> List[dict]:
    """Return parsed monthly releases, cached 24h in the settings table."""
    if not force_refresh:
        cached = _load_cache(db)
        if cached is not None:
            return cached
    try:
        with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
                          headers={"User-Agent": "TrueVuln/1.0"}) as client:
            resp = client.get(M365_SECURITY_URL)
            resp.raise_for_status()
            html = resp.text
    except httpx.HTTPError as e:
        raise M365Error(f"could not fetch MS365 security page: {e}") from e

    releases = _parse_security_page(html)
    if not releases:
        raise M365Error("MS365 page parsed to zero releases — layout changed?")
    _store_cache(db, releases)
    logger.info("MS365: parsed %d monthly releases", len(releases))
    return releases


# ============================================================
# detection
# ============================================================

def _channel_max(release: dict, channel: str) -> Optional[Tuple[int, int]]:
    """Newest (max) build for `channel` in a release, as a tuple."""
    builds = release.get("channel_builds", {}).get(channel)
    if not builds:
        return None
    # builds may be lists from JSON -> normalise to tuples
    return max(tuple(b) for b in builds)


def _os_owned_cve_ids(db: Session) -> set:
    """CVE ids the cvelistV5 Windows-OS registry owns. The MS 365 Apps page
    dumps OS-level components (GDI, MSXML, …) under its "Office suite" heading —
    CVEs that are really Windows-OS bugs Office just bundles (e.g.
    CVE-2026-50387, a Windows GDI vuln). Those belong to scan_asset_os, which
    knows the correct build + MSRC KB; attributing them to M365 is wrong AND
    upsert clobbers the correct OS finding on the same (cve, asset) row."""
    try:
        from app.services import cvelistv5_scan_service
        idx = cvelistv5_scan_service.load_index(db) or {}
        return {(e.get("cve") or "").upper()
                for e in (idx.get("windows") or []) if e.get("cve")}
    except Exception as e:
        logger.debug("M365: OS-CVE dedup index unavailable: %s", e)
        return set()


def detect_missing_cves(
    releases: List[dict],
    *,
    installed_version: str,
    channel: str,
) -> dict:
    """Compare an installed M365 build against the parsed releases.

    Returns {
        "affected": bool,
        "installed_build": "19929.20172" or None,
        "latest_build": "19929.20162" or None,   # newest patched, this channel
        "missing_cves": [ ... ],                  # union, deduped
        "behind_releases": [ "May 12, 2026", ... ],
    }
    """
    out = {
        "affected": False,
        "installed_build": None,
        "latest_build": None,
        "missing_cves": [],
        "behind_releases": [],
    }
    installed = parse_build(installed_version)
    if not installed:
        return out
    out["installed_build"] = f"{installed[0]}.{installed[1]}"

    # Newest patched build for this channel across the whole page.
    channel_builds = [b for r in releases if (b := _channel_max(r, channel))]
    if not channel_builds:
        return out
    latest = max(channel_builds)
    out["latest_build"] = f"{latest[0]}.{latest[1]}"

    if installed >= latest:
        return out  # fully patched -> unaffected

    # Behind: union CVEs from every section whose channel build the host
    # has not reached.
    out["affected"] = True
    cve_set: set = set()
    for r in releases:
        b = _channel_max(r, channel)
        if b and installed < b:
            cve_set.update(r.get("cves", []))
            out["behind_releases"].append(r.get("date"))
    out["missing_cves"] = sorted(cve_set)
    return out


def upsert_m365_vulnerability(
    db: Session,
    *,
    asset_id: int,
    cve_id: str,
    product_name: str,
    installed_version: str,
    fixed_build: Optional[str],
) -> Tuple[Optional[int], bool]:
    """Create/refresh a real-CVE M365 vuln row. Returns (id, was_created).

    CVSS/severity are left as a neutral placeholder; the nightly
    enrichment (EPSS/KEV/NVD dates) and the Correct-CVSS job refine them.
    These are real CVE ids, so they enrich like any other CVE.
    """
    from app.models.vulnerability import (
        Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
    )

    cve_id = cve_id.upper()
    existing = (
        db.query(Vulnerability)
        .filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset_id)
        .first()
    )
    title = f"{product_name} — {cve_id} (Microsoft 365 Apps security update)"
    desc = (
        f"{cve_id} affects {product_name} and is fixed by a Microsoft 365 "
        f"Apps security update not yet applied on this host.\n"
        f"Installed build: {installed_version}. Fixed in build: "
        f"{fixed_build or 'unknown'} or later — update via the configured "
        f"Office update channel.\n\n"
        f"Detection source: this finding comes from the host's installed "
        f"Microsoft 365 Apps build (Wazuh syscollector inventory) compared "
        f"against the Microsoft 365 Apps security-updates release notes — "
        f"M365 Apps fixes are NOT published to NVD and are NOT seen by "
        f"Wazuh's vulnerability detector.\n"
        f"Severity / CVSS / dates are enriched from MSRC + CISA Vulnrichment "
        f"/ cvelistV5 for this real CVE id (see the Remediation section for "
        f"the Microsoft (MSRC) KB / advisory details)."
    )

    if existing:
        existing.title = title[:500]
        existing.description = desc
        existing.package_name = product_name[:255]
        existing.package_version = installed_version[:100]
        existing.fixed_version = (fixed_build or None)
        from app.services.audit_events import reopen_if_patched
        reopen_if_patched(db, existing, reason="Microsoft 365 Apps check reports this CVE again", source="m365_check")
        existing.detected_at = datetime.now()
        try:
            existing.refresh_scores()
        except Exception:
            pass
        return existing.id, False

    vuln = Vulnerability(
        cve_id=cve_id,
        asset_id=asset_id,
        cvss_score=None,
        severity=VulnerabilitySeverity.medium,  # placeholder; enrichment refines
        status=VulnerabilityStatus.open,
        title=title[:500],
        description=desc,
        package_name=product_name[:255],
        package_version=installed_version[:100],
        fixed_version=(fixed_build or None),
        detected_at=datetime.now(),
        sources='["microsoft365-apps"]',
        first_detected_by="m365_check",
    )
    db.add(vuln)
    db.flush()
    try:
        vuln.refresh_scores()
    except Exception:
        pass
    # Revisionssicher: initial detected-event for the new M365 finding.
    try:
        from app.services.audit_events import audit_new_vulnerabilities
        audit_new_vulnerabilities(db, [vuln.id], source="m365_check")
    except Exception:
        pass
    return vuln.id, True


# ============================================================
# orchestration (shared by the endpoint and the nightly job)
# ============================================================

_M365_SOURCE = "microsoft365-apps"


def _resolve_stale_m365(db: Session, asset, still_affected: set) -> int:
    """Mark M365-only OPEN findings patched when the host's current build has
    caught up (CVE no longer in the missing set). Without this the check only
    ever ADDED rows: a host that updated Office kept the old finding open with a
    stale installed build forever (upsert refreshes package_version only while
    the CVE is still missing). Mirrors the Defender/app-scan reconcile: drop OUR
    source, close only when nobody else still reports it. Caller guarantees an
    M365 install was actually seen on this asset (else 'patched' is unprovable).
    """
    from app.models.vulnerability import Vulnerability, VulnerabilityStatus
    rows = (db.query(Vulnerability)
              .filter(Vulnerability.asset_id == asset.id,
                      Vulnerability.status == VulnerabilityStatus.open,
                      Vulnerability.sources.contains(f'"{_M365_SOURCE}"'))
              .all())
    resolved = 0
    for v in rows:
        if v.cve_id and v.cve_id.upper() in still_affected:
            continue
        v.remove_source(_M365_SOURCE)
        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"Installed Microsoft 365 Apps build on {asset.hostname} "
                       f"now includes the fix for this CVE",
                cve_id=v.cve_id, source="m365_check",
            )
        except Exception as e:
            logger.warning("audit log for M365 auto-resolve failed (vuln_id=%s): %s", v.id, e)
    return resolved


def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
    """Walk Wazuh-linked assets, detect M365-Apps CVE exposure, upsert rows.

    `wazuh` is an already-configured WazuhClient (the caller owns its
    lifecycle, matching the eol-check pattern).
    """
    from app.models.asset import Asset

    releases = fetch_security_data(db)

    os_cve_ids = _os_owned_cve_ids(db)

    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()

    stats = {
        "assets_scanned": 0,
        "m365_installs": 0,
        "assets_affected": 0,
        "cve_findings_total": 0,
        "cve_findings_new": 0,
        "releases_parsed": len(releases),
        "errors": [],
    }
    touched_cves: set = set()

    for asset in assets:
        try:
            pkgs = 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

        # An asset can list the same product per language pack (de-de,
        # en-us, .proof, ...) — collapse to one detection per build.
        seen_builds: set = set()
        asset_affected = False
        m365_install_seen = False
        keep_open: set = set()  # CVEs still missing on the current build
        for pkg in pkgs:
            name = (pkg.get("name") or "").strip()
            version = (pkg.get("version") or "").strip()
            if not name or not version or not is_m365_apps(name):
                continue
            stats["m365_installs"] += 1
            m365_install_seen = True
            channel = channel_for_product(name)
            key = (channel, version)
            if key in seen_builds:
                continue
            seen_builds.add(key)

            result = detect_missing_cves(
                releases, installed_version=version, channel=channel
            )
            if not result["affected"]:
                continue
            asset_affected = True
            for cve_id in result["missing_cves"]:
                if cve_id.upper() in os_cve_ids:
                    continue  # Windows-OS CVE — owned by scan_asset_os, not M365
                try:
                    _, created = upsert_m365_vulnerability(
                        db,
                        asset_id=asset.id,
                        cve_id=cve_id,
                        product_name=name,
                        installed_version=version,
                        fixed_build=result["latest_build"],
                    )
                    stats["cve_findings_total"] += 1
                    touched_cves.add(cve_id.upper())
                    keep_open.add(cve_id.upper())
                    if created:
                        stats["cve_findings_new"] += 1
                except Exception as e:
                    logger.warning(
                        "M365 upsert failed (%s on asset %s): %s",
                        cve_id, asset.id, e,
                    )
        if asset_affected:
            stats["assets_affected"] += 1
        # Resolve findings the host has since patched — only when we actually
        # saw an M365 install (else 'patched' is unprovable, same guard as the
        # Defender/app-scan reconcile against an empty read).
        if m365_install_seen:
            stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_m365(
                db, asset, keep_open)

    db.commit()

    # Pull real metrics for the M365 CVEs right at check-in: these are real
    # CVE ids absent from Wazuh/NVD, so the CVSS/severity placeholder is
    # corrected from the vulnrichment → cvelistV5 → NVD cascade, and dates
    # via the enrichment service. The custom source note in `description`
    # is preserved (the override path never touches description).
    if touched_cves:
        cve_list = sorted(touched_cves)
        try:
            from app.services.vuln_override_service import correct_vulnerability_scores
            cstats = correct_vulnerability_scores(db, cve_ids=cve_list)
            stats["cvss_corrected"] = cstats.get("updated", 0)
        except Exception as e:
            logger.warning("M365: CVSS correction failed (non-fatal): %s", e)
        try:
            from app.models.vulnerability import Vulnerability
            from app.services.enrichment_service import enrich_vulnerabilities
            fresh = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(cve_list)).all()
            if fresh:
                enrich_vulnerabilities(db, fresh)  # EPSS/KEV/EUVD + NVD dates
        except Exception as e:
            logger.warning("M365: enrichment failed (non-fatal): %s", e)
        try:
            stats["meta_filled"] = apply_real_cve_metadata(db, cve_list)
        except Exception as e:
            logger.warning("M365: real CVE metadata fill failed (non-fatal): %s", e)

    logger.info(
        "M365 check: %d assets scanned, %d installs, %d affected, "
        "%d CVE rows (%d new), %d cvss-corrected",
        stats["assets_scanned"], stats["m365_installs"],
        stats["assets_affected"], stats["cve_findings_total"],
        stats["cve_findings_new"], stats.get("cvss_corrected", 0),
    )
    return stats


_M365_SOURCE_NOTE = (
    "\n\n— Detected via the Microsoft 365 Apps security-updates page "
    "(installed build vs. patched channel build; not published to NVD and "
    "not seen by Wazuh). Title/description from CVE.org cvelistV5; "
    "severity/CVSS/dates via MSRC + CISA Vulnrichment."
)


def _fetch_cve_title_desc(cve_id: str) -> Tuple[Optional[str], Optional[str]]:
    """Real CVE title + English description from CVE.org cvelistV5 raw."""
    m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper())
    if not m:
        return None, None
    year, num = m.group(1), m.group(2)
    url = (f"https://raw.githubusercontent.com/CVEProject/cvelistV5/main/"
           f"cves/{year}/{int(num) // 1000}xxx/{cve_id.upper()}.json")
    try:
        with httpx.Client(timeout=15.0, follow_redirects=True,
                          headers={"User-Agent": "TrueVuln/1.0"}) as c:
            r = c.get(url)
            if r.status_code != 200:
                return None, None
            cna = (r.json().get("containers") or {}).get("cna") or {}
    except (httpx.HTTPError, ValueError):
        return None, None
    title = cna.get("title")
    desc = None
    for d in cna.get("descriptions") or []:
        if (d.get("lang") or "").lower().startswith("en"):
            desc = d.get("value")
            break
    return title, desc


def apply_real_cve_metadata(db: Session, cve_ids: list) -> int:
    """Fill the REAL CVE title + description (cvelistV5) on M365 findings,
    keeping a trailing note that the finding originated from the M365 Apps
    source. CVSS-correction deliberately leaves title/description alone, so
    this runs separately. Returns rows updated. Caller commits."""
    from app.models.vulnerability import Vulnerability
    updated = 0
    for cve_id in sorted({c.upper() for c in cve_ids if c}):
        title, desc = _fetch_cve_title_desc(cve_id)
        if not title and not desc:
            continue
        rows = (
            db.query(Vulnerability)
            .filter(
                Vulnerability.cve_id == cve_id,
                Vulnerability.first_detected_by == "m365_check",
            )
            .all()
        )
        for v in rows:
            if title:
                v.title = title[:500]
            if desc:
                v.description = desc.strip() + _M365_SOURCE_NOTE
            updated += 1
    if updated:
        db.commit()
    return updated


def run_m365_for_packages(db: Session, asset, packages: list) -> int:
    """Source-agnostic M365-Apps CVE detection for one asset's installed
    apps (e.g. Intune detectedApps). Same build-vs-channel logic as
    run_m365_check; pulls real metrics for new CVEs. Returns findings
    upserted. Caller commits."""
    try:
        releases = fetch_security_data(db)
    except M365Error as e:
        logger.debug("M365-for-packages: security data unavailable: %s", e)
        return 0

    os_cve_ids = _os_owned_cve_ids(db)
    count = 0
    touched: set = set()
    seen: set = set()
    m365_install_seen = False
    keep_open: set = set()
    for pkg in packages or []:
        name = (pkg.get("name") or "").strip()
        version = (pkg.get("version") or "").strip()
        if not name or not version or not is_m365_apps(name):
            continue
        m365_install_seen = True
        channel = channel_for_product(name)
        key = (channel, version)
        if key in seen:
            continue
        seen.add(key)
        result = detect_missing_cves(releases, installed_version=version, channel=channel)
        if not result["affected"]:
            continue
        for cve_id in result["missing_cves"]:
            if cve_id.upper() in os_cve_ids:
                continue  # Windows-OS CVE — owned by scan_asset_os, not M365
            try:
                upsert_m365_vulnerability(
                    db, asset_id=asset.id, cve_id=cve_id, product_name=name,
                    installed_version=version, fixed_build=result["latest_build"],
                )
                count += 1
                touched.add(cve_id.upper())
                keep_open.add(cve_id.upper())
            except Exception as e:
                logger.warning("M365-for-packages upsert failed (%s on asset %s): %s", cve_id, asset.id, e)

    # Resolve findings the host has since patched (see run_m365_check).
    if m365_install_seen:
        _resolve_stale_m365(db, asset, keep_open)

    if touched:
        try:
            from app.services.vuln_override_service import correct_vulnerability_scores
            correct_vulnerability_scores(db, cve_ids=sorted(touched))
        except Exception as e:
            logger.debug("M365-for-packages CVSS correction failed: %s", e)
        try:
            apply_real_cve_metadata(db, sorted(touched))
        except Exception as e:
            logger.debug("M365-for-packages metadata fill failed: %s", e)
    return count
