#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
# Copyright (C) 2026 Sudhir Chauhan
"""
Checkmk special agent for Microsoft 365 / Office 365.

Target: Checkmk 2.5.x
Uses only Python stdlib plus Checkmk's password-store API.
"""

from __future__ import annotations

import argparse
import csv
import datetime as dt
import hashlib
import io
import json
import os
from pathlib import Path
import re
import sys
import time
from typing import Any
import urllib.error
import urllib.parse
import urllib.request

from cmk.password_store.v1_unstable import parser_add_secret_option, resolve_secret_option


GRAPH = "https://graph.microsoft.com/v1.0"
TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
USER_AGENT = "checkmk-microsoft-365/0.0.1"


class GraphError(RuntimeError):
    def __init__(self, status: int | None, message: str, retry_after: str | None = None):
        super().__init__(message)
        self.status = status
        self.retry_after = retry_after


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


NO_REDIRECT = urllib.request.build_opener(NoRedirect())


def utc_now() -> dt.datetime:
    return dt.datetime.now(dt.timezone.utc)


def iso_z(value: dt.datetime) -> str:
    return value.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def json_dump(obj: Any) -> str:
    return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)


def emit_section(name: str, payload: Any) -> None:
    print(f"<<<{name}:sep(0)>>>")
    print(json_dump(payload))


def cache_root(tenant_id: str) -> Path:
    root = Path(os.environ.get("OMD_ROOT", "/tmp"))
    tenant_hash = hashlib.sha256(tenant_id.encode("utf-8")).hexdigest()[:16]
    path = root / "var" / "check_mk" / "microsoft_365_cache" / tenant_hash
    path.mkdir(parents=True, exist_ok=True)
    try:
        path.chmod(0o700)
    except OSError:
        pass
    return path


def read_cache(path: Path, max_age: int) -> Any | None:
    try:
        st = path.stat()
        if time.time() - st.st_mtime > max_age:
            return None
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError, json.JSONDecodeError):
        return None


def write_cache(path: Path, data: Any) -> None:
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json_dump(data), encoding="utf-8")
    os.replace(tmp, path)
    try:
        path.chmod(0o600)
    except OSError:
        pass


def clean_csv_rows(raw: bytes) -> list[dict[str, str]]:
    text = raw.decode("utf-8-sig", errors="replace")
    reader = csv.DictReader(io.StringIO(text))
    rows: list[dict[str, str]] = []
    for row in reader:
        clean: dict[str, str] = {}
        for k, v in row.items():
            if k is None:
                continue
            key = k.lstrip("\ufeff").strip()
            clean[key] = "" if v is None else v.strip()
        rows.append(clean)
    return rows


class GraphClient:
    def __init__(self, tenant_id: str, client_id: str, client_secret: str, timeout: int = 30):
        self.tenant_id = tenant_id
        self.client_id = client_id
        self.client_secret = client_secret
        self.timeout = timeout
        self.token = ""
        self.request_count = 0
        self.throttled = 0
        self.warnings: list[str] = []
        self.critical_errors: list[str] = []

    def authenticate(self) -> None:
        payload = urllib.parse.urlencode(
            {
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "scope": "https://graph.microsoft.com/.default",
                "grant_type": "client_credentials",
            }
        ).encode("utf-8")
        req = urllib.request.Request(
            TOKEN_URL.format(tenant=urllib.parse.quote(self.tenant_id)),
            data=payload,
            method="POST",
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "User-Agent": USER_AGENT,
            },
        )
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                data = json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace")
            raise GraphError(exc.code, f"OAuth token request failed: HTTP {exc.code}: {body[:500]}") from exc
        except Exception as exc:
            raise GraphError(None, f"OAuth token request failed: {exc}") from exc
        token = data.get("access_token")
        if not token:
            raise GraphError(None, "OAuth response did not contain access_token")
        self.token = token

    def _request(self, url: str, extra_headers: dict[str, str] | None = None) -> bytes:
        headers = {
            "Authorization": f"Bearer {self.token}",
            "Accept": "application/json",
            "User-Agent": USER_AGENT,
        }
        if extra_headers:
            headers.update(extra_headers)
        req = urllib.request.Request(url, headers=headers)
        self.request_count += 1
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                return resp.read()
        except urllib.error.HTTPError as exc:
            if exc.code == 429:
                self.throttled += 1
            body = exc.read().decode("utf-8", errors="replace")
            retry_after = exc.headers.get("Retry-After")
            try:
                msg = json.loads(body).get("error", {}).get("message") or body
            except Exception:
                msg = body
            raise GraphError(exc.code, f"HTTP {exc.code}: {msg[:500]}", retry_after) from exc
        except Exception as exc:
            raise GraphError(None, str(exc)) from exc

    def get_json(self, path_or_url: str, extra_headers: dict[str, str] | None = None) -> dict[str, Any]:
        url = path_or_url if path_or_url.startswith("http") else GRAPH + path_or_url
        raw = self._request(url, extra_headers)
        try:
            return json.loads(raw.decode("utf-8"))
        except Exception as exc:
            raise GraphError(None, f"Invalid JSON from Graph: {exc}") from exc

    def get_all(self, path: str, extra_headers: dict[str, str] | None = None) -> list[dict[str, Any]]:
        data = self.get_json(path, extra_headers)
        values = list(data.get("value", []))
        next_link = data.get("@odata.nextLink")
        while next_link:
            data = self.get_json(next_link, extra_headers)
            values.extend(data.get("value", []))
            next_link = data.get("@odata.nextLink")
        return values

    def get_report(self, endpoint: str) -> list[dict[str, str]]:
        url = f"{GRAPH}/reports/{endpoint}"
        req = urllib.request.Request(
            url,
            headers={
                "Authorization": f"Bearer {self.token}",
                "Accept": "text/csv",
                "User-Agent": USER_AGENT,
            },
        )
        self.request_count += 1
        location = None
        raw: bytes | None = None
        try:
            with NO_REDIRECT.open(req, timeout=self.timeout) as resp:
                raw = resp.read()
        except urllib.error.HTTPError as exc:
            if exc.code == 302:
                location = exc.headers.get("Location")
            else:
                if exc.code == 429:
                    self.throttled += 1
                body = exc.read().decode("utf-8", errors="replace")
                retry_after = exc.headers.get("Retry-After")
                try:
                    msg = json.loads(body).get("error", {}).get("message") or body
                except Exception:
                    msg = body
                raise GraphError(exc.code, f"HTTP {exc.code}: {msg[:500]}", retry_after) from exc

        if location:
            self.request_count += 1
            try:
                dl_req = urllib.request.Request(location, headers={"User-Agent": USER_AGENT})
                with urllib.request.urlopen(dl_req, timeout=self.timeout) as resp:
                    raw = resp.read()
            except urllib.error.HTTPError as exc:
                raise GraphError(exc.code, f"Report download failed: HTTP {exc.code}") from exc
            except Exception as exc:
                raise GraphError(None, f"Report download failed: {exc}") from exc

        if raw is None:
            raise GraphError(None, "Report response contained neither CSV content nor redirect URL")
        return clean_csv_rows(raw)


PERIOD_REPORTS: dict[str, str] = {
    "teams_activity": "getTeamsUserActivityCounts(period='{period}')",
    "teams_user_counts": "getTeamsUserActivityUserCounts(period='{period}')",
    "teams_device": "getTeamsDeviceUsageUserCounts(period='{period}')",
    "teams_device_distribution": "getTeamsDeviceUsageDistributionUserCounts(period='{period}')",
    "teams_team_activity": "getTeamsTeamActivityCounts(period='{period}')",
    "teams_team_distribution": "getTeamsTeamActivityDistributionCounts(period='{period}')",
    "teams_team_counts": "getTeamsTeamCounts(period='{period}')",
    "onedrive_activity_users": "getOneDriveActivityUserCounts(period='{period}')",
    "onedrive_activity_files": "getOneDriveActivityFileCounts(period='{period}')",
    "onedrive_usage_files": "getOneDriveUsageFileCounts(period='{period}')",
    "onedrive_usage_accounts": "getOneDriveUsageAccountCounts(period='{period}')",
    "onedrive_usage_storage": "getOneDriveUsageStorage(period='{period}')",
    "email_activity": "getEmailActivityCounts(period='{period}')",
    "email_users": "getEmailActivityUserCounts(period='{period}')",
    "email_apps": "getEmailAppUsageAppsUserCounts(period='{period}')",
    "email_app_users": "getEmailAppUsageUserCounts(period='{period}')",
    "email_versions": "getEmailAppUsageVersionsUserCounts(period='{period}')",
    "mailbox_counts": "getMailboxUsageMailboxCounts(period='{period}')",
    "mailbox_quota": "getMailboxUsageQuotaStatusMailboxCounts(period='{period}')",
    "mailbox_storage": "getMailboxUsageStorage(period='{period}')",
    "active_users": "getOffice365ActiveUserCounts(period='{period}')",
    "services_users": "getOffice365ServicesUserCounts(period='{period}')",
    "sharepoint_activity_users": "getSharePointActivityUserCounts(period='{period}')",
    "sharepoint_activity_files": "getSharePointActivityFileCounts(period='{period}')",
    "sharepoint_activity_pages": "getSharePointActivityPages(period='{period}')",
    "sharepoint_site_counts": "getSharePointSiteUsageSiteCounts(period='{period}')",
    "sharepoint_site_files": "getSharePointSiteUsageFileCounts(period='{period}')",
    "sharepoint_storage": "getSharePointSiteUsageStorage(period='{period}')",
    "sharepoint_pages": "getSharePointSiteUsagePages(period='{period}')",
    "groups_activity": "getOffice365GroupsActivityCounts(period='{period}')",
    "groups_count": "getOffice365GroupsActivityGroupCounts(period='{period}')",
    "groups_storage": "getOffice365GroupsActivityStorage(period='{period}')",
    "groups_files": "getOffice365GroupsActivityFileCounts(period='{period}')",
    "m365_app_users": "getM365AppUserCounts(period='{period}')",
    "m365_app_platform": "getM365AppPlatformUserCounts(period='{period}')",
}

STATIC_REPORTS: dict[str, str] = {
    "office_activation_counts": "getOffice365ActivationCounts",
    "office_activation_users": "getOffice365ActivationsUserCounts",
}


def summarize_report(rows: list[dict[str, str]]) -> dict[str, Any]:
    refresh_dates = sorted({r.get("Report Refresh Date", "") for r in rows if r.get("Report Refresh Date")})
    report_dates = sorted({r.get("Report Date", "") for r in rows if r.get("Report Date")})
    return {
        "status": "ok" if rows else "empty",
        "row_count": len(rows),
        "refresh_date": refresh_dates[-1] if refresh_dates else None,
        "latest_report_date": report_dates[-1] if report_dates else None,
        "rows": rows,
    }


def collect_reports(client: GraphClient, period: str) -> dict[str, Any]:
    reports: dict[str, Any] = {}
    definitions = {**{k: v.format(period=period) for k, v in PERIOD_REPORTS.items()}, **STATIC_REPORTS}
    for name, endpoint in definitions.items():
        try:
            reports[name] = summarize_report(client.get_report(endpoint))
        except GraphError as exc:
            # 400/404 often means a report is not applicable to the tenant/product.
            status = "unavailable" if exc.status in (400, 404) else "error"
            reports[name] = {
                "status": status,
                "row_count": 0,
                "refresh_date": None,
                "latest_report_date": None,
                "rows": [],
                "http_status": exc.status,
                "error": str(exc),
            }
            if status == "error":
                client.warnings.append(f"Report {name}: {exc}")
    return {
        "period": period,
        "collected_at": iso_z(utc_now()),
        "reports": reports,
    }


DURATION_RE = re.compile(
    r"^P(?:(?P<days>\d+(?:\.\d+)?)D)?(?:T(?:(?P<hours>\d+(?:\.\d+)?)H)?"
    r"(?:(?P<minutes>\d+(?:\.\d+)?)M)?(?:(?P<seconds>\d+(?:\.\d+)?)S)?)?$"
)


def duration_ms(value: Any) -> float | None:
    if not isinstance(value, str) or not value:
        return None
    m = DURATION_RE.match(value)
    if not m:
        return None
    days = float(m.group("days") or 0)
    hours = float(m.group("hours") or 0)
    minutes = float(m.group("minutes") or 0)
    seconds = float(m.group("seconds") or 0)
    return (days * 86400 + hours * 3600 + minutes * 60 + seconds) * 1000.0


def average(values: list[float]) -> float | None:
    return sum(values) / len(values) if values else None


def collect_qos(client: GraphClient, window_minutes: int, max_calls: int) -> dict[str, Any]:
    now = utc_now()
    if window_minutes > 0:
        start = now - dt.timedelta(minutes=window_minutes)
        query = urllib.parse.urlencode(
            {
                "$filter": f"startDateTime ge {iso_z(start)} and startDateTime lt {iso_z(now)}",
                "$select": "id,version,type,modalities,startDateTime,endDateTime,lastModifiedDateTime",
            }
        )
        path = f"/communications/callRecords?{query}"
    else:
        path = "/communications/callRecords"

    calls = client.get_all(path, {"Prefer": f"odata.maxpagesize={max(1, min(max_calls, 60))}"})
    calls = calls[:max_calls]

    jitter_avg: list[float] = []
    jitter_max: list[float] = []
    audio_jitter_avg: list[float] = []
    audio_jitter_max: list[float] = []
    rtt_avg: list[float] = []
    rtt_max: list[float] = []
    loss_avg: list[float] = []
    loss_max: list[float] = []
    video_loss_avg: list[float] = []
    bandwidth: list[float] = []
    audio_degradation: list[float] = []
    freeze_avg: list[float] = []
    frame_rate: list[float] = []

    sessions_count = 0
    segments_count = 0
    media_count = 0
    streams_count = 0
    failed_sessions = 0
    audio_streams = 0
    video_streams = 0
    screen_share_streams = 0

    for call in calls:
        call_id = call.get("id")
        if not call_id:
            continue
        encoded = urllib.parse.quote(str(call_id), safe="")
        detail = client.get_json(
            f"/communications/callRecords/{encoded}?$expand=sessions($expand=segments)"
        )
        for session in detail.get("sessions", []):
            sessions_count += 1
            if session.get("failureInfo"):
                failed_sessions += 1
            for segment in session.get("segments", []):
                segments_count += 1
                for media in segment.get("media", []):
                    media_count += 1
                    label = str(media.get("label", "")).lower()
                    for stream in media.get("streams", []):
                        streams_count += 1
                        ac = str(stream.get("audioCodec") or "").lower()
                        vc = str(stream.get("videoCodec") or "").lower()
                        if "screen" in label:
                            screen_share_streams += 1
                        elif ac and ac not in ("unknown", "invalid", "none"):
                            audio_streams += 1
                        elif vc and vc not in ("unknown", "invalid", "none"):
                            video_streams += 1

                        for key, target in (
                            ("averageJitter", jitter_avg),
                            ("maxJitter", jitter_max),
                            ("averageAudioNetworkJitter", audio_jitter_avg),
                            ("maxAudioNetworkJitter", audio_jitter_max),
                            ("averageRoundTripTime", rtt_avg),
                            ("maxRoundTripTime", rtt_max),
                            ("averageFreezeDuration", freeze_avg),
                        ):
                            val = duration_ms(stream.get(key))
                            if val is not None:
                                target.append(val)

                        for key, target, factor in (
                            ("averagePacketLossRate", loss_avg, 100.0),
                            ("maxPacketLossRate", loss_max, 100.0),
                            ("averageVideoPacketLossRate", video_loss_avg, 100.0),
                            ("averageBandwidthEstimate", bandwidth, 1.0),
                            ("averageAudioDegradation", audio_degradation, 1.0),
                            ("averageReceivedFrameRate", frame_rate, 1.0),
                        ):
                            value = stream.get(key)
                            if isinstance(value, (int, float)):
                                target.append(float(value) * factor)

    metrics: dict[str, float | int | None] = {
        "calls_analyzed": len(calls),
        "sessions_analyzed": sessions_count,
        "segments_analyzed": segments_count,
        "media_analyzed": media_count,
        "streams_analyzed": streams_count,
        "failed_sessions": failed_sessions,
        "audio_streams": audio_streams,
        "video_streams": video_streams,
        "screen_share_streams": screen_share_streams,
        "avg_jitter_ms": average(jitter_avg),
        "max_jitter_ms": max(jitter_max) if jitter_max else None,
        "avg_audio_network_jitter_ms": average(audio_jitter_avg),
        "max_audio_network_jitter_ms": max(audio_jitter_max) if audio_jitter_max else None,
        "avg_rtt_ms": average(rtt_avg),
        "max_rtt_ms": max(rtt_max) if rtt_max else None,
        "avg_packet_loss_percent": average(loss_avg),
        "max_packet_loss_percent": max(loss_max) if loss_max else None,
        "avg_video_packet_loss_percent": average(video_loss_avg),
        "avg_bandwidth_bps": average(bandwidth),
        "avg_audio_degradation": average(audio_degradation),
        "avg_freeze_ms": average(freeze_avg),
        "avg_received_frame_rate": average(frame_rate),
    }
    return {
        "enabled": True,
        "window_minutes": window_minutes,
        "max_calls": max_calls,
        "collected_at": iso_z(now),
        "metrics": metrics,
    }



def _to_int(value: Any) -> int | None:
    if value is None:
        return None
    text = str(value).strip().replace(",", "")
    if not text:
        return None
    try:
        return int(float(text))
    except ValueError:
        return None


def _to_bool(value: Any) -> bool | None:
    if value is None:
        return None
    text = str(value).strip().lower()
    if text in ("true", "yes", "1"):
        return True
    if text in ("false", "no", "0"):
        return False
    return None


def _safe_item_from_url(value: str) -> str:
    try:
        parsed = urllib.parse.urlparse(value)
        if parsed.netloc:
            result = parsed.netloc + (parsed.path.rstrip("/") or "/")
            return result
    except Exception:
        pass
    return value


def _detail_match(row: dict[str, str], fields: tuple[str, ...], pattern: re.Pattern[str]) -> bool:
    haystack = " ".join(str(row.get(field, "")) for field in fields)
    return bool(pattern.search(haystack))


def _normalize_mailbox(row: dict[str, str]) -> dict[str, Any] | None:
    item = row.get("User Principal Name") or row.get("Display Name")
    if not item:
        return None
    return {
        "item": item,
        "display_name": row.get("Display Name"),
        "is_deleted": _to_bool(row.get("Is Deleted")),
        "created_date": row.get("Created Date") or None,
        "last_activity_date": row.get("Last Activity Date") or None,
        "item_count": _to_int(row.get("Item Count")),
        "storage_used_bytes": _to_int(row.get("Storage Used (Byte)")),
        "issue_warning_quota_bytes": _to_int(row.get("Issue Warning Quota (Byte)")),
        "prohibit_send_quota_bytes": _to_int(row.get("Prohibit Send Quota (Byte)")),
        "prohibit_send_receive_quota_bytes": _to_int(row.get("Prohibit Send/Receive Quota (Byte)")),
        "deleted_item_count": _to_int(row.get("Deleted Item Count")),
        "deleted_item_size_bytes": _to_int(row.get("Deleted Item Size (Byte)")),
        "deleted_item_quota_bytes": _to_int(row.get("Deleted Item Quota (Byte)")),
        "has_archive": _to_bool(row.get("Has Archive")),
    }


def _normalize_onedrive(row: dict[str, str]) -> dict[str, Any] | None:
    item = row.get("Owner Principal Name") or row.get("Owner Display Name") or row.get("Site URL")
    if not item:
        return None
    return {
        "item": item,
        "display_name": row.get("Owner Display Name"),
        "site_url": row.get("Site URL"),
        "is_deleted": _to_bool(row.get("Is Deleted")),
        "last_activity_date": row.get("Last Activity Date") or None,
        "file_count": _to_int(row.get("File Count")),
        "active_file_count": _to_int(row.get("Active File Count")),
        "storage_used_bytes": _to_int(row.get("Storage Used (Byte)")),
        "storage_allocated_bytes": _to_int(row.get("Storage Allocated (Byte)")),
    }


def _normalize_sharepoint(row: dict[str, str]) -> dict[str, Any] | None:
    raw_url = row.get("Site URL") or ""
    item = _safe_item_from_url(raw_url) if raw_url else (row.get("Site Id") or row.get("Owner Display Name"))
    if not item:
        return None
    return {
        "item": item,
        "site_id": row.get("Site Id"),
        "site_url": raw_url or None,
        "owner_display_name": row.get("Owner Display Name"),
        "owner_principal_name": row.get("Owner Principal Name"),
        "root_web_template": row.get("Root Web Template"),
        "is_deleted": _to_bool(row.get("Is Deleted")),
        "last_activity_date": row.get("Last Activity Date") or None,
        "file_count": _to_int(row.get("File Count")),
        "active_file_count": _to_int(row.get("Active File Count")),
        "page_view_count": _to_int(row.get("Page View Count")),
        "visited_page_count": _to_int(row.get("Visited Page Count")),
        "storage_used_bytes": _to_int(row.get("Storage Used (Byte)")),
        "storage_allocated_bytes": _to_int(row.get("Storage Allocated (Byte)")),
    }


def _normalize_outlook_user(row: dict[str, str]) -> dict[str, Any] | None:
    item = row.get("User Principal Name") or row.get("Display Name")
    if not item:
        return None
    return {
        "item": item,
        "display_name": row.get("Display Name"),
        "is_deleted": _to_bool(row.get("Is Deleted")),
        "last_activity_date": row.get("Last Activity Date") or None,
        "send_count": _to_int(row.get("Send Count")),
        "receive_count": _to_int(row.get("Receive Count")),
        "read_count": _to_int(row.get("Read Count")),
        "meeting_created_count": _to_int(row.get("Meeting Created Count")),
        "meeting_interacted_count": _to_int(row.get("Meeting Interacted Count")),
        "assigned_products": row.get("Assigned Products") or None,
    }


DETAIL_FAMILIES = {
    "mailboxes": {
        "endpoint": "getMailboxUsageDetail(period='{period}')",
        "identity_fields": ("User Principal Name", "Display Name"),
        "normalizer": _normalize_mailbox,
    },
    "onedrive_accounts": {
        "endpoint": "getOneDriveUsageAccountDetail(period='{period}')",
        "identity_fields": ("Owner Principal Name", "Owner Display Name", "Site URL"),
        "normalizer": _normalize_onedrive,
    },
    "sharepoint_sites": {
        "endpoint": "getSharePointSiteUsageDetail(period='{period}')",
        "identity_fields": ("Site URL", "Owner Principal Name", "Owner Display Name"),
        "normalizer": _normalize_sharepoint,
    },
    "outlook_users": {
        "endpoint": "getEmailActivityUserDetail(period='{period}')",
        "identity_fields": ("User Principal Name", "Display Name"),
        "normalizer": _normalize_outlook_user,
    },
}


def collect_details(
    client: GraphClient,
    period: str,
    config: dict[str, dict[str, Any]],
    max_entities: int,
    include_deleted: bool,
) -> dict[str, Any]:
    result: dict[str, Any] = {
        "enabled": any(v.get("enabled") for v in config.values()),
        "max_entities_per_family": max_entities,
        "include_deleted": include_deleted,
        "collected_at": iso_z(utc_now()),
        "families": {},
    }

    for family, family_config in config.items():
        enabled = bool(family_config.get("enabled"))
        if not enabled:
            result["families"][family] = {"enabled": False}
            continue

        definition = DETAIL_FAMILIES[family]
        pattern_text = str(family_config.get("filter") or ".*")
        try:
            pattern = re.compile(pattern_text, re.IGNORECASE)
        except re.error as exc:
            client.warnings.append(f"Detailed {family} filter is invalid: {exc}")
            result["families"][family] = {
                "enabled": True,
                "status": "error",
                "error": f"Invalid regular expression: {exc}",
                "entities": [],
            }
            continue

        try:
            rows = client.get_report(definition["endpoint"].format(period=period))
        except GraphError as exc:
            client.warnings.append(f"Detailed {family}: {exc}")
            result["families"][family] = {
                "enabled": True,
                "status": "error",
                "error": str(exc),
                "entities": [],
            }
            continue

        refresh_dates = sorted(
            {r.get("Report Refresh Date", "") for r in rows if r.get("Report Refresh Date")}
        )
        matched_rows: list[dict[str, str]] = []
        for row in rows:
            if not include_deleted and _to_bool(row.get("Is Deleted")) is True:
                continue
            if not _detail_match(row, definition["identity_fields"], pattern):
                continue
            matched_rows.append(row)

        normalized: list[dict[str, Any]] = []
        for row in matched_rows:
            entity = definition["normalizer"](row)
            if entity is not None:
                normalized.append(entity)

        normalized.sort(key=lambda x: str(x.get("item", "")).lower())
        total_matched = len(normalized)
        emitted = normalized[:max(1, max_entities)]

        result["families"][family] = {
            "enabled": True,
            "status": "ok" if rows else "empty",
            "source_rows": len(rows),
            "matched_rows": total_matched,
            "emitted_rows": len(emitted),
            "truncated": total_matched > len(emitted),
            "refresh_date": refresh_dates[-1] if refresh_dates else None,
            "filter": pattern_text,
            "entities": emitted,
        }

    # Compact user detail: when mailbox and Outlook-user detail are both enabled,
    # enrich the mailbox entity instead of creating a second service for the same UPN.
    mailbox_family = result["families"].get("mailboxes", {})
    outlook_family = result["families"].get("outlook_users", {})
    if mailbox_family.get("enabled") and outlook_family.get("enabled"):
        outlook_by_item = {
            str(entity.get("item", "")).lower(): entity
            for entity in outlook_family.get("entities", [])
            if entity.get("item")
        }
        merged = 0
        for mailbox in mailbox_family.get("entities", []):
            key = str(mailbox.get("item", "")).lower()
            outlook = outlook_by_item.get(key)
            if not outlook:
                continue
            for field in (
                "send_count",
                "receive_count",
                "read_count",
                "meeting_created_count",
                "meeting_interacted_count",
                "assigned_products",
            ):
                mailbox[field] = outlook.get(field)
            mailbox["outlook_last_activity_date"] = outlook.get("last_activity_date")
            merged += 1

        outlook_family["merged_into_mailboxes"] = True
        outlook_family["merged_count"] = merged
        outlook_family["standalone_services"] = 0

    return result


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Checkmk Microsoft 365 special agent")
    parser.add_argument("--tenant-id", required=True)
    parser.add_argument("--client-id", required=True)
    parser_add_secret_option(
        parser,
        long="--secret",
        help="Microsoft Entra application client secret",
        required=True,
    )
    parser.add_argument("--period", choices=("D7", "D30", "D90", "D180"), default="D7")
    parser.add_argument("--report-cache-seconds", type=int, default=3600)
    parser.add_argument("--qos-cache-seconds", type=int, default=300)
    parser.add_argument("--qos-window-minutes", type=int, default=180)
    parser.add_argument("--qos-max-calls", type=int, default=50)
    parser.add_argument("--no-qos", action="store_true")

    parser.add_argument("--enable-mailbox-detail", action="store_true")
    parser.add_argument("--enable-onedrive-detail", action="store_true")
    parser.add_argument("--enable-sharepoint-detail", action="store_true")
    parser.add_argument("--enable-outlook-user-detail", action="store_true")
    parser.add_argument("--mailbox-filter", default=".*")
    parser.add_argument("--onedrive-filter", default=".*")
    parser.add_argument("--sharepoint-filter", default=".*")
    parser.add_argument("--outlook-user-filter", default=".*")
    parser.add_argument("--detail-max-entities", type=int, default=200)
    parser.add_argument("--include-deleted-details", action="store_true")

    parser.add_argument("--timeout", type=int, default=30)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    secret = resolve_secret_option(args, "secret").reveal()
    started = time.monotonic()
    client = GraphClient(args.tenant_id, args.client_id, secret, timeout=args.timeout)

    auth_error: str | None = None
    try:
        client.authenticate()
    except GraphError as exc:
        auth_error = str(exc)

    if auth_error:
        api = {
            "ok": False,
            "authenticated": False,
            "critical_errors": [auth_error],
            "warnings": [],
            "request_count": 0,
            "throttled_requests": 0,
            "duration_seconds": time.monotonic() - started,
            "reports_from_cache": False,
            "details_from_cache": False,
            "qos_from_cache": False,
            "using_last_good": False,
            "collected_at": iso_z(utc_now()),
        }
        last_good = read_cache(cache_root(args.tenant_id) / "last_good_snapshot.json", 10**10)
        if isinstance(last_good, dict):
            api["using_last_good"] = True
            emit_section("m365_api", api)
            emit_section("m365_health", last_good.get("health") or {"services": [], "error": auth_error})
            emit_section("m365_issues", last_good.get("issues") or {"issues": [], "error": auth_error})
            emit_section("m365_subscriptions", last_good.get("subscriptions") or {"subscriptions": [], "error": auth_error})
            emit_section("m365_reports", last_good.get("reports") or {"reports": {}, "error": auth_error})
            emit_section("m365_details", last_good.get("details") or {"enabled": False, "families": {}, "error": auth_error})
            emit_section("m365_qos", last_good.get("qos") or {"enabled": not args.no_qos, "error": auth_error})
            return 0

        emit_section("m365_api", api)
        emit_section("m365_health", {"services": [], "error": auth_error})
        emit_section("m365_issues", {"issues": [], "error": auth_error})
        emit_section("m365_subscriptions", {"subscriptions": [], "error": auth_error})
        emit_section("m365_reports", {"reports": {}, "error": auth_error})
        emit_section("m365_details", {"enabled": False, "families": {}, "error": auth_error})
        emit_section("m365_qos", {"enabled": not args.no_qos, "error": auth_error})
        return 0

    health: dict[str, Any]
    try:
        raw = client.get_json("/admin/serviceAnnouncement/healthOverviews")
        health = {
            "services": [
                {"id": x.get("id"), "service": x.get("service"), "status": x.get("status")}
                for x in raw.get("value", [])
            ],
            "collected_at": iso_z(utc_now()),
        }
    except GraphError as exc:
        client.critical_errors.append(f"Service Health: {exc}")
        health = {"services": [], "error": str(exc)}

    issues: dict[str, Any]
    try:
        raw_issues = client.get_all(
            "/admin/serviceAnnouncement/issues",
            {"Prefer": "odata.maxpagesize=1000"},
        )
        active = []
        for x in raw_issues:
            if bool(x.get("isResolved")):
                continue
            active.append(
                {
                    "id": x.get("id"),
                    "service": x.get("service"),
                    "classification": x.get("classification"),
                    "status": x.get("status"),
                    "isResolved": x.get("isResolved"),
                    "startDateTime": x.get("startDateTime"),
                    "lastModifiedDateTime": x.get("lastModifiedDateTime"),
                    "title": x.get("title"),
                    "impactDescription": x.get("impactDescription"),
                    "feature": x.get("feature"),
                    "featureGroup": x.get("featureGroup"),
                }
            )
        issues = {
            "issues": active,
            "total_returned": len(raw_issues),
            "active_count": len(active),
            "collected_at": iso_z(utc_now()),
        }
    except GraphError as exc:
        client.critical_errors.append(f"Service Issues: {exc}")
        issues = {"issues": [], "error": str(exc)}

    subscriptions: dict[str, Any]
    try:
        skus = client.get_all("/subscribedSkus")
        normalized = []
        for x in skus:
            prepaid = x.get("prepaidUnits") or {}
            normalized.append(
                {
                    "skuPartNumber": x.get("skuPartNumber"),
                    "skuId": x.get("skuId"),
                    "capabilityStatus": x.get("capabilityStatus"),
                    "consumedUnits": x.get("consumedUnits"),
                    "enabled": prepaid.get("enabled"),
                    "warning": prepaid.get("warning"),
                    "suspended": prepaid.get("suspended"),
                    "lockedOut": prepaid.get("lockedOut"),
                }
            )
        subscriptions = {
            "subscriptions": normalized,
            "collected_at": iso_z(utc_now()),
        }
    except GraphError as exc:
        client.critical_errors.append(f"Subscriptions: {exc}")
        subscriptions = {"subscriptions": [], "error": str(exc)}

    croot = cache_root(args.tenant_id)

    report_cache = croot / f"reports_{args.period}.json"
    reports = read_cache(report_cache, max(0, args.report_cache_seconds))
    reports_from_cache = reports is not None
    if reports is None:
        reports = collect_reports(client, args.period)
        try:
            write_cache(report_cache, reports)
        except OSError as exc:
            client.warnings.append(f"Could not write report cache: {exc}")

    detail_config = {
        "mailboxes": {"enabled": args.enable_mailbox_detail, "filter": args.mailbox_filter},
        "onedrive_accounts": {"enabled": args.enable_onedrive_detail, "filter": args.onedrive_filter},
        "sharepoint_sites": {"enabled": args.enable_sharepoint_detail, "filter": args.sharepoint_filter},
        "outlook_users": {"enabled": args.enable_outlook_user_detail, "filter": args.outlook_user_filter},
    }
    detail_settings = json.dumps(
        {
            "period": args.period,
            "config": detail_config,
            "max_entities": args.detail_max_entities,
            "include_deleted": args.include_deleted_details,
        },
        sort_keys=True,
        separators=(",", ":"),
    )
    detail_key = hashlib.sha256(detail_settings.encode("utf-8")).hexdigest()[:16]
    detail_cache = croot / f"details_{detail_key}.json"
    details = read_cache(detail_cache, max(0, args.report_cache_seconds))
    details_from_cache = details is not None
    if details is None:
        details = collect_details(
            client,
            args.period,
            detail_config,
            max(1, args.detail_max_entities),
            args.include_deleted_details,
        )
        try:
            write_cache(detail_cache, details)
        except OSError as exc:
            client.warnings.append(f"Could not write detailed report cache: {exc}")

    qos_from_cache = False
    if args.no_qos:
        qos = {"enabled": False, "reason": "Disabled by configuration"}
    else:
        qos_cache = croot / f"qos_{args.qos_window_minutes}_{args.qos_max_calls}.json"
        qos = read_cache(qos_cache, max(0, args.qos_cache_seconds))
        qos_from_cache = qos is not None
        if qos is None:
            try:
                qos = collect_qos(client, max(0, args.qos_window_minutes), max(1, args.qos_max_calls))
                try:
                    write_cache(qos_cache, qos)
                except OSError as exc:
                    client.warnings.append(f"Could not write QoS cache: {exc}")
            except GraphError as exc:
                client.warnings.append(f"Teams QoS: {exc}")
                qos = {
                    "enabled": True,
                    "window_minutes": args.qos_window_minutes,
                    "error": str(exc),
                }

    api = {
        "ok": not client.critical_errors,
        "authenticated": True,
        "critical_errors": client.critical_errors,
        "warnings": client.warnings,
        "request_count": client.request_count,
        "throttled_requests": client.throttled,
        "duration_seconds": time.monotonic() - started,
        "reports_from_cache": reports_from_cache,
        "details_from_cache": details_from_cache,
        "qos_from_cache": qos_from_cache,
        "using_last_good": False,
        "using_last_good_sections": [],
        "collected_at": iso_z(utc_now()),
    }

    snapshot = {
        "health": health,
        "issues": issues,
        "subscriptions": subscriptions,
        "reports": reports,
        "details": details,
        "qos": qos,
    }
    last_good_path = croot / "last_good_snapshot.json"
    if client.critical_errors:
        last_good = read_cache(last_good_path, 10**10)
        if isinstance(last_good, dict):
            for section_name in ("health", "issues", "subscriptions"):
                current = snapshot[section_name]
                if isinstance(current, dict) and current.get("error") and last_good.get(section_name):
                    snapshot[section_name] = last_good[section_name]
                    api["using_last_good_sections"].append(section_name)
            api["using_last_good"] = bool(api["using_last_good_sections"])
    else:
        try:
            write_cache(last_good_path, snapshot)
        except OSError as exc:
            api["warnings"].append(f"Could not write last-good snapshot: {exc}")

    emit_section("m365_api", api)
    emit_section("m365_health", snapshot["health"])
    emit_section("m365_issues", snapshot["issues"])
    emit_section("m365_subscriptions", snapshot["subscriptions"])
    emit_section("m365_reports", snapshot["reports"])
    emit_section("m365_details", snapshot["details"])
    emit_section("m365_qos", snapshot["qos"])
    return 0


if __name__ == "__main__":
    sys.exit(main())
