#!/usr/bin/env python3
"""
Google Workspace users + groups (Admin SDK Directory API) → data/google-workspace.json.

Uses a service account with domain-wide delegation. A Workspace super admin must grant the
service account OAuth client access in Admin console → Security → Access and data control →
API controls → Domain-wide delegation with scopes:
  https://www.googleapis.com/auth/admin.directory.user.readonly
  https://www.googleapis.com/auth/admin.directory.group.readonly
  https://www.googleapis.com/auth/apps.licensing   (Workspace license assignments, e.g. Enterprise Standard)
(group member roles for manager/owner lookup use the same group.readonly scope)

Auth: GOOGLE_WORKSPACE_SA_* service account keys (or legacy GOOGLE_WORKSPACE_CREDENTIALS_JSON) and
GOOGLE_WORKSPACE_DELEGATED_ADMIN in .env.
"""
from __future__ import annotations

import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

from google_workspace_credentials import load_service_account_info
from store import json_snapshot_exists, read_json_snapshot, write_json_snapshot

from env_config import env_int, env_str
from paths import DATA_DIR, REPO_ROOT, load_env_file as paths_load_env_file

ROOT = REPO_ROOT
OUT = Path(os.environ.get("GSUITE_OUT", str(DATA_DIR / "google-workspace.json")))
ANALYTICS_OUT = Path(os.environ.get("ANALYTICS_OUT", str(DATA_DIR / "analytics.json")))
USER_SCOPE = "https://www.googleapis.com/auth/admin.directory.user.readonly"
GROUP_SCOPE = "https://www.googleapis.com/auth/admin.directory.group.readonly"
LICENSING_SCOPE = "https://www.googleapis.com/auth/apps.licensing"
USERS_URL = "https://admin.googleapis.com/admin/directory/v1/users"
GROUPS_URL = "https://admin.googleapis.com/admin/directory/v1/groups"
GROUP_ALIASES_URL = "https://admin.googleapis.com/admin/directory/v1/groups/{group_key}/aliases"
GROUP_MEMBERS_URL = "https://admin.googleapis.com/admin/directory/v1/groups/{group_key}/members"
LICENSING_USERS_URL = (
    "https://www.googleapis.com/apps/licensing/v1/product/{product_id}/sku/{sku_id}/users"
)
# Baked-in defaults so license totals render even when the deployment env
# (Vault / k8s secret) does not provide GOOGLE_WORKSPACE_LICENSE_* overrides.
# Google's Licensing API does not expose purchased/available seat counts, so
# these come from Admin → Billing → Subscriptions. Env vars still take priority.
DEFAULT_GSUITE_LICENSE_SKUS: list[tuple[str, str, str]] = [
    ("Google-Apps", "1010020026", "Google Workspace Enterprise Standard"),
]
DEFAULT_GSUITE_LICENSE_TOTALS: dict[str, int] = {
    "1010020026": 650,
}


def load_env_file() -> None:
    paths_load_env_file()


def _user_row(u: dict) -> dict[str, object]:
    name = u.get("name") if isinstance(u.get("name"), dict) else {}
    given = str(name.get("givenName") or "").strip()
    family = str(name.get("familyName") or "").strip()
    full = (given + " " + family).strip() or str(name.get("fullName") or "").strip()
    return {
        "id": u.get("id"),
        "primaryEmail": str(u.get("primaryEmail") or "").strip(),
        "fullName": full,
        "suspended": bool(u.get("suspended")),
        "isAdmin": bool(u.get("isAdmin")),
        "orgUnitPath": str(u.get("orgUnitPath") or "").strip(),
    }


def _aliases_from_group_object(g: dict) -> list[str]:
    raw = g.get("aliases")
    if not isinstance(raw, list):
        return []
    out: list[str] = []
    for item in raw:
        s = str(item).strip()
        if s and s not in out:
            out.append(s)
    return out


def _fetch_group_aliases(session, g: dict) -> list[str]:
    from_object = _aliases_from_group_object(g)
    if from_object:
        return from_object
    key = str(g.get("id") or g.get("email") or "").strip()
    if not key:
        return []
    url = GROUP_ALIASES_URL.format(group_key=key)
    try:
        r = session.get(url, timeout=60)
        if not r.ok:
            return []
        data = r.json()
        batch = data.get("aliases") or []
        out: list[str] = []
        if isinstance(batch, list):
            for item in batch:
                if not isinstance(item, dict):
                    continue
                alias = str(item.get("alias") or "").strip()
                if alias and alias not in out:
                    out.append(alias)
        return out
    except Exception:
        return []


def _member_email(member: dict) -> str:
    return str(member.get("email") or member.get("id") or "").strip()


def _fetch_group_role_emails(session, g: dict) -> tuple[list[str], list[str]]:
    """Return (manager_emails, owner_emails) for a group."""
    key = str(g.get("id") or g.get("email") or "").strip()
    if not key:
        return [], []
    url = GROUP_MEMBERS_URL.format(group_key=key)
    managers: list[str] = []
    owners: list[str] = []
    page_token: str | None = None
    try:
        while True:
            params: dict[str, str] = {
                "roles": "MANAGER,OWNER",
                "maxResults": "200",
            }
            if page_token:
                params["pageToken"] = page_token
            r = session.get(url, params=params, timeout=60)
            if not r.ok:
                return managers, owners
            data = r.json()
            batch = data.get("members") or []
            if isinstance(batch, list):
                for member in batch:
                    if not isinstance(member, dict):
                        continue
                    email = _member_email(member)
                    if not email:
                        continue
                    role = str(member.get("role") or "").strip().upper()
                    if role == "MANAGER" and email not in managers:
                        managers.append(email)
                    elif role == "OWNER" and email not in owners:
                        owners.append(email)
            page_token = data.get("nextPageToken")
            if not page_token:
                break
    except Exception:
        return managers, owners
    return managers, owners


def _group_row(
    g: dict,
    aliases: list[str],
    *,
    manager_emails: list[str] | None = None,
    owner_emails: list[str] | None = None,
) -> dict[str, object]:
    email = str(g.get("email") or "").strip()
    gid = str(g.get("id") or "").strip()
    admin_url = ""
    if email:
        admin_url = "https://admin.google.com/ac/groups/" + email
    managers = list(manager_emails or [])
    owners = list(owner_emails or [])
    direct_members = g.get("directMembersCount")
    try:
        direct_members_n = max(0, int(direct_members))
    except (TypeError, ValueError):
        direct_members_n = None
    return {
        "id": gid or None,
        "email": email,
        "name": str(g.get("name") or "").strip(),
        "description": str(g.get("description") or "").strip(),
        "adminConsoleUrl": admin_url,
        "aliases": aliases,
        "managerEmails": managers,
        "ownerEmails": owners,
        "directMembersCount": direct_members_n,
    }


def _parse_license_seat_map(env_key: str) -> dict[str, int]:
    """Parse skuId:count pairs from GOOGLE_WORKSPACE_LICENSE_TOTALS or _AVAILABLE."""
    raw = (os.environ.get(env_key) or "").strip()
    out: dict[str, int] = {}
    if not raw:
        return out
    for chunk in raw.split(","):
        piece = chunk.strip()
        if not piece or ":" not in piece:
            continue
        sku_id, count_raw = piece.split(":", 1)
        sku_id = sku_id.strip()
        try:
            count = int(str(count_raw).strip())
        except ValueError:
            continue
        if sku_id and count >= 0:
            out[sku_id] = count
    return out


def _configured_license_totals() -> dict[str, int]:
    """
    Purchased seat counts per SKU (Google does not expose totals via Licensing API).
    GOOGLE_WORKSPACE_LICENSE_TOTALS=1010020026:650,otherSku:100

    Falls back to DEFAULT_GSUITE_LICENSE_TOTALS so totals render even when the
    deployment env does not set the override. Env-provided SKUs win per key.
    """
    totals = dict(DEFAULT_GSUITE_LICENSE_TOTALS)
    totals.update(_parse_license_seat_map("GOOGLE_WORKSPACE_LICENSE_TOTALS"))
    return totals


def _configured_license_available() -> dict[str, int]:
    """
    Spare seat counts per SKU from Admin → Billing → Subscriptions.
    GOOGLE_WORKSPACE_LICENSE_AVAILABLE=1010020026:178
    When set, total = available + assigned (matches admin “X available, Y assigned”).
    """
    return _parse_license_seat_map("GOOGLE_WORKSPACE_LICENSE_AVAILABLE")


def _configured_license_skus() -> list[tuple[str, str, str]]:
    """
    GOOGLE_WORKSPACE_LICENSE_SKUS — comma-separated skuId:Display Name entries.
    Optional product prefix: Google-Apps/1010020026:Google Workspace Enterprise Standard
    """
    raw = env_str("GOOGLE_WORKSPACE_LICENSE_SKUS", required=False)
    out: list[tuple[str, str, str]] = []
    for chunk in raw.split(","):
        piece = chunk.strip()
        if not piece or ":" not in piece:
            continue
        sku_part, name = piece.split(":", 1)
        sku_part = sku_part.strip()
        name = name.strip()
        product = "Google-Apps"
        sku_id = sku_part
        if "/" in sku_part:
            product, sku_id = sku_part.split("/", 1)
            product = product.strip()
            sku_id = sku_id.strip()
        if not sku_id:
            continue
        out.append((product, sku_id, name or sku_id))
    if not out:
        if DEFAULT_GSUITE_LICENSE_SKUS:
            return list(DEFAULT_GSUITE_LICENSE_SKUS)
        raise ValueError("GOOGLE_WORKSPACE_LICENSE_SKUS must list at least one skuId:Name entry")
    return out


def _alert_cap(env_key: str, default: str = "50") -> int:
    raw = (os.environ.get(env_key) or default).strip()
    try:
        n = int(raw)
    except ValueError:
        n = int(default)
    return max(1, min(n, 500))


def _suspended_license_alert_max() -> int:
    return _alert_cap("GOOGLE_WORKSPACE_SUSPENDED_LICENSE_ALERT_MAX")


def _suspended_user_alert_max() -> int:
    return _alert_cap("GOOGLE_WORKSPACE_SUSPENDED_ALERT_MAX")


def _empty_group_alert_max() -> int:
    return _alert_cap("GOOGLE_WORKSPACE_EMPTY_GROUP_ALERT_MAX")


def _suspended_users_with_license(
    directory_users: list[dict],
    licenses_out: list[dict[str, object]],
) -> tuple[int, list[dict[str, object]], list[dict[str, object]]]:
    """Suspended directory users who still appear on a Licensing API assignment list."""
    by_email: dict[str, dict[str, object]] = {}
    for u in directory_users:
        if not isinstance(u, dict):
            continue
        email = str(u.get("primaryEmail") or "").strip().lower()
        if email:
            by_email[email] = _user_row(u)

    hits: dict[str, dict[str, object]] = {}
    for lic in licenses_out:
        if not isinstance(lic, dict):
            continue
        if lic.get("assignmentSource") != "licensing_api":
            continue
        lic_name = str(lic.get("name") or lic.get("skuId") or "License").strip()
        assigned = lic.get("users")
        if not isinstance(assigned, list):
            continue
        for row in assigned:
            if not isinstance(row, dict):
                continue
            email = str(row.get("userEmail") or "").strip().lower()
            if not email:
                continue
            user = by_email.get(email)
            if not user or not user.get("suspended"):
                continue
            if email not in hits:
                hits[email] = {
                    "id": user.get("id"),
                    "primaryEmail": user.get("primaryEmail"),
                    "fullName": user.get("fullName"),
                    "suspended": True,
                    "orgUnitPath": user.get("orgUnitPath"),
                    "licenseCount": 0,
                    "licenseNames": [],
                }
            entry = hits[email]
            names = entry.get("licenseNames")
            if not isinstance(names, list):
                names = []
                entry["licenseNames"] = names
            if lic_name not in names:
                names.append(lic_name)
                entry["licenseCount"] = len(names)

    detail: list[dict[str, object]] = list(hits.values())
    detail.sort(
        key=lambda row: (
            -int(row.get("licenseCount") or 0),
            str(row.get("primaryEmail") or "").lower(),
        )
    )
    for row in detail:
        names = row.get("licenseNames")
        if not isinstance(names, list):
            continue
        preview = "; ".join(str(n) for n in names[:4])
        if len(names) > 4:
            preview += f" (+{len(names) - 4} more)"
        row["licenseNamesPreview"] = preview

    cap = _suspended_license_alert_max()
    return len(detail), detail, detail[:cap]


def _suspended_users_alert(
    directory_users: list[dict],
    *,
    exclude_emails: set[str] | None = None,
) -> tuple[int, list[dict[str, object]], list[dict[str, object]]]:
    """Suspended directory users for dashboard alerts (excludes emails in license-critical set)."""
    exclude = {e.strip().lower() for e in (exclude_emails or set()) if e}
    detail: list[dict[str, object]] = []
    for u in directory_users:
        if not isinstance(u, dict) or not u.get("suspended"):
            continue
        row = _user_row(u)
        email = str(row.get("primaryEmail") or "").strip().lower()
        if email and email in exclude:
            continue
        detail.append(row)
    detail.sort(key=lambda row: str(row.get("primaryEmail") or "").lower())
    cap = _suspended_user_alert_max()
    return len(detail), detail, detail[:cap]


def _empty_groups_alert(
    groups_detail: list[dict],
) -> tuple[int, list[dict[str, object]], list[dict[str, object]]]:
    """Groups with zero direct members (Admin SDK directMembersCount)."""
    detail: list[dict[str, object]] = []
    for row in groups_detail:
        if not isinstance(row, dict):
            continue
        count = row.get("directMembersCount")
        if count is None:
            continue
        try:
            if int(count) != 0:
                continue
        except (TypeError, ValueError):
            continue
        detail.append(row)
    detail.sort(key=lambda row: str(row.get("email") or "").lower())
    cap = _empty_group_alert_max()
    return len(detail), detail, detail[:cap]


def _directory_license_user_fallback(payload: dict[str, object]) -> list[dict[str, str]]:
    """
    When Licensing API is unavailable, list enabled directory users so the
    dashboard can still show Enterprise Standard assignments (best-effort).
    """
    out: list[dict[str, str]] = []
    detail = payload.get("usersDetail")
    if not isinstance(detail, list):
        return out
    for row in detail:
        if not isinstance(row, dict) or row.get("suspended"):
            continue
        email = str(row.get("primaryEmail") or "").strip()
        if email:
            out.append({"userEmail": email})
    out.sort(key=lambda item: item["userEmail"].lower())
    return out


def _fetch_license_assignments(
    session,
    product_id: str,
    sku_id: str,
    customer_id: str,
) -> tuple[list[dict[str, str]], str | None]:
    """Users assigned to a Google Workspace product SKU (Enterprise License Manager API)."""
    rows: list[dict[str, str]] = []
    page_token: str | None = None
    err: str | None = None
    url = LICENSING_USERS_URL.format(product_id=product_id, sku_id=sku_id)
    cust = (customer_id or "").strip() or "my_customer"
    try:
        while True:
            params: dict[str, str] = {
                "customerId": cust,
                "maxResults": "1000",
            }
            if page_token:
                params["pageToken"] = page_token
            r = session.get(url, params=params, timeout=120)
            if not r.ok:
                try:
                    body = r.json()
                    msg = body.get("error", {}).get("message", r.text)
                except Exception:
                    msg = r.text[:500]
                err = f"HTTP {r.status_code}: {msg}"[:800]
                break
            data = r.json()
            batch = data.get("items") or []
            if isinstance(batch, list):
                for item in batch:
                    if not isinstance(item, dict):
                        continue
                    email = str(item.get("userId") or "").strip()
                    if not email:
                        continue
                    rows.append({"userEmail": email})
            page_token = data.get("nextPageToken")
            if not page_token:
                break
    except Exception as ex:  # noqa: BLE001
        err = str(ex)[:800]
    rows.sort(key=lambda row: row.get("userEmail", "").lower())
    return rows, err


def _paginate_directory(
    session,
    url: str,
    list_key: str,
    *,
    order_by: str | None = None,
) -> tuple[list[dict], str | None]:
    rows: list[dict] = []
    page_token: str | None = None
    err: str | None = None
    try:
        while True:
            params: dict[str, str] = {
                "customer": "my_customer",
                "maxResults": "200",
            }
            if order_by:
                params["orderBy"] = order_by
            if page_token:
                params["pageToken"] = page_token
            r = session.get(url, params=params, timeout=120)
            if not r.ok:
                try:
                    body = r.json()
                    msg = body.get("error", {}).get("message", r.text)
                except Exception:
                    msg = r.text[:500]
                err = f"HTTP {r.status_code}: {msg}"[:800]
                break
            data = r.json()
            batch = data.get(list_key) or []
            if isinstance(batch, list):
                rows.extend(batch)
            page_token = data.get("nextPageToken")
            if not page_token:
                break
    except Exception as ex:  # noqa: BLE001
        err = str(ex)[:800]
    return rows, err


def _sync_google_licenses_to_analytics(payload: dict[str, object]) -> None:
    """Keep Google license rows in analytics.json so the Licenses section loads with M365 data."""
    if not json_snapshot_exists(ANALYTICS_OUT):
        return
    data = read_json_snapshot(ANALYTICS_OUT)
    if not isinstance(data, dict):
        return
    licenses = payload.get("licenses")
    data["googleWorkspaceLicenses"] = licenses if isinstance(licenses, list) else []
    for key in ("googleLicensesError", "googleLicensesNote"):
        val = payload.get(key)
        if val:
            data[key] = val
        elif key in data:
            del data[key]
    updated = payload.get("updatedAt")
    if updated:
        data["googleLicensesUpdatedAt"] = updated
    try:
        out_path = write_json_snapshot(ANALYTICS_OUT, data)
        print(f"Synced Google licenses into {out_path}")
    except OSError as ex:
        print(f"[warn] Could not sync Google licenses to analytics.json: {ex}", file=sys.stderr)


def main() -> int:
    load_env_file()
    admin_email = (os.environ.get("GOOGLE_WORKSPACE_DELEGATED_ADMIN") or "").strip()
    if not admin_email:
        print(
            "Missing GOOGLE_WORKSPACE_DELEGATED_ADMIN in .env.\n"
            "  See env.example and app/src/jobs/fetch_google_workspace.py docstring.",
            file=sys.stderr,
        )
        return 1
    try:
        service_account_info = load_service_account_info()
    except (OSError, json.JSONDecodeError, ValueError) as ex:
        print(
            "Missing or invalid Google Workspace credentials "
            "(GOOGLE_WORKSPACE_SA_* or GOOGLE_WORKSPACE_CREDENTIALS_JSON).\n"
            f"  {ex}",
            file=sys.stderr,
        )
        return 1

    try:
        from google.auth.transport.requests import AuthorizedSession
        from google.oauth2 import service_account
    except ImportError:
        print(
            "Missing google-auth. Install dependencies: pip install -r requirements.txt",
            file=sys.stderr,
        )
        return 1

    try:
        max_user_detail = int(os.environ.get("GSUITE_DETAIL_MAX", "5000") or "5000")
    except ValueError:
        max_user_detail = 5000
    max_user_detail = max(1, min(max_user_detail, 50000))

    try:
        max_group_detail = int(os.environ.get("GSUITE_GROUPS_DETAIL_MAX", "5000") or "5000")
    except ValueError:
        max_group_detail = 5000
    max_group_detail = max(1, min(max_group_detail, 50000))

    def _session_for(scopes: list[str]):
        creds = service_account.Credentials.from_service_account_info(
            service_account_info, scopes=scopes
        )
        return AuthorizedSession(creds.with_subject(admin_email))

    # Separate scopes so users still load when only user.readonly is delegated.
    directory_users, users_err = _paginate_directory(
        _session_for([USER_SCOPE]), USERS_URL, "users", order_by="email"
    )
    group_session = _session_for([GROUP_SCOPE])
    groups, groups_err = _paginate_directory(group_session, GROUPS_URL, "groups")

    primary_domain = ""
    if "@" in admin_email:
        primary_domain = admin_email.split("@", 1)[1].strip()

    payload: dict[str, object] = {
        "updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "primaryDomain": primary_domain,
        "adminConsoleUrl": "https://admin.google.com/ac/users",
        "groupsAdminConsoleUrl": "https://admin.google.com/ac/groups",
        "usersTotal": None,
        "usersActive": None,
        "usersSuspended": None,
        "usersAdmins": None,
        "usersDetail": [],
        "groupsTotal": None,
        "groupsWithoutOwner": None,
        "groupsDetail": [],
        "groupsWithoutOwnerDetail": [],
        "licenses": [],
        "suspendedWithLicense": 0,
        "suspendedWithLicenseDetail": [],
        "suspendedWithLicenseAlertDetail": [],
        "suspendedUsersAlert": 0,
        "suspendedUsersAlertDetail": [],
        "emptyGroups": 0,
        "emptyGroupsDetail": [],
        "emptyGroupsAlertDetail": [],
        "googleWorkspaceError": users_err,
        "googleGroupsError": groups_err,
        "googleLicensesError": None,
    }

    if not users_err and directory_users:
        total = len(directory_users)
        suspended_n = sum(
            1 for u in directory_users if isinstance(u, dict) and u.get("suspended")
        )
        admin_n = sum(1 for u in directory_users if isinstance(u, dict) and u.get("isAdmin"))
        payload["usersTotal"] = total
        payload["usersActive"] = total - suspended_n
        payload["usersSuspended"] = suspended_n
        payload["usersAdmins"] = admin_n

        detail: list[dict] = []
        for u in directory_users:
            if not isinstance(u, dict):
                continue
            if len(detail) >= max_user_detail:
                break
            detail.append(_user_row(u))
        payload["usersDetail"] = detail
        if len(directory_users) > len(detail):
            print(
                f"[info] usersDetail capped at {max_user_detail} rows (GSUITE_DETAIL_MAX). "
                f"Directory returned {total} user(s).",
                file=sys.stderr,
            )

    if not groups_err and groups:
        total_g = len(groups)
        payload["groupsTotal"] = total_g

        gdetail: list[dict] = []
        without_owner: list[dict] = []
        for i, g in enumerate(groups):
            if not isinstance(g, dict):
                continue
            if len(gdetail) >= max_group_detail:
                break
            aliases = _fetch_group_aliases(group_session, g)
            managers, owners = _fetch_group_role_emails(group_session, g)
            row = _group_row(g, aliases, manager_emails=managers, owner_emails=owners)
            gdetail.append(row)
            if not owners:
                without_owner.append(row)
            if (i + 1) % 50 == 0:
                print(f"[info] groupsDetail progress {i + 1}/{total_g}", flush=True)
        payload["groupsDetail"] = gdetail
        payload["groupsWithoutOwner"] = len(without_owner)
        payload["groupsWithoutOwnerDetail"] = without_owner
        empty_n, empty_detail, empty_alert = _empty_groups_alert(gdetail)
        payload["emptyGroups"] = empty_n
        payload["emptyGroupsDetail"] = empty_detail
        payload["emptyGroupsAlertDetail"] = empty_alert
        if empty_n:
            print(
                f"[info] {empty_n} Google group(s) have no direct members.",
                file=sys.stderr,
            )
        if len(groups) > len(gdetail):
            print(
                f"[info] groupsDetail capped at {max_group_detail} rows (GSUITE_GROUPS_DETAIL_MAX). "
                f"Directory returned {total_g} group(s).",
                file=sys.stderr,
            )

    license_skus = _configured_license_skus()
    license_totals = _configured_license_totals()
    license_available = _configured_license_available()
    license_customer_id = primary_domain or "my_customer"
    licenses_out: list[dict[str, object]] = []
    license_errs: list[str] = []
    license_notes: list[str] = []
    prev_snapshot = read_json_snapshot(OUT) or {}
    prev_licenses_by_sku = {
        str(lic.get("skuId")): lic
        for lic in (prev_snapshot.get("licenses") or [])
        if isinstance(lic, dict) and lic.get("skuId")
    }
    for product_id, sku_id, display_name in license_skus:
        license_users: list[dict[str, str]] = []
        lic_err: str | None = None
        try:
            license_session = _session_for([LICENSING_SCOPE])
            license_users, lic_err = _fetch_license_assignments(
                license_session,
                product_id,
                sku_id,
                license_customer_id,
            )
        except Exception as ex:  # noqa: BLE001
            lic_err = str(ex)[:800]
        assignment_source = "licensing_api"
        if lic_err:
            license_errs.append(f"{display_name}: {lic_err}")
            if not license_users and not users_err:
                license_users = _directory_license_user_fallback(payload)
                if license_users:
                    assignment_source = "directory_enabled_users"
                    license_notes.append(
                        f"{display_name}: Licensing API unavailable — "
                        "showing enabled directory users as assignments. "
                        "Grant apps.licensing + a Workspace admin role with license read, "
                        f"and use customerId={license_customer_id!r}."
                    )
        assigned = len(license_users)
        prev_entry = prev_licenses_by_sku.get(sku_id)
        prev_assigned = (
            int(prev_entry.get("assigned") or 0) if isinstance(prev_entry, dict) else 0
        )
        if (
            (lic_err or assigned == 0)
            and assignment_source == "licensing_api"
            and prev_entry is not None
            and prev_assigned > 0
        ):
            kept = dict(prev_entry)
            kept["assignmentSource"] = "previous_snapshot"
            licenses_out.append(kept)
            reason = lic_err or "licensing API returned no assignments"
            license_notes.append(
                f"{display_name}: {reason} — kept previous snapshot "
                f"({prev_assigned} assigned)."
            )
            print(
                f"[info] {display_name} licensing fetch empty/failed; "
                f"kept previous snapshot ({prev_assigned} assigned).",
                file=sys.stderr,
            )
            continue
        total = license_totals.get(sku_id)
        available_override = license_available.get(sku_id)
        available: int | None = None
        if available_override is not None:
            available = available_override
            total = assigned + available
        elif isinstance(total, int):
            available = max(0, total - assigned)
        elif assigned > 0:
            license_notes.append(
                f"{display_name}: Set GOOGLE_WORKSPACE_LICENSE_TOTALS={sku_id}:<purchased> "
                f"or GOOGLE_WORKSPACE_LICENSE_AVAILABLE={sku_id}:<available> in .env "
                "(Admin console → Billing → Subscriptions, e.g. “178 available, 472 assigned” → total 650)."
            )
        status = "Active" if assignment_source == "licensing_api" and assigned >= 0 else None
        licenses_out.append(
            {
                "productId": product_id,
                "skuId": sku_id,
                "name": display_name,
                "status": status,
                "total": total,
                "assigned": assigned,
                "available": available,
                "expiringSoon": 0,
                "users": license_users,
                "assignmentSource": assignment_source,
            }
        )
    payload["licenses"] = licenses_out
    if license_notes:
        payload["googleLicensesNote"] = " · ".join(license_notes)
    if license_errs:
        payload["googleLicensesError"] = " · ".join(license_errs)

    if not users_err and directory_users:
        sus_count, sus_detail, sus_alert_detail = _suspended_users_with_license(
            directory_users, licenses_out
        )
        payload["suspendedWithLicense"] = sus_count
        payload["suspendedWithLicenseDetail"] = sus_detail
        payload["suspendedWithLicenseAlertDetail"] = sus_alert_detail
        license_emails = {
            str(row.get("primaryEmail") or "").strip().lower()
            for row in sus_alert_detail
            if isinstance(row, dict)
        }
        for row in sus_detail:
            if not isinstance(row, dict):
                continue
            email = str(row.get("primaryEmail") or "").strip().lower()
            if email:
                license_emails.add(email)
        sus_alert_n, sus_alert_detail_rows, sus_alert_rows = _suspended_users_alert(
            directory_users, exclude_emails=license_emails
        )
        payload["suspendedUsersAlert"] = sus_alert_n
        payload["suspendedUsersAlertDetail"] = sus_alert_rows
        if sus_alert_n:
            print(
                f"[info] {sus_alert_n} suspended user(s) for warn alerts "
                "(excluding users already in license-critical alerts).",
                file=sys.stderr,
            )
        if sus_count:
            note = (
                f"{sus_count} suspended user(s) still have Google Workspace license(s) "
                "— remove assignments in Admin console."
            )
            if license_notes:
                license_notes.append(note)
                payload["googleLicensesNote"] = " · ".join(license_notes)
            else:
                payload["googleLicensesNote"] = note

    if users_err:
        print(f"[warn] Google Workspace users: {users_err}", file=sys.stderr)
    if groups_err:
        print(f"[warn] Google Workspace groups: {groups_err}", file=sys.stderr)
    if license_errs:
        print(f"[warn] Google Workspace licenses: {payload['googleLicensesError']}", file=sys.stderr)

    out_path = write_json_snapshot(OUT, payload)
    print(f"Wrote {out_path}")
    _sync_google_licenses_to_analytics(payload)
    return 1 if (users_err and groups_err) else 0


if __name__ == "__main__":
    raise SystemExit(main())

