#!/usr/bin/env python3
"""Local Zoho OAuth/API CLI.

This tool is intentionally independent of Workweaver backend auth state. It can
use 1Password item fields at runtime and can keep an encrypted machine-local
token cache for access/refresh tokens acquired through a local OAuth callback.
"""

from __future__ import annotations

import argparse
import getpass
import hashlib
import json
import os
import secrets
import socket
import subprocess
import sys
import time
import webbrowser
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.request import Request, urlopen

try:
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except Exception as exc:  # pragma: no cover
    raise SystemExit("Missing dependency: cryptography. Install with `python3 -m pip install cryptography`.") from exc


DEFAULT_CONFIG_DIR = Path.home() / ".config" / "zoho-local-cli"
DEFAULT_VAULT = DEFAULT_CONFIG_DIR / "vault.enc"
KEY_SALT = b"zoho-local-cli-v1"
DEFAULT_REDIRECT = "http://127.0.0.1:8714/callback"
# Keep under common Zoho/proxy 414 ceilings when packing full-registry scopes.
DEFAULT_MAX_AUTH_URL_LENGTH = 1800

FALLBACK_REGISTRY = {
    "version": 1,
    "apps": {
        "cliq": {
            "title": "Cliq",
            "aliases": ["cliq", "zohocliq", "zoho-cliq"],
            "api": {"kind": "product", "subdomain": "cliq"},
            "scopes": [
                "ZohoCliq.Channels.READ",
                "ZohoCliq.Channels.CREATE",
                "ZohoCliq.Channels.UPDATE",
                "ZohoCliq.Channels.DELETE",
                "ZohoCliq.Chats.CREATE",
                "ZohoCliq.Chats.READ",
                "ZohoCliq.Chats.UPDATE",
                "ZohoCliq.Chats.DELETE",
                "ZohoCliq.Messages.CREATE",
                "ZohoCliq.Messages.READ",
                "ZohoCliq.Messages.UPDATE",
                "ZohoCliq.Messages.DELETE",
                "ZohoCliq.Attachments.READ",
                "ZohoCliq.Users.READ",
                "ZohoCliq.Users.CREATE",
                "ZohoCliq.Users.UPDATE",
                "ZohoCliq.Departments.READ",
                "ZohoCliq.Departments.CREATE",
                "ZohoCliq.Departments.UPDATE",
                "ZohoCliq.Departments.DELETE",
                "ZohoCliq.Organisation.READ",
                "ZohoCliq.Organisation.CREATE",
                "ZohoCliq.Organisation.UPDATE",
                "ZohoCliq.Organisation.DELETE",
                "ZohoCliq.Profile.READ",
                "ZohoCliq.Profile.CREATE",
                "ZohoCliq.Profile.UPDATE",
                "ZohoCliq.Profile.DELETE",
                "ZohoCliq.Webhooks.CREATE",
            ],
        },
        "mail": {
            "title": "Mail",
            "aliases": ["mail", "zohomail", "zoho-mail"],
            "api": {"kind": "product", "subdomain": "mail"},
            "scopes": [
                "ZohoMail.messages.ALL",
                "ZohoMail.accounts.ALL",
                "ZohoMail.folders.ALL",
                "ZohoMail.tasks.ALL",
                "ZohoMail.notes.ALL",
                "ZohoMail.tags.ALL",
            ],
        },
        "people": {
            "title": "People",
            "aliases": ["people", "zohopeople", "zoho-people"],
            "api": {"kind": "product", "subdomain": "people"},
            "scopes": [
                "ZOHOPEOPLE.employee.ALL",
                "ZOHOPEOPLE.forms.ALL",
                "ZOHOPEOPLE.forms.CREATE",
                "ZOHOPEOPLE.forms.READ",
                "ZOHOPEOPLE.forms.UPDATE",
                "ZOHOPEOPLE.dashboard.ALL",
                "ZOHOPEOPLE.automation.ALL",
                "ZOHOPEOPLE.timetracker.ALL",
                "ZOHOPEOPLE.timesheet.READ",
                "ZOHOPEOPLE.attendance.ALL",
                "ZOHOPEOPLE.leave.ALL",
                "ZOHOPEOPLE.leave.READ",
                "ZOHOPEOPLE.leave.CREATE",
                "ZOHOPEOPLE.leave.UPDATE",
            ],
        },
        "crm": {
            "title": "CRM",
            "aliases": ["crm", "zohocrm", "zoho-crm"],
            "api": {"kind": "zohoapis"},
            "scopes": [
                "ZohoCRM.modules.ALL",
                "ZohoCRM.settings.ALL",
                "ZohoCRM.users.ALL",
                "ZohoCRM.org.ALL",
                "ZohoCRM.bulk.ALL",
                "ZohoCRM.notifications.ALL",
                "ZohoCRM.coql.READ",
            ],
        },
    },
}


@dataclass
class Credentials:
    client_id: str
    client_secret: str
    refresh_token: str = ""
    api_domain: str = "https://www.zohoapis.in"


def default_vault() -> dict[str, Any]:
    return {"version": 1, "profiles": {}}


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


def json_print(value: Any, *, compact: bool = False) -> None:
    print(json.dumps(value, separators=(",", ":") if compact else None, indent=None if compact else 2, sort_keys=not compact))


def redacted(value: str, left: int = 8, right: int = 4) -> str:
    if not value:
        return ""
    if len(value) <= left + right:
        return "<redacted>"
    return f"{value[:left]}...{value[-right:]}"


def account_host(api_domain: str) -> str:
    host = urlparse(api_domain).hostname or "www.zohoapis.in"
    suffix = host.split("zohoapis", 1)[1] if "zohoapis" in host else ".in"
    return f"https://accounts.zoho{suffix}"


def registry_candidates() -> list[Path]:
    here = Path(__file__).resolve()
    candidates = [
        here.with_name("registry.json"),
        here.parent.parent / "registry.json",
        Path.cwd() / "skills" / "business" / "zoho-cli" / "registry.json",
        Path.cwd() / "registry.json",
    ]
    return candidates


def load_registry(args: argparse.Namespace | None = None) -> dict[str, Any]:
    raw_path = getattr(args, "registry", "") if args is not None else ""
    env_path = os.environ.get("ZOHO_LOCAL_REGISTRY", "")
    for raw in [raw_path, env_path]:
        if raw:
            return json.loads(Path(raw).expanduser().read_text(encoding="utf-8"))
    for path in registry_candidates():
        if path.exists():
            return json.loads(path.read_text(encoding="utf-8"))
    return FALLBACK_REGISTRY


def registry_apps(args: argparse.Namespace | None = None) -> dict[str, Any]:
    apps = load_registry(args).get("apps") or {}
    if not isinstance(apps, dict):
        raise RuntimeError("Invalid Zoho registry: apps must be an object.")
    return apps


def canonical_app(args: argparse.Namespace | None, app: str) -> str:
    key = app.strip().lower()
    apps = registry_apps(args)
    if key in apps:
        return key
    for slug, payload in apps.items():
        aliases = [str(alias).lower() for alias in payload.get("aliases") or []]
        connector_id = f"zoho-{slug}"
        if key in aliases or key == connector_id:
            return slug
    raise RuntimeError(f"Unknown Zoho app '{app}'. Run `zoho-local apps list`.")


def selected_apps(args: argparse.Namespace | None, apps_csv: str) -> list[str]:
    apps = registry_apps(args)
    requested = [part.strip() for part in apps_csv.split(",") if part.strip()]
    if not requested or any(part.lower() in {"all", "zoho-one", "zohoone"} for part in requested):
        return list(apps)
    selected: list[str] = []
    for item in requested:
        slug = canonical_app(args, item)
        if slug not in selected:
            selected.append(slug)
    return selected


def product_base(api_domain: str, app: str, args: argparse.Namespace | None = None) -> str:
    apps = registry_apps(args)
    app_payload = apps.get(canonical_app(args, app), {})
    api = app_payload.get("api") or {}
    if api.get("kind") != "product":
        return api_domain.rstrip("/")
    sub = api.get("subdomain")
    if not sub:
        return api_domain.rstrip("/")
    host = urlparse(api_domain).hostname or "www.zohoapis.in"
    suffix = host.split("zohoapis", 1)[1] if "zohoapis" in host else ".in"
    return f"https://{sub}.zoho{suffix}"


def derive_key() -> bytes:
    seed = f"{getpass.getuser()}:{socket.gethostname()}:{os.getuid()}".encode()
    extra = os.environ.get("ZOHO_LOCAL_CACHE_SECRET")
    if extra:
        seed += b":" + extra.encode()
    return hashlib.pbkdf2_hmac("sha256", seed, KEY_SALT, 200_000, dklen=32)


def load_vault(path: Path = DEFAULT_VAULT) -> dict[str, Any]:
    if not path.exists():
        return default_vault()
    try:
        blob = path.read_bytes()
        nonce, data = blob[:12], blob[12:]
        raw = AESGCM(derive_key()).decrypt(nonce, data, None)
        payload = json.loads(raw.decode())
        if not isinstance(payload, dict):
            raise RuntimeError("Invalid vault payload")
        payload.setdefault("version", 1)
        payload.setdefault("profiles", {})
        return payload
    except Exception:
        # This vault is a machine-local cache, not the canonical source of truth.
        # If the encrypted file is stale, foreign, or corrupted, let env/1Password
        # credentials keep working and allow a future successful auth flow to
        # rebuild the cache.
        return default_vault()


def save_vault(vault: dict[str, Any], path: Path = DEFAULT_VAULT) -> None:
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    nonce = secrets.token_bytes(12)
    blob = json.dumps(vault, sort_keys=True).encode()
    path.write_bytes(nonce + AESGCM(derive_key()).encrypt(nonce, blob, None))
    path.chmod(0o600)


def profile(vault: dict[str, Any], name: str) -> dict[str, Any]:
    profiles = vault.setdefault("profiles", {})
    entry = profiles.setdefault(name, {})
    entry.setdefault("api_domain", "https://www.zohoapis.in")
    entry.setdefault("access_token", "")
    entry.setdefault("refresh_token", "")
    entry.setdefault("access_token_expires_at", "")
    entry.setdefault("scope", "")
    return entry


def op_read(ref: str) -> str:
    result = subprocess.run(["op", "read", ref], check=True, capture_output=True, text=True)
    return result.stdout.strip()


def op_item_fields(item: str, vault: str = "Work") -> dict[str, str]:
    result = subprocess.run(
        ["op", "item", "get", item, "--vault", vault, "--format", "json"],
        check=True,
        capture_output=True,
        text=True,
    )
    payload = json.loads(result.stdout)
    fields: dict[str, str] = {}
    for field in payload.get("fields") or []:
        label = str(field.get("label") or field.get("id") or "").strip()
        value = field.get("value")
        if label and value is not None:
            fields[label] = str(value)
    return fields


def resolve_credentials(args: argparse.Namespace, entry: dict[str, Any] | None = None) -> Credentials:
    op_item = args.op_item or os.environ.get("ZOHO_OP_ITEM")
    api_domain = os.environ.get("ZOHO_API_DOMAIN") or (entry or {}).get("api_domain") or "https://www.zohoapis.in"

    if op_item:
        fields = op_item_fields(op_item)
        def field(name: str) -> str:
            return fields.get(name, "")
        client_id = field("client_id")
        client_secret = field("client_secret")
        refresh_token = field("refresh_token")
        api_domain = field("api_domain") or api_domain
        if client_id and client_secret:
            return Credentials(client_id, client_secret, refresh_token, api_domain)

    client_id = os.environ.get("ZOHO_CLIENT_ID") or (entry or {}).get("client_id", "")
    client_secret = os.environ.get("ZOHO_CLIENT_SECRET") or (entry or {}).get("client_secret", "")
    refresh_token = os.environ.get("ZOHO_REFRESH_TOKEN") or (entry or {}).get("refresh_token", "")
    if not client_id or not client_secret:
        raise RuntimeError("Missing credentials. Set ZOHO_OP_ITEM or ZOHO_CLIENT_ID/ZOHO_CLIENT_SECRET.")
    return Credentials(client_id, client_secret, refresh_token, api_domain)


def scopes_for_args(args: argparse.Namespace | None, apps: str, explicit: str = "") -> list[str]:
    merged: list[str] = []
    registry = registry_apps(args)
    for app in selected_apps(args, apps):
        for scope in registry.get(app, {}).get("scopes") or []:
            if scope not in merged:
                merged.append(scope)
    for scope in [s.strip() for s in explicit.split(",") if s.strip()]:
        if scope not in merged:
            merged.append(scope)
    return merged


def scopes_for(apps: str, explicit: str = "") -> list[str]:
    return scopes_for_args(None, apps, explicit)


def _merge_scope_csv(*parts: str) -> str:
    merged: list[str] = []
    for part in parts:
        for raw in str(part or "").replace(" ", ",").split(","):
            scope = raw.strip()
            if scope and scope not in merged:
                merged.append(scope)
    return ",".join(merged)


def pack_scope_batches(
    scopes: list[str],
    *,
    url_builder,
    max_url_length: int = DEFAULT_MAX_AUTH_URL_LENGTH,
) -> list[list[str]]:
    """Greedy pack so each authorize URL stays under ``max_url_length`` (#18141)."""
    if not scopes:
        return [[]]
    batches: list[list[str]] = []
    index = 0
    while index < len(scopes):
        batch = [scopes[index]]
        index += 1
        while index < len(scopes):
            candidate = [*batch, scopes[index]]
            if len(url_builder(candidate)) <= max_url_length:
                batch.append(scopes[index])
                index += 1
                continue
            break
        batches.append(batch)
    return batches


def _auth_url_for_scopes(
    creds: Credentials,
    *,
    scopes: list[str],
    redirect_uri: str,
    state: str,
) -> str:
    params = {
        "response_type": "code",
        "client_id": creds.client_id,
        "scope": ",".join(scopes),
        "redirect_uri": redirect_uri,
        "access_type": "offline",
        "prompt": "consent",
        "state": state,
    }
    return f"{account_host(creds.api_domain)}/oauth/v2/auth?{urlencode(params)}"


def auth_url(
    creds: Credentials,
    *,
    apps: str,
    scope: str,
    redirect_uri: str,
    state: str,
    registry_args: argparse.Namespace | None = None,
) -> str:
    scopes = scopes_for_args(registry_args, apps, scope)
    return _auth_url_for_scopes(creds, scopes=scopes, redirect_uri=redirect_uri, state=state)


def auth_url_batches(
    creds: Credentials,
    *,
    apps: str,
    scope: str,
    redirect_uri: str,
    state: str,
    registry_args: argparse.Namespace | None = None,
    max_url_length: int = DEFAULT_MAX_AUTH_URL_LENGTH,
) -> list[dict[str, Any]]:
    """Build one or more authorize URLs when a single full-scope URL would 414."""
    scopes = scopes_for_args(registry_args, apps, scope)

    def _build(batch: list[str]) -> str:
        return _auth_url_for_scopes(creds, scopes=batch, redirect_uri=redirect_uri, state=state)

    single = _build(scopes)
    if len(single) <= max_url_length:
        return [{"batch_index": 0, "auth_url": single, "scopes": scopes, "scope_count": len(scopes)}]
    packed = pack_scope_batches(scopes, url_builder=_build, max_url_length=max_url_length)
    return [
        {
            "batch_index": index,
            "auth_url": _build(batch),
            "scopes": batch,
            "scope_count": len(batch),
        }
        for index, batch in enumerate(packed)
    ]


def post_form(url: str, form: dict[str, str]) -> dict[str, Any]:
    request = Request(url, data=urlencode(form).encode(), method="POST")
    request.add_header("Content-Type", "application/x-www-form-urlencoded")
    try:
        with urlopen(request, timeout=45) as response:
            return json.loads(response.read().decode())
    except HTTPError as exc:
        body = exc.read().decode() if exc.fp else ""
        raise RuntimeError(f"HTTP {exc.code}: {body}") from exc


def exchange_code(args: argparse.Namespace, code: str, redirect_uri: str) -> dict[str, Any]:
    vault = load_vault()
    entry = profile(vault, args.profile)
    creds = resolve_credentials(args, entry)
    data = post_form(
        f"{account_host(creds.api_domain)}/oauth/v2/token",
        {
            "grant_type": "authorization_code",
            "code": code,
            "client_id": creds.client_id,
            "client_secret": creds.client_secret,
            "redirect_uri": redirect_uri,
        },
    )
    if "access_token" not in data:
        raise RuntimeError(f"Token response missing access_token: {data}")
    expires_in = int(data.get("expires_in", 3600) or 3600)
    # Preserve working refresh tokens across batch re-consent; union scopes so
    # later batches never drop prior grants (#18141).
    entry.update(
        {
            "client_id": creds.client_id,
            "client_secret": creds.client_secret if args.cache_client_secret else "",
            "access_token": data["access_token"],
            "refresh_token": data.get("refresh_token") or creds.refresh_token or entry.get("refresh_token", ""),
            "api_domain": data.get("api_domain") or creds.api_domain,
            "access_token_expires_at": (now() + timedelta(seconds=expires_in)).isoformat(),
            "scope": _merge_scope_csv(
                str(entry.get("scope") or ""),
                str(data.get("scope") or ""),
                ",".join(scopes_for_args(args, args.apps, args.scope)),
            ),
            "updated_at": now().isoformat(),
        }
    )
    save_vault(vault)
    return {
        "profile": args.profile,
        "api_domain": entry["api_domain"],
        "has_refresh_token": bool(entry.get("refresh_token")),
        "access_token_expires_at": entry["access_token_expires_at"],
        "scope": entry.get("scope", ""),
    }


def token_fresh(entry: dict[str, Any]) -> bool:
    raw = entry.get("access_token_expires_at") or ""
    if not raw:
        return False
    try:
        return now() < datetime.fromisoformat(raw) - timedelta(seconds=120)
    except ValueError:
        return False


def refresh_access_token(args: argparse.Namespace) -> dict[str, Any]:
    vault = load_vault()
    entry = profile(vault, args.profile)
    creds = resolve_credentials(args, entry)
    refresh_token = creds.refresh_token or entry.get("refresh_token")
    if not refresh_token:
        raise RuntimeError("No refresh token available. Run auth listen/exchange first or store refresh_token in 1Password.")
    data = post_form(
        f"{account_host(creds.api_domain)}/oauth/v2/token",
        {
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
            "client_id": creds.client_id,
            "client_secret": creds.client_secret,
        },
    )
    if "access_token" not in data:
        raise RuntimeError(f"Refresh response missing access_token: {data}")
    expires_in = int(data.get("expires_in", 3600) or 3600)
    entry.update(
        {
            "client_id": creds.client_id,
            "client_secret": creds.client_secret if args.cache_client_secret else "",
            "refresh_token": refresh_token,
            "access_token": data["access_token"],
            "api_domain": data.get("api_domain") or creds.api_domain,
            "access_token_expires_at": (now() + timedelta(seconds=expires_in)).isoformat(),
            "updated_at": now().isoformat(),
        }
    )
    save_vault(vault)
    return {"profile": args.profile, "access_token_expires_at": entry["access_token_expires_at"]}


def access_token(args: argparse.Namespace) -> tuple[str, dict[str, Any]]:
    vault = load_vault()
    entry = profile(vault, args.profile)
    if not token_fresh(entry):
        refresh_access_token(args)
        vault = load_vault()
        entry = profile(vault, args.profile)
    token = entry.get("access_token")
    if not token:
        raise RuntimeError("No access token available.")
    return token, entry


def call_api(args: argparse.Namespace, *, method: str, app: str, path: str, body: str | None = None) -> Any:
    token, entry = access_token(args)
    api_domain = entry.get("api_domain") or "https://www.zohoapis.in"
    app = canonical_app(args, app)
    base = product_base(api_domain, app, args)
    url = path if path.startswith("http") else f"{base.rstrip('/')}/{path.lstrip('/')}"
    data = body.encode() if body is not None else None
    request = Request(url, data=data, method=method.upper())
    auth_scheme = "Bearer" if app == "cliq" else "Zoho-oauthtoken"
    request.add_header("Authorization", f"{auth_scheme} {token}")
    if body is not None:
        request.add_header("Content-Type", "application/json")
    try:
        with urlopen(request, timeout=45) as response:
            raw = response.read().decode()
            return json.loads(raw) if raw else {}
    except HTTPError as exc:
        raw = exc.read().decode() if exc.fp else ""
        raise RuntimeError(f"Zoho API error [{exc.code}]: {raw}") from exc


def cmd_auth_url(args: argparse.Namespace) -> None:
    vault = load_vault()
    entry = profile(vault, args.profile)
    creds = resolve_credentials(args, entry)
    state = args.state or secrets.token_urlsafe(12)
    scopes = scopes_for_args(args, args.apps, args.scope)
    max_len = int(getattr(args, "max_url_length", DEFAULT_MAX_AUTH_URL_LENGTH) or DEFAULT_MAX_AUTH_URL_LENGTH)
    force_batch = bool(getattr(args, "batch", False))
    batches = auth_url_batches(
        creds,
        apps=args.apps,
        scope=args.scope,
        redirect_uri=args.redirect_uri,
        state=state,
        registry_args=args,
        max_url_length=1 if force_batch and len(scopes) > 1 else max_len,
    )
    if force_batch or len(batches) > 1:
        json_print(
            {
                "batched": True,
                "batch_count": len(batches),
                "redirect_uri": args.redirect_uri,
                "state": state,
                "scope_count": len(scopes),
                "scope": ",".join(scopes),
                "batches": batches,
                "auth_url": batches[0]["auth_url"],
                "operator_note": (
                    "Complete each batch via auth listen/exchange; scopes merge into the "
                    "same vault profile without wiping an existing refresh_token. "
                    "Prefer WW tenant Connect (api.workweaver.ai/oauth/callback) when online."
                ),
            }
        )
        return
    url = batches[0]["auth_url"]
    json_print(
        {
            "batched": False,
            "auth_url": url,
            "redirect_uri": args.redirect_uri,
            "state": state,
            "scope": ",".join(scopes),
            "scope_count": len(scopes),
        }
    )


def cmd_auth_listen(args: argparse.Namespace) -> None:
    vault = load_vault()
    entry = profile(vault, args.profile)
    creds = resolve_credentials(args, entry)
    parsed = urlparse(args.redirect_uri)
    if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost"}:
        raise RuntimeError("auth listen requires a registered http://127.0.0.1 or http://localhost redirect URI.")
    port = parsed.port or 80
    path = parsed.path or "/callback"
    state = args.state or secrets.token_urlsafe(12)
    url = auth_url(creds, apps=args.apps, scope=args.scope, redirect_uri=args.redirect_uri, state=state, registry_args=args)
    result: dict[str, str] = {}

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *_: Any) -> None:
            return

        def do_GET(self) -> None:  # noqa: N802
            nonlocal result
            parsed_req = urlparse(self.path)
            if parsed_req.path != path:
                self.send_response(404)
                self.end_headers()
                return
            qs = {k: v[0] for k, v in parse_qs(parsed_req.query).items()}
            if qs.get("state") != state:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"OAuth state mismatch.")
                result = {"error": "state_mismatch"}
                return
            result = qs
            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Zoho local OAuth complete. You can close this tab.")

    print(url, file=sys.stderr)
    if args.open_browser:
        webbrowser.open(url)
    server = HTTPServer((parsed.hostname or "127.0.0.1", port), Handler)
    deadline = time.time() + args.timeout
    while time.time() < deadline and not result:
        server.timeout = max(0.5, min(2.0, deadline - time.time()))
        server.handle_request()
    if not result:
        raise RuntimeError("Timed out waiting for OAuth callback.")
    if result.get("error"):
        raise RuntimeError(f"OAuth failed: {result}")
    payload = exchange_code(args, result["code"], args.redirect_uri)
    json_print(payload)


def cmd_auth_exchange(args: argparse.Namespace) -> None:
    json_print(exchange_code(args, args.code, args.redirect_uri))


def cmd_token_refresh(args: argparse.Namespace) -> None:
    json_print(refresh_access_token(args))


def cmd_token_show(args: argparse.Namespace) -> None:
    vault = load_vault()
    entry = profile(vault, args.profile)
    json_print(
        {
            "profile": args.profile,
            "api_domain": entry.get("api_domain"),
            "client_id": redacted(entry.get("client_id") or ""),
            "has_access_token": bool(entry.get("access_token")),
            "has_refresh_token": bool(entry.get("refresh_token")),
            "access_token_expires_at": entry.get("access_token_expires_at"),
            "scope": entry.get("scope", ""),
        }
    )


def cmd_api(args: argparse.Namespace) -> None:
    body = args.body
    if body and body.startswith("@"):
        body = Path(body[1:]).read_text()
    if body:
        body = json.dumps(json.loads(body))
    json_print(call_api(args, method=args.method, app=args.app, path=args.path, body=body), compact=args.compact)


def cmd_apps_list(args: argparse.Namespace) -> None:
    rows = []
    for slug, payload in registry_apps(args).items():
        rows.append(
            {
                "slug": slug,
                "title": payload.get("title") or slug,
                "aliases": payload.get("aliases") or [],
                "scope_count": len(payload.get("scopes") or []),
                "api": payload.get("api") or {},
            }
        )
    json_print({"apps": rows, "count": len(rows)}, compact=args.compact)


def cmd_apps_show(args: argparse.Namespace) -> None:
    slug = canonical_app(args, args.app)
    payload = dict(registry_apps(args)[slug])
    payload["slug"] = slug
    payload["scope_count"] = len(payload.get("scopes") or [])
    json_print(payload, compact=args.compact)


def cmd_apps_scopes(args: argparse.Namespace) -> None:
    if args.app:
        slug = canonical_app(args, args.app)
        payload = registry_apps(args)[slug]
        json_print({"app": slug, "scope_count": len(payload.get("scopes") or []), "scopes": payload.get("scopes") or []}, compact=args.compact)
        return
    scopes = scopes_for_args(args, "all", "")
    json_print({"scope_count": len(scopes), "scopes": scopes}, compact=args.compact)


def cmd_cliq_send(args: argparse.Namespace) -> None:
    payload = json.dumps({"text": args.text})
    path = f"/api/v2/buddies/{args.to}/message"
    json_print(call_api(args, method="POST", app="cliq", path=path, body=payload), compact=args.compact)


def cmd_cliq_channels(args: argparse.Namespace) -> None:
    json_print(call_api(args, method="GET", app="cliq", path="/api/v2/channels"), compact=args.compact)


def cmd_cliq_messages(args: argparse.Namespace) -> None:
    suffix = f"/{args.message_id}" if args.message_id else ""
    path = f"/api/v2/chats/{args.chat_id}/messages{suffix}"
    json_print(call_api(args, method="GET", app="cliq", path=path), compact=args.compact)


def build_parser() -> argparse.ArgumentParser:
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--profile", default="default")
    common.add_argument("--op-item", default=os.environ.get("ZOHO_OP_ITEM", ""))
    common.add_argument("--registry", default=os.environ.get("ZOHO_LOCAL_REGISTRY", ""))
    common.add_argument("--cache-client-secret", action="store_true", help="Store client_secret in encrypted local vault. Default keeps it runtime-only.")

    parser = argparse.ArgumentParser(description="Local Zoho OAuth/API CLI")
    parser.add_argument("--profile", default="default")
    parser.add_argument("--op-item", default=os.environ.get("ZOHO_OP_ITEM", ""))
    parser.add_argument("--registry", default=os.environ.get("ZOHO_LOCAL_REGISTRY", ""))
    parser.add_argument("--cache-client-secret", action="store_true", help="Store client_secret in encrypted local vault. Default keeps it runtime-only.")
    sub = parser.add_subparsers(required=True)

    auth = sub.add_parser("auth", parents=[common])
    auth_sub = auth.add_subparsers(required=True)
    for name in ("url", "listen", "exchange"):
        p = auth_sub.add_parser(name, parents=[common])
        p.add_argument("--apps", default="all")
        p.add_argument("--scope", default="")
        p.add_argument("--redirect-uri", default=DEFAULT_REDIRECT)
        p.add_argument("--state", default="")
        if name == "url":
            p.add_argument(
                "--batch",
                action="store_true",
                help="Force multi-URL batch plan when full-registry scopes would 414 (#18141).",
            )
            p.add_argument(
                "--max-url-length",
                type=int,
                default=DEFAULT_MAX_AUTH_URL_LENGTH,
                help=f"Authorize URL length ceiling before batching (default {DEFAULT_MAX_AUTH_URL_LENGTH}).",
            )
            p.set_defaults(func=cmd_auth_url)
        elif name == "listen":
            p.add_argument("--open-browser", action="store_true")
            p.add_argument("--timeout", type=int, default=300)
            p.set_defaults(func=cmd_auth_listen)
        else:
            p.add_argument("--code", required=True)
            p.set_defaults(func=cmd_auth_exchange)

    token = sub.add_parser("token", parents=[common])
    token_sub = token.add_subparsers(required=True)
    token_sub.add_parser("refresh", parents=[common]).set_defaults(func=cmd_token_refresh)
    token_sub.add_parser("show", parents=[common]).set_defaults(func=cmd_token_show)

    apps = sub.add_parser("apps", parents=[common])
    apps_sub = apps.add_subparsers(required=True)
    apps_list = apps_sub.add_parser("list", parents=[common])
    apps_list.add_argument("--compact", action="store_true")
    apps_list.set_defaults(func=cmd_apps_list)
    apps_show = apps_sub.add_parser("show", parents=[common])
    apps_show.add_argument("app")
    apps_show.add_argument("--compact", action="store_true")
    apps_show.set_defaults(func=cmd_apps_show)
    apps_scopes = apps_sub.add_parser("scopes", parents=[common])
    apps_scopes.add_argument("app", nargs="?", default="")
    apps_scopes.add_argument("--compact", action="store_true")
    apps_scopes.set_defaults(func=cmd_apps_scopes)

    api = sub.add_parser("api", parents=[common])
    api.add_argument("--method", default="GET")
    api.add_argument("--app", default="cliq")
    api.add_argument("--body")
    api.add_argument("--compact", action="store_true")
    api.add_argument("path")
    api.set_defaults(func=cmd_api)

    cliq = sub.add_parser("cliq", parents=[common])
    cliq_sub = cliq.add_subparsers(required=True)
    send = cliq_sub.add_parser("send", parents=[common])
    send.add_argument("--to", required=True)
    send.add_argument("--text", required=True)
    send.add_argument("--compact", action="store_true")
    send.set_defaults(func=cmd_cliq_send)
    channels = cliq_sub.add_parser("channels", parents=[common])
    channels.add_argument("--compact", action="store_true")
    channels.set_defaults(func=cmd_cliq_channels)
    messages = cliq_sub.add_parser("messages", parents=[common])
    messages.add_argument("--chat-id", required=True)
    messages.add_argument("--message-id", default="")
    messages.add_argument("--compact", action="store_true")
    messages.set_defaults(func=cmd_cliq_messages)
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        args.func(args)
        return 0
    except Exception as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1


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