#!/usr/bin/env python3
"""Workweaver-owned Zoho CLI v2.

This CLI is intentionally thin:
- product truth comes from the shared Zoho product registry
- auth and connection state come from Workweaver backend APIs
- no local Zoho token vault is treated as the canonical source of truth
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen

REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(REPO_ROOT))

from apps.backend.services.zoho.product_registry import get_zoho_product, list_zoho_products

DEFAULT_BASE_URL = os.environ.get("WORKWEAVER_BASE_URL") or os.environ.get("API_BASE_URL") or "https://api.workweaver.ai"
DEFAULT_TIMEOUT = float(os.environ.get("WORKWEAVER_ZOHO_CLI_TIMEOUT", "30"))
DEFAULT_SAFE_RETRIES = int(os.environ.get("WORKWEAVER_ZOHO_CLI_SAFE_RETRIES", "2"))
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
SAFE_METHODS = {"GET", "HEAD"}

EXIT_OK = 0
EXIT_USAGE = 2
EXIT_CONFIG = 3
EXIT_BACKEND_4XX = 4
EXIT_BACKEND_5XX = 5
EXIT_TRANSPORT = 6
EXIT_PROTOCOL = EXIT_BACKEND_5XX

COMMON_OPTION_HELP = """Common options (accepted before or after the command):
  --json
  --base-url URL
  --tenant-id TENANT
  --user-id USER
  --bearer-token TOKEN
  --header NAME=VALUE
  --timeout SECONDS
  --safe-retries COUNT

Environment:
  WORKWEAVER_BASE_URL
  WORKWEAVER_TENANT_ID
  WORKWEAVER_USER_ID
  WORKWEAVER_BEARER_TOKEN
  WORKWEAVER_ZOHO_CLI_TIMEOUT
  WORKWEAVER_ZOHO_CLI_SAFE_RETRIES
"""
COMMON_OPTION_LINES = COMMON_OPTION_HELP.strip().splitlines()


@dataclass
class PaginationConfig:
    """Configuration for paginated API responses."""

    per_page: int = 200
    max_records: int | None = None
    page_token: str | None = None


def _paginate_api(
    session: Any,
    url: str,
    params: dict[str, Any],
    config: PaginationConfig,
) -> Iterable[dict[str, Any]]:
    """Yield items across pages. Handles token-based and page-number pagination."""
    collected = 0
    page = 1
    while True:
        page_params = dict(params)
        page_params["per_page"] = config.per_page
        page_params["page"] = page
        if config.page_token:
            page_params["page_token"] = config.page_token

        resp = session.get(url, params=page_params, timeout=30)
        resp.raise_for_status()
        data = resp.json()

        items = data.get("data", data.get("items", []))
        if not items:
            break

        for item in items:
            yield item
            collected += 1
            if config.max_records and collected >= config.max_records:
                return

        next_token = data.get("info", {}).get("next_page_token") or data.get(
            "next_page_token"
        )
        if next_token:
            config.page_token = next_token
        else:
            page += 1
            if not data.get("info", {}).get("more_records", False):
                break


class CLIError(RuntimeError):
    def __init__(
        self,
        message: str,
        *,
        kind: str,
        exit_code: int,
        status_code: int | None = None,
        details: Any = None,
        hint: str | None = None,
    ) -> None:
        super().__init__(message)
        self.kind = kind
        self.exit_code = exit_code
        self.status_code = status_code
        self.details = details
        self.hint = hint

    def to_payload(self) -> dict[str, Any]:
        payload = {
            "kind": self.kind,
            "message": str(self),
            "exit_code": self.exit_code,
        }
        if self.status_code is not None:
            payload["status_code"] = self.status_code
        if self.details is not None:
            payload["details"] = self.details
        if self.hint:
            payload["hint"] = self.hint
        return payload


class CLIArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) -> None:  # pragma: no cover - argparse glue
        raise CLIError(message, kind="usage_error", exit_code=EXIT_USAGE)


def _json_loads(text: str, *, label: str) -> Any:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise CLIError(
            f"{label} is not valid JSON: {exc}",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        ) from exc


def _parse_json_input(source: str | None) -> dict[str, Any]:
    if not source:
        return {}
    if source == "-":
        raw = sys.stdin.read()
        label = "stdin"
    elif source.startswith("@"):
        path = Path(source[1:]).expanduser()
        if not path.exists():
            raise CLIError(
                f"Input file not found: {path}",
                kind="usage_error",
                exit_code=EXIT_USAGE,
            )
        raw = path.read_text(encoding="utf-8")
        label = str(path)
    else:
        raw = source
        label = "inline input"
    data = _json_loads(raw, label=label)
    if not isinstance(data, dict):
        raise CLIError(
            f"{label} must be a JSON object",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        )
    return data


def _parse_csv(values: Iterable[str] | str | None) -> list[str]:
    if values is None:
        return []
    if isinstance(values, str):
        values = [values]
    merged: list[str] = []
    for value in values:
        for item in str(value or "").split(","):
            token = item.strip()
            if token and token not in merged:
                merged.append(token)
    return merged


def _coalesce(*values: Any) -> Any:
    for value in values:
        if value is None:
            continue
        if isinstance(value, str) and not value.strip():
            continue
        return value
    return None


def _strip_empty(mapping: Mapping[str, Any]) -> dict[str, Any]:
    out: dict[str, Any] = {}
    for key, value in mapping.items():
        if value is None:
            continue
        if isinstance(value, str) and not value.strip():
            continue
        if isinstance(value, list) and not value:
            continue
        out[key] = value
    return out


def _canonical_product(raw: str) -> Any:
    try:
        return get_zoho_product(raw)
    except KeyError as exc:
        raise CLIError(
            str(exc),
            kind="usage_error",
            exit_code=EXIT_USAGE,
            hint="Run `zoho products list --json` to inspect canonical product slugs and aliases.",
        ) from exc


def _product_payload(product: Any) -> dict[str, Any]:
    return {
        "product_slug": product.product_slug,
        "key": product.key,
        "title": product.title,
        "display_name": product.display_name,
        "aliases": list(product.aliases),
        "category": product.category,
        "auth_type": product.auth_type,
        "description": product.description,
        "capabilities": list(product.capabilities),
        "catalog_capabilities": list(product.catalog_capabilities),
        "catalog_visible": product.catalog_visible,
        "connector_id": product.connector_id,
        "scopes": list(product.scopes),
        "requirements": dict(product.requirements),
        "readiness": dict(product.readiness),
        "webhooks": dict(product.webhooks),
        "source": product.source,
        "note": product.note,
    }


def _products_reference() -> list[dict[str, Any]]:
    return [_product_payload(product) for product in sorted(list_zoho_products(), key=lambda item: item.display_name.lower())]


def _build_reference(base_url: str) -> dict[str, Any]:
    return {
        "name": "zoho",
        "summary": "Workweaver-owned Zoho CLI v2",
        "defaults": {
            "base_url": base_url,
            "timeout_seconds": DEFAULT_TIMEOUT,
            "safe_retries": DEFAULT_SAFE_RETRIES,
        },
        "commands": [
            {
                "path": "help",
                "description": "Show human or JSON help with examples, defaults, and exit codes.",
                "examples": [
                    "zoho help",
                    "zoho --json help",
                    "zoho help auth",
                ],
            },
            {
                "path": "products list",
                "description": "List Zoho products from the shared backend registry.",
                "examples": [
                    "zoho products list",
                    "zoho --json products list",
                    "zoho products list --catalog-only",
                ],
            },
            {
                "path": "products show <product>",
                "description": "Show one product from the shared backend registry using any accepted alias.",
                "examples": [
                    "zoho products show crm",
                    "zoho --json products show zohobooks",
                ],
            },
            {
                "path": "connections list",
                "description": "List canonical Zoho connections from Workweaver backend.",
                "examples": [
                    "zoho --tenant-id tenant-123 connections list",
                    "zoho --json --tenant-id tenant-123 --bearer-token $WORKWEAVER_BEARER_TOKEN connections list",
                ],
            },
            {
                "path": "connections show <product>",
                "description": "Show the canonical connection for one Zoho product.",
                "examples": [
                    "zoho --tenant-id tenant-123 connections show crm",
                    "zoho --json --tenant-id tenant-123 connections show zoho-calendar",
                ],
            },
            {
                "path": "auth start <product>",
                "description": "Start a Workweaver-owned Zoho auth flow for a product.",
                "examples": [
                    "zoho --tenant-id tenant-123 auth start crm --redirect-uri http://localhost:3000/callback",
                    "zoho --json --tenant-id tenant-123 auth start desk --connection-scope agent --agent-id agent-42 --zoho-scope Desk.tickets.ALL",
                    "zoho --json --tenant-id tenant-123 auth start books --input @start.json --dry-run",
                ],
            },
            {
                "path": "auth complete",
                "description": "Complete Zoho auth via explicit `/auth/complete` or callback-compatible `/oauth/callback` flow.",
                "examples": [
                    "zoho --tenant-id tenant-123 auth complete --connection-id conn-1 --code CODE --state OAUTH_STATE",
                    "printf '{\"code\":\"CODE\",\"state\":\"ENCODED_STATE\"}' | zoho --json --tenant-id tenant-123 auth complete --input -",
                ],
            },
            {
                "path": "scopes [product]",
                "description": "Show shared-registry scopes for one product or all products.",
                "examples": [
                    "zoho scopes",
                    "zoho --json scopes desk",
                ],
            },
        ],
        "exit_codes": [
            {"code": EXIT_OK, "meaning": "success"},
            {"code": EXIT_USAGE, "meaning": "usage or input validation error"},
            {"code": EXIT_CONFIG, "meaning": "missing CLI configuration such as tenant id"},
            {"code": EXIT_BACKEND_4XX, "meaning": "backend returned a 4xx response"},
            {"code": EXIT_BACKEND_5XX, "meaning": "backend returned a 5xx response"},
            {"code": EXIT_TRANSPORT, "meaning": "network or transport failure"},
        ],
        "common_options": COMMON_OPTION_LINES,
    }


def _format_reference_text(reference: Mapping[str, Any], topic: str = "") -> str:
    commands = list(reference["commands"])
    if topic:
        needle = topic.strip().lower()
        commands = [item for item in commands if item["path"].lower().startswith(needle)]
        if not commands:
            raise CLIError(
                f"Unknown help topic: {topic}",
                kind="usage_error",
                exit_code=EXIT_USAGE,
            )
    lines = [
        f"{reference['name']}: {reference['summary']}",
        "",
        *reference["common_options"],
        "",
        "Commands:",
    ]
    for command in commands:
        lines.append(f"- {command['path']}: {command['description']}")
        for example in command["examples"]:
            lines.append(f"  example: {example}")
    if not topic:
        lines.extend(
            [
                "",
                "Exit codes:",
            ]
        )
        for item in reference["exit_codes"]:
            lines.append(f"- {item['code']}: {item['meaning']}")
    return "\n".join(lines)


def _extract_common_options(argv: list[str]) -> tuple[dict[str, Any], list[str]]:
    options: dict[str, Any] = {
        "json": False,
        "base_url": "",
        "tenant_id": "",
        "user_id": "",
        "bearer_token": "",
        "header": [],
        "timeout": "",
        "safe_retries": "",
    }
    value_flags = {
        "--base-url": "base_url",
        "--tenant-id": "tenant_id",
        "--user-id": "user_id",
        "--bearer-token": "bearer_token",
        "--timeout": "timeout",
        "--safe-retries": "safe_retries",
    }
    repeat_flags = {"--header": "header"}
    clean: list[str] = []
    index = 0
    while index < len(argv):
        token = argv[index]
        name, eq, inline_value = token.partition("=")
        if token == "--json":
            options["json"] = True
            index += 1
            continue
        if name in value_flags:
            if eq:
                value = inline_value
            else:
                index += 1
                if index >= len(argv):
                    raise CLIError(
                        f"{name} requires a value",
                        kind="usage_error",
                        exit_code=EXIT_USAGE,
                    )
                value = argv[index]
            options[value_flags[name]] = value
            index += 1
            continue
        if name in repeat_flags:
            if eq:
                value = inline_value
            else:
                index += 1
                if index >= len(argv):
                    raise CLIError(
                        f"{name} requires a value",
                        kind="usage_error",
                        exit_code=EXIT_USAGE,
                    )
                value = argv[index]
            options[repeat_flags[name]].append(value)
            index += 1
            continue
        clean.append(token)
        index += 1
    return options, clean


def _resolve_common_options(options: Mapping[str, Any]) -> dict[str, Any]:
    timeout_raw = _coalesce(options.get("timeout"), os.environ.get("WORKWEAVER_ZOHO_CLI_TIMEOUT"), str(DEFAULT_TIMEOUT))
    retries_raw = _coalesce(options.get("safe_retries"), os.environ.get("WORKWEAVER_ZOHO_CLI_SAFE_RETRIES"), str(DEFAULT_SAFE_RETRIES))
    try:
        timeout = float(timeout_raw)
    except (TypeError, ValueError) as exc:
        raise CLIError(
            f"Invalid timeout value: {timeout_raw}",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        ) from exc
    try:
        safe_retries = int(retries_raw)
    except (TypeError, ValueError) as exc:
        raise CLIError(
            f"Invalid safe retry count: {retries_raw}",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        ) from exc
    return {
        "json": bool(options.get("json")),
        "base_url": str(_coalesce(options.get("base_url"), os.environ.get("WORKWEAVER_BASE_URL"), os.environ.get("API_BASE_URL"), DEFAULT_BASE_URL)),
        "tenant_id": str(_coalesce(options.get("tenant_id"), os.environ.get("WORKWEAVER_TENANT_ID")) or ""),
        "user_id": str(_coalesce(options.get("user_id"), os.environ.get("WORKWEAVER_USER_ID")) or ""),
        "bearer_token": str(_coalesce(options.get("bearer_token"), os.environ.get("WORKWEAVER_BEARER_TOKEN")) or ""),
        "headers": list(options.get("header") or []),
        "timeout": timeout,
        "safe_retries": max(0, safe_retries),
    }


def _parse_extra_headers(values: Iterable[str]) -> dict[str, str]:
    headers: dict[str, str] = {}
    for item in values:
        if "=" not in item:
            raise CLIError(
                f"Invalid header '{item}'. Expected NAME=VALUE.",
                kind="usage_error",
                exit_code=EXIT_USAGE,
            )
        name, value = item.split("=", 1)
        name = name.strip()
        value = value.strip()
        if not name:
            raise CLIError(
                f"Invalid header '{item}'. Header name is empty.",
                kind="usage_error",
                exit_code=EXIT_USAGE,
            )
        headers[name] = value
    return headers


def _normalize_base_url(base_url: str) -> str:
    normalized = str(base_url or "").strip().rstrip("/")
    if not normalized:
        raise CLIError(
            "Missing Workweaver base URL.",
            kind="config_error",
            exit_code=EXIT_CONFIG,
            hint="Set --base-url or WORKWEAVER_BASE_URL.",
        )
    if not normalized.startswith(("http://", "https://")):
        raise CLIError(
            f"Invalid base URL: {base_url}",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        )
    return normalized


def _require_tenant_id(args: argparse.Namespace) -> str:
    tenant_id = str(getattr(args, "tenant_id", "") or "").strip()
    if tenant_id:
        return tenant_id
    raise CLIError(
        "Missing tenant id for backend-backed command.",
        kind="config_error",
        exit_code=EXIT_CONFIG,
        hint="Set --tenant-id or WORKWEAVER_TENANT_ID.",
    )


def _request_headers(args: argparse.Namespace, *, include_json: bool) -> dict[str, str]:
    tenant_id = _require_tenant_id(args)
    headers = {
        "Accept": "application/json",
        "X-Tenant-ID": tenant_id,
        "X-Tenant-Id": tenant_id,
    }
    bearer_token = str(getattr(args, "bearer_token", "") or "").strip()
    if bearer_token:
        headers["Authorization"] = f"Bearer {bearer_token}"
    user_id = str(getattr(args, "user_id", "") or "").strip()
    if user_id:
        headers["X-User-ID"] = user_id
    if include_json:
        headers["Content-Type"] = "application/json"
    headers.update(_parse_extra_headers(getattr(args, "headers", []) or []))
    return headers


def _join_url(base_url: str, path: str) -> str:
    if path.startswith(("http://", "https://")):
        return path
    return f"{_normalize_base_url(base_url)}{path}"


def _parse_response_body(status_code: int, raw: str) -> Any:
    if not raw.strip():
        return {"status_code": status_code}
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return {"status_code": status_code, "raw": raw}


def _http_error_to_cli(err: HTTPError, body: str) -> CLIError:
    details = _parse_response_body(err.code, body)
    message = body.strip() or f"Backend request failed with HTTP {err.code}"
    if isinstance(details, dict):
        detail_message = details.get("detail") or details.get("message") or details.get("error")
        if detail_message:
            message = str(detail_message)
    exit_code = EXIT_BACKEND_4XX if 400 <= err.code < 500 else EXIT_BACKEND_5XX
    hint = None
    if err.code == 401:
        hint = "Pass --bearer-token for authenticated environments or use local dev auth headers."
    elif err.code == 403:
        hint = "Confirm the tenant id matches the authenticated organization."
    elif err.code == 404:
        hint = "Check the product slug and whether the connection exists."
    return CLIError(
        message,
        kind="backend_error",
        exit_code=exit_code,
        status_code=err.code,
        details=details,
        hint=hint,
    )


def _request_json(
    args: argparse.Namespace,
    *,
    method: str,
    path: str,
    body: Any = None,
    retry_safe: bool | None = None,
) -> Any:
    method = method.upper()
    url = _join_url(args.base_url, path)
    payload = None if body is None else json.dumps(body).encode("utf-8")
    headers = _request_headers(args, include_json=payload is not None)
    attempts = 1
    if retry_safe is True or (retry_safe is None and method in SAFE_METHODS):
        attempts += int(getattr(args, "safe_retries", DEFAULT_SAFE_RETRIES))

    for attempt in range(attempts):
        request = Request(url, data=payload, method=method)
        for name, value in headers.items():
            request.add_header(name, value)
        try:
            with urlopen(request, timeout=float(getattr(args, "timeout", DEFAULT_TIMEOUT))) as response:
                raw = response.read().decode("utf-8")
                return _parse_response_body(response.status, raw)
        except HTTPError as err:
            body_text = err.read().decode("utf-8") if err.fp else ""
            if attempt < attempts - 1 and err.code in RETRYABLE_STATUS_CODES:
                time.sleep(min(2 ** attempt, 8))
                continue
            raise _http_error_to_cli(err, body_text) from err
        except URLError as err:
            if attempt < attempts - 1:
                time.sleep(min(2 ** attempt, 8))
                continue
            raise CLIError(
                f"Network error contacting {url}: {err}",
                kind="transport_error",
                exit_code=EXIT_TRANSPORT,
                details={"url": url, "reason": str(err)},
            ) from err


def _emit_json(payload: Any) -> None:
    print(json.dumps(payload, indent=2, sort_keys=True))


def _emit_text_plan(plan: Mapping[str, Any]) -> None:
    print("dry_run: true")
    for key in ("method", "path", "base_url", "mode"):
        value = plan.get(key)
        if value is not None:
            print(f"{key}: {value}")
    if plan.get("body") is not None:
        print(f"body: {json.dumps(plan['body'], sort_keys=True)}")


def _emit_error(err: CLIError, *, json_mode: bool) -> None:
    payload = {"error": err.to_payload()}
    if json_mode:
        _emit_json(payload)
        return
    print(f"{err.kind}: {err}", file=sys.stderr)
    if err.status_code is not None:
        print(f"status_code: {err.status_code}", file=sys.stderr)
    if err.hint:
        print(f"hint: {err.hint}", file=sys.stderr)


def cmd_help(args: argparse.Namespace) -> None:
    reference = _build_reference(args.base_url)
    if args.json:
        if args.topic:
            match = [item for item in reference["commands"] if item["path"].lower().startswith(args.topic.lower())]
            if not match:
                raise CLIError(
                    f"Unknown help topic: {args.topic}",
                    kind="usage_error",
                    exit_code=EXIT_USAGE,
                )
            _emit_json({"command": match[0], "defaults": reference["defaults"], "exit_codes": reference["exit_codes"]})
            return
        _emit_json(reference)
        return
    print(_format_reference_text(reference, topic=args.topic))


def cmd_products_list(args: argparse.Namespace) -> None:
    products = _products_reference()
    if args.catalog_only:
        products = [product for product in products if product["catalog_visible"]]
    if args.json:
        _emit_json({"products": products, "count": len(products)})
        return
    for product in products:
        readiness = product["readiness"]
        print(
            f"{product['product_slug']}: {product['display_name']} "
            f"[auth_ready={readiness.get('auth_ready')} "
            f"execution_ready={readiness.get('execution_ready')} "
            f"webhook_ready={readiness.get('webhook_ready')}]"
        )


def cmd_products_show(args: argparse.Namespace) -> None:
    payload = _product_payload(_canonical_product(args.product))
    if args.json:
        _emit_json(payload)
        return
    for key, value in payload.items():
        print(f"{key}: {value}")


def cmd_scopes(args: argparse.Namespace) -> None:
    if args.product:
        payload = _product_payload(_canonical_product(args.product))
        scopes = {
            "product_slug": payload["product_slug"],
            "display_name": payload["display_name"],
            "scopes": payload["scopes"],
            "aliases": payload["aliases"],
        }
        if args.json:
            _emit_json(scopes)
            return
        print(f"{scopes['product_slug']}: {scopes['display_name']}")
        print(f"aliases: {', '.join(scopes['aliases'])}")
        print("scopes:")
        for scope in scopes["scopes"]:
            print(f"- {scope}")
        return
    rows = [
        {
            "product_slug": product["product_slug"],
            "display_name": product["display_name"],
            "scope_count": len(product["scopes"]),
            "scopes": product["scopes"],
        }
        for product in _products_reference()
    ]
    if args.json:
        _emit_json({"products": rows, "count": len(rows)})
        return
    for row in rows:
        print(f"{row['product_slug']}: {row['scope_count']} scopes")


def cmd_connections_list(args: argparse.Namespace) -> None:
    payload = _request_json(args, method="GET", path="/api/v1/connectors/zoho/connections")
    if isinstance(payload, Mapping):
        payload = payload.get("connections") or []
    if not isinstance(payload, list):
        raise CLIError(
            "Unexpected connections response shape from backend.",
            kind="protocol_error",
            exit_code=EXIT_PROTOCOL,
            details={"response_type": type(payload).__name__},
        )
    if args.json:
        _emit_json({"connections": payload, "count": len(payload)})
        return
    if not payload:
        print("No Zoho connections found.")
        return
    for item in payload:
        print(f"{item['product_slug']}: state={item['state']} connection_id={item['connection_id']}")


def cmd_connections_show(args: argparse.Namespace) -> None:
    product = _canonical_product(args.product)
    payload = _request_json(
        args,
        method="GET",
        path=f"/api/v1/connectors/zoho/connections/{quote(product.product_slug, safe='')}",
    )
    if args.json:
        _emit_json(payload)
        return
    for key, value in payload.items():
        print(f"{key}: {value}")


def _auth_start_payload(args: argparse.Namespace) -> dict[str, Any]:
    input_payload = _parse_json_input(args.input)
    product_value = _coalesce(args.product, input_payload.get("product"), input_payload.get("product_slug"))
    if not product_value:
        raise CLIError(
            "auth start requires a product or an input payload with product/product_slug.",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        )
    product = _canonical_product(str(product_value))
    input_scopes = input_payload.get("scopes") or []
    if isinstance(input_scopes, str):
        input_scopes = [input_scopes]
    requested_scopes = _parse_csv(args.zoho_scope or []) or _parse_csv(input_scopes)
    return _strip_empty(
        {
            "product_slug": product.product_slug,
            "scope": _coalesce(args.connection_scope, input_payload.get("scope"), "tenant"),
            "agent_id": _coalesce(args.agent_id, input_payload.get("agent_id")),
            "dc": _coalesce(args.dc, input_payload.get("dc")),
            "redirect_uri": _coalesce(args.redirect_uri, input_payload.get("redirect_uri")),
            "scopes": requested_scopes,
            "org_id": _coalesce(args.org_id, input_payload.get("org_id")),
            "portal_id": _coalesce(args.portal_id, input_payload.get("portal_id")),
            "instance_id": _coalesce(args.instance_id, input_payload.get("instance_id")),
        }
    )


def cmd_auth_start(args: argparse.Namespace) -> None:
    payload = _auth_start_payload(args)
    plan = {
        "method": "POST",
        "path": "/api/v1/connectors/zoho/auth/start",
        "base_url": args.base_url,
        "body": payload,
    }
    if args.dry_run:
        if args.json:
            _emit_json({"dry_run": True, "request": plan})
        else:
            _emit_text_plan(plan)
        return
    response = _request_json(
        args,
        method="POST",
        path="/api/v1/connectors/zoho/auth/start",
        body=payload,
        retry_safe=False,
    )
    if args.json:
        _emit_json(response)
        return
    print(f"authorization_url: {response.get('authorization_url', '')}")
    print(f"state: {response.get('state', '')}")
    connection = response.get("connection") or {}
    if connection.get("connection_id"):
        print(f"connection_id: {connection['connection_id']}")
    requested_scopes = response.get("requested_scopes") or []
    if requested_scopes:
        print(f"requested_scopes: {', '.join(requested_scopes)}")


def _auth_complete_payload(args: argparse.Namespace) -> tuple[str, str, dict[str, Any], str]:
    input_payload = _parse_json_input(args.input)
    connection_id = str(_coalesce(args.connection_id, input_payload.get("connection_id"), "") or "").strip()
    code = str(_coalesce(args.code, input_payload.get("code"), "") or "").strip()
    state = str(_coalesce(args.state, input_payload.get("state"), "") or "").strip()
    if not code:
        raise CLIError(
            "auth complete requires code or an input payload containing code.",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        )
    if not state:
        raise CLIError(
            "auth complete requires state or an input payload containing state.",
            kind="usage_error",
            exit_code=EXIT_USAGE,
        )
    payload = _strip_empty(
        {
            "connection_id": connection_id,
            "code": code,
            "state": state,
            "org_id": _coalesce(args.org_id, input_payload.get("org_id")),
            "portal_id": _coalesce(args.portal_id, input_payload.get("portal_id")),
            "instance_id": _coalesce(args.instance_id, input_payload.get("instance_id")),
        }
    )
    if connection_id:
        return "POST", "/api/v1/connectors/zoho/auth/complete", payload, "explicit"
    query = urlencode({"code": code, "state": state})
    return "GET", f"/api/v1/connectors/zoho/oauth/callback?{query}", {}, "callback"


def cmd_auth_complete(args: argparse.Namespace) -> None:
    method, path, payload, mode = _auth_complete_payload(args)
    plan = {
        "method": method,
        "path": path,
        "base_url": args.base_url,
        "mode": mode,
    }
    if payload:
        plan["body"] = payload
    if args.dry_run:
        if args.json:
            _emit_json({"dry_run": True, "request": plan})
        else:
            _emit_text_plan(plan)
        return
    response = _request_json(
        args,
        method=method,
        path=path,
        body=payload or None,
        retry_safe=False,
    )
    if args.json:
        _emit_json(response)
        return
    print(f"success: {response.get('success', False)}")
    connection = response.get("connection") or {}
    for key, value in connection.items():
        print(f"{key}: {value}")


def build_parser() -> argparse.ArgumentParser:
    parser = CLIArgumentParser(
        prog="zoho",
        description="Workweaver-owned Zoho CLI v2",
        epilog=COMMON_OPTION_HELP,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sub = parser.add_subparsers(dest="command", required=True)

    help_parser = sub.add_parser("help", help="Show CLI help and examples")
    help_parser.add_argument("topic", nargs="?", default="")
    help_parser.set_defaults(func=cmd_help)

    products = sub.add_parser("products", help="Inspect shared Zoho product registry truth")
    products_sub = products.add_subparsers(dest="products_cmd", required=True)
    products_list = products_sub.add_parser("list", help="List Zoho products from the shared registry")
    products_list.add_argument("--catalog-only", action="store_true")
    products_list.set_defaults(func=cmd_products_list)
    products_show = products_sub.add_parser("show", help="Show one Zoho product from the shared registry")
    products_show.add_argument("product")
    products_show.set_defaults(func=cmd_products_show)

    scopes = sub.add_parser("scopes", help="Show shared-registry scopes for one product or all products")
    scopes.add_argument("product", nargs="?", default="")
    scopes.set_defaults(func=cmd_scopes)

    connections = sub.add_parser("connections", help="Inspect canonical Zoho backend connections")
    connections_sub = connections.add_subparsers(dest="connections_cmd", required=True)
    connections_list = connections_sub.add_parser("list", help="List canonical Zoho connections")
    connections_list.set_defaults(func=cmd_connections_list)
    connections_show = connections_sub.add_parser("show", help="Show one canonical Zoho connection")
    connections_show.add_argument("product")
    connections_show.set_defaults(func=cmd_connections_show)

    auth = sub.add_parser("auth", help="Start or complete Workweaver-owned Zoho auth flows")
    auth_sub = auth.add_subparsers(dest="auth_cmd", required=True)

    auth_start = auth_sub.add_parser("start", help="Start Zoho auth for a product")
    auth_start.add_argument("product", nargs="?", default="")
    auth_start.add_argument("--input", default="")
    auth_start.add_argument("--connection-scope", dest="connection_scope", choices=("tenant", "agent"), default="")
    auth_start.add_argument("--agent-id", default="")
    auth_start.add_argument("--dc", default="")
    auth_start.add_argument("--redirect-uri", default="")
    auth_start.add_argument("--zoho-scope", action="append", default=[])
    auth_start.add_argument("--org-id", default="")
    auth_start.add_argument("--portal-id", default="")
    auth_start.add_argument("--instance-id", default="")
    auth_start.add_argument("--dry-run", action="store_true")
    auth_start.set_defaults(func=cmd_auth_start)

    auth_complete = auth_sub.add_parser("complete", help="Complete Zoho auth via explicit or callback-compatible flow")
    auth_complete.add_argument("--input", default="")
    auth_complete.add_argument("--connection-id", default="")
    auth_complete.add_argument("--code", default="")
    auth_complete.add_argument("--state", default="")
    auth_complete.add_argument("--org-id", default="")
    auth_complete.add_argument("--portal-id", default="")
    auth_complete.add_argument("--instance-id", default="")
    auth_complete.add_argument("--dry-run", action="store_true")
    auth_complete.set_defaults(func=cmd_auth_complete)

    return parser


def main(argv: list[str] | None = None) -> int:
    raw_argv = list(sys.argv[1:] if argv is None else argv)
    try:
        common, clean_argv = _extract_common_options(raw_argv)
        resolved = _resolve_common_options(common)
        if not clean_argv:
            clean_argv = ["help"]
        parser = build_parser()
        args = parser.parse_args(clean_argv)
        args.json = bool(resolved["json"])
        args.base_url = _normalize_base_url(resolved["base_url"])
        args.tenant_id = resolved["tenant_id"]
        args.user_id = resolved["user_id"]
        args.bearer_token = resolved["bearer_token"]
        args.headers = resolved["headers"]
        args.timeout = resolved["timeout"]
        args.safe_retries = resolved["safe_retries"]
        args.func(args)
        return EXIT_OK
    except CLIError as err:
        json_mode = "--json" in raw_argv
        _emit_error(err, json_mode=json_mode)
        return err.exit_code


if __name__ == "__main__":  # pragma: no cover - CLI entrypoint
    raise SystemExit(main())
