"""
Microsoft Intune (MDM/UEM) inventory sync.

Pulls Intune managed devices via Microsoft Graph (app-only) and registers
them as assets (source=INTUNE), then runs OS-level EOL detection on each —
the same find-or-create + lifecycle-reconcile pattern as the Nessus sync.

Phase 2 (detectedApps → EOL/M365 per installed app) hooks in here too.
"""
import json
import logging
import re
from datetime import datetime
from typing import Optional

from sqlalchemy.orm import Session

from app.models.asset import Asset, AssetSource, AssetStatus

logger = logging.getLogger(__name__)

SOURCE_NAME = "intune"
SETTING_KEY = "intune_config"


def load_intune_config(db: Session) -> Optional[dict]:
    """Decrypt + parse intune_config, or None when not configured."""
    from app.auth.setting_crypto import read_setting_value
    raw = read_setting_value(db, SETTING_KEY)
    if not raw:
        return None
    try:
        cfg = json.loads(raw)
    except json.JSONDecodeError:
        logger.warning("intune_config is not valid JSON")
        return None
    if not all([cfg.get("tenant_id"), cfg.get("client_id"), cfg.get("client_secret")]):
        return None
    return cfg


def _build_client(cfg: dict):
    from app.integrations.graph_client import GraphClient
    return GraphClient(
        tenant_id=cfg["tenant_id"],
        client_id=cfg["client_id"],
        client_secret=cfg["client_secret"],
        verify_ssl=cfg.get("verify_ssl", True),
    )


# Intune's `deviceName` is the Entra/management name for supervised / userless
# / ABM iOS devices — a "<enrollment-GUID>_<Model>_<M/D/YYYY>_<time>" blob.
# Detect it so we can show a readable, stable name instead.
_MGMT_NAME_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_", re.I)


def _clean_device_name(device: dict) -> str:
    """Readable hostname. Graph fills `deviceName` with the management-name
    GUID blob for supervised/userless iOS; compose a stable name from
    model + serial instead. Falls back to whatever's there."""
    name = (device.get("deviceName") or "").strip()
    if name and not _MGMT_NAME_RE.match(name):
        return name
    model = (device.get("model") or "").strip()
    serial = (device.get("serialNumber") or "").strip()
    if model and serial:
        return f"{model}-{serial}"[:255]
    if model and device.get("id"):
        return f"{model}-{device['id'][:8]}"[:255]
    return name  # nothing better available


def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
    """Match an Intune device to an asset by stable id
    (intune_device_id → aad_device_id → hostname), else auto-create. Matching
    on ids first makes device renames (very common in autodeployments) a no-op
    — the asset is found by id and its hostname refreshed."""
    device_id = (device.get("id") or "").strip() or None
    aad_id = (device.get("azureADDeviceId") or "").strip() or None
    hostname = _clean_device_name(device)

    def _pin(a):
        # Backfill both ids on whatever we matched, so future syncs (and the
        # Defender sync) converge on this one asset.
        if device_id and not a.intune_device_id:
            a.intune_device_id = device_id
        if aad_id and not a.aad_device_id:
            a.aad_device_id = aad_id

    if device_id:
        a = db.query(Asset).filter(Asset.intune_device_id == device_id).first()
        if a:
            _pin(a)
            return a, "intune_id"

    if aad_id:
        a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
        if a:
            _pin(a)
            return a, "aad_id"

    short = hostname.split(".")[0] if hostname else ""
    for candidate in [c for c in (hostname, short) if c]:
        a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
        if a:
            _pin(a)
            return a, "hostname"
    if short:
        a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
        if a:
            _pin(a)
            return a, "hostname-fqdn-prefix"

    if auto_create and hostname:
        a = Asset(
            hostname=short or hostname,
            intune_device_id=device_id,
            aad_device_id=aad_id,
            source=AssetSource.INTUNE,
            status=AssetStatus.ACTIVE,
        )
        db.add(a)
        db.flush()
        logger.info("Intune sync: auto-created asset %s", a.hostname)
        return a, "created"

    return None, "skipped"


def _os_string(device: dict) -> str:
    """Intune operatingSystem is short ('Windows'/'macOS'); prefix so the
    EOL OS resolver recognises it like a Wazuh OS string."""
    os_name = (device.get("operatingSystem") or "").strip()
    if os_name.lower() == "windows":
        return "Microsoft Windows"
    return os_name


# Cross-process guard: only one Intune/Defender sync may touch the asset rows
# at a time. The router's module-level flag is per-worker, and the nightly
# scheduler runs in yet another context — two overlapping syncs update the same
# assets in different orders → Postgres deadlock. A Postgres advisory lock is
# global to the DB, so it serialises every caller.
_SYNC_ADVISORY_LOCK_KEY = 0x54560101  # arbitrary constant ("TV" + 01)


def _try_sync_lock(db: Session) -> bool:
    from sqlalchemy import text
    return bool(db.execute(text("SELECT pg_try_advisory_lock(:k)"),
                           {"k": _SYNC_ADVISORY_LOCK_KEY}).scalar())


def _release_sync_lock(db: Session) -> None:
    from sqlalchemy import text
    try:
        db.rollback()  # unlock must run outside a failed (deadlocked) txn
    except Exception:
        pass
    try:
        db.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": _SYNC_ADVISORY_LOCK_KEY})
        db.commit()
    except Exception:
        pass


def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
    """Sync Intune managed devices → assets + OS-EOL. Returns stats."""
    cfg = load_intune_config(db)
    if not cfg:
        raise RuntimeError("Intune is not configured (settings.intune_config missing/incomplete).")

    if not _try_sync_lock(db):
        logger.warning("Intune sync skipped — another Intune/Defender sync holds the lock")
        return {"skipped": "another sync already running"}
    try:
        return _run_intune_sync_locked(db, cfg)
    finally:
        _release_sync_lock(db)


def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
    from app.services import eol_service
    from app.services.asset_lifecycle import reconcile_intune_by_seen_ids

    auto_create = bool(cfg.get("auto_create_assets", True))
    detected_apps_enabled = bool(cfg.get("detected_apps", True))

    client = _build_client(cfg)
    stats = {
        "devices": 0, "assets_matched": 0, "assets_created": 0,
        "os_eol_findings": 0, "app_findings": 0,
        "assets_inactivated": 0, "assets_reactivated": 0, "errors": [],
    }
    seen_asset_ids: set = set()
    try:
        devices = client.get_managed_devices()
    except Exception as e:
        raise RuntimeError(f"Graph managedDevices fetch failed: {e}") from e

    for device in devices:
        stats["devices"] += 1
        try:
            asset, how = _find_or_create_asset(db, device, auto_create)
            if not asset:
                continue
            if how == "created":
                stats["assets_created"] += 1
            else:
                stats["assets_matched"] += 1
                # Rename tracking: matched by a stable id → adopt the current
                # (cleaned) device name so autodeploy renames propagate.
                new_name = _clean_device_name(device)
                if how in ("intune_id", "aad_id") and new_name and asset.hostname != new_name:
                    asset.hostname = new_name.split(".")[0] or new_name
            # refresh inventory fields
            os_name = _os_string(device)
            if os_name:
                asset.operating_system = os_name[:255]
            if device.get("osVersion"):
                asset.os_version = str(device["osVersion"])[:100]
            asset.last_scan = datetime.now()
            db.flush()
            if asset.id:
                seen_asset_ids.add(asset.id)

            # OS-level EOL
            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):
                    eol_service.upsert_eol_vulnerability(
                        db, asset_id=asset.id,
                        product_name=(asset.operating_system or "Operating System").strip(),
                        installed_version=(asset.os_version or os_status.release_name or "unknown"),
                        status=os_status,
                    )
                    stats["os_eol_findings"] += 1
            except Exception as e:
                logger.warning("Intune OS-EOL failed for %s: %s", asset.hostname, e)

            # Mobile device EOL/EOS (model) + Android patch-level staleness.
            try:
                from app.services import mobile_eol_service
                stats["mobile_eol_findings"] = (
                    stats.get("mobile_eol_findings", 0)
                    + mobile_eol_service.check_device(db, asset, device)
                )
            except Exception as e:
                logger.debug("Intune mobile-EOL failed for %s: %s", asset.hostname, e)

            # Phase 2 — detected apps → existing EOL/M365 per-package detection
            if detected_apps_enabled and device.get("id"):
                try:
                    pkgs = client.get_detected_apps(device["id"])
                    if pkgs:
                        stats["app_findings"] += _run_app_inventory(db, asset, pkgs)
                except Exception as e:
                    logger.debug("Intune detectedApps failed for %s: %s", asset.hostname, e)
        except Exception as e:
            stats["errors"].append(f"device {device.get('deviceName')}: {e}")

    db.commit()

    # Lifecycle reconcile (event-driven, id-keyed).
    try:
        recon = reconcile_intune_by_seen_ids(
            db, seen_asset_ids=seen_asset_ids,
            reason="not reported by the latest Intune sync",
        )
        stats["assets_inactivated"] = recon["inactivated"]
        stats["assets_reactivated"] = recon["reactivated"]
        db.commit()
    except Exception as e:
        logger.warning("Intune reconcile failed: %s", e)

    client.close()

    # Phase 3 — Defender for Endpoint TVM real CVEs (opt-in, separate API).
    if cfg.get("defender_tvm"):
        try:
            from app.services.defender_service import run_defender_sync
            dstats = run_defender_sync(db)
            stats["defender"] = {k: v for k, v in dstats.items() if k != "errors"}
        except Exception as e:
            logger.warning("Defender TVM sync failed (non-fatal): %s", e)

    logger.info(
        "Intune sync done: %d devices, %d matched, %d created, %d OS-EOL, "
        "%d app findings, %d inactivated, %d reactivated",
        stats["devices"], stats["assets_matched"], stats["assets_created"],
        stats["os_eol_findings"], stats["app_findings"],
        stats["assets_inactivated"], stats["assets_reactivated"],
    )
    return stats


def _run_app_inventory(db: Session, asset, packages: list) -> int:
    """Feed Intune detectedApps into the existing EOL + M365 detection.
    Returns number of findings upserted (best-effort)."""
    count = 0
    try:
        from app.services import eol_service
        count += eol_service.run_eol_for_packages(db, asset, packages)
    except Exception as e:
        logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
    try:
        from app.services import m365_service
        count += m365_service.run_m365_for_packages(db, asset, packages)
    except Exception as e:
        logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
    try:
        from app.services import app_cve_scanner_service
        count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
    except Exception as e:
        logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
    return count
