"""
Microsoft Graph API client (app-only / client-credentials).

Used to pull Intune (MDM/UEM) inventory — managed devices and their
detected apps — as a third asset/software source next to Wazuh and Nessus.

Auth: client-credentials. A bearer token is fetched from
login.microsoftonline.com and cached until shortly before expiry, mirroring
the token-cache pattern in app/integrations/wazuh_client.py. No msal /
azure-identity dependency — plain httpx.

Required Entra app (Application) permissions + admin consent:
  DeviceManagementManagedDevices.Read.All   (devices + detected apps)
"""
import logging
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional

import httpx
from tenacity import (
    retry, stop_after_attempt, wait_exponential, retry_if_exception_type,
)

logger = logging.getLogger(__name__)

GRAPH_BASE = "https://graph.microsoft.com/v1.0"
LOGIN_BASE = "https://login.microsoftonline.com"
_DETECTED_APPS_MIN_GAP = 0.3  # seconds between get_detected_apps calls on one client
GRAPH_SCOPE = "https://graph.microsoft.com/.default"


class GraphAPIError(Exception):
    """Base exception for Microsoft Graph errors."""


class GraphAuthError(GraphAPIError):
    """Token acquisition / authentication failure."""


class GraphClient:
    def __init__(
        self,
        tenant_id: str,
        client_id: str,
        client_secret: str,
        verify_ssl: bool = True,
    ):
        if not all([tenant_id, client_id, client_secret]):
            raise ValueError("Intune/Graph requires tenant_id, client_id and client_secret.")
        self.tenant_id = tenant_id
        self.client_id = client_id
        self.client_secret = client_secret
        self.verify_ssl = verify_ssl

        self._token: Optional[str] = None
        self._token_expires_at: Optional[datetime] = None
        self._v1_detected_apps_ok = True  # flips off after the first v1.0 400
        self._last_detected_apps_call = 0.0  # paces get_detected_apps across a device loop

        # connect fails fast (unreachable), read generous for paged calls.
        self.client = httpx.Client(
            verify=verify_ssl,
            timeout=httpx.Timeout(30.0, connect=8.0),
            limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
        )

    # ---- auth ----------------------------------------------------------
    def _authenticate(self) -> str:
        url = f"{LOGIN_BASE}/{self.tenant_id}/oauth2/v2.0/token"
        data = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": GRAPH_SCOPE,
        }
        try:
            r = self.client.post(url, data=data)
            if r.status_code in (400, 401):
                # AAD returns error_description with the real cause.
                detail = ""
                try:
                    detail = r.json().get("error_description", "")[:200]
                except Exception:
                    detail = r.text[:200]
                raise GraphAuthError(f"token request rejected ({r.status_code}): {detail}")
            r.raise_for_status()
            payload = r.json()
        except httpx.HTTPError as e:
            raise GraphAuthError(f"token request failed: {e}") from e

        token = payload.get("access_token")
        if not token:
            raise GraphAuthError("no access_token in token response")
        expires_in = int(payload.get("expires_in", 3600))
        self._token = token
        # refresh 2 min before expiry
        self._token_expires_at = datetime.now() + timedelta(seconds=max(60, expires_in - 120))
        return token

    def _ensure_token(self) -> str:
        if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
            return self._authenticate()
        return self._token

    # ---- requests ------------------------------------------------------
    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=1, max=8),
        retry=retry_if_exception_type((httpx.ReadTimeout, httpx.WriteTimeout)),
    )
    def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        token = self._ensure_token()
        for attempt in range(4):
            r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
                                              "Accept": "application/json"}, params=params)
            if r.status_code == 429:
                # Honour Retry-After (Graph sets it); cap so a bad header can't hang us.
                wait = r.headers.get("Retry-After")
                delay = min(int(wait), 60) if (wait or "").isdigit() else (attempt + 1) * 5
                logger.debug("Graph 429, waiting %ss (try %d)", delay, attempt + 1)
                time.sleep(delay)
                continue
            break
        if r.status_code == 429:
            raise GraphAPIError("Graph rate limit (429) after retries")
        if r.status_code >= 400:
            raise GraphAPIError(f"Graph GET {url} -> {r.status_code}: {r.text[:200]}")
        return r.json()

    def _get_all(self, path: str, params: Optional[Dict[str, Any]] = None,
                 max_pages: int = 200) -> List[dict]:
        """Follow @odata.nextLink pagination, return the flattened value list."""
        url = f"{GRAPH_BASE}{path}"
        out: List[dict] = []
        pages = 0
        while url and pages < max_pages:
            data = self._get(url, params=params)
            out.extend(data.get("value", []) or [])
            url = data.get("@odata.nextLink")
            params = None  # nextLink already carries the query
            pages += 1
        return out

    # ---- API surface ---------------------------------------------------
    def get_managed_devices(self) -> List[dict]:
        """All Intune managed devices."""
        fields = ("id,deviceName,operatingSystem,osVersion,complianceState,"
                  "lastSyncDateTime,manufacturer,model,serialNumber,"
                  "managedDeviceOwnerType,azureADDeviceId,androidSecurityPatchLevel")
        return self._get_all("/deviceManagement/managedDevices",
                             params={"$select": fields})

    def get_detected_apps(self, device_id: str) -> List[dict]:
        """Detected (installed) apps for one managed device → {name, version}.

        Phase 2: feeds the existing EOL / M365 per-package detection.

        detectedApps is not expandable on managedDevices in the v1.0 Graph
        (returns HTTP 400). The reliable path is the dedicated navigation
        property on the beta endpoint; we try v1.0 $expand once, then fall
        back to beta's /detectedApps collection — and once v1.0 has 400'd we
        skip it for the rest of this client's life (no 400 per device).

        Paced: a device-by-device loop calling this in a tight sequence was
        sustaining 429s from Graph (no per-call delay was enough on its own).
        Enforce a minimum gap since the last call on this client.
        """
        gap = time.monotonic() - self._last_detected_apps_call
        if gap < _DETECTED_APPS_MIN_GAP:
            time.sleep(_DETECTED_APPS_MIN_GAP - gap)
        self._last_detected_apps_call = time.monotonic()

        # 1) v1.0 $expand (works on some tenants) — only until it 400s once.
        if self._v1_detected_apps_ok:
            try:
                data = self._get(
                    f"{GRAPH_BASE}/deviceManagement/managedDevices/{device_id}",
                    params={"$expand": "detectedApps"},
                )
                apps = data.get("detectedApps")
                if apps is not None:
                    return self._map_apps(apps)
            except GraphAPIError as e:
                self._v1_detected_apps_ok = False
                logger.debug("v1.0 detectedApps $expand unsupported, using beta: %s", e)

        # 2) beta dedicated navigation collection (paginated)
        try:
            out: List[dict] = []
            url = (f"https://graph.microsoft.com/beta/deviceManagement/"
                   f"managedDevices/{device_id}/detectedApps")
            pages = 0
            while url and pages < 50:
                data = self._get(url)
                out.extend(self._map_apps(data.get("value", []) or []))
                url = data.get("@odata.nextLink")
                pages += 1
            return out
        except GraphAPIError as e:
            logger.debug("beta detectedApps failed for %s: %s", device_id, e)
            return []

    @staticmethod
    def _map_apps(apps: list) -> List[dict]:
        out = []
        for a in apps or []:
            name = (a.get("displayName") or "").strip()
            if name:
                out.append({"name": name, "version": (a.get("version") or "").strip(),
                            "vendor": (a.get("publisher") or "").strip() or None})
        return out

    def test_connection(self) -> dict:
        """Acquire a token + read one device → connectivity check."""
        try:
            self._ensure_token()
        except GraphAuthError as e:
            return {"ok": False, "step": "auth", "error": str(e)}
        try:
            data = self._get(f"{GRAPH_BASE}/deviceManagement/managedDevices",
                             params={"$top": 1, "$select": "id,deviceName"})
            # $count needs ConsistencyLevel; cheap proxy: did we get a page.
            sample = len(data.get("value", []) or [])
            return {"ok": True, "tenant": self.tenant_id, "device_sample": sample}
        except GraphAPIError as e:
            return {"ok": False, "step": "devices", "error": str(e)}

    def close(self) -> None:
        try:
            self.client.close()
        except Exception:
            pass
