#!/usr/bin/env python3
"""adom-google — programmatic access to a user's Google Workspace from an Adom container.

Layered, REST-direct CLI (no browser automation, no heavyweight base CLI):

  ┌─ adom OAuth layer ── one shared Adom OAuth client + OAuth Gateway redirect →
  │                      a long-lived refresh token stored 0600 per user
  ├─ adom-google ─────── thin, modular wrapper exposing: gmail / contacts /
  │                      (drive, calendar trivially addable — same token plumbing)
  └─ base ────────────── the Google REST APIs themselves, called directly

Generalises (and replaces) the old `adom-gmail`, which was gmail.readonly-only.
The gmail subcommands here are behaviour-compatible with adom-gmail, and the old
~/.config/adom-gmail/config.json is auto-imported on first run.

Auth (one consent, long-lived refresh token):
  adom-google auth            # Adom way — uses the OAuth Gateway (no localhost loopback)
  adom-google auth --manual   # fallback — desktop-client paste flow (like adom-gmail)
  adom-google auth-code '<code>'   # finish the --manual flow
  adom-google status [--json] # show what's authorised (never prints secrets); exit 1 if not

See `adom-google help`.
"""
import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time

ADOM_GOOGLE_VERSION = "0.8.22"
if sys.argv[1:2] == ["--version"]:
    print(f"adom-google {ADOM_GOOGLE_VERSION}")
    sys.exit(0)

import requests

# --- ACCOUNTS ----------------------------------------------------------------------------------
# adom-google supports multiple Google accounts on one container, fully isolated from each
# other. The unnamed "default" account keeps the original ~/.config/adom-google/ paths; a
# named account (e.g. `--account personal`) lives under ~/.config/adom-google/accounts/<name>/
# with its OWN config.json (refresh token, mode) and its OWN provider.json (which may point at
# a different OAuth client/app — a Workspace-Internal client can't sign in a gmail.com user).
# Select with `--account <name>` (any position) or ADOM_GOOGLE_ACCOUNT=<name>.
def _resolve_account():
    acct = os.environ.get("ADOM_GOOGLE_ACCOUNT", "").strip()
    argv, kept, i = sys.argv, [sys.argv[0]], 1
    while i < len(argv):
        a = argv[i]
        if a == "--account":
            if i + 1 >= len(argv):
                sys.exit("ERROR: --account requires a name, e.g. --account personal")
            acct = argv[i + 1]; i += 2; continue
        if a.startswith("--account="):
            acct = a.split("=", 1)[1]; i += 1; continue
        kept.append(a); i += 1
    sys.argv = kept
    acct = (acct or "default").strip().lower()
    if not acct or not all(c.isalnum() or c in "-_" for c in acct):
        sys.exit(f"ERROR: bad account name {acct!r} — letters/digits/dashes/underscores only")
    return acct

ACCOUNT = _resolve_account()
BASE_CFG_DIR = os.path.expanduser("~/.config/adom-google")
CFG_DIR = BASE_CFG_DIR if ACCOUNT == "default" else os.path.join(BASE_CFG_DIR, "accounts", ACCOUNT)
CFG = os.path.join(CFG_DIR, "config.json")
LEGACY_CFG = os.path.expanduser("~/.config/adom-gmail/config.json")
HERE = os.path.dirname(os.path.realpath(__file__))  # realpath: resolve the ~/.local/bin symlink

# Scopes adom-google requests. Adding drive/calendar later = add one line here and
# re-run `adom-google auth`; everything else (token refresh, API calls) is generic.
# Two scope PROFILES. The consent screen's wording is driven entirely by these.
#   SAFE (default): no "see/delete ALL your files/sheets/calendars" lines. Uses narrowed
#     scopes — drive.file (only files this app creates/opens), calendar.events, gmail.modify
#     (NO permanent delete). Can create+edit Sheets/Docs/Slides it makes (via drive.file)
#     without the broad spreadsheets/documents/presentations "delete all" scopes.
#   FULL (opt-in, `auth --full`): the broad set — can touch ALL of the user's existing
#     Drive/Sheets/Docs/Slides/Calendars. For trusted users who want the whole board.
SCOPES_SAFE = [
    "https://www.googleapis.com/auth/gmail.modify",        # Gmail read/compose/send/labels (NOT permanent-delete)
    "https://www.googleapis.com/auth/contacts",            # People API — contacts read+write
    "https://www.googleapis.com/auth/drive.file",          # only files THIS app creates/opens (not "all your files")
    "https://www.googleapis.com/auth/calendar.events",     # calendar EVENTS (not "delete all your calendars")
    "https://www.googleapis.com/auth/tasks",               # Tasks
    "https://www.googleapis.com/auth/chat.messages",       # Google Chat — read + post (not a "delete all" risk)
    "https://www.googleapis.com/auth/chat.spaces.readonly", # Google Chat — list your spaces
    "https://www.googleapis.com/auth/chat.spaces.create",  # Google Chat — CREATE spaces (create-only, fits SAFE)
]
SCOPES_FULL = [
    "https://www.googleapis.com/auth/gmail.modify",        # Gmail read/compose/send/labels
    "https://www.googleapis.com/auth/contacts",            # contacts read+write
    "https://www.googleapis.com/auth/drive",               # ALL Drive files, sharing, permissions
    "https://www.googleapis.com/auth/spreadsheets",        # ALL Sheets — read+edit
    "https://www.googleapis.com/auth/documents",           # ALL Docs — read+edit
    "https://www.googleapis.com/auth/presentations",       # ALL Slides — read+edit
    "https://www.googleapis.com/auth/calendar",            # full Calendar
    "https://www.googleapis.com/auth/tasks",               # Tasks
    "https://www.googleapis.com/auth/chat.messages",       # Google Chat — read + POST messages as you (user OAuth)
    "https://www.googleapis.com/auth/chat.spaces",         # Google Chat — create spaces + read/update metadata
    #   (supersedes chat.spaces.readonly, which capped FULL users at read-only space listing —
    #    "full" could never create a space no matter what Google granted. wiki-notify-gchat 2026-07-21.)
    "https://www.googleapis.com/auth/chat.memberships",         # add/remove space members + managers
    "https://www.googleapis.com/auth/chat.delete",              # delete conversations/spaces — FULL only, NEVER safe
    "https://www.googleapis.com/auth/chat.customemojis",        # custom emoji
    "https://www.googleapis.com/auth/chat.users.readstate",     # per-user last-read time
    "https://www.googleapis.com/auth/chat.users.spacesettings", # per-user space settings
    "https://www.googleapis.com/auth/chat.users.availability",  # presence
    "https://www.googleapis.com/auth/chat.users.sections",      # sections
    # (Chat works because the shared rock-verbena project has a configured Chat app.)
    # Every string above was validated against Google's authorize endpoint before shipping (issue
    # #233): an invalid scope makes Google reject the WHOLE request with invalid_scope, which
    # breaks sign-in entirely rather than just the new capability. Re-validate before adding more.
    # Admin/Directory scopes stay OUT — separate admin-only opt-in. chat.admin.* stays OUT
    # PERMANENTLY: it is an org-wide admin surface, not a "drive my own Chat" capability.
]


# PERSONAL scope set — for connecting a personal @gmail via Adom's global "Adom Personal" OAuth
# client (see ADOM_PERSONAL_* below). Deliberately NON-RESTRICTED so the shared client works for
# every user without Google's restricted-scope CASA assessment: Calendar, Contacts, Tasks, the
# Drive files this app creates, and Gmail SEND/COMPOSE (sensitive, not restricted). NO inbox read
# (gmail.readonly/modify) and NO full Drive — those are restricted and switch on once Adom's
# verification + CASA clears; SCOPES_FULL then applies to the personal client too.
SCOPES_PERSONAL = [
    "https://www.googleapis.com/auth/calendar.events",
    "https://www.googleapis.com/auth/contacts",
    "https://www.googleapis.com/auth/tasks",
    "https://www.googleapis.com/auth/drive.file",
    "https://www.googleapis.com/auth/gmail.send",
    "https://www.googleapis.com/auth/gmail.compose",
]


# ADMIN / Directory bundle — the "separate admin-only opt-in" the policy always anticipated.
# NEVER in safe/full/personal: only a Workspace ADMIN can grant these, and they are org-wide
# (every user, every group), not "drive my own Workspace". Opt in explicitly at auth time with
# `auth --admin` so it costs ONE consent, not a second browser trip later (John, 2026-07-21).
SCOPES_ADMIN = [
    "https://www.googleapis.com/auth/admin.directory.group",         # create/manage groups (distribution lists)
    "https://www.googleapis.com/auth/admin.directory.group.member",  # add/remove group members
    "https://www.googleapis.com/auth/admin.directory.user.readonly", # read the user directory (to populate groups)
    "https://www.googleapis.com/auth/apps.groups.settings",          # Groups Settings API — who may post,
    #   moderation, Collaborative Inbox. WITHOUT it a group can be created but silently rejects
    #   external mail, so a contact@ address would bounce every message from a web form.
]


def _scopes_for(mode):
    if mode == "personal":
        return SCOPES_PERSONAL
    return SCOPES_FULL if mode == "full" else SCOPES_SAFE


# ---------------------------------------------------------------- org onboarding
# Adom's shared OAuth gateway. Its /callback is a PUBLIC, MULTI-TENANT relay: any org can
# register a one-time `state` over the WebSocket and have its auth code relayed back — zero
# per-org config on the gateway, near-zero load (a callback is one tiny request). A new org
# points its OWN Google OAuth client's redirect_uri at this callback and keeps its OWN client
# secret on its OWN container (RELAY mode, broker:false) — Adom never sees the org's secret.
# (Adom employees use BROKER mode instead, where the secret lives on the gateway; see provider.)
ADOM_SHARED_GATEWAY = "https://oauth-9mycwxij7tif.adom.cloud"

# The global "Adom Google Connector" OAuth client — External (User Type = External), so ANY
# Google account OUTSIDE adom.inc can authorize through it: personal @gmail users AND other orgs'
# Workspace employees (the Adom work client is Internal to adom.inc and rejects them). One client
# for everyone external, brokered in BROKER mode, so the SECRET lives only on the gateway
# (creds/adom-connector.json) and the public client_id below is safe to ship. Users connect with
# zero setup via `adom-google connect-personal`. (Constants keep the *_PERSONAL_* names for
# back-compat; they mean the Connector.)
ADOM_PERSONAL_APP = "adom-connector"
ADOM_PERSONAL_CLIENT_ID = "89165832027-6ce69tm6vji6i0v8a5hl6g3cr56d0v0b.apps.googleusercontent.com"

# The nine Google APIs adom-google drives — (console "enable" id, human label).
ONBOARD_APIS = [
    ("gmail.googleapis.com",         "Gmail API"),
    ("people.googleapis.com",        "People API (Contacts)"),
    ("drive.googleapis.com",         "Google Drive API"),
    ("sheets.googleapis.com",        "Google Sheets API"),
    ("docs.googleapis.com",          "Google Docs API"),
    ("slides.googleapis.com",        "Google Slides API"),
    ("calendar-json.googleapis.com", "Google Calendar API"),
    ("tasks.googleapis.com",         "Google Tasks API"),
    ("chat.googleapis.com",          "Google Chat API"),
]

def _enable_apis_url(project=None):
    # One URL that enables ALL nine APIs in one click (the GCP "enable multiple APIs" flow).
    u = "https://console.cloud.google.com/flows/enableapi?apiid=" + ",".join(a for a, _ in ONBOARD_APIS)
    return u + ("&project=" + project if project else "")

def _onboard_config(org=None):
    """The exact, canonical values an org needs to wire up their own Google OAuth client.
    Consumed by the onboarding skill (via `onboard --json`) to auto-fill the Cloud Console."""
    gw = (_provider().get("gateway_url") or ADOM_SHARED_GATEWAY).rstrip("/")
    label = "Adom Workspace CLI" + (f" — {org}" if org else "")
    return {
        "org": org,
        "gateway_url": gw,
        "redirect_uri": gw + "/callback",          # paste verbatim into the OAuth client
        "javascript_origins": [],                   # none needed (no browser JS client)
        "enable_apis_url": _enable_apis_url(),
        "apis": [{"id": a, "label": l} for a, l in ONBOARD_APIS],
        "scopes_safe": SCOPES_SAFE,
        "scopes_full": SCOPES_FULL,
        "consent": {                                # OAuth consent screen settings
            "user_type": "Internal",               # Internal → only the org's Workspace, long-lived tokens
            "app_name": label,
            "client_type": "Web application",       # the OAuth client TYPE to create
            "client_name": label,
        },
        "console": {                                # deep links the skill drives the browser to
            "home":           "https://console.cloud.google.com/",
            "project_create": "https://console.cloud.google.com/projectcreate",
            "consent_screen": "https://console.cloud.google.com/auth/overview",
            "credentials":    "https://console.cloud.google.com/apis/credentials",
            "create_client":  "https://console.cloud.google.com/apis/credentials/oauthclient",
            "chat_config":    "https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat",
        },
    }

ONBOARD_HINT = """Hint: ORG ONBOARDING — drive the user's OWN browser through Google Cloud Console.
Hint: This is the hand-held setup that gives a brand-new org their own Google OAuth client so
Hint: adom-google works for their whole team. Use the `adom-google-onboarding` skill, which
Hint: drives the user's REAL logged-in browser (adom-bridge `nbrowser_*`; pup native-mode as
Hint: fallback) and AUTO-FILLS the Cloud Console fields. Get the exact values to type with:
Hint:     adom-google onboard --json        # redirect_uri, the 9 APIs, scopes, consent settings
Hint: Steps the skill performs (screenshotting + pausing for the user's approval clicks):
Hint:   1. Create/select a Google Cloud project.
Hint:   2. Enable all nine APIs in one click (the `enable_apis_url`).
Hint:   3. Configure the OAuth consent screen (User type = Internal; app name from config).
Hint:   4. Create an OAuth client (type = Web application); AUTO-FILL Authorized redirect URI
Hint:      with the gateway `redirect_uri` (the error-prone field — never make the user type it).
Hint:   5. Capture the client_id + client_secret from the final dialog.
Hint:   6. Save them:   adom-google onboard finish --org <slug> --client-id <id> --client-secret <secret>
Hint:   7. Authorize (full Workspace by default, no mode quiz):   adom-google auth
Hint: The secret stays on THE USER'S container (0600) and rides Adom's shared callback in RELAY
Hint: mode — Adom never holds it."""

TOKEN_URL = "https://oauth2.googleapis.com/token"
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me"
PEOPLE_API = "https://people.googleapis.com/v1"
CAL_API = "https://www.googleapis.com/calendar/v3"
DRIVE_API = "https://www.googleapis.com/drive/v3"
DRIVE_UPLOAD = "https://www.googleapis.com/upload/drive/v3"
SHEETS_API = "https://sheets.googleapis.com/v4"
DOCS_API = "https://docs.googleapis.com/v1"
SLIDES_API = "https://slides.googleapis.com/v1"
TASKS_API = "https://tasks.googleapis.com/tasks/v1"

# --- OAuth PROVIDER (gateway URL + client) — deliberately NOT hardcoded -----------------------
# This open-source CLI ships with ZERO provider baked in. The provider (which OAuth gateway to
# talk to + which Google client_id) is resolved at runtime so the public code carries no
# Adom-specific infrastructure. Resolution, later wins:
#   1. bundled provider.json next to the script  (a private *provider* package may ship this)
#   2. legacy credentials.json next to the script (old {clientId,...}; back-compat only)
#   3. ~/.config/adom-google/provider.json        (what `provider set` / the employee pkg writes)
#   4. env: OAUTH_GATEWAY_URL, OAUTH_GATEWAY_WS, ADOM_GOOGLE_CLIENT_ID/SECRET/APP
# Adom employees get the Adom provider via the private `adom-google-adom` package; everyone else
# self-hosts a gateway or buys a managed one (support@adom.inc). See README.
DEFAULT_APP = "adom-google"
PROVIDER_PATH = os.path.join(CFG_DIR, "provider.json")          # per-account when --account is set
BASE_PROVIDER_PATH = os.path.join(BASE_CFG_DIR, "provider.json")  # named accounts inherit from this
BUNDLED_PROVIDER = os.path.join(HERE, "provider.json")
LEGACY_CREDS = os.path.join(HERE, "credentials.json")
MANUAL_REDIRECT = "http://localhost"  # manual desktop-client flow: loopback, user copies the code

_PROVIDER = None
def _provider():
    """Resolve {app, gateway_url, gateway_ws, gateway_redirect, client_id, client_secret}."""
    global _PROVIDER
    if _PROVIDER is not None:
        return _PROVIDER
    p = {"app": DEFAULT_APP, "gateway_url": None, "gateway_ws": None,
         "client_id": None, "client_secret": None, "broker": None}
    def merge(path, keymap):
        if not os.path.exists(path):
            return
        try:
            with open(path) as f:
                d = json.load(f)
        except Exception:
            return
        for src, dst in keymap.items():
            if d.get(src):
                p[dst] = d[src]
    full = {"gateway_url": "gateway_url", "gateway_ws": "gateway_ws", "client_id": "client_id",
            "client_secret": "client_secret", "app": "app"}
    merge(BUNDLED_PROVIDER, full)
    merge(LEGACY_CREDS, {"clientId": "client_id", "clientSecret": "client_secret", "app": "app"})
    merge(BASE_PROVIDER_PATH, full)
    if PROVIDER_PATH != BASE_PROVIDER_PATH:
        merge(PROVIDER_PATH, full)   # named account's own provider wins over the inherited base
    e = os.environ
    p["gateway_url"]    = e.get("OAUTH_GATEWAY_URL",      p["gateway_url"])
    p["gateway_ws"]     = e.get("OAUTH_GATEWAY_WS",       p["gateway_ws"])
    p["client_id"]      = e.get("ADOM_GOOGLE_CLIENT_ID",  p["client_id"])
    p["client_secret"]  = e.get("ADOM_GOOGLE_CLIENT_SECRET", p["client_secret"])
    p["app"]            = e.get("ADOM_GOOGLE_APP",        p["app"])
    # broker is a bool — merge() skips falsy values, so read it explicitly (later wins).
    for path in (BUNDLED_PROVIDER, BASE_PROVIDER_PATH, PROVIDER_PATH):
        if os.path.exists(path):
            try:
                d = json.load(open(path))
                if "broker" in d:
                    p["broker"] = bool(d["broker"])
            except Exception:
                pass
    if "ADOM_GOOGLE_BROKER" in e:
        p["broker"] = e["ADOM_GOOGLE_BROKER"].strip().lower() in ("1", "true", "yes", "on")
    if p["gateway_url"]:
        p["gateway_url"] = p["gateway_url"].rstrip("/")
        p["gateway_ws"] = (p["gateway_ws"] or "wss://" + p["gateway_url"].split("://", 1)[-1]).rstrip("/")
        p["gateway_redirect"] = p["gateway_url"] + "/callback"
    else:
        p["gateway_redirect"] = None
    _PROVIDER = p
    return p


NO_PROVIDER_HINT = """ERROR: adom-google has no OAuth provider configured (gateway URL + client_id).
The open-source CLI ships without one on purpose — the provider is account-specific. Three ways:
Hint:   • Adom employee  → install the private `adom-google-adom` package (drops the Adom provider).
Hint:   • Managed (any org, ~$5/mo) → email support@adom.inc for a hosted OAuth gateway container.
Hint:   • Self-host (DIY) → run your own gateway (john/service-oauth) + Google OAuth client, then:
Hint:         adom-google provider set --gateway https://your-gateway.example --client-id <id>
Hint: Check the current provider any time with: adom-google provider show"""

# AI hint emitted whenever adom-google is unconfigured. Per ai-hints-in-clis: this is how the
# CLI forces the installing AI to ASK THE USER the safe/full choice at the moment of setup,
# instead of silently picking. Read at the moment of need — survives context compaction.
SETUP_HINT = """ERROR: adom-google is not authorized yet (no Google token for this container).
Hint: Just run:  adom-google auth
Hint: It prints a Google consent URL — open it in the user's browser; they click Allow ONCE.
Hint: ⚠ That URL contains '&'. Open it as ONE value (a browser tool with the URL inside JSON, or a
Hint:   SINGLE-QUOTED shell arg). If you drop it UNQUOTED into a shell, it truncates at the first
Hint:   '&' and Google shows "Error 400: missing response_type". The auth output shows the safe way.
Hint: Default access is FULL Workspace (Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar,
Hint: Tasks, Chat + any Google API). There is NO mode question to ask the user — full is the
Hint: default so the tool "just works".
Hint:   (Advanced/rare only: `adom-google auth --safe` grants narrower scopes — just the
Hint:    Drive/Sheets/Docs/Slides files this app creates, no delete-all. Do NOT prompt for it;
Hint:    use --safe only if the user explicitly asks to minimize scopes.)
Hint: The user has ~60 minutes to click Allow (the gateway keeps the sign-in session alive), so
Hint: there's no rush. If they ever see a "Session Expired" page, just re-run `adom-google auth`."""


# ---------------------------------------------------------------- config / creds
def _load():
    cfg = _maybe_load_raw()
    if cfg is None:
        sys.exit(SETUP_HINT)
    if not cfg.get("refresh_token"):
        sys.exit(SETUP_HINT)
    return cfg


def _maybe_load_raw():
    if os.path.exists(CFG):
        with open(CFG) as f:
            return json.load(f)
    # One-time migration from adom-gmail (default account only — named accounts start clean).
    if ACCOUNT == "default" and os.path.exists(LEGACY_CFG):
        with open(LEGACY_CFG) as f:
            legacy = json.load(f)
        legacy.setdefault("scopes", ["https://www.googleapis.com/auth/gmail.readonly"])
        legacy["_migrated_from"] = LEGACY_CFG
        _save(legacy)
        sys.stderr.write(f"[adom-google] imported credentials from {LEGACY_CFG} "
                         f"(gmail.readonly only — run `adom-google auth` to add contacts).\n")
        return legacy
    return None


def _save(cfg):
    os.makedirs(CFG_DIR, exist_ok=True)
    fd = os.open(CFG, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(cfg, f, indent=2)
    os.chmod(CFG, 0o600)


def _client_creds(cfg):
    """client_id (public) + client_secret, from the per-user config or the resolved provider.
    In BROKER mode the secret is None — it lives only on the gateway; the manual/local flow
    still uses a real secret (via `init` or a self-host provider)."""
    pr = _provider()
    cid = cfg.get("client_id") or pr["client_id"]
    csec = cfg.get("client_secret") or pr.get("client_secret")
    if not cid:
        sys.exit(NO_PROVIDER_HINT)
    return cid, csec


# ---------------------------------------------------------------- token / API
def _access_token(cfg):
    # Reuse a cached access token while it's still valid.
    if cfg.get("access_token") and cfg.get("access_expires", 0) > time.time():
        return cfg["access_token"]
    if cfg.get("broker"):
        # Confidential-client broker: the gateway holds the secret and does the refresh.
        pr = _provider()
        if not pr["gateway_url"]:
            sys.exit(NO_PROVIDER_HINT)
        r = requests.post(pr["gateway_url"] + "/refresh",
                          json={"app": cfg.get("app", pr["app"]), "refresh_token": cfg["refresh_token"]},
                          timeout=30)
        if r.status_code != 200:
            sys.exit(f"Gateway token refresh failed ({r.status_code}). Re-run `adom-google auth`.")
        j = r.json()
    else:
        cid, csec = _client_creds(cfg)
        if not csec:
            sys.exit("No client secret for local refresh. Re-run `adom-google auth` (uses the broker).")
        r = requests.post(TOKEN_URL, data={
            "client_id": cid, "client_secret": csec,
            "refresh_token": cfg["refresh_token"], "grant_type": "refresh_token",
        }, timeout=30)
        if r.status_code != 200:
            sys.exit(f"Token refresh failed ({r.status_code}). Re-run `adom-google auth`.")
        j = r.json()
    cfg["access_token"] = j["access_token"]
    cfg["access_expires"] = time.time() + int(j.get("expires_in", 3600)) - 60
    _save(cfg)
    return cfg["access_token"]


def _request(cfg, method, url, **kw):
    tok = _access_token(cfg)
    headers = kw.pop("headers", {})
    headers["Authorization"] = f"Bearer {tok}"
    r = requests.request(method, url, headers=headers, timeout=30, **kw)
    if r.status_code == 403 and "people.googleapis.com" in url:
        sys.exit(f"403 from People API ({r.status_code}). The stored token likely lacks the "
                 f"contacts scope — run `adom-google auth` to re-consent with the full scope set.")
    if r.status_code >= 400:
        sys.exit(f"Google API error ({r.status_code}) {method} {url}:\n{r.text}")
    return r.json() if r.content else {}


def _gmail(cfg, path):
    return _request(cfg, "GET", f"{GMAIL_API}/{path}")


def _has_scope(cfg, scope):
    return scope in (cfg.get("scopes") or [])


# ---------------------------------------------------------------- gmail (migrated)
def _walk_parts(part, out):
    body = part.get("body", {})
    if body.get("attachmentId"):
        out.append({
            "attachmentId": body["attachmentId"],
            "filename": part.get("filename", ""),
            "mimeType": part.get("mimeType", ""),
            "size": body.get("size", 0),
        })
    for p in part.get("parts", []):
        _walk_parts(p, out)


def _list_attachments(cfg, msg_id):
    msg = _gmail(cfg, f"messages/{msg_id}")
    out = []
    _walk_parts(msg.get("payload", {}), out)
    return out


def _fetch_attachment_bytes(cfg, msg_id, att_id):
    data = _gmail(cfg, f"messages/{msg_id}/attachments/{att_id}")["data"]
    return base64.urlsafe_b64decode(data + "===")


def _unfold(text):
    lines, cur = [], ""
    for raw in text.replace("\r\n", "\n").split("\n"):
        if raw[:1] in (" ", "\t"):
            cur += raw[1:]
        else:
            if cur:
                lines.append(cur)
            cur = raw
    if cur:
        lines.append(cur)
    return lines


def _fmt_dt(val):
    v = val.strip()
    try:
        if v.endswith("Z"):
            return f"{v[0:4]}-{v[4:6]}-{v[6:8]} {v[9:11]}:{v[11:13]} UTC"
        if "T" in v and len(v) >= 15:
            return f"{v[0:4]}-{v[4:6]}-{v[6:8]} {v[9:11]}:{v[11:13]}"
        if len(v) == 8:
            return f"{v[0:4]}-{v[4:6]}-{v[6:8]} (all day)"
    except Exception:
        pass
    return v


def parse_ics(raw):
    text = raw.decode("utf-8", "replace")
    events, ev = [], None
    for line in _unfold(text):
        if line.startswith("BEGIN:VEVENT"):
            ev = {}
        elif line.startswith("END:VEVENT"):
            if ev is not None:
                events.append(ev)
            ev = None
        elif ev is not None and ":" in line:
            key, val = line.split(":", 1)
            name = key.split(";", 1)[0].upper()
            tzid = ""
            if "TZID=" in key:
                tzid = " " + key.split("TZID=", 1)[1].split(";")[0]
            if name in ("SUMMARY", "LOCATION", "DESCRIPTION", "ORGANIZER", "STATUS"):
                ev[name] = val.replace("\\,", ",").replace("\\n", " ").replace("\\;", ";")
            elif name in ("DTSTART", "DTEND"):
                ev[name] = _fmt_dt(val) + tzid
            elif name == "ATTENDEE":
                ev.setdefault("ATTENDEES", []).append(val.replace("mailto:", ""))
    return events


def cmd_gmail_attachments(a):
    cfg = _load()
    atts = _list_attachments(cfg, a.message_id)
    if not atts:
        print("(no attachments on this message)")
        return
    for x in atts:
        print(f"{x['mimeType']:24} {x['size']:>8}B  {x['filename']}\n    id: {x['attachmentId']}")


def cmd_gmail_read(a):
    cfg = _load()
    data = _fetch_attachment_bytes(cfg, a.message_id, a.attachment_id)
    if a.out:
        with open(a.out, "wb") as f:
            f.write(data)
        print(f"Wrote {len(data)} bytes to {a.out}")
    else:
        sys.stdout.buffer.write(data)


def cmd_gmail_ics(a):
    cfg = _load()
    atts = [x for x in _list_attachments(cfg, a.message_id)
            if x["filename"].lower().endswith(".ics") or "calendar" in x["mimeType"]]
    if not atts:
        sys.exit("No .ics/calendar attachment found on this message.")
    for x in atts:
        raw = _fetch_attachment_bytes(cfg, a.message_id, x["attachmentId"])
        for ev in parse_ics(raw):
            print("── Calendar event ──")
            for label in ("SUMMARY", "DTSTART", "DTEND", "LOCATION", "STATUS", "ORGANIZER"):
                if ev.get(label):
                    print(f"  {label.title():10}: {ev[label]}")
            if ev.get("ATTENDEES"):
                print(f"  Attendees : {', '.join(ev['ATTENDEES'])}")
            if ev.get("DESCRIPTION"):
                print(f"  Notes     : {ev['DESCRIPTION']}")
            print()


# ---------------------------------------------------------------- contacts (People API)
def cmd_contacts_create(a):
    cfg = None if a.dry_run else _load()
    person = {}
    if a.name:
        parts = a.name.split(" ", 1)
        person["names"] = [{"givenName": parts[0], "familyName": parts[1] if len(parts) > 1 else ""}]
    if a.email:
        person["emailAddresses"] = [{"value": e} for e in a.email]
    if a.org or a.title:
        org = {}
        if a.org:
            org["name"] = a.org
        if a.title:
            org["title"] = a.title
        person["organizations"] = [org]
    if a.phone:
        person["phoneNumbers"] = [{"value": p} for p in a.phone]
    if a.notes:
        # Google Contacts "Notes" == People API biographies (plain text).
        person["biographies"] = [{"value": a.notes, "contentType": "TEXT_PLAIN"}]
    if a.dry_run:
        print("DRY RUN — would POST to people:createContact:\n")
        print(json.dumps(person, indent=2))
        return
    res = _request(cfg, "POST", f"{PEOPLE_API}/people:createContact",
                   headers={"Content-Type": "application/json"}, json=person)
    rn = res.get("resourceName", "?")
    nm = (res.get("names") or [{}])[0].get("displayName", a.name or "(no name)")
    print(f"✅ Created contact: {nm}")
    print(f"   resourceName: {rn}")
    if res.get("organizations"):
        print(f"   org: {res['organizations'][0].get('name','')}")
    if a.json:
        print(json.dumps(res, indent=2))


def cmd_contacts_list(a):
    cfg = _load()
    params = {
        "personFields": "names,emailAddresses,organizations,phoneNumbers",
        "pageSize": str(a.limit),
        "sortOrder": "LAST_MODIFIED_DESCENDING",
    }
    res = _request(cfg, "GET", f"{PEOPLE_API}/people/me/connections?{urllib.parse.urlencode(params)}")
    conns = res.get("connections", [])
    if a.json:
        print(json.dumps(conns, indent=2))
        return
    if not conns:
        print("(no contacts)")
        return
    for c in conns:
        name = (c.get("names") or [{}])[0].get("displayName", "(no name)")
        email = (c.get("emailAddresses") or [{}])[0].get("value", "")
        org = (c.get("organizations") or [{}])[0].get("name", "")
        line = f"{name:32} {email:30}"
        if org:
            line += f" [{org}]"
        print(line.rstrip())
    if res.get("totalpeople"):
        print(f"\n({len(conns)} shown of {res['totalpeople']} total)")


def cmd_contacts_search(a):
    cfg = _load()
    # People API requires a "warmup" request before the first searchContacts call.
    base = f"{PEOPLE_API}/people:searchContacts"
    rm = "names,emailAddresses,organizations,phoneNumbers,biographies"
    requests.get(base, params={"query": "", "readMask": rm},
                 headers={"Authorization": f"Bearer {_access_token(cfg)}"}, timeout=30)
    time.sleep(1)
    res = _request(cfg, "GET", f"{base}?{urllib.parse.urlencode({'query': a.query, 'readMask': rm, 'pageSize': '20'})}")
    results = res.get("results", [])
    if a.json:
        print(json.dumps(results, indent=2))
        return
    if not results:
        print("(no matches)")
        return
    for r in results:
        p = r.get("person", {})
        name = (p.get("names") or [{}])[0].get("displayName", "(no name)")
        email = (p.get("emailAddresses") or [{}])[0].get("value", "")
        org = (p.get("organizations") or [{}])[0].get("name", "")
        print(f"{name:32} {email:30}" + (f" [{org}]" if org else ""))
        notes = (p.get("biographies") or [{}])[0].get("value", "")
        if a.verbose and notes:
            print(f"    notes: {notes}")
        if a.verbose:
            print(f"    resourceName: {p.get('resourceName','')}")


# ---------------------------------------------------------------- Google Chat (as the user)
# Posts as the SIGNED-IN user (their own Google identity) — no attribution prefix. This is
# distinct from adom-gchat, which posts via an org webhook and prefixes "(on behalf of …)".
CHAT_API = "https://chat.googleapis.com/v1"


def _chat(cfg, path, method="GET", body=None, params=None, allow_404=False):
    url = path if path.startswith("http") else f"{CHAT_API}/{path.lstrip('/')}"
    headers = {"Authorization": f"Bearer {_access_token(cfg)}"}
    data = None
    if body is not None:
        data = json.dumps(body)
        headers["Content-Type"] = "application/json"
    r = requests.request(method, url, headers=headers, params=params, data=data, timeout=60)
    if r.status_code in (400, 404) and allow_404:
        return {}                                    # caller prints a friendly "no DM/space" hint
    if r.status_code >= 400:
        sys.exit(f"Chat API error ({r.status_code}) {method} {url}:\n{r.text}")
    return r.json() if r.content else {}


def _find_dm(cfg, email):
    """Resolve the 1:1 DM space with a person by email → the Space dict (name, spaceUri)."""
    return _chat(cfg, "spaces:findDirectMessage", params={"name": f"users/{email}"}, allow_404=True)


def _space_name(v):
    """Normalize a space id to a 'spaces/XXX' resource name (caller handles emails)."""
    return v if v.startswith("spaces/") else f"spaces/{v}"


def _msg_text(text):
    """Message body source: '-' (stdin), '@file', or a literal string."""
    if text == "-":
        return sys.stdin.read()
    if text.startswith("@"):
        return open(text[1:], encoding="utf-8").read()
    return text


def _chat_upload(cfg, space, path):
    """Upload a local file as a Chat attachment → the attachmentDataRef to attach to a message.

    Two-step, per the Chat API: POST the bytes to the /upload endpoint (multipart: a small JSON
    metadata part naming the file, then the raw bytes), then reference the returned
    attachmentDataRef on messages.create. Covers images, video, PDFs, anything Chat accepts.
    Uses the SAME chat.messages scope as a text post (verified 2026-07-21), so no re-consent."""
    if not os.path.isfile(path):
        sys.exit(f"attachment not found: {path}")
    import mimetypes
    filename = os.path.basename(path)
    ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
    with open(path, "rb") as f:
        blob = f.read()

    boundary = "adomgoogle" + secrets.token_hex(12)
    meta = json.dumps({"filename": filename}).encode()
    body = b"".join([
        f"--{boundary}\r\n".encode(),
        b"Content-Type: application/json; charset=UTF-8\r\n\r\n", meta, b"\r\n",
        f"--{boundary}\r\n".encode(),
        f"Content-Type: {ctype}\r\n\r\n".encode(), blob, b"\r\n",
        f"--{boundary}--\r\n".encode(),
    ])
    # NOTE: the endpoint is attachments:upload. messages:upload returns a bare 503 from the
    # upload frontend (no JSON), which reads like an outage rather than a bad path.
    r = requests.post(
        f"https://chat.googleapis.com/upload/v1/{space}/attachments:upload",
        headers={"Authorization": f"Bearer {_access_token(cfg)}",
                 "Content-Type": f"multipart/related; boundary={boundary}"},
        params={"uploadType": "multipart"}, data=body, timeout=300)
    if r.status_code >= 400:
        hint = ""
        if r.status_code == 403 and "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in r.text:
            hint = "\nMissing scope — run: adom-google scopes --require chat.messages --auto-widen"
        sys.exit(f"Chat upload failed ({r.status_code}) for {filename}:\n{r.text[:400]}{hint}")
    ref = (r.json() or {}).get("attachmentDataRef")
    if not ref:
        sys.exit(f"Chat upload returned no attachmentDataRef for {filename}: {r.text[:300]}")
    sys.stderr.write(f"(uploaded {filename}, {len(blob)/1024:.0f} KB, {ctype})\n")
    return ref


def cmd_chat_spaces(a):
    cfg = _load()
    spaces, page = [], None
    while True:
        params = {"pageSize": 1000}
        if page:
            params["pageToken"] = page
        j = _chat(cfg, "spaces", params=params)
        spaces += j.get("spaces", [])
        page = j.get("nextPageToken")
        if not page:
            break
    if a.json:
        print(json.dumps(spaces, indent=2, ensure_ascii=False))
        return
    for s in spaces:
        print(f"{s.get('name')}  ·  {s.get('spaceType','?')}  ·  {s.get('displayName') or '(direct message)'}")
    sys.stderr.write(f"({len(spaces)} spaces — pass a spaces/XXX id OR an email to chat send/read)\n")


def cmd_chat_dm(a):
    cfg = _load()
    sp = _find_dm(cfg, a.email)
    name = sp.get("name")
    if not name:
        sys.exit(f"no DM/space found for {a.email} — run 'adom-google chat spaces', or check the email")
    if a.json:
        print(json.dumps(sp, indent=2, ensure_ascii=False))
        return
    print(name + (f"  ·  {sp.get('spaceUri')}" if sp.get("spaceUri") else ""))


def cmd_chat_send(a):
    cfg = _load()
    if not a.text and not getattr(a, "attach", None):
        sys.exit("nothing to send — pass message text and/or --attach <path>")
    if "@" in a.to:
        sp = _find_dm(cfg, a.to)
        space = sp.get("name")
        if not space:
            sys.exit(f"no DM/space found for {a.to} — run 'adom-google chat spaces', or check the email")
        sys.stderr.write(f"(auto-resolved DM {space} for {a.to})\n")
        label = a.to
    else:
        space = _space_name(a.to)
        sp = _chat(cfg, space)                       # validate + fetch spaceUri / displayName
        label = sp.get("displayName") or space
    body = {"text": _msg_text(a.text)}
    # --attach is repeatable: upload each file first, then reference them on the message.
    attachments = [_chat_upload(cfg, space, p) for p in (getattr(a, "attach", None) or [])]
    if attachments:
        body["attachment"] = [{"attachmentDataRef": ref} for ref in attachments]
    params = {}
    if a.thread:
        body["thread"] = {"threadKey": a.thread}
        params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
    # Stamp agent-sent messages so the RECEIVING side can detect them exactly.
    # Google Chat renders an app-attribution badge ("Adom Google") in the UI for
    # anything posted through the API with user credentials, but exposes that
    # attribution NOWHERE in the REST API. A custom clientAssignedMessageId is
    # the only durable, machine-readable signal, and it is invisible in the UI.
    # Opt out with --no-agent-marker.
    if not getattr(a, "no_agent_marker", False):
        params["messageId"] = "client-agent-" + secrets.token_hex(10)
    msg = _chat(cfg, f"{space}/messages", method="POST", body=body, params=params or None)
    uri = sp.get("spaceUri", "")
    who = (msg.get("sender") or {}).get("displayName") or "you"
    print(msg.get("name", "") + (f"  ·  {uri}" if uri else ""))
    sys.stderr.write(f"→ posted to {label} as {who}" + (f" · {uri}" if uri else "") + "\n")


def cmd_chat_read(a):
    cfg = _load()
    if "@" in a.frm:
        sp = _find_dm(cfg, a.frm)
        space = sp.get("name")
        if not space:
            sys.exit(f"no DM/space found for {a.frm} — run 'adom-google chat spaces', or check the email")
    else:
        space = _space_name(a.frm)
    j = _chat(cfg, f"{space}/messages", params={"pageSize": a.limit, "orderBy": "createTime desc"})
    msgs = j.get("messages", [])
    if a.json:
        print(json.dumps(msgs, indent=2, ensure_ascii=False))
        return
    for m in reversed(msgs):                         # show oldest → newest of the recent batch
        who = (m.get("sender") or {}).get("displayName") or (m.get("sender") or {}).get("name", "?")
        print(f"{who}: {m.get('text','')}")


# ---------------------------------------------------------------- generic API passthrough
# Hosts whose scopes adom-google deliberately does NOT request, at any tier. Hitting these is a
# policy decision, not a missing feature, so say so instead of suggesting a re-auth that won't help.
_EXCLUDED_HOSTS = {
    "admin.googleapis.com": "Admin SDK (Directory: users, groups/distribution lists, org units)",
    "cloudidentity.googleapis.com": "Cloud Identity (groups/memberships)",
}
# Best-effort host → the scope tail most likely missing, for the actionable hint.
_HOST_SCOPE = {
    "gmail.googleapis.com": "gmail.modify", "chat.googleapis.com": "chat.messages",
    "sheets.googleapis.com": "spreadsheets", "docs.googleapis.com": "documents",
    "slides.googleapis.com": "presentations", "www.googleapis.com": "drive",
    "drive.googleapis.com": "drive", "calendar-json.googleapis.com": "calendar",
    "tasks.googleapis.com": "tasks", "people.googleapis.com": "contacts",
}


def _scope_wall_hint(url, resp):
    """On a 403 scope failure, tell the caller what to DO next.

    Google's 403 body names the reason but never the fix, so an agent hitting a scope wall through
    the generic `api` passthrough just saw raw JSON and stalled (wiki-notify-gchat tried to create
    a contact@adom.inc distribution list, 2026-07-21). Print the exact next command, or say plainly
    that the scope is excluded by policy so nobody burns a re-auth chasing it."""
    if resp.status_code != 403:
        return
    text = resp.text or ""
    host = urllib.parse.urlparse(url).netloc
    e = sys.stderr

    # THREE different 403s that look identical in raw JSON but need opposite actions:
    #   SERVICE_DISABLED               -> the API is off in the GCP project (re-auth won't help)
    #   ACCESS_TOKEN_SCOPE_INSUFFICIENT-> missing scope (re-auth WILL help)
    #   not-an-admin                   -> the human lacks Workspace admin rights (nothing helps)
    # Keep them apart: one the user can fix in a console, one in a consent screen, one not at all.
    if "SERVICE_DISABLED" in text or "accessNotConfigured" in text:
        proj = ""
        try:
            for d in resp.json().get("error", {}).get("details", []):
                c = (d.get("metadata") or {}).get("consumer") or ""
                if c.startswith("projects/"):
                    proj = c.split("/", 1)[1]
        except Exception:
            pass
        e.write("\n── API not enabled (NOT a scope problem) ───────────────────\n")
        e.write(f"{host} is switched OFF in the OAuth client's Google Cloud project"
                + (f" ({proj})" if proj else "") + ".\n")
        e.write("Your token is fine — re-running `auth` will NOT fix this. Someone with access to\n"
                "that project must enable the API once:\n")
        if proj:
            e.write(f"  https://console.developers.google.com/apis/api/{host}/overview?project={proj}\n")
        e.write("Then wait a minute for it to propagate and retry.\n")
        e.write("────────────────────────────────────────────────────────────\n")
        return
    if "Not Authorized to access this resource" in text or "authError" in text:
        e.write("\n── not a Workspace admin ───────────────────────────────────\n")
        e.write("The API is reachable and the token has the scope, but this Google account is not a\n"
                "Workspace ADMIN, so it cannot manage the domain. Nothing you can change here — a\n"
                "Workspace super-admin must either grant admin rights or run this themselves.\n")
        e.write("────────────────────────────────────────────────────────────\n")
        return
    if "ACCESS_TOKEN_SCOPE_INSUFFICIENT" not in text and "insufficientPermissions" not in text:
        return
    e.write("\n── scope wall ──────────────────────────────────────────────\n")
    if host in _EXCLUDED_HOSTS:
        e.write(f"{host} needs {_EXCLUDED_HOSTS[host]} scopes. These are NOT in safe/full/personal\n"
                "by design (org-wide admin surface), so a bare `auth` will never unlock them.\n"
                "Opt in explicitly — one consent, and only a Workspace ADMIN can grant it:\n"
                "  adom-google auth --admin\n"
                "That covers Groups/distribution lists end to end, including apps.groups.settings,\n"
                "without which a new group silently rejects external mail.\n")
    else:
        guess = _HOST_SCOPE.get(host)
        e.write("The token is missing a scope for this API. Check what you actually hold:\n"
                "  adom-google scopes\n")
        if guess:
            e.write(f"and widen it (one Allow click, existing access is preserved):\n"
                    f"  adom-google scopes --require {guess} --auto-widen\n")
        else:
            e.write("and widen it with the scope this method needs:\n"
                    "  adom-google scopes --require <scope> --auto-widen\n")
        e.write("NOTE: a granted scope is not enough on its own — the API must also be ENABLED in\n"
                "the OAuth client's GCP project.\n")
    e.write("────────────────────────────────────────────────────────────\n")


def cmd_api(a):
    """Sign ANY Google REST/Discovery call with the stored token — the 'whole board'.
    Reach of this command == the scopes the stored token holds (see `status`); add scopes
    in SCOPES + re-`auth` to widen it, and enable the API in the GCP project.

    Examples:
      adom-google api https://sheets.googleapis.com/v4/spreadsheets/<id>/values/A1:D10
      adom-google api -X POST https://sheets.googleapis.com/v4/spreadsheets -d '{"properties":{"title":"BOM"}}'
      adom-google api -X POST https://docs.googleapis.com/v1/documents/<id>:batchUpdate -d @reqs.json
    """
    cfg = _load()
    url = a.url if a.url.startswith("http") else "https://" + a.url.lstrip("/")
    headers = {"Authorization": f"Bearer {_access_token(cfg)}"}
    body = None
    if getattr(a, "upload_file", None):
        body = open(a.upload_file, "rb").read()  # raw bytes: Drive media, YouTube resumable PUT, etc.
        headers["Content-Type"] = a.content_type or "application/octet-stream"
    elif a.data is not None:
        body = open(a.data[1:], "rb").read() if a.data.startswith("@") else a.data
        headers["Content-Type"] = a.content_type or "application/json"
    # A LIST of pairs, not a dict: repeated keys are how several Google APIs take lists
    # (metadataHeaders, fields, orderBy...). dict() kept only the LAST value, so
    #   -q metadataHeaders=From -q metadataHeaders=To
    # silently requested only To — a truncated REQUEST returning a valid 200, which reads as
    # complete-but-empty data. That cost a sibling thread an entire email-harvesting pass across
    # 19 people before they worked out the tool was wrong, not the mailbox (2026-07-21).
    params = [tuple(q.split("=", 1)) for q in (a.query or [])]
    r = requests.request(a.method.upper(), url, headers=headers,
                         params=params or None, data=body, timeout=120)
    sys.stderr.write(f"HTTP {r.status_code} {a.method.upper()} {url}\n")
    ct = r.headers.get("content-type", "")
    if a.raw or "application/json" not in ct:
        sys.stdout.buffer.write(r.content)
    else:
        print(json.dumps(r.json(), indent=2, ensure_ascii=False))
    _scope_wall_hint(url, r)
    sys.exit(0 if r.status_code < 400 else 1)


# ---------------------------------------------------------------- auth: gateway flow
def _ws_wait_with_reconnect(ws_url, send_msgs, want_types, timeout=3600, state=None,
                            gateway_url=None):
    """Wait for the gateway's callback, surviving dropped sockets.

    The ingress kills idle WebSockets (~120s), laptops sleep, wifi flaps. A single drop used to
    abort the whole sign-in ("gateway closed the connection") and, worse, the gateway deleted the
    pending session on disconnect so clicking Allow later showed "Session Expired". The gateway
    now keeps sessions alive and PARKS the result; this loop reconnects, re-registers the same
    state (which replays a parked result), and polls GET /result as a last resort. The user gets
    the full window to read Google's consent screen and click Allow."""
    deadline = time.time() + timeout
    attempt = 0
    while time.time() < deadline:
        remaining = max(30, int(deadline - time.time()))
        try:
            return _minimal_ws_roundtrip(ws_url, send_msgs, want_types, timeout=remaining)
        except Exception as e:
            # Before retrying, ask the gateway whether the result already landed while we were
            # disconnected — the common case when the drop happened mid-consent.
            if state and gateway_url:
                try:
                    r = requests.get(gateway_url.rstrip("/") + "/result",
                                     params={"state": state}, timeout=20)
                    j = r.json()
                    if j.get("ok") and not j.get("pending") and j.get("result"):
                        res = j["result"]
                        if res.get("type") == "error":
                            raise RuntimeError("gateway error: " + str(res.get("error") or res.get("detail")))
                        if res.get("type") in want_types:
                            return res
                except requests.RequestException:
                    pass
            if time.time() >= deadline:
                break
            attempt += 1
            time.sleep(min(5, attempt))          # brief backoff, then reconnect + re-register
    raise RuntimeError("timed out waiting for the gateway callback")


def _minimal_ws_roundtrip(ws_url, send_msgs, want_types, timeout=3600):
    """Tiny RFC6455 client (stdlib only): connect, send JSON text frames, return the first
    received JSON message whose 'type' == want_type. Used for the OAuth Gateway handshake so
    adom-google stays a single dependency-light file (no `ws`/`websockets` package).

    NOTE: this path is exercised only when the gateway is live. The `--manual` flow is the
    fallback that works without the gateway."""
    u = urllib.parse.urlparse(ws_url)
    host = u.hostname
    port = u.port or (443 if u.scheme == "wss" else 80)
    path = u.path or "/"
    key = base64.b64encode(secrets.token_bytes(16)).decode()

    raw = socket.create_connection((host, port), timeout=30)
    if u.scheme == "wss":
        raw = ssl.create_default_context().wrap_socket(raw, server_hostname=host)
    req = (f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
           f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
           f"Sec-WebSocket-Version: 13\r\n\r\n")
    raw.sendall(req.encode())

    # read handshake response headers
    buf = b""
    while b"\r\n\r\n" not in buf:
        chunk = raw.recv(4096)
        if not chunk:
            raise RuntimeError("gateway closed during handshake")
        buf += chunk
    if b" 101 " not in buf.split(b"\r\n", 1)[0]:
        raise RuntimeError("gateway did not accept websocket upgrade: " + buf[:80].decode("latin1"))
    leftover = buf.split(b"\r\n\r\n", 1)[1]

    def _send_text(s):
        payload = s.encode()
        mask = secrets.token_bytes(4)
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
        header = b"\x81"  # FIN + text
        n = len(payload)
        if n < 126:
            header += struct.pack("!B", 0x80 | n)
        elif n < 65536:
            header += struct.pack("!BH", 0x80 | 126, n)
        else:
            header += struct.pack("!BQ", 0x80 | 127, n)
        raw.sendall(header + mask + masked)

    inbuf = bytearray(leftover)

    def _recv_frame():
        nonlocal inbuf
        while True:
            while len(inbuf) < 2:
                inbuf += raw.recv(4096)
            b0, b1 = inbuf[0], inbuf[1]
            opcode = b0 & 0x0F
            ln = b1 & 0x7F
            idx = 2
            if ln == 126:
                while len(inbuf) < 4:
                    inbuf += raw.recv(4096)
                ln = struct.unpack("!H", inbuf[2:4])[0]; idx = 4
            elif ln == 127:
                while len(inbuf) < 10:
                    inbuf += raw.recv(4096)
                ln = struct.unpack("!Q", inbuf[2:10])[0]; idx = 10
            while len(inbuf) < idx + ln:
                inbuf += raw.recv(4096)
            data = bytes(inbuf[idx:idx + ln])
            del inbuf[:idx + ln]
            if opcode == 0x8:  # close
                raise RuntimeError("gateway closed the connection")
            if opcode == 0x9:  # ping → pong
                raw.sendall(b"\x8a\x80" + secrets.token_bytes(4))
                continue
            if opcode in (0x1, 0x2):
                return data

    for m in send_msgs:
        _send_text(json.dumps(m))

    raw.settimeout(timeout)
    deadline = time.time() + timeout
    try:
        while time.time() < deadline:
            data = _recv_frame()
            try:
                msg = json.loads(data.decode())
            except Exception:
                continue
            if msg.get("type") == "error":
                raise RuntimeError("gateway error: " + str(msg.get("error") or msg.get("detail")))
            if msg.get("type") in want_types:
                return msg
    finally:
        try:
            raw.close()
        except Exception:
            pass
    raise RuntimeError("timed out waiting for the gateway callback")


def _exchange_code(cfg, code, redirect_uri, scopes, mode):
    cid, csec = _client_creds(cfg)
    r = requests.post(TOKEN_URL, data={
        "client_id": cid, "client_secret": csec, "code": code,
        "redirect_uri": redirect_uri, "grant_type": "authorization_code",
    }, timeout=30)
    if r.status_code != 200:
        sys.exit(f"Code exchange failed ({r.status_code}): {r.text}")
    rt = r.json().get("refresh_token")
    if not rt:
        sys.exit("No refresh_token returned (re-run auth; needs access_type=offline & prompt=consent).")
    cfg["refresh_token"] = rt
    cfg["scopes"] = scopes
    cfg["mode"] = mode
    cfg["broker"] = False
    cfg.pop("_migrated_from", None)
    cfg.pop("_pending_mode", None)
    _save(cfg)


def _store_broker_tokens(cfg, tokens, scopes, mode):
    """Gateway broker returned the tokens directly — no client secret on this container."""
    cfg["refresh_token"] = tokens["refresh_token"]
    if tokens.get("access_token"):
        cfg["access_token"] = tokens["access_token"]
        cfg["access_expires"] = time.time() + int(tokens.get("expires_in", 3600)) - 60
    cfg["scopes"] = scopes
    cfg["mode"] = mode
    cfg["broker"] = True
    cfg["app"] = _provider()["app"]
    cfg.pop("client_secret", None)   # broker mode: never keep a secret locally
    cfg.pop("_migrated_from", None)
    cfg.pop("_pending_mode", None)
    _save(cfg)


def _print_open_url(auth_link, step="1"):
    """Present the consent URL so an AI can act on it without mangling it.

    THREE LANES, cheapest-for-the-human first (issue #9, #8):
      A. pup, hands-free: pup's durable `adom-you` profile + OS-keychain vault can sign into
         Google and click Allow with no human in the loop, once a Google login is saved there.
         On a machine where it is not, the FIRST pass is one interactive sign-in inside the pup
         window (toast the user with focus on it); every later `auth` is silent.
      B. a clickable hyperlink: Hydrogen's browser picker lets the USER choose browser/profile.
         One click, nothing for the AI to get wrong, no extension required.
      C. the Adom extension window (nbrowser_open_window), ONLY when `nbrowser_profiles` says
         extensionInstalled:true for that profile; it often is not, and the call then fails with
         "native browser extension not connected" (nbrowser_wake_profile does not fix it).

    The '&' warning matters in every lane: dropped UNQUOTED into a shell/open command the shell
    cuts the URL at the first '&', Google receives only client_id and errors
    'invalid_request: missing response_type' (issue #3)."""
    safe = json.dumps({"sessionId": "gauth", "url": auth_link})
    print(f"{step}) Get this consent URL in front of Google and get Allow clicked.\n")
    print("   " + auth_link + "\n")
    print("   ⚠ The URL contains '&'. Keep it as ONE whole value. Do NOT drop it UNQUOTED into")
    print("     a shell/open command: the shell cuts it at the first '&', Google then gets only")
    print("     client_id and shows \"Error 400: invalid_request, missing response_type\".\n")
    print("   LANE A, HANDS-FREE (try first): drive the consent page in pup. pup's durable")
    print("   `adom-you` profile keeps the Google session, so once it is signed in the user")
    print("   never clicks again. Check the vault for a Google login first:")
    print("     adom-bridge credential_list        # any accounts.google.com / google.com entry?")
    print("     adom-bridge pup_open_window '" + safe + "'")
    print("     # sign-in page appeared and the vault has a Google login? submit it (reason-gated):")
    print("     adom-bridge pup_login '{\"sessionId\":\"gauth\",\"submit\":true,\"submitReason\":\"Google consent for adom-google\"}'")
    print("     # then pick the account + click Allow via pup_eval on the classic consent page:")
    print("     adom-bridge pup_eval '{\"sessionId\":\"gauth\",\"expression\":\"[...document.querySelectorAll(\\\"button\\\")].find(b=>/^(Allow|Continue)$/.test(b.innerText.trim()))?.click()\"}'")
    print("     Caveats: a FedCM \"Sign in with Google\" chooser rejects in-page clicks (the classic")
    print("     accounts.google.com consent page does not). NO Google login in the vault yet? Then")
    print("     the first pass is one interactive sign-in INSIDE that pup window: open it as above,")
    print("     toast the user with focus on it (below), they sign in once, and every later `auth`")
    print("     on this machine is silent. Do not read \"hands-free\" as \"always\".\n")
    print("   LANE B, ONE HUMAN CLICK (always works): surface the URL to the user as a CLICKABLE")
    print("   HYPERLINK in your reply. Hydrogen's browser picker lets them choose browser/profile.")
    print("   Do NOT put it in a code fence: a fence renders as plain text, the user has to")
    print("   copy-paste it by hand, and the picker never fires.\n")
    print("   LANE C, ONLY IF the Adom extension is installed in the target profile (check with")
    print("   `adom-bridge nbrowser_profiles`, require extensionInstalled:true):")
    print("     adom-bridge nbrowser_open_window '" + safe + "'")
    print("     # extension NOT installed there? Do not fall back to nbrowser_open_os_window: it is")
    print("     # extension-free and returns ok without confirming what rendered. Use lane B.\n")
    # Foreground guidance. abe/nbrowser rightly defaults to BACKGROUND windows and the
    # foreground-etiquette skill says never steal focus. OAuth consent is the one flow whose
    # entire purpose is a human physically clicking Allow, so this is a sanctioned exception:
    # TELL THEM, with a toast that lands the click ON the consent window, AFTER it exists.
    print("   TOASTING THE USER (lanes A-first-pass and C; the foreground exception):")
    print("     Consent is the one flow where taking the foreground is justified. Fire the toast")
    print("     AFTER the window exists (a toast that points at nothing is worse than none) and")
    print("     give it `focus` so the click raises the CONSENT window, not Adom Bridge:\n")
    print("       adom-bridge notify_user '{\"id\":\"gauth\",\"title\":\"Google sign-in needed\",")
    print("         \"body\":\"Click Allow in the window I just opened. You have ~60 min.\",")
    print("         \"scenario\":\"reminder\",\"buttons\":[{\"label\":\"Done\"},{\"label\":\"Cancel\"}],")
    print("         \"focus\":{\"titleContains\":\"Sign in - Google\"}}'")
    print("       # READ THE REPLY: require clickBehavior:\"focus-window\" and focus.resolved:true;")
    print("       # if it carries _couldHaveDoneBetter, do what it says (e.g. pass the hwnd from")
    print("       # the window verb's reply instead of a title). Then learn what they did:")
    print("       adom-bridge notify_response '{\"id\":\"gauth\"}'   # or add \"wait\":true above to block\n")
    print("     GENTLER, prefer it when the user may be mid-task: leave the window in the")
    print("     background and flash the taskbar (browser_alert_window) so they come to it when")
    print("     ready. The session survives a dropped socket, so there is no rush either way.\n")
    print("   IF GOOGLE SHOWS \"Sorry, something went wrong there. Try again.\": that is Google's")
    print("   generic first-load hiccup, not a scope or client problem. Stop this listener and")
    print("   re-run `adom-google auth` for a fresh URL; an earlier approval is recovered and")
    print("   nobody clicks Allow twice. This listener prints a line the moment the callback")
    print("   lands, so \"user clicked and Google failed\" (line printed, then an error) is")
    print("   distinguishable from \"user never clicked\" (still waiting).\n")


def _auth_json_line(real_out, cfg, scopes, mode, via):
    """`auth --json`: the ONE machine-readable line an AI waits for (issue #9 §3)."""
    real_out.write(json.dumps({"ok": True, "authorized": True, "account": ACCOUNT, "mode": mode,
                               "via": via, "scopes": scopes, "config": CFG}) + "\n")
    real_out.flush()


def cmd_auth(a):
    # --json: every human-facing line (URL block, waiting notes) goes to STDERR and the ONLY
    # thing on stdout is one JSON line on success, so a waiter never has to poll `status`.
    as_json = getattr(a, "json", False)
    real_out = sys.stdout
    if as_json:
        sys.stdout = sys.stderr
    try:
        return _cmd_auth_impl(a, as_json, real_out)
    finally:
        sys.stdout = real_out


def _cmd_auth_impl(a, as_json, real_out):
    cfg = _maybe_load_raw() or {}
    # FULL is the default (no first-run mode quiz — issue #2). `--safe` is an advanced opt-in
    # for users who explicitly want the narrower create-only scopes. `--full` is a no-op kept
    # for back-compat. A PERSONAL @gmail (the global adom-personal client) uses the non-restricted
    # personal scope set automatically.
    mode = "safe" if getattr(a, "safe", False) else "full"
    if _provider().get("app") == ADOM_PERSONAL_APP:
        # Adom Google Connector (External, Production/unverified): default to the non-restricted
        # (lite) set — Calendar/Contacts/Tasks/drive.file/gmail send+compose — which works for
        # external users TODAY while Google verification + CASA are pending. `--full` requests the
        # complete set: it succeeds for whitelisted testers now, and becomes usable for everyone
        # once CASA clears (at which point this default flips to full).
        mode = "full" if getattr(a, "full", False) else "personal"
    # `preserve_scopes` (set by `scopes --auto-widen`) is the AUTHORITATIVE base: the scopes the
    # token actually holds. Otherwise fall back to the tier default.
    preserve = list(getattr(a, "preserve_scopes", None) or [])
    scopes = preserve or _scopes_for(mode)
    # `--admin` folds in the Directory/Groups bundle so ONE consent covers admin work too.
    if getattr(a, "admin", False):
        scopes = scopes + SCOPES_ADMIN
    # `--add-scope` (repeatable) widens the computed set — also how `scopes --auto-widen` re-auths.
    extra = [_normalize_scope(s) for s in (getattr(a, "add_scopes", None) or [])]
    scopes = list(dict.fromkeys(scopes + extra))

    # SAFETY NET for every path, not just --auto-widen: a Google consent REPLACES the grant, so
    # any currently-held scope missing from this request is silently revoked on Allow. Warn loudly
    # and offer the exact command that keeps everything. Best-effort: never block auth on it.
    if not preserve and cfg.get("refresh_token"):
        try:
            tok = _access_token(cfg)
            r = requests.get("https://oauth2.googleapis.com/tokeninfo",
                             params={"access_token": tok}, timeout=20)
            if r.status_code == 200:
                held = set(r.json().get("scope", "").split())
                dropping = sorted(held - set(scopes))
                if dropping:
                    tails = ", ".join(s.split("/")[-1] for s in dropping)
                    sys.stderr.write(
                        "\n⚠ THIS CONSENT WOULD REVOKE SCOPES YOU CURRENTLY HOLD:\n"
                        f"    {tails}\n"
                        "  Approving replaces your grant with only what is requested here.\n"
                        "  To ADD a capability while keeping everything, cancel and run:\n"
                        f"    adom-google scopes --require <scope> --auto-widen\n"
                        "  (or re-run this command with the flag that includes them, e.g. --admin)\n\n")
        except requests.RequestException:
            pass
    if a.manual:
        return _auth_manual(cfg, scopes, mode)
    # Gateway flow — one static redirect, code routed back over WebSocket. Needs a provider.
    pr = _provider()
    if not pr["gateway_url"]:
        sys.exit(NO_PROVIDER_HINT)
    cid, _ = _client_creds(cfg)
    # Before minting a new sign-in, see whether an EARLIER one already completed while no CLI was
    # listening. If so the user has already clicked Allow and must not be asked to do it twice.
    if _pending_claim(cfg, scopes, mode, pr["gateway_url"]):
        if as_json:
            _auth_json_line(real_out, _maybe_load_raw() or cfg, scopes, mode, "recovered-approval")
        return
    state = secrets.token_urlsafe(24)
    _pending_save(state, scopes, mode, pr["gateway_url"])
    params = {
        "client_id": cid, "redirect_uri": pr["gateway_redirect"], "response_type": "code",
        "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent",
        "state": state,
    }
    # broker=False → RELAY mode: the gateway just relays the code back; THIS container exchanges
    # it locally with the org's own secret. (None/True → broker mode: gateway exchanges.)
    want_exchange = pr.get("broker") is not False
    auth_link = AUTH_URL + "?" + urllib.parse.urlencode(params)
    if getattr(a, "url_file", None):
        # Clean machine-readable copy of the consent URL (one bare line). Tools that open the
        # browser for the user should read THIS instead of scraping stdout — quote/JSON wrappers
        # in the human output have corrupted scraped URLs before (trailing "}' broke the state).
        with open(a.url_file, "w") as f:
            f.write(auth_link + "\n")
    print(f"OAuth Gateway flow — {mode.upper()} access"
          + ("  (full Workspace: Gmail/Contacts/Drive/Sheets/Docs/Slides/Calendar/Tasks/Chat + delete)"
             if mode == "full" else "  (safe: only files this app makes; no 'delete all')") + ".\n")
    _print_open_url(auth_link)
    # The gateway keeps the sign-in session alive for the full hour and parks the result if our
    # socket drops; _ws_wait_with_reconnect reconnects and claims it. A network blip no longer
    # costs the user their sign-in.
    print("2) Waiting for your approval — you have ~60 minutes, no rush.")
    # An AI wrapping this in a `timeout` kills the listener while the human is still
    # deciding; the consent is fine but the run looks like a failed sign-in. Say so here,
    # and say that re-running recovers, because that is not guessable from the outside.
    print("   AI: do NOT wrap this in a `timeout` — it blocks until the human clicks.")
    print("   If the listener does die, just run `adom-google auth` again: an earlier")
    print("   approval is recovered and the user does not click twice.")
    print("   Capturing stdout to a file? Run with PYTHONUNBUFFERED=1 or you will see")
    print("   nothing until the buffer flushes and will not find the URL above.")
    sys.stdout.flush()
    try:
        # Broker mode: the gateway exchanges the code and returns tokens (the client
        # secret stays on the gateway). Falls back to relayed code if it can't broker.
        msg = _ws_wait_with_reconnect(
            pr["gateway_ws"],
            send_msgs=[{"type": "register", "state": state, "provider": "google",
                        "app": pr["app"], "exchange": want_exchange}],
            want_types=("tokens", "callback"),
            timeout=3600,
            state=state,
            gateway_url=pr["gateway_url"],
        )
    except Exception as e:
        sys.exit(f"\nGateway flow failed: {e}\n"
                 f"If you saw a 'Session Expired' page, Google's generic 'Sorry, something went\n"
                 f"wrong there. Try again.', or waited more than ~60 minutes, just re-run\n"
                 f"`adom-google auth` (a fresh URL; an earlier approval is recovered, nobody clicks\n"
                 f"twice). If the gateway itself is down ({pr['gateway_url']}),\n"
                 f"fallback: `adom-google auth --manual`.")
    # The callback landed. Say so BEFORE judging it, so an AI can tell "the user clicked and
    # Google returned an error" from "the user never clicked" (issue #9 §4).
    print(f"\n↳ Callback received from the gateway ({msg.get('type')}); the user reached the end of the consent flow.")
    sys.stdout.flush()
    if msg.get("state") != state:
        sys.exit("State mismatch from gateway — aborting.")
    if msg.get("error") or (msg.get("type") == "callback" and not msg.get("code")):
        sys.exit(f"\nGoogle returned '{msg.get('error') or 'no code'}' instead of an auth code.\n"
                 f"If that's 'access_denied' you declined consent. Otherwise the sign-in session\n"
                 f"likely expired — re-run `adom-google auth` (you have ~60 minutes to click Allow).")
    if msg.get("type") == "tokens":
        _store_broker_tokens(cfg, msg["tokens"], scopes, mode)
        _pending_clear()
        print(f"\n✅ Authorized via gateway broker ({mode} mode). Token stored (0600); "
              "client secret never touched this container.")
        via = "gateway-broker"
    else:
        _exchange_code(cfg, msg["code"], pr["gateway_redirect"], scopes, mode)
        _pending_clear()
        print(f"\n✅ Authorized via gateway ({mode} mode). Refresh token stored (0600).")
        via = "gateway-relay"
    print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes))
    if as_json:
        _auth_json_line(real_out, cfg, scopes, mode, via)


def _pending_path():
    return os.path.join(CFG_DIR, "pending-auth.json")


def _pending_save(state, scopes, mode, gateway_url):
    """Remember the in-flight sign-in so a LATER run can claim it.

    Each auth run mints a fresh random state, so without this a click that lands after the CLI
    exits is unrecoverable: the gateway parks the tokens under a state nobody remembers, they
    expire unclaimed, and the user consented for nothing while seeing "Authorization Complete".
    Observed in the wild — a consent link whose CLI had exited was swept at exactly 60m."""
    try:
        os.makedirs(CFG_DIR, exist_ok=True)
        fd = os.open(_pending_path(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            json.dump({"state": state, "scopes": scopes, "mode": mode,
                       "gateway_url": gateway_url, "created_at": time.time()}, f)
    except OSError:
        pass                                        # never block auth on bookkeeping


def _pending_clear():
    try:
        os.remove(_pending_path())
    except OSError:
        pass


def _pending_claim(cfg, scopes, mode, gateway_url):
    """Try to finish an EARLIER sign-in whose CLI exited before the user clicked Allow.

    Returns True if it recovered a completed authorization (nothing left to do), else False.
    Only auto-claims when the parked grant matches the scopes being asked for now — claiming a
    narrower earlier grant would silently under-authorize."""
    try:
        p = json.load(open(_pending_path()))
    except Exception:
        return False
    state = p.get("state")
    if not state:
        return False
    age = time.time() - (p.get("created_at") or 0)
    if age > 3700:                                  # past the gateway's 60m sweep — nothing to get
        _pending_clear(); return False
    try:
        r = requests.get((p.get("gateway_url") or gateway_url).rstrip("/") + "/result",
                         params={"state": state}, timeout=20)
        j = r.json()
    except Exception:
        return False
    if not j.get("ok"):
        _pending_clear(); return False              # unknown/expired state
    if j.get("pending"):
        mins = int(age // 60)
        sys.stderr.write(
            f"\nNote: an earlier consent link from {mins}m ago is still open and unclicked.\n"
            "  Approving THAT tab still works — this run mints a new link, and clicking either\n"
            "  finishes the job. (Nothing is lost either way.)\n")
        return False
    result = j.get("result") or {}
    if result.get("type") == "error":
        _pending_clear(); return False
    if sorted(p.get("scopes") or []) != sorted(scopes):
        sys.stderr.write(
            "\nNote: a COMPLETED earlier approval is waiting, but it granted a different scope\n"
            "  set than this run is requesting, so it was not claimed automatically.\n")
        return False
    if result.get("type") == "tokens":
        _store_broker_tokens(cfg, result["tokens"], p.get("scopes"), p.get("mode") or mode)
    elif result.get("code"):
        _exchange_code(cfg, result["code"], _provider()["gateway_redirect"],
                       p.get("scopes"), p.get("mode") or mode)
    else:
        return False
    _pending_clear()
    print("✅ Recovered your earlier approval — you already clicked Allow, so no need to do it "
          "again.\n   Token stored (0600).")
    print("   Scopes: " + ", ".join(s.split("/")[-1] for s in (p.get("scopes") or [])))
    return True


def _auth_manual(cfg, scopes, mode):
    cid, _ = _client_creds(cfg)
    if not cfg.get("client_id"):
        print("Manual flow uses the configured OAuth client, but its redirect must allow")
        print(f"`{MANUAL_REDIRECT}`. If you hit redirect_uri_mismatch, run:")
        print("   adom-google init <your_desktop_client_id> <your_desktop_client_secret>\n")
    cfg["_pending_mode"] = mode       # so auth-code knows which scope profile was requested
    _save(cfg)
    params = {
        "client_id": cid, "redirect_uri": MANUAL_REDIRECT, "response_type": "code",
        "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent",
    }
    manual_link = AUTH_URL + "?" + urllib.parse.urlencode(params)
    print(f"Manual (desktop-client) flow — {mode.upper()} mode.\n")
    print("1) Open this URL and approve. ⚠ It contains '&' — SINGLE-QUOTE the whole URL if you use")
    print("   any shell/open command, or the shell truncates it at the first '&' and Google shows")
    print("   \"Error 400: invalid_request — missing response_type\":\n")
    print("   " + manual_link)
    print("\n2) The page will fail to load (expected). Copy the `code` value from the address")
    print("   bar, then run:\n\n   adom-google auth-code '<paste-code-here>'")


def cmd_auth_code(a):
    cfg = _maybe_load_raw() or {}
    mode = cfg.get("_pending_mode", "full")
    scopes = _scopes_for(mode)
    _exchange_code(cfg, a.code, MANUAL_REDIRECT, scopes, mode)
    print(f"✅ Authorized ({mode} mode). Refresh token stored (0600).")
    print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes))


def cmd_init(a):
    cfg = _maybe_load_raw() or {}
    cfg["client_id"] = a.client_id
    cfg["client_secret"] = a.client_secret
    _save(cfg)
    print(f"Saved client credentials to {CFG}. Next: `adom-google auth --manual`")


def cmd_provider_set(a):
    """Write the OAuth provider (gateway + client) to ~/.config/adom-google/provider.json.
    This is what the private `adom-google-adom` package drops for Adom employees; self-hosters
    point it at their own gateway + client."""
    prov = {}
    if os.path.exists(PROVIDER_PATH):
        try:
            with open(PROVIDER_PATH) as f:
                prov = json.load(f)
        except Exception:
            prov = {}
    if a.gateway:       prov["gateway_url"] = a.gateway.rstrip("/")
    if a.ws:            prov["gateway_ws"] = a.ws
    if a.client_id:     prov["client_id"] = a.client_id
    if a.client_secret: prov["client_secret"] = a.client_secret
    if a.app:           prov["app"] = a.app
    os.makedirs(CFG_DIR, exist_ok=True)
    fd = os.open(PROVIDER_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(prov, f, indent=2)
    os.chmod(PROVIDER_PATH, 0o600)
    print(f"Saved provider → {PROVIDER_PATH} (0600).")
    print("Next: ask the user safe vs full, then `adom-google auth`.")


def cmd_provider_show(a):
    pr = _provider()
    if os.path.exists(PROVIDER_PATH):       src = PROVIDER_PATH
    elif os.path.exists(BASE_PROVIDER_PATH): src = BASE_PROVIDER_PATH + " (inherited from default account)"
    elif os.path.exists(BUNDLED_PROVIDER):  src = BUNDLED_PROVIDER + " (bundled)"
    elif os.path.exists(LEGACY_CREDS):      src = LEGACY_CREDS + " (legacy)"
    elif os.environ.get("OAUTH_GATEWAY_URL"): src = "environment"
    else:                                   src = "(none — run `provider set`)"
    print(f"app:           {pr['app']}")
    print(f"gateway_url:   {pr['gateway_url'] or '(UNSET)'}")
    print(f"gateway_ws:    {pr['gateway_ws'] or '(derived from gateway_url)'}")
    print(f"client_id:     {(pr['client_id'][:22] + '…') if pr['client_id'] else '(UNSET)'}")
    print(f"client_secret: {'present' if pr.get('client_secret') else '(none — broker mode)'}")
    print(f"source:        {src}")


def cmd_onboard(a):
    """Guided org onboarding. Prints the exact plan + canonical values (or --json for the
    skill that drives the browser). The actual Cloud-Console clicking is done by the
    `adom-google-onboarding` skill via the native browser; this command supplies the truth."""
    org = getattr(a, "org", None)
    cfg = _onboard_config(org)
    if getattr(a, "json", False):
        print(json.dumps(cfg, indent=2))
        return
    R = cfg["redirect_uri"]
    print("┌─ adom-google · onboard your org to Google Workspace ─────────────────────────┐")
    print("│  One-time setup: your team gets its OWN Google OAuth client so the AI can     │")
    print("│  read mail, post Chat on your behalf, build Slides/Sheets/Docs, search Drive. │")
    print("│  Your client SECRET stays on YOUR machine (0600). Adom only lends its public  │")
    print("│  callback URL — it never sees your secret.                                    │")
    print("└──────────────────────────────────────────────────────────────────────────────┘\n")
    print("The AI can drive your real browser through every step below and auto-fill the")
    print("fields (recommended) — or you can do it by hand. Either way, the exact values:\n")
    print("1) Create / pick a Google Cloud project:")
    print("     " + cfg["console"]["project_create"])
    print("\n2) Enable all nine APIs in ONE click:")
    print("     " + cfg["enable_apis_url"])
    for ap in cfg["apis"]:
        print(f"        • {ap['label']}")
    print("\n3) OAuth consent screen  →  User type = Internal")
    print("     " + cfg["console"]["consent_screen"])
    print(f"        App name:  {cfg['consent']['app_name']}")
    print("\n4) Create an OAuth client  →  Application type = Web application")
    print("     " + cfg["console"]["create_client"])
    print("     ⚠ Authorized redirect URI — paste EXACTLY (this is the gateway callback):")
    print("        " + R)
    print("\n5) Copy the Client ID + Client secret it shows, then run:")
    print("     adom-google onboard finish --org <your-org-slug> \\")
    print("         --client-id <client-id> --client-secret <client-secret>")
    print("\n6) Authorize (full Workspace access by default):")
    print("     adom-google auth          # one Allow click with your Workspace account (you have ~60 min)")
    print("\n(Chat also needs a one-time Chat-app config: " + cfg["console"]["chat_config"] + ")")
    print("\n" + ONBOARD_HINT)


def cmd_onboard_finish(a):
    """Record the org's freshly-created OAuth client and wire RELAY mode (secret stays local)."""
    gw = (a.gateway or ADOM_SHARED_GATEWAY).rstrip("/")
    prov = {}
    if os.path.exists(PROVIDER_PATH):
        try:
            with open(PROVIDER_PATH) as f:
                prov = json.load(f)
        except Exception:
            prov = {}
    prov["gateway_url"] = gw
    prov["client_id"] = a.client_id
    prov["client_secret"] = a.client_secret      # the org's OWN secret, on the org's OWN box
    prov["app"] = a.org
    prov["broker"] = False                         # relay mode: gateway relays, we exchange locally
    os.makedirs(CFG_DIR, exist_ok=True)
    fd = os.open(PROVIDER_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(prov, f, indent=2)
    os.chmod(PROVIDER_PATH, 0o600)
    print(f"✅ Saved provider → {PROVIDER_PATH} (0600, RELAY mode).")
    print(f"   org/app:      {a.org}")
    print(f"   gateway:      {gw}  (shared callback; your secret never leaves this box)")
    print(f"   redirect_uri: {gw}/callback  (must match your OAuth client)")
    print("\nNext: ask the user safe vs full, then `adom-google auth`.")


def cmd_accounts(a):
    """List every configured account: the default plus each dir under accounts/."""
    rows = []
    def probe(name, cfg_dir):
        cfg_path = os.path.join(cfg_dir, "config.json")
        cfg = {}
        if os.path.exists(cfg_path):
            try:
                cfg = json.load(open(cfg_path))
            except Exception:
                pass
        prov_path = os.path.join(cfg_dir, "provider.json")
        app = None
        if os.path.exists(prov_path):
            try:
                app = json.load(open(prov_path)).get("app")
            except Exception:
                pass
        rows.append({"account": name, "authorized": bool(cfg.get("refresh_token")),
                     "mode": cfg.get("mode"), "app": app, "config": cfg_path})
    probe("default", BASE_CFG_DIR)
    acct_root = os.path.join(BASE_CFG_DIR, "accounts")
    if os.path.isdir(acct_root):
        for name in sorted(os.listdir(acct_root)):
            if os.path.isdir(os.path.join(acct_root, name)):
                probe(name, os.path.join(acct_root, name))
    if getattr(a, "json", False):
        print(json.dumps(rows, indent=2))
        return
    for r in rows:
        star = "*" if r["account"] == ACCOUNT else " "
        auth = f"authorized ({r['mode'] or 'mode unset'})" if r["authorized"] else "NOT authorized"
        app = f"  app={r['app']}" if r["app"] else ""
        print(f"{star} {r['account']:<12} {auth}{app}")
    print("\nSelect one per-command with `--account <name>` or export ADOM_GOOGLE_ACCOUNT=<name>.")


def cmd_connect_personal(a):
    """Connect a non-adom.inc Google account (personal @gmail, or another org's Workspace) via
    Adom's global 'Adom Google Connector' OAuth client — no manual Google-Cloud setup. Writes the
    built-in provider to the `personal` account, then the user runs
    `adom-google --account personal auth`."""
    if not ADOM_PERSONAL_CLIENT_ID:
        sys.exit("Connector sign-in is still being provisioned (Adom is standing up the shared\n"
                 "'Adom Google Connector' client). Not available yet — check back soon or email\n"
                 "support@adom.inc. Your work account (`adom-google auth`) is unaffected.")
    acct_dir = os.path.join(BASE_CFG_DIR, "accounts", "personal")
    os.makedirs(acct_dir, exist_ok=True)
    prov = {"gateway_url": ADOM_SHARED_GATEWAY, "client_id": ADOM_PERSONAL_CLIENT_ID,
            "app": ADOM_PERSONAL_APP, "broker": True}
    ppath = os.path.join(acct_dir, "provider.json")
    fd = os.open(ppath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(prov, f, indent=2)
    os.chmod(ppath, 0o600)
    print("✅ Personal profile ready (uses Adom's shared Google Connector client — no setup needed).")
    print("   Authorize your account now:\n")
    print("      adom-google --account personal auth\n")
    print("   Grants Calendar, Contacts, Tasks, Drive (files it creates), and Gmail send/compose today.")
    print("   (Full Gmail read + all Drive/Docs/Sheets/Slides unlock automatically once Adom's Google")
    print("   verification + CASA clear — no action needed. Testers can add `--full` now.)")
    print("   Google will show an 'unverified app' notice while verification is pending — choose")
    print("   Advanced → Continue to proceed.")
    print("   Use the personal profile any time:  adom-google --account personal <command>")


def cmd_setup(a):
    # Canonical first-run entry point. Always prints the ask-the-user hint (even mid-config),
    # so the installing AI is reminded to ask the safe/full question before authorizing.
    print(SETUP_HINT)


def _normalize_scope(s):
    """Accept either a full scope URL or the short tail ('chat.spaces')."""
    return s if s.startswith("http") else "https://www.googleapis.com/auth/" + s


def cmd_scopes(a):
    """GRANTED scopes (authoritative, from Google's tokeninfo) vs REQUESTED (what auth asked for).

    `status` prints cfg['scopes'], which is only a record of what was REQUESTED at auth
    time — it says nothing about what Google actually granted, and agents have made wrong
    architectural calls trusting it (wiki-notify-gchat, 2026-07-21). This command refreshes
    the access token and asks tokeninfo for the truth.

    NOTE: tokeninfo reports granted SCOPES but not whether an API is enabled in the GCP
    project. To probe real capability without creating anything, send a deliberately
    invalid body to the target method: missing scope → 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT
    (fails before body validation); scope present → 400 INVALID_ARGUMENT.
    """
    cfg = _maybe_load_raw()
    if not cfg or not cfg.get("refresh_token"):
        sys.exit(SETUP_HINT)
    tok = _access_token(cfg)
    r = requests.get("https://oauth2.googleapis.com/tokeninfo",
                     params={"access_token": tok}, timeout=30)
    if r.status_code != 200:
        sys.exit(f"tokeninfo failed ({r.status_code}): {r.text[:200]}")
    granted = sorted(r.json().get("scope", "").split())
    requested = sorted(cfg.get("scopes") or [])
    missing = [s for s in requested if s not in granted]      # asked for, NOT granted
    extra = [s for s in granted if s not in requested]        # granted, never recorded
    required = [_normalize_scope(s) for s in (getattr(a, "require", None) or [])]
    short = [s for s in required if s not in granted]

    if getattr(a, "json", False):
        print(json.dumps({"granted": granted, "requested": requested,
                          "drift": {"requested_not_granted": missing, "granted_not_requested": extra},
                          "require": {"required": required, "missing": short} if required else None,
                          "account": ACCOUNT, "mode": cfg.get("mode")}, indent=2))
    else:
        tail = lambda s: s.split("/")[-1]
        print(f"GRANTED   ({len(granted)}, authoritative via tokeninfo):")
        for s in granted: print(f"  ✓ {tail(s)}")
        if missing:
            print(f"DRIFT — requested at auth but NOT granted:")
            for s in missing: print(f"  ✗ {tail(s)}")
        if extra:
            print(f"DRIFT — granted but not in the recorded request:")
            for s in extra: print(f"  + {tail(s)}")
        if not missing and not extra:
            print("No drift: granted matches the recorded request.")
        if required:
            print(f"REQUIRE check: {'ALL PRESENT' if not short else 'MISSING → ' + ', '.join(tail(s) for s in short)}")

    if short:
        widen_flags = " ".join(f"--add-scope {s.split('/')[-1]}" for s in short)
        acct = "" if ACCOUNT == "default" else f" --account {ACCOUNT}"
        fix = f"adom-google{acct} auth{' --safe' if cfg.get('mode') == 'safe' else ''} {widen_flags}"
        if getattr(a, "auto_widen", False):
            print(f"\n--auto-widen: re-running auth with the union of GRANTED + required…")
            import types
            # Union against what is actually GRANTED, never the tier default. Unioning against
            # the tier silently dropped everything held beyond it — after `auth --admin` this
            # built an 18-scope URL (17 tier + 1 new) instead of 22, so approving it would have
            # REVOKED domain group admin while appearing to add a capability. The help text
            # promises --auto-widen "preserves what you already hold", so it must actually do
            # that. (Reported 2026-07-22.)
            na = types.SimpleNamespace(manual=False, safe=cfg.get("mode") == "safe",
                                       full=cfg.get("mode") == "full", admin=False,
                                       url_file=getattr(a, "url_file", None),
                                       add_scopes=short, preserve_scopes=granted)
            return cmd_auth(na)
        print(f"\nFix: {fix}", file=sys.stderr)
        sys.exit(2)


def cmd_status(a):
    cfg = _maybe_load_raw()
    authorized = bool(cfg and cfg.get("refresh_token"))
    if getattr(a, "json", False):
        # One object, one boolean, and the exit code agrees with it: a waiter can use either.
        try:
            pr = _provider()
        except SystemExit:
            pr = {}
        print(json.dumps({"authorized": authorized, "account": ACCOUNT, "config": CFG,
                          "mode": (cfg or {}).get("mode"), "scopes": (cfg or {}).get("scopes") or [],
                          "gateway": pr.get("gateway_url"), "app": pr.get("app"),
                          "hint": None if authorized else "not authorized: run `adom-google auth` (or `auth --json` to block until Allow is clicked)"}))
        sys.exit(0 if authorized else 1)
    if not authorized:
        # Failure text goes to STDERR and the exit code is 1. Before, `status` exited 0 and its
        # setup hint contained the words "authorized"/"FULL"/"access", so a naive
        # `until adom-google status | grep -qi authorized` fired instantly on the FAILURE
        # message (issue #9 §3). Waiters should use `auth --json` or `status --json` instead.
        sys.stderr.write(SETUP_HINT + "\n")
        sys.exit(1)
    pr = _provider()
    cid = cfg.get("client_id") or pr["client_id"] or "(none)"
    src = "config" if cfg.get("client_id") else ("provider" if pr["client_id"] else "UNSET")
    print(f"Account:     {ACCOUNT}" + ("" if ACCOUNT == "default" else "  (select with --account or ADOM_GOOGLE_ACCOUNT)"))
    print(f"Config:      {CFG}")
    print(f"Provider:    gateway={pr['gateway_url'] or '(UNSET — run `provider set`)'}  app={pr['app']}")
    print(f"OAuth client: {(cid[:18] + '…') if cid != '(none)' else cid} (from {src})")
    print(f"Refresh token: {'present' if cfg.get('refresh_token') else 'MISSING — run auth'}")
    print(f"Access:       {cfg.get('mode', '(unset)')}  (full = whole Workspace incl. delete [default]; safe = create-only)")
    relay = pr.get("broker") is False
    print(f"Auth:         {'broker (secret on gateway, none here)' if cfg.get('broker') else ('relay (your secret 0600 here; Adom lends only its callback)' if relay else 'local (client secret in config)')}")
    print(f"Scopes:       {', '.join(s.split('/')[-1] for s in (cfg.get('scopes') or [])) or '(none)'}")
    if cfg.get("_migrated_from"):
        print(f"Migrated from: {cfg['_migrated_from']} (gmail.readonly only — run `auth` to add contacts)")
    # never print client_secret / refresh_token values


HELP = """adom-google — Google Workspace from your Adom container (Gmail, Contacts, …)

AUTH (one time; one consent → long-lived refresh token, stored 0600):
  adom-google auth              Authorize (FULL Workspace access — the default; no mode quiz).
                                Prints a consent URL; user clicks Allow ONCE (they have ~60 min,
                                no rush).
  adom-google auth --safe       Advanced: narrower create-only scopes (no "delete all"). Only
                                if the user explicitly wants to minimize scopes.
  adom-google auth --manual     fallback: desktop-client paste flow
  adom-google auth-code '<code>'   finish the --manual flow
  adom-google init <id> <secret>   set an explicit desktop OAuth client (for --manual)
  adom-google auth --json       tooling: guidance on stderr, ONE JSON line on stdout when authorized
  adom-google status [--json]   show access + what was REQUESTED at auth (never prints secrets).
                                Exit 1 when NOT authorized; --json gives authorized:true|false.
  adom-google scopes [--json]   authoritative GRANTED scopes (live tokeninfo) + drift vs requested
  adom-google scopes --require chat.spaces [--auto-widen]
                                gate for other tools: exit 2 + exact fix when a scope is missing;
                                --auto-widen re-runs auth with the union

ONBOARD A NEW ORG (give your team its OWN Google OAuth client — the AI hand-holds you):
  adom-google onboard [--org <slug>]   print the step-by-step plan + exact values to use
  adom-google onboard --json           same, machine-readable (the onboarding skill reads this)
  adom-google onboard finish --org <slug> --client-id <id> --client-secret <secret>
                                       record your new client (RELAY mode: secret stays 0600
                                       on THIS box; rides Adom's shared callback URL)
  → The `adom-google-onboarding` skill drives your real browser through Google Cloud Console
    and auto-fills the fields. Then: pick safe/full, `adom-google auth`, done.

GMAIL (read-only; migrated from adom-gmail):
  adom-google gmail attachments <messageId>            list attachments + ids
  adom-google gmail read <messageId> <attId> [-o f]    dump attachment bytes
  adom-google gmail ics  <messageId>                   parse .ics invite(s)

CONTACTS (Google People API; read + write):
  adom-google contacts create --name "First Last" [--org O] [--title T] \\
      [--email a@b.com ...] [--phone +1... ...] [--notes "..."] [--json]
  adom-google contacts list [--limit N] [--json]
  adom-google contacts search <query> [-v] [--json]

CHAT (Google Chat — posts as YOU, your own identity, no attribution prefix;
      adom-gchat is the other one — it posts via an org webhook with "(on behalf of)"):
  adom-google chat spaces [--json]                     list your spaces (id · type · name)
  adom-google chat dm <email> [--json]                 resolve the 1:1 DM space with a person
  adom-google chat send --to <space-id|email> "<text>" post a message (email auto-resolves DM)
      "<text>" may be '-' (stdin) or '@file'; --thread <key> replies in a thread
  adom-google chat read --from <space-id|email> [--limit N] [--json]   recent messages

API (generic passthrough — hit ANY Google API with your token; the "whole board"):
  adom-google api <url> [-X METHOD] [-d '<json>'|@file] [-q k=v ...] [--raw]
    e.g. adom-google api https://sheets.googleapis.com/v4/spreadsheets/<id>/values/A1:C5
         adom-google api -X POST https://docs.googleapis.com/v1/documents -d '{"title":"Report"}'
  Reach == the scopes the token holds (see `status`). Widen via SCOPES + re-`auth`
  + enabling that API in the GCP project.

DRIVE / CALENDAR / SHEETS / DOCS / SLIDES: either use `api` above, or add a typed
subcommand. Add the scope in SCOPES (top of this file) and re-run `auth`.

ACCOUNTS (multiple Google accounts, fully isolated — e.g. work + personal):
  adom-google accounts [--json]        list configured accounts (token presence, mode, app)
  adom-google --account <name> <cmd>   run ANY command against a named account
  ADOM_GOOGLE_ACCOUNT=<name>           same, via env (flag wins)
  The default (unnamed) account keeps the original paths; a named account lives at
  ~/.config/adom-google/accounts/<name>/ with its OWN config.json + provider.json.
  Its provider inherits from the default account's provider.json unless overridden —
  set a different OAuth client/app with:  adom-google --account <name> provider set …
  First-time auth for a new account:      adom-google --account <name> auth [--full]

SECURITY: config is 0600; secrets are never echoed to stdout/stderr or logs.
"""


# ---------------------------------------------------------------- typed subcommands
# Thin, guided wrappers over the Google REST APIs (issue #662): Calendar, Drive, Sheets,
# Docs, Slides, and Tasks were named in the brief and discovery triggers but only reachable
# through the raw `api` passthrough. These give first-class, discoverable commands for the
# common flows; anything deeper still drops to `adom-google api <url>`.

def cmd_calendar_list(a):
    cfg = _load()
    params = {"maxResults": str(a.max), "singleEvents": "true", "orderBy": "startTime",
              "timeMin": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat()}
    res = _request(cfg, "GET", f"{CAL_API}/calendars/{urllib.parse.quote(a.cal)}/events?{urllib.parse.urlencode(params)}")
    events = res.get("items", [])
    if a.json:
        print(json.dumps(events, indent=2, ensure_ascii=False)); return
    if not events:
        print("(no upcoming events)"); return
    for e in events:
        start = (e.get("start") or {}).get("dateTime") or (e.get("start") or {}).get("date") or "?"
        print(f"{start}  {e.get('summary','(no title)')}  [{e.get('id','')}]")


def cmd_calendar_create(a):
    cfg = _load()
    if not a.start or not a.end:
        sys.exit("calendar create needs --start and --end (RFC3339, e.g. 2026-08-20T14:00:00-05:00)")
    body = {"summary": a.summary,
            "start": {"dateTime": a.start}, "end": {"dateTime": a.end}}
    if a.location: body["location"] = a.location
    if a.description: body["description"] = a.description
    if a.attendees:
        body["attendees"] = [{"email": e.strip()} for e in a.attendees.split(",") if e.strip()]
    params = {"sendUpdates": "all" if a.send else "none"}
    res = _request(cfg, "POST", f"{CAL_API}/calendars/{urllib.parse.quote(a.cal)}/events?{urllib.parse.urlencode(params)}",
                   json=body)
    if a.json:
        print(json.dumps(res, indent=2, ensure_ascii=False)); return
    print(res.get("htmlLink") or res.get("id", ""))
    sys.stderr.write(f"→ created '{a.summary}'" + (" (invites sent)" if a.send else "") + "\n")


def cmd_calendar_calendars(a):
    cfg = _load()
    res = _request(cfg, "GET", f"{CAL_API}/users/me/calendarList")
    cals = res.get("items", [])
    if a.json:
        print(json.dumps(cals, indent=2, ensure_ascii=False)); return
    for c in cals:
        print(f"{c.get('id')}  ·  {c.get('summary','')}" + ("  (primary)" if c.get("primary") else ""))


def cmd_drive_list(a):
    cfg = _load()
    params = {"pageSize": str(a.limit), "fields": "files(id,name,mimeType,modifiedTime,size)",
              "orderBy": "modifiedTime desc"}
    if a.query: params["q"] = a.query
    res = _request(cfg, "GET", f"{DRIVE_API}/files?{urllib.parse.urlencode(params)}")
    files = res.get("files", [])
    if a.json:
        print(json.dumps(files, indent=2, ensure_ascii=False)); return
    if not files:
        print("(no files)"); return
    for f in files:
        print(f"{f.get('id')}  {f.get('name','')}  ({f.get('mimeType','')})")


def cmd_drive_upload(a):
    cfg = _load()
    if not os.path.isfile(a.path):
        sys.exit(f"file not found: {a.path}")
    import mimetypes
    name = a.name or os.path.basename(a.path)
    mime = a.mime or mimetypes.guess_type(name)[0] or "application/octet-stream"
    with open(a.path, "rb") as f:
        blob = f.read()
    up = _request(cfg, "POST", f"{DRIVE_UPLOAD}/files?uploadType=media",
                  headers={"Content-Type": mime}, data=blob)
    fid = up.get("id")
    if fid and name:
        up = _request(cfg, "PATCH", f"{DRIVE_API}/files/{fid}?fields=id,name,webViewLink",
                      json={"name": name})
    if a.json:
        print(json.dumps(up, indent=2, ensure_ascii=False)); return
    print(up.get("webViewLink") or up.get("id", ""))
    sys.stderr.write(f"→ uploaded {name} ({len(blob)//1024} KB, {mime})\n")


def _created_link(res, kind):
    fid = res.get("spreadsheetId") or res.get("documentId") or res.get("presentationId") or res.get("id", "")
    urls = {"sheet": f"https://docs.google.com/spreadsheets/d/{fid}/edit",
            "doc": f"https://docs.google.com/document/d/{fid}/edit",
            "slides": f"https://docs.google.com/presentation/d/{fid}/edit"}
    return urls.get(kind, fid), fid


def cmd_sheets_create(a):
    cfg = _load()
    res = _request(cfg, "POST", f"{SHEETS_API}/spreadsheets", json={"properties": {"title": a.title}})
    if a.json:
        print(json.dumps(res, indent=2, ensure_ascii=False)); return
    link, _ = _created_link(res, "sheet"); print(link)
    sys.stderr.write(f"→ created spreadsheet '{a.title}'\n")


def cmd_docs_create(a):
    cfg = _load()
    res = _request(cfg, "POST", f"{DOCS_API}/documents", json={"title": a.title})
    if a.json:
        print(json.dumps(res, indent=2, ensure_ascii=False)); return
    link, _ = _created_link(res, "doc"); print(link)
    sys.stderr.write(f"→ created doc '{a.title}'\n")


def cmd_slides_create(a):
    cfg = _load()
    res = _request(cfg, "POST", f"{SLIDES_API}/presentations", json={"title": a.title})
    if a.json:
        print(json.dumps(res, indent=2, ensure_ascii=False)); return
    link, _ = _created_link(res, "slides"); print(link)
    sys.stderr.write(f"→ created presentation '{a.title}'\n")


def cmd_tasks_list(a):
    cfg = _load()
    res = _request(cfg, "GET", f"{TASKS_API}/lists/{a.list}/tasks?showCompleted=false&maxResults=100")
    items = res.get("items", [])
    if a.json:
        print(json.dumps(items, indent=2, ensure_ascii=False)); return
    if not items:
        print("(no tasks)"); return
    for t in items:
        print(f"[{'x' if t.get('status')=='completed' else ' '}] {t.get('title','')}  ({t.get('id','')})")


def cmd_tasks_add(a):
    cfg = _load()
    body = {"title": a.title}
    if a.notes: body["notes"] = a.notes
    res = _request(cfg, "POST", f"{TASKS_API}/lists/{a.list}/tasks", json=body)
    if a.json:
        print(json.dumps(res, indent=2, ensure_ascii=False)); return
    print(res.get("id", ""))
    sys.stderr.write(f"→ added task '{a.title}'\n")


def main():
    p = argparse.ArgumentParser(add_help=False)
    sub = p.add_subparsers(dest="cmd")

    s = sub.add_parser("auth", description=(
        "Authorize this container against Google. Default = FULL Workspace access (Gmail, Drive, "
        "Sheets, Docs, Slides, Calendar, Tasks, Chat incl. spaces/memberships/delete). "
        "Admin/Directory scopes are NOT in any tier by default; add them with --admin (one "
        "consent, no second browser trip) to manage Google Groups / distribution lists. "
        "Already authorized and just missing one capability? Do NOT re-run a bare `auth` -- use "
        "`adom-google scopes --require <scope> --auto-widen`, which preserves what you already "
        "hold. See granted-vs-requested with `adom-google scopes`."))
    s.add_argument("--manual", action="store_true", help="fallback when the gateway is unreachable: desktop-client copy/paste flow (finish with `auth-code '<code>'`)"); s.add_argument("--safe", action="store_true", help="advanced: request the NARROW create-only scope set (no delete-all); default is FULL"); s.add_argument("--full", action="store_true", help="Connector: request the FULL set incl. Gmail-read + all Drive (works for whitelisted testers now, everyone once CASA clears). No-op for the work client (already full)."); s.add_argument("--url-file", dest="url_file", help="also write the consent URL to this file (one clean line, for tooling)"); s.add_argument("--admin", action="store_true", help="ALSO request the Workspace ADMIN bundle (admin.directory.group + .group.member + .user.readonly + apps.groups.settings) so ONE consent covers Google Groups / distribution lists. Only a Workspace admin can grant it."); s.add_argument("--add-scope", dest="add_scopes", action="append", help="widen the scope set (repeatable; full URL or short tail like chat.spaces)"); s.add_argument("--json", action="store_true", help="AI/tooling mode: all guidance (consent URL, waiting notes) goes to STDERR; stdout carries exactly ONE JSON line on success ({\"ok\":true,\"authorized\":true,\"scopes\":[...]}) and the exit code is 0. auth already blocks until Allow is clicked, so this replaces polling `status`."); s.add_argument("--wait", action="store_true", help="accepted no-op: auth ALWAYS waits for the click (up to ~60 min); here so `auth --wait --json` reads naturally"); s.set_defaults(fn=cmd_auth)
    s = sub.add_parser("scopes"); s.add_argument("--json", action="store_true"); s.add_argument("--require", nargs="+", help="scopes that MUST be granted (full URL or short tail); exit 2 + print the fix when short"); s.add_argument("--auto-widen", dest="auto_widen", action="store_true", help="with --require: re-run auth with the union instead of exiting"); s.add_argument("--url-file", dest="url_file", help="with --auto-widen: also write the consent URL here"); s.set_defaults(fn=cmd_scopes)
    s = sub.add_parser("auth-code"); s.add_argument("code"); s.set_defaults(fn=cmd_auth_code)
    s = sub.add_parser("init"); s.add_argument("client_id"); s.add_argument("client_secret"); s.set_defaults(fn=cmd_init)
    pv = sub.add_parser("provider"); pvs = pv.add_subparsers(dest="sub")
    x = pvs.add_parser("set")
    x.add_argument("--gateway"); x.add_argument("--ws")
    x.add_argument("--client-id", dest="client_id"); x.add_argument("--client-secret", dest="client_secret")
    x.add_argument("--app"); x.set_defaults(fn=cmd_provider_set)
    x = pvs.add_parser("show"); x.set_defaults(fn=cmd_provider_show)
    s = sub.add_parser("status"); s.add_argument("--json", action="store_true", help="one JSON object with authorized:true|false (+ account, mode, scopes, gateway); exit 1 when NOT authorized"); s.set_defaults(fn=cmd_status)
    s = sub.add_parser("accounts"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_accounts)
    s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup)
    s = sub.add_parser("connect-personal"); s.set_defaults(fn=cmd_connect_personal)

    ob = sub.add_parser("onboard"); ob.add_argument("--org"); ob.add_argument("--json", action="store_true")
    ob.set_defaults(fn=cmd_onboard)
    obx = ob.add_subparsers(dest="sub")
    y = obx.add_parser("finish")
    y.add_argument("--org", required=True)
    y.add_argument("--client-id", dest="client_id", required=True)
    y.add_argument("--client-secret", dest="client_secret", required=True)
    y.add_argument("--gateway", help="override the shared callback gateway (advanced)")
    y.set_defaults(fn=cmd_onboard_finish)

    g = sub.add_parser("gmail"); gs = g.add_subparsers(dest="sub")
    x = gs.add_parser("attachments"); x.add_argument("message_id"); x.set_defaults(fn=cmd_gmail_attachments)
    x = gs.add_parser("read"); x.add_argument("message_id"); x.add_argument("attachment_id"); x.add_argument("-o", "--out"); x.set_defaults(fn=cmd_gmail_read)
    x = gs.add_parser("ics"); x.add_argument("message_id"); x.set_defaults(fn=cmd_gmail_ics)

    c = sub.add_parser("contacts"); cs = c.add_subparsers(dest="sub")
    x = cs.add_parser("create")
    x.add_argument("--name"); x.add_argument("--org"); x.add_argument("--title")
    x.add_argument("--email", action="append"); x.add_argument("--phone", action="append")
    x.add_argument("--notes"); x.add_argument("--json", action="store_true")
    x.add_argument("--dry-run", dest="dry_run", action="store_true", help="print the People API payload without sending")
    x.set_defaults(fn=cmd_contacts_create)
    x = cs.add_parser("list"); x.add_argument("--limit", type=int, default=50); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_contacts_list)
    x = cs.add_parser("search"); x.add_argument("query"); x.add_argument("-v", "--verbose", action="store_true"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_contacts_search)

    ch = sub.add_parser("chat"); chx = ch.add_subparsers(dest="sub")
    x = chx.add_parser("spaces"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_spaces)
    x = chx.add_parser("dm"); x.add_argument("email"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_dm)
    x = chx.add_parser("send"); x.add_argument("--to", required=True, help="spaces/XXX id OR an email (auto-resolves the DM)"); x.add_argument("text", nargs="?", default="", help="message text, or '-' for stdin, or '@file' (optional when --attach is used)"); x.add_argument("--thread", help="threadKey for a threaded reply"); x.add_argument("--attach", action="append", metavar="PATH", help="attach a local file (image/video/PDF/any); repeatable"); x.add_argument("--no-agent-marker", action="store_true", help="don't stamp clientAssignedMessageId=client-agent-*"); x.set_defaults(fn=cmd_chat_send)
    x = chx.add_parser("read"); x.add_argument("--from", dest="frm", required=True, help="spaces/XXX id OR an email"); x.add_argument("--limit", type=int, default=20); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_read)

    # Calendar (issue #662)
    cal = sub.add_parser("calendar", help="Google Calendar: list/create events, list calendars"); cax = cal.add_subparsers(dest="sub")
    x = cax.add_parser("list", help="upcoming events"); x.add_argument("--cal", default="primary"); x.add_argument("--max", type=int, default=10); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_calendar_list)
    x = cax.add_parser("create", help="create an event"); x.add_argument("--summary", required=True); x.add_argument("--start", help="RFC3339, e.g. 2026-08-20T14:00:00-05:00"); x.add_argument("--end", help="RFC3339"); x.add_argument("--location"); x.add_argument("--description"); x.add_argument("--attendees", help="comma-separated emails"); x.add_argument("--cal", default="primary"); x.add_argument("--send", action="store_true", help="email invites to attendees"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_calendar_create)
    x = cax.add_parser("calendars", help="list your calendars"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_calendar_calendars)

    # Drive
    dr = sub.add_parser("drive", help="Google Drive: list/search + upload files"); drx = dr.add_subparsers(dest="sub")
    x = drx.add_parser("list", help="list/search files"); x.add_argument("--query", help="Drive query, e.g. \"name contains 'invoice'\""); x.add_argument("--limit", type=int, default=20); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_drive_list)
    x = drx.add_parser("upload", help="upload a local file"); x.add_argument("path"); x.add_argument("--name"); x.add_argument("--mime"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_drive_upload)

    # Sheets / Docs / Slides (create the file, then edit via `api` batchUpdate)
    sh = sub.add_parser("sheets", help="Google Sheets"); shx = sh.add_subparsers(dest="sub")
    x = shx.add_parser("create", help="create a spreadsheet"); x.add_argument("--title", required=True); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_sheets_create)
    dc = sub.add_parser("docs", help="Google Docs"); dcx = dc.add_subparsers(dest="sub")
    x = dcx.add_parser("create", help="create a document"); x.add_argument("--title", required=True); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_docs_create)
    sl = sub.add_parser("slides", help="Google Slides"); slx = sl.add_subparsers(dest="sub")
    x = slx.add_parser("create", help="create a presentation"); x.add_argument("--title", required=True); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_slides_create)

    # Tasks
    tk = sub.add_parser("tasks", help="Google Tasks: list/add"); tkx = tk.add_subparsers(dest="sub")
    x = tkx.add_parser("list", help="list tasks"); x.add_argument("--list", default="@default"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_tasks_list)
    x = tkx.add_parser("add", help="add a task"); x.add_argument("--title", required=True); x.add_argument("--notes"); x.add_argument("--list", default="@default"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_tasks_add)

    # Generic passthrough — hit ANY Google API with the stored token.
    x = sub.add_parser("api")
    x.add_argument("url", help="full URL or host/path (https:// assumed)")
    x.add_argument("-X", "--method", default="GET")
    x.add_argument("-d", "--data", help="JSON body string, or @file")
    x.add_argument("-q", "--query", action="append", help="key=value query param (repeatable)")
    x.add_argument("--raw", action="store_true", help="print raw bytes (don't pretty-print JSON)")
    x.add_argument("--upload-file", dest="upload_file", help="send a file's raw bytes as the body (binary uploads: Drive media, YouTube resumable PUT)")
    x.add_argument("--content-type", dest="content_type", help="override Content-Type (e.g. video/mp4)")
    x.set_defaults(fn=cmd_api)

    sub.add_parser("help")

    if len(sys.argv) == 1 or sys.argv[1] in ("help", "-h", "--help"):
        print(HELP); return
    args = p.parse_args()
    if not hasattr(args, "fn"):
        print(HELP); return
    args.fn(args)


if __name__ == "__main__":
    main()
