#!/usr/bin/env python3
"""Collect per-user M365 detail for Cascades Tucson batch 2."""
import csv
import json
import sys
import time
import urllib.parse
import urllib.request

TOKEN_FILE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\.token_batch2"
OUT_FILE = r"C:\claudetools\clients\cascades-tucson\reports\user-detail-batches\batch-2.csv"

USERS = [
    "ann.dery@cascadestucson.com",
    "ashley.jensen@cascadestucson.com",
    "boadmin@cascadestucson.com",
    "christina.dupras@cascadestucson.com",
    "christine.nyanzunda@cascadestucson.com",
]

with open(TOKEN_FILE, "r") as f:
    TOKEN = f.read().strip()

HEADERS_BASE = {
    "Authorization": f"Bearer {TOKEN}",
    "Accept": "application/json",
}

# Map Graph method @odata.type -> short name
METHOD_MAP = {
    "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod": "Authenticator",
    "#microsoft.graph.phoneAuthenticationMethod": "SMS",
    "#microsoft.graph.fido2AuthenticationMethod": "FIDO2",
    "#microsoft.graph.temporaryAccessPassAuthenticationMethod": "TAP",
    "#microsoft.graph.softwareOathAuthenticationMethod": "OATH",
    "#microsoft.graph.passwordAuthenticationMethod": "Password",
    "#microsoft.graph.emailAuthenticationMethod": "Email",
    "#microsoft.graph.windowsHelloForBusinessAuthenticationMethod": "WindowsHello",
    "#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod": "AuthenticatorPasswordless",
}

def graph_get(url, extra_headers=None, max_retries=4):
    headers = dict(HEADERS_BASE)
    if extra_headers:
        headers.update(extra_headers)
    attempt = 0
    while True:
        req = urllib.request.Request(url, headers=headers, method="GET")
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                status = resp.status
                data = resp.read().decode("utf-8")
                return status, json.loads(data) if data else {}
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", errors="replace")
            try:
                j = json.loads(body)
            except Exception:
                j = {"raw": body}
            if e.code == 429 and attempt < max_retries:
                retry_after = int(e.headers.get("Retry-After", "2"))
                time.sleep(max(retry_after, 2))
                attempt += 1
                continue
            if e.code >= 500 and attempt < max_retries:
                time.sleep(2 ** attempt)
                attempt += 1
                continue
            return e.code, j
        except Exception as e:
            if attempt < max_retries:
                time.sleep(2 ** attempt)
                attempt += 1
                continue
            return 0, {"error": str(e)}


def collect_user(upn):
    row = {
        "UPN": upn,
        "DisplayName": "",
        "AccountEnabled": "",
        "MailboxType": "User",
        "Licenses": "",
        "MFARegistered": "false",
        "MFAMethods": "",
        "DefaultMFAMethod": "",
        "LastSignIn": "",
        "LastInteractiveSignIn": "",
        "AdminRoles": "",
        "JobTitle": "",
        "Department": "",
        "GroupCount": "",
        "Notes": "",
    }
    notes = []

    enc_upn = urllib.parse.quote(upn)

    # 1a. Core profile (without signInActivity - Graph requires GUID key for that)
    url = (f"https://graph.microsoft.com/v1.0/users/{enc_upn}"
           "?$select=id,userPrincipalName,displayName,accountEnabled,"
           "jobTitle,department")
    status, data = graph_get(url)
    if status == 404:
        row["Notes"] = "not_found"
        return row
    user_id = ""
    if status == 200:
        user_id = data.get("id", "")
        row["DisplayName"] = data.get("displayName") or ""
        row["AccountEnabled"] = str(data.get("accountEnabled", "")).lower()
        row["JobTitle"] = data.get("jobTitle") or ""
        row["Department"] = data.get("department") or ""
    elif status in (401, 403):
        notes.append("profile:scope_unavailable")
    else:
        notes.append(f"profile:http_{status}")

    # 1b. signInActivity (requires GUID)
    if user_id:
        url = (f"https://graph.microsoft.com/v1.0/users/{user_id}"
               "?$select=signInActivity")
        status, data = graph_get(url)
        if status == 200:
            sia = data.get("signInActivity") or {}
            row["LastSignIn"] = sia.get("lastSignInDateTime") or ""
            row["LastInteractiveSignIn"] = (
                sia.get("lastSignInDateTime")
                or sia.get("lastNonInteractiveSignInDateTime")
                or ""
            )
        elif status in (401, 403):
            row["LastSignIn"] = "scope_unavailable"
            row["LastInteractiveSignIn"] = "scope_unavailable"
        else:
            notes.append(f"signin:http_{status}")

    # 2. Licenses
    url = f"https://graph.microsoft.com/v1.0/users/{enc_upn}/licenseDetails"
    status, data = graph_get(url)
    if status == 200:
        skus = [x.get("skuPartNumber", "") for x in data.get("value", [])]
        row["Licenses"] = ";".join(s for s in skus if s)
    elif status == 404:
        notes.append("licenses:not_found")
    elif status in (401, 403):
        row["Licenses"] = "scope_unavailable"
    else:
        notes.append(f"licenses:http_{status}")

    # 3. MFA methods
    url = f"https://graph.microsoft.com/v1.0/users/{enc_upn}/authentication/methods"
    status, data = graph_get(url)
    if status == 200:
        methods_raw = data.get("value", [])
        short_names = []
        non_password_count = 0
        for m in methods_raw:
            t = m.get("@odata.type", "")
            short = METHOD_MAP.get(t, t.replace("#microsoft.graph.", "").replace("AuthenticationMethod", ""))
            short_names.append(short)
            if t != "#microsoft.graph.passwordAuthenticationMethod":
                non_password_count += 1
        row["MFAMethods"] = ";".join(short_names)
        row["MFARegistered"] = "true" if non_password_count > 0 else "false"
    elif status in (401, 403):
        row["MFAMethods"] = "scope_unavailable"
        row["MFARegistered"] = "scope_unavailable"
    elif status == 404:
        notes.append("mfa:not_found")
    else:
        notes.append(f"mfa:http_{status}")

    # 4. Default MFA method
    url = f"https://graph.microsoft.com/beta/users/{enc_upn}/authentication/signInPreferences"
    status, data = graph_get(url)
    if status == 200:
        row["DefaultMFAMethod"] = data.get("userPreferredMethodForSecondaryAuthentication") or ""
    elif status in (401, 403):
        row["DefaultMFAMethod"] = "scope_unavailable"
    elif status == 404:
        notes.append("default_mfa:not_found")
    else:
        notes.append(f"default_mfa:http_{status}")

    # 5. Directory roles (transitive)
    url = (f"https://graph.microsoft.com/v1.0/users/{enc_upn}/transitiveMemberOf/"
           "microsoft.graph.directoryRole")
    status, data = graph_get(url)
    if status == 200:
        roles = [r.get("displayName", "") for r in data.get("value", [])]
        row["AdminRoles"] = ";".join(r for r in roles if r)
    elif status in (401, 403):
        row["AdminRoles"] = "scope_unavailable"
    elif status == 404:
        pass
    else:
        notes.append(f"roles:http_{status}")

    # 6. Group count
    url = f"https://graph.microsoft.com/v1.0/users/{enc_upn}/memberOf?$count=true&$top=1"
    status, data = graph_get(url, extra_headers={"ConsistencyLevel": "eventual"})
    if status == 200:
        cnt = data.get("@odata.count")
        row["GroupCount"] = str(cnt) if cnt is not None else ""
    elif status in (401, 403):
        row["GroupCount"] = "scope_unavailable"
    elif status == 404:
        pass
    else:
        notes.append(f"groups:http_{status}")

    if notes and not row["Notes"]:
        row["Notes"] = ";".join(notes)
    return row


def main():
    rows = []
    for i, upn in enumerate(USERS):
        print(f"[{i+1}/{len(USERS)}] {upn}", file=sys.stderr)
        row = collect_user(upn)
        rows.append(row)
        if i < len(USERS) - 1:
            time.sleep(0.25)

    fieldnames = [
        "UPN", "DisplayName", "AccountEnabled", "MailboxType", "Licenses",
        "MFARegistered", "MFAMethods", "DefaultMFAMethod", "LastSignIn",
        "LastInteractiveSignIn", "AdminRoles", "JobTitle", "Department",
        "GroupCount", "Notes",
    ]
    with open(OUT_FILE, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL)
        writer.writeheader()
        for r in rows:
            writer.writerow(r)

    print(f"Wrote {len(rows)} rows to {OUT_FILE}", file=sys.stderr)
    # Also print summary to stdout
    for r in rows:
        print(json.dumps(r))


if __name__ == "__main__":
    main()
