"""
Command line interface for g-gremlin.
"""

from __future__ import annotations

import csv
import fnmatch
import hashlib
import importlib
import os
import re
import json
import sys
import platform
import subprocess
import uuid
from functools import partial
from datetime import datetime, timezone, timedelta
from pathlib import Path
from dataclasses import asdict
import time
import tempfile
import webbrowser
import textwrap
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Literal


EXIT_SUCCESS = 0
EXIT_ISSUES = 1
EXIT_USAGE = 2
EXIT_REMOTE_ERROR = 3

import click
from click.core import ParameterSource

from g_gremlin import __version__
from g_gremlin.auth import format_auth_resolution, resolve_bridge_auth, resolve_runtime_auth
from g_gremlin.a1 import col_index_to_letter, cell_a1_from_offsets, parse_a1_range
from g_gremlin import errors as error_helpers
from g_gremlin import help_introspection
from g_gremlin import command_metadata
# Heavy modules (import google_client) - lazily loaded via _get_*() helpers below
# import g_gremlin.push as push_helpers
# import g_gremlin.sheets_io as sheets_io / sheets_helpers
# import g_gremlin.apps_script as apps_script
from g_gremlin.contracts import AgenticWarning, CapabilityCheck, DoctorResult
from g_gremlin.safety import SafetyContext
from g_gremlin.diffing import compute_csv_diff
from g_gremlin.models import SheetState
from g_gremlin.pacing import get_amplemarket_profile, get_hubspot_profile, get_outreach_profile
from g_gremlin import cli_telemetry
from g_gremlin import dev_mode
from g_gremlin.paths import gremlin_path, resolve_gremlin_home
from g_gremlin import support_bundle
from g_gremlin import support_log
from g_gremlin import update_check
from g_gremlin.support_context import (
    _build_support_context,
    _load_sheet_state_snapshot,
    _load_support_config,
    _read_support_log_tail,
    _redact_text,
    _sanitize_log_line,
)


# ---------------------------------------------------------------------------
# Lazy import helpers - defer heavy Google API imports until actually needed
# These modules import google_client which triggers the entire Google auth chain
# ---------------------------------------------------------------------------
def _get_google_client():
    """Lazy import google_client to avoid loading Google auth stack on --help."""
    from g_gremlin import google_client
    google_client.set_google_profile(_get_google_profile_from_ctx())
    return google_client


def _get_local_state():
    """Lazy import local_state to avoid loading Google auth chain on --help."""
    from g_gremlin import local_state
    return local_state


def _get_http_error():
    """Lazy import HttpError to avoid loading googleapiclient on --help."""
    from googleapiclient.errors import HttpError
    return HttpError


def _get_push_helpers():
    import g_gremlin.push as push_helpers
    return push_helpers


def _get_sheets_io():
    import g_gremlin.sheets_io as sheets_io
    return sheets_io


def _get_sheets_helpers():
    import g_gremlin.sheets_io as sheets_helpers
    return sheets_helpers


def _get_apps_script():
    import g_gremlin.apps_script as apps_script
    return apps_script


def _get_script_resolution_error():
    from g_gremlin.apps_script import ScriptResolutionError
    return ScriptResolutionError


def _get_license():
    """Lazy import license module to avoid loading on --help."""
    from g_gremlin.credentials import license as license_mod
    return license_mod


def _get_entitlement_gate():
    """Lazy import entitlement gate module."""
    from g_gremlin.credentials import gate as gate_mod
    return gate_mod


def _get_merge_helpers():
    import g_gremlin.merge as merge_helpers
    return merge_helpers


def _get_fuzzy_match_helpers():
    import g_gremlin.fuzzy_match as fuzzy_match_helpers
    return fuzzy_match_helpers


def _get_sfdc_helpers():
    import g_gremlin.sfdc as sfdc_helpers
    return sfdc_helpers


def _get_zendesk_helpers():
    import g_gremlin.zendesk as zendesk_helpers
    return zendesk_helpers


def _get_apify_helpers():
    import g_gremlin.apify as apify_helpers
    return apify_helpers


def _get_nimbleway_helpers():
    import g_gremlin.nimbleway as nimbleway_helpers
    return nimbleway_helpers


def _get_fg_helpers():
    import g_gremlin.foundrygraph as fg_helpers
    return fg_helpers


def _get_query_helpers():
    import g_gremlin.query as query_helpers
    return query_helpers


def _get_provenance_helpers():
    import g_gremlin.provenance as provenance_helpers
    return provenance_helpers


def _get_hubspot_helpers():
    import g_gremlin.hubspot as hubspot_helpers
    return hubspot_helpers


def _get_hubspot_profile() -> str | None:
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            return ctx.obj.get("hubspot_profile")
    except Exception:
        return None
    return None


def _get_google_profile_from_ctx() -> str | None:
    """Read --google-profile from the Click context (set by top-level cli group)."""
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            return ctx.obj.get("google_profile")
    except Exception:
        return None
    return None


def _resolve_hubspot_dev_api_key(dev_api_key: str | None) -> str:
    token = str(dev_api_key or os.getenv("G_GREMLIN_HUBSPOT_DEV_API_KEY") or "").strip()
    if not token:
        raise click.ClickException(
            "HubSpot developer API key is required. "
            "Provide --dev-api-key or set G_GREMLIN_HUBSPOT_DEV_API_KEY."
        )
    return token


def _get_hubspot_schema_helpers():
    import g_gremlin.hubspot_schema as hubspot_schema_helpers
    return hubspot_schema_helpers


def _get_hubspot_infra_helpers():
    import g_gremlin.hubspot_infra as hubspot_infra_helpers
    return hubspot_infra_helpers


def _get_hubspot_workflows_helpers():
    import g_gremlin.hubspot_workflows as hubspot_workflows_helpers
    return hubspot_workflows_helpers


def _get_hubspot_lists_helpers():
    import g_gremlin.hubspot_lists as hubspot_lists_helpers
    return hubspot_lists_helpers


def _get_hubspot_pipelines_helpers():
    import g_gremlin.hubspot_pipelines as hubspot_pipelines_helpers
    return hubspot_pipelines_helpers


def _get_hubspot_aggregate_helpers():
    import g_gremlin.hubspot_aggregate as hubspot_aggregate_helpers
    return hubspot_aggregate_helpers


def _get_hubspot_marketing_helpers():
    import g_gremlin.hubspot_marketing as hubspot_marketing_helpers
    return hubspot_marketing_helpers


def _get_hubspot_snapshot_helpers():
    import g_gremlin.hubspot_snapshot as hubspot_snapshot_helpers
    return hubspot_snapshot_helpers


def _get_hubspot_snapshot_compare_helpers():
    import g_gremlin.hubspot_snapshot_compare as hubspot_snapshot_compare_helpers
    return hubspot_snapshot_compare_helpers


def _get_hubspot_pack_helpers():
    import g_gremlin.hubspot_pack as hubspot_pack_helpers
    return hubspot_pack_helpers


def _get_hubspot_smoke_helpers():
    import g_gremlin.hubspot_smoke as hubspot_smoke_helpers
    return hubspot_smoke_helpers


def _get_hubspot_validations_helpers():
    import g_gremlin.hubspot_validations as hubspot_validations_helpers
    return hubspot_validations_helpers


def _get_hubspot_objects_helpers():
    import g_gremlin.hubspot_objects as hubspot_objects_helpers
    return hubspot_objects_helpers


def _get_dynamics_helpers():
    import g_gremlin.dynamics as dynamics_helpers
    return dynamics_helpers


def _get_dynamics_snapshot_helpers():
    import g_gremlin.dynamics_snapshot as dynamics_snapshot_helpers
    return dynamics_snapshot_helpers


def _get_dynamics_snapshot_compare_helpers():
    import g_gremlin.dynamics_snapshot_compare as dynamics_snapshot_compare_helpers
    return dynamics_snapshot_compare_helpers


def _get_dynamics_pack_helpers():
    import g_gremlin.dynamics_pack as dynamics_pack_helpers
    return dynamics_pack_helpers


def _get_dynamics_smoke_helpers():
    import g_gremlin.dynamics_smoke as dynamics_smoke_helpers
    return dynamics_smoke_helpers


def _get_dynamics_profile() -> str | None:
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            return ctx.obj.get("dynamics_profile")
    except Exception:
        return None
    return None


def _get_dynamics_environment() -> str | None:
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj and ctx.obj.get("dynamics_environment"):
            return ctx.obj.get("dynamics_environment")
    except Exception:
        return None
    env = os.getenv("G_GREMLIN_DYNAMICS_DEFAULT_ENVIRONMENT")
    if env:
        return env
    try:
        cfg = _get_dynamics_helpers().DynamicsConfig.load(profile=_get_dynamics_profile())
        if cfg.org_url:
            return cfg.org_url
    except Exception:
        return None
    return None


def _get_dynamics_pac_path() -> str | None:
    env = os.getenv("G_GREMLIN_DYNAMICS_PAC_PATH")
    return env.strip() if env else None


def _get_dynamics_pac():
    dynamics_mod = _get_dynamics_helpers()
    try:
        return dynamics_mod.PacWrapper(
            pac_path=_get_dynamics_pac_path(),
            environment=_get_dynamics_environment(),
        )
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        click.echo("Install the Power Platform CLI (pac) and ensure it's on PATH.", err=True)
        sys.exit(EXIT_USAGE)


def _get_enrich_helpers():
    import g_gremlin.enrich as enrich_helpers
    return enrich_helpers


def _get_dedup_helpers():
    import g_gremlin.dedup as dedup_helpers
    return dedup_helpers


def _get_commonroom_helpers():
    import g_gremlin.commonroom as commonroom_helpers
    return commonroom_helpers


def _get_heyreach_helpers():
    import g_gremlin.heyreach as heyreach_helpers
    return heyreach_helpers


def _get_fullenrich_helpers():
    import g_gremlin.fullenrich as fullenrich_helpers
    return fullenrich_helpers


def _get_foundrymatch_helpers():
    import g_gremlin.foundrymatch as fm_helpers
    return fm_helpers


def _resolve_foundrymatch_credentials(cred_provider, profile: str = "default") -> Optional[Dict[str, str]]:
    api_base = cred_provider.get_secret_or_none("foundrymatch", "api_base", profile)
    api_key = cred_provider.get_secret_or_none("foundrymatch", "api_key", profile)
    workspace_id = cred_provider.get_secret_or_none("foundrymatch", "workspace_id", profile)
    if not api_base or not api_key or not workspace_id:
        return None
    return {
        "api_base": api_base.value,
        "api_key": api_key.value,
        "workspace_id": workspace_id.value,
    }


def _get_outreach_helpers():
    import g_gremlin.outreach as outreach_helpers
    return outreach_helpers


def _get_zoominfo_helpers():
    import g_gremlin.zoominfo as zoominfo_helpers
    return zoominfo_helpers


def _load_auth_explain_config(integration: str) -> Mapping[str, Any]:
    if integration == "zoominfo":
        return _get_zoominfo_helpers()._load_config()
    if integration == "outreach":
        return _get_outreach_helpers()._load_config()
    return {}


def _get_amplemarket_helpers():
    import g_gremlin.amplemarket as amplemarket_helpers
    return amplemarket_helpers


def _get_google_docs_helpers():
    import g_gremlin.google_docs as google_docs_helpers
    return google_docs_helpers


def _get_google_slides_helpers():
    import g_gremlin.google_slides as google_slides_helpers
    return google_slides_helpers


def _get_pubsub_helpers():
    import g_gremlin.pubsub as pubsub_helpers
    return pubsub_helpers


def _get_docs_sink_helpers():
    import g_gremlin.docs_sink as docs_sink_helpers
    return docs_sink_helpers


def _get_validate_helpers():
    from g_gremlin import validate as validate_helpers
    return validate_helpers


def _get_sfdc_preview_helpers():
    from g_gremlin import sfdc_preview as sfdc_preview_helpers
    return sfdc_preview_helpers


def _get_sfdc_dedupe_apply_helpers():
    from g_gremlin import sfdc_dedupe_apply as sfdc_dedupe_apply_helpers
    return sfdc_dedupe_apply_helpers


def _sfdc_context(org_alias: str | None) -> Dict[str, Any]:
    alias = org_alias or _get_sfdc_helpers().get_configured_org()
    context: Dict[str, Any] = {"target": alias}
    try:
        identity = _get_sfdc_helpers().get_org_identity(org_alias=alias)
        context.update(
            {
                "org_id": identity.get("org_id"),
                "user_id": identity.get("user_id"),
                "instance_url": identity.get("instance_url"),
                "username": identity.get("username"),
                "api_version": identity.get("api_version"),
            }
        )
    except Exception as exc:
        context["error"] = str(exc)
    return context


def _print_sfdc_header(context: Dict[str, Any]) -> None:
    if context.get("error"):
        click.echo(f"Current org: unavailable ({context.get('error')})")
        return
    target = context.get("target") or "default"
    username = context.get("username") or "n/a"
    instance = context.get("instance_url") or "n/a"
    api_version = context.get("api_version") or "n/a"
    click.echo(f"Current org: {target} | {username} | {instance} | v{api_version}")


def _emit_sfdc_json(payload: Dict[str, Any], context: Dict[str, Any]) -> None:
    click.echo(json.dumps({"context": context, "result": payload}, indent=2))


def _get_sfdc_snapshot_helpers():
    from g_gremlin import sfdc_snapshot as sfdc_snapshot_helpers
    return sfdc_snapshot_helpers


def _get_sfdc_pack_helpers():
    from g_gremlin import sfdc_pack as sfdc_pack_helpers
    return sfdc_pack_helpers


def _get_sfdc_metadata_pack_helpers():
    from g_gremlin import sfdc_metadata_pack as sfdc_metadata_pack_helpers
    return sfdc_metadata_pack_helpers


def _get_sfdc_smoke_helpers():
    from g_gremlin import sfdc_smoke as sfdc_smoke_helpers
    return sfdc_smoke_helpers


def _get_sfdc_docs_helpers():
    from g_gremlin import sfdc_docs as sfdc_docs_helpers
    return sfdc_docs_helpers


def _get_graph_helpers():
    import g_gremlin.graph as graph_helpers
    return graph_helpers


def _get_lint_helpers():
    import g_gremlin.lint as lint_helpers
    return lint_helpers


def _get_repair_helpers():
    import g_gremlin.repair as repair_helpers
    return repair_helpers


def _get_transform_helpers():
    from g_gremlin import transform as transform_helpers
    return transform_helpers


def _get_profile():
    from g_gremlin.profile import compute_sheet_profile
    return compute_sheet_profile


def _get_build_impact():
    from g_gremlin.impact import build_impact
    return build_impact


def _get_print_impact_box():
    from g_gremlin.impact import print_impact_box
    return print_impact_box


def _get_update_stats():
    from g_gremlin.impact import update_stats
    return update_stats


def _get_stats_display():
    from g_gremlin.impact import get_stats_display
    return get_stats_display


def _get_stats_file():
    from g_gremlin.impact import STATS_FILE
    return STATS_FILE


def _get_justcall_helpers():
    import g_gremlin.justcall as justcall
    return justcall


def _get_recipes():
    from g_gremlin.recipes import build_account_dim as account_dim_recipe
    from g_gremlin.recipes import detect_open_renewals as detect_open_renewals_recipe
    from g_gremlin.recipes import attach_products as attach_products_recipe
    from g_gremlin.recipes import zendesk_user_stats as zendesk_user_stats_recipe
    from g_gremlin.recipes import zendesk_org_stats as zendesk_org_stats_recipe
    from g_gremlin.recipes import enrich_from_sfdc as enrich_from_sfdc_recipe
    from g_gremlin.recipes import resolve_unmatched as resolve_unmatched_recipe
    return {
        "account_dim": account_dim_recipe,
        "detect_open_renewals": detect_open_renewals_recipe,
        "attach_products": attach_products_recipe,
        "zendesk_user_stats": zendesk_user_stats_recipe,
        "zendesk_org_stats": zendesk_org_stats_recipe,
        "enrich_from_sfdc": enrich_from_sfdc_recipe,
        "resolve_unmatched": resolve_unmatched_recipe,
    }


def _get_snapshot():
    """Lazy import snapshot module for backup/restore functionality."""
    from g_gremlin import snapshot
    return snapshot


def _get_credential_provider():
    """Lazy import credential provider to avoid loading keyring on --help."""
    from g_gremlin.credentials.provider import CredentialProvider
    return CredentialProvider()


def _get_integration_registry():
    """Lazy import integration registry for auth commands."""
    from g_gremlin.credentials.models import INTEGRATION_REGISTRY
    return INTEGRATION_REGISTRY


def _format_source_label(source: str | None) -> str:
    mapping = {
        "env_var": "env var",
        "keychain": "keychain",
        "legacy_file": "legacy file",
        "cli_flag": "cli flag",
        "plugin": "plugin",
        "saas": "saas",
    }
    if not source:
        return "unknown"
    return mapping.get(source, source)


def _format_duration(seconds: float) -> str:
    seconds = int(max(seconds, 0))
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    if hours and minutes:
        return f"{hours}h {minutes}m"
    if hours:
        return f"{hours}h"
    return f"{minutes}m"


def _overall_status_from_checks(checks: List[CapabilityCheck], identity_ok: bool = True) -> str:
    if not identity_ok:
        return "fail"
    statuses = [c.status for c in checks]
    if "error" in statuses:
        return "fail"
    if "denied" in statuses:
        return "degraded"
    return "ok"


def _format_hubspot_scope_error(required_scopes: List[str]) -> str:
    if required_scopes:
        scopes = ", ".join(required_scopes)
        return (
            f"Missing required scopes: {scopes}. "
            "Run g-gremlin hubspot doctor --json and g-gremlin hubspot setup --pack <dir> or --full-beta."
        )
    return (
        "Missing required scopes for this operation. "
        "Run g-gremlin hubspot doctor --json and g-gremlin hubspot setup --pack <dir> or --full-beta."
    )


_HUBSPOT_WHERE_OPERATOR_MAP = {
    "=": "EQ",
    "!=": "NEQ",
    ">": "GT",
    ">=": "GTE",
    "<": "LT",
    "<=": "LTE",
}


def _parse_hubspot_where_clauses(
    clauses: Iterable[str],
    *,
    property_definitions: Mapping[str, Mapping[str, Any]] | None = None,
) -> List[Dict[str, str]]:
    filters: List[Dict[str, str]] = []
    for clause in clauses:
        prop, raw_operator, raw_value = _split_hubspot_where_clause(clause)
        property_definition = None
        if property_definitions:
            property_definition = property_definitions.get(prop) or property_definitions.get(prop.lower())
        filters.append(
            {
                "propertyName": prop,
                "operator": _HUBSPOT_WHERE_OPERATOR_MAP[raw_operator],
                "value": _coerce_hubspot_filter_value(
                    property_name=prop,
                    raw_value=raw_value,
                    property_definition=property_definition,
                ),
            }
        )
    return filters


def _split_hubspot_where_clause(clause: str) -> tuple[str, str, str]:
    raw_clause = str(clause or "").strip()
    for token in (">=", "<=", "!=", ">", "<", "="):
        if token not in raw_clause:
            continue
        prop, value = raw_clause.split(token, 1)
        prop = prop.strip()
        value = value.strip()
        if not prop or value == "":
            break
        if any(symbol in prop for symbol in ("!", ">", "<", "=", "~")):
            break
        if any(fragment in value for fragment in (">=", "<=", "!=", "~")):
            break
        if token == "=" and "=" in value:
            break
        if token in {">", "<"} and any(symbol in value for symbol in (">", "<", "=")):
            break
        return prop, token, value
    raise click.ClickException(
        f"Invalid --where '{clause}'. Use property=value, property!=value, property>=value, property<=value, property>value, or property<value."
    )


def _build_hubspot_property_definition_map(client: Any, object_type: str) -> Dict[str, Dict[str, Any]]:
    getter = getattr(client, "get_properties", None)
    if not callable(getter):
        return {}
    try:
        properties = getter(object_type)
    except Exception:
        return {}
    definitions: Dict[str, Dict[str, Any]] = {}
    for item in properties or []:
        name = str(item.get("name") or "").strip()
        if not name:
            continue
        definitions[name] = dict(item)
        definitions[name.lower()] = dict(item)
    return definitions


def _coerce_hubspot_filter_value(
    *,
    property_name: str,
    raw_value: str,
    property_definition: Mapping[str, Any] | None = None,
) -> str:
    token = str(raw_value or "").strip()
    if not property_definition:
        return token

    property_type = str(property_definition.get("type") or "").strip().lower()
    if property_type == "date":
        return _coerce_hubspot_date_filter_value(property_name, token, date_only=True)
    if property_type == "datetime":
        return _coerce_hubspot_date_filter_value(property_name, token, date_only=False)
    if property_type == "number":
        return _coerce_hubspot_number_filter_value(property_name, token)
    if property_type == "bool":
        return _coerce_hubspot_bool_filter_value(property_name, token)
    return token


def _coerce_hubspot_number_filter_value(property_name: str, raw_value: str) -> str:
    token = str(raw_value or "").strip()
    try:
        float(token)
    except ValueError as exc:
        raise click.ClickException(
            f"Invalid numeric filter value for '{property_name}': '{raw_value}'."
        ) from exc
    return token


def _coerce_hubspot_bool_filter_value(property_name: str, raw_value: str) -> str:
    token = str(raw_value or "").strip().lower()
    mapping = {
        "true": "true",
        "false": "false",
        "1": "true",
        "0": "false",
        "yes": "true",
        "no": "false",
    }
    normalized = mapping.get(token)
    if normalized is None:
        raise click.ClickException(
            f"Invalid boolean filter value for '{property_name}': '{raw_value}'. Use true/false."
        )
    return normalized


def _coerce_hubspot_date_filter_value(property_name: str, raw_value: str, *, date_only: bool) -> str:
    token = str(raw_value or "").strip()
    if token.isdigit():
        return token

    try:
        if date_only:
            if re.fullmatch(r"\d{4}-\d{2}-\d{2}", token):
                parsed = datetime.fromisoformat(f"{token}T00:00:00+00:00")
            else:
                parsed = _parse_hubspot_datetime_literal(token)
                parsed = datetime(parsed.year, parsed.month, parsed.day, tzinfo=timezone.utc)
        else:
            parsed = _parse_hubspot_datetime_literal(token)
    except ValueError as exc:
        raise click.ClickException(
            f"Invalid date filter value for '{property_name}': '{raw_value}'. Use YYYY-MM-DD, ISO 8601, or epoch milliseconds."
        ) from exc

    return str(int(parsed.timestamp() * 1000))


def _parse_hubspot_datetime_literal(raw_value: str) -> datetime:
    token = str(raw_value or "").strip()
    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", token):
        return datetime.fromisoformat(f"{token}T00:00:00+00:00")
    normalized = token[:-1] + "+00:00" if token.endswith("Z") else token
    parsed = datetime.fromisoformat(normalized)
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _expand_hubspot_property_options(values: Iterable[str]) -> List[str]:
    expanded: List[str] = []
    seen: set[str] = set()
    for raw in values:
        for token in [item.strip() for item in str(raw or "").split(",") if item.strip()]:
            if token not in seen:
                seen.add(token)
                expanded.append(token)
    return expanded


_HUBSPOT_ASSOCIATION_ALIASES: Dict[str, str] = {
    "contact": "contacts",
    "contacts": "contacts",
    "company": "companies",
    "companies": "companies",
    "deal": "deals",
    "deals": "deals",
    "ticket": "tickets",
    "tickets": "tickets",
    "task": "tasks",
    "tasks": "tasks",
    "email": "emails",
    "emails": "emails",
    "call": "calls",
    "calls": "calls",
    "meeting": "meetings",
    "meetings": "meetings",
    "note": "notes",
    "notes": "notes",
}


def _parse_hubspot_association_targets(raw_value: str | None) -> List[str]:
    if not raw_value:
        return []
    targets: List[str] = []
    seen: set[str] = set()
    for token in [part.strip().lower() for part in raw_value.split(",") if part.strip()]:
        normalized = _HUBSPOT_ASSOCIATION_ALIASES.get(token)
        if not normalized:
            allowed = ", ".join(sorted(set(_HUBSPOT_ASSOCIATION_ALIASES.values())))
            raise click.ClickException(
                f"Unsupported --associations value '{token}'. Supported targets: {allowed}."
            )
        if normalized not in seen:
            seen.add(normalized)
            targets.append(normalized)
    return targets


def _hubspot_object_id_column(object_type: str) -> str:
    token = str(object_type or "").strip().lower()
    if token.endswith("ies") and len(token) > 3:
        token = token[:-3] + "y"
    elif token.endswith("s") and len(token) > 1:
        token = token[:-1]
    return f"{token}_id"


def _load_hubspot_schema_payloads() -> List[Dict[str, Any]]:
    schema_mod = _get_hubspot_schema_helpers()
    base_dir = schema_mod.DEFAULT_CACHE_DIR
    payloads: List[Dict[str, Any]] = []
    if not base_dir.exists():
        return payloads
    for path in base_dir.glob("*/schema.json"):
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
            payloads.append(payload)
        except Exception:
            continue
    return payloads


def pacing_option(f):
    return click.option(
        "--safe/--turbo",
        "safe_mode",
        default=True,
        show_default=True,
        help="--safe: conservative batching (default). --turbo: max batch, minimal delay.",
    )(f)


# ---------------------------------------------------------------------------
# License check helper for licensed commands
# ---------------------------------------------------------------------------
def _admin_bypass_enabled() -> bool:
    # Admin bypass is intentionally disabled in production and source builds.
    return False


def _check_license() -> None:
    """
    Check for valid FoundryOps license before running a licensed command.

    Call this at the start of commands that require a license (enrich, sync, etc.).
    Prints warnings for trial expiration and exits if no valid license.
    """
    license_mod = _get_license()
    result = license_mod.check_license_unified()

    if not result.valid:
        license_mod.print_license_error(result)
        sys.exit(EXIT_ISSUES)

    # Print any warnings (trial expiring soon, etc.)
    if not (result.plan == "local_free" and result.source in {"local_free", "license_v2_invalid"}):
        license_mod.print_license_warnings(result)


def _report_usage(
    command: str,
    rows_processed: int = 0,
    fg_lookups: int = 0,
    docs_published: int = 0,
) -> None:
    """
    Report usage metrics to FoundryOps for trial value tracking.

    Call this after successful command execution. Non-blocking, fire-and-forget.
    """
    try:
        license_mod = _get_license()
        license_mod.report_usage(
            command=command,
            rows_processed=rows_processed,
            fg_lookups=fg_lookups,
            docs_published=docs_published,
        )
    except Exception:
        # Never fail the command due to usage reporting
        pass


# ---------------------------------------------------------------------------
# Apollo OAuth helpers (FoundryMatch backend)
# ---------------------------------------------------------------------------
APOLLO_DEFAULT_API_BASE = "https://api.foundryops.io"


def _apollo_api_base(api_base: str | None = None) -> str:
    base = (
        api_base
        or os.getenv("FOUNDRYOPS_API_URL")
        or os.getenv("FOUNDRYMATCH_API_URL")
        or APOLLO_DEFAULT_API_BASE
    )
    return base.rstrip("/")


def _maybe_dump_auth_resolution(resolution) -> None:
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj and ctx.obj.get("debug_auth"):
            click.echo(format_auth_resolution(resolution, json_output=True), err=True)
    except Exception:
        pass


def _resolve_bridge_auth_resolution(
    *,
    integration: str,
    auth_token: str | None = None,
    api_key: str | None = None,
    workspace_id: str | None = None,
    api_base: str | None = None,
    profile: str = "default",
    dev: bool = False,
    config: Mapping[str, Any] | None = None,
):
    resolution = resolve_bridge_auth(
        integration=integration,
        auth_token=auth_token,
        api_key=api_key,
        workspace_id=workspace_id,
        api_base=api_base,
        profile=profile,
        allow_dev=dev,
        credential_provider=_get_credential_provider(),
        config=config,
    )
    _maybe_dump_auth_resolution(resolution)
    return resolution


def _resolve_apollo_auth_headers(
    auth_token: str | None,
    api_key: str | None,
    dev: bool = False,
) -> dict[str, str]:
    return _resolve_bridge_auth_resolution(
        integration="apollo",
        auth_token=auth_token,
        api_key=api_key,
        dev=dev,
    ).headers


# ---------------------------------------------------------------------------
# Restore-on-error helper for backup/rollback functionality
# ---------------------------------------------------------------------------
_restore_attempted = False  # Idempotence: only one restore per failure


def _handle_restore_on_error(
    error: Exception,
    snapshot_result,
    auto_rollback: bool,
    json_summary: bool = False,
) -> None:
    """
    Handle restore-on-error after a failed mutation.

    Args:
        error: The exception that occurred
        snapshot_result: SnapshotResult from create_snapshot (or None)
        auto_rollback: If True, auto-restore without prompting
        json_summary: If True, emit JSON output
    """
    global _restore_attempted
    if _restore_attempted:
        return  # Don't cascade restores
    if not snapshot_result or not snapshot_result.success:
        return  # No valid snapshot to restore from

    snapshot_mod = _get_snapshot()
    snapshot_path = snapshot_result.snapshot_path

    # Determine if we should auto-restore
    is_interactive = sys.stdin.isatty()

    if auto_rollback or not is_interactive:
        # Non-interactive or explicit auto-rollback: restore automatically
        click.echo(f"\nOperation failed: {error}")
        click.echo(f"Auto-restoring from backup: {snapshot_path}")

        _restore_attempted = True
        restore_result = snapshot_mod.restore_snapshot(
            snapshot_path=snapshot_path,
            force=True,  # Skip concurrency check since we just created this
            create_backup=False,  # Don't create another backup during restore
        )

        if restore_result.success:
            click.echo(f"Restored {restore_result.tabs_restored} tab(s), {restore_result.cells_restored} cell(s).")
        else:
            click.echo(f"Restore failed: {'; '.join(restore_result.errors)}")

        if json_summary:
            result = {
                "status": "rollback_completed" if restore_result.success else "rollback_failed",
                "original_error": str(error),
                "restored_from": str(snapshot_path),
                "tabs_restored": restore_result.tabs_restored if restore_result.success else 0,
            }
            click.echo(json.dumps(result, indent=2))
    else:
        # Interactive mode: prompt for restore
        click.echo(f"\nOperation failed: {error}")
        click.echo("\nA backup was created before this operation:")
        click.echo(f"  Snapshot: {snapshot_path}")
        restore_cmd = f"g-gremlin snapshot restore --name {snapshot_path.stem.rsplit('_', 2)[0]}"
        click.echo(f"  Restore command: {restore_cmd}")

        if click.confirm("\nRestore from backup?", default=False):
            _restore_attempted = True
            restore_result = snapshot_mod.restore_snapshot(
                snapshot_path=snapshot_path,
                force=True,
                create_backup=False,
            )
            if restore_result.success:
                click.echo(f"Restored {restore_result.tabs_restored} tab(s).")
            else:
                click.echo(f"Restore failed: {'; '.join(restore_result.errors)}")


def _safe_echo(text: str, **kwargs) -> None:
    """
    Echo text to console, handling Unicode encoding errors on Windows.
    Replaces unencodable characters with '?' to avoid UnicodeEncodeError.
    """
    try:
        click.echo(text, **kwargs)
    except UnicodeEncodeError:
        # Fall back to ASCII-safe version
        safe_text = text.encode(sys.stdout.encoding or "utf-8", errors="replace").decode(
            sys.stdout.encoding or "utf-8", errors="replace"
        )
        click.echo(safe_text, **kwargs)


def _parse_iso8601(timestamp: str | None) -> datetime | None:
    """Parse an ISO 8601 timestamp into UTC-aware datetime."""
    if not timestamp:
        return None
    ts = timestamp.strip()
    if not ts:
        return None
    try:
        # Handle trailing Z by replacing with +00:00
        if ts.endswith("Z"):
            ts = ts[:-1] + "+00:00"
        return datetime.fromisoformat(ts).astimezone(timezone.utc)
    except Exception:
        return None


def _emit_json_payload(payload: str, output_path: Path | None) -> None:
    """Write JSON payload either to stdout or to a file (with '-' meaning stdout)."""
    if output_path and str(output_path) != "-":
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(payload, encoding="utf-8")
    else:
        click.echo(payload)


def _write_csv(output_path: Path, rows: List[Dict[str, Any]]) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    if not rows:
        output_path.write_text("", encoding="utf-8")
        return
    fieldnames: List[str] = []
    seen: set[str] = set()
    for row in rows:
        for key in row.keys():
            if key not in seen:
                seen.add(key)
                fieldnames.append(key)
    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            writer.writerow(row)


def _write_csv_with_headers(
    output_path: Path,
    rows: List[Dict[str, Any]],
    headers: List[str],
) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    if rows:
        _write_csv(output_path, rows)
        return
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=headers)
        writer.writeheader()


def _should_emit_json(output_format: str | None, json_flag: bool, json_summary: bool) -> bool:
    """Determine if a command should emit JSON given format flags."""
    return json_summary or json_flag or (output_format or "").lower() == "json"


def _read_input_source(input_path: Path | None) -> str:
    """
    Read input from a file or stdin (returns empty string if no stdin piped).
    """
    if input_path and str(input_path) != "-":
        return input_path.read_text(encoding="utf-8")
    if sys.stdin.isatty():
        return ""
    return sys.stdin.read()


def _normalize_exit_code(code: Any) -> int:
    if code is None:
        return EXIT_SUCCESS
    if isinstance(code, int):
        return code
    return EXIT_USAGE


def _get_or_create_session_id() -> str:
    session_id = None
    ppid = os.getppid()
    path = Path(tempfile.gettempdir()) / f"g_gremlin_session_{ppid}"
    if path.exists():
        try:
            session_id = path.read_text(encoding="utf-8").strip()
        except Exception:
            session_id = None
    if session_id:
        return session_id
    session_id = str(uuid.uuid4())
    try:
        path.write_text(session_id, encoding="utf-8")
    except Exception:
        pass
    return session_id


def _resolve_command_path(ctx: click.Context) -> str:
    if ctx.obj and ctx.obj.get("command_path"):
        return str(ctx.obj.get("command_path"))
    if sys.argv:
        tokens: list[str] = []
        for arg in sys.argv[1:]:
            if arg.startswith("-"):
                continue
            tokens.append(arg)
            if len(tokens) >= 3:
                break
        if tokens:
            return " ".join([sys.argv[0]] + tokens)
        return sys.argv[0]
    return "g-gremlin"


def _extract_command_tokens_for_gate(ctx: click.Context) -> list[str]:
    """
    Extract potential command path tokens from argv for pre-exec gate resolution.

    This intentionally ignores option flags and treats only positional command
    names at the start of invocation as path candidates.

    Always prefer sys.argv — ctx.args often has the first subcommand consumed
    by Click's Group parsing, which causes nested commands (e.g. ``hubspot upsert``)
    to be unresolvable and bypass the gate.
    """
    # Click 8 exposes remaining "help" tokens in _protected_args; in Click 9
    # this is folded into ctx.args and protected_args is removed.
    protected = list(getattr(ctx, "_protected_args", []) or [])
    if protected and protected[0] == "help":
        args = protected + list(ctx.args or [])
    else:
        args = list(sys.argv[1:]) or list(ctx.args or [])
    tokens: list[str] = []
    for arg in args:
        if not arg:
            continue
        if arg == "--":
            break
        if arg.startswith("-"):
            continue
        tokens.append(arg)
    return tokens


def _resolve_leaf_command_for_gate(ctx: click.Context) -> tuple[str | None, click.Command | None]:
    tokens = _extract_command_tokens_for_gate(ctx)
    if not tokens:
        return None, None

    root = ctx.command
    if not isinstance(root, click.Group):
        return None, None

    path_parts: list[str] = []
    current_group: click.Group = root
    current_ctx: click.Context = ctx
    leaf: click.Command | None = None

    for token in tokens:
        cmd = current_group.get_command(current_ctx, token)
        if cmd is None:
            break
        path_parts.append(token)
        leaf = cmd
        if isinstance(cmd, click.Group):
            current_ctx = click.Context(cmd, info_name=token, parent=current_ctx)
            current_group = cmd
            continue
        break

    if not path_parts:
        return None, None
    return command_metadata.build_command_id(path_parts), leaf


def _is_meta_command_for_gate(cmd_id: str | None) -> bool:
    if not cmd_id:
        return True
    root = cmd_id.split(":", 1)[0]
    if root in {"help", "auth", "license"}:
        return True
    if cmd_id.startswith("help:"):
        return True
    # Allow --help / -h on any command without hitting the entitlement gate.
    # Click handles --help during invoke(); the gate fires first, so without
    # this check, gated commands block even help discovery.
    if "--help" in sys.argv or "-h" in sys.argv:
        return True
    return False


def _sanitize_args(args: list[str]) -> list[str]:
    return [_sanitize_log_line(arg) for arg in args]


class _CliRunContext:
    def __init__(self, ctx: click.Context) -> None:
        self.ctx = ctx
        self.start_time = None

    def __enter__(self) -> "_CliRunContext":
        self.start_time = time.monotonic()
        self.ctx.ensure_object(dict)
        if not self.ctx.obj.get("correlation_id"):
            self.ctx.obj["correlation_id"] = str(uuid.uuid4())
        if not self.ctx.obj.get("session_id"):
            self.ctx.obj["session_id"] = _get_or_create_session_id()
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        exit_code = EXIT_SUCCESS
        if exc_type is SystemExit:
            exit_code = _normalize_exit_code(getattr(exc, "code", None))
        elif exc_type is KeyboardInterrupt:
            exit_code = 130
        elif exc_type is click.UsageError:
            exit_code = EXIT_USAGE
        elif exc_type is click.ClickException:
            exit_code = EXIT_ISSUES
        elif exc_type is not None:
            exit_code = EXIT_REMOTE_ERROR

        duration_ms = 0
        if self.start_time is not None:
            duration_ms = int((time.monotonic() - self.start_time) * 1000)

        status = "success" if exit_code == 0 else "error"
        correlation_id = str(self.ctx.obj.get("correlation_id") or "")
        command = _resolve_command_path(self.ctx)
        args_redacted = _sanitize_args(sys.argv[1:])
        agentic_result = self.ctx.obj.get("last_agentic_result")
        error_fingerprint = support_log.compute_error_fingerprint(agentic_result, exc)

        try:
            entry = support_log.CommandLogEntry(
                timestamp=datetime.now(timezone.utc).isoformat(),
                command=command,
                args_redacted=args_redacted,
                exit_code=exit_code,
                correlation_id=correlation_id,
                status=status,
                duration_ms=duration_ms,
                error_fingerprint=error_fingerprint,
                agentic_result=agentic_result,
                version=__version__,
            )
            support_log.append_entry(entry)
        except Exception:
            pass

        telemetry_status = "success" if exit_code == 0 else "failed"
        thread = cli_telemetry.emit_cli_event(
            command=command,
            status=telemetry_status,
            duration_ms=duration_ms,
            correlation_id=correlation_id,
            exit_code=exit_code,
            attrs={
                "exit_code": exit_code,
                "error_fingerprint": error_fingerprint,
            },
        )
        if thread is not None:
            thread.join(0.2)

        if exc_type in (SystemExit, KeyboardInterrupt):
            self.ctx.obj["exit_code"] = exit_code
            return True

        return False


class _LazyCommandMap(Mapping[str, click.Command]):
    def __init__(self, loader: Callable[[], click.Command]) -> None:
        self._loader = loader

    def _commands(self) -> Dict[str, click.Command]:
        cmd = self._loader()
        if isinstance(cmd, click.Group):
            return cmd.commands
        return {}

    def __getitem__(self, key: str) -> click.Command:
        return self._commands()[key]

    def __iter__(self):
        return iter(self._commands())

    def __len__(self) -> int:
        return len(self._commands())

    def get(self, key: str, default=None):
        return self._commands().get(key, default)

    def items(self):
        return self._commands().items()

    def keys(self):
        return self._commands().keys()

    def values(self):
        return self._commands().values()


class LazyGroup(click.Group):
    def __init__(self, import_path: str, name: str, help: str | None = None) -> None:
        super().__init__(name=name, help=help)
        self._import_path = import_path
        self._loaded: click.Group | None = None
        self._load_error: click.ClickException | None = None
        # Provide a proxy for help/introspection that loads on access.
        self.commands = _LazyCommandMap(self._load_group)

    def _load_group(self) -> click.Group:
        if self._loaded is not None:
            return self._loaded
        if self._load_error is not None:
            raise self._load_error
        try:
            module_path, attr = self._import_path.rsplit(":", 1)
        except ValueError:
            err = click.ClickException(
                f"Invalid lazy import path '{self._import_path}'. Use module:attr."
            )
            self._load_error = err
            raise err
        try:
            module = importlib.import_module(module_path)
            cmd = getattr(module, attr)
        except Exception as exc:
            err = click.ClickException(f"Failed to load command '{self.name}': {exc}")
            self._load_error = err
            raise err
        if not isinstance(cmd, click.Group):
            err = click.ClickException(
                f"Lazy command '{self.name}' did not resolve to a Click group."
            )
            self._load_error = err
            raise err
        self._loaded = cmd
        return cmd

    def list_commands(self, ctx: click.Context):
        return self._load_group().list_commands(ctx)

    def get_command(self, ctx: click.Context, name: str):
        return self._load_group().get_command(ctx, name)

    def invoke(self, ctx: click.Context):
        return self._load_group().invoke(ctx)

    def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
        return self._load_group().format_help(ctx, formatter)


class GremlinCLI(click.Group):
    _CMD_COL_WIDTH = 22
    _INDENT = 4

    def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
        formatter.write(
            "\n  Impact: (R)=read-only  (W)=local/Sheet writes  (X)=external side effects  (?)=unknown\n"
        )
        formatter.write("  Tip: for namespaces or '?', run: g-gremlin help <cmd> --json (then inspect subcommands)\n")

        all_commands = list(self.list_commands(ctx))
        groups, uncategorized = command_metadata.normalize_command_groups(all_commands)
        printed: set[str] = set()

        for group_name, cmd_names in groups.items():
            formatter.write(f"\n  {group_name}:\n")
            for cmd_name in cmd_names:
                printed.add(cmd_name)
                cmd = self.get_command(ctx, cmd_name)
                desc = help_introspection._extract_short_description(cmd.help or "") if cmd else ""
                group_label = command_metadata.find_group_for_command(cmd_name)
                safety = command_metadata.resolve_safety(cmd, cmd_name, group_label) if cmd else {}
                if isinstance(cmd, click.Group):
                    safety = command_metadata.resolve_safety_for_schema(cmd, cmd_name, group_label, True)
                impact = safety.get("impact", "?")
                impact_tag = f"[{impact}] "
                prefix = " " * self._INDENT + f"{cmd_name:<{self._CMD_COL_WIDTH}s}{impact_tag}"
                desc_width = max(20, formatter.width - len(prefix))
                if not desc:
                    formatter.write(f"{prefix}\n")
                    continue
                lines = textwrap.wrap(desc, width=desc_width) or [desc]
                formatter.write(f"{prefix}{lines[0]}\n")
                continuation_indent = " " * (len(prefix))
                for line in lines[1:]:
                    formatter.write(f"{continuation_indent}{line}\n")

        if uncategorized:
            formatter.write("\n  Other (uncategorized):\n")
            for cmd_name in uncategorized:
                if cmd_name in printed:
                    continue
                cmd = self.get_command(ctx, cmd_name)
                desc = help_introspection._extract_short_description(cmd.help or "") if cmd else ""
                safety = command_metadata.resolve_safety(cmd, cmd_name, None) if cmd else {}
                if isinstance(cmd, click.Group):
                    safety = command_metadata.resolve_safety_for_schema(cmd, cmd_name, None, True)
                impact = safety.get("impact", "?")
                impact_tag = f"[{impact}] "
                prefix = " " * self._INDENT + f"{cmd_name:<{self._CMD_COL_WIDTH}s}{impact_tag}"
                desc_width = max(20, formatter.width - len(prefix))
                if not desc:
                    formatter.write(f"{prefix}\n")
                    continue
                lines = textwrap.wrap(desc, width=desc_width) or [desc]
                formatter.write(f"{prefix}{lines[0]}\n")
                continuation_indent = " " * (len(prefix))
                for line in lines[1:]:
                    formatter.write(f"{continuation_indent}{line}\n")

    def invoke(self, ctx: click.Context) -> Any:
        ctx.ensure_object(dict)
        update_state = None
        try:
            update_state = update_check.prepare_update_state(__version__, sys.argv)
        except Exception:
            update_state = None
        with _CliRunContext(ctx):
            cmd_id, leaf_cmd = _resolve_leaf_command_for_gate(ctx)
            if cmd_id and not _is_meta_command_for_gate(cmd_id):
                license_mod = _get_license()
                license_result = license_mod.check_license_unified()
                if leaf_cmd is not None:
                    root_command = cmd_id.split(":", 1)[0]
                    group_name = command_metadata.find_group_for_command(root_command)
                    safety = command_metadata.resolve_safety_for_schema(
                        leaf_cmd,
                        cmd_id,
                        group_name,
                        isinstance(leaf_cmd, click.Group),
                    )
                else:
                    safety = {"required_capability": None, "meters": [], "fallback_mode": "none"}
                gate_mod = _get_entitlement_gate()
                gate_mod.entitlement_gate(cmd_id, safety, license_result)
                ctx.obj["license_result"] = license_result
                ctx.obj.setdefault("command_path", f"g-gremlin {cmd_id.replace(':', ' ')}")
            result = super().invoke(ctx)
        if update_state and update_state.message:
            _safe_echo(update_state.message)
        exit_code = ctx.obj.pop("exit_code", None)
        if exit_code is not None:
            ctx.exit(exit_code)
        return result


def _support_api_base() -> str:
    """Return base URL for support/telemetry endpoints (FastAPI backend)."""
    base = os.getenv("FOUNDRYOPS_SUPPORT_API_URL") or os.getenv(
        "FOUNDRYGRAPH_API_BASE",
        "https://fuzzy-matcher-api-5pkec6diha-uc.a.run.app",
    )
    return base.rstrip("/")


def _support_auth_headers() -> dict[str, str]:
    try:
        license_mod = _get_license()
        license_token = license_mod.get_license_token()
    except Exception:
        license_token = None
    if not license_token:
        return {}
    return {"Authorization": f"Bearer {license_token}"}


def _compute_idempotency_key(data: Any, explicit: str | None, field: str | None) -> str:
    """Choose an idempotency key from explicit, a data field, or fallbacks."""
    if explicit:
        return explicit
    if field and isinstance(data, dict) and field in data:
        return str(data.get(field))
    if isinstance(data, dict):
        for candidate in ("plan_hash", "correlation_id", "run_id"):
            if candidate in data and data[candidate]:
                return str(data[candidate])
    return str(uuid.uuid4())


def _hash_file(path: Path) -> str:
    """Return SHA256 hash of a file path (empty string if missing)."""
    if not path.exists() or not path.is_file():
        return ""
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(8192), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _compute_plan_hash(config_path: Path, plan, extra_fields: dict[str, Any] | None = None) -> str:
    """
    Compute a deterministic plan hash from config + CSV inputs + CLI version.
    """
    digest = hashlib.sha256()
    digest.update(__version__.encode("utf-8"))
    digest.update(str(config_path.resolve()).encode("utf-8"))
    if config_path.exists():
        try:
            digest.update(config_path.read_bytes())
        except Exception:
            pass

    for tab in sorted(getattr(plan, "tabs", []), key=lambda t: t.tab_name):
        csv_path = Path(tab.csv_path)
        digest.update(tab.tab_name.encode("utf-8"))
        digest.update(str(csv_path).encode("utf-8"))
        file_hash = _hash_file(csv_path)
        digest.update(file_hash.encode("utf-8"))

    if extra_fields:
        for key in sorted(extra_fields.keys()):
            digest.update(str(key).encode("utf-8"))
            digest.update(str(extra_fields[key]).encode("utf-8"))

    return digest.hexdigest()


@click.group(cls=GremlinCLI, context_settings={"max_content_width": 120})
@click.version_option(__version__, prog_name="g-gremlin")
@click.option("--google-profile", envvar="G_GREMLIN_GOOGLE_PROFILE", default=None, hidden=True, help="Google account profile to use.")
@click.option("--debug-auth/--no-debug-auth", default=False, help="Dump redacted auth resolution details to stderr.")
@click.pass_context
def cli(ctx: click.Context, google_profile: str | None, debug_auth: bool) -> None:
    """
    Batch data CLI for Google Sheets, CSVs, CRMs, enrichment, and sales sequencing.

    \b
    What It Does:
      - Pull/inspect/repair Google Sheets safely with CSV diffs
      - Enrich/route data with CRM + enrichment integrations
      - Automate workflows with dry-run previews + apply/yes execution gates
      - HubSpot infra-as-code: props/lists/pipelines/workflows + packs + snapshots

    \b
    AI Quick Start:
      1) g-gremlin help --json --full        # full command index in one call
      2) If CRM/enrichment/sequencer/docs/sink, or on exit 2/3:
         g-gremlin doctor --live --format json && g-gremlin auth status
      3) g-gremlin help <command> [subcommand...] --json  # deep-dive (nested OK)
      4) If auth is unclear: g-gremlin auth explain <integration> --surface bridge|runtime --json
      5) Prefer --dry-run when supported     # preview changes
      6) Use --apply/--yes only when instructed (some commands execute by default)

    \b
    State & Auth:
      - State lives in .g_gremlin/ (created after pull/sync)
      - Auth: g-gremlin auth status; creds in ~/.g_gremlin/ or env vars
      - Browser OAuth bootstrap for ZoomInfo/Outreach/Apollo runs through FoundryOps bridge auth
      - Runtime credential selection can be inspected with: g-gremlin auth explain <integration> --surface runtime --json

    \b
    Common Workflows:
      1) Pull -> transform -> push:
         g-gremlin pull SHEET_ID --range "Tab!A1:Z" --output data.csv
         g-gremlin transform --input data.csv --output data_out.csv --rename Old=New
         g-gremlin push --dry-run
         g-gremlin push        # executes (writes)
      2) Enrich contacts via FullEnrich:
         g-gremlin fullenrich enrich --csv leads.csv --dry-run
         g-gremlin fullenrich enrich --csv leads.csv --apply
      3) Push to sales sequence (Outreach, NOT idempotent):
         g-gremlin outreach enroll --csv enriched.csv --sequence-id SEQ_123 --dry-run
         g-gremlin outreach enroll --csv enriched.csv --sequence-id SEQ_123 --apply
      4) Autopilot playbooks (CRM docs):
         g-gremlin autopilot play list
         g-gremlin autopilot play run document_cpq
         See docs/PLAYBOOKS.md
      5) Submit support ticket (default dry-run):
         g-gremlin support bug --summary "..." --description "..." --apply
      6) HubSpot snap -> plan -> deploy:
         g-gremlin hubspot snapshot --out snapshots/baseline
         g-gremlin hubspot pack init partner_deal_reg
         g-gremlin hubspot pack plan partner_deal_reg --json
         g-gremlin hubspot pack apply partner_deal_reg --yes
         g-gremlin hubspot compare-snapshots snapshots/baseline partner_deal_reg/artifacts/<ts>/after_snapshot

    \b
    Safety & Retries:
      - Prefer --dry-run when supported; use --apply/--yes where required
      - Some commands execute by default (see per-command help)
      - Do not run destructive commands without a dry-run or explicit user instruction
      - Most commands are idempotent (safe to retry); exceptions noted in per-command help

    \b
    Getting Help:
      g-gremlin help <command>                    # Detailed help with examples
      g-gremlin help <cmd> [subcmd...] --json    # Machine-readable schema (nested OK)
      g-gremlin help --json --full               # Full command index (one call)
      g-gremlin help --json                      # List all commands as JSON

    \b
    For AI Agents:
      - Use 'g-gremlin help <cmd> --json' for command introspection
      - Read the `auth` section in help JSON before attempting networked commands
      - Use 'g-gremlin auth explain <integration> --surface bridge|runtime --json' when auth behavior is unclear
      - Exit codes: 0=success, 1=lint/issues, 2=usage/missing prereqs, 3=remote/API failure
      - See docs/ai_agent_conventions.md for full conventions
    """
    ctx.ensure_object(dict)
    if google_profile:
        ctx.obj["google_profile"] = google_profile
    ctx.obj["debug_auth"] = debug_auth


# =============================================================================
# Embedded Documentation (for AI agents and users without README access)
# =============================================================================

_DOCS = {
    "overview": """
g-gremlin CLI - Batch data CLI for Sheets, CSV, CRM, enrichment, sequencing
============================================================================

A command-line tool for batch Google Sheets operations, CSV manipulation,
and external service integrations (Salesforce, Zendesk, Apify, Nimbleway).

QUICK START
-----------
  # Pull data from a Google Sheet
  g-gremlin pull SPREADSHEET_ID --range "Sheet1!A1:Z" --output data.csv

  # Write CSV back to a sheet
  g-gremlin write SPREADSHEET_ID --tab "Output" --csv data.csv

  # Full workflow (pull -> enrich -> transform -> write)
  g-gremlin chain --spreadsheet-id ID --pull-range "Data!A:Z" --write-tab "Output"

COMMAND GROUPS
--------------
  Sheets Core:      pull, write, push, copy-tab, chain, sync
  Data Transform:   diff, merge, fuzzy-match, transform, filter, validate, dedup, query
  Sheet Analysis:   inspect, inspect-formulas, lint, check-bands, generate-lag, find-refs, schema-validate
  Sheet Repair:     repair, rescue, style, formula, names
  Enrichment:       enrich, enrich-doctor, fullenrich, fg, apollo, apify, nimbleway
  CRM Integrations: sfdc, hubspot, dynamics, zendesk, commonroom
  Sales Engagement: outreach, sequencer, heyreach, amplemarket, justcall
  Docs & Sinks:     docs, gdocs, gslides, sink, pubsub, notify
  Ops & Auth:       doctor, safety, auth, support, snapshot, recipe, script, google, provenance, stats
  Autopilot:        autopilot, scout

SAFETY & MACHINE OUTPUT
-----------------------
  - g-gremlin doctor [--live] [--sfdc] --format json|text
  - g-gremlin safety  # backups, limits, exit codes, output flags
  - g-gremlin notify webhook URL --apply  # send job-level notifications (default: dry-run)
  - Machine output: --format json (alias --json) + --output PATH; --json-summary is a shortcut on mutating commands.
  - Exit codes: 0 success, 1 issues, 2 usage/missing prereqs, 3 remote/API failure.

TOP RECIPES
-----------
  1) Deploy model: diff -> lint -> sync (dry) -> sync --apply
  2) Resolve accounts to SFDC: pull accounts -> fg search/lookup or fuzzy-match -> sfdc preview-push -> push
  3) Contacts from accounts: pull -> enrich --csv ... --company-col ... -> dedupe (fuzzy-match/merge) -> write/sync

GREMLIN TRANSFORM TOOLS (CHAT/SHEETS)
-------------------------------------
  45 transform ops are registered as Gremlin tools (same /api/v1/transform/batch path):
  - Email: extract_domain_from_email, is_business_email, is_role_based_email, email_domain_match, parse_email_pattern
  - Phone: fix_phone_format, normalize_phone_to_e164
  - Location: normalize_state, state_name_to_code, normalize_country, assign_us_region,
              assign_global_region, assign_territory, parse_address_components
  - Company/Domain: normalize_company, clean_website, company_to_domain, detect_parent_company,
                    extract_domain_root, extract_subdomain, extract_id_from_url
  - Classification/Quality: normalize_industry, normalize_job_function, normalize_title,
                             company_size_bucket, classify_revenue_band, classify_lead_source,
                             calculate_completeness, detect_data_quality_issues,
                             detect_duplicate_likelihood, generate_dedup_key,
                             infer_employee_count, detect_technographics
  - Enrichment: enrich_company_name, enrich_company_industry, enrich_company_country,
                enrich_company_headquarters, enrich_social_handles, enrich_stock_info,
                enrich_subsidiaries
  - Names/Codes: split_fullname, clean_name, parse_naics, parse_sic, state_name_to_code

  Tip: g-gremlin docs gremlin  # full list, parameters, and example prompts

Run 'g-gremlin docs <topic>' for detailed help on:
  sfdc, zendesk, apify, nimbleway, script, recipes, sheets, config, gremlin, viz, support
""",
    "viz": """
Visualization & Charts (g-gremlin chart / viz / gslides)
========================================================

Native Sheets charts (fastest path)
-----------------------------------
  # Write data (use --user-entered to coerce numeric strings)
  g-gremlin write SHEET_ID --tab "viz" --csv data.csv --user-entered

  # Create a chart from headers
  g-gremlin chart add SHEET_ID "viz" --type column --x-col "Month" --y-col "Revenue" \\
      --title "Monthly Revenue" --replace

  # List charts in a tab
  g-gremlin chart list SHEET_ID "viz" --json

Slides: embed linked Sheets chart
---------------------------------
  g-gremlin gslides chart add PRESENTATION_ID \\
      --spreadsheet-id SHEET_ID --chart-id 12345 --slide-index 0 --layout full --replace

Plotly (PNG) render + Slides embed
----------------------------------
  # Render to local PNG
  g-gremlin viz render --csv data.csv --x date --y revenue --type line --output chart.png

  # Embed PNG into Slides (uploads to Drive)
  g-gremlin viz embed-slide PRESENTATION_ID --csv data.csv --x date --y revenue --type line \\
      --slide-index 0 --layout left --replace

Viz doctor (dependency + scope preflight)
-----------------------------------------
  g-gremlin viz doctor
""",

    "support": """
Support Tickets (CLI)
=====================

Submit bug reports and feature requests directly from g-gremlin. The command
defaults to dry-run; add --apply to submit.

Examples
--------
  g-gremlin support bug --summary "Crash on enrich" --description "App hangs after click" --apply
  g-gremlin support feature --summary "Add CSV preview" --description "Need a preview pane" --apply

Flags
-----
  --include-logs / --no-logs    Include recent g-gremlin logs if available
  --include-config / --no-config Include sanitized config
  --include-state / --no-state  Include local sheet state (if present)
  --apply / --dry-run           Submit vs preview (default: dry-run)
""",

    "gremlin": """
Gremlin Transform Tools (Sheets & Chat)
=======================================

All 45 Foundry transform operations are registered as Gremlin tools and execute through
the unified `_fmCall` -> `/api/v1/transform/batch` path. Use natural language in Gremlin
chat or the Sheets add-on; intent routing picks the right operation.

Categories & Key Ops
--------------------
  Email: extract_domain_from_email, is_business_email, is_role_based_email,
         email_domain_match, parse_email_pattern
  Phone: fix_phone_format, normalize_phone_to_e164
  Location: normalize_state, state_name_to_code, normalize_country,
            assign_us_region, assign_global_region, assign_territory,
            parse_address_components
  Company/Domain: normalize_company, clean_website, company_to_domain,
                  detect_parent_company, extract_domain_root,
                  extract_subdomain, extract_id_from_url
  Classification & Data Quality: normalize_industry, normalize_job_function,
            normalize_title, company_size_bucket, classify_revenue_band,
            classify_lead_source, calculate_completeness,
            detect_data_quality_issues, detect_duplicate_likelihood,
            generate_dedup_key, infer_employee_count, detect_technographics
  Enrichment: enrich_company_name, enrich_company_industry,
              enrich_company_country, enrich_company_headquarters,
              enrich_social_handles, enrich_stock_info, enrich_subsidiaries
  Names/Codes: split_fullname, clean_name, parse_naics, parse_sic, state_name_to_code

Example prompts
---------------
  "Which of these are personal emails?"
  "Convert these phone numbers to E164"
  "Assign US regions and territories for these rows"
  "Find the parent company and root domain for each row"
  "Bucket company size and classify revenue band"
  "Enrich HQ and industry for these domains"

Execution notes
---------------
  - All tools run client-side via `_fmCall` to `/api/v1/transform/batch`
  - Non-destructive; results write back to the sheet
  - Use natural language; Gremlin will choose the right tool
""",

    "sfdc": """
Salesforce Integration (g-gremlin sfdc)
=======================================

Requires: Salesforce CLI (sf) installed and authenticated.

DOCTOR / WHOAMI
---------------
  g-gremlin sfdc doctor [--json]
  g-gremlin sfdc whoami [--json]

CONNECT
-------
  g-gremlin sfdc connect --target myorg

ORGS / TARGET
-------------
  g-gremlin sfdc orgs list
  g-gremlin sfdc target set myorg

SNAPSHOT (READ-ONLY BASELINE)
-----------------------------
  g-gremlin sfdc snapshot --object Account --fields "Id,Name,Industry" --out snapshots/baseline
  g-gremlin sfdc snapshot --spec ./snapshot.yaml --out snapshots/baseline --schema

ENRICH CSV WITH SFDC DATA
-------------------------
  # Basic enrichment
  g-gremlin sfdc enrich --csv accounts.csv --id-column "AccountId" \\
      --object Account --fields "Type,Industry,BillingCity" --output enriched.csv

  # With field aliases
  g-gremlin sfdc enrich --csv data.csv --id-column "Id" --object Account \\
      --fields "Type,CS_Scorecard__c AS 'CS Score'" --output out.csv

AUDIT SALESFORCE IDS
--------------------
  g-gremlin sfdc audit --csv accounts.csv --id-column "AccountId" --object Account

  Checks: ID format (15/18 char), prefix validity, duplicates, existence in SFDC.

PREVIEW PUSH TO SALESFORCE
--------------------------
  g-gremlin sfdc preview-push --csv changes.csv --id-column "Id" --object Account

  Compares CSV values to current SFDC values before pushing.

SOQL QUERY (CSV EXPORT)
-----------------------
  # Positional SOQL
  g-gremlin sfdc query "SELECT StageName, Amount FROM Opportunity" --output pipeline.csv

  # With --soql (equivalent)
  g-gremlin sfdc query --soql "SELECT Id, Name FROM Account" --out accounts.csv

PACK LOOP (PLAN/APPLY/DRIFT/SMOKE)
---------------------------------
  g-gremlin sfdc pack init ./sfdc_pack
  g-gremlin sfdc pack plan ./sfdc_pack
  g-gremlin sfdc pack apply ./sfdc_pack --yes
  g-gremlin sfdc drift ./sfdc_pack
  g-gremlin sfdc smoke ./sfdc_pack

METADATA PACK (CUSTOM FIELDS / PERMSETS / RULES)
-----------------------------------------------
  g-gremlin sfdc metadata-pack init ./sfdc_metadata_pack
  g-gremlin sfdc metadata-pack plan ./sfdc_metadata_pack
  g-gremlin sfdc metadata-pack apply ./sfdc_metadata_pack --yes
  g-gremlin sfdc metadata-pack verify ./sfdc_metadata_pack
  g-gremlin sfdc metadata-pack drift ./sfdc_metadata_pack

CONFIG LOCATION
---------------
  Project-local: .g_gremlin/sfdc.json
""",

    "zendesk": """
Zendesk Integration (g-gremlin zendesk)
=======================================

CONNECT
-------
  g-gremlin zendesk connect --subdomain mycompany \\
      --email admin@example.com --api-token YOUR_TOKEN

USER TICKET STATS
-----------------
  g-gremlin recipe zendesk-user-stats --csv users.csv \\
      --email-col "Email" --output stats.csv

  Adds columns: ticket_count, open_tickets, avg_resolution_hours, last_ticket_date

ORG TICKET STATS
----------------
  g-gremlin recipe zendesk-org-stats --csv orgs.csv \\
      --org-col "Zendesk Org ID" --output stats.csv

  Adds columns: org_ticket_count, org_open_tickets, org_users

CONFIG LOCATION
---------------
  User-level: ~/.g_gremlin/zendesk.json
""",

    "apify": """
Apify Integration (g-gremlin apify)
===================================

Web scraping and company enrichment via Apify actors.

CONNECT
-------
  g-gremlin apify connect --api-token "apify_api_xxx"

VALIDATE CREDENTIALS
--------------------
  g-gremlin apify validate

ENRICH COMPANY NAMES
--------------------
  # Full enrichment
  g-gremlin apify enrich --csv contacts.csv --company-col "Company" --output enriched.csv

  # Dry run (no API calls, validates input)
  g-gremlin apify enrich --csv contacts.csv --output out.csv --dry-run --limit 10

  # With row limit
  g-gremlin apify enrich --csv data.csv --company-col "Company" --limit 100 --output out.csv

OUTPUT COLUMNS ADDED
--------------------
  website, domain, linkedin_url, twitter_url, confidence_or_signal

ACTORS USED (approximate costs)
-------------------------------
  apioracle/company-domain     - Company name -> website + social (~$19/1000)
  s-r/free-linkedin-company-finder - Domain -> LinkedIn URL (~$3/1000)

CONFIG LOCATION
---------------
  User-level: ~/.g_gremlin/apify.json
""",

    "nimbleway": """
Nimbleway Integration (g-gremlin nimbleway)
===========================================

Web extraction and search helpers via the Nimbleway SDK.

DOCTOR
------
  g-gremlin nimbleway doctor
  g-gremlin nimbleway doctor --url "https://example.com" --json

EXTRACT A PAGE
--------------
  g-gremlin nimbleway extract --url "https://www.linkedin.com/company/openai" --render
  g-gremlin nimbleway extract --url "https://example.com" --instructions "Extract the company name" --json

STRUCTURED COMPANY PROFILE
--------------------------
  g-gremlin nimbleway company --url "https://www.hubspot.com" --json
  g-gremlin nimbleway company --url "https://www.linkedin.com/company/openai" --render --json

SEARCH THE WEB
--------------
  g-gremlin nimbleway search --query "openai recruiting" --focus social --include-domain linkedin.com
  g-gremlin nimbleway search --query "AI safety startups" --country US --max-results 5 --json

CREDENTIALS
-----------
  Preferred: g-gremlin auth set nimbleway --key api_key --value <key>
  Env vars:  NIMBLEWAY_API_KEY or NIMBLE_API_KEY
  Optional:  g-gremlin auth set nimbleway --key social_auth --value <credential>
  Social:    NIMBLEWAY_SOCIAL_AUTH or NIMBLE_SOCIAL_AUTH
""",

    "script": """
Apps Script Integration (g-gremlin script)
==========================================

COMMANDS
--------
  g-gremlin script pull SHEET_ID [--output scripts/] [--create-if-missing]
      Pulls the bound Apps Script project (writes .gs/.html/appsscript.json).

  g-gremlin script push SHEET_ID [--source scripts/] [--create-if-missing]
      Pushes local script files to the bound project (full replace).

  g-gremlin script init SHEET_ID [--title \"My Script\"] [--pull]
      Creates/binds a project to the sheet (uses parentId) and caches the scriptId.

  g-gremlin script run SHEET_ID FUNCTION [--params '[1,\"abc\"]']
      Executes a function via the Execution API (devMode=true). Requires Apps Script API enabled + a deployed version.

  g-gremlin script logs SHEET_ID [--limit 10]
      Lists recent operations for the project (best-effort).

BINDING / LOOKUP
----------------
- By default we auto-resolve the bound script via Drive search and cache it in
  ~/.g_gremlin/script_bindings.json. Override with --script-id when needed.
- Use --create-if-missing (or script init) to create a container-bound script.

WORKFLOW EXAMPLES
-----------------
  g-gremlin pull SHEET_ID --range \"Data!A:Z\" --output data.csv --include-scripts
  g-gremlin script push SHEET_ID --source ./scripts
  g-gremlin script run SHEET_ID initializeFormulas --params \"[]\"
""",

    "recipes": """
Data Recipes (g-gremlin recipe)
===============================

Pre-built workflows combining multiple operations.

BUILD ACCOUNT DIMENSION
-----------------------
  g-gremlin recipe build-account-dim --csv opps.csv --output accounts.csv

  Creates account dimension table from opportunity data with ARR/MRR rollups.

DETECT OPEN RENEWALS
--------------------
  g-gremlin recipe detect-open-renewals --csv accounts.csv --output flagged.csv

  Flags accounts with open renewal opportunities.

ATTACH PRODUCTS
---------------
  g-gremlin recipe attach-products --csv accounts.csv \\
      --products-csv products.csv --output enriched.csv

  Attaches distinct product names per account.

ENRICH FROM SFDC
----------------
  g-gremlin recipe enrich-from-sfdc --csv data.csv --id-column "AccountId" \\
      --object Account --fields "Type,Industry" --output enriched.csv

RESOLVE UNMATCHED
-----------------
  g-gremlin recipe resolve-unmatched --csv data.csv \\
      --candidates candidates.csv --output suggestions.csv

  Fuzzy-matches unmatched rows against candidate list.

ZENDESK STATS
-------------
  g-gremlin recipe zendesk-user-stats --csv users.csv --email-col "Email" --output out.csv
  g-gremlin recipe zendesk-org-stats --csv orgs.csv --org-col "Org ID" --output out.csv
""",

    "sheets": """
Google Sheets Operations
========================

PULL DATA
---------
  g-gremlin pull SPREADSHEET_ID --range "Sheet1!A1:Z" --output data.csv
  g-gremlin pull SPREADSHEET_ID --range "Leads!A:D" --output leads.csv

WRITE DATA
----------
  # Overwrite tab
  g-gremlin write SPREADSHEET_ID --tab "Output" --csv data.csv

  # Append to existing
  g-gremlin write SPREADSHEET_ID --tab "Log" --csv new_rows.csv --mode append

PUSH CHANGES (with ETag safety)
-------------------------------
  # After editing pulled CSV, push changes back
  g-gremlin push
  g-gremlin push --dry-run    # Preview without applying
  g-gremlin push --force      # Skip ETag check

COPY TAB
--------
  g-gremlin copy-tab SPREADSHEET_ID --source "Template" --dest "Jan 2025"

CHAIN WORKFLOW
--------------
  g-gremlin chain --spreadsheet-id ID --pull-range "Data!A:Z" \\
      --enrich-object Account --enrich-fields "Type,Industry" \\
      --rename "Old Name=New Name" --write-tab "Output"

INSPECT SHEET
-------------
  g-gremlin inspect SPREADSHEET_ID --tab "Sheet1" --output analysis.json

REPAIR SHEET
------------
  g-gremlin repair SPREADSHEET_ID --tab "Broken" --fix-formulas --fix-formats
  g-gremlin rescue SPREADSHEET_ID    # Auto-fix all tabs
""",

    "config": """
Configuration & Authentication
==============================

GOOGLE OAUTH (first-time setup)
-------------------------------
  # First command opens browser for OAuth consent
  g-gremlin pull SPREADSHEET_ID --range "A1:B10" --output test.csv

  Token location: ~/.g_gremlin/token.json

CONFIG FILE LOCATIONS
---------------------
  Google OAuth:  ~/.g_gremlin/token.json
  Salesforce:    .g_gremlin/sfdc.json (project-local)
  Zendesk:       ~/.g_gremlin/zendesk.json
  Apify:         ~/.g_gremlin/apify.json
  Local state:   .g_gremlin/state.json (ETags, last pull info)

ENVIRONMENT VARIABLES
---------------------
  G_GREMLIN_CREDENTIALS  - Path to Google OAuth credentials JSON
  APIFY_API_TOKEN        - Apify API token (alternative to config file)
  NIMBLEWAY_API_KEY      - Nimbleway API key
  NIMBLE_API_KEY         - Nimbleway API key (compat alias)

SPREADSHEET ID
--------------
  The ID is in the Google Sheets URL:
  https://docs.google.com/spreadsheets/d/SPREADSHEET_ID_HERE/edit

  Example: 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms

A1 NOTATION
-----------
  "Sheet1!A1:Z"      - Columns A-Z, all rows
  "Sheet1!A1:D100"   - Columns A-D, rows 1-100
  "Sheet1!A:A"       - Just column A
  "'My Sheet'!A1:Z"  - Sheet name with spaces (use quotes)
""",
}

# External docs loaded from markdown files
_DOCS_EXTERNAL = {
    "ai_agent": "docs/ai_agent_conventions.md",
    "ai_agent_conventions": "docs/ai_agent_conventions.md",
    "ai": "docs/ai_agent_conventions.md",
    "looker": "docs/looker_quickstart.md",
    "looker_commands": "docs/looker_commands.md",
    "looker_cli": "docs/looker_commands.md",
    "tableau": "docs/tableau_quickstart.md",
    "tableau_commands": "docs/tableau_commands.md",
    "tableau_cli": "docs/tableau_commands.md",
    "tableau_template_guide": "docs/tableau_template_guide.md",
    "tableau_template_authoring": "docs/tableau_template_authoring.md",
    "tableau_manifest_reference": "docs/tableau_manifest_reference.md",
    "tableau_template_examples": "docs/tableau_template_examples.md",
    "tableau_template_troubleshooting": "docs/tableau_template_troubleshooting.md",
}

_DOCS_TOPICS = list(dict.fromkeys(list(_DOCS.keys()) + list(_DOCS_EXTERNAL.keys())))


@cli.command("docs")
@click.argument("topic", required=False, default="overview")
def docs_command(topic: str) -> None:
    """
    Show built-in documentation for g-gremlin commands.

    Available topics: overview, sfdc, zendesk, apify, nimbleway, script, recipes, sheets, config, gremlin, ai_agent, tableau, looker

    Examples:

        g-gremlin docs              # Show overview
        g-gremlin docs sfdc         # Salesforce integration help
        g-gremlin docs recipes      # Data recipes help
        g-gremlin docs config       # Configuration & auth help
        g-gremlin docs nimbleway    # Nimbleway extraction/search help
        g-gremlin docs gremlin      # Gremlin transform tool catalog (45 ops)
        g-gremlin docs ai_agent     # AI agent conventions & structured output
        g-gremlin docs tableau      # Tableau Cloud quick start
        g-gremlin docs looker       # Looker quick start
        g-gremlin docs tableau_template_guide  # Template workflow guide
    """
    topic_lower = topic.lower().strip()

    # Check external files first
    if topic_lower in _DOCS_EXTERNAL:
        # Find the docs directory relative to the package
        pkg_dir = Path(__file__).parent.parent.parent  # src/g_gremlin/cli.py -> repo root
        doc_path = pkg_dir / _DOCS_EXTERNAL[topic_lower]
        if doc_path.exists():
            click.echo(doc_path.read_text(encoding="utf-8"))
        else:
            click.echo(f"Documentation file not found: {doc_path}")
            sys.exit(1)
    elif topic_lower in _DOCS:
        click.echo(_DOCS[topic_lower])
    else:
        click.echo(f"Unknown topic: {topic}")
        click.echo(f"Available topics: {', '.join(_DOCS_TOPICS)}")
        sys.exit(1)


@cli.command("help")
@click.argument("command_parts", nargs=-1)
@click.option(
    "--json",
    "output_json",
    is_flag=True,
    help="Output machine-readable JSON schema instead of human-readable help.",
)
@click.option(
    "--full",
    "full_index",
    is_flag=True,
    help="Output full command index (JSON only).",
)
def help_command(command_parts: tuple[str, ...], output_json: bool, full_index: bool) -> None:
    """
    Get detailed help for a specific command (including nested subcommands).

    With --json, outputs machine-readable command schema for AI agents including
    arguments, options, examples, exit codes, and defaults.

    \b
    Examples:
      # Human-readable help
      g-gremlin help lint
      g-gremlin help sfdc metadata-pack plan

      # Machine-readable JSON for AI agents
      g-gremlin help lint --json
      g-gremlin help sfdc metadata-pack plan --json

      # Colon-separated form also works
      g-gremlin help sfdc:metadata-pack:plan --json
    """
    import json

    if full_index and not output_json:
        output_json = True

    # Normalise command path: accept "sfdc metadata-pack plan" or "sfdc:metadata-pack:plan"
    tokens: list[str] = []
    for part in command_parts:
        tokens.extend(part.split(":"))
    tokens = [t.strip() for t in tokens if t.strip()]
    command_name: str | None = tokens[0] if tokens else None

    # If no command specified, show list of available commands
    if not command_name:
        if output_json:
            ctx = click.Context(cli)
            command_names = command_metadata.filter_display_commands(cli.list_commands(ctx))
            command_groups, uncategorized = command_metadata.normalize_command_groups(command_names)

            if full_index:
                def build_full_index(
                    group: click.Group,
                    path_parts: list[str],
                    group_name: str | None,
                    parent_ctx: click.Context,
                ) -> Dict[str, Any]:
                    entries: Dict[str, Any] = {}
                    group_ctx = click.Context(group, parent=parent_ctx)
                    for name in group.list_commands(group_ctx):
                        if name in command_metadata.HIDDEN_COMMANDS:
                            continue
                        cmd = group.get_command(group_ctx, name)
                        if cmd is None:
                            continue
                        next_parts = path_parts + [name]
                        command_id = command_metadata.build_command_id(next_parts)
                        root_group = group_name or command_metadata.find_group_for_command(next_parts[0])
                        schema = help_introspection.extract_command_schema(
                            cmd,
                            name,
                            cmd_id=command_id,
                            group_name=root_group,
                        )
                        safety = schema.get("safety", {})
                        entry = {
                            "command_id": command_id,
                            "name": name,
                            "path": next_parts,
                            "group": root_group,
                            "description_short": schema.get("short_description", ""),
                            "description_full": schema.get("description", ""),
                            "is_group": schema.get("is_group", False),
                            "impact": safety.get("impact", "?"),
                            "destructive": safety.get("destructive", False),
                            "idempotent": safety.get("idempotent", True),
                            "side_effects": safety.get("side_effects", []),
                            "data_access": safety.get("data_access", "local_only"),
                            "requires_auth": safety.get("requires_auth", []),
                            "requires_network": safety.get("requires_network", False),
                            "local_writes_mode": safety.get("local_writes_mode", "never"),
                            "safety_source": safety.get("safety_source", "unknown"),
                            "safety_confidence": safety.get("safety_confidence", "low"),
                            "supports_dry_run": schema.get("supports_dry_run", False),
                            "supports_apply": schema.get("supports_apply", False),
                            "supports_json": schema.get("supports_json", False),
                            "supports_json_summary": schema.get("supports_json_summary", False),
                            "structured_output": schema.get("structured_output", False),
                            "examples": schema.get("examples", []),
                            "exit_codes": schema.get("exit_codes", {}),
                            "exit_code_actions": schema.get("exit_code_actions", {}),
                            "options": schema.get("options", []),
                            "arguments": schema.get("arguments", []),
                            "flags": schema.get("flags", []),
                            "defaults": schema.get("defaults", {}),
                            "auth": schema.get("auth", {"required": False, "integrations": []}),
                        }
                        if isinstance(cmd, click.Group):
                            entry["subcommands"] = build_full_index(cmd, next_parts, root_group, group_ctx)
                        entries[command_id] = entry
                    return entries

                payload = {
                    "schema_version": 1,
                    "commands_index": build_full_index(cli, [], None, ctx),
                    "command_groups": command_groups,
                    "uncategorized": uncategorized,
                    "legend": command_metadata.IMPACT_LEGEND,
                    "group_examples": command_metadata.GROUP_EXAMPLES,
                }
                click.echo(json.dumps(payload, indent=2, default=str))
            else:
                commands_list = []
                for name in command_names:
                    cmd = cli.get_command(ctx, name)
                    schema = help_introspection.extract_command_schema(
                        cmd,
                        name,
                        cmd_id=name,
                        group_name=command_metadata.find_group_for_command(name),
                    )
                    safety = schema.get("safety", {})
                    commands_list.append(
                        {
                            "name": name,
                            "description": schema.get("short_description", ""),
                            "is_group": schema.get("is_group", False),
                            "impact": safety.get("impact", "?"),
                            "safety_confidence": safety.get("safety_confidence", "low"),
                            "structured_output": schema.get("structured_output", False),
                            "requires_auth": safety.get("requires_auth", []),
                        }
                    )
                click.echo(
                    json.dumps(
                        {
                            "schema_version": 1,
                            "commands": commands_list,
                            "command_groups": command_groups,
                            "uncategorized": uncategorized,
                            "legend": command_metadata.IMPACT_LEGEND,
                        },
                        indent=2,
                        default=str,
                    )
                )
        else:
            ctx = click.Context(cli)
            click.echo(cli.get_help(ctx))
        return

    # Resolve the command, walking into subgroups for nested paths
    cmd = cli.commands.get(command_name)
    if not cmd:
        click.echo(f"Error: Command '{command_name}' not found.", err=True)
        click.echo("\nAvailable commands:", err=True)
        for name in sorted(cli.commands.keys()):
            click.echo(f"  {name}", err=True)
        sys.exit(1)

    resolved_parts = [command_name]
    for sub_token in tokens[1:]:
        if not isinstance(cmd, click.Group):
            click.echo(
                f"Error: '{' '.join(resolved_parts)}' is not a group; "
                f"cannot resolve subcommand '{sub_token}'.",
                err=True,
            )
            sys.exit(1)
        sub_cmd = cmd.get_command(click.Context(cmd), sub_token)
        if not sub_cmd:
            click.echo(
                f"Error: Subcommand '{sub_token}' not found under "
                f"'{' '.join(resolved_parts)}'.",
                err=True,
            )
            available = cmd.list_commands(click.Context(cmd))
            if available:
                click.echo(f"Available: {', '.join(available)}", err=True)
            sys.exit(1)
        cmd = sub_cmd
        resolved_parts.append(sub_token)

    display_name = " ".join(resolved_parts)
    cmd_id = ":".join(resolved_parts)

    if output_json:
        # Generate JSON schema
        schema = help_introspection.extract_command_schema(
            cmd,
            display_name,
            cmd_id=cmd_id,
            group_name=command_metadata.find_group_for_command(command_name),
        )
        click.echo(help_introspection.format_command_help_json(schema))
    else:
        # Show standard help
        schema = help_introspection.extract_command_schema(
            cmd,
            display_name,
            cmd_id=cmd_id,
            group_name=command_metadata.find_group_for_command(command_name),
        )
        ctx = click.Context(cmd, info_name=f"g-gremlin {display_name}", parent=click.Context(cli))
        help_text = cmd.get_help(ctx)
        auth_help = help_introspection.format_auth_help_text(schema.get("auth", {}))
        if auth_help:
            help_text = f"{help_text.rstrip()}\n\n{auth_help}\n"
        click.echo(help_text)


@cli.command("safety")
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format (text|json).",
)
@click.option(
    "--json",
    "json_flag",
    is_flag=True,
    help="Shortcut for --format json.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write report to a file (use - for stdout).",
)
def safety(output_format: str, json_flag: bool, output_path: Path | None) -> None:
    """
    Show guardrails and defaults (backups, limits, confirmations).
    """
    guardrails = {
        "limits": {
            "max_cells_default": 100_000,
            "override": "config.settings.max_cells",
        },
        "backups_default_on": ["write", "sync", "copy-tab", "repair fix", "rescue", "push"],
        "auto_rollback_flag": "--auto-rollback",
        "confirmation": {
            "sync": "dry-run by default; use --apply to mutate",
            "write": "--apply/--no-apply (default apply)",
            "rescue": "--dry-run to preview; apply by default",
            "push": "--dry-run to preview diff apply",
        },
        "throttling": {
            "sheets": "~3 req/sec when --throttle (default for rescue/style)",
        },
        "output_contract": {
            "format": "--format text|json (alias --json)",
            "output": "--output PATH or '-' for stdout",
            "json_summary": "--json-summary aliases json output for most mutating commands",
            "exit_codes": {
                "success": EXIT_SUCCESS,
                "issues": EXIT_ISSUES,
                "usage": EXIT_USAGE,
                "remote_error": EXIT_REMOTE_ERROR,
            },
        },
    }
    emit_json = _should_emit_json(output_format, json_flag, False) or output_path is not None

    if emit_json:
        _emit_json_payload(json.dumps(guardrails, indent=2), output_path)
        return

    lines = [
        "g-gremlin safety",
        "- Max cells: default 100000 (override: config.settings.max_cells)",
        "- Backups default ON: write, sync, copy-tab, repair fix, rescue, push (use --no-backup to skip).",
        "- Auto-rollback: add --auto-rollback to auto-restore after errors.",
        "- Confirmation: sync is dry-run unless --apply; write/rescue apply by default; push supports --dry-run.",
        "- Throttle: rescue/style default --throttle (~3 req/sec) to respect quotas; use --no-throttle to disable.",
        "- Output: --format json/--json + --output PATH for machine output; --json-summary is a shortcut.",
        "- Exit codes: 0 success, 1 issues (lint), 2 usage/missing prereqs, 3 remote/API failure.",
    ]
    click.echo("\n".join(lines))


def _validate_support_fields(summary: str, description: str, category: str) -> None:
    if not (8 <= len(summary.strip()) <= 120):
        click.echo("Error: summary must be 8-120 characters.", err=True)
        sys.exit(EXIT_USAGE)
    desc_len = len(description.strip())
    if desc_len < 20:
        click.echo("Error: description must be at least 20 characters.", err=True)
        sys.exit(EXIT_USAGE)
    if desc_len > 20000:
        click.echo("Error: description must be at most 20000 characters.", err=True)
        sys.exit(EXIT_USAGE)
    cat_len = len(category.strip())
    if cat_len < 2:
        click.echo("Error: category must be at least 2 characters.", err=True)
        sys.exit(EXIT_USAGE)
    if cat_len > 64:
        click.echo("Error: category must be at most 64 characters.", err=True)
        sys.exit(EXIT_USAGE)


def _support_payload_hash(payload: dict[str, Any]) -> str:
    base = {
        "type": payload.get("type"),
        "category": payload.get("category"),
        "severity": payload.get("severity"),
        "summary": payload.get("summary"),
        "description": payload.get("description"),
    }
    digest = hashlib.sha256()
    digest.update(json.dumps(base, sort_keys=True).encode("utf-8"))
    return digest.hexdigest()


def _extract_org_id_from_license() -> str | None:
    try:
        license_mod = _get_license()
        token = license_mod.get_license_token()
    except Exception:
        token = None
    if not token:
        return None
    try:
        org_id, _ = cli_telemetry._extract_identity(token)
        return org_id
    except Exception:
        return None


def _build_support_id(org_id: str | None, correlation_id: str | None) -> str:
    stamp = datetime.now(timezone.utc).isoformat()
    payload = f"{org_id or 'unknown'}|{correlation_id or ''}|{stamp}"
    digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
    return digest[:12]


def _collect_support_info(correlation_id: str | None) -> dict[str, Any]:
    python_version = platform.python_version()
    os_value = platform.platform()
    user_config_dir = resolve_gremlin_home()
    project_config_dir = Path(".g_gremlin")

    license_status = "unknown"
    license_detail = ""
    license_meta: dict[str, Any] | None = None
    try:
        license_mod = _get_license()
        license_result = license_mod.check_license_unified()
        if license_result.valid and license_result.license:
            lic = license_result.license
            days_msg = f", {lic.days_remaining} days remaining" if lic.days_remaining is not None else ""
            license_status = lic.status or "active"
            license_detail = f"{lic.tier}{days_msg}".strip()
            license_meta = {
                "tier": lic.tier,
                "status": lic.status,
                "trial_ends_at": lic.trial_ends_at,
                "days_remaining": lic.days_remaining,
                "token_expires_at": lic.token_expires_at,
            }
        else:
            license_status = "not found"
            license_detail = license_result.error or "No license information available."
    except Exception as exc:
        license_status = "error"
        license_detail = str(exc)

    org_id = _extract_org_id_from_license()
    support_id = _build_support_id(org_id, correlation_id)

    return {
        "version": __version__,
        "python": python_version,
        "os": os_value,
        "user_config_dir": str(user_config_dir),
        "user_config_found": user_config_dir.exists(),
        "project_config_dir": str(project_config_dir),
        "project_config_found": project_config_dir.exists(),
        "license_status": license_status,
        "license_detail": license_detail,
        "license_meta": license_meta,
        "support_id": support_id,
    }


def _submit_support_ticket(payload: dict[str, Any], dry_run: bool, idempotency_key: str | None) -> None:
    correlation_id = ""
    session_id = None
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            correlation_id = str(ctx.obj.get("correlation_id") or "")
            session_id = ctx.obj.get("session_id")
    except Exception:
        pass

    payload_type = str(payload.get("type") or "").lower()
    if payload_type == "support":
        command = "g-gremlin support bug"
    elif payload_type == "feature":
        command = "g-gremlin support feature"
    else:
        command = "g-gremlin support"

    agentic = _submit_support_ticket_structured(
        payload=payload,
        dry_run=dry_run,
        idempotency_key=idempotency_key,
        command=command,
        correlation_id=correlation_id,
        session_id=session_id,
        fingerprint=payload.get("fingerprint"),
    )

    if dry_run:
        click.echo("Dry run: support ticket not submitted.")
        click.echo(f"POST {_support_api_base()}/api/v2/support/tickets")
        click.echo(f"Idempotency-Key: {agentic.result.get('idempotency_key')}")
        click.echo(json.dumps(payload, indent=2))
        return

    if agentic.status == "success":
        ticket_id = agentic.result.get("ticket_id")
        status = agentic.result.get("status") or "created"
        if ticket_id:
            click.echo(f"Support ticket {status}: {ticket_id}")
        else:
            click.echo("Support ticket submitted.")
        return

    error_message = agentic.error or "Support ticket submission failed."
    click.echo(f"Error submitting support ticket: {error_message}", err=True)
    http_status = agentic.result.get("http_status")
    if http_status and int(http_status) >= 500:
        sys.exit(EXIT_REMOTE_ERROR)
    if agentic.result.get("error_type") == "missing_dependency":
        sys.exit(EXIT_USAGE)
    if agentic.result.get("error_type") in ("timeout", "network_error"):
        sys.exit(EXIT_REMOTE_ERROR)
    sys.exit(EXIT_ISSUES)


def _submit_support_ticket_structured(
    payload: dict[str, Any],
    dry_run: bool,
    idempotency_key: str | None,
    *,
    command: str,
    correlation_id: str | None,
    session_id: str | None,
    fingerprint: str | None,
) -> "AgenticResult":
    base = _support_api_base()
    url = f"{base}/api/v2/support/tickets"
    headers = _support_auth_headers()
    safety_ctx = SafetyContext(command=command, correlation_id=correlation_id or "")

    idem = idempotency_key or str(uuid.uuid4())
    result_base = {
        "idempotency_key": idem,
        "fingerprint": fingerprint,
        "source": "gremlin",
        "session_id": session_id,
    }

    if not headers:
        return safety_ctx.build_result(
            status="error",
            dry_run=dry_run,
            result={**result_base, "status": "error", "error_type": "missing_license"},
            error="No license token found. Run 'g-gremlin auth login' first.",
        )

    headers = {
        **headers,
        "Idempotency-Key": idem,
        "User-Agent": f"g-gremlin/{__version__}",
        "Content-Type": "application/json",
    }

    if dry_run:
        return safety_ctx.build_result(
            status="success",
            dry_run=True,
            result={**result_base, "status": "dry_run", "url": url},
        )

    try:
        import httpx
    except Exception as exc:
        return safety_ctx.build_result(
            status="error",
            dry_run=dry_run,
            result={**result_base, "status": "error", "error_type": "missing_dependency"},
            error=f"Missing httpx dependency: {exc}",
        )

    timeout_exc = getattr(httpx, "TimeoutException", None)
    request_exc = getattr(httpx, "RequestError", None)
    try:
        resp = httpx.post(url, json=payload, headers=headers, timeout=20.0)
    except Exception as exc:
        if timeout_exc and isinstance(exc, timeout_exc):
            error_type = "timeout"
        elif request_exc and isinstance(exc, request_exc):
            error_type = "network_error"
        else:
            error_type = "network_error"
        return safety_ctx.build_result(
            status="error",
            dry_run=dry_run,
            result={**result_base, "status": "error", "error_type": error_type},
            error=f"Error submitting support ticket: {exc}",
        )

    if resp.status_code >= 400:
        return safety_ctx.build_result(
            status="error",
            dry_run=dry_run,
            result={
                **result_base,
                "status": "error",
                "http_status": resp.status_code,
                "response": resp.text[:500],
            },
            error=f"Support API error ({resp.status_code})",
        )

    try:
        data = resp.json()
    except Exception:
        data = {}

    public_id = data.get("data", {}).get("public_id")
    status = data.get("data", {}).get("status") or "created"
    return safety_ctx.build_result(
        status="success",
        dry_run=dry_run,
        result={
            **result_base,
            "ticket_id": public_id,
            "status": status,
        },
    )


@cli.group()
def support() -> None:
    """
    Submit support tickets to FoundryOps.

    Requires a valid g-gremlin license token.
    Default behavior is dry-run unless --apply is set.

    Examples:
      g-gremlin support bug --summary "Crash on enrich" --description "App hangs after click" --apply
      g-gremlin support feature --summary "Add CSV preview" --description "Need a preview pane" --apply
    """
    _check_license()


@support.command("info")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def support_info(json_output: bool) -> None:
    """Show a compact support context summary."""
    correlation_id = None
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            correlation_id = ctx.obj.get("correlation_id")
    except Exception:
        correlation_id = None
    payload = _collect_support_info(correlation_id)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    license_status = payload.get("license_status", "unknown")
    license_detail = payload.get("license_detail", "")
    license_line = license_status
    if license_detail:
        license_line = f"{license_status} ({license_detail})"

    lines = [
        "g-gremlin support info",
        f"  Version: {payload.get('version')}",
        f"  Python: {payload.get('python')}",
        f"  OS: {payload.get('os')}",
        f"  User config: {payload.get('user_config_dir')} ({'found' if payload.get('user_config_found') else 'not found'})",
        f"  Project config: {payload.get('project_config_dir')} ({'found' if payload.get('project_config_found') else 'not found'})",
        f"  License: {license_line}",
        f"  Support ID: {payload.get('support_id')}",
        "",
        "Copy the above when filing a support case.",
    ]
    click.echo("\n".join(lines))


@support.command("bundle")
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output path for bundle JSON.",
)
@click.option(
    "--log-lines",
    default=100,
    show_default=True,
    type=int,
    help="Max log lines to include.",
)
@click.option(
    "--command-history",
    "command_history_count",
    default=50,
    show_default=True,
    type=int,
    help="Number of recent commands to include.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
def support_bundle_cmd(
    output_path: Path | None,
    log_lines: int,
    command_history_count: int,
    json_output: bool,
) -> None:
    """Generate a support bundle JSON file."""
    if output_path is None:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
        output_path = Path(".g_gremlin") / "support" / f"bundle_{stamp}.json"

    options = support_bundle.BundleOptions(
        include_logs=True,
        log_lines=log_lines,
        include_config=True,
        include_state=True,
        include_command_history=True,
        command_history_count=command_history_count,
    )
    bundle = support_bundle.generate_bundle(options)
    output_path = support_bundle.write_bundle_file(bundle, output_path)

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin support bundle")
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={"output_path": str(output_path)},
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    click.echo(f"Support bundle written: {output_path}")


@support.command("submit")
@click.option(
    "--payload-file",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to JSON payload file.",
)
@click.option(
    "--stdin",
    "stdin_payload",
    is_flag=True,
    help="Read JSON payload from stdin.",
)
@click.option(
    "--bundle",
    "bundle_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional support bundle JSON file to merge.",
)
@click.option("--correlation-id", help="Override correlation ID.")
@click.option("--session-id", help="Override session ID.")
@click.option(
    "--apply/--dry-run",
    default=False,
    show_default=True,
    help="Submit the ticket (default: dry-run).",
)
def support_submit(
    payload_file: Path | None,
    stdin_payload: bool,
    bundle_path: Path | None,
    correlation_id: str | None,
    session_id: str | None,
    apply: bool,
) -> None:
    """Submit a structured support ticket (agent-friendly)."""
    if payload_file and stdin_payload:
        click.echo("Error: Use either --payload-file or --stdin, not both.", err=True)
        sys.exit(EXIT_USAGE)
    if not payload_file and not stdin_payload:
        click.echo("Error: --payload-file or --stdin is required.", err=True)
        sys.exit(EXIT_USAGE)

    if stdin_payload:
        if sys.stdin.isatty():
            click.echo("Error: stdin is empty.", err=True)
            sys.exit(EXIT_USAGE)
        raw_payload = sys.stdin.read()
    else:
        raw_payload = payload_file.read_text(encoding="utf-8") if payload_file else ""

    try:
        payload_input = json.loads(raw_payload)
    except Exception:
        click.echo("Error: payload must be valid JSON.", err=True)
        sys.exit(EXIT_USAGE)
    if not isinstance(payload_input, dict):
        click.echo("Error: payload must be a JSON object.", err=True)
        sys.exit(EXIT_USAGE)

    ticket_type = str(payload_input.get("type") or "").strip().lower()
    if ticket_type not in ("bug", "feature"):
        click.echo("Error: type must be 'bug' or 'feature'.", err=True)
        sys.exit(EXIT_USAGE)

    category = str(payload_input.get("category") or "")
    summary = str(payload_input.get("summary") or "")
    description = str(payload_input.get("description") or "")
    severity = payload_input.get("severity")
    job_id = payload_input.get("job_id")
    doc_id = payload_input.get("doc_id")
    fingerprint_input = payload_input.get("fingerprint")
    client_type = payload_input.get("client_type")

    _validate_support_fields(summary, description, category)

    if severity is None and ticket_type == "bug":
        click.echo("Error: severity is required for bug reports.", err=True)
        sys.exit(EXIT_USAGE)

    if severity is not None:
        severity = str(severity).strip().lower()
        if severity not in ("critical", "high", "medium", "low"):
            click.echo("Error: severity must be critical|high|medium|low.", err=True)
            sys.exit(EXIT_USAGE)

    bundle_data: dict[str, Any] | None = None
    if bundle_path:
        try:
            bundle_data = json.loads(bundle_path.read_text(encoding="utf-8"))
        except Exception:
            click.echo("Error: bundle file must be valid JSON.", err=True)
            sys.exit(EXIT_USAGE)

    ctx = click.get_current_context(silent=True)
    if ctx is not None:
        ctx.ensure_object(dict)
        if correlation_id:
            ctx.obj["correlation_id"] = correlation_id
        else:
            correlation_id = ctx.obj.get("correlation_id") or str(uuid.uuid4())
            ctx.obj["correlation_id"] = correlation_id
        if session_id:
            ctx.obj["session_id"] = session_id
        else:
            session_id = ctx.obj.get("session_id") or _get_or_create_session_id()
            ctx.obj["session_id"] = session_id
    else:
        correlation_id = correlation_id or str(uuid.uuid4())
        session_id = session_id or _get_or_create_session_id()

    context_json = _build_support_context(
        include_logs=True,
        log_lines=50,
        include_config=True,
        include_state=True,
        extra=None,
    )
    context_json["support_info"] = _collect_support_info(correlation_id)

    if bundle_data is not None:
        context_json["debug_bundle"] = bundle_data

    if correlation_id:
        context_json["correlation_id"] = correlation_id
    if session_id:
        context_json["session_id"] = session_id
    if client_type:
        context_json["client_type"] = client_type

    fingerprint_used = None
    if fingerprint_input:
        fingerprint_used = str(fingerprint_input)
    elif bundle_data and bundle_data.get("last_error_fingerprint"):
        fingerprint_used = str(bundle_data.get("last_error_fingerprint"))

    if fingerprint_used:
        context_json["fingerprint"] = fingerprint_used

    if fingerprint_used:
        org_id = _extract_org_id_from_license() or ""
        raw = f"{fingerprint_used}{org_id}{__version__}"
        idempotency_key = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
    else:
        payload_hash = _support_payload_hash(
            {
                "type": ticket_type,
                "category": category.strip(),
                "severity": severity,
                "summary": summary.strip(),
                "description": description.strip(),
            }
        )
        idempotency_key = payload_hash[:32]

    backend_type = "support" if ticket_type == "bug" else "feature"
    payload = {
        "type": backend_type,
        "source": "gremlin",
        "category": category.strip(),
        "severity": severity,
        "summary": summary.strip(),
        "description": description.strip(),
        "job_id": job_id,
        "doc_id": doc_id,
        "context_json": context_json,
    }

    agentic = _submit_support_ticket_structured(
        payload=payload,
        dry_run=not apply,
        idempotency_key=idempotency_key,
        command="g-gremlin support submit",
        correlation_id=correlation_id,
        session_id=session_id,
        fingerprint=fingerprint_used,
    )

    click.echo(json.dumps(agentic.to_dict(), indent=2))

    if agentic.status == "error":
        http_status = agentic.result.get("http_status")
        if http_status and int(http_status) >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        if agentic.result.get("error_type") in ("timeout", "network_error"):
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)


@support.command("bug")
@click.option("--summary", required=True, help="Short summary (8-120 chars).")
@click.option("--description", required=True, help="Detailed description (min 20 chars).")
@click.option("--category", default="gremlin", show_default=True, help="Issue category.")
@click.option(
    "--severity",
    type=click.Choice(["critical", "high", "medium", "low"], case_sensitive=False),
    default="medium",
    show_default=True,
    help="Issue severity.",
)
@click.option("--job-id", help="Optional job ID or run identifier.")
@click.option(
    "--include-logs/--no-logs",
    default=True,
    show_default=True,
    help="Include recent g-gremlin logs if available.",
)
@click.option(
    "--log-lines",
    default=50,
    show_default=True,
    type=int,
    help="Max log lines to include.",
)
@click.option(
    "--include-config/--no-config",
    default=True,
    show_default=True,
    help="Include sanitized g-gremlin config if available.",
)
@click.option(
    "--include-state/--no-state",
    default=True,
    show_default=True,
    help="Include local sheet state if available.",
)
@click.option(
    "--apply/--dry-run",
    default=False,
    show_default=True,
    help="Submit the ticket (default: dry-run).",
)
@click.option(
    "--idempotency-key",
    help="Optional Idempotency-Key header value.",
)
def support_bug(
    summary: str,
    description: str,
    category: str,
    severity: str,
    job_id: str | None,
    include_logs: bool,
    log_lines: int,
    include_config: bool,
    include_state: bool,
    apply: bool,
    idempotency_key: str | None,
) -> None:
    """
    Submit a bug report to support.

    Examples:
      g-gremlin support bug --summary "Crash on enrich" --description "App hangs after click" --apply
      g-gremlin support bug --summary "Timeout" --description "Job stuck for 20 minutes" --dry-run
    """
    _validate_support_fields(summary, description, category)

    correlation_id = None
    session_id = None
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            correlation_id = ctx.obj.get("correlation_id")
            session_id = ctx.obj.get("session_id")
    except Exception:
        pass

    state_snapshot = _load_sheet_state_snapshot() if include_state else None
    extra = {"support_kind": "bug"}
    if correlation_id:
        extra["correlation_id"] = correlation_id
    if session_id:
        extra["session_id"] = session_id
    context = _build_support_context(
        include_logs=include_logs,
        log_lines=log_lines,
        include_config=include_config,
        include_state=include_state,
        extra=extra,
    )
    context["support_info"] = _collect_support_info(correlation_id)
    context["support_info"] = _collect_support_info(correlation_id)

    payload = {
        "type": "support",
        "source": "gremlin",
        "category": category.strip(),
        "severity": severity.lower(),
        "summary": summary.strip(),
        "description": description.strip(),
        "job_id": job_id,
        "doc_id": state_snapshot.get("spreadsheet_id") if state_snapshot else None,
        "context_json": context,
    }

    _submit_support_ticket(payload, dry_run=not apply, idempotency_key=idempotency_key)


@support.command("feature")
@click.option("--summary", required=True, help="Short summary (8-120 chars).")
@click.option("--description", required=True, help="Problem statement (min 20 chars).")
@click.option("--category", default="gremlin", show_default=True, help="Feature category.")
@click.option(
    "--priority",
    type=click.Choice(["nice", "important", "critical"], case_sensitive=False),
    default="important",
    show_default=True,
    help="Requested priority.",
)
@click.option(
    "--include-logs/--no-logs",
    default=True,
    show_default=True,
    help="Include recent g-gremlin logs if available.",
)
@click.option(
    "--log-lines",
    default=50,
    show_default=True,
    type=int,
    help="Max log lines to include.",
)
@click.option(
    "--include-config/--no-config",
    default=True,
    show_default=True,
    help="Include sanitized g-gremlin config if available.",
)
@click.option(
    "--include-state/--no-state",
    default=True,
    show_default=True,
    help="Include local sheet state if available.",
)
@click.option(
    "--apply/--dry-run",
    default=False,
    show_default=True,
    help="Submit the ticket (default: dry-run).",
)
@click.option(
    "--idempotency-key",
    help="Optional Idempotency-Key header value.",
)
def support_feature(
    summary: str,
    description: str,
    category: str,
    priority: str,
    include_logs: bool,
    log_lines: int,
    include_config: bool,
    include_state: bool,
    apply: bool,
    idempotency_key: str | None,
) -> None:
    """
    Submit a feature request to support.

    Examples:
      g-gremlin support feature --summary "Add CSV preview" --description "Preview before write" --apply
      g-gremlin support feature --summary "New filter" --description "Add exclude list option" --dry-run
    """
    _validate_support_fields(summary, description, category)

    correlation_id = None
    session_id = None
    try:
        ctx = click.get_current_context(silent=True)
        if ctx and ctx.obj:
            correlation_id = ctx.obj.get("correlation_id")
            session_id = ctx.obj.get("session_id")
    except Exception:
        pass

    state_snapshot = _load_sheet_state_snapshot() if include_state else None
    extra = {"support_kind": "feature", "feature_priority": priority.lower()}
    if correlation_id:
        extra["correlation_id"] = correlation_id
    if session_id:
        extra["session_id"] = session_id
    context = _build_support_context(
        include_logs=include_logs,
        log_lines=log_lines,
        include_config=include_config,
        include_state=include_state,
        extra=extra,
    )

    payload = {
        "type": "feature",
        "source": "gremlin",
        "category": category.strip(),
        "severity": None,
        "summary": summary.strip(),
        "description": description.strip(),
        "doc_id": state_snapshot.get("spreadsheet_id") if state_snapshot else None,
        "context_json": context,
    }

    _submit_support_ticket(payload, dry_run=not apply, idempotency_key=idempotency_key)


@cli.group()
def notify() -> None:
    """
    Job-level notifications (webhook/Slack/email).

    Default: dry-run unless --apply is provided.
    """


@notify.command("webhook")
@click.argument("url")
@click.option(
    "--input",
    "input_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default="-",
    show_default=True,
    help="Input payload file (default: stdin).",
)
@click.option(
    "--template",
    "template_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional template file for message body (uses str.format on JSON input).",
)
@click.option(
    "--body",
    "body_template",
    help="Inline template for message body (uses str.format on JSON input).",
)
@click.option(
    "--idempotency-key",
    "idempotency_key",
    help="Explicit Idempotency-Key header value.",
)
@click.option(
    "--idempotency-key-field",
    "idempotency_key_field",
    help="Field name in JSON input to use for Idempotency-Key (fallback: plan_hash/correlation_id).",
)
@click.option(
    "--redact/--no-redact",
    default=True,
    show_default=True,
    help="Redact common PII in previews/logs.",
)
@click.option(
    "--timeout",
    default=15.0,
    show_default=True,
    type=float,
    help="Request timeout (seconds).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Send the notification (default is dry-run preview).",
)
@click.option(
    "--yes",
    is_flag=True,
    help="Skip confirmation prompt.",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format for summary (text|json).",
)
@click.option(
    "--json",
    "json_flag",
    is_flag=True,
    help="Shortcut for --format json.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write JSON summary to a file (use - for stdout).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def notify_webhook(
    url: str,
    input_path: Path | None,
    template_path: Path | None,
    body_template: str | None,
    idempotency_key: str | None,
    idempotency_key_field: str | None,
    redact: bool,
    timeout: float,
    apply: bool,
    yes: bool,
    output_format: str,
    json_flag: bool,
    output_path: Path | None,
    json_summary: bool,
) -> None:
    """
    Send a job-level notification to a webhook (Slack/Discord/custom).

    Reads JSON from stdin by default so it can be piped from other commands.
    """
    emit_json = _should_emit_json(output_format, json_flag, json_summary) or output_path is not None
    raw_input = _read_input_source(input_path)
    parsed: Any | None = None
    if raw_input.strip():
        try:
            parsed = json.loads(raw_input)
        except json.JSONDecodeError:
            parsed = None

    message_text: str | None = None
    if template_path or body_template:
        template = body_template or template_path.read_text(encoding="utf-8")
        context = parsed if isinstance(parsed, dict) else {}
        try:
            message_text = template.format(**context)
        except KeyError as exc:
            click.echo(f"Template error - missing key: {exc}", err=True)
            sys.exit(EXIT_USAGE)
    elif not raw_input.strip():
        click.echo("No input provided. Pipe JSON to stdin or use --body/--template.", err=True)
        sys.exit(EXIT_USAGE)

    payload_json: Any | None = None
    payload_content: str | None = None

    if message_text:
        payload_json = {"text": message_text}
        if isinstance(parsed, (dict, list)):
            payload_json["data"] = parsed
    elif isinstance(parsed, (dict, list)):
        payload_json = parsed
    else:
        payload_content = raw_input

    if payload_json is None and payload_content is None:
        click.echo("Nothing to send. Provide JSON input or a message template.", err=True)
        sys.exit(EXIT_USAGE)

    idem_key = _compute_idempotency_key(parsed, idempotency_key, idempotency_key_field)
    headers = {"Idempotency-Key": idem_key}
    if payload_json is not None:
        headers["Content-Type"] = "application/json"
    else:
        headers["Content-Type"] = "text/plain"

    preview_text = message_text or (payload_content if isinstance(payload_content, str) else "")
    if redact:
        preview_text = _redact_text(preview_text)
    click.echo(f"Target: {url}")
    click.echo(f"Idempotency-Key: {idem_key}")
    if preview_text:
        click.echo(f"Message preview: {preview_text[:500]}")
    elif payload_json is not None:
        preview_payload = json.dumps(payload_json)[:500]
        click.echo(f"Payload preview: {_redact_text(preview_payload) if redact else preview_payload}")

    if not apply:
        status = "dry_run"
        summary = {"status": status, "target": url, "idempotency_key": idem_key}
        if emit_json:
            _emit_json_payload(json.dumps(summary, indent=2), output_path)
        return

    if not yes and sys.stdin.isatty():
        if not click.confirm("Send notification?", default=False):
            click.echo("Aborted.")
            return

    try:
        import httpx  # lazy import to keep startup fast
    except Exception as exc:
        click.echo(f"Missing httpx dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    try:
        resp = httpx.post(
            url,
            json=payload_json,
            content=None if payload_json is not None else payload_content,
            headers=headers,
            timeout=timeout,
        )
    except Exception as exc:
        click.echo(f"Error sending notification: {exc}", err=True)
        if emit_json:
            summary = {
                "status": "error",
                "error": str(exc),
                "target": url,
                "idempotency_key": idem_key,
            }
            _emit_json_payload(json.dumps(summary, indent=2), output_path)
        sys.exit(EXIT_REMOTE_ERROR)

    success = 200 <= resp.status_code < 300
    status_code = resp.status_code
    if success:
        click.echo(f"Delivered notification ({status_code}).")
        summary = {
            "status": "success",
            "target": url,
            "idempotency_key": idem_key,
            "status_code": status_code,
        }
        if emit_json:
            _emit_json_payload(json.dumps(summary, indent=2), output_path)
        sys.exit(EXIT_SUCCESS)

    # Non-2xx response
    click.echo(f"Notification failed ({status_code}): {resp.text[:500]}", err=True)
    summary = {
        "status": "failed",
        "target": url,
        "idempotency_key": idem_key,
        "status_code": status_code,
        "body": resp.text[:500],
    }
    if emit_json:
        _emit_json_payload(json.dumps(summary, indent=2), output_path)
    if status_code >= 500:
        sys.exit(EXIT_REMOTE_ERROR)
    sys.exit(EXIT_ISSUES)


@cli.command("query")
@click.argument("sql")
@click.option(
    "--output",
    type=click.Path(path_type=Path, dir_okay=False),
    help="Output file path (.csv/.parquet/.json). Writes to stdout if omitted.",
)
@click.option(
    "--overwrite",
    is_flag=True,
    help="Allow overwriting an existing output file.",
)
@click.option(
    "--explain",
    is_flag=True,
    help="Show EXPLAIN plan without executing the query.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@click.option(
    "--format",
    "fmt",
    type=click.Choice(["csv", "parquet", "json"], case_sensitive=False),
    help="Force output format (otherwise inferred from file extension).",
)
@click.option(
    "--memory-limit",
    type=str,
    help="DuckDB memory limit (e.g., 2GB or 50%%). Defaults to min(2GB, 50%% of RAM).",
)
def query_command(
    sql: str,
    output: Path | None,
    overwrite: bool,
    explain: bool,
    json_summary: bool,
    fmt: str | None,
    memory_limit: str | None,
) -> None:
    """
    Run a read-only DuckDB query against local CSV/Parquet files.

    This command is intentionally read-only (SELECT/CTE/CREATE TEMP VIEW).
    For large results, provide --output; stdout is capped to avoid OOM.
    """
    safety_ctx = _get_query_helpers().get_safety_context()

    try:
        outcome = _get_query_helpers().run_query(
            sql=sql,
            output=output,
            overwrite=overwrite,
            explain=explain,
            fmt=fmt,
            memory_limit=memory_limit,
        )
    except click.ClickException:
        raise
    except Exception as exc:
        raise click.ClickException(f"Query failed: {exc}") from exc

    agentic = safety_ctx.build_result(
        status=outcome.status,
        dry_run=False,
        result={
            "row_count": outcome.row_count,
            "output_path": outcome.output_path,
            "output_format": outcome.output_format,
            "explain_plan": outcome.explain_plan,
        },
        warnings=[
            _get_query_helpers().warning_to_agentic(w) for w in outcome.warnings
        ],
    )

    if json_summary:
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if explain and outcome.explain_plan:
        click.echo(outcome.explain_plan)
        return

    if outcome.output_path:
        row_text = outcome.row_count if outcome.row_count is not None else "unknown"
        click.echo(f"Wrote {row_text} rows to {outcome.output_path}.")
        # Write provenance sidecar when materializing output files.
        try:
            prov = _get_provenance_helpers().build_provenance(
                command="g-gremlin query",
                args={
                    "sql": sql,
                    "output": outcome.output_path,
                    "explain": explain,
                    "format": fmt,
                    "memory_limit": memory_limit,
                },
                inputs=[],
            )
            sidecar_dir = _get_provenance_helpers().default_provenance_dir()
            sidecar_path = _get_provenance_helpers().write_sidecar(
                Path(outcome.output_path),
                prov,
                provenance_dir=sidecar_dir,
            )
            click.echo(f"Provenance written to {sidecar_path}.")
        except Exception:
            click.echo("Warning: failed to write provenance sidecar.", err=True)
    else:
        row_text = outcome.row_count if outcome.row_count is not None else "unknown"
        click.echo(f"Returned {row_text} row(s) to stdout.")


@cli.group()
def provenance() -> None:
    """
    Provenance utilities.
    """


@provenance.command("cleanup")
@click.option(
    "--days",
    type=int,
    default=None,
    help="Remove provenance files older than this many days (default: config or 30).",
)
@click.option(
    "--path",
    "target_path",
    type=click.Path(path_type=Path),
    help="Directory to clean. Defaults to configured provenance_dir or .g_gremlin if present.",
)
@click.option(
    "--include-sidecars/--skip-sidecars",
    default=True,
    help="Whether to delete *.gremlin.meta.json sidecars.",
)
def provenance_cleanup(days: int | None, target_path: Path | None, include_sidecars: bool) -> None:
    """
    Cleanup old provenance files based on retention policy.
    """
    retention_days = days if days is not None else _get_provenance_helpers().default_retention_days()
    target = target_path or _get_provenance_helpers().default_provenance_dir() or Path(".g_gremlin")

    if not target.exists():
        click.echo(f"No provenance directory found at {target}. Nothing to clean.")
        return

    removed = _get_provenance_helpers().cleanup_provenance(
        target, days=retention_days, include_sidecars=include_sidecars
    )
    click.echo(f"Removed {len(removed)} provenance file(s) from {target}.")


@cli.group()
def sfdc() -> None:
    """
    Salesforce helpers (query, describe, snapshot, enrich, packs, metadata).

    \b
    IMPORTANT: When a g-gremlin sfdc command exists for an operation, use it
    instead of raw 'sf' CLI calls. g-gremlin wraps sf with receipts, previews,
    plan-hash write gates, and audit trails that raw sf bypasses.

    \b
    Common workflows:
      g-gremlin sfdc doctor --json              # check SF CLI + auth
      g-gremlin sfdc query --soql "..." --out r.csv  # SOQL to CSV
      g-gremlin sfdc describe Account --json    # schema introspection
      g-gremlin sfdc snapshot --out ./snap ...  # deterministic export
      g-gremlin sfdc preview-push --csv ...     # diff before write
      g-gremlin sfdc pack plan DIR --json       # plan data changes
      g-gremlin sfdc pack apply DIR --yes       # apply with receipt
    """


@cli.group()
def zendesk() -> None:
    """
    Zendesk helpers.
    """


@cli.group()
@click.option(
    "--profile",
    help="HubSpot profile name (uses current if omitted).",
)
@click.pass_context
def hubspot(ctx: click.Context, profile: str | None) -> None:
    """
    HubSpot admin CLI (props, lists, pipelines, workflows, packs, snapshots).

    \b
    IMPORTANT: When a g-gremlin hubspot command exists for an operation, use it
    instead of raw HubSpot API calls. g-gremlin wraps HubSpot with snapshots,
    planned apply flows, before/after diffs, and rollback support.
    """
    ctx.ensure_object(dict)
    if profile:
        ctx.obj["hubspot_profile"] = profile
        os.environ["G_GREMLIN_HUBSPOT_PROFILE"] = profile


@hubspot.group("auth")
def hubspot_auth() -> None:
    """HubSpot auth profile management."""


@hubspot_auth.command("list")
def hubspot_auth_list() -> None:
    """List saved HubSpot profiles and current selection."""
    hs_mod = _get_hubspot_helpers()
    store = hs_mod.ProfileStore.load(hs_mod.CONFIG_PATH, default_profile=hs_mod.DEFAULT_PROFILE)
    current = store.current_profile_name()
    profiles = store.list_profiles()
    if not profiles:
        click.echo("No HubSpot profiles found. Run: g-gremlin hubspot connect --access-token <token> --profile <name>")
        return
    for name in profiles:
        marker = "*" if name == current else " "
        data = store.profiles.get(name, {})
        portal_id = str(data.get("portal_id") or "")
        hub_domain = data.get("hub_domain") or ""
        verified = data.get("verified", True)
        parts = [f"{marker} {name}"]
        if portal_id:
            parts.append(f"portal:{portal_id}")
            if hub_domain:
                parts.append(hub_domain)
        else:
            parts.append("(unknown portal)")
        if not verified:
            parts.append("(unverified)")
        click.echo("  ".join(parts))


@hubspot_auth.command("use")
@click.argument("profile")
def hubspot_auth_use(profile: str) -> None:
    """Set the current HubSpot profile."""
    hs_mod = _get_hubspot_helpers()
    store = hs_mod.ProfileStore.load(hs_mod.CONFIG_PATH, default_profile=hs_mod.DEFAULT_PROFILE)
    try:
        store.set_current(profile)
    except hs_mod.ProfileNotFoundError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)
    store.save()
    click.echo(f"Current HubSpot profile set to '{profile}'.")


@hubspot_auth.command("remove")
@click.argument("profile")
def hubspot_auth_remove(profile: str) -> None:
    """Remove a HubSpot profile."""
    hs_mod = _get_hubspot_helpers()
    store = hs_mod.ProfileStore.load(hs_mod.CONFIG_PATH, default_profile=hs_mod.DEFAULT_PROFILE)
    if profile not in store.profiles:
        click.echo(f"Profile not found: {profile}", err=True)
        sys.exit(EXIT_USAGE)
    del store.profiles[profile]
    if store.current == profile:
        store.current = store.default_profile if store.default_profile in store.profiles else ""
    store.save()
    click.echo(f"Removed HubSpot profile '{profile}'.")
    if store.current == "":
        click.echo("No current HubSpot profile set. Run 'g-gremlin hubspot auth use <name>'.", err=True)


@hubspot.group("oauth")
def hubspot_oauth() -> None:
    """
    HubSpot OAuth helpers (recommended for non-technical onboarding).

    This flow connects HubSpot through FoundryOps web OAuth and stores
    refreshable tokens server-side. PAT-based `hubspot connect` remains
    available as an advanced fallback.
    """


@hubspot_oauth.command("connect")
@click.option(
    "--workspace-id",
    envvar="FOUNDRYMATCH_WORKSPACE_ID",
    help="Optional workspace/org ID override. Defaults to authenticated workspace.",
)
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--no-browser",
    is_flag=True,
    help="Do not open browser automatically.",
)
def hubspot_oauth_connect(
    workspace_id: str | None,
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    no_browser: bool,
) -> None:
    """Start browser OAuth for HubSpot and wait for completion."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="hubspot",
        auth_token=auth_token,
        api_key=api_key,
        workspace_id=workspace_id,
        api_base=api_base,
        dev=dev,
    )
    base = resolution.api_base or _apollo_api_base(api_base)
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    session_id = str(uuid.uuid4())

    try:
        authorize_params = {"session_id": session_id}
        if resolution.workspace_id:
            authorize_params["workspace_id"] = resolution.workspace_id
        resp = requests.get(
            f"{base}/api/v2/integrations/hubspot/cli/authorize",
            params=authorize_params,
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error starting HubSpot OAuth: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code != 200:
        click.echo(
            f"Error starting HubSpot OAuth ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code in (401, 403):
            click.echo("Tip: re-authenticate with 'g-gremlin auth login'.", err=True)
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        authorize_payload = resp.json()
    except Exception:
        authorize_payload = {}
    if not isinstance(authorize_payload, dict):
        authorize_payload = {}
    auth_url = authorize_payload.get("auth_url")
    authorize_workspace_id = (
        str(authorize_payload.get("workspace_id") or resolution.workspace_id or "").strip()
    )

    if not auth_url:
        click.echo("Error: Missing auth_url from server.", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo("Opening browser for HubSpot authorization...")
    click.echo(f"\nIf browser doesn't open, visit:\n{auth_url}\n")
    if not no_browser:
        try:
            webbrowser.open(auth_url)
        except Exception:
            pass

    for _ in range(60):
        time.sleep(5)
        try:
            poll = requests.get(
                f"{base}/api/v2/integrations/hubspot/cli/poll",
                params={"session_id": session_id},
                timeout=15,
            )
        except Exception as exc:
            click.echo(f"Error polling HubSpot OAuth: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if poll.status_code != 200:
            click.echo(f"Poll failed ({poll.status_code}): {poll.text[:300]}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        try:
            payload = poll.json()
        except Exception:
            payload = {}

        status = payload.get("status")
        if status == "complete":
            workspace_for_token = (
                payload.get("workspace_id")
                or authorize_workspace_id
                or resolution.workspace_id
            )
            token_params = {"session_id": session_id}
            if workspace_for_token:
                token_params["workspace_id"] = workspace_for_token
            try:
                token_resp = requests.get(
                    f"{base}/api/v2/integrations/hubspot/cli/token",
                    params=token_params,
                    headers=headers,
                    timeout=20,
                )
            except Exception as exc:
                click.echo(
                    click.style("X ", fg="red")
                    + f"HubSpot OAuth finished, but local credential provisioning failed: {exc}",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

            if token_resp.status_code != 200:
                click.echo(
                    click.style("X ", fg="red")
                    + (
                        "HubSpot OAuth finished, but failed to retrieve local token "
                        f"({token_resp.status_code}): {token_resp.text[:300]}"
                    ),
                    err=True,
                )
                sys.exit(EXIT_ISSUES)

            try:
                token_payload = token_resp.json()
            except Exception:
                token_payload = {}
            access_token = str(token_payload.get("access_token") or "")
            if not access_token:
                click.echo(
                    click.style("X ", fg="red")
                    + "HubSpot OAuth finished, but token payload was invalid.",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

            hs_mod = _get_hubspot_helpers()
            api_version = str(token_payload.get("api_version") or hs_mod.DEFAULT_API_VERSION)
            try:
                cfg = hs_mod.connect(
                    access_token=access_token,
                    api_version=api_version,
                    profile=_get_hubspot_profile(),
                    set_current=True,
                    portal_id=str(token_payload.get("portal_id") or ""),
                    hub_domain=token_payload.get("hub_domain") or "",
                    time_zone=token_payload.get("time_zone") or "",
                    verified=True,
                )
            except Exception as exc:
                click.echo(
                    click.style("X ", fg="red")
                    + f"HubSpot OAuth finished, but writing local profile failed: {exc}",
                    err=True,
                )
                sys.exit(EXIT_ISSUES)

            click.echo(click.style("OK ", fg="green") + "HubSpot OAuth connected.")
            click.echo(f"Local HubSpot profile saved to {hs_mod.CONFIG_PATH} (profile={cfg.profile}).")
            return
        if status == "error":
            message = payload.get("message") or "HubSpot authorization failed."
            click.echo(click.style("X ", fg="red") + message, err=True)
            sys.exit(EXIT_ISSUES)

        click.echo(".", nl=False)

    click.echo("")
    click.echo(click.style("X ", fg="red") + "Timeout waiting for HubSpot authorization.", err=True)
    sys.exit(EXIT_ISSUES)


@hubspot_oauth.command("status")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON status output.",
)
def hubspot_oauth_status(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    json_output: bool,
) -> None:
    """Check server-side HubSpot OAuth status."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="hubspot",
        auth_token=auth_token,
        api_key=api_key,
        api_base=api_base,
        dev=dev,
    )
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    base = resolution.api_base or _apollo_api_base(api_base)
    try:
        resp = requests.get(
            f"{base}/api/v2/integrations/hubspot/status",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error checking HubSpot OAuth status: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code in (401, 403):
        click.echo("Unauthorized. Re-authenticate with 'g-gremlin auth login'.", err=True)
        sys.exit(EXIT_ISSUES)
    if resp.status_code != 200:
        click.echo(
            f"Error checking HubSpot OAuth status ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        payload = resp.json()
    except Exception:
        payload = {}

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    connected = bool(payload.get("connected"))
    status = payload.get("status") or ("connected" if connected else "disconnected")
    click.echo(f"Status: {status}")
    if connected:
        portal_id = payload.get("portal_id") or "unknown"
        hub_domain = payload.get("hub_domain") or "unknown"
        click.echo(f"Portal: {portal_id} ({hub_domain})")
        if payload.get("expires_at"):
            click.echo(f"Expires at: {payload.get('expires_at')}")
        if payload.get("needs_refresh"):
            click.echo("Token refresh: due soon")
    elif payload.get("error"):
        error = payload.get("error") or {}
        click.echo(f"Error: {error.get('message') or error}", err=True)


@hubspot.command("connect")
@click.option(
    "--access-token",
    required=True,
    help="HubSpot Private App access token.",
)
@click.option(
    "--api-version",
    default="v3",
    show_default=True,
    help="API version for CRM operations.",
)
@click.option(
    "--set-current/--no-set-current",
    default=True,
    show_default=True,
    help="Set this profile as the current default.",
)
def hubspot_connect(access_token: str, api_version: str, set_current: bool) -> None:
    """
    Store HubSpot PAT credentials locally (advanced fallback).

    Recommended for most users: `g-gremlin hubspot oauth connect`.
    """
    click.echo("Verifying access with hubspot doctor...")
    hs_mod = _get_hubspot_helpers()
    identity: Dict[str, Any] = {}
    verified = False
    try:
        client = hs_mod.HubSpotClient(access_token=access_token, api_version=api_version)
        identity = client.whoami()
        checks = client.probe_capabilities()
        denied = [c.name for c in checks if c.status == "denied"]
        verified = True
        if denied:
            click.echo(f"Missing scopes for: {', '.join(denied)}", err=True)
            click.echo("Run: g-gremlin hubspot setup --pack <dir> or --full-beta", err=True)
        else:
            click.echo(
                f"Doctor OK: portal {identity.get('portal_id', 'unknown')} "
                f"({identity.get('hub_domain', 'unknown')})"
            )
    except Exception as exc:
        click.echo(f"Doctor failed: {exc}", err=True)
    cfg = hs_mod.connect(
        access_token=access_token,
        api_version=api_version,
        profile=_get_hubspot_profile(),
        set_current=set_current,
        portal_id=str(identity.get("portal_id") or ""),
        hub_domain=identity.get("hub_domain") or "",
        time_zone=identity.get("time_zone") or "",
        verified=verified,
    )
    click.echo(
        f"HubSpot config saved to {hs_mod.CONFIG_PATH} "
        f"(profile={cfg.profile}, api_version={cfg.api_version})."
    )
    if not verified:
        click.echo("Token saved but not validated. Run 'g-gremlin hubspot doctor' to verify.", err=True)


@hubspot.command("open")
@click.option(
    "--path",
    help="Path within app.hubspot.com (use {portal_id} to insert).",
)
def hubspot_open(path: str | None) -> None:
    """Open HubSpot in the browser for the current profile."""
    hs_mod = _get_hubspot_helpers()
    try:
        cfg = hs_mod.HubSpotConfig.load(profile=_get_hubspot_profile())
    except hs_mod.HubSpotError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)

    portal_id = cfg.portal_id or ""
    if not portal_id:
        click.echo(
            "No portal ID stored for this profile. Re-run 'g-gremlin hubspot connect --access-token <token>' to verify.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    target_path = path or f"/contacts/{portal_id}"
    if "{portal_id}" in target_path:
        target_path = target_path.format(portal_id=portal_id)
    if not target_path.startswith("/"):
        target_path = f"/{target_path}"
    url = f"https://app.hubspot.com{target_path}"
    click.echo(f"Opening {url}")
    try:
        webbrowser.open(url)
    except Exception as exc:
        click.echo(f"Failed to open browser: {exc}", err=True)
        click.echo(f"Visit: {url}", err=True)


@hubspot.command("query")
@click.argument("object_type")
@click.option(
    "--where",
    "where_clauses",
    multiple=True,
    help="Filter in property=value, property!=value, property>=value, property<=value, property>value, or property<value form (repeatable). Date filters accept YYYY-MM-DD, ISO 8601, or epoch milliseconds.",
)
@click.option(
    "--filter-json",
    "filter_json_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to JSON file containing HubSpot filterGroups.",
)
@click.option(
    "--properties",
    help="Comma-separated list of properties to return.",
)
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Max records to return (0 = all, up to 10k).",
)
@click.option(
    "--sort",
    "sort_spec",
    help="Sort property (prefix - for desc).",
)
@click.option(
    "--group-by",
    "group_by_spec",
    help="Comma-separated property names to group by. Without aggregate flags, returns distinct groups.",
)
@click.option(
    "--sum",
    "sum_specs",
    multiple=True,
    help="Property to sum (repeatable or comma-separated).",
)
@click.option(
    "--avg",
    "avg_specs",
    multiple=True,
    help="Property to average (repeatable or comma-separated).",
)
@click.option(
    "--min",
    "min_specs",
    multiple=True,
    help="Property to take the minimum of (repeatable or comma-separated).",
)
@click.option(
    "--max",
    "max_specs",
    multiple=True,
    help="Property to take the maximum of (repeatable or comma-separated).",
)
@click.option(
    "--count",
    "count_rows",
    is_flag=True,
    help="Add a count column.",
)
@click.option(
    "--bucket-date",
    "bucket_date_property",
    help="Date/datetime property to bucket before aggregation.",
)
@click.option(
    "--bucket-interval",
    type=click.Choice(["week", "month", "quarter"], case_sensitive=False),
    help="Time bucket interval used with --bucket-date.",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV (.csv) or JSON (.json).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON to stdout.",
)
@click.pass_context
def hubspot_query(
    ctx: click.Context,
    object_type: str,
    where_clauses: tuple[str, ...],
    filter_json_path: Path | None,
    properties: str | None,
    limit: int,
    sort_spec: str | None,
    group_by_spec: str | None,
    sum_specs: tuple[str, ...],
    avg_specs: tuple[str, ...],
    min_specs: tuple[str, ...],
    max_specs: tuple[str, ...],
    count_rows: bool,
    bucket_date_property: str | None,
    bucket_interval: str | None,
    out_path: Path | None,
    json_output: bool,
) -> None:
    """Query HubSpot objects via the Search API."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    if filter_json_path and where_clauses:
        raise click.ClickException("Use either --where or --filter-json, not both.")
    if out_path and json_output:
        raise click.ClickException("Use either --out or --json, not both.")
    if bucket_date_property and not bucket_interval:
        raise click.ClickException("--bucket-interval is required with --bucket-date.")
    if bucket_interval and not bucket_date_property:
        raise click.ClickException("--bucket-date is required with --bucket-interval.")

    if properties:
        prop_list = [p.strip() for p in properties.split(",") if p.strip()]
        if not prop_list:
            raise click.ClickException("--properties cannot be empty.")
    else:
        prop_list = ["id", "hs_object_id"]

    group_by = _split_comma_list(group_by_spec) if group_by_spec else []
    sum_fields = _expand_hubspot_property_options(sum_specs)
    avg_fields = _expand_hubspot_property_options(avg_specs)
    min_fields = _expand_hubspot_property_options(min_specs)
    max_fields = _expand_hubspot_property_options(max_specs)

    aggregate_mod = _get_hubspot_aggregate_helpers()
    aggregation_plan = aggregate_mod.QueryPlan(
        object_type=object_type,
        properties=prop_list,
        limit=limit,
        limit_explicit=ctx.get_parameter_source("limit") != ParameterSource.DEFAULT,
        group_by=group_by,
        sum_fields=sum_fields,
        avg_fields=avg_fields,
        min_fields=min_fields,
        max_fields=max_fields,
        count_rows=count_rows,
        bucket_date=bucket_date_property,
        bucket_interval=(bucket_interval or "").lower() or None,
    )
    if aggregation_plan.aggregation_mode and ctx.get_parameter_source("limit") == ParameterSource.DEFAULT:
        aggregation_plan.limit = 0

    filter_groups: List[Dict[str, Any]] = []
    if filter_json_path:
        try:
            raw = json.loads(filter_json_path.read_text(encoding="utf-8"))
        except Exception as exc:
            raise click.ClickException(f"Invalid JSON in {filter_json_path}: {exc}") from exc
        if isinstance(raw, dict) and "filterGroups" in raw:
            raw = raw.get("filterGroups")
        if not isinstance(raw, list):
            raise click.ClickException("--filter-json must contain a list of filterGroups.")
        filter_groups = raw

    sorts = [sort_spec] if sort_spec else None
    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    property_definitions = _build_hubspot_property_definition_map(client, object_type) if where_clauses else {}
    if not filter_json_path:
        filters = _parse_hubspot_where_clauses(where_clauses, property_definitions=property_definitions)
        if filters:
            filter_groups = [{"filters": filters}]
    try:
        execution = aggregate_mod.execute_query_plan(
            client,
            aggregation_plan,
            filter_groups=filter_groups,
            sorts=sorts,
        )
    except Exception as exc:
        raise click.ClickException(f"HubSpot query failed: {exc}") from exc

    rows = execution.rows
    for warning in execution.warnings:
        click.echo(f"Warning: {warning}", err=True)

    if out_path:
        suffix = out_path.suffix.lower()
        if suffix == ".csv":
            _write_csv(out_path, rows)
        elif suffix == ".json":
            out_path.parent.mkdir(parents=True, exist_ok=True)
            out_path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
        else:
            raise click.ClickException("--out must end with .csv or .json.")
        click.echo(f"Wrote {len(rows)} record(s) to {out_path}.")
        return

    if json_output:
        click.echo(json.dumps(rows, indent=2))
        return

    if execution.aggregated:
        label = "group(s)" if aggregation_plan.group_by or aggregation_plan.bucket_column else "aggregated row(s)"
        click.echo(f"Found {len(rows)} {label}. Use --json or --out for full output.")
    else:
        click.echo(f"Found {len(rows)} record(s). Use --json or --out for full output.")
    for row in rows[:10]:
        click.echo(json.dumps(row))
    if len(rows) > 10:
        click.echo(f"... {len(rows) - 10} more")


def _write_json_file(output_path: Path, payload: Any) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def _emit_hubspot_read_output(
    *,
    rows: List[Dict[str, Any]],
    payload: Any,
    out_path: Path | None,
    json_output: bool,
    label: str,
) -> bool:
    if out_path:
        suffix = out_path.suffix.lower()
        if suffix == ".csv":
            _write_csv(out_path, rows)
        elif suffix == ".json":
            _write_json_file(out_path, payload)
        else:
            raise click.ClickException("--out must end with .csv or .json.")
        click.echo(f"Wrote {len(rows)} {label} to {out_path}.")
        return True
    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return True
    return False


@hubspot.command("validate")
def hubspot_validate() -> None:
    """
    Validate HubSpot access token by fetching a single contact.
    """
    try:
        resp = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile()).validate()
        click.echo("HubSpot credentials are valid.")
        if resp.get("sample"):
            click.echo("Sample response fetched.")
    except Exception as exc:
        click.echo(f"Validation failed: {exc}")
        sys.exit(1)


@hubspot.command("doctor")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_doctor(json_output: bool) -> None:
    """Validate HubSpot credentials and probe capabilities."""
    hs_mod = _get_hubspot_helpers()

    try:
        client = hs_mod.HubSpotClient()
    except Exception:
        click.echo("Error: HubSpot access token not configured.", err=True)
        click.echo("Run 'g-gremlin hubspot connect --access-token <token>' to configure.", err=True)
        sys.exit(EXIT_USAGE)

    provider = _get_credential_provider()
    source = None
    secret = provider.get_secret_or_none(
        "hubspot",
        "access_token",
        profile=_get_hubspot_profile() or "default",
    )
    if secret:
        source = secret.source.value
    else:
        try:
            cfg = hs_mod.HubSpotConfig.load(profile=_get_hubspot_profile())
            if cfg.access_token:
                source = "legacy_file"
        except Exception:
            source = None

    source_label = _format_source_label(source)
    token_line = "configured" if source else "not configured"
    if source:
        token_line = f"{token_line} ({source_label})"

    identity: Dict[str, Any] = {}
    identity_error = None
    try:
        identity = client.whoami()
    except Exception as exc:
        identity_error = str(exc)
        identity = {"error": identity_error}

    checks = client.probe_capabilities()
    overall_status = _overall_status_from_checks(checks, identity_ok=identity_error is None)
    rate_limit_line = "Private App (500 req/10s)"

    doctor = DoctorResult(
        provider="hubspot",
        checks=checks,
        identity={
            **identity,
            "credential_source": source_label if source else None,
            "rate_limit": rate_limit_line,
        },
        overall_status=overall_status,
    )

    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return

    click.echo(f"Access Token:  {token_line}")
    if identity_error:
        click.echo(f"Portal:        unavailable ({identity_error})")
    else:
        click.echo(
            f"Portal:        {identity.get('portal_id', 'unknown')} "
            f"({identity.get('hub_domain', 'unknown')})"
        )
        click.echo(f"Time Zone:     {identity.get('time_zone', 'unknown')}")
    click.echo("Capabilities:")
    for check in checks:
        click.echo(f"  {check.name:<12} {check.status}")
    click.echo(f"Rate Limit:    {rate_limit_line}")
    click.echo(f"Status:        {overall_status}")


@hubspot.command("whoami")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_whoami(json_output: bool) -> None:
    """Show current HubSpot portal details."""
    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient()
    except Exception:
        click.echo("Error: HubSpot access token not configured.", err=True)
        click.echo("Run 'g-gremlin hubspot connect --access-token <token>' to configure.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        identity = client.whoami()
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    doctor = DoctorResult(
        provider="hubspot",
        checks=[],
        identity=identity,
        overall_status="ok",
    )

    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return

    click.echo(f"Portal: {identity.get('portal_id', 'unknown')} ({identity.get('hub_domain', 'unknown')})")


@hubspot.group("forms")
def hubspot_forms() -> None:
    """HubSpot marketing form helpers."""


@hubspot_forms.command("ls")
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Maximum forms to return (0 = no limit).",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV (.csv) or JSON (.json).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_forms_ls(limit: int, out_path: Path | None, json_output: bool) -> None:
    """List HubSpot forms."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    hs_mod = _get_hubspot_helpers()
    marketing_mod = _get_hubspot_marketing_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        forms = marketing_mod.list_forms(client, limit=limit)
        rows = marketing_mod.format_forms_rows(forms)
    except Exception as exc:
        click.echo(f"HubSpot forms ls failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if _emit_hubspot_read_output(rows=rows, payload=forms, out_path=out_path, json_output=json_output, label="form(s)"):
        return
    if not rows:
        click.echo("No forms returned.")
        return
    for row in rows:
        click.echo(f"{row.get('id')}  {row.get('name')} ({row.get('formType')})")


@hubspot_forms.command("submissions")
@click.option("--form-id", required=True, help="HubSpot form GUID.")
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Maximum submissions to return (0 = no limit).",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV (.csv) or JSON (.json).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_forms_submissions(form_id: str, limit: int, out_path: Path | None, json_output: bool) -> None:
    """Pull submissions for a HubSpot form."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    hs_mod = _get_hubspot_helpers()
    marketing_mod = _get_hubspot_marketing_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        submissions = marketing_mod.list_form_submissions(client, form_id, limit=limit)
        rows = marketing_mod.format_form_submission_rows(submissions, form_id=form_id)
    except Exception as exc:
        click.echo(f"HubSpot forms submissions failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if _emit_hubspot_read_output(
        rows=rows,
        payload=submissions,
        out_path=out_path,
        json_output=json_output,
        label="submission(s)",
    ):
        return
    if not rows:
        click.echo("No submissions returned.")
        return
    click.echo(f"Found {len(rows)} submission(s).")
    for row in rows[:10]:
        click.echo(f"{row.get('submittedAt')}  {row.get('pageUrl')}")
    if len(rows) > 10:
        click.echo(f"... {len(rows) - 10} more")


@hubspot.group("emails")
def hubspot_emails() -> None:
    """HubSpot marketing email helpers."""


@hubspot_emails.command("ls")
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Maximum emails to return (0 = no limit).",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV (.csv) or JSON (.json).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_emails_ls(limit: int, out_path: Path | None, json_output: bool) -> None:
    """List HubSpot marketing emails."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    hs_mod = _get_hubspot_helpers()
    marketing_mod = _get_hubspot_marketing_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        emails = marketing_mod.list_emails(client, limit=limit)
        rows = marketing_mod.format_email_rows(emails)
    except Exception as exc:
        click.echo(f"HubSpot emails ls failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if _emit_hubspot_read_output(rows=rows, payload=emails, out_path=out_path, json_output=json_output, label="email(s)"):
        return
    if not rows:
        click.echo("No emails returned.")
        return
    for row in rows:
        click.echo(f"{row.get('id')}  {row.get('name')} ({row.get('state')})")


@hubspot_emails.command("stats")
@click.option("--email-id", required=True, help="HubSpot marketing email ID.")
@click.option("--start", "start_timestamp", default=None, help="Optional ISO8601 start timestamp.")
@click.option("--end", "end_timestamp", default=None, help="Optional ISO8601 end timestamp.")
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV (.csv) or JSON (.json).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_emails_stats(
    email_id: str,
    start_timestamp: str | None,
    end_timestamp: str | None,
    out_path: Path | None,
    json_output: bool,
) -> None:
    """Pull aggregated marketing email statistics."""
    hs_mod = _get_hubspot_helpers()
    marketing_mod = _get_hubspot_marketing_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        stats = marketing_mod.get_email_stats(
            client,
            email_id,
            start_timestamp=start_timestamp,
            end_timestamp=end_timestamp,
        )
        rows = marketing_mod.format_email_stats_rows(stats, email_id=email_id)
    except Exception as exc:
        click.echo(f"HubSpot emails stats failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if _emit_hubspot_read_output(
        rows=rows,
        payload=stats,
        out_path=out_path,
        json_output=json_output,
        label="stats row(s)",
    ):
        return
    if not rows:
        click.echo("No statistics returned.")
        return
    row = rows[0]
    click.echo(f"Email: {email_id}")
    for key in [
        "counters_sent",
        "counters_delivered",
        "counters_open",
        "counters_click",
        "counters_bounce",
        "ratios_openratio",
        "ratios_clickratio",
        "ratios_clickthroughratio",
    ]:
        if row.get(key) is not None:
            click.echo(f"{key}: {row.get(key)}")


@hubspot.group("campaigns")
def hubspot_campaigns() -> None:
    """HubSpot marketing campaign helpers."""


@hubspot_campaigns.command("ls")
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Maximum campaigns to return (0 = no limit).",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV (.csv) or JSON (.json).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_campaigns_ls(limit: int, out_path: Path | None, json_output: bool) -> None:
    """List HubSpot campaigns."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    hs_mod = _get_hubspot_helpers()
    marketing_mod = _get_hubspot_marketing_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        campaigns = marketing_mod.list_campaigns(client, limit=limit)
        rows = marketing_mod.format_campaign_rows(campaigns)
    except Exception as exc:
        click.echo(f"HubSpot campaigns ls failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if _emit_hubspot_read_output(
        rows=rows,
        payload=campaigns,
        out_path=out_path,
        json_output=json_output,
        label="campaign(s)",
    ):
        return
    if not rows:
        click.echo("No campaigns returned.")
        return
    for row in rows:
        click.echo(f"{row.get('id')}  {row.get('name')}")


@hubspot.group("admins")
def hubspot_admins() -> None:
    """HubSpot admin governance helpers (users, goals)."""


@hubspot_admins.group("users")
def hubspot_admins_users() -> None:
    """HubSpot user/owner governance helpers."""


@hubspot_admins_users.command("ls")
@click.option(
    "--include-archived",
    is_flag=True,
    help="Include archived users.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_admins_users_ls(include_archived: bool, json_output: bool) -> None:
    """List HubSpot users (via owners endpoint)."""
    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        users = client.get_users(include_archived=include_archived)
    except Exception as exc:
        click.echo(f"HubSpot users list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"count": len(users), "users": users}, indent=2))
        return

    if not users:
        click.echo("No users returned.")
        return

    for user in users:
        user_id = user.get("id") or user.get("userId") or user.get("ownerId") or ""
        email = user.get("email") or ""
        first = user.get("firstName") or ""
        last = user.get("lastName") or ""
        name = f"{first} {last}".strip()
        archived = bool(user.get("archived", False))
        archived_suffix = " archived" if archived else ""
        if name:
            click.echo(f"{user_id}  {name}  <{email}>{archived_suffix}")
        else:
            click.echo(f"{user_id}  {email}{archived_suffix}")


@hubspot_admins.group("goals")
def hubspot_admins_goals() -> None:
    """HubSpot goals governance helpers."""


@hubspot_admins_goals.command("ls")
@click.option(
    "--limit",
    type=int,
    default=1000,
    show_default=True,
    help="Maximum goals to return (0 = no limit).",
)
@click.option(
    "--properties",
    default=None,
    help="Optional comma-separated goal properties to include.",
)
@click.option(
    "--archived",
    is_flag=True,
    help="Include archived goals.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_admins_goals_ls(limit: int, properties: str | None, archived: bool, json_output: bool) -> None:
    """List HubSpot goals."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    prop_list = _split_comma_list(properties) if properties else []

    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        goals = client.get_goals(properties=prop_list, limit=limit, archived=archived)
    except Exception as exc:
        click.echo(f"HubSpot goals list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"count": len(goals), "goals": goals}, indent=2))
        return

    if not goals:
        click.echo("No goals returned.")
        return

    for goal in goals:
        goal_id = goal.get("id") or ""
        label = (
            goal.get("goal_name")
            or goal.get("hs_name")
            or goal.get("name")
            or goal.get("title")
            or ""
        )
        if label:
            click.echo(f"{goal_id}  {label}")
        else:
            click.echo(f"{goal_id}")


@hubspot_admins_goals.command("pull")
@click.argument("goal_id")
@click.option(
    "--properties",
    default=None,
    help="Optional comma-separated goal properties to include.",
)
@click.option(
    "--id-property",
    default=None,
    help="Optional alternate id property used to resolve goal_id.",
)
@click.option(
    "--archived",
    is_flag=True,
    help="Include archived goals.",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Optional output JSON file path.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_admins_goals_pull(
    goal_id: str,
    properties: str | None,
    id_property: str | None,
    archived: bool,
    out_path: Path | None,
    json_output: bool,
) -> None:
    """Pull a single HubSpot goal record."""
    prop_list = _split_comma_list(properties) if properties else []
    hs_mod = _get_hubspot_helpers()

    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        payload = client.get_goal(
            goal_id,
            properties=prop_list,
            archived=archived,
            id_property=id_property,
        )
    except Exception as exc:
        click.echo(f"HubSpot goals pull failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    payload_json = json.dumps(payload, indent=2)
    if out_path:
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(payload_json, encoding="utf-8")

    if json_output:
        click.echo(payload_json)
        return

    if out_path:
        click.echo(f"Wrote {out_path}.")
    else:
        click.echo(payload_json)


@hubspot.group("schema")
def hubspot_schema() -> None:
    """HubSpot schema cache helpers."""


@hubspot_schema.command("sync")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_schema_sync(json_output: bool) -> None:
    """Fetch and cache HubSpot schema metadata."""
    hs_mod = _get_hubspot_helpers()
    schema_mod = _get_hubspot_schema_helpers()

    try:
        client = hs_mod.HubSpotClient()
    except Exception:
        click.echo("Error: HubSpot access token not configured.", err=True)
        click.echo("Run 'g-gremlin hubspot connect --access-token <token>' to configure.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        identity = client.whoami()
        portal_id = identity.get("portal_id") or ""
        if not portal_id:
            raise ValueError("Portal ID not available from HubSpot whoami.")
        cache = schema_mod.HubSpotSchemaCache(portal_id)
        result = cache.sync(client)
    except Exception as exc:
        click.echo(f"Schema sync failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result.to_dict(), indent=2))
        return

    click.echo(
        f"Synced {result.standard_objects} standard objects, "
        f"{result.custom_objects} custom objects, "
        f"{result.properties_count} properties"
    )


@hubspot_schema.command("ls")
@click.option(
    "--refresh",
    is_flag=True,
    help="Refresh cache if stale before listing.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_schema_ls(refresh: bool, json_output: bool) -> None:
    """List cached HubSpot objects."""
    hs_mod = _get_hubspot_helpers()
    schema_mod = _get_hubspot_schema_helpers()

    if refresh:
        try:
            client = hs_mod.HubSpotClient()
            identity = client.whoami()
            portal_id = identity.get("portal_id") or ""
            if not portal_id:
                raise ValueError("Portal ID not available from HubSpot whoami.")
            cache = schema_mod.HubSpotSchemaCache(portal_id)
            if cache.is_stale():
                cache.sync(client)
        except Exception as exc:
            click.echo(f"Schema refresh failed: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

    payloads = _load_hubspot_schema_payloads()
    if not payloads:
        click.echo("No cached schema found. Run: g-gremlin hubspot schema sync", err=True)
        sys.exit(EXIT_USAGE)

    if json_output:
        click.echo(json.dumps(payloads, indent=2))
        return

    for payload in payloads:
        portal_id = payload.get("portal_id", "unknown")
        click.echo(f"Portal: {portal_id}")
        objects = payload.get("objects") or []
        properties = payload.get("properties") or {}
        for obj in objects:
            name = obj.get("name")
            label = obj.get("label")
            custom = "custom" if obj.get("is_custom") else "standard"
            prop_count = len(properties.get(name, []))
            click.echo(f"  {name}: {label} ({custom}, {prop_count} properties)")


@hubspot_schema.command("show")
@click.argument("object_type")
@click.option(
    "--refresh",
    is_flag=True,
    help="Refresh cache if stale before showing.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_schema_show(object_type: str, refresh: bool, json_output: bool) -> None:
    """Show properties and associations for a cached object type."""
    hs_mod = _get_hubspot_helpers()
    schema_mod = _get_hubspot_schema_helpers()
    object_type = object_type.lower()

    cache_payload = None
    if refresh:
        try:
            client = hs_mod.HubSpotClient()
            identity = client.whoami()
            portal_id = identity.get("portal_id") or ""
            if not portal_id:
                raise ValueError("Portal ID not available from HubSpot whoami.")
            cache = schema_mod.HubSpotSchemaCache(portal_id)
            if cache.is_stale():
                cache.sync(client)
            cache_payload = json.loads(cache.cache_path.read_text(encoding="utf-8"))
        except Exception as exc:
            click.echo(f"Schema refresh failed: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
    else:
        payloads = _load_hubspot_schema_payloads()
        matches = [
            payload for payload in payloads
            if object_type in (payload.get("properties") or {})
        ]
        if len(matches) == 1:
            cache_payload = matches[0]
        elif len(matches) > 1:
            click.echo("Multiple portal caches found. Run with --refresh to select the active portal.", err=True)
            sys.exit(EXIT_USAGE)

    if cache_payload is None:
        click.echo("Schema not cached for this object. Run: g-gremlin hubspot schema sync", err=True)
        sys.exit(EXIT_USAGE)

    properties = (cache_payload.get("properties") or {}).get(object_type, [])
    associations = [
        assoc for assoc in (cache_payload.get("associations") or [])
        if assoc.get("from_type") == object_type or assoc.get("to_type") == object_type
    ]

    if json_output:
        click.echo(json.dumps({"object_type": object_type, "properties": properties, "associations": associations}, indent=2))
        return

    click.echo(f"Object: {object_type}")
    click.echo("Properties:")
    for prop in properties:
        click.echo(
            f"  {prop.get('name')}: {prop.get('label')} "
            f"({prop.get('type')}, {prop.get('field_type')})"
        )
    if associations:
        click.echo("Associations:")
        for assoc in associations:
            click.echo(
                f"  {assoc.get('from_type')} -> {assoc.get('to_type')}: "
                f"{assoc.get('label')} (type_id={assoc.get('type_id')})"
            )


@hubspot.group("props")
def hubspot_props() -> None:
    """HubSpot properties-as-code helpers."""


@hubspot_props.command("list")
@click.argument("object_types", nargs=-1)
@click.option(
    "--match",
    help="Case-insensitive substring filter applied to property name/label.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_props_list(object_types: tuple[str, ...], match: str | None, json_output: bool) -> None:
    """List available properties for HubSpot objects (defaults: contacts companies deals)."""
    targets = [item.strip().lower() for item in object_types if item.strip()]
    if not targets:
        targets = ["contacts", "companies", "deals"]

    needle = (match or "").strip().lower()
    hs_mod = _get_hubspot_helpers()

    rows: List[Dict[str, Any]] = []
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        for object_type in targets:
            props = client.get_properties(object_type)
            for prop in props:
                name = str(prop.get("name") or "")
                label = str(prop.get("label") or "")
                if needle and needle not in name.lower() and needle not in label.lower():
                    continue
                rows.append(
                    {
                        "object_type": object_type,
                        "name": name,
                        "label": label,
                        "type": prop.get("type") or "",
                        "field_type": prop.get("fieldType") or prop.get("field_type") or "",
                        "group_name": prop.get("groupName") or prop.get("group_name") or "",
                        "archived": bool(prop.get("archived", False)),
                        "hubspot_defined": bool(
                            prop.get("hubspotDefined", prop.get("hubspot_defined", False))
                        ),
                    }
                )
    except Exception as exc:
        click.echo(f"HubSpot props list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"object_types": targets, "count": len(rows), "properties": rows}, indent=2))
        return

    if not rows:
        click.echo("No properties found.")
        return

    current_object = None
    for row in rows:
        if row["object_type"] != current_object:
            current_object = row["object_type"]
            click.echo(f"\n{current_object}:")
        archived_flag = " archived" if row["archived"] else ""
        click.echo(
            f"  {row['name']}: {row['label']} "
            f"({row['type']}, {row['field_type']}, group={row['group_name']}){archived_flag}"
        )
    click.echo(f"\nTotal properties: {len(rows)}")


_HUBSPOT_FIELD_TYPES = {
    "string": "text",
    "number": "number",
    "enumeration": "select",
    "bool": "booleancheckbox",
    "datetime": "date",
    "date": "date",
    "phone_number": "phonenumber",
}


@hubspot_props.command("scaffold")
@click.argument("object_type")
@click.argument("properties", nargs=-1, required=True)
@click.option("--type", "prop_type", default="string", help="Property type (string, number, enumeration, bool, datetime).")
@click.option("--group", "group_name", default="custom", help="Property group name.")
@click.option("--output", "-o", "output_path", type=click.Path(dir_okay=False, path_type=Path), help="Write to file instead of stdout.")
def hubspot_props_scaffold(
    object_type: str,
    properties: tuple[str, ...],
    prop_type: str,
    group_name: str,
    output_path: Path | None,
) -> None:
    """Generate a starter property spec YAML file.

    \b
    Examples:
      g-gremlin hubspot props scaffold company icp_tier
      g-gremlin hubspot props scaffold company icp_tier lead_source --type enumeration --group scoring
      g-gremlin hubspot props scaffold company icp_tier -o properties.yaml
    """
    field_type = _HUBSPOT_FIELD_TYPES.get(prop_type, "text")
    prop_entries = []
    for name in properties:
        label = name.replace("_", " ").title()
        entry = (
            f"        - name: {name}\n"
            f"          label: {label}\n"
            f"          type: {prop_type}\n"
            f"          field_type: {field_type}\n"
            f"          group_name: {group_name}"
        )
        if prop_type == "enumeration":
            entry += (
                "\n          options:\n"
                "            - label: Option A\n"
                "              value: option_a\n"
                "            - label: Option B\n"
                "              value: option_b"
            )
        prop_entries.append(entry)

    group_label = group_name.replace("_", " ").title()
    spec = (
        f"version: 1\n"
        f"objects:\n"
        f"  {object_type}:\n"
        f"    groups:\n"
        f"      - name: {group_name}\n"
        f"        label: {group_label}\n"
        f"    properties:\n"
        + "\n".join(prop_entries)
        + "\n"
    )

    if output_path:
        output_path.write_text(spec, encoding="utf-8")
        click.echo(f"Spec written to {output_path}")
    else:
        click.echo(spec)


_PROPS_SPEC_EXAMPLE = """\b
Spec format (YAML):
  version: 1
  objects:
    company:
      groups:
        - name: my_group
          label: My Group
      properties:
        - name: icp_tier
          label: ICP Tier
          type: enumeration
          field_type: select
          group_name: my_group
          options:
            - label: Durable Core
              value: durable_core
            - label: Growth
              value: growth

Generate a starter spec with: g-gremlin hubspot props scaffold
"""


@hubspot_props.command("plan", epilog=_PROPS_SPEC_EXAMPLE)
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to YAML/JSON property spec.",
)
@click.option(
    "--refresh",
    is_flag=True,
    help="Refresh schema cache before planning.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_props_plan(spec_path: Path, refresh: bool, json_output: bool) -> None:
    """Plan property changes without applying."""
    hs_mod = _get_hubspot_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        spec = infra.load_property_spec(spec_path)
        manager = infra.HubSpotPropsManager(client)
        plan = manager.plan(spec, refresh_cache=refresh)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot props plan failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps([p.to_dict() for p in plan], indent=2))
        return

    output = infra.format_props_plan(plan)
    click.echo(output)


@hubspot_props.command("drift")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to YAML/JSON property spec.",
)
@click.option(
    "--refresh",
    is_flag=True,
    help="Refresh schema cache before drift check.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_props_drift(spec_path: Path, refresh: bool, json_output: bool) -> None:
    """Report drift between spec and HubSpot."""
    hs_mod = _get_hubspot_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        spec = infra.load_property_spec(spec_path)
        manager = infra.HubSpotPropsManager(client)
        plan = manager.plan(spec, refresh_cache=refresh)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot props drift failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps([p.to_dict() for p in plan], indent=2))
        return

    output = infra.format_props_plan(plan)
    click.echo(output)


@hubspot_props.command("apply")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to YAML/JSON property spec.",
)
@click.option(
    "--refresh",
    is_flag=True,
    help="Refresh schema cache before apply.",
)
@click.option(
    "--allow-archive",
    is_flag=True,
    help="Allow property archival when specified in the spec.",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply changes (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_props_apply(
    spec_path: Path,
    refresh: bool,
    allow_archive: bool,
    yes_apply: bool,
    json_output: bool,
) -> None:
    """Apply property changes from a spec (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = infra.load_property_spec(spec_path)
        manager = infra.HubSpotPropsManager(client)
        plan = manager.plan(spec, refresh_cache=refresh)
        incompatible = [item for p in plan for item in p.incompatible]
        if incompatible:
            raise infra.HubSpotInfraError("Incompatible property changes detected; aborting.")
        result = manager.apply(spec, plan, allow_archive=allow_archive)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot props apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"results": result}, indent=2))
        return

    for entry in result:
        click.echo(
            f"{entry['object_type']}: "
            f"groups created {entry['groups_created']}, "
            f"groups updated {entry['groups_updated']}, "
            f"properties created {entry['properties_created']}, "
            f"properties updated {entry['properties_updated']}, "
            f"properties archived {entry['properties_archived']}"
        )


@hubspot_props.command("create")
@click.argument("object_type")
@click.argument("name")
@click.option("--label", required=True, help="Display label for the property.")
@click.option("--type", "prop_type", required=True, help="Property type (string, number, enumeration, bool, datetime).")
@click.option("--field-type", "field_type", default=None, help="HubSpot field type (auto-detected from --type if omitted).")
@click.option("--group", "group_name", required=True, help="Property group name.")
@click.option("--description", default=None, help="Property description.")
@click.option(
    "--options",
    "options_str",
    default=None,
    help='Enumeration options as "value:Label,value2:Label 2".',
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def hubspot_props_create(
    object_type: str,
    name: str,
    label: str,
    prop_type: str,
    field_type: str | None,
    group_name: str,
    description: str | None,
    options_str: str | None,
    json_output: bool,
) -> None:
    """Create a single property directly (no spec file needed).

    \b
    Examples:
      g-gremlin hubspot props create company icp_tier \\
        --label "ICP Tier" --type enumeration --group scoring \\
        --options "durable_core:Durable Core,growth:Growth,avoid:Avoid"

      g-gremlin hubspot props create contacts lead_source \\
        --label "Lead Source" --type string --group contactinfo
    """
    if not field_type:
        field_type = _HUBSPOT_FIELD_TYPES.get(prop_type, "text")

    options: list[dict[str, str]] = []
    if options_str:
        for pair in options_str.split(","):
            pair = pair.strip()
            if ":" in pair:
                val, lbl = pair.split(":", 1)
                options.append({"value": val.strip(), "label": lbl.strip()})
            else:
                options.append({"value": pair, "label": pair.replace("_", " ").title()})

    payload = {
        "name": name,
        "label": label,
        "type": prop_type,
        "field_type": field_type,
        "group_name": group_name,
    }
    if description:
        payload["description"] = description
    if options:
        payload["options"] = options

    hs_mod = _get_hubspot_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        manager = infra.HubSpotPropsManager(client)
        manager._create_property(object_type, payload)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"Property creation failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"created": payload}, indent=2))
    else:
        click.echo(f"Created {object_type}.{name} ({prop_type}/{field_type}, group={group_name})")


@hubspot.group("workflows")
def hubspot_workflows() -> None:
    """HubSpot automation workflow helpers (beta)."""


@hubspot_workflows.command("ls")
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Limit number of workflows.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
@click.option(
    "--show-ids",
    is_flag=True,
    help="Show both v4 and v3 (legacy) IDs side by side.",
)
def hubspot_workflows_ls(limit: int, json_output: bool, show_ids: bool) -> None:
    """List automation workflows (beta)."""
    hs_mod = _get_hubspot_helpers()
    workflows = _get_hubspot_workflows_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        flows = workflows.list_flows(client, limit=limit)
    except workflows.AutomationBetaDisabledError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot workflows list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(flows, indent=2))
        return

    for flow in flows:
        if show_ids:
            v4 = flow.get("id", "\u2014")
            v3 = flow.get("flowId", "\u2014")
            click.echo(f"v4:{v4}  v3:{v3}  {flow.get('name', '')}")
        else:
            click.echo(f"{flow.get('id')}  {flow.get('name', '')}")


@hubspot_workflows.command("pull")
@click.argument("flow_id")
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Output JSON file path.",
)
def hubspot_workflows_pull(flow_id: str, out_path: Optional[Path]) -> None:
    """Pull a workflow definition to disk."""
    hs_mod = _get_hubspot_helpers()
    workflows = _get_hubspot_workflows_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        payload, was_v3 = workflows.get_flow_with_v3_fallback(client, flow_id)
        if was_v3:
            v4_id = payload.get("id", "?")
            click.echo(f"Resolved v3 workflow ID {flow_id} \u2192 v4 flow ID {v4_id}")
    except workflows.AutomationBetaDisabledError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)
    except workflows.FlowNotFoundError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot workflows pull failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_id = payload.get("id", flow_id)
    target = out_path or Path("workflows") / f"{resolved_id}.json"
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    click.echo(f"Wrote {target}.")


@hubspot_workflows.command("diff")
@click.argument("flow_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_workflows_diff(flow_file: Path, json_output: bool) -> None:
    """Diff local workflow file against remote."""
    hs_mod = _get_hubspot_helpers()
    workflows = _get_hubspot_workflows_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        local_payload = workflows.load_flow_file(flow_file)
        flow_id = workflows.resolve_flow_id(local_payload)
        if not flow_id:
            raise workflows.WorkflowApplyError("Workflow file missing flow id.")
        remote_payload, was_v3 = workflows.get_flow_with_v3_fallback(client, flow_id)
        if was_v3:
            v4_id = remote_payload.get("id", "?")
            click.echo(f"Resolved v3 workflow ID {flow_id} \u2192 v4 flow ID {v4_id}")
        diff = workflows.diff_flow(local_payload, remote_payload)
    except workflows.AutomationBetaDisabledError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)
    except workflows.FlowNotFoundError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot workflows diff failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"flow_id": flow_id, "diff": diff}, indent=2))
        return

    if not diff:
        click.echo("No differences.")
        return
    click.echo(diff)


@hubspot_workflows.command("apply")
@click.argument("flow_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--force",
    is_flag=True,
    help="Apply even if revisionId mismatches.",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply changes (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_workflows_apply(flow_file: Path, force: bool, yes_apply: bool, json_output: bool) -> None:
    """Create or update a workflow from a file (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    workflows = _get_hubspot_workflows_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        local_payload = workflows.load_flow_file(flow_file)
        flow_id = workflows.resolve_flow_id(local_payload)
        result: Dict[str, Any]
        backup_path = None
        if flow_id:
            workflows.validate_flow_payload(local_payload, mode="update")
            remote_payload, was_v3 = workflows.get_flow_with_v3_fallback(client, flow_id)
            if was_v3:
                v4_id = remote_payload.get("id", "?")
                click.echo(f"Resolved v3 workflow ID {flow_id} \u2192 v4 flow ID {v4_id}")
                flow_id = v4_id
            latest_revision = workflows.ensure_revision_or_raise(
                local_payload.get("revisionId"),
                remote_payload.get("revisionId"),
                force,
            )
            backup_path = workflows.backup_flow(
                flow_id,
                remote_payload,
                Path("workflows") / "_backup",
            )
            local_payload["revisionId"] = latest_revision
            local_payload["id"] = flow_id
            result = workflows.update_flow(client, flow_id, local_payload)
        else:
            workflows.validate_flow_payload(local_payload, mode="create")
            result = workflows.create_flow(client, local_payload)
    except workflows.AutomationBetaDisabledError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)
    except workflows.FlowNotFoundError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot workflows apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"result": result, "backup": str(backup_path) if backup_path else None}, indent=2))
        return

    if flow_id:
        click.echo(f"Updated workflow {flow_id}.")
        if backup_path:
            click.echo(f"Backup saved to {backup_path}.")
    else:
        click.echo(f"Created workflow {result.get('id', 'unknown')}.")


@hubspot_workflows.command("delete")
@click.argument("flow_id")
@click.option(
    "--yes-really",
    is_flag=True,
    help="Confirm deletion (cannot be restored via API).",
)
def hubspot_workflows_delete(flow_id: str, yes_really: bool) -> None:
    """Delete a workflow (requires --yes-really)."""
    if not yes_really:
        click.echo("Deletion refused. Re-run with --yes-really to confirm.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    workflows = _get_hubspot_workflows_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        workflows.delete_flow(client, flow_id)
    except workflows.AutomationBetaDisabledError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_USAGE)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot workflows delete failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo(f"Deleted workflow {flow_id}.")


@hubspot.command("snapshot")
@click.option(
    "--out",
    "out_dir",
    required=True,
    type=click.Path(file_okay=False, path_type=Path),
    help="Output snapshot directory.",
)
@click.option(
    "--object",
    "pipeline_object",
    default="deals",
    show_default=True,
    help="Pipeline object type (deals, tickets, leads).",
)
@click.option(
    "--pipeline-id",
    "pipeline_ids",
    multiple=True,
    help="Limit snapshot to specific pipeline ID(s).",
)
@click.option(
    "--list-id",
    "list_ids",
    multiple=True,
    help="Limit snapshot to specific list ID(s).",
)
@click.option(
    "--workflow-id",
    "workflow_ids",
    multiple=True,
    help="Limit snapshot to specific workflow ID(s).",
)
@click.option(
    "--include-workflows/--skip-workflows",
    default=True,
    show_default=True,
    help="Include workflows (requires beta flag).",
)
@click.option(
    "--include-list-members",
    is_flag=True,
    help="Export list memberships to CSV.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_snapshot(
    out_dir: Path,
    pipeline_object: str,
    pipeline_ids: tuple[str, ...],
    list_ids: tuple[str, ...],
    workflow_ids: tuple[str, ...],
    include_workflows: bool,
    include_list_members: bool,
    json_output: bool,
) -> None:
    """Create a HubSpot portal snapshot."""
    hs_mod = _get_hubspot_helpers()
    snapshot_mod = _get_hubspot_snapshot_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        snapshotter = snapshot_mod.HubSpotSnapshotter(client)
        result = snapshotter.snapshot(
            out_dir,
            include_workflows=include_workflows,
            list_ids=list(list_ids) or None,
            pipeline_object=pipeline_object,
            pipeline_ids=list(pipeline_ids) or None,
            workflow_ids=list(workflow_ids) or None,
            include_custom_objects=True,
            include_list_members=include_list_members,
        )
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot snapshot failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        meta = json.loads(result.meta_path.read_text(encoding="utf-8"))
        click.echo(json.dumps({"result": result.to_dict(), "meta": meta}, indent=2))
        return

    click.echo(f"Snapshot written to {out_dir}.")
    click.echo(
        "Counts: "
        f"properties={result.counts.get('properties_objects', 0)}, "
        f"pipelines={result.counts.get('pipelines', 0)}, "
        f"lists={result.counts.get('lists', 0)}, "
        f"workflows={result.counts.get('workflows', 0)}"
    )


@hubspot.command("compare-snapshots")
@click.argument("a_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.argument("b_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_compare_snapshots(a_dir: Path, b_dir: Path, json_output: bool) -> None:
    """Compare two HubSpot snapshots."""
    compare_mod = _get_hubspot_snapshot_compare_helpers()

    try:
        diff = compare_mod.compare_snapshots(a_dir, b_dir)
    except Exception as exc:
        click.echo(f"Snapshot compare failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(diff.to_dict(), indent=2))
        return

    click.echo(compare_mod.format_summary(diff))


@hubspot.command("setup")
@click.option(
    "--pack",
    "pack_dir",
    type=click.Path(exists=True, file_okay=False, path_type=Path),
    help="Pack directory to derive minimal scopes.",
)
@click.option(
    "--full-beta",
    is_flag=True,
    help="Emit full beta scope preset.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_setup(pack_dir: Path | None, full_beta: bool, json_output: bool) -> None:
    """Print HubSpot Private App setup checklist and scopes."""
    pack_mod = _get_hubspot_pack_helpers()

    if full_beta and pack_dir:
        click.echo("Choose either --pack or --full-beta, not both.", err=True)
        sys.exit(EXIT_USAGE)

    scopes_by_component: Dict[str, List[str]] = {}
    mode = "full_beta" if full_beta else "pack"
    if full_beta:
        scopes_by_component = {
            "core_objects": [
                "crm.objects.contacts.read",
                "crm.objects.contacts.write",
                "crm.objects.companies.read",
                "crm.objects.companies.write",
                "crm.objects.deals.read",
                "crm.objects.deals.write",
                "crm.objects.tickets.read",
                "crm.objects.tickets.write",
                "crm.objects.leads.read",
                "crm.objects.leads.write",
            ],
            "lists": [
                "crm.lists.read",
                "crm.lists.write",
            ],
            "schemas": [
                "crm.schemas.contacts.read",
                "crm.schemas.contacts.write",
                "crm.schemas.companies.read",
                "crm.schemas.companies.write",
                "crm.schemas.deals.read",
                "crm.schemas.deals.write",
                "crm.schemas.custom.read",
                "crm.schemas.custom.write",
            ],
            "custom_objects": [
                "crm.objects.custom.read",
                "crm.objects.custom.write",
            ],
            "automation": [
                "automation",
            ],
        }
    else:
        if not pack_dir:
            click.echo("Provide --pack <dir> or use --full-beta.", err=True)
            sys.exit(EXIT_USAGE)
        report = pack_mod.pack_doctor(pack_dir, json_output=True)
        scopes_by_component = report["json"].get("required_scopes") or {}

    flat_scopes = sorted({s for scopes in scopes_by_component.values() for s in scopes})
    notes = [
        "Create a HubSpot Private App (not Legacy App).",
        "Settings → Integrations → Private Apps.",
        "Create new private app (name: g-gremlin).",
        "Scopes: enable the list below.",
        "Save and copy access token.",
        "Run: g-gremlin hubspot connect --access-token <token>.",
    ]

    setup_md = Path.cwd() / "hubspot_setup.md"
    setup_md.write_text(
        "\n".join(
            [
                "# HubSpot Private App Setup",
                "",
                "Checklist:",
                "1) Settings → Integrations → Private Apps",
                "2) Create new private app (name: g-gremlin)",
                "3) Scopes: enable the list below",
                "4) Save and copy access token",
                "5) Run: g-gremlin hubspot connect --access-token <token>",
                "",
                "Scopes:",
                *[f"- {scope}" for scope in flat_scopes],
                "",
            ]
        ),
        encoding="utf-8",
    )

    payload = {
        "mode": mode,
        "scopes": scopes_by_component,
        "flat_scopes": flat_scopes,
        "output_path": str(setup_md),
        "notes": notes,
    }

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo("HubSpot Private App setup checklist:")
    for note in notes:
        click.echo(f"- {note}")
    click.echo("Scopes:")
    for scope in flat_scopes:
        click.echo(f"  - {scope}")
    click.echo(f"Wrote {setup_md}")


@hubspot.command("drift")
@click.argument("dir_path", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_drift(dir_path: Path, json_output: bool) -> None:
    """Show drift across all pack components."""
    hs_mod = _get_hubspot_helpers()
    pack_mod = _get_hubspot_pack_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        summary = pack_mod.compute_drift(client, dir_path)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot drift failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(summary.to_dict(), indent=2))
        return

    click.echo(f"Drift Report: {dir_path.name}")
    click.echo("-" * 50)
    name_width = max((len(c.name) for c in summary.components), default=12)
    for item in summary.components:
        label = f"  {item.name:<{name_width}}"
        if item.status == "clean":
            click.echo(f"{label} clean")
        elif item.status == "drifted":
            click.echo(f"{label} {item.changes} change(s)")
        elif item.status == "skipped":
            detail = item.details or "skipped"
            click.echo(f"{label} skipped ({detail})")
        else:
            detail = item.error or item.details or "error"
            click.echo(f"{label} error ({detail})")
    click.echo("-" * 50)
    click.echo(f"Total changes: {summary.total_changes}")


@hubspot.command("smoke")
@click.argument("dir_path", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_smoke(dir_path: Path, json_output: bool) -> None:
    """Run smoke assertions defined in pack.yaml."""
    hs_mod = _get_hubspot_helpers()
    pack_mod = _get_hubspot_pack_helpers()
    smoke_mod = _get_hubspot_smoke_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        pack_yaml = pack_mod.load_pack_yaml(dir_path / "pack.yaml")
        assertions = smoke_mod.load_smoke_assertions(pack_yaml)
        report = smoke_mod.run_smoke(client, assertions)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot smoke failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(report.to_dict(), indent=2))
    else:
        for item in report.results:
            status = "PASS" if item.passed else ("SKIP" if item.skipped else "FAIL")
            label = item.assertion.type
            params = item.assertion.params
            if item.assertion.type == "property_exists":
                label = f"{label} {params.get('object_type')}:{params.get('name')}"
            elif item.assertion.type == "pipeline_has_stages":
                label = f"{label} {params.get('object_type')}:{params.get('pipeline_id')}"
            elif item.assertion.type == "list_exists":
                label = f"{label} {params.get('list_id')}"
            elif item.assertion.type == "workflow_exists":
                label = f"{label} {params.get('flow_id')}"
            detail = f" - {item.detail}" if item.detail else ""
            click.echo(f"[{status}] {label}{detail}")
        summary = report.summary
        click.echo(
            f"Summary: {summary['passed']} passed, {summary['failed']} failed, {summary['skipped']} skipped."
        )

    if report.summary.get("failed"):
        sys.exit(EXIT_ISSUES)


@hubspot.group("pack")
def hubspot_pack() -> None:
    """
    HubSpot pack orchestration (plan -> review -> apply).

    \b
    Workflow:
      1) g-gremlin hubspot pack init my_pack            # scaffold pack directory
      2) g-gremlin hubspot pack plan my_pack --json      # generate change plan + plan_hash
      3) # Review the plan output
      4) g-gremlin hubspot pack apply my_pack --yes [--plan-hash HASH]  # apply with receipt
      5) g-gremlin hubspot pack doctor my_pack           # validate pack health

    \b
    The apply step requires --yes and can verify a reviewed plan with --plan-hash.
    Use --plan <path> to apply a specific saved plan artifact.
    Set `options.require_plan_hash: true` in pack.yaml to enforce --plan-hash.
    Use --replan if the portal drifted since the reviewed plan.
    """


@hubspot_pack.command("init")
@click.argument("dir_path", type=click.Path(file_okay=False, path_type=Path))
def hubspot_pack_init(dir_path: Path) -> None:
    """Initialize a pack folder with stub specs."""
    pack_mod = _get_hubspot_pack_helpers()
    pack_mod.init_pack(dir_path)
    click.echo(f"Pack initialized at {dir_path}.")


@hubspot_pack.command("plan")
@click.argument("dir_path", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_pack_plan(dir_path: Path, json_output: bool) -> None:
    """Plan a pack without applying changes."""
    hs_mod = _get_hubspot_helpers()
    pack_mod = _get_hubspot_pack_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        plan = pack_mod.plan_pack(client, dir_path)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot pack plan failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(plan.to_dict(), indent=2))
        return

    click.echo(json.dumps(plan.to_dict(), indent=2))


@hubspot_pack.command("apply")
@click.argument("dir_path", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply the pack (default is dry-run).",
)
@click.option(
    "--replan",
    is_flag=True,
    help="Recompute plan before apply and record effective_plan.json.",
)
@click.option(
    "--plan",
    "plan_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to plan.json from a prior pack plan run.",
)
@click.option(
    "--plan-hash",
    "expected_plan_hash",
    help="Verify the reviewed plan hash before applying. Required when pack.yaml sets options.require_plan_hash=true.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_pack_apply(
    dir_path: Path,
    yes_apply: bool,
    replan: bool,
    plan_path: Path | None,
    expected_plan_hash: str | None,
    json_output: bool,
) -> None:
    """Apply a pack (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    pack_mod = _get_hubspot_pack_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        pack_cfg = pack_mod.load_pack_yaml(dir_path / "pack.yaml")
        options = pack_cfg.get("options") or {}
        result = pack_mod.apply_pack(
            client,
            dir_path,
            None,
            allow_delete_stages=bool(options.get("allow_delete_stages")),
            allow_archive_properties=bool(options.get("allow_archive_properties")),
            allow_delete_workflows=bool(options.get("allow_delete_workflows")),
            replan=replan,
            plan_path=plan_path,
            expected_plan_hash=expected_plan_hash,
        )
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot pack apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    click.echo(f"Artifacts written to {result.get('artifacts_dir')}.")
    click.echo(f"Summary: {result.get('summary_path')}.")


@hubspot_pack.command("doctor")
@click.argument("dir_path", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_pack_doctor(dir_path: Path, json_output: bool) -> None:
    """Validate pack structure and required files."""
    pack_mod = _get_hubspot_pack_helpers()
    try:
        report = pack_mod.pack_doctor(dir_path, json_output=json_output)
    except Exception as exc:
        click.echo(f"Pack doctor failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(report["json"], indent=2))
        return

    if report["json"]["missing_files"]:
        click.echo(
            f"Missing required files: {', '.join(report['json']['missing_files'])}",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    click.echo("Pack looks OK.")
    if report["json"].get("env_needed"):
        click.echo(f"Env needed: {', '.join(report['json']['env_needed'])}")
    click.echo("Required scopes:")
    for component, scopes in report["json"]["required_scopes"].items():
        click.echo(f"  {component}: {', '.join(scopes)}")
    if report["json"]["notes"]:
        click.echo("Notes:")
        for note in report["json"]["notes"]:
            click.echo(f"  - {note}")


@hubspot.group("validations")
def hubspot_validations() -> None:
    """HubSpot property validations."""


@hubspot_validations.command("plan")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Validations spec file.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_validations_plan(spec_path: Path, json_output: bool) -> None:
    """Plan validation rules."""
    hs_mod = _get_hubspot_helpers()
    validations_mod = _get_hubspot_validations_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = validations_mod.load_validations_spec(spec_path)
        plan = validations_mod.plan_validations(client, spec)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot validations plan failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps([p.to_dict() for p in plan], indent=2))
        return

    click.echo(validations_mod.format_validations_plan(plan))


@hubspot_validations.command("apply")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Validations spec file.",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply validations (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_validations_apply(spec_path: Path, yes_apply: bool, json_output: bool) -> None:
    """Apply validation rules (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    validations_mod = _get_hubspot_validations_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = validations_mod.load_validations_spec(spec_path)
        plan = validations_mod.plan_validations(client, spec)
        result = validations_mod.apply_validations(client, plan)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot validations apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    click.echo("Validations applied.")


@hubspot.group("objects")
def hubspot_objects() -> None:
    """HubSpot custom objects helpers."""


@hubspot_objects.command("ls")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_objects_ls(json_output: bool) -> None:
    """List HubSpot custom objects."""
    hs_mod = _get_hubspot_helpers()
    objects_mod = _get_hubspot_objects_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        objects = objects_mod.list_objects(client)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot objects ls failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(objects, indent=2))
        return

    for obj in objects:
        click.echo(f"{obj.get('name')}  {obj.get('objectTypeId')}")


@hubspot_objects.command("pull")
@click.argument("object_type_id")
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Output JSON file path.",
)
def hubspot_objects_pull(object_type_id: str, out_path: Optional[Path]) -> None:
    """Pull an object schema to disk."""
    hs_mod = _get_hubspot_helpers()
    objects_mod = _get_hubspot_objects_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        schema = objects_mod.get_object_schema(client, object_type_id)
        spec = objects_mod.export_object_spec(schema)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot objects pull failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    target = out_path or Path("objects") / f"{object_type_id}.json"
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(spec, indent=2, sort_keys=True), encoding="utf-8")
    click.echo(f"Wrote {target}.")


@hubspot_objects.command("plan")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Objects spec file.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_objects_plan(spec_path: Path, json_output: bool) -> None:
    """Plan custom object changes."""
    hs_mod = _get_hubspot_helpers()
    objects_mod = _get_hubspot_objects_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = objects_mod.load_objects_spec(spec_path)
        plan = objects_mod.plan_objects(client, spec)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot objects plan failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(plan.to_dict(), indent=2))
        return

    click.echo(json.dumps(plan.to_dict(), indent=2))


@hubspot_objects.command("apply")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Objects spec file.",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply object changes (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_objects_apply(spec_path: Path, yes_apply: bool, json_output: bool) -> None:
    """Apply object changes (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    objects_mod = _get_hubspot_objects_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = objects_mod.load_objects_spec(spec_path)
        plan = objects_mod.plan_objects(client, spec)
        result = objects_mod.apply_objects(client, plan, spec)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot objects apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    click.echo("Objects applied.")


@hubspot.group("lists")
def hubspot_lists() -> None:
    """HubSpot list helpers."""


@hubspot_lists.command("ls")
@click.option(
    "--limit",
    type=int,
    default=100,
    show_default=True,
    help="Limit number of lists.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_lists_ls(limit: int, json_output: bool) -> None:
    """List HubSpot lists."""
    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        lists = lists_mod.list_lists(client, limit=limit)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists ls failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(lists, indent=2))
        return

    for item in lists:
        click.echo(f"{item.get('listId') or item.get('id')}  {item.get('name')}")


@hubspot_lists.command("pull")
@click.argument("list_id")
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Output JSON file path.",
)
def hubspot_lists_pull(list_id: str, out_path: Optional[Path]) -> None:
    """Pull a list definition to disk."""
    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        payload = lists_mod.get_list(client, list_id)
        spec = lists_mod.export_list_spec(payload)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists pull failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    target = out_path or Path("lists") / f"{list_id}.json"
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(spec, indent=2), encoding="utf-8")
    click.echo(f"Wrote {target}.")


@hubspot_lists.command("members")
@click.argument("list_id")
@click.option(
    "--limit",
    type=int,
    default=0,
    show_default=False,
    help="Limit number of members (0 = no limit).",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    required=True,
    help="Output CSV path.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_lists_members(list_id: str, limit: int, out_path: Path, json_output: bool) -> None:
    """Export list members to CSV."""
    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        members = lists_mod.list_members(client, list_id, limit=limit or None)
        count = lists_mod.export_members_to_csv(list_id, members, out_path)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists members failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"list_id": list_id, "count": count, "output": str(out_path)}, indent=2))
        return

    click.echo(f"Wrote {count} members to {out_path}.")


@hubspot_lists.command("diff")
@click.argument("list_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_lists_diff(list_file: Path, json_output: bool) -> None:
    """Diff local list spec against remote."""
    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        local_payload = lists_mod.load_list_file(list_file)
        list_id = local_payload.get("listId") or local_payload.get("id")
        if not list_id:
            raise lists_mod.HubSpotListsError("List spec missing listId.")
        remote_payload = lists_mod.get_list(client, str(list_id))
        diff = lists_mod.diff_lists(local_payload, lists_mod.export_list_spec(remote_payload))
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists diff failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"list_id": list_id, "diff": diff}, indent=2))
        return

    if not diff:
        click.echo("No differences.")
        return
    click.echo(diff)


@hubspot_lists.command("apply")
@click.argument("list_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply changes (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_lists_apply(list_file: Path, yes_apply: bool, json_output: bool) -> None:
    """Apply list changes from a file (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        local_payload = lists_mod.load_list_file(list_file)
        list_id = local_payload.get("listId") or local_payload.get("id")
        remote_payload = lists_mod.get_list(client, str(list_id)) if list_id else None
        plan = lists_mod.plan_list_changes(local_payload, remote_payload)
        if plan.blocked:
            raise lists_mod.HubSpotListsError(
                f"Blocked changes for fields: {', '.join(plan.blocked)}"
            )
        backup_path = None
        if remote_payload and list_id:
                backup_path = lists_mod.backup_list(
                    str(list_id),
                    lists_mod.export_list_spec(remote_payload),
                    gremlin_path("backups", "hubspot", "lists"),
                )
        if plan.create:
            result = lists_mod.create_list(client, plan.updates)
        else:
            result = lists_mod.update_list(client, str(list_id), plan.updates)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"result": result, "backup": str(backup_path) if backup_path else None}, indent=2))
        return

    if plan.create:
        click.echo(f"Created list {result.get('listId') or result.get('id')}.")
    else:
        click.echo(f"Updated list {list_id}.")
        if backup_path:
            click.echo(f"Backup saved to {backup_path}.")


@hubspot_lists.command("add-members")
@click.argument("list_id")
@click.option(
    "--csv",
    "csv_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="CSV file with record IDs (expects a 'record_id' column by default).",
)
@click.option(
    "--column",
    default="record_id",
    show_default=True,
    help="Column name in CSV that contains record IDs.",
)
@click.option(
    "--ids",
    "inline_ids",
    default=None,
    help="Comma-separated record IDs (alternative to --csv).",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply changes (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_lists_add_members(
    list_id: str,
    csv_path: Optional[Path],
    column: str,
    inline_ids: Optional[str],
    yes_apply: bool,
    json_output: bool,
) -> None:
    """Add contacts to a static (MANUAL/SNAPSHOT) list.

    Provide record IDs via --csv or --ids. Only works on lists with
    processingType MANUAL or SNAPSHOT — dynamic lists are managed by
    their filter rules.

    \b
    Examples:
      g-gremlin hubspot lists add-members 123 --ids 501,502,503 --yes
      g-gremlin hubspot lists add-members 123 --csv contacts.csv --yes
    """
    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    # Resolve record IDs ------------------------------------------------
    if csv_path:
        record_ids = lists_mod.load_record_ids_from_csv(csv_path, column=column)
    elif inline_ids:
        record_ids = [rid.strip() for rid in inline_ids.split(",") if rid.strip()]
    else:
        click.echo("Provide --csv or --ids.", err=True)
        sys.exit(EXIT_USAGE)

    if not record_ids:
        click.echo("No record IDs found.", err=True)
        sys.exit(EXIT_USAGE)

    if not yes_apply:
        click.echo(
            f"Dry-run: would add {len(record_ids)} member(s) to list {list_id}. "
            "Re-run with --yes to apply.",
            err=True,
        )
        if json_output:
            click.echo(json.dumps({"dry_run": True, "list_id": list_id, "count": len(record_ids)}, indent=2))
        sys.exit(EXIT_USAGE)

    try:
        client = hs_mod.HubSpotClient()
        result = lists_mod.add_members(client, list_id, record_ids)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except lists_mod.HubSpotListsError as exc:
        click.echo(f"HubSpot lists add-members failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists add-members failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"list_id": list_id, "added": len(record_ids), "result": result}, indent=2))
        return

    click.echo(f"Added {len(record_ids)} member(s) to list {list_id}.")


@hubspot_lists.command("remove-members")
@click.argument("list_id")
@click.option(
    "--csv",
    "csv_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="CSV file with record IDs (expects a 'record_id' column by default).",
)
@click.option(
    "--column",
    default="record_id",
    show_default=True,
    help="Column name in CSV that contains record IDs.",
)
@click.option(
    "--ids",
    "inline_ids",
    default=None,
    help="Comma-separated record IDs (alternative to --csv).",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply changes (default is dry-run).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_lists_remove_members(
    list_id: str,
    csv_path: Optional[Path],
    column: str,
    inline_ids: Optional[str],
    yes_apply: bool,
    json_output: bool,
) -> None:
    """Remove contacts from a static (MANUAL/SNAPSHOT) list.

    Provide record IDs via --csv or --ids. Only works on lists with
    processingType MANUAL or SNAPSHOT — dynamic lists are managed by
    their filter rules.

    \b
    Examples:
      g-gremlin hubspot lists remove-members 123 --ids 501,502,503 --yes
      g-gremlin hubspot lists remove-members 123 --csv contacts.csv --yes
    """
    hs_mod = _get_hubspot_helpers()
    lists_mod = _get_hubspot_lists_helpers()
    infra = _get_hubspot_infra_helpers()

    # Resolve record IDs ------------------------------------------------
    if csv_path:
        record_ids = lists_mod.load_record_ids_from_csv(csv_path, column=column)
    elif inline_ids:
        record_ids = [rid.strip() for rid in inline_ids.split(",") if rid.strip()]
    else:
        click.echo("Provide --csv or --ids.", err=True)
        sys.exit(EXIT_USAGE)

    if not record_ids:
        click.echo("No record IDs found.", err=True)
        sys.exit(EXIT_USAGE)

    if not yes_apply:
        click.echo(
            f"Dry-run: would remove {len(record_ids)} member(s) from list {list_id}. "
            "Re-run with --yes to apply.",
            err=True,
        )
        if json_output:
            click.echo(json.dumps({"dry_run": True, "list_id": list_id, "count": len(record_ids)}, indent=2))
        sys.exit(EXIT_USAGE)

    try:
        client = hs_mod.HubSpotClient()
        result = lists_mod.remove_members(client, list_id, record_ids)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except lists_mod.HubSpotListsError as exc:
        click.echo(f"HubSpot lists remove-members failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot lists remove-members failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"list_id": list_id, "removed": len(record_ids), "result": result}, indent=2))
        return

    click.echo(f"Removed {len(record_ids)} member(s) from list {list_id}.")


@hubspot.group("pipelines")
def hubspot_pipelines() -> None:
    """HubSpot pipeline helpers."""


@hubspot_pipelines.command("ls")
@click.option(
    "--object",
    "object_type",
    default="deals",
    show_default=True,
    help="Object type (deals, tickets, leads).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_pipelines_ls(object_type: str, json_output: bool) -> None:
    """List HubSpot pipelines."""
    hs_mod = _get_hubspot_helpers()
    pipelines_mod = _get_hubspot_pipelines_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        pipelines = pipelines_mod.list_pipelines(client, object_type)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot pipelines ls failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(pipelines, indent=2))
        return

    for item in pipelines:
        click.echo(f"{item.get('id')}  {item.get('label')}")


@hubspot_pipelines.command("pull")
@click.option(
    "--object",
    "object_type",
    default="deals",
    show_default=True,
    help="Object type (deals, tickets, leads).",
)
@click.option(
    "--id",
    "pipeline_id",
    required=True,
    help="Pipeline ID.",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Output JSON file path.",
)
def hubspot_pipelines_pull(object_type: str, pipeline_id: str, out_path: Optional[Path]) -> None:
    """Pull a pipeline definition to disk."""
    hs_mod = _get_hubspot_helpers()
    pipelines_mod = _get_hubspot_pipelines_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        payload = pipelines_mod.get_pipeline(client, object_type, pipeline_id)
        spec = pipelines_mod.export_pipeline_spec(object_type, payload)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot pipelines pull failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    target = out_path or Path("pipelines") / f"{object_type}_{pipeline_id}.json"
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(spec, indent=2), encoding="utf-8")
    click.echo(f"Wrote {target}.")


@hubspot_pipelines.command("drift")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Pipeline spec JSON file.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_pipelines_drift(spec_path: Path, json_output: bool) -> None:
    """Report pipeline drift against a spec."""
    hs_mod = _get_hubspot_helpers()
    pipelines_mod = _get_hubspot_pipelines_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = pipelines_mod.load_pipeline_spec(spec_path)
        object_type = spec.get("object_type") or ""
        pipeline_id = spec.get("pipeline_id") or spec.get("id") or ""
        if not object_type or not pipeline_id:
            raise pipelines_mod.HubSpotPipelinesError("Spec missing object_type or pipeline_id.")
        remote = pipelines_mod.get_pipeline(client, object_type, pipeline_id)
        plan = pipelines_mod.plan_pipeline_changes(spec, remote)
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot pipelines drift failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(plan.to_dict(), indent=2))
        return

    click.echo(pipelines_mod.format_pipeline_plan(plan))


@hubspot_pipelines.command("apply")
@click.option(
    "--spec",
    "spec_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Pipeline spec JSON file.",
)
@click.option(
    "--yes",
    "yes_apply",
    is_flag=True,
    help="Apply changes (default is dry-run).",
)
@click.option(
    "--allow-delete",
    is_flag=True,
    help="Allow stage deletions.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_pipelines_apply(
    spec_path: Path,
    yes_apply: bool,
    allow_delete: bool,
    json_output: bool,
) -> None:
    """Apply pipeline changes (requires --yes)."""
    if not yes_apply:
        click.echo("Dry-run mode. Re-run with --yes to apply changes.", err=True)
        sys.exit(EXIT_USAGE)

    click.echo("Warning: pipeline changes can impact reporting and automations.", err=True)
    if allow_delete:
        click.echo("Warning: stage deletions can break reporting history.", err=True)

    hs_mod = _get_hubspot_helpers()
    pipelines_mod = _get_hubspot_pipelines_helpers()
    infra = _get_hubspot_infra_helpers()

    try:
        client = hs_mod.HubSpotClient()
        spec = pipelines_mod.load_pipeline_spec(spec_path)
        object_type = spec.get("object_type") or ""
        pipeline_id = spec.get("pipeline_id") or spec.get("id") or ""
        if not object_type or not pipeline_id:
            raise pipelines_mod.HubSpotPipelinesError("Spec missing object_type or pipeline_id.")
        remote = pipelines_mod.get_pipeline(client, object_type, pipeline_id)
        plan = pipelines_mod.plan_pipeline_changes(spec, remote)
        backup_path = pipelines_mod.backup_pipeline(
            object_type,
            pipeline_id,
            remote,
            gremlin_path("backups", "hubspot", "pipelines", object_type),
        )
        result = pipelines_mod.apply_pipeline_plan(
            client,
            plan,
            spec,
            allow_delete=allow_delete,
        )
    except infra.MissingScopeError as exc:
        click.echo(_format_hubspot_scope_error(exc.required_scopes), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except infra.InvalidTokenError as exc:
        click.echo(str(exc), err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except Exception as exc:
        click.echo(f"HubSpot pipelines apply failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps({"result": result, "backup": str(backup_path)}, indent=2))
        return

    click.echo(
        f"Pipeline updated={result['pipeline_updated']}, "
        f"stages created={result['stages_created']}, "
        f"stages updated={result['stages_updated']}, "
        f"stages deleted={result['stages_deleted']}"
    )
    click.echo(f"Backup saved to {backup_path}.")


@hubspot.group("exports")
def hubspot_exports() -> None:
    """HubSpot async export helpers."""


@hubspot_exports.command("start")
@click.option(
    "--object",
    "object_type",
    default="contacts",
    show_default=True,
    help="HubSpot object type (contacts, companies, deals, tickets, leads, or custom object).",
)
@click.option(
    "--export-type",
    type=click.Choice(["VIEW", "LIST"], case_sensitive=False),
    default="VIEW",
    show_default=True,
    help="Export type.",
)
@click.option(
    "--list-id",
    default=None,
    help="List ID (required when --export-type LIST).",
)
@click.option(
    "--properties",
    default=None,
    help="Comma-separated object properties to include.",
)
@click.option(
    "--associated-objects",
    default=None,
    help="Comma-separated associated object types to include.",
)
@click.option(
    "--filter-json",
    "filter_json_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Path to JSON request body for publicCrmSearchRequest (VIEW exports only).",
)
@click.option(
    "--name",
    "export_name",
    default=None,
    help="Optional export name for HubSpot task history.",
)
@click.option(
    "--format",
    "file_format",
    type=click.Choice(["CSV", "XLS", "XLSX"], case_sensitive=False),
    default="CSV",
    show_default=True,
    help="Export file format.",
)
@click.option(
    "--language",
    default="EN",
    show_default=True,
    help="Language code (for example EN).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_exports_start(
    object_type: str,
    export_type: str,
    list_id: str | None,
    properties: str | None,
    associated_objects: str | None,
    filter_json_path: Path | None,
    export_name: str | None,
    file_format: str,
    language: str,
    json_output: bool,
) -> None:
    """Start an async export task."""
    export_type_token = str(export_type).upper()
    if export_type_token == "LIST" and not list_id:
        raise click.ClickException("--list-id is required when --export-type LIST.")
    if export_type_token == "LIST" and filter_json_path:
        raise click.ClickException("--filter-json is only supported for VIEW exports.")

    object_properties = [item.strip() for item in (properties or "").split(",") if item.strip()]
    associated_object_types = [item.strip() for item in (associated_objects or "").split(",") if item.strip()]

    search_request: Dict[str, Any] | None = None
    if filter_json_path:
        try:
            raw = json.loads(filter_json_path.read_text(encoding="utf-8"))
        except Exception as exc:
            raise click.ClickException(f"Invalid JSON in {filter_json_path}: {exc}") from exc
        if isinstance(raw, list):
            search_request = {"filterGroups": raw}
        elif isinstance(raw, dict):
            search_request = raw
        else:
            raise click.ClickException("--filter-json must contain a JSON object or list.")

    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        payload = client.start_export(
            object_type=object_type,
            export_type=export_type_token,
            file_format=file_format,
            export_name=export_name,
            object_properties=object_properties,
            associated_object_types=associated_object_types,
            public_crm_search_request=search_request,
            language=language,
            list_id=list_id,
        )
    except Exception as exc:
        raise click.ClickException(f"HubSpot export start failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    export_id = payload.get("id") or payload.get("taskId") or payload.get("task_id") or ""
    status = payload.get("status") or payload.get("exportState") or "PENDING"
    if export_id:
        click.echo(f"Started export task {export_id} (status={status}).")
    else:
        click.echo(f"Started export task (status={status}).")


@hubspot_exports.command("status")
@click.option(
    "--id",
    "task_id",
    required=True,
    help="Export task id.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_exports_status(task_id: str, json_output: bool) -> None:
    """Get export task status."""
    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        payload = client.get_export_status(task_id)
    except Exception as exc:
        raise click.ClickException(f"HubSpot export status failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    state = payload.get("status") or payload.get("exportState") or "unknown"
    url = _get_hubspot_helpers().HubSpotClient.extract_export_download_url(payload)
    click.echo(f"Export {task_id}: {state}")
    if url:
        click.echo(f"Download URL available: {url}")


@hubspot_exports.command("download")
@click.option(
    "--id",
    "task_id",
    required=True,
    help="Export task id.",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    required=True,
    help="Output file path.",
)
@click.option(
    "--wait/--no-wait",
    default=True,
    show_default=True,
    help="Wait for task completion before downloading.",
)
@click.option(
    "--timeout",
    "timeout_seconds",
    type=int,
    default=900,
    show_default=True,
    help="Maximum wait time in seconds when --wait is enabled.",
)
@click.option(
    "--poll-interval",
    "poll_interval_seconds",
    type=int,
    default=5,
    show_default=True,
    help="Polling interval in seconds.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_exports_download(
    task_id: str,
    out_path: Path,
    wait: bool,
    timeout_seconds: int,
    poll_interval_seconds: int,
    json_output: bool,
) -> None:
    """Download an async export result file."""
    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        result = client.download_export_file(
            task_id,
            out_path,
            wait=wait,
            timeout_seconds=timeout_seconds,
            poll_interval_seconds=poll_interval_seconds,
        )
    except Exception as exc:
        raise click.ClickException(f"HubSpot export download failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    click.echo(f"Wrote export file to {result['path']} ({result['bytes_written']} bytes).")


@hubspot.group("imports")
def hubspot_imports() -> None:
    """HubSpot native import helpers."""


@hubspot_imports.command("start")
@click.option(
    "--request-json",
    "request_json_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    required=True,
    help="Path to ImportRequest JSON payload.",
)
@click.option(
    "--file",
    "files",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    multiple=True,
    required=True,
    help="Input file path (repeat for multi-file imports).",
)
@click.option(
    "--wait/--no-wait",
    default=False,
    show_default=True,
    help="Wait for terminal import state before returning.",
)
@click.option(
    "--timeout",
    "timeout_seconds",
    type=int,
    default=1800,
    show_default=True,
    help="Maximum wait time in seconds when --wait is enabled.",
)
@click.option(
    "--poll-interval",
    "poll_interval_seconds",
    type=int,
    default=5,
    show_default=True,
    help="Polling interval in seconds.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_imports_start(
    request_json_path: Path,
    files: tuple[Path, ...],
    wait: bool,
    timeout_seconds: int,
    poll_interval_seconds: int,
    json_output: bool,
) -> None:
    """Start a native HubSpot import."""
    try:
        import_request = json.loads(request_json_path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise click.ClickException(f"Invalid JSON in {request_json_path}: {exc}") from exc
    if not isinstance(import_request, dict):
        raise click.ClickException("--request-json must contain a JSON object.")

    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        start_payload = client.start_import(import_request, list(files))
    except Exception as exc:
        raise click.ClickException(f"HubSpot import start failed: {exc}") from exc

    import_id = start_payload.get("id") or start_payload.get("importId") or start_payload.get("import_id")
    wait_payload = None
    if wait and import_id:
        try:
            wait_payload = client.wait_for_import(
                str(import_id),
                timeout_seconds=timeout_seconds,
                poll_interval_seconds=poll_interval_seconds,
            )
        except Exception as exc:
            raise click.ClickException(f"HubSpot import wait failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps({"start": start_payload, "final": wait_payload}, indent=2))
        return

    state = start_payload.get("state") or start_payload.get("status") or "STARTED"
    if import_id:
        click.echo(f"Started import {import_id} (state={state}).")
    else:
        click.echo(f"Started import (state={state}).")
    if wait_payload:
        final_state = wait_payload.get("state") or wait_payload.get("status") or "unknown"
        click.echo(f"Final state: {final_state}")


@hubspot_imports.command("status")
@click.option("--id", "import_id", required=True, help="Import id.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def hubspot_imports_status(import_id: str, json_output: bool) -> None:
    """Get import status."""
    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        payload = client.get_import_status(import_id)
    except Exception as exc:
        raise click.ClickException(f"HubSpot import status failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    state = payload.get("state") or payload.get("status") or "unknown"
    click.echo(f"Import {import_id}: {state}")


@hubspot_imports.command("errors")
@click.option("--id", "import_id", required=True, help="Import id.")
@click.option(
    "--include-warnings/--errors-only",
    default=True,
    show_default=True,
    help="Include warnings in the returned payload.",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def hubspot_imports_errors(import_id: str, include_warnings: bool, json_output: bool) -> None:
    """Get import row-level errors."""
    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        payload = client.get_import_errors(import_id, include_warnings=include_warnings)
    except Exception as exc:
        raise click.ClickException(f"HubSpot import errors failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    results = payload.get("results") or []
    click.echo(f"Import {import_id} returned {len(results)} error/warning row(s). Use --json for details.")


@hubspot_imports.command("cancel")
@click.option("--id", "import_id", required=True, help="Import id.")
@click.option("--yes", "yes_cancel", is_flag=True, help="Confirm cancellation.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def hubspot_imports_cancel(import_id: str, yes_cancel: bool, json_output: bool) -> None:
    """Cancel an in-flight import."""
    if not yes_cancel:
        click.echo("Cancellation refused. Re-run with --yes to confirm.", err=True)
        sys.exit(EXIT_USAGE)

    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        payload = client.cancel_import(import_id)
    except Exception as exc:
        raise click.ClickException(f"HubSpot import cancel failed: {exc}") from exc

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(f"Cancellation requested for import {import_id}.")


def _parse_hubspot_association_limit_token(token: str) -> Dict[str, Any]:
    raw = str(token or "").strip()
    parts = [part.strip() for part in raw.split(":")]
    if len(parts) != 3:
        raise click.ClickException(
            "Invalid --input value. Expected category:type_id:max_to_object_ids "
            f"but got: {raw}"
        )
    category, type_id_text, max_to_object_ids_text = parts
    if not category:
        raise click.ClickException("Association limit category is required.")
    try:
        type_id = int(type_id_text)
    except Exception as exc:
        raise click.ClickException(f"Invalid association type id: {type_id_text}") from exc
    try:
        max_to_object_ids = int(max_to_object_ids_text)
    except Exception as exc:
        raise click.ClickException(f"Invalid max_to_object_ids value: {max_to_object_ids_text}") from exc
    if max_to_object_ids <= 0:
        raise click.ClickException("max_to_object_ids must be > 0.")
    return {
        "category": category.upper(),
        "typeId": type_id,
        "maxToObjectIds": max_to_object_ids,
    }


@hubspot.group("associations")
def hubspot_associations() -> None:
    """HubSpot association schema helpers (labels and limits)."""


@hubspot_associations.group("labels")
def hubspot_associations_labels() -> None:
    """Manage HubSpot association labels."""


@hubspot_associations_labels.command("ls")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_associations_labels_ls(from_type: str, to_type: str, json_output: bool) -> None:
    """List association labels for a HubSpot object pair."""
    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        labels = client.get_association_labels(from_type.lower(), to_type.lower())
    except Exception as exc:
        click.echo(f"HubSpot association labels list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(
            json.dumps(
                {
                    "from_type": from_type.lower(),
                    "to_type": to_type.lower(),
                    "count": len(labels),
                    "labels": labels,
                },
                indent=2,
            )
        )
        return

    if not labels:
        click.echo("No association labels returned.")
        return

    for label in labels:
        type_id = label.get("typeId") or label.get("associationTypeId") or ""
        category = label.get("category") or ""
        value = label.get("label") or ""
        inverse = label.get("inverseLabel") or ""
        if inverse:
            click.echo(f"{type_id}  {category}  {value}  (inverse={inverse})")
        else:
            click.echo(f"{type_id}  {category}  {value}")


@hubspot_associations_labels.command("create")
@click.argument("from_type")
@click.argument("to_type")
@click.option("--name", required=True, help="Internal API name for the label.")
@click.option("--label", "label_value", required=True, help="Forward-facing label text.")
@click.option("--inverse-label", default=None, help="Optional inverse label.")
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the label create (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_associations_labels_create(
    from_type: str,
    to_type: str,
    name: str,
    label_value: str,
    inverse_label: str | None,
    apply: bool,
    json_output: bool,
) -> None:
    """Create an association label."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot associations labels create", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "from_type": from_type.lower(),
        "to_type": to_type.lower(),
        "name": name,
        "label": label_value,
        "inverse_label": inverse_label or "",
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would create association label {name} ({label_value}) "
                f"for {from_type.lower()}->{to_type.lower()}."
            )
        return

    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        payload = client.create_association_label(
            from_type.lower(),
            to_type.lower(),
            name=name,
            label=label_value,
            inverse_label=inverse_label,
        )
    except Exception as exc:
        click.echo(f"HubSpot association label create failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    created_id = payload.get("typeId") or payload.get("associationTypeId") or "unknown"
    click.echo(f"Created association label {name} (typeId={created_id}).")


@hubspot_associations_labels.command("update")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--association-type-id",
    "--type-id",
    "association_type_id",
    required=True,
    type=int,
    help="Association type id to update.",
)
@click.option("--label", "label_value", required=True, help="Updated label text.")
@click.option("--inverse-label", default=None, help="Optional inverse label.")
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the label update (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_associations_labels_update(
    from_type: str,
    to_type: str,
    association_type_id: int,
    label_value: str,
    inverse_label: str | None,
    apply: bool,
    json_output: bool,
) -> None:
    """Update an association label."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot associations labels update", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "from_type": from_type.lower(),
        "to_type": to_type.lower(),
        "association_type_id": association_type_id,
        "label": label_value,
        "inverse_label": inverse_label or "",
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would update association label typeId={association_type_id} "
                f"for {from_type.lower()}->{to_type.lower()}."
            )
        return

    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        payload = client.update_association_label(
            from_type.lower(),
            to_type.lower(),
            association_type_id=association_type_id,
            label=label_value,
            inverse_label=inverse_label,
        )
    except Exception as exc:
        click.echo(f"HubSpot association label update failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(f"Updated association label typeId={association_type_id}.")


@hubspot_associations_labels.command("delete")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--association-type-id",
    "--type-id",
    "association_type_id",
    required=True,
    type=int,
    help="Association type id to delete.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the label delete (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_associations_labels_delete(
    from_type: str,
    to_type: str,
    association_type_id: int,
    apply: bool,
    json_output: bool,
) -> None:
    """Delete an association label."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot associations labels delete", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "from_type": from_type.lower(),
        "to_type": to_type.lower(),
        "association_type_id": association_type_id,
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would delete association label typeId={association_type_id} "
                f"for {from_type.lower()}->{to_type.lower()}."
            )
        return

    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        payload = client.delete_association_label(
            from_type.lower(),
            to_type.lower(),
            association_type_id,
        )
    except Exception as exc:
        click.echo(f"HubSpot association label delete failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(f"Deleted association label typeId={association_type_id}.")


@hubspot_associations.group("limits")
def hubspot_associations_limits() -> None:
    """Manage HubSpot association limits."""


@hubspot_associations_limits.command("ls")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_associations_limits_ls(from_type: str, to_type: str, json_output: bool) -> None:
    """List association limits for an object pair."""
    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        payload = client.get_association_limits(from_type.lower(), to_type.lower())
    except Exception as exc:
        click.echo(f"HubSpot association limits list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    rows = payload.get("results") or payload.get("inputs") or []
    if not rows:
        click.echo("No association limits returned.")
        return

    for item in rows:
        category = item.get("category") or ""
        type_id = item.get("typeId") or ""
        max_to = item.get("maxToObjectIds") or ""
        click.echo(f"{category}  typeId={type_id}  maxToObjectIds={max_to}")


@hubspot_associations_limits.command("set")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--input",
    "input_tokens",
    multiple=True,
    help=(
        "Association limit entry as category:type_id:max_to_object_ids "
        "(repeatable; comma-separated supported)."
    ),
)
@click.option(
    "--inputs-json",
    "inputs_json_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Path to JSON list (or {\"inputs\": [...]}) of association limit entries.",
)
@click.option(
    "--mode",
    type=click.Choice(["create", "update"], case_sensitive=False),
    default="update",
    show_default=True,
    help="HubSpot definitions configurations batch mode.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the association limits update (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_associations_limits_set(
    from_type: str,
    to_type: str,
    input_tokens: tuple[str, ...],
    inputs_json_path: Path | None,
    mode: str,
    apply: bool,
    json_output: bool,
) -> None:
    """Create or update association limits."""
    if input_tokens and inputs_json_path:
        raise click.ClickException("Use either --input or --inputs-json, not both.")

    inputs: List[Dict[str, Any]] = []
    if inputs_json_path:
        try:
            payload = json.loads(inputs_json_path.read_text(encoding="utf-8"))
        except Exception as exc:
            raise click.ClickException(f"Invalid JSON in {inputs_json_path}: {exc}") from exc
        if isinstance(payload, dict):
            raw_inputs = payload.get("inputs")
        else:
            raw_inputs = payload
        if not isinstance(raw_inputs, list):
            raise click.ClickException("--inputs-json must contain a JSON list or object with an inputs list.")
        for item in raw_inputs:
            if not isinstance(item, dict):
                raise click.ClickException("Each association limit entry in --inputs-json must be an object.")
            try:
                normalized = {
                    "category": str(item.get("category") or "").strip().upper(),
                    "typeId": int(item.get("typeId")),
                    "maxToObjectIds": int(item.get("maxToObjectIds")),
                }
            except Exception as exc:
                raise click.ClickException(f"Invalid association limit entry in JSON: {item}") from exc
            if not normalized["category"] or normalized["maxToObjectIds"] <= 0:
                raise click.ClickException(f"Invalid association limit entry in JSON: {item}")
            inputs.append(normalized)
    else:
        parsed_tokens: List[str] = []
        for raw in input_tokens:
            parsed_tokens.extend(_split_comma_list(raw))
        for token in parsed_tokens:
            inputs.append(_parse_hubspot_association_limit_token(token))

    if not inputs:
        raise click.ClickException("Provide at least one --input or --inputs-json entry.")

    mode_token = mode.lower()
    safety_ctx = SafetyContext(command="g-gremlin hubspot associations limits set", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "from_type": from_type.lower(),
        "to_type": to_type.lower(),
        "mode": mode_token,
        "inputs": inputs,
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would {mode_token} {len(inputs)} association limit(s) "
                f"for {from_type.lower()}->{to_type.lower()}."
            )
        return

    hs_mod = _get_hubspot_helpers()
    try:
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        payload = client.set_association_limits(
            from_type.lower(),
            to_type.lower(),
            inputs=inputs,
            mode=mode_token,
        )
    except Exception as exc:
        click.echo(f"HubSpot association limits set failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(
        f"Applied {mode_token} association limits for {from_type.lower()}->{to_type.lower()} "
        f"({len(inputs)} entry/entries)."
    )


@hubspot.group("webhooks")
def hubspot_webhooks() -> None:
    """HubSpot developer app webhook helpers."""


@hubspot_webhooks.group("subscriptions")
def hubspot_webhooks_subscriptions() -> None:
    """Manage HubSpot app webhook subscriptions."""


@hubspot_webhooks_subscriptions.command("ls")
@click.option("--app-id", required=True, help="HubSpot developer app ID.")
@click.option(
    "--dev-api-key",
    default=None,
    help="HubSpot developer API key (defaults to G_GREMLIN_HUBSPOT_DEV_API_KEY).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_webhooks_subscriptions_ls(app_id: str, dev_api_key: str | None, json_output: bool) -> None:
    """List webhook subscriptions for a HubSpot developer app."""
    hs_mod = _get_hubspot_helpers()
    api_key = _resolve_hubspot_dev_api_key(dev_api_key)
    try:
        subscriptions = hs_mod.get_webhook_subscriptions(
            app_id,
            developer_api_key=api_key,
            profile=_get_hubspot_profile(),
        )
    except Exception as exc:
        click.echo(f"HubSpot webhook subscriptions list failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(
            json.dumps(
                {
                    "app_id": app_id,
                    "count": len(subscriptions),
                    "subscriptions": subscriptions,
                },
                indent=2,
            )
        )
        return

    if not subscriptions:
        click.echo("No webhook subscriptions returned.")
        return

    for sub in subscriptions:
        sub_id = sub.get("id") or ""
        event_type = sub.get("eventType") or ""
        property_name = sub.get("propertyName") or ""
        active = bool(sub.get("active"))
        suffix = f" property={property_name}" if property_name else ""
        click.echo(f"{sub_id}  {event_type}  active={str(active).lower()}{suffix}")


@hubspot_webhooks_subscriptions.command("create")
@click.option("--app-id", required=True, help="HubSpot developer app ID.")
@click.option("--event-type", required=True, help="Webhook event type (for example contact.creation).")
@click.option("--property-name", default=None, help="Optional property name for property-specific events.")
@click.option(
    "--active/--inactive",
    default=False,
    show_default=True,
    help="Set active state for the subscription.",
)
@click.option(
    "--dev-api-key",
    default=None,
    help="HubSpot developer API key (defaults to G_GREMLIN_HUBSPOT_DEV_API_KEY).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the webhook subscription create (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_webhooks_subscriptions_create(
    app_id: str,
    event_type: str,
    property_name: str | None,
    active: bool,
    dev_api_key: str | None,
    apply: bool,
    json_output: bool,
) -> None:
    """Create a HubSpot app webhook subscription."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot webhooks subscriptions create", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "app_id": app_id,
        "event_type": event_type,
        "property_name": property_name or "",
        "active": active,
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(f"[DRY-RUN] Would create webhook subscription for app {app_id}: {event_type}.")
        return

    hs_mod = _get_hubspot_helpers()
    api_key = _resolve_hubspot_dev_api_key(dev_api_key)
    try:
        payload = hs_mod.create_webhook_subscription(
            app_id,
            event_type=event_type,
            property_name=property_name,
            active=active,
            developer_api_key=api_key,
            profile=_get_hubspot_profile(),
        )
    except Exception as exc:
        click.echo(f"HubSpot webhook subscription create failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    created_id = payload.get("id") or "unknown"
    click.echo(f"Created webhook subscription id={created_id}.")


@hubspot_webhooks_subscriptions.command("set-active")
@click.option("--app-id", required=True, help="HubSpot developer app ID.")
@click.option("--subscription-id", required=True, type=int, help="Webhook subscription id.")
@click.option(
    "--active/--inactive",
    default=True,
    show_default=True,
    help="Set active state for the subscription.",
)
@click.option(
    "--dev-api-key",
    default=None,
    help="HubSpot developer API key (defaults to G_GREMLIN_HUBSPOT_DEV_API_KEY).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the subscription update (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_webhooks_subscriptions_set_active(
    app_id: str,
    subscription_id: int,
    active: bool,
    dev_api_key: str | None,
    apply: bool,
    json_output: bool,
) -> None:
    """Set the active state for a HubSpot app webhook subscription."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot webhooks subscriptions set-active", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "app_id": app_id,
        "subscription_id": subscription_id,
        "active": active,
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would set webhook subscription {subscription_id} "
                f"active={str(active).lower()} for app {app_id}."
            )
        return

    hs_mod = _get_hubspot_helpers()
    api_key = _resolve_hubspot_dev_api_key(dev_api_key)
    try:
        payload = hs_mod.update_webhook_subscription(
            app_id,
            subscription_id,
            active=active,
            developer_api_key=api_key,
            profile=_get_hubspot_profile(),
        )
    except Exception as exc:
        click.echo(f"HubSpot webhook subscription update failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(f"Updated webhook subscription id={subscription_id} active={str(active).lower()}.")


@hubspot_webhooks_subscriptions.command("delete")
@click.option("--app-id", required=True, help="HubSpot developer app ID.")
@click.option("--subscription-id", required=True, type=int, help="Webhook subscription id.")
@click.option(
    "--dev-api-key",
    default=None,
    help="HubSpot developer API key (defaults to G_GREMLIN_HUBSPOT_DEV_API_KEY).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the subscription delete (default is dry-run preview).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def hubspot_webhooks_subscriptions_delete(
    app_id: str,
    subscription_id: int,
    dev_api_key: str | None,
    apply: bool,
    json_output: bool,
) -> None:
    """Delete a HubSpot app webhook subscription."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot webhooks subscriptions delete", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    preview = {
        "app_id": app_id,
        "subscription_id": subscription_id,
    }
    if not apply:
        if json_output:
            click.echo(json.dumps({"dry_run": True, "preview": preview}, indent=2))
        else:
            click.echo(f"[DRY-RUN] Would delete webhook subscription {subscription_id} for app {app_id}.")
        return

    hs_mod = _get_hubspot_helpers()
    api_key = _resolve_hubspot_dev_api_key(dev_api_key)
    try:
        payload = hs_mod.delete_webhook_subscription(
            app_id,
            subscription_id,
            developer_api_key=api_key,
            profile=_get_hubspot_profile(),
        )
    except Exception as exc:
        click.echo(f"HubSpot webhook subscription delete failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(f"Deleted webhook subscription id={subscription_id}.")


def _load_hubspot_ids_from_csv(csv_path: Path, id_column: str) -> List[str]:
    with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        fieldnames = set(reader.fieldnames or [])
        if id_column not in fieldnames:
            raise click.ClickException(
                f"CSV missing required id column '{id_column}'. "
                f"Available columns: {', '.join(sorted(fieldnames))}"
            )
        ids: List[str] = []
        for row in reader:
            value = str(row.get(id_column) or "").strip()
            if value:
                ids.append(value)
    return ids


@hubspot.group("history")
def hubspot_history() -> None:
    """HubSpot property history helpers."""


@hubspot_history.command("pull")
@click.argument("object_type")
@click.option(
    "--property",
    "history_property_tokens",
    multiple=True,
    help="Property name(s) to fetch with history (repeatable, comma-separated supported).",
)
@click.option(
    "--id",
    "record_id_tokens",
    multiple=True,
    help="Record id(s) to fetch (repeatable, comma-separated supported).",
)
@click.option(
    "--csv",
    "csv_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Optional CSV containing record ids.",
)
@click.option(
    "--id-column",
    default="id",
    show_default=True,
    help="ID column name when --csv is provided.",
)
@click.option(
    "--id-property",
    default=None,
    help="Optional HubSpot idProperty for alternate identifiers (for example email).",
)
@click.option(
    "--include-current/--history-only",
    default=True,
    show_default=True,
    help="Include current property values alongside propertiesWithHistory.",
)
@click.option(
    "--archived",
    is_flag=True,
    help="Include archived records.",
)
@click.option(
    "--continue-on-error/--fail-fast",
    default=True,
    show_default=True,
    help="Continue processing remaining ids if one history read fails.",
)
@click.option(
    "--out",
    "out_path",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Optional output JSON file path.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def hubspot_history_pull(
    object_type: str,
    history_property_tokens: tuple[str, ...],
    record_id_tokens: tuple[str, ...],
    csv_path: Path | None,
    id_column: str,
    id_property: str | None,
    include_current: bool,
    archived: bool,
    continue_on_error: bool,
    out_path: Path | None,
    json_output: bool,
    json_summary: bool,
) -> None:
    """Pull HubSpot propertiesWithHistory for specific records."""
    history_properties: List[str] = []
    for token in history_property_tokens:
        history_properties.extend(_split_comma_list(token))
    history_properties = list(dict.fromkeys(history_properties))
    if not history_properties:
        raise click.ClickException("Provide at least one --property value.")

    requested_ids: List[str] = []
    for token in record_id_tokens:
        requested_ids.extend(_split_comma_list(token))
    if csv_path:
        requested_ids.extend(_load_hubspot_ids_from_csv(csv_path, id_column=id_column))
    if not requested_ids:
        raise click.ClickException("Provide at least one --id value or --csv input.")

    deduped_ids: List[str] = []
    seen_ids: set[str] = set()
    for value in requested_ids:
        token = str(value).strip()
        if not token or token in seen_ids:
            continue
        seen_ids.add(token)
        deduped_ids.append(token)

    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    records: List[Dict[str, Any]] = []
    failures: List[Dict[str, Any]] = []
    for idx, record_id in enumerate(deduped_ids):
        try:
            payload = client.get_object_with_history(
                object_type.lower(),
                record_id,
                properties_with_history=history_properties,
                properties=history_properties if include_current else None,
                archived=archived,
                id_property=id_property,
            )
            records.append(payload)
        except Exception as exc:
            failures.append(
                {
                    "index": idx,
                    "id": record_id,
                    "error": str(exc),
                }
            )
            if not continue_on_error:
                break

    result_payload = {
        "object_type": object_type.lower(),
        "history_properties": history_properties,
        "requested_ids": len(deduped_ids),
        "retrieved": len(records),
        "failed": len(failures),
        "records": records,
        "failures": failures,
        "source": {
            "ids": deduped_ids,
            "csv": str(csv_path) if csv_path else "",
            "id_column": id_column if csv_path else "",
            "id_property": str(id_property or ""),
            "include_current": include_current,
            "archived": archived,
        },
    }

    if out_path:
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(json.dumps(result_payload, indent=2), encoding="utf-8")

    if json_output:
        click.echo(json.dumps(result_payload, indent=2))
    elif out_path:
        click.echo(
            f"Wrote HubSpot history payload to {out_path}. "
            f"Retrieved {len(records)} record(s), failed {len(failures)}."
        )
    else:
        click.echo(
            f"HubSpot history pull finished. Retrieved {len(records)} record(s), failed {len(failures)}."
        )
        if failures:
            for failure in failures[:5]:
                click.echo(f"  Failed id {failure.get('id')}: {failure.get('error')}")
            if len(failures) > 5:
                click.echo(f"  ... and {len(failures) - 5} more failure(s).")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin hubspot history pull", remote_mutation=False)
        warnings: List[AgenticWarning] = []
        if failures:
            warnings.append(
                AgenticWarning(
                    code="HISTORY_PULL_PARTIAL_FAILURE",
                    message=(
                        f"Failed to retrieve property history for {len(failures)} of {len(deduped_ids)} record(s)."
                    ),
                    severity="warning",
                )
            )
        status = "success" if not failures else ("partial_success" if records else "error")
        agentic = safety_ctx.build_result(
            status=status,
            dry_run=False,
            result={
                "object_type": object_type.lower(),
                "history_properties": history_properties,
                "requested_ids": len(deduped_ids),
                "retrieved": len(records),
                "failed": failures,
                "output": str(out_path) if out_path else "",
            },
            warnings=warnings,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@hubspot.group("engagements")
def hubspot_engagements() -> None:
    """HubSpot engagement/activity helpers."""


@hubspot_engagements.command("pull")
@click.option(
    "--types",
    "engagement_types",
    default="emails,calls,meetings,notes,tasks",
    show_default=True,
    help="Comma-separated engagement object types (emails,calls,meetings,notes,tasks).",
)
@click.option(
    "--properties",
    help="Optional extra comma-separated engagement properties to request.",
)
@click.option(
    "--since",
    help="Inclusive lower-bound timestamp (ISO 8601, e.g. 2025-01-01T00:00:00Z).",
)
@click.option(
    "--until",
    help="Exclusive upper-bound timestamp (ISO 8601, e.g. 2026-01-01T00:00:00Z).",
)
@click.option(
    "--limit",
    type=int,
    default=0,
    show_default=True,
    help="Maximum engagements to return across all requested types (0 = no limit).",
)
@click.option(
    "--output-dir",
    type=click.Path(file_okay=False, path_type=Path),
    required=True,
    help="Directory for output CSVs.",
)
@click.option(
    "--auto-export-on-cap/--no-auto-export-on-cap",
    default=True,
    show_default=True,
    help="When the 10k Search cap is hit, automatically run async export fallback per truncated engagement type.",
)
@click.option(
    "--export-timeout",
    "export_timeout_seconds",
    type=int,
    default=900,
    show_default=True,
    help="Timeout in seconds for each async export fallback download.",
)
@click.option(
    "--export-poll-interval",
    "export_poll_interval_seconds",
    type=int,
    default=5,
    show_default=True,
    help="Polling interval (seconds) for async export fallback.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def hubspot_engagements_pull(
    engagement_types: str,
    properties: str | None,
    since: str | None,
    until: str | None,
    limit: int,
    output_dir: Path,
    auto_export_on_cap: bool,
    export_timeout_seconds: int,
    export_poll_interval_seconds: int,
    json_summary: bool,
) -> None:
    """Pull engagement data and write normalized fact/bridge CSVs."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")

    since_dt = _parse_iso8601(since) if since else None
    until_dt = _parse_iso8601(until) if until else None
    if since and since_dt is None:
        raise click.ClickException("--since must be an ISO 8601 timestamp.")
    if until and until_dt is None:
        raise click.ClickException("--until must be an ISO 8601 timestamp.")
    if since_dt and until_dt and since_dt >= until_dt:
        raise click.ClickException("--since must be earlier than --until.")

    raw_types = [token.strip() for token in engagement_types.split(",") if token.strip()]
    extra_props = [token.strip() for token in properties.split(",") if token.strip()] if properties else []

    hs_mod = _get_hubspot_helpers()
    client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
    try:
        normalized_types = hs_mod.normalize_engagement_types(raw_types or hs_mod.DEFAULT_ENGAGEMENT_TYPES)
        payload = client.pull_engagements(
            normalized_types,
            properties=extra_props,
            limit=limit,
            since=since,
            until=until,
        )
    except Exception as exc:
        raise click.ClickException(f"HubSpot engagements pull failed: {exc}") from exc

    fact_rows = payload.get("fact_engagement", []) or []
    contact_rows = payload.get("bridge_engagement_contact", []) or []
    deal_rows = payload.get("bridge_engagement_deal", []) or []
    company_rows = payload.get("bridge_engagement_company", []) or []

    fact_path = output_dir / "fact_engagement.csv"
    contact_path = output_dir / "bridge_engagement_contact.csv"
    deal_path = output_dir / "bridge_engagement_deal.csv"
    company_path = output_dir / "bridge_engagement_company.csv"

    _write_csv_with_headers(
        fact_path,
        fact_rows,
        [
            "engagement_id",
            "engagement_type",
            "timestamp",
            "timestamp_ms",
            "created_at",
            "updated_at",
            "owner_id",
            "subject",
            "status",
            "direction",
            "outcome",
            "activity_type",
            "raw_properties",
        ],
    )
    _write_csv_with_headers(
        contact_path,
        contact_rows,
        ["engagement_id", "engagement_type", "contact_id"],
    )
    _write_csv_with_headers(
        deal_path,
        deal_rows,
        ["engagement_id", "engagement_type", "deal_id"],
    )
    _write_csv_with_headers(
        company_path,
        company_rows,
        ["engagement_id", "engagement_type", "company_id"],
    )

    click.echo(f"Wrote {len(fact_rows)} rows to {fact_path}.")
    click.echo(f"Wrote {len(contact_rows)} rows to {contact_path}.")
    click.echo(f"Wrote {len(deal_rows)} rows to {deal_path}.")
    click.echo(f"Wrote {len(company_rows)} rows to {company_path}.")

    # --- 10k ceiling truncation detection ---
    truncated_types = payload.get("_truncated_types") or []
    truncation_warnings: list[AgenticWarning] = []
    suggested_sub_ranges: list[dict[str, str]] = []
    auto_export_exports: list[dict[str, Any]] = []
    auto_export_failures: list[dict[str, Any]] = []
    if truncated_types:
        types_str = ", ".join(truncated_types)
        click.echo(
            f"\nWARNING: HubSpot returned 10,000 records for: {types_str}\n"
            "  Results are likely truncated due to the HubSpot Search API ceiling."
        )
        suggested_sub_ranges = hs_mod.suggest_sub_ranges(since, until)
        if suggested_sub_ranges:
            click.echo("  Suggested sub-ranges to retrieve complete data:")
            for sr in suggested_sub_ranges:
                click.echo(f"    --since {sr['since']} --until {sr['until']}")
        else:
            click.echo(
                "  Provide --since and --until flags to enable sub-range suggestions."
            )

        truncation_warnings.append(
            AgenticWarning(
                code="SEARCH_CAP_TRUNCATION",
                message=(
                    f"HubSpot Search API returned 10,000 records (hard ceiling) for: {types_str}. "
                    "Results are likely incomplete. Re-run with smaller --since/--until date windows. "
                    "If any single month still hits 10,000, split into weekly intervals."
                ),
                severity="warning",
            )
        )

        if auto_export_on_cap:
            click.echo("  Auto-export fallback enabled. Attempting async exports for truncated types...")
            export_filters: list[dict[str, Any]] = []
            if since_dt:
                export_filters.append(
                    {
                        "propertyName": "hs_timestamp",
                        "operator": "GTE",
                        "value": int(since_dt.timestamp() * 1000),
                    }
                )
            if until_dt:
                export_filters.append(
                    {
                        "propertyName": "hs_timestamp",
                        "operator": "LT",
                        "value": int(until_dt.timestamp() * 1000),
                    }
                )
            public_search_request = (
                {"filterGroups": [{"filters": export_filters}]}
                if export_filters
                else None
            )

            for engagement_type in truncated_types:
                try:
                    export_name = (
                        f"g-gremlin-{engagement_type}-"
                        f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
                    )
                    start_payload = client.start_export(
                        object_type=engagement_type,
                        export_type="VIEW",
                        file_format="CSV",
                        export_name=export_name,
                        object_properties=extra_props or None,
                        public_crm_search_request=public_search_request,
                    )
                    task_id = str(
                        start_payload.get("id")
                        or start_payload.get("taskId")
                        or start_payload.get("task_id")
                        or ""
                    ).strip()
                    if not task_id:
                        raise RuntimeError("missing export task id")

                    export_path = output_dir / f"export_{engagement_type}.csv"
                    dl_payload = client.download_export_file(
                        task_id,
                        export_path,
                        wait=True,
                        timeout_seconds=export_timeout_seconds,
                        poll_interval_seconds=export_poll_interval_seconds,
                    )
                    artifact = {
                        "engagement_type": engagement_type,
                        "task_id": task_id,
                        "path": str(export_path),
                        "bytes_written": int(dl_payload.get("bytes_written") or 0),
                        "state": str(dl_payload.get("state") or ""),
                    }
                    auto_export_exports.append(artifact)
                    click.echo(
                        f"  Auto-export fallback wrote {export_path} ({artifact['bytes_written']} bytes)."
                    )
                except Exception as exc:
                    failure = {
                        "engagement_type": engagement_type,
                        "error": str(exc),
                    }
                    auto_export_failures.append(failure)
                    click.echo(
                        f"  Auto-export fallback failed for {engagement_type}: {exc}"
                    )
                    truncation_warnings.append(
                        AgenticWarning(
                            code="SEARCH_CAP_AUTO_EXPORT_FAILED",
                            message=(
                                f"Auto-export fallback failed for engagement type '{engagement_type}': {exc}"
                            ),
                            severity="warning",
                        )
                    )
        else:
            click.echo("  Auto-export fallback disabled (--no-auto-export-on-cap).")

    auto_export_note = ""
    if auto_export_exports:
        auto_export_note = (
            "Auto-export fallback files are raw HubSpot export format (object properties), "
            "not normalized fact/bridge schema."
        )
        click.echo(f"  NOTE: {auto_export_note}")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin hubspot engagements pull", remote_mutation=False)
        result_payload: dict[str, Any] = {
            "types": normalized_types,
            "limit": limit,
            "since": since or "",
            "until": until or "",
            "outputs": {
                "fact_engagement": str(fact_path),
                "bridge_engagement_contact": str(contact_path),
                "bridge_engagement_deal": str(deal_path),
                "bridge_engagement_company": str(company_path),
            },
            "counts": {
                "fact_engagement": len(fact_rows),
                "bridge_engagement_contact": len(contact_rows),
                "bridge_engagement_deal": len(deal_rows),
                "bridge_engagement_company": len(company_rows),
            },
        }
        if truncated_types:
            result_payload["truncated_types"] = truncated_types
            result_payload["suggested_sub_ranges"] = suggested_sub_ranges
            result_payload["auto_export_on_cap"] = auto_export_on_cap
            result_payload["auto_export_exports"] = auto_export_exports
            if auto_export_failures:
                result_payload["auto_export_failures"] = auto_export_failures
            if auto_export_note:
                result_payload["auto_export_note"] = auto_export_note
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result=result_payload,
            warnings=truncation_warnings or None,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@hubspot.command("pull")
@click.argument("object_type")
@click.option(
    "--properties",
    help="Comma-separated list of properties to include.",
)
@click.option(
    "--associations",
    help="Comma-separated associated object types to include (example: contacts).",
)
@click.option(
    "--associations-output-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Directory for bridge CSV outputs when --associations is set (default: output dir).",
)
@click.option(
    "--limit",
    type=int,
    default=0,
    help="Limit number of records (0 = no limit).",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    required=True,
    help="Output CSV path.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@pacing_option
def hubspot_pull(
    object_type: str,
    properties: str | None,
    associations: str | None,
    associations_output_dir: Path | None,
    limit: int,
    output_path: Path,
    json_summary: bool,
    safe_mode: bool,
) -> None:
    """
    Pull HubSpot objects to CSV (with optional association bridges).
    """
    props = [p.strip() for p in properties.split(",") if p.strip()] if properties else []
    association_targets = _parse_hubspot_association_targets(associations)
    hs_mod = _get_hubspot_helpers()
    try:
        records = hs_mod.pull_objects(
            object_type.lower(),
            props,
            limit,
            pacing_profile=get_hubspot_profile("safe" if safe_mode else "turbo"),
        )
    except Exception as exc:
        click.echo(f"Error pulling from HubSpot: {exc}")
        sys.exit(1)

    if not records:
        click.echo("No records returned.")
        return

    bridge_files: Dict[str, str] = {}
    bridge_counts: Dict[str, int] = {}
    if association_targets:
        record_ids = [str(item.get("id")) for item in records if item.get("id")]
        if record_ids:
            assoc_client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
            bridge_dir = associations_output_dir or output_path.parent
            bridge_dir.mkdir(parents=True, exist_ok=True)

            from_type = object_type.lower()
            from_col = _hubspot_object_id_column(from_type)
            for target_type in association_targets:
                try:
                    assoc_map = assoc_client.read_associations_batch(from_type, target_type, record_ids)
                except Exception as exc:
                    raise click.ClickException(
                        f"HubSpot association pull failed for {from_type}->{target_type}: {exc}"
                    ) from exc

                to_col = _hubspot_object_id_column(target_type)
                bridge_rows: List[Dict[str, Any]] = []
                for record in records:
                    raw_id = record.get("id")
                    if raw_id is None:
                        continue
                    src_id = str(raw_id)
                    targets = [str(item) for item in (assoc_map.get(src_id) or []) if item]
                    record[f"associations_{target_type}"] = ";".join(targets)
                    for target_id in targets:
                        bridge_rows.append(
                            {
                                from_col: src_id,
                                to_col: target_id,
                                "association_type": "api",
                            }
                        )
                for record in records:
                    if f"associations_{target_type}" not in record:
                        record[f"associations_{target_type}"] = ""

                bridge_path = bridge_dir / f"bridge_{from_type}_{target_type}.csv"
                _write_csv_with_headers(
                    bridge_path,
                    bridge_rows,
                    [from_col, to_col, "association_type"],
                )
                bridge_files[target_type] = str(bridge_path)
                bridge_counts[target_type] = len(bridge_rows)
                click.echo(f"Wrote {len(bridge_rows)} associations to {bridge_path}.")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        writer.writerows(records)

    click.echo(f"Wrote {len(records)} {object_type} to {output_path}.")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin hubspot pull", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "object": object_type,
                "count": len(records),
                "properties": props,
                "output": str(output_path),
                "associations": association_targets,
                "association_bridge_files": bridge_files,
                "association_bridge_counts": bridge_counts,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@hubspot.command("upsert")
@click.argument("object_type")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with records to upsert.",
)
@click.option(
    "--id-column",
    required=True,
    help="Column used as idProperty for upsert (e.g., hs_object_id, email for contacts).",
)
@click.option(
    "--batch-size",
    type=int,
    default=None,
    show_default=False,
    help="Optional batch size override for HubSpot upsert (max 100).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the upsert (default is dry-run preview).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@pacing_option
def hubspot_upsert(
    object_type: str,
    csv_path: Path,
    id_column: str,
    batch_size: int | None,
    apply: bool,
    json_summary: bool,
    safe_mode: bool,
) -> None:
    """
    Upsert HubSpot objects from a CSV (dry-run by default).
    """
    pacing_profile = get_hubspot_profile("safe" if safe_mode else "turbo")
    effective_batch_size = min(batch_size or pacing_profile.batch_size, 100)
    safety_ctx = SafetyContext(command="g-gremlin hubspot upsert", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    if not apply:
        click.echo(f"[DRY-RUN] Would upsert records from {csv_path} into {object_type}.")
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "object": object_type,
                    "csv": str(csv_path),
                    "id_column": id_column,
                    "batch_size": effective_batch_size,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    try:
        outcome = _get_hubspot_helpers().upsert_objects_from_csv(
            object_type.lower(),
            csv_path,
            id_column,
            batch_size=batch_size,
            pacing_profile=pacing_profile,
        )
    except Exception as exc:
        click.echo(f"HubSpot upsert failed: {exc}")
        sys.exit(1)

    succeeded = len(outcome.succeeded)
    failed_items = outcome.failed + outcome.retryable_failed
    failed = len(failed_items)
    status = outcome.status
    click.echo(f"Upsert {status}. Succeeded: {succeeded}, Failed: {failed}.")

    if failed_items:
        # Show up to 5 sample errors so users can diagnose
        sample = failed_items[:5]
        click.echo("\nSample errors:", err=True)
        for item in sample:
            err_id = item.get("id", "?")
            err_msg = item.get("error", "unknown")
            click.echo(f"  id={err_id}: {err_msg}", err=True)
        if failed > len(sample):
            click.echo(f"  ... and {failed - len(sample)} more. Use --json-summary for full details.", err=True)

    if json_summary:
        agentic = safety_ctx.build_result(
            status=status,
            dry_run=False,
            result={
                "object": object_type,
                "succeeded": outcome.succeeded,
                "failed": outcome.failed,
                "retryable_failed": outcome.retryable_failed,
            },
            batch_outcome=outcome,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@hubspot.command("associate")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV mapping file.",
)
@click.option(
    "--from-column",
    required=True,
    help="Column name for source object id.",
)
@click.option(
    "--to-column",
    required=True,
    help="Column name for target object id.",
)
@click.option(
    "--batch-size",
    type=int,
    default=None,
    show_default=False,
    help="Optional batch size override for associations (max 2000).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute associations (default is dry-run preview).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@pacing_option
def hubspot_associate(
    from_type: str,
    to_type: str,
    csv_path: Path,
    from_column: str,
    to_column: str,
    batch_size: int | None,
    apply: bool,
    json_summary: bool,
    safe_mode: bool,
) -> None:
    """
    Create associations between HubSpot objects (dry-run by default).
    """
    pacing_profile = get_hubspot_profile("safe" if safe_mode else "turbo")
    effective_batch_size = min(batch_size or pacing_profile.batch_size, 2000)
    safety_ctx = SafetyContext(command="g-gremlin hubspot associate", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    pairs = _get_hubspot_helpers().load_csv_mapping(csv_path, from_column, to_column)
    if not apply:
        click.echo(f"[DRY-RUN] Would associate {len(pairs)} pairs {from_type}->{to_type}.")
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "from_type": from_type,
                    "to_type": to_type,
                    "count": len(pairs),
                    "csv": str(csv_path),
                    "batch_size": effective_batch_size,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    try:
        outcome = _get_hubspot_helpers().associate_batch(
            from_type.lower(),
            to_type.lower(),
            pairs,
            batch_size=batch_size,
            pacing_profile=pacing_profile,
        )
    except Exception as exc:
        click.echo(f"Association failed: {exc}")
        sys.exit(1)

    succeeded = len(outcome.succeeded)
    failed = len(outcome.failed) + len(outcome.retryable_failed)
    click.echo(f"Associations result: success={succeeded}, failed={failed}.")

    if json_summary:
        agentic = safety_ctx.build_result(
            status=outcome.status,
            dry_run=False,
            result={
                "from_type": from_type,
                "to_type": to_type,
                "succeeded": outcome.succeeded,
                "failed": outcome.failed,
                "retryable_failed": outcome.retryable_failed,
            },
            batch_outcome=outcome,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@hubspot.command("dissociate")
@click.argument("from_type")
@click.argument("to_type")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV mapping file.",
)
@click.option(
    "--from-column",
    required=True,
    help="Column name for source object id.",
)
@click.option(
    "--to-column",
    required=True,
    help="Column name for target object id.",
)
@click.option(
    "--batch-size",
    type=int,
    default=None,
    show_default=False,
    help="Optional batch size override for dissociations (max 2000).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute dissociations (default is dry-run preview).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@pacing_option
def hubspot_dissociate(
    from_type: str,
    to_type: str,
    csv_path: Path,
    from_column: str,
    to_column: str,
    batch_size: int | None,
    apply: bool,
    json_summary: bool,
    safe_mode: bool,
) -> None:
    """
    Remove associations between HubSpot objects (dry-run by default).
    """
    pacing_profile = get_hubspot_profile("safe" if safe_mode else "turbo")
    effective_batch_size = min(batch_size or pacing_profile.batch_size, 2000)
    safety_ctx = SafetyContext(command="g-gremlin hubspot dissociate", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    pairs = _get_hubspot_helpers().load_csv_mapping(csv_path, from_column, to_column)
    if not apply:
        click.echo(f"[DRY-RUN] Would dissociate {len(pairs)} pairs {from_type}->{to_type}.")
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "from_type": from_type,
                    "to_type": to_type,
                    "count": len(pairs),
                    "csv": str(csv_path),
                    "batch_size": effective_batch_size,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    try:
        outcome = _get_hubspot_helpers().dissociate_batch(
            from_type.lower(),
            to_type.lower(),
            pairs,
            batch_size=batch_size,
            pacing_profile=pacing_profile,
        )
    except Exception as exc:
        click.echo(f"Dissociation failed: {exc}")
        sys.exit(1)

    succeeded = len(outcome.succeeded)
    failed = len(outcome.failed) + len(outcome.retryable_failed)
    click.echo(f"Dissociations result: success={succeeded}, failed={failed}.")

    if json_summary:
        agentic = safety_ctx.build_result(
            status=outcome.status,
            dry_run=False,
            result={
                "from_type": from_type,
                "to_type": to_type,
                "succeeded": outcome.succeeded,
                "failed": outcome.failed,
                "retryable_failed": outcome.retryable_failed,
            },
            batch_outcome=outcome,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


def _coerce_hubspot_merge_timestamp(value: Any) -> int | None:
    """Normalize timestamp-ish values to epoch millis for merge heuristics."""
    if value is None:
        return None
    raw = str(value).strip()
    if not raw:
        return None
    if raw.isdigit():
        numeric = int(raw)
        if numeric >= 1_000_000_000_000:
            return numeric
        if numeric >= 1_000_000_000:
            return numeric * 1000
        return None
    parsed = _parse_iso8601(raw)
    if not parsed:
        return None
    return int(parsed.timestamp() * 1000)


def _select_hubspot_merge_primary(
    members: List[Dict[str, Any]],
    *,
    keep_strategy: str,
    created_column: str,
    activity_column: str,
) -> Dict[str, Any]:
    if not members:
        raise ValueError("members must not be empty")
    if keep_strategy == "first":
        return members[0]

    if keep_strategy == "oldest-created":
        ranked = [
            (_coerce_hubspot_merge_timestamp(item.get(created_column)), int(item.get("_index") or 0), item)
            for item in members
        ]
        candidates = [entry for entry in ranked if entry[0] is not None]
        if not candidates:
            return members[0]
        candidates.sort(key=lambda t: (int(t[0]), int(t[1])))
        return candidates[0][2]

    if keep_strategy == "newest-activity":
        ranked = [
            (_coerce_hubspot_merge_timestamp(item.get(activity_column)), int(item.get("_index") or 0), item)
            for item in members
        ]
        candidates = [entry for entry in ranked if entry[0] is not None]
        if not candidates:
            return members[0]
        candidates.sort(key=lambda t: (int(t[0]), -int(t[1])), reverse=True)
        return candidates[0][2]

    return members[0]


def _build_hubspot_merge_plan_payload(
    *,
    object_type: str,
    rows: List[Dict[str, Any]],
    key_column: str,
    keep_strategy: str,
    created_column: str,
    activity_column: str,
    source_meta: Dict[str, Any],
    max_merges: int,
) -> Dict[str, Any]:
    grouped: Dict[str, List[Dict[str, Any]]] = {}
    for idx, row in enumerate(rows):
        record_id = str(row.get("id") or "").strip()
        if not record_id:
            continue
        raw_key = row.get(key_column)
        key_value = str(raw_key or "").strip()
        if not key_value:
            continue
        norm_key = key_value.lower()
        candidate = dict(row)
        candidate["_index"] = idx
        candidate["_record_id"] = record_id
        candidate["_dedupe_key"] = key_value
        grouped.setdefault(norm_key, []).append(candidate)

    duplicate_groups = []
    merges = []
    for norm_key, members in sorted(grouped.items(), key=lambda item: item[0]):
        if len(members) < 2:
            continue
        members_sorted = sorted(members, key=lambda item: int(item.get("_index") or 0))
        primary = _select_hubspot_merge_primary(
            members_sorted,
            keep_strategy=keep_strategy,
            created_column=created_column,
            activity_column=activity_column,
        )
        merge_targets = [item for item in members_sorted if item["_record_id"] != primary["_record_id"]]
        if not merge_targets:
            continue
        duplicate_groups.append(
            {
                "duplicate_key": primary["_dedupe_key"],
                "group_size": len(members_sorted),
                "primary_id": primary["_record_id"],
                "merge_ids": [item["_record_id"] for item in merge_targets],
            }
        )
        for target in merge_targets:
            merges.append(
                {
                    "object_type": object_type,
                    "primary_id": primary["_record_id"],
                    "merge_id": target["_record_id"],
                    "duplicate_key": primary["_dedupe_key"],
                    "reason": f"keep={keep_strategy}",
                }
            )
            if max_merges > 0 and len(merges) >= max_merges:
                break
        if max_merges > 0 and len(merges) >= max_merges:
            break

    payload = {
        "version": 1,
        "object_type": object_type,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "source": source_meta,
        "strategy": {
            "key_column": key_column,
            "keep": keep_strategy,
            "created_column": created_column,
            "activity_column": activity_column,
        },
        "stats": {
            "records_scanned": len(rows),
            "duplicate_groups": len(duplicate_groups),
            "merges_planned": len(merges),
        },
        "groups": duplicate_groups,
        "merges": merges,
    }
    return payload


def _load_hubspot_merge_rows_from_csv(
    csv_path: Path,
    *,
    id_column: str,
    key_column: str,
    created_column: str,
    activity_column: str,
) -> List[Dict[str, Any]]:
    with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        fieldnames = set(reader.fieldnames or [])
        required = [id_column, key_column]
        missing = [name for name in required if name not in fieldnames]
        if missing:
            raise click.ClickException(
                f"CSV missing required column(s): {', '.join(missing)}. "
                f"Available columns: {', '.join(sorted(fieldnames))}"
            )

        rows: List[Dict[str, Any]] = []
        for item in reader:
            rows.append(
                {
                    "id": item.get(id_column),
                    key_column: item.get(key_column),
                    created_column: item.get(created_column),
                    activity_column: item.get(activity_column),
                }
            )
    return rows


def _window_hubspot_filter_groups(
    filter_groups: List[Dict[str, Any]] | None,
    *,
    property_name: str,
    start_ms: int,
    end_ms: int,
) -> List[Dict[str, Any]]:
    base_groups = filter_groups or [{"filters": []}]
    windowed_groups: List[Dict[str, Any]] = []
    for group in base_groups:
        existing_filters = list((group or {}).get("filters") or [])
        existing_filters.extend(
            [
                {"propertyName": property_name, "operator": "GTE", "value": int(start_ms)},
                {"propertyName": property_name, "operator": "LT", "value": int(end_ms)},
            ]
        )
        next_group = dict(group or {})
        next_group["filters"] = existing_filters
        windowed_groups.append(next_group)
    return windowed_groups


@hubspot.command("merge-plan")
@click.argument("object_type")
@click.option(
    "--csv",
    "csv_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="CSV input with candidate duplicates.",
)
@click.option(
    "--id-column",
    default="id",
    show_default=True,
    help="ID column name in CSV mode.",
)
@click.option(
    "--key-column",
    required=True,
    help="Column/property to group duplicates by (for example: email, domain, external_id).",
)
@click.option(
    "--created-column",
    default="createdate",
    show_default=True,
    help="Column/property used when --keep oldest-created.",
)
@click.option(
    "--activity-column",
    default="hs_lastmodifieddate",
    show_default=True,
    help="Column/property used when --keep newest-activity.",
)
@click.option(
    "--keep",
    type=click.Choice(["first", "oldest-created", "newest-activity"], case_sensitive=False),
    default="oldest-created",
    show_default=True,
    help="Primary record selection strategy per duplicate group.",
)
@click.option(
    "--where",
    "where_clauses",
    multiple=True,
    help="HubSpot filter in property=value form (live mode only; repeatable).",
)
@click.option(
    "--filter-json",
    "filter_json_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Path to JSON file with HubSpot filterGroups (live mode only).",
)
@click.option(
    "--limit",
    type=int,
    default=1000,
    show_default=True,
    help="Maximum records to scan in live mode (0 = up to API ceiling).",
)
@click.option(
    "--max-merges",
    type=int,
    default=0,
    show_default=True,
    help="Optional cap on planned merges (0 = no cap).",
)
@click.option(
    "--auto-window-on-cap/--no-auto-window-on-cap",
    default=True,
    show_default=True,
    help=(
        "When live mode hits the HubSpot Search cap, recursively split by created date "
        "to retrieve additional records."
    ),
)
@click.option(
    "--auto-window-max-depth",
    type=int,
    default=8,
    show_default=True,
    help="Maximum recursive split depth for --auto-window-on-cap.",
)
@click.option(
    "--auto-window-min-days",
    type=int,
    default=7,
    show_default=True,
    help="Stop splitting windows at this size (in days).",
)
@click.option(
    "--out",
    "out_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output merge plan JSON file.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def hubspot_merge_plan(
    object_type: str,
    csv_path: Path | None,
    id_column: str,
    key_column: str,
    created_column: str,
    activity_column: str,
    keep: str,
    where_clauses: tuple[str, ...],
    filter_json_path: Path | None,
    limit: int,
    max_merges: int,
    auto_window_on_cap: bool,
    auto_window_max_depth: int,
    auto_window_min_days: int,
    out_path: Path,
    json_summary: bool,
) -> None:
    """Build a HubSpot merge plan from CSV or live HubSpot search results."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    if max_merges < 0:
        raise click.ClickException("--max-merges must be >= 0.")
    if auto_window_max_depth < 0:
        raise click.ClickException("--auto-window-max-depth must be >= 0.")
    if auto_window_min_days <= 0:
        raise click.ClickException("--auto-window-min-days must be > 0.")
    if filter_json_path and where_clauses:
        raise click.ClickException("Use either --where or --filter-json, not both.")

    keep_strategy = keep.lower()
    rows: List[Dict[str, Any]] = []
    source_meta: Dict[str, Any]
    merge_plan_warnings: List[AgenticWarning] = []
    live_search_cap_reached = False
    live_search_cap = 0
    auto_window_attempted = False
    auto_window_used = False
    auto_window_window_property = ""
    auto_window_windows_scanned = 0
    auto_window_seeded_start_ms = 0
    auto_window_backfill_window_added = False
    auto_window_saturated_windows: List[Dict[str, Any]] = []
    auto_window_failures: List[Dict[str, Any]] = []

    if csv_path:
        if where_clauses or filter_json_path:
            raise click.ClickException("--where/--filter-json are only valid in live mode (without --csv).")
        rows = _load_hubspot_merge_rows_from_csv(
            csv_path,
            id_column=id_column,
            key_column=key_column,
            created_column=created_column,
            activity_column=activity_column,
        )
        source_meta = {
            "mode": "csv",
            "csv": str(csv_path),
            "id_column": id_column,
        }
    else:
        hs_mod = _get_hubspot_helpers()
        client = hs_mod.HubSpotClient(profile=_get_hubspot_profile())
        filter_groups: List[Dict[str, Any]] = []
        if filter_json_path:
            try:
                raw = json.loads(filter_json_path.read_text(encoding="utf-8"))
            except Exception as exc:
                raise click.ClickException(f"Invalid JSON in {filter_json_path}: {exc}") from exc
            if isinstance(raw, dict) and "filterGroups" in raw:
                raw = raw.get("filterGroups")
            if not isinstance(raw, list):
                raise click.ClickException("--filter-json must contain a list of filterGroups.")
            filter_groups = raw
        else:
            parsed_filters = _parse_hubspot_where_clauses(where_clauses)
            if parsed_filters:
                filter_groups = [{"filters": parsed_filters}]

        live_properties = [key_column, created_column, activity_column]
        # Search API returns id as top-level; avoid requesting it as property.
        live_properties = [item for item in live_properties if item not in {"id", "hs_object_id"}]
        # De-duplicate while preserving order.
        live_properties = list(dict.fromkeys([item for item in live_properties if item]))

        try:
            results = client.search_all(
                object_type.lower(),
                filter_groups=filter_groups,
                properties=live_properties,
                limit=limit,
            )
        except Exception as exc:
            raise click.ClickException(f"HubSpot merge-plan live query failed: {exc}") from exc

        live_search_cap = int(getattr(hs_mod, "SEARCH_TOTAL_CAP", 10000))
        if len(results) >= live_search_cap and (limit == 0 or limit >= live_search_cap):
            live_search_cap_reached = True
            click.echo(
                f"\nWARNING: HubSpot returned {live_search_cap:,} records in live merge-plan mode.\n"
                "  HubSpot Search API has a hard ceiling; duplicate groups beyond this window may be missing.\n"
                "  Narrow the scope with --where/--filter-json date windows and run multiple merge-plan passes.\n"
                "  Auto-windowing can split this query for additional coverage."
            )
            merge_plan_warnings.append(
                AgenticWarning(
                    code="SEARCH_CAP_TRUNCATION",
                    message=(
                        "HubSpot Search API returned its maximum record count in live merge-plan mode. "
                        "The merge plan may be incomplete; split queries into smaller filtered windows."
                    ),
                    severity="warning",
                )
            )

            if auto_window_on_cap and (limit == 0 or limit > live_search_cap):
                auto_window_attempted = True
                auto_window_window_property = created_column.strip() or "createdate"
                if auto_window_window_property in {"id", "hs_object_id"}:
                    auto_window_window_property = "createdate"

                min_window_ms = auto_window_min_days * 24 * 60 * 60 * 1000
                start_ms = 0
                end_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + 1
                seeded_start_ms = start_ms
                backfill_window_added = False
                target_limit = limit if limit > 0 else 0
                seen_record_keys: set[str] = set()
                windowed_results: List[Dict[str, Any]] = []
                windows_to_scan: List[tuple[int, int, int, List[Dict[str, Any]] | None]] = []

                observed_timestamps: List[int] = []
                for item in results:
                    props = item.get("properties") or {}
                    raw_value = props.get(auto_window_window_property)
                    parsed_value = _coerce_hubspot_merge_timestamp(raw_value)
                    if parsed_value is not None:
                        observed_timestamps.append(int(parsed_value))
                if observed_timestamps:
                    observed_min = min(observed_timestamps)
                    if observed_min > start_ms:
                        seeded_start_ms = observed_min
                        backfill_window_added = True
                        windows_to_scan.append((start_ms, seeded_start_ms, 0, None))
                        start_ms = seeded_start_ms

                windows_to_scan.append((start_ms, end_ms, 0, results))

                click.echo(
                    f"  Auto-window enabled. Splitting by {auto_window_window_property} "
                    f"(max depth={auto_window_max_depth}, min window={auto_window_min_days}d)..."
                )

                while windows_to_scan:
                    window_start_ms, window_end_ms, depth, preloaded_results = windows_to_scan.pop()
                    if window_end_ms <= window_start_ms:
                        continue

                    if preloaded_results is None:
                        window_filter_groups = _window_hubspot_filter_groups(
                            filter_groups,
                            property_name=auto_window_window_property,
                            start_ms=window_start_ms,
                            end_ms=window_end_ms,
                        )
                        try:
                            window_results = client.search_all(
                                object_type.lower(),
                                filter_groups=window_filter_groups,
                                properties=live_properties,
                                limit=0,
                            )
                        except Exception as exc:
                            auto_window_failures.append(
                                {
                                    "start_ms": window_start_ms,
                                    "end_ms": window_end_ms,
                                    "depth": depth,
                                    "error": str(exc),
                                }
                            )
                            continue
                    else:
                        window_results = preloaded_results

                    auto_window_windows_scanned += 1
                    saturated_recorded = False
                    can_split = (
                        len(window_results) >= live_search_cap
                        and depth < auto_window_max_depth
                        and (window_end_ms - window_start_ms) > min_window_ms
                    )
                    if can_split:
                        midpoint_ms = window_start_ms + ((window_end_ms - window_start_ms) // 2)
                        if midpoint_ms <= window_start_ms or midpoint_ms >= window_end_ms:
                            auto_window_saturated_windows.append(
                                {
                                    "start_ms": window_start_ms,
                                    "end_ms": window_end_ms,
                                    "depth": depth,
                                    "returned": len(window_results),
                                }
                            )
                            saturated_recorded = True
                        else:
                            # Process lower ranges first for deterministic record ordering.
                            windows_to_scan.append((midpoint_ms, window_end_ms, depth + 1, None))
                            windows_to_scan.append((window_start_ms, midpoint_ms, depth + 1, None))
                            continue

                    if len(window_results) >= live_search_cap and not saturated_recorded:
                        auto_window_saturated_windows.append(
                            {
                                "start_ms": window_start_ms,
                                "end_ms": window_end_ms,
                                "depth": depth,
                                "returned": len(window_results),
                            }
                        )

                    for item in window_results:
                        record_id = str(item.get("id") or "").strip()
                        dedupe_key = record_id or json.dumps(item, sort_keys=True)
                        if dedupe_key in seen_record_keys:
                            continue
                        seen_record_keys.add(dedupe_key)
                        windowed_results.append(item)
                        if target_limit > 0 and len(windowed_results) >= target_limit:
                            break

                    if target_limit > 0 and len(windowed_results) >= target_limit:
                        break

                if windowed_results:
                    auto_window_used = True
                    results = windowed_results
                    auto_window_seeded_start_ms = seeded_start_ms
                    auto_window_backfill_window_added = backfill_window_added
                    click.echo(
                        f"  Auto-window complete: {len(results):,} unique records "
                        f"from {auto_window_windows_scanned} window queries."
                    )
                else:
                    auto_window_failures.append(
                        {
                            "start_ms": start_ms,
                            "end_ms": end_ms,
                            "depth": 0,
                            "error": "auto_window_returned_no_results",
                        }
                    )

                if auto_window_failures:
                    click.echo(
                        f"  WARNING: Auto-window encountered {len(auto_window_failures)} failed "
                        "window query(ies); merge plan may be incomplete."
                    )
                    merge_plan_warnings.append(
                        AgenticWarning(
                            code="SEARCH_CAP_AUTO_WINDOW_FAILED",
                            message=(
                                f"Auto-window encountered {len(auto_window_failures)} failed window queries. "
                                "Merge plan may still be incomplete."
                            ),
                            severity="warning",
                        )
                    )

                if auto_window_saturated_windows:
                    click.echo(
                        "  WARNING: Some auto-window ranges still hit the Search cap "
                        "at minimum split depth; merge plan may be incomplete."
                    )
                    merge_plan_warnings.append(
                        AgenticWarning(
                            code="SEARCH_CAP_AUTO_WINDOW_INCOMPLETE",
                            message=(
                                "One or more auto-window ranges still hit the HubSpot Search cap "
                                "after recursive splitting."
                            ),
                            severity="warning",
                        )
                    )

        for item in results:
            props = item.get("properties") or {}
            rows.append(
                {
                    "id": item.get("id"),
                    key_column: item.get("id") if key_column in {"id", "hs_object_id"} else props.get(key_column),
                    created_column: item.get("id")
                    if created_column in {"id", "hs_object_id"}
                    else props.get(created_column),
                    activity_column: item.get("id")
                    if activity_column in {"id", "hs_object_id"}
                    else props.get(activity_column),
                }
            )
        source_meta = {
            "mode": "hubspot_search",
            "limit": limit,
            "where": list(where_clauses),
            "filter_json": str(filter_json_path) if filter_json_path else "",
            "retrieved": len(rows),
            "search_cap_reached": live_search_cap_reached,
            "search_cap": live_search_cap,
            "auto_window_on_cap": auto_window_on_cap,
            "auto_window_attempted": auto_window_attempted,
            "auto_window_used": auto_window_used,
            "auto_window_window_property": auto_window_window_property,
            "auto_window_windows_scanned": auto_window_windows_scanned,
            "auto_window_seeded_start_ms": auto_window_seeded_start_ms,
            "auto_window_backfill_window_added": auto_window_backfill_window_added,
            "auto_window_failures": auto_window_failures,
            "auto_window_saturated_windows": auto_window_saturated_windows,
        }

    payload = _build_hubspot_merge_plan_payload(
        object_type=object_type.lower(),
        rows=rows,
        key_column=key_column,
        keep_strategy=keep_strategy,
        created_column=created_column,
        activity_column=activity_column,
        source_meta=source_meta,
        max_merges=max_merges,
    )

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

    merges_count = int((payload.get("stats") or {}).get("merges_planned") or 0)
    groups_count = int((payload.get("stats") or {}).get("duplicate_groups") or 0)
    click.echo(
        f"HubSpot merge plan written to {out_path}. "
        f"Duplicate groups: {groups_count}, merges planned: {merges_count}."
    )

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin hubspot merge-plan", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "object_type": object_type.lower(),
                "output": str(out_path),
                "duplicate_groups": groups_count,
                "merges_planned": merges_count,
                "source": source_meta,
                "strategy": payload.get("strategy") or {},
                "search_cap_reached": live_search_cap_reached,
                "auto_window_attempted": auto_window_attempted,
                "auto_window_used": auto_window_used,
            },
            warnings=merge_plan_warnings,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


def _hubspot_merge_apply_plan_digest(plan_text: str) -> str:
    return hashlib.sha256(plan_text.encode("utf-8")).hexdigest()


def _write_hubspot_merge_apply_state(state_path: Path, state: Dict[str, Any]) -> None:
    state_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = state_path.with_suffix(state_path.suffix + ".tmp")
    tmp_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
    tmp_path.replace(state_path)


def _load_hubspot_merge_apply_state(state_path: Path) -> Dict[str, Any]:
    try:
        raw = json.loads(state_path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise click.ClickException(f"Invalid merge apply state JSON: {exc}") from exc
    if not isinstance(raw, dict):
        raise click.ClickException("Merge apply state file must contain a JSON object.")
    return raw


def _is_retryable_hubspot_merge_error(exc: Exception) -> bool:
    status_code = getattr(exc, "status_code", None)
    if isinstance(status_code, int) and status_code in {429, 500, 502, 503, 504}:
        return True
    class_name = exc.__class__.__name__.lower()
    if "ratelimit" in class_name or "transient" in class_name:
        return True
    text = str(exc).lower()
    if "rate limit" in text or "rate limited" in text or "(429)" in text:
        return True
    if re.search(r"\bretryable error (500|502|503|504)\b", text):
        return True
    if "gateway timeout" in text or "service unavailable" in text:
        return True
    return False


def _hubspot_merge_retry_delay(attempt: int, base_delay: float, max_delay: float) -> float:
    delay = base_delay * (2 ** max(attempt - 1, 0))
    return min(delay, max_delay)


@hubspot.command("merge-apply-plan")
@click.option(
    "--plan",
    "plan_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to merge plan JSON generated by merge-plan.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute planned merges (default is dry-run preview).",
)
@click.option(
    "--continue-on-error/--fail-fast",
    default=True,
    show_default=True,
    help="Continue applying remaining merges when one merge fails.",
)
@click.option(
    "--state-file",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Optional JSON state file for resumable execution.",
)
@click.option(
    "--resume",
    is_flag=True,
    help="Resume from --state-file progress (requires --apply).",
)
@click.option(
    "--max-retries",
    type=int,
    default=3,
    show_default=True,
    help="Retry attempts for retryable merge failures (429/5xx).",
)
@click.option(
    "--retry-base-delay",
    type=float,
    default=1.0,
    show_default=True,
    help="Initial backoff delay in seconds.",
)
@click.option(
    "--retry-max-delay",
    type=float,
    default=20.0,
    show_default=True,
    help="Maximum backoff delay in seconds.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def hubspot_merge_apply_plan(
    plan_path: Path,
    apply: bool,
    continue_on_error: bool,
    state_file: Path | None,
    resume: bool,
    max_retries: int,
    retry_base_delay: float,
    retry_max_delay: float,
    json_summary: bool,
) -> None:
    """Apply a HubSpot merge plan (dry-run by default)."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot merge-apply-plan", remote_mutation=True)
    if max_retries < 0:
        raise click.ClickException("--max-retries must be >= 0.")
    if retry_base_delay <= 0:
        raise click.ClickException("--retry-base-delay must be > 0.")
    if retry_max_delay <= 0:
        raise click.ClickException("--retry-max-delay must be > 0.")
    if retry_base_delay > retry_max_delay:
        raise click.ClickException("--retry-base-delay must be <= --retry-max-delay.")
    if resume and state_file is None:
        raise click.ClickException("--resume requires --state-file.")
    if resume and not apply:
        raise click.ClickException("--resume can only be used with --apply.")

    try:
        plan_text = plan_path.read_text(encoding="utf-8")
        payload = json.loads(plan_text)
    except Exception as exc:
        raise click.ClickException(f"Invalid merge plan JSON: {exc}") from exc

    plan_digest = _hubspot_merge_apply_plan_digest(plan_text)
    object_type = str(payload.get("object_type") or "").strip().lower()
    merges = payload.get("merges") or []
    if not object_type:
        raise click.ClickException("Merge plan missing 'object_type'.")
    if not isinstance(merges, list):
        raise click.ClickException("Merge plan 'merges' must be a list.")

    if not apply:
        click.echo(
            f"[DRY-RUN] Would apply {len(merges)} merge(s) for object {object_type} from {plan_path}."
        )
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "object_type": object_type,
                    "plan": str(plan_path),
                    "merges_planned": len(merges),
                    "state_file": str(state_file) if state_file else "",
                    "resume": resume,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    state_payload: Dict[str, Any] | None = None
    run_started_at = datetime.now(timezone.utc).isoformat()
    start_index = 0
    succeeded: List[Dict[str, Any]] = []
    failed: List[Dict[str, Any]] = []

    def _merge_state_index(value: Dict[str, Any]) -> int:
        try:
            return int(value.get("index"))
        except Exception:
            return -1

    if state_file:
        if resume:
            state_payload = _load_hubspot_merge_apply_state(state_file)
            state_plan_digest = str(state_payload.get("plan_digest") or "").strip()
            if state_plan_digest and state_plan_digest != plan_digest:
                raise click.ClickException(
                    f"State file plan digest does not match {plan_path}. "
                    "Use a matching plan or remove --resume."
                )
            state_object_type = str(state_payload.get("object_type") or "").strip().lower()
            if state_object_type and state_object_type != object_type:
                raise click.ClickException(
                    f"State file object_type '{state_object_type}' does not match plan object_type '{object_type}'."
                )
            state_total_merges = int(state_payload.get("total_merges") or 0)
            if state_total_merges and state_total_merges != len(merges):
                raise click.ClickException(
                    f"State file total_merges {state_total_merges} does not match plan merges {len(merges)}."
                )
            start_index = max(0, min(int(state_payload.get("next_index") or 0), len(merges)))
            succeeded = list(state_payload.get("succeeded") or [])
            failed = list(state_payload.get("failed") or [])
            click.echo(f"Resuming HubSpot merge plan from index {start_index} using {state_file}.")
        else:
            state_payload = {
                "version": 1,
                "command": "g-gremlin hubspot merge-apply-plan",
                "plan": str(plan_path),
                "plan_digest": plan_digest,
                "object_type": object_type,
                "total_merges": len(merges),
                "continue_on_error": continue_on_error,
                "max_retries": max_retries,
                "retry_base_delay_seconds": retry_base_delay,
                "retry_max_delay_seconds": retry_max_delay,
                "started_at": run_started_at,
                "updated_at": run_started_at,
                "next_index": 0,
                "succeeded": [],
                "failed": [],
            }
            _write_hubspot_merge_apply_state(state_file, state_payload)

    current_index = start_index
    interrupted = False
    for idx in range(start_index, len(merges)):
        current_index = idx
        entry = merges[idx]
        if not isinstance(entry, dict):
            failed.append({"index": idx, "error": "invalid_entry", "retryable": False, "attempts": 0})
            if state_payload is not None:
                state_payload["failed"] = failed
                state_payload["updated_at"] = datetime.now(timezone.utc).isoformat()
                state_payload["next_index"] = idx if not continue_on_error else idx + 1
                _write_hubspot_merge_apply_state(state_file, state_payload)
            if not continue_on_error:
                break
            continue

        primary_id = str(entry.get("primary_id") or "").strip()
        merge_id = str(entry.get("merge_id") or "").strip()
        if not primary_id or not merge_id:
            failed.append(
                {
                    "index": idx,
                    "primary_id": primary_id,
                    "merge_id": merge_id,
                    "error": "missing_primary_or_merge_id",
                    "retryable": False,
                    "attempts": 0,
                }
            )
            if state_payload is not None:
                state_payload["failed"] = failed
                state_payload["updated_at"] = datetime.now(timezone.utc).isoformat()
                state_payload["next_index"] = idx if not continue_on_error else idx + 1
                _write_hubspot_merge_apply_state(state_file, state_payload)
            if not continue_on_error:
                break
            continue

        attempt_num = 0
        merge_succeeded = False
        while True:
            try:
                failed = [item for item in failed if _merge_state_index(item) != idx]
                succeeded = [item for item in succeeded if _merge_state_index(item) != idx]
                response = client.merge_objects(object_type, primary_id, merge_id)
                succeeded.append(
                    {
                        "index": idx,
                        "primary_id": primary_id,
                        "merge_id": merge_id,
                        "response": response,
                        "attempts": attempt_num + 1,
                    }
                )
                merge_succeeded = True
                break
            except KeyboardInterrupt:
                interrupted = True
                break
            except Exception as exc:
                retryable = _is_retryable_hubspot_merge_error(exc)
                if retryable and attempt_num < max_retries:
                    attempt_num += 1
                    delay = _hubspot_merge_retry_delay(attempt_num, retry_base_delay, retry_max_delay)
                    click.echo(
                        f"Retryable merge failure for index {idx} ({primary_id} <- {merge_id}); "
                        f"retry {attempt_num}/{max_retries} in {delay:.1f}s: {exc}"
                    )
                    time.sleep(delay)
                    continue
                failed.append(
                    {
                        "index": idx,
                        "primary_id": primary_id,
                        "merge_id": merge_id,
                        "error": str(exc),
                        "retryable": retryable,
                        "attempts": attempt_num + 1,
                    }
                )
                break

        if state_payload is not None:
            state_payload["succeeded"] = succeeded
            state_payload["failed"] = failed
            state_payload["updated_at"] = datetime.now(timezone.utc).isoformat()
            if interrupted:
                state_payload["next_index"] = idx
            elif merge_succeeded or continue_on_error:
                state_payload["next_index"] = idx + 1
            else:
                state_payload["next_index"] = idx
            _write_hubspot_merge_apply_state(state_file, state_payload)

        if interrupted:
            break
        if not merge_succeeded and not continue_on_error:
            break

    if interrupted:
        click.echo("Merge apply interrupted. Progress was saved to state file." if state_file else "Merge apply interrupted.")
        if state_payload is not None:
            state_payload["interrupted_at"] = datetime.now(timezone.utc).isoformat()
            state_payload["next_index"] = current_index
            _write_hubspot_merge_apply_state(state_file, state_payload)
        raise click.ClickException("Interrupted. Re-run with --resume to continue.")

    if state_payload is not None:
        state_payload["finished_at"] = datetime.now(timezone.utc).isoformat()
        if state_payload.get("next_index") is None:
            state_payload["next_index"] = len(merges)
        if int(state_payload.get("next_index") or 0) >= len(merges):
            state_payload["completed"] = True
            state_payload["next_index"] = len(merges)
        else:
            state_payload["completed"] = False
        _write_hubspot_merge_apply_state(state_file, state_payload)

    click.echo(
        f"HubSpot merge-apply-plan finished. Succeeded: {len(succeeded)}, Failed: {len(failed)}."
    )
    if state_file:
        next_index = int((state_payload or {}).get("next_index") or 0)
        click.echo(f"State saved to {state_file} (next_index={next_index}).")

    status = "success" if not failed else ("partial_success" if succeeded else "error")
    if json_summary:
        agentic = safety_ctx.build_result(
            status=status,
            dry_run=False,
            result={
                "object_type": object_type,
                "plan": str(plan_path),
                "succeeded": succeeded,
                "failed": failed,
                "continue_on_error": continue_on_error,
                "state_file": str(state_file) if state_file else "",
                "resume": resume,
                "start_index": start_index,
                "next_index": int((state_payload or {}).get("next_index") or 0),
                "max_retries": max_retries,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@hubspot.command("merge")
@click.argument("object_type")
@click.option(
    "--primary-id",
    required=True,
    help="Record ID to keep as the surviving record.",
)
@click.option(
    "--merge-id",
    required=True,
    help="Record ID to merge into --primary-id (will be archived by HubSpot).",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute merge (default is dry-run preview).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def hubspot_merge(
    object_type: str,
    primary_id: str,
    merge_id: str,
    apply: bool,
    json_summary: bool,
) -> None:
    """Merge two HubSpot records of the same object type."""
    safety_ctx = SafetyContext(command="g-gremlin hubspot merge", remote_mutation=True)

    if not apply:
        click.echo(
            f"[DRY-RUN] Would merge {object_type} record {merge_id} into {primary_id}."
        )
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "object_type": object_type,
                    "primary_id": primary_id,
                    "merge_id": merge_id,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    client = _get_hubspot_helpers().HubSpotClient(profile=_get_hubspot_profile())
    try:
        result = client.merge_objects(object_type.lower(), primary_id, merge_id)
    except Exception as exc:
        click.echo(f"HubSpot merge failed: {exc}")
        sys.exit(1)

    click.echo(f"Merged {object_type} record {merge_id} into {primary_id}.")
    if json_summary:
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "object_type": object_type,
                "primary_id": primary_id,
                "merge_id": merge_id,
                "response": result,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@cli.group()
def apify() -> None:
    """
    Apify web scraping & enrichment helpers.
    """


@cli.group()
def nimbleway() -> None:
    """
    Nimbleway web search & extraction helpers.
    """


@cli.group()
def script() -> None:
    """
    Apps Script helpers (pull/push/run bound scripts).
    """


@cli.group()
def google() -> None:
    """
    Google OAuth helpers (multi-account profiles, CI token generation).
    """


@google.command("generate-ci-token")
@click.option(
    "--credentials-path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="OAuth client credentials JSON path (optional).",
)
@click.option(
    "--output",
    "output_format",
    type=click.Choice(["env", "json"]),
    default="env",
    show_default=True,
    help="Output format for CI secrets.",
)
def google_generate_ci_token(
    credentials_path: Optional[Path],
    output_format: str,
) -> None:
    """
    Generate a refresh token for CI and print env vars.

    Runs the local OAuth flow once, extracts client_id/client_secret/refresh_token,
    and prints values ready for GitHub Secrets.
    """
    google_mod = _get_google_client()

    if credentials_path is None:
        try:
            credentials_path = google_mod._get_credentials_path()
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_USAGE)

    try:
        payload = json.loads(credentials_path.read_text(encoding="utf-8"))
    except Exception as exc:
        click.echo(f"Error: Failed to read credentials file: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    client_info = payload.get("installed") or payload.get("web")
    if not isinstance(client_info, dict):
        click.echo("Error: OAuth credentials file missing 'installed' or 'web' section.", err=True)
        sys.exit(EXIT_USAGE)

    client_id = client_info.get("client_id")
    client_secret = client_info.get("client_secret")
    if not client_id or not client_secret:
        click.echo("Error: OAuth credentials missing client_id/client_secret.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        from google_auth_oauthlib.flow import InstalledAppFlow
        flow = InstalledAppFlow.from_client_secrets_file(
            str(credentials_path),
            google_mod.SCOPES,
        )
        creds = flow.run_local_server(
            port=0,
            access_type="offline",
            prompt="consent",
        )
    except Exception as exc:
        click.echo(f"OAuth flow failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    refresh_token = getattr(creds, "refresh_token", None)
    if not refresh_token:
        click.echo("Error: No refresh token returned.", err=True)
        click.echo("Tip: Revoke the app in your Google Account and re-run to force consent.", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    output = {
        "G_GREMLIN_GOOGLE_CLIENT_ID": client_id,
        "G_GREMLIN_GOOGLE_CLIENT_SECRET": client_secret,
        "G_GREMLIN_GOOGLE_REFRESH_TOKEN": refresh_token,
    }

    if output_format == "json":
        click.echo(json.dumps(output, indent=2))
        return

    click.echo(f"G_GREMLIN_GOOGLE_CLIENT_ID={client_id}")
    click.echo(f"G_GREMLIN_GOOGLE_CLIENT_SECRET={client_secret}")
    click.echo(f"G_GREMLIN_GOOGLE_REFRESH_TOKEN={refresh_token}")


# ---------------------------------------------------------------------------
# google auth — multi-account profile management
# ---------------------------------------------------------------------------
@google.group("auth")
def google_auth() -> None:
    """Google account profile management (multiple accounts)."""


@google_auth.command("connect")
@click.option("--profile", "profile_name", default="default", show_default=True, help="Profile name for this account.")
@click.option(
    "--credentials-path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="OAuth client credentials JSON path (optional).",
)
def google_auth_connect(profile_name: str, credentials_path: Optional[Path]) -> None:
    """Authenticate a Google account and save it as a named profile.

    Opens a browser for OAuth consent. The token is stored per-profile so you
    can switch between accounts without re-authenticating.

    \b
    Examples:
      g-gremlin google auth connect                    # auth as "default"
      g-gremlin google auth connect --profile personal # auth as "personal"
      g-gremlin google auth connect --profile work     # auth as "work"
    """
    google_mod = _get_google_client()
    google_mod.maybe_adopt_legacy_token()

    if credentials_path is None:
        try:
            credentials_path = google_mod._get_credentials_path()
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_USAGE)

    token_path = google_mod._token_path_for_profile(profile_name)
    try:
        from google_auth_oauthlib.flow import InstalledAppFlow
        flow = InstalledAppFlow.from_client_secrets_file(str(credentials_path), google_mod.SCOPES)
        creds = flow.run_local_server(port=0, access_type="offline", prompt="consent")
        token_path.parent.mkdir(parents=True, exist_ok=True)
        token_path.write_text(creds.to_json())
    except Exception as exc:
        click.echo(f"OAuth flow failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    email = google_mod._fetch_google_email(creds) or "(unknown)"
    store = google_mod._load_google_profile_store()
    store.set_profile(profile_name, {"email": email})
    store.save()

    click.echo(f"Authenticated as {email} (profile: {profile_name})")
    if store.current_profile_name() == profile_name:
        click.echo(f"Active profile: {profile_name}")
    else:
        click.echo(f"Tip: run 'g-gremlin google auth use {profile_name}' to make it active.")


@google_auth.command("list")
def google_auth_list() -> None:
    """List saved Google account profiles.

    \b
    Examples:
      g-gremlin google auth list
    """
    google_mod = _get_google_client()
    google_mod.maybe_adopt_legacy_token()
    store = google_mod._load_google_profile_store()
    profiles = store.list_profiles()
    if not profiles:
        click.echo("No Google profiles found. Run: g-gremlin google auth connect --profile <name>")
        return
    current = store.current_profile_name()
    for name in profiles:
        marker = "*" if name == current else " "
        data = store.profiles.get(name, {})
        email = data.get("email", "(unknown)")
        token_file = google_mod._token_path_for_profile(name)
        status = "ok" if token_file.exists() else "token missing"
        click.echo(f"  {marker} {name}  {email}  ({status})")


@google_auth.command("use")
@click.argument("profile_name")
def google_auth_use(profile_name: str) -> None:
    """Set the active Google profile.

    \b
    Examples:
      g-gremlin google auth use personal
    """
    google_mod = _get_google_client()
    google_mod.maybe_adopt_legacy_token()
    store = google_mod._load_google_profile_store()
    from g_gremlin.profile_manager import ProfileNotFoundError
    try:
        store.set_current(profile_name)
    except ProfileNotFoundError:
        click.echo(f"Profile not found: {profile_name}", err=True)
        click.echo(f"Available profiles: {', '.join(store.list_profiles()) or '(none)'}", err=True)
        sys.exit(EXIT_USAGE)
    store.save()
    email = store.profiles.get(profile_name, {}).get("email", "")
    click.echo(f"Active Google profile: {profile_name}" + (f" ({email})" if email else ""))


@google_auth.command("remove")
@click.argument("profile_name")
@click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
def google_auth_remove(profile_name: str, yes: bool) -> None:
    """Remove a Google profile and its token.

    \b
    Examples:
      g-gremlin google auth remove work
    """
    google_mod = _get_google_client()
    google_mod.maybe_adopt_legacy_token()
    store = google_mod._load_google_profile_store()
    if profile_name not in store.profiles:
        click.echo(f"Profile not found: {profile_name}", err=True)
        sys.exit(EXIT_USAGE)
    if not yes:
        click.confirm(f"Remove Google profile '{profile_name}' and delete its token?", abort=True)
    del store.profiles[profile_name]
    if store.current == profile_name:
        if store.default_profile in store.profiles:
            store.current = store.default_profile
        else:
            remaining = store.list_profiles()
            store.current = remaining[0] if remaining else ""
    store.save()
    token_file = google_mod._token_path_for_profile(profile_name)
    if token_file.exists():
        token_file.unlink()
        click.echo(f"Deleted token: {token_file}")
    click.echo(f"Removed Google profile '{profile_name}'.")
    if store.current:
        click.echo(f"Active profile: {store.current}")
    else:
        click.echo("No active Google profile set. Run 'g-gremlin google auth use <name>'.", err=True)


@google_auth.command("status")
def google_auth_status() -> None:
    """Show the current Google profile and token details.

    \b
    Examples:
      g-gremlin google auth status
    """
    google_mod = _get_google_client()
    google_mod.maybe_adopt_legacy_token()
    store = google_mod._load_google_profile_store()
    current = store.current_profile_name()
    data = store.profiles.get(current, {})
    email = data.get("email", "(unknown)")
    token_file = google_mod._token_path_for_profile(current)

    click.echo(f"Active profile: {current}")
    click.echo(f"Email:          {email}")
    click.echo(f"Token file:     {token_file}")

    if not token_file.exists():
        click.echo("Status:         token missing — run 'g-gremlin google auth connect'")
        return

    try:
        payload = json.loads(token_file.read_text(encoding="utf-8"))
        scopes = payload.get("scopes") or []
        click.echo(f"Scopes:         {len(scopes)} granted")
        from google.oauth2.credentials import Credentials
        creds = Credentials.from_authorized_user_file(str(token_file), scopes or google_mod.SCOPES)
        if creds.valid:
            click.echo("Status:         valid")
        elif creds.expired and creds.refresh_token:
            click.echo("Status:         expired (will auto-refresh)")
        else:
            click.echo("Status:         invalid — re-run 'g-gremlin google auth connect'")
    except Exception as exc:
        click.echo(f"Status:         error reading token — {exc}")


@cli.group()
def formula() -> None:
    """
    Formula helpers.
    """


@cli.group()
def names() -> None:
    """
    Named range management.
    """


@cli.group()
def snapshot() -> None:
    """
    Snapshot management - the Time Machine.

    Save and restore spreadsheet state with safety checks.
    """


@snapshot.command("create")
@click.argument("spreadsheet_id")
@click.option("--name", required=True, help="Name for the snapshot.")
@click.option("--tabs", help="Comma-separated list of tabs to snapshot (default: all).")
def snapshot_create(spreadsheet_id: str, name: str, tabs: str | None) -> None:
    """
    Create a snapshot of a spreadsheet.

    \b
    Examples:
      g-gremlin snapshot create SHEET_ID --name "before-refactor"
      g-gremlin snapshot create SHEET_ID --name "backup" --tabs "Sheet1,Sheet2"
    """
    from g_gremlin import snapshot as snapshot_module

    tabs_list = [t.strip() for t in tabs.split(",")] if tabs else None

    click.echo(f"Creating snapshot '{name}'...")
    result = snapshot_module.create_snapshot(spreadsheet_id, name, tabs=tabs_list)

    if result.success:
        click.echo(f"Snapshot created: {result.snapshot_path}")
        click.echo(result.message)
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)


@snapshot.command("list")
@click.argument("spreadsheet_id", required=False)
def snapshot_list(spreadsheet_id: str | None) -> None:
    """
    List available snapshots.

    \b
    Examples:
      g-gremlin snapshot list              # List all snapshots
      g-gremlin snapshot list SHEET_ID     # List snapshots for a specific sheet
    """
    from g_gremlin import snapshot as snapshot_module

    snapshots = snapshot_module.list_snapshots(spreadsheet_id=spreadsheet_id)

    if not snapshots:
        click.echo("No snapshots found.")
        return

    click.echo(f"Found {len(snapshots)} snapshot(s):\n")

    for snap in snapshots:
        click.echo(f"  {snap.name}")
        click.echo(f"    Created: {snap.created_at}")
        click.echo(f"    Tabs: {snap.tab_count}, Cells: {snap.total_cells}")
        click.echo(f"    File: {snap.file_path}")
        click.echo()


@snapshot.command("diff")
@click.argument("spreadsheet_id")
@click.option("--name", required=True, help="Name of the snapshot to compare against.")
def snapshot_diff(spreadsheet_id: str, name: str) -> None:
    """
    Compare current spreadsheet state against a snapshot.

    Shows exactly what values have changed since the snapshot was taken.

    \b
    Examples:
      g-gremlin snapshot diff SHEET_ID --name "before-refactor"
    """
    from g_gremlin import snapshot as snapshot_module

    # Find snapshot
    path = snapshot_module.find_snapshot_by_name(name)
    if not path:
        error = error_helpers.snapshot_not_found_error(name)
        click.echo(str(error), err=True)
        sys.exit(1)

    click.echo(f"Comparing current state against snapshot '{name}'...")

    diff = snapshot_module.diff_with_snapshot(spreadsheet_id, path)

    if diff is None:
        click.echo("Failed to compare snapshot.", err=True)
        sys.exit(1)

    if diff.tabs_added:
        _safe_echo(f"\nTabs added since snapshot: {', '.join(diff.tabs_added)}")

    if diff.tabs_removed:
        _safe_echo(f"\nTabs removed since snapshot: {', '.join(diff.tabs_removed)}")

    if diff.tabs_modified:
        click.echo(f"\nTabs with changes ({diff.total_cell_changes} total cell changes):")
        for tab_diff in diff.tabs_modified:
            formula_cnt = tab_diff.get('formula_changes', 0)
            if formula_cnt > 0:
                _safe_echo(f"\n  {tab_diff['name']}: {tab_diff['changes']} changes ({formula_cnt} formula)")
            else:
                _safe_echo(f"\n  {tab_diff['name']}: {tab_diff['changes']} changes")
            for change in tab_diff.get("sample_changes", []):
                snap_val = change.get("snapshot_value")
                curr_val = change.get("current_value")
                _safe_echo(f"    {change['cell']}: '{snap_val}' -> '{curr_val}'")
    else:
        click.echo("\nNo cell changes detected - spreadsheet matches snapshot.")


@snapshot.command("restore")
@click.argument("spreadsheet_id")
@click.option("--name", required=True, help="Name of the snapshot to restore.")
@click.option("--tabs", help="Comma-separated list of tabs to restore (default: all).")
@click.option("--backup/--no-backup", default=True, help="Create backup tabs before restore.")
@click.option("--force", is_flag=True, help="Skip concurrency check (may lose data).")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
@click.option("--dry-run", is_flag=True, help="Show what would be restored without making changes.")
def snapshot_restore(
    spreadsheet_id: str,
    name: str,
    tabs: str | None,
    backup: bool,
    force: bool,
    yes: bool,
    dry_run: bool,
) -> None:
    """
    Restore a spreadsheet from a snapshot.

    Safety features:
    - ETag verification (warns if sheet modified since snapshot)
    - Diff-before-restore preview
    - Auto-backup before restore
    - Confirmation prompt

    \b
    Examples:
      g-gremlin snapshot restore SHEET_ID --name "before-refactor"
      g-gremlin snapshot restore SHEET_ID --name "backup" --dry-run
      g-gremlin snapshot restore SHEET_ID --name "backup" --no-backup --force
    """
    from g_gremlin import snapshot as snapshot_module

    # Find snapshot
    path = snapshot_module.find_snapshot_by_name(name)
    if not path:
        error = error_helpers.snapshot_not_found_error(name)
        click.echo(str(error), err=True)
        sys.exit(1)

    tabs_list = [t.strip() for t in tabs.split(",")] if tabs else None

    # Show diff first
    click.echo(f"Loading snapshot '{name}'...")
    diff = snapshot_module.diff_with_snapshot(spreadsheet_id, path)

    if diff:
        if diff.total_cell_changes > 0:
            click.echo(f"\nThis restore will change {diff.total_cell_changes} cells.")
            for tab_diff in diff.tabs_modified[:5]:
                _safe_echo(f"  {tab_diff['name']}: {tab_diff['changes']} changes")
            if len(diff.tabs_modified) > 5:
                click.echo(f"  ... and {len(diff.tabs_modified) - 5} more tabs")
        else:
            click.echo("\nNo changes detected - spreadsheet already matches snapshot.")
            if not force:
                return

    if dry_run:
        click.echo("\nDry-run complete. No changes made.")
        return

    # Confirmation
    if not yes:
        if not click.confirm("\nProceed with restore?"):
            click.echo("Aborted.")
            return

    # Perform restore
    result = snapshot_module.restore_snapshot(
        snapshot_path=path,
        spreadsheet_id=spreadsheet_id,
        tabs=tabs_list,
        create_backup=backup,
        force=force,
    )

    if result.success:
        click.echo(f"\n{result.message}")
        if result.backup_created:
            click.echo(f"Backup tabs created with suffix: {result.backup_created}")
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)


@cli.group()
def sheets() -> None:
    """
    Google Sheets tab operations (inventory, delete, rename).
    """


@sheets.group("tabs")
def sheets_tabs() -> None:
    """
    Tab inventory and mutation helpers.
    """


def _tab_a1_ref(tab_name: str) -> str:
    escaped = tab_name.replace("'", "''")
    return f"'{escaped}'"


def _parse_multi_selectors(raw_values: tuple[str, ...]) -> list[str]:
    out: list[str] = []
    seen: set[str] = set()
    for raw in raw_values:
        for value in _split_comma_list(raw):
            if value not in seen:
                seen.add(value)
                out.append(value)
    return out


def _count_non_empty_rows(values: list[list[Any]]) -> int:
    count = 0
    for row in values:
        if any((str(cell).strip() if cell is not None else "") != "" for cell in row):
            count += 1
    return count


def _fetch_tab_inventory(service, spreadsheet_id: str) -> list[dict[str, Any]]:
    metadata = (
        service.spreadsheets()
        .get(
            spreadsheetId=spreadsheet_id,
            fields=(
                "sheets.properties.sheetId,"
                "sheets.properties.title,"
                "sheets.properties.index,"
                "sheets.properties.gridProperties.rowCount,"
                "sheets.properties.gridProperties.columnCount"
            ),
        )
        .execute()
    )

    tabs: list[dict[str, Any]] = []
    for sheet in metadata.get("sheets", []) or []:
        props = sheet.get("properties", {}) or {}
        grid = props.get("gridProperties", {}) or {}
        title = str(props.get("title") or "")
        tabs.append(
            {
                "title": title,
                "sheet_id": int(props.get("sheetId", -1)),
                "row_count": int(grid.get("rowCount", 0) or 0),
                "column_count": int(grid.get("columnCount", 0) or 0),
                "non_empty_rows": 0,
                "position": int(props.get("index", 0) or 0),
            }
        )

    ranges = [_tab_a1_ref(tab["title"]) for tab in tabs if tab["title"]]
    if ranges:
        values_resp = (
            service.spreadsheets()
            .values()
            .batchGet(spreadsheetId=spreadsheet_id, ranges=ranges, majorDimension="ROWS")
            .execute()
        )
        value_ranges = values_resp.get("valueRanges", []) or []
        for idx, tab in enumerate(tabs):
            rows = value_ranges[idx].get("values", []) if idx < len(value_ranges) else []
            tab["non_empty_rows"] = _count_non_empty_rows(rows or [])

    tabs.sort(key=lambda t: (t["position"], t["title"]))
    return tabs


def _emit_tab_inventory(
    spreadsheet_id: str,
    tabs: list[dict[str, Any]],
    limit: int | None,
    include_empty: bool,
    json_output: bool,
    json_summary: bool,
) -> None:
    result = {
        "spreadsheet_id": spreadsheet_id,
        "total_tabs": len(tabs),
        "returned_tabs": len(tabs),
        "include_empty": include_empty,
        "limit": limit,
        "tabs": tabs,
    }

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sheets tabs list", remote_mutation=False)
        agentic = safety_ctx.build_result(status="success", dry_run=True, result=result)
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return
    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    click.echo(f"Spreadsheet: {spreadsheet_id}")
    click.echo(f"Tabs returned: {len(tabs)}")
    for tab in tabs:
        click.echo(
            f"  [{tab['position']}] {tab['title']} "
            f"(sheetId={tab['sheet_id']}, rows={tab['row_count']}, cols={tab['column_count']}, "
            f"non_empty_rows={tab['non_empty_rows']})"
        )


@sheets_tabs.command("list")
@click.argument("spreadsheet_id")
@click.option(
    "--limit",
    "-n",
    type=int,
    default=None,
    help="Maximum number of tabs to return (after filtering).",
)
@click.option(
    "--include-empty",
    is_flag=True,
    help="Include tabs with zero non-empty rows (default: exclude empty tabs).",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sheets_tabs_list(
    spreadsheet_id: str,
    limit: int | None,
    include_empty: bool,
    json_output: bool,
    json_summary: bool,
) -> None:
    """List tab inventory with shape and occupancy stats."""
    if limit is not None and limit < 1:
        raise click.ClickException("--limit/-n must be >= 1.")

    try:
        service = _get_google_client().get_sheets_service()
        tabs = _fetch_tab_inventory(service, spreadsheet_id)
    except Exception as exc:
        click.echo(f"Error reading tab inventory: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if not include_empty:
        tabs = [tab for tab in tabs if tab["non_empty_rows"] > 0]
    if limit is not None:
        tabs = tabs[:limit]

    _emit_tab_inventory(
        spreadsheet_id=spreadsheet_id,
        tabs=tabs,
        limit=limit,
        include_empty=include_empty,
        json_output=json_output,
        json_summary=json_summary,
    )


def _compile_tab_patterns(patterns: list[str]) -> list[tuple[str, Any]]:
    compiled: list[tuple[str, Any]] = []
    for pattern in patterns:
        if pattern.startswith("re:"):
            regex = pattern[3:]
            try:
                compiled.append(("regex", re.compile(regex)))
            except re.error as exc:
                raise click.ClickException(f"Invalid regex pattern '{pattern}': {exc}") from exc
        else:
            compiled.append(("glob", pattern))
    return compiled


def _tab_matches_patterns(title: str, compiled_patterns: list[tuple[str, Any]]) -> bool:
    for pattern_type, payload in compiled_patterns:
        if pattern_type == "regex":
            if payload.search(title):
                return True
        elif fnmatch.fnmatch(title, payload):
            return True
    return False


def _select_tabs_for_delete(
    inventory: list[dict[str, Any]],
    tab_names: list[str],
    prefixes: list[str],
    patterns: list[str],
    sheet_ids: list[int],
) -> list[dict[str, Any]]:
    if not tab_names and not prefixes and not patterns and not sheet_ids:
        raise click.ClickException(
            "Provide at least one selector: --tabs, --prefix, --pattern, or --sheet-id."
        )

    name_set = set(tab_names)
    prefix_list = prefixes
    compiled_patterns = _compile_tab_patterns(patterns)
    sheet_id_set = set(sheet_ids)

    selected: list[dict[str, Any]] = []
    seen_ids: set[int] = set()
    for tab in inventory:
        title = tab["title"]
        sheet_id = int(tab["sheet_id"])
        matches = (
            title in name_set
            or any(title.startswith(prefix) for prefix in prefix_list)
            or _tab_matches_patterns(title, compiled_patterns)
            or sheet_id in sheet_id_set
        )
        if matches and sheet_id not in seen_ids:
            selected.append(tab)
            seen_ids.add(sheet_id)

    selected.sort(key=lambda t: (t["position"], t["title"]))
    return selected


def _build_backup_tab_name(title: str, timestamp: str, existing: set[str]) -> str:
    suffix = f"__backup_{timestamp}"
    max_len = 100
    base_title = title
    if len(base_title) + len(suffix) > max_len:
        base_title = base_title[: max_len - len(suffix)]
    candidate = f"{base_title}{suffix}"
    seq = 2
    while candidate in existing:
        seq_suffix = f"{suffix}_{seq}"
        trimmed = base_title[: max(1, max_len - len(seq_suffix))]
        candidate = f"{trimmed}{seq_suffix}"
        seq += 1
    return candidate


def _rollback_deleted_tabs_from_backups(
    service,
    spreadsheet_id: str,
    backup_entries: list[dict[str, Any]],
) -> dict[str, Any]:
    helpers = _get_sheets_helpers()
    restored: list[str] = []
    errors: list[str] = []

    for entry in backup_entries:
        original = entry["original_title"]
        backup_title = entry["backup_title"]
        try:
            original_id = helpers.get_sheet_id_by_name(service, spreadsheet_id, original)
            backup_id = helpers.get_sheet_id_by_name(service, spreadsheet_id, backup_title)
            if original_id is None and backup_id is not None:
                helpers.rename_tab(service, spreadsheet_id, backup_id, original)
                restored.append(original)
        except Exception as exc:
            errors.append(f"{backup_title}->{original}: {exc}")

    return {"restored": restored, "errors": errors}


def _run_sheets_delete(
    spreadsheet_id: str,
    tabs: tuple[str, ...],
    prefixes: tuple[str, ...],
    patterns: tuple[str, ...],
    sheet_ids: tuple[int, ...],
    max_delete: int,
    apply: bool,
    yes: bool,
    backup: bool,
    auto_rollback: bool,
    json_output: bool,
    json_summary: bool,
) -> None:
    if max_delete < 0:
        raise click.ClickException("--max-delete must be >= 0.")

    tab_names = _parse_multi_selectors(tabs)
    prefix_values = _parse_multi_selectors(prefixes)
    pattern_values = _parse_multi_selectors(patterns)
    sheet_id_values = [int(value) for value in sheet_ids]
    apply_effective = bool(apply or yes)
    emit_json = bool(json_output or json_summary)

    safety_ctx = SafetyContext(command="g-gremlin sheets delete", remote_mutation=True)

    try:
        service = _get_google_client().get_sheets_service()
        inventory = _fetch_tab_inventory(service, spreadsheet_id)
        selected = _select_tabs_for_delete(
            inventory=inventory,
            tab_names=tab_names,
            prefixes=prefix_values,
            patterns=pattern_values,
            sheet_ids=sheet_id_values,
        )
    except click.ClickException:
        raise
    except Exception as exc:
        click.echo(f"Error resolving tabs: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if not selected:
        preview_result = {
            "spreadsheet_id": spreadsheet_id,
            "dry_run": True,
            "delete_count": 0,
            "tabs": [],
        }
        if json_summary:
            agentic = safety_ctx.build_result(status="success", dry_run=True, result=preview_result)
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        elif json_output:
            click.echo(json.dumps(preview_result, indent=2))
        else:
            click.echo("No tabs matched the provided selectors.")
        return

    if max_delete > 0 and len(selected) > max_delete:
        raise click.ClickException(
            f"Selection matched {len(selected)} tabs which exceeds --max-delete {max_delete}."
        )

    if len(selected) >= len(inventory):
        raise click.ClickException(
            "Refusing to delete all tabs. Adjust selectors so at least one tab remains."
        )

    preview = {
        "spreadsheet_id": spreadsheet_id,
        "dry_run": not apply_effective,
        "selectors": {
            "tabs": tab_names,
            "prefixes": prefix_values,
            "patterns": pattern_values,
            "sheet_ids": sheet_id_values,
        },
        "delete_count": len(selected),
        "tabs": selected,
        "max_delete": max_delete,
        "backup_enabled": backup,
    }

    if not apply_effective:
        if json_summary:
            agentic = safety_ctx.build_result(status="success", dry_run=True, result=preview)
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        elif json_output:
            click.echo(json.dumps(preview, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would delete {len(selected)} tab(s) from {spreadsheet_id}:"
            )
            for tab in selected:
                click.echo(
                    f"  [{tab['position']}] {tab['title']} "
                    f"(sheetId={tab['sheet_id']}, rows={tab['row_count']}, cols={tab['column_count']}, "
                    f"non_empty_rows={tab['non_empty_rows']})"
                )
            click.echo("Re-run with --apply or --yes to execute.")
        return

    helpers = _get_sheets_helpers()
    backup_entries: list[dict[str, Any]] = []

    try:
        if backup:
            existing_titles = {tab["title"] for tab in inventory}
            ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
            for tab in selected:
                backup_title = _build_backup_tab_name(tab["title"], ts, existing_titles)
                backup_sheet_id = helpers.copy_tab(
                    service,
                    spreadsheet_id,
                    int(tab["sheet_id"]),
                    backup_title,
                )
                backup_entries.append(
                    {
                        "original_title": tab["title"],
                        "original_sheet_id": int(tab["sheet_id"]),
                        "backup_title": backup_title,
                        "backup_sheet_id": backup_sheet_id,
                    }
                )
                existing_titles.add(backup_title)

        delete_requests = [
            {"deleteSheet": {"sheetId": int(tab["sheet_id"])}}
            for tab in sorted(selected, key=lambda t: int(t["position"]), reverse=True)
        ]
        service.spreadsheets().batchUpdate(
            spreadsheetId=spreadsheet_id,
            body={"requests": delete_requests},
        ).execute()
    except Exception as exc:
        rollback_result: dict[str, Any] | None = None
        if auto_rollback and backup_entries:
            rollback_result = _rollback_deleted_tabs_from_backups(
                service=service,
                spreadsheet_id=spreadsheet_id,
                backup_entries=backup_entries,
            )

        error_result = {
            "spreadsheet_id": spreadsheet_id,
            "delete_count": len(selected),
            "tabs": selected,
            "backup_entries": backup_entries,
            "rollback": rollback_result,
        }
        if json_summary:
            agentic = safety_ctx.build_result(
                status="error",
                dry_run=False,
                result=error_result,
                error=str(exc),
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        elif json_output:
            error_result["error"] = str(exc)
            click.echo(json.dumps(error_result, indent=2))
        else:
            click.echo(f"Delete failed: {exc}", err=True)
            if rollback_result is not None:
                restored = rollback_result.get("restored", []) or []
                rollback_errors = rollback_result.get("errors", []) or []
                click.echo(
                    f"Auto-rollback attempted. Restored {len(restored)} tab(s).",
                    err=True,
                )
                for rollback_error in rollback_errors:
                    click.echo(f"Rollback warning: {rollback_error}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    result = {
        "spreadsheet_id": spreadsheet_id,
        "delete_count": len(selected),
        "deleted_tabs": selected,
        "backup_entries": backup_entries,
        "backup_enabled": backup,
    }

    if json_summary:
        agentic = safety_ctx.build_result(status="success", dry_run=False, result=result)
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return
    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    click.echo(f"Deleted {len(selected)} tab(s) from {spreadsheet_id}.")
    for tab in selected:
        click.echo(
            f"  [{tab['position']}] {tab['title']} "
            f"(sheetId={tab['sheet_id']}, non_empty_rows={tab['non_empty_rows']})"
        )
    if backup_entries:
        click.echo("Backup copies created:")
        for entry in backup_entries:
            click.echo(f"  {entry['original_title']} -> {entry['backup_title']}")


@sheets.command("delete")
@click.argument("spreadsheet_id")
@click.option(
    "--tabs",
    "tabs",
    multiple=True,
    help="Tab title selector (repeat or use comma-separated values).",
)
@click.option(
    "--prefix",
    "prefixes",
    multiple=True,
    help="Delete tabs with title prefix (repeat or comma-separated).",
)
@click.option(
    "--pattern",
    "patterns",
    multiple=True,
    help="Wildcard pattern or regex using re:<pattern> (repeat or comma-separated).",
)
@click.option(
    "--sheet-id",
    "sheet_ids",
    multiple=True,
    type=int,
    help="Delete by numeric sheetId (repeatable).",
)
@click.option(
    "--max-delete",
    type=int,
    default=20,
    show_default=True,
    help="Guardrail: block if selection exceeds this count (0 disables guardrail).",
)
@click.option(
    "--apply/--dry-run",
    "apply",
    default=False,
    show_default=True,
    help="Apply deletions or preview only (default dry-run).",
)
@click.option("--yes", is_flag=True, help="Alias for --apply.")
@click.option(
    "--backup/--no-backup",
    default=True,
    show_default=True,
    help="Create in-sheet backup copies before deletion.",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Attempt to rename backup copies back on failure.",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sheets_delete(
    spreadsheet_id: str,
    tabs: tuple[str, ...],
    prefixes: tuple[str, ...],
    patterns: tuple[str, ...],
    sheet_ids: tuple[int, ...],
    max_delete: int,
    apply: bool,
    yes: bool,
    backup: bool,
    auto_rollback: bool,
    json_output: bool,
    json_summary: bool,
) -> None:
    """Delete tabs using title/sheetId selectors (dry-run by default)."""
    _run_sheets_delete(
        spreadsheet_id=spreadsheet_id,
        tabs=tabs,
        prefixes=prefixes,
        patterns=patterns,
        sheet_ids=sheet_ids,
        max_delete=max_delete,
        apply=apply,
        yes=yes,
        backup=backup,
        auto_rollback=auto_rollback,
        json_output=json_output,
        json_summary=json_summary,
    )


@sheets_tabs.command("delete")
@click.argument("spreadsheet_id")
@click.option(
    "--tabs",
    "tabs",
    multiple=True,
    help="Tab title selector (repeat or use comma-separated values).",
)
@click.option(
    "--prefix",
    "prefixes",
    multiple=True,
    help="Delete tabs with title prefix (repeat or comma-separated).",
)
@click.option(
    "--pattern",
    "patterns",
    multiple=True,
    help="Wildcard pattern or regex using re:<pattern> (repeat or comma-separated).",
)
@click.option(
    "--sheet-id",
    "sheet_ids",
    multiple=True,
    type=int,
    help="Delete by numeric sheetId (repeatable).",
)
@click.option(
    "--max-delete",
    type=int,
    default=20,
    show_default=True,
    help="Guardrail: block if selection exceeds this count (0 disables guardrail).",
)
@click.option(
    "--apply/--dry-run",
    "apply",
    default=False,
    show_default=True,
    help="Apply deletions or preview only (default dry-run).",
)
@click.option("--yes", is_flag=True, help="Alias for --apply.")
@click.option(
    "--backup/--no-backup",
    default=True,
    show_default=True,
    help="Create in-sheet backup copies before deletion.",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Attempt to rename backup copies back on failure.",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sheets_tabs_delete(
    spreadsheet_id: str,
    tabs: tuple[str, ...],
    prefixes: tuple[str, ...],
    patterns: tuple[str, ...],
    sheet_ids: tuple[int, ...],
    max_delete: int,
    apply: bool,
    yes: bool,
    backup: bool,
    auto_rollback: bool,
    json_output: bool,
    json_summary: bool,
) -> None:
    """Alias for `g-gremlin sheets delete`."""
    _run_sheets_delete(
        spreadsheet_id=spreadsheet_id,
        tabs=tabs,
        prefixes=prefixes,
        patterns=patterns,
        sheet_ids=sheet_ids,
        max_delete=max_delete,
        apply=apply,
        yes=yes,
        backup=backup,
        auto_rollback=auto_rollback,
        json_output=json_output,
        json_summary=json_summary,
    )


@sheets.command("rename")
@click.argument("spreadsheet_id")
@click.option("--from", "source_tab", required=True, help="Current tab title.")
@click.option("--to", "dest_tab", required=True, help="New tab title.")
@click.option(
    "--apply/--dry-run",
    "apply",
    default=False,
    show_default=True,
    help="Apply rename or preview only (default dry-run).",
)
@click.option("--yes", is_flag=True, help="Alias for --apply.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sheets_rename(
    spreadsheet_id: str,
    source_tab: str,
    dest_tab: str,
    apply: bool,
    yes: bool,
    json_output: bool,
    json_summary: bool,
) -> None:
    """Rename a tab (dry-run by default)."""
    apply_effective = bool(apply or yes)
    emit_json = bool(json_output or json_summary)
    safety_ctx = SafetyContext(command="g-gremlin sheets rename", remote_mutation=True)

    try:
        service = _get_google_client().get_sheets_service()
        helpers = _get_sheets_helpers()
        source_sheet_id = helpers.get_sheet_id_by_name(service, spreadsheet_id, source_tab)
        dest_sheet_id = helpers.get_sheet_id_by_name(service, spreadsheet_id, dest_tab)
    except Exception as exc:
        click.echo(f"Error reading tabs: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if source_sheet_id is None:
        raise click.ClickException(f"Tab not found: {source_tab}")
    if dest_sheet_id is not None:
        raise click.ClickException(f"Destination tab already exists: {dest_tab}")

    result = {
        "spreadsheet_id": spreadsheet_id,
        "source_tab": source_tab,
        "dest_tab": dest_tab,
        "sheet_id": int(source_sheet_id),
    }

    if not apply_effective:
        if json_summary:
            agentic = safety_ctx.build_result(status="success", dry_run=True, result=result)
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        elif emit_json:
            click.echo(json.dumps({"dry_run": True, **result}, indent=2))
        else:
            click.echo(
                f"[DRY-RUN] Would rename tab '{source_tab}' -> '{dest_tab}' "
                f"(sheetId={source_sheet_id})."
            )
            click.echo("Re-run with --apply or --yes to execute.")
        return

    try:
        helpers.rename_tab(service, spreadsheet_id, int(source_sheet_id), dest_tab)
    except Exception as exc:
        if json_summary:
            agentic = safety_ctx.build_result(
                status="error",
                dry_run=False,
                result=result,
                error=str(exc),
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        elif emit_json:
            click.echo(json.dumps({"error": str(exc), **result}, indent=2))
        else:
            click.echo(f"Rename failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_summary:
        agentic = safety_ctx.build_result(status="success", dry_run=False, result=result)
        click.echo(json.dumps(agentic.to_dict(), indent=2))
    elif emit_json:
        click.echo(json.dumps(result, indent=2))
    else:
        click.echo(f"Renamed tab '{source_tab}' -> '{dest_tab}'.")


@names.command("list")
@click.argument("spreadsheet_id")
def names_list(spreadsheet_id: str) -> None:
    """
    List all named ranges in a spreadsheet.
    """
    from g_gremlin.named_ranges import list_named_ranges

    try:
        ranges = list_named_ranges(spreadsheet_id)
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

    if not ranges:
        click.echo("No named ranges found.")
        return

    click.echo(f"Named ranges in spreadsheet ({len(ranges)}):")
    for r in ranges:
        click.echo(f"  {r.name:<30} {r.range_a1}")


@names.command("create")
@click.argument("spreadsheet_id")
@click.argument("name")
@click.argument("range_a1")
def names_create(spreadsheet_id: str, name: str, range_a1: str) -> None:
    """
    Create a named range.

    Example:
        g-gremlin names create SHEET_ID INPUT_VOLUME "'Quick Calculator'!C10"
    """
    from g_gremlin.named_ranges import create_named_range

    result = create_named_range(spreadsheet_id, name, range_a1)

    if result.success:
        click.echo(f"Created named range '{name}' -> {range_a1}")
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)


@names.command("create-batch")
@click.argument("spreadsheet_id")
@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
def names_create_batch(spreadsheet_id: str, csv_path: Path) -> None:
    """
    Create multiple named ranges from a CSV file.

    CSV format:
        name,range
        INPUT_VOLUME,'Quick Calculator'!C10
        PRC_CA_RATE,'Pricing Engine'!B55

    Example:
        g-gremlin names create-batch SHEET_ID named_ranges.csv
    """
    import csv
    from g_gremlin.named_ranges import create_named_ranges_batch

    # Read CSV
    ranges = []
    try:
        with csv_path.open("r", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                name = row.get("name", "").strip()
                range_a1 = row.get("range", "").strip()
                if name and range_a1:
                    ranges.append((name, range_a1))
    except Exception as e:
        click.echo(f"Error reading CSV: {e}", err=True)
        sys.exit(1)

    if not ranges:
        click.echo("No valid ranges found in CSV", err=True)
        sys.exit(1)

    click.echo(f"Creating {len(ranges)} named ranges...")

    result = create_named_ranges_batch(spreadsheet_id, ranges)

    if result.errors:
        for error in result.errors:
            click.echo(f"Warning: {error}", err=True)

    if result.created > 0:
        click.echo(f"Successfully created {result.created} named ranges")

    if not result.success:
        sys.exit(1)


@names.command("delete")
@click.argument("spreadsheet_id")
@click.argument("name")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
def names_delete(spreadsheet_id: str, name: str, yes: bool) -> None:
    """
    Delete a named range.
    """
    from g_gremlin.named_ranges import delete_named_range

    if not yes:
        if not click.confirm(f"Delete named range '{name}'?"):
            click.echo("Aborted.")
            return

    result = delete_named_range(spreadsheet_id, name)

    if result.success:
        click.echo(f"Deleted named range '{name}'")
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)


@names.command("rename")
@click.argument("spreadsheet_id")
@click.option("--old", required=True, help="Current name of the range.")
@click.option("--new", required=True, help="New name for the range.")
def names_rename(spreadsheet_id: str, old: str, new: str) -> None:
    """
    Rename an existing named range.

    Example:
        g-gremlin names rename SHEET_ID --old "foo" --new "bar"
        g-gremlin ranges rename SHEET_ID --old "INPUT_Volume" --new "INPUT_ANNUAL_VOLUME"
    """
    from g_gremlin.named_ranges import rename_named_range

    result = rename_named_range(spreadsheet_id, old, new)

    if result.success:
        click.echo(f"Renamed named range '{old}' -> '{new}'")
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)


@names.command("apply-to-formulas")
@click.argument("spreadsheet_id")
@click.option("--sheet", help="Only process this sheet (default: all sheets).")
@click.option("--apply", "do_apply", is_flag=True, help="Actually apply changes (default is dry-run).")
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="Write detailed report to JSON file.")
def names_apply_to_formulas(
    spreadsheet_id: str,
    sheet: str | None,
    do_apply: bool,
    output: Path | None,
) -> None:
    """
    Replace raw cell references in formulas with named ranges.

    Scans all formulas for raw cell references (e.g., B55) and replaces them
    with their defined Named Range (e.g., DRV_COACHING_RATE).

    Handles scope resolution: if B55 exists as a local named range on "Tab A"
    and a different one on "Tab B", we check the cell's sheet context.

    \b
    Examples:
      g-gremlin names apply-to-formulas SHEET_ID           # Dry-run
      g-gremlin names apply-to-formulas SHEET_ID --apply   # Actually apply
      g-gremlin ranges apply-to-formulas SHEET_ID --apply  # Using alias
    """
    from g_gremlin.named_ranges import apply_named_ranges_to_formulas

    click.echo("Scanning formulas for named range opportunities...")

    result = apply_named_ranges_to_formulas(
        spreadsheet_id,
        dry_run=not do_apply,
        sheet_name=sheet,
    )

    click.echo(f"Scanned {result.total_formulas_scanned} formulas.")

    if result.errors:
        for error in result.errors:
            click.echo(f"Note: {error}")

    if result.replacements_found == 0:
        click.echo("No replacements found. All cell references are already using named ranges or no named ranges match.")
        return

    click.echo(f"\nFound {result.replacements_found} replacements:")

    # Show up to 20 replacements
    for rep in result.replacements[:20]:
        click.echo(f"  {rep['cell']}: {rep['old_ref']} -> {rep['new_ref']}")

    if len(result.replacements) > 20:
        click.echo(f"  ... and {len(result.replacements) - 20} more")

    if do_apply:
        click.echo(f"\nApplied {result.replacements_applied} formula updates.")
    else:
        click.echo("\nDry-run complete. Run with --apply to execute changes.")

    # Write detailed report if requested
    if output:
        import json
        output.parent.mkdir(parents=True, exist_ok=True)
        report = {
            "summary": {
                "total_formulas_scanned": result.total_formulas_scanned,
                "replacements_found": result.replacements_found,
                "replacements_applied": result.replacements_applied,
                "dry_run": not do_apply,
            },
            "replacements": result.replacements,
        }
        with output.open("w", encoding="utf-8") as f:
            json.dump(report, f, indent=2)
        click.echo(f"Detailed report written to {output}")


# Create 'ranges' as an alias for 'names' command group
cli.add_command(names, name="ranges")


@cli.command()
@click.argument("spreadsheet_id")
@click.option(
    "--range",
    "a1_range",
    help="A1 range to lint (default: all sheets).",
)
@click.option(
    "--rules",
    help="Comma-separated rule names: no-hardcoded, valid-refs, no-circular, no-errors, quoting, all",
)
@click.option(
    "--output",
    "output_file",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write JSON report to file.",
)
@click.option(
    "--fix",
    is_flag=True,
    help="Auto-fix fixable issues (e.g., unquoted sheet refs).",
)
@click.option(
    "--transitive",
    is_flag=True,
    help="Detect transitive circular references (slower but thorough).",
)
def lint(
    spreadsheet_id: str,
    a1_range: str | None,
    rules: str | None,
    output_file: Path | None,
    fix: bool,
    transitive: bool,
) -> None:
    """
    Scan Google Sheet formulas for errors, broken references, and style issues.

    Validates formulas and returns a structured report with severity levels and
    auto-fix suggestions. Useful for catching issues before they break your model.

    \b
    Common Usage:
      # Scan for formula errors (#REF!, #NAME?, #VALUE!, #DIV/0!)
      g-gremlin lint SHEET_ID --rules "errors"

      # Catch broken sheet references (e.g., 'Old Tab' vs 'New Tab')
      g-gremlin lint SHEET_ID --rules "dead-refs"

      # Full audit with structured output
      g-gremlin lint SHEET_ID --rules "all" --output lint_report.json

      # Auto-fix unquoted sheet references
      g-gremlin lint SHEET_ID --rules "quoting" --fix

    \b
    Available Rules:
      dead-refs     Broken sheet references
      errors        #REF!, #NAME?, #VALUE!, #DIV/0!
      no-hardcoded  Magic numbers in formulas
      quoting       Unquoted sheet names
      all           Run all checks

    \b
    For AI Agents:
      - Returns exit code 0 if no errors, 1 if errors found
      - Use --output for structured JSON parsing
      - Use --rules "errors,dead-refs" to validate formula health
    """
    from g_gremlin import lint as lint_module
    from g_gremlin.sheets_io import get_all_sheet_names

    try:
        service = _get_google_client().get_sheets_service()
    except Exception as exc:
        click.echo(f"Auth error: {exc}", err=True)
        sys.exit(1)

    # Get existing sheets for validation
    try:
        existing_sheets = get_all_sheet_names(spreadsheet_id)
    except _get_http_error() as err:
        click.echo(f"Error fetching sheet metadata: {err}", err=True)
        sys.exit(1)

    # Parse rules filter
    enabled_rules: set[str] | None = None
    if rules:
        rule_list = [r.strip() for r in rules.split(",") if r.strip()]
        enabled_rules = lint_module.expand_rule_aliases(rule_list)
        if not enabled_rules:
            click.echo(f"No valid rules found in: {rules}", err=True)
            click.echo(f"Available rules: {', '.join(lint_module.RULE_ALIASES.keys())}")
            sys.exit(1)

    # Determine range to lint
    if a1_range:
        ranges_to_lint = [a1_range]
    else:
        # Lint all sheets
        ranges_to_lint = [f"'{sheet}'!A1:ZZ10000" for sheet in existing_sheets]

    all_issues: list[dict[str, Any]] = []
    cells_for_cycle_check: dict[str, dict[str, Any]] = {}

    for range_spec in ranges_to_lint:
        try:
            # Fetch both values and formulas
            values_response = (
                service.spreadsheets()
                .values()
                .get(spreadsheetId=spreadsheet_id, range=range_spec, valueRenderOption="UNFORMATTED_VALUE")
                .execute()
            )
            formulas_response = (
                service.spreadsheets()
                .values()
                .get(spreadsheetId=spreadsheet_id, range=range_spec, valueRenderOption="FORMULA")
                .execute()
            )
        except _get_http_error() as err:
            if "Unable to parse range" in str(err) or "exceeds grid limits" in str(err):
                continue  # Skip empty/invalid sheets
            click.echo(f"Error fetching range {range_spec}: {err}", err=True)
            continue

        values_grid = values_response.get("values", [])
        formulas_grid = formulas_response.get("values", [])

        # Extract sheet name from range
        sheet_name = range_spec.split("!")[0].strip("'") if "!" in range_spec else existing_sheets[0] if existing_sheets else ""

        # Lint each cell
        for r_idx, (v_row, f_row) in enumerate(zip(values_grid, formulas_grid)):
            for c_idx, (value, formula) in enumerate(zip(v_row, f_row)):
                col_letter = chr(ord("A") + c_idx) if c_idx < 26 else chr(ord("A") + c_idx // 26 - 1) + chr(ord("A") + c_idx % 26)
                cell_a1 = f"{sheet_name}!{col_letter}{r_idx + 1}"

                # Store for transitive cycle check
                if transitive and isinstance(formula, str) and formula.startswith("="):
                    cells_for_cycle_check[cell_a1] = {"formula": formula, "value": value}

                # Run linting
                if enabled_rules is not None:
                    issues = lint_module.lint_formula_filtered(cell_a1, formula, value, existing_sheets, enabled_rules)
                else:
                    issues = lint_module.lint_formula(cell_a1, formula, value, existing_sheets)

                all_issues.extend(issues)

    # Transitive circular reference detection
    if transitive and cells_for_cycle_check:
        cycle_issues = lint_module.detect_transitive_circular_refs(cells_for_cycle_check)
        all_issues.extend(cycle_issues)

    # Generate report
    report = lint_module.generate_lint_report(all_issues)

    # Output
    if output_file:
        import json
        output_file.parent.mkdir(parents=True, exist_ok=True)
        with output_file.open("w", encoding="utf-8") as f:
            json.dump(report, f, indent=2)
        click.echo(f"Lint report written to {output_file}")
    else:
        summary = report["lint_results"]["summary"]
        click.echo(f"Lint complete: {summary['total_issues']} issues ({summary['errors']} errors, {summary['warnings']} warnings, {summary['fixable']} fixable)")

        if all_issues:
            # Group by severity
            errors = [i for i in all_issues if i.get("severity") == "error"]
            warnings = [i for i in all_issues if i.get("severity") == "warning"]

            if errors:
                click.echo("\nErrors:")
                for issue in errors[:20]:  # Limit to 20
                    click.echo(f"  {issue.get('cell', '?')}: [{issue.get('type')}] {issue.get('message', '')}")
                if len(errors) > 20:
                    click.echo(f"  ... and {len(errors) - 20} more errors")

            if warnings:
                click.echo("\nWarnings:")
                for issue in warnings[:10]:  # Limit to 10
                    click.echo(f"  {issue.get('cell', '?')}: [{issue.get('type')}] {issue.get('message', '')}")
                if len(warnings) > 10:
                    click.echo(f"  ... and {len(warnings) - 10} more warnings")

    # Handle --fix
    if fix:
        fixable = [i for i in all_issues if i.get("fixable") and i.get("suggested_fix")]
        if fixable:
            click.echo(f"\nApplying {len(fixable)} fixes...")
            # Group by cell for batch update
            fixes_by_cell: dict[str, str] = {}
            for issue in fixable:
                cell = issue.get("cell")
                if cell and issue.get("suggested_fix"):
                    fixes_by_cell[cell] = issue["suggested_fix"]

            # Apply fixes via batch update
            for cell, fixed_formula in fixes_by_cell.items():
                try:
                    service.spreadsheets().values().update(
                        spreadsheetId=spreadsheet_id,
                        range=cell,
                        valueInputOption="USER_ENTERED",
                        body={"values": [[fixed_formula]]},
                    ).execute()
                except _get_http_error() as err:
                    click.echo(f"  Failed to fix {cell}: {err}", err=True)

            click.echo(f"Applied {len(fixes_by_cell)} fixes.")
        else:
            click.echo("\nNo fixable issues found.")

    # Exit with error code if there are errors
    if any(i.get("severity") == "error" for i in all_issues):
        sys.exit(1)


@cli.command("schema-validate")
@click.argument("spreadsheet_id")
@click.option(
    "--schema",
    "schema_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to YAML schema file.",
)
@click.option(
    "--strict",
    is_flag=True,
    help="Treat warnings as errors.",
)
@click.option(
    "--output",
    "output_file",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write JSON report to file.",
)
def schema_validate(
    spreadsheet_id: str,
    schema_path: Path,
    strict: bool,
    output_file: Path | None,
) -> None:
    """
    Validate a spreadsheet against a YAML schema.

    Schema format:
      version: 1
      sheets:
        "Sheet Name":
          cells:
            C10:
              type: number
              min: 0
              label: "Annual Volume"
            C36:
              type: formula
              must_reference: ["C32", "C29"]

    Supported rules per cell:
      type: number|string|boolean|formula|date
      min: minimum numeric value
      max: maximum numeric value
      must_reference: list of cells formula must reference
      must_not_reference: list of forbidden references
      pattern: regex pattern for value
      not_empty: true if cell must have value
      allowed_values: list of allowed values (enum)

    \b
    Examples:
      g-gremlin schema-validate SHEET_ID --schema spec.yaml
      g-gremlin schema-validate SHEET_ID --schema spec.yaml --strict
    """
    from g_gremlin import schema as schema_module

    # Load schema
    schema = schema_module.load_schema(schema_path)
    if schema is None:
        click.echo(f"Error: Failed to load schema from {schema_path}", err=True)
        sys.exit(1)

    click.echo(f"Validating against schema: {schema_path}")

    # Run validation
    result = schema_module.validate_spreadsheet(spreadsheet_id, schema, strict=strict)

    # Format and display
    output = schema_module.format_validation_result(result)
    click.echo(output)

    # Write JSON report if requested
    if output_file:
        report = {
            "success": result.success,
            "total_checks": result.total_checks,
            "passed": result.passed,
            "failed": result.failed,
            "warnings": result.warnings,
            "issues": [
                {
                    "cell": i.cell,
                    "rule": i.rule,
                    "severity": i.severity,
                    "message": i.message,
                    "expected": i.expected,
                    "actual": i.actual,
                }
                for i in result.issues
            ],
        }
        output_file.parent.mkdir(parents=True, exist_ok=True)
        with output_file.open("w", encoding="utf-8") as f:
            json.dump(report, f, indent=2)
        click.echo(f"Report written to {output_file}")

    # Exit with error code if validation failed
    if not result.success:
        sys.exit(1)


@cli.command()
@click.argument("spreadsheet_id")
@click.option(
    "--cell",
    required=True,
    help="Cell to trace (e.g., 'Quick Calculator!C36').",
)
@click.option(
    "--direction",
    type=click.Choice(["upstream", "downstream", "both"]),
    default="both",
    help="Trace direction: upstream (feeds into), downstream (depends on), or both.",
)
@click.option(
    "--depth",
    default=10,
    type=int,
    help="Maximum traversal depth.",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["tree", "mermaid", "dot"]),
    default="tree",
    help="Output format: tree (text), mermaid (markdown flowchart), or dot (Graphviz).",
)
@click.option(
    "--output",
    "output_file",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write output to file.",
)
def trace(
    spreadsheet_id: str,
    cell: str,
    direction: str,
    depth: int,
    output_format: str,
    output_file: Path | None,
) -> None:
    """
    Trace formula dependencies for a cell.

    Shows what feeds INTO a cell (upstream) and what DEPENDS on it (downstream).
    Flags volatile functions (INDIRECT, OFFSET) that cannot be statically traced.

    \b
    Examples:
      g-gremlin trace SHEET_ID --cell "Quick Calculator!C36" --direction both
      g-gremlin trace SHEET_ID --cell "Sheet!A1" --format mermaid --output trace.md
      g-gremlin trace SHEET_ID --cell "B55" --direction downstream
    """
    from g_gremlin import graph as graph_module
    from g_gremlin.sheets_io import get_all_sheet_names

    try:
        service = _get_google_client().get_sheets_service()
    except Exception as exc:
        click.echo(f"Auth error: {exc}", err=True)
        sys.exit(1)

    # Get all sheets for context
    try:
        existing_sheets = get_all_sheet_names(spreadsheet_id)
    except _get_http_error() as err:
        click.echo(f"Error fetching sheet metadata: {err}", err=True)
        sys.exit(1)

    # Parse cell reference to determine default sheet
    if "!" in cell:
        default_sheet = cell.split("!")[0].strip("'")
        cell_ref = cell
    else:
        # Default to first sheet
        default_sheet = existing_sheets[0] if existing_sheets else "Sheet1"
        cell_ref = f"{default_sheet}!{cell}"

    # Fetch ALL sheets to build cross-sheet dependency graph
    cells: dict[str, dict[str, Any]] = {}
    click.echo(f"Building dependency graph across {len(existing_sheets)} sheets...")

    for sheet_name in existing_sheets:
        range_spec = f"'{sheet_name}'!A1:ZZ10000"
        try:
            values_response = (
                service.spreadsheets()
                .values()
                .get(spreadsheetId=spreadsheet_id, range=range_spec, valueRenderOption="UNFORMATTED_VALUE")
                .execute()
            )
            formulas_response = (
                service.spreadsheets()
                .values()
                .get(spreadsheetId=spreadsheet_id, range=range_spec, valueRenderOption="FORMULA")
                .execute()
            )
        except _get_http_error():
            continue  # Skip sheets that fail

        values_grid = values_response.get("values", [])
        formulas_grid = formulas_response.get("values", [])

        # Build cells dict for this sheet (handle ragged grids)
        max_rows = max(len(values_grid), len(formulas_grid))
        for r_idx in range(max_rows):
            v_row = values_grid[r_idx] if r_idx < len(values_grid) else []
            f_row = formulas_grid[r_idx] if r_idx < len(formulas_grid) else []
            max_cols = max(len(v_row), len(f_row))

            for c_idx in range(max_cols):
                value = v_row[c_idx] if c_idx < len(v_row) else None
                formula = f_row[c_idx] if c_idx < len(f_row) else None
                col_letter = col_index_to_letter(c_idx + 1)  # 1-based
                cell_a1 = f"{sheet_name}!{col_letter}{r_idx + 1}"
                cells[cell_a1] = {"formula": formula, "value": value}

    # Build dependency graph from all cells
    graph = graph_module.build_dependency_graph(cells, default_sheet=default_sheet)

    # Normalize cell reference
    if "!" not in cell_ref:
        cell_ref = f"{default_sheet}!{cell_ref}"

    # Check if cell exists in graph
    if cell_ref not in graph:
        # Try without sheet prefix
        cell_only = cell_ref.split("!")[-1]
        matches = [c for c in graph if c.endswith(f"!{cell_only}")]
        if matches:
            cell_ref = matches[0]
        else:
            sheet_name = cell_ref.split("!")[0] if "!" in cell_ref else "Unknown"
            cell_only = cell_ref.split("!")[-1]
            error = error_helpers.cell_not_found_error(cell_only, sheet_name)
            click.echo(str(error), err=True)
            sys.exit(1)

    # Perform tracing
    output_parts: list[str] = []

    if direction in ("upstream", "both"):
        upstream_results = graph_module.trace_upstream(graph, cell_ref, max_depth=depth, cells_metadata=cells)
        if output_format == "tree":
            output_parts.append("=== UPSTREAM (what feeds into this cell) ===")
            output_parts.append(graph_module.format_trace_tree(upstream_results, cell_ref, "upstream"))
        elif output_format == "mermaid":
            output_parts.append("## Upstream Dependencies")
            output_parts.append(graph_module.format_trace_mermaid(upstream_results, cell_ref, "upstream"))
        elif output_format == "dot":
            output_parts.append(graph_module.format_trace_dot(upstream_results, cell_ref, "upstream"))

        if upstream_results:
            click.echo(f"Found {len(upstream_results)} upstream dependencies.")
        else:
            click.echo("No upstream dependencies found (cell may be a driver or constant).")

    if direction in ("downstream", "both"):
        if direction == "both" and output_parts:
            output_parts.append("")

        downstream_results = graph_module.trace_downstream(graph, cell_ref, max_depth=depth, cells_metadata=cells)
        if output_format == "tree":
            output_parts.append("=== DOWNSTREAM (what depends on this cell) ===")
            output_parts.append(graph_module.format_trace_tree(downstream_results, cell_ref, "downstream"))
        elif output_format == "mermaid":
            output_parts.append("## Downstream Dependents")
            output_parts.append(graph_module.format_trace_mermaid(downstream_results, cell_ref, "downstream"))
        elif output_format == "dot":
            output_parts.append(graph_module.format_trace_dot(downstream_results, cell_ref, "downstream"))

        if downstream_results:
            click.echo(f"Found {len(downstream_results)} downstream dependents.")
        else:
            click.echo("No downstream dependents found (cell may be an output or unused).")

    # Output
    output_text = "\n".join(output_parts)
    if output_file:
        output_file.parent.mkdir(parents=True, exist_ok=True)
        with output_file.open("w", encoding="utf-8") as f:
            f.write(output_text)
        click.echo(f"Trace written to {output_file}")
    else:
        click.echo("")
        click.echo(output_text)


@cli.command("find-refs")
@click.argument("spreadsheet_id")
@click.option(
    "--cell",
    required=True,
    help="Cell to find references to (e.g., 'Driver Modules!B55').",
)
@click.option(
    "--output",
    "output_file",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write output to file.",
)
def find_refs(
    spreadsheet_id: str,
    cell: str,
    output_file: Path | None,
) -> None:
    """
    Find all cells that reference a given cell.

    This is a simplified version of 'trace --direction downstream'.

    \b
    Examples:
      g-gremlin find-refs SHEET_ID --cell "Driver Modules!B55"
      g-gremlin find-refs SHEET_ID --cell "B55"
    """
    from g_gremlin import graph as graph_module
    from g_gremlin.sheets_io import get_all_sheet_names

    try:
        service = _get_google_client().get_sheets_service()
    except Exception as exc:
        click.echo(f"Auth error: {exc}", err=True)
        sys.exit(1)

    # Get all sheets
    try:
        existing_sheets = get_all_sheet_names(spreadsheet_id)
    except _get_http_error() as err:
        click.echo(f"Error fetching sheet metadata: {err}", err=True)
        sys.exit(1)

    # Parse cell reference to determine default sheet
    if "!" in cell:
        default_sheet = cell.split("!")[0].strip("'")
        cell_ref = cell
    else:
        default_sheet = existing_sheets[0] if existing_sheets else "Sheet1"
        cell_ref = f"{default_sheet}!{cell}"

    # Fetch ALL sheets to build cross-sheet dependency graph
    cells: dict[str, dict[str, Any]] = {}
    click.echo(f"Building dependency graph across {len(existing_sheets)} sheets...")

    for sheet_name in existing_sheets:
        range_spec = f"'{sheet_name}'!A1:ZZ10000"
        try:
            formulas_response = (
                service.spreadsheets()
                .values()
                .get(spreadsheetId=spreadsheet_id, range=range_spec, valueRenderOption="FORMULA")
                .execute()
            )
        except _get_http_error():
            continue  # Skip sheets that fail

        formulas_grid = formulas_response.get("values", [])

        # Build cells dict for this sheet
        for r_idx, f_row in enumerate(formulas_grid):
            for c_idx, formula in enumerate(f_row):
                col_letter = col_index_to_letter(c_idx + 1)  # 1-based
                cell_a1 = f"{sheet_name}!{col_letter}{r_idx + 1}"
                cells[cell_a1] = {"formula": formula, "value": None}

    # Build dependency graph from all cells
    graph = graph_module.build_dependency_graph(cells, default_sheet=default_sheet)

    # Normalize cell reference
    if "!" not in cell_ref:
        cell_ref = f"{default_sheet}!{cell_ref}"

    # Find downstream dependents
    downstream_results = graph_module.trace_downstream(graph, cell_ref, max_depth=10, cells_metadata=cells)

    # Format output
    lines: list[str] = [f"Cells referencing {cell_ref}:"]

    if downstream_results:
        for item in downstream_results:
            ref_cell = item.get("cell", "?")
            formula = item.get("formula", "")
            if formula and len(formula) > 60:
                formula = formula[:57] + "..."
            lines.append(f"  {ref_cell}  {formula}")
        lines.append("")
        lines.append(f"Total: {len(downstream_results)} references found")
    else:
        lines.append("  (no references found)")

    output_text = "\n".join(lines)

    if output_file:
        output_file.parent.mkdir(parents=True, exist_ok=True)
        with output_file.open("w", encoding="utf-8") as f:
            f.write(output_text)
        click.echo(f"References written to {output_file}")
    else:
        click.echo(output_text)


@formula.command("set")
@click.argument("spreadsheet_id")
@click.argument("a1_range")
@click.argument("formula_text")
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the formula write (default is dry-run preview).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def formula_set(
    spreadsheet_id: str,
    a1_range: str,
    formula_text: str,
    apply: bool,
    json_summary: bool,
) -> None:
    """
    Set a formula across a range without preparing a CSV.

    By default, runs in dry-run mode. Use --apply to execute.
    """
    safety_ctx = SafetyContext(command="g-gremlin formula set", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    try:
        parsed = parse_a1_range(a1_range)
    except Exception as exc:
        click.echo(f"Invalid range: {exc}", err=True)
        sys.exit(1)

    # Normalize formula (ensure leading "=")
    normalized_formula = formula_text if formula_text.startswith("=") else f"={formula_text}"
    row_count = (parsed.end_row or parsed.start_row) - parsed.start_row + 1
    col_count = (parsed.end_col or parsed.start_col) - parsed.start_col + 1
    cell_count = row_count * col_count

    # Dry-run preview
    if not apply:
        click.echo(
            f"[DRY-RUN] Would set formula '{normalized_formula[:50]}{'...' if len(normalized_formula) > 50 else ''}' "
            f"across {row_count}×{col_count} ({cell_count}) cells at {a1_range}."
        )
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "range": a1_range,
                    "formula_preview": normalized_formula[:100],
                    "row_count": row_count,
                    "col_count": col_count,
                    "cell_count": cell_count,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    try:
        service = _get_google_client().get_sheets_service()
    except Exception as exc:
        click.echo(f"Auth error: {exc}", err=True)
        sys.exit(1)

    rows = [[normalized_formula for _ in range(col_count)] for _ in range(row_count)]

    try:
        service.spreadsheets().values().update(
            spreadsheetId=spreadsheet_id,
            range=a1_range,
            valueInputOption="USER_ENTERED",
            body={"values": rows},
        ).execute()
    except _get_http_error() as err:
        click.echo(f"Error writing formula: {err}", err=True)
        sys.exit(1)

    click.echo(f"Set formula across {row_count}×{col_count} cells at {a1_range}.")

    if json_summary:
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "range": a1_range,
                "formula": normalized_formula,
                "row_count": row_count,
                "col_count": col_count,
                "cell_count": cell_count,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@script.command("pull")
@click.argument("spreadsheet_id")
@click.option(
    "--output",
    "output_dir",
    default=Path("scripts"),
    show_default=True,
    type=click.Path(file_okay=False, path_type=Path),
    help="Directory to write the Apps Script files.",
)
@click.option(
    "--script-id",
    "script_id_opt",
    help="Apps Script project ID override (defaults to bound script for the sheet).",
)
@click.option(
    "--create-if-missing",
    is_flag=True,
    help="Create a bound Apps Script project if none exists.",
)
def script_pull(spreadsheet_id: str, output_dir: Path, script_id_opt: str | None, create_if_missing: bool) -> None:
    """
    Pull .gs/.html/appsscript.json files from the bound Apps Script project.
    """
    try:
        script_id = _get_apps_script().resolve_script_id(
            spreadsheet_id,
            explicit_script_id=script_id_opt,
            create_if_missing=create_if_missing,
        )
    except _get_script_resolution_error() as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)
    try:
        written = _get_apps_script().pull_script(script_id, output_dir)
    except _get_http_error() as err:
        click.echo(f"Error pulling Apps Script project: {err}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error pulling script: {exc}", err=True)
        sys.exit(1)

    click.echo(f"Pulled {len(written)} file(s) to {output_dir} (scriptId={script_id}).")


@script.command("push")
@click.argument("spreadsheet_id")
@click.option(
    "--source",
    "source_dir",
    default=Path("scripts"),
    show_default=True,
    type=click.Path(file_okay=False, path_type=Path),
    help="Directory containing .gs/.html/appsscript.json files to push.",
)
@click.option(
    "--script-id",
    "script_id_opt",
    help="Apps Script project ID override (defaults to bound script for the sheet).",
)
@click.option(
    "--create-if-missing",
    is_flag=True,
    help="Create a bound Apps Script project if none exists.",
)
def script_push(spreadsheet_id: str, source_dir: Path, script_id_opt: str | None, create_if_missing: bool) -> None:
    """
    Push local Apps Script files to the bound project (full replace).
    """
    try:
        script_id = _get_apps_script().resolve_script_id(
            spreadsheet_id,
            explicit_script_id=script_id_opt,
            create_if_missing=create_if_missing,
        )
    except _get_script_resolution_error() as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)

    try:
        file_count = _get_apps_script().push_script(script_id, source_dir)
    except (_get_http_error(), FileNotFoundError, ValueError) as exc:
        click.echo(f"Error pushing Apps Script project: {exc}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error pushing script: {exc}", err=True)
        sys.exit(1)

    click.echo(f"Pushed {file_count} file(s) from {source_dir} to scriptId={script_id}.")


@script.command("init")
@click.argument("spreadsheet_id")
@click.option("--title", help="Title for the new Apps Script project.")
@click.option(
    "--force",
    is_flag=True,
    help="Create a new bound project even if one already exists.",
)
@click.option(
    "--pull",
    "pull_after",
    is_flag=True,
    help="After init, pull the script files locally.",
)
@click.option(
    "--output",
    "output_dir",
    default=Path("scripts"),
    show_default=True,
    type=click.Path(file_okay=False, path_type=Path),
    help="Where to write files when using --pull.",
)
def script_init(spreadsheet_id: str, title: str | None, force: bool, pull_after: bool, output_dir: Path) -> None:
    """
    Create/bind a new Apps Script project to a spreadsheet.
    """
    existing = _get_apps_script().find_bound_script(spreadsheet_id)
    if existing and not force:
        _get_apps_script().cache_binding(spreadsheet_id, existing)
        click.echo(f"Script already bound (scriptId={existing}). Use --force to create a new one.")
        if pull_after:
            written = _get_apps_script().pull_script(existing, output_dir)
            click.echo(f"Pulled {len(written)} file(s) to {output_dir}.")
        return

    try:
        script_id = _get_apps_script().create_bound_script(spreadsheet_id, title=title)
        _get_apps_script().cache_binding(spreadsheet_id, script_id)
    except _get_http_error() as err:
        click.echo(f"Error creating Apps Script project: {err}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error creating Apps Script project: {exc}", err=True)
        sys.exit(1)

    click.echo(f"Created bound Apps Script project (scriptId={script_id}).")
    if pull_after:
        written = _get_apps_script().pull_script(script_id, output_dir)
        click.echo(f"Pulled {len(written)} file(s) to {output_dir}.")


@script.command("run")
@click.argument("spreadsheet_id")
@click.argument("function_name")
@click.option(
    "--params",
    default="[]",
    help="JSON array of parameters to pass to the function.",
)
@click.option(
    "--script-id",
    "script_id_opt",
    help="Apps Script project ID override (defaults to bound script for the sheet).",
)
def script_run(spreadsheet_id: str, function_name: str, params: str, script_id_opt: str | None) -> None:
    """
    Execute a function in the bound Apps Script project.
    """
    try:
        parsed_params = json.loads(params) if params else []
        if not isinstance(parsed_params, list):
            raise ValueError("Params must be a JSON array.")
    except Exception as exc:
        click.echo(f"Error parsing --params: {exc}", err=True)
        sys.exit(1)

    try:
        script_id = _get_apps_script().resolve_script_id(spreadsheet_id, explicit_script_id=script_id_opt)
    except _get_script_resolution_error() as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)

    try:
        result = _get_apps_script().run_function(script_id, function_name, parsed_params)
    except _get_http_error() as err:
        if getattr(err, "resp", None) and getattr(err.resp, "status", None) == 403:
            click.echo(
                "Error executing Apps Script function (403). Ensure the Apps Script API is enabled "
                "and the project has a deployed version. Original error follows:",
                err=True,
            )
        click.echo(f"Error executing Apps Script function: {err}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error executing Apps Script function: {exc}", err=True)
        sys.exit(1)

    click.echo("Execution result:")
    click.echo(json.dumps(result, indent=2))


@script.command("logs")
@click.argument("spreadsheet_id")
@click.option(
    "--limit",
    default=10,
    show_default=True,
    type=int,
    help="Number of recent operations to fetch.",
)
@click.option(
    "--script-id",
    "script_id_opt",
    help="Apps Script project ID override (defaults to bound script for the sheet).",
)
def script_logs(spreadsheet_id: str, limit: int, script_id_opt: str | None) -> None:
    """
    Show recent Apps Script executions (best-effort).
    """
    try:
        script_id = _get_apps_script().resolve_script_id(spreadsheet_id, explicit_script_id=script_id_opt)
    except _get_script_resolution_error() as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)

    try:
        operations = _get_apps_script().list_recent_operations(script_id, page_size=limit)
    except _get_http_error() as err:
        click.echo(f"Error fetching script operations: {err}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error fetching script operations: {exc}", err=True)
        sys.exit(1)

    if not operations:
        click.echo("No recent Apps Script operations found.")
        return

    click.echo(f"Recent operations for scriptId={script_id}:")
    for op in operations:
        name = op.get("name", "<unknown>")
        done = op.get("done", False)
        err = op.get("error", {})
        response = op.get("response")
        click.echo(f"- {name} | done={done}")
        if err:
            click.echo(f"    error: {err}")
        elif response:
            click.echo(f"    response: {response}")


def _parse_nimbleway_multi_values(raw_values: tuple[str, ...]) -> list[str]:
    values: list[str] = []
    seen: set[str] = set()
    for raw in raw_values:
        for item in [part.strip() for part in raw.split(",") if part.strip()]:
            if item not in seen:
                seen.add(item)
                values.append(item)
    return values


def _nimble_output_options(func: Callable[..., Any]) -> Callable[..., Any]:
    func = click.option(
        "--json",
        "json_output",
        is_flag=True,
        help="Emit JSON output.",
    )(func)
    func = click.option(
        "--display-format",
        type=click.Choice(["pretty", "json", "yaml", "raw"], case_sensitive=False),
        default="pretty",
        show_default=True,
        help="Render the response payload as pretty text, JSON, YAML, or raw output.",
    )(func)
    func = click.option(
        "--transform",
        "display_transform",
        default=None,
        help="Optional dotted path selector, e.g. structured.company_name or results[0].url.",
    )(func)
    return func


def _resolve_nimble_display_format(json_output: bool, display_format: str) -> str:
    normalized = str(display_format or "pretty").strip().lower()
    if json_output:
        if normalized != "pretty":
            raise click.ClickException("Use either --json or --display-format, not both.")
        return "json"
    return normalized or "pretty"


def _parse_nimble_transform_tokens(expression: str) -> list[str | int]:
    expr = str(expression or "").strip()
    if not expr:
        return []

    tokens: list[str | int] = []
    index = 0
    while index < len(expr):
        current = expr[index]
        if current == ".":
            index += 1
            if index >= len(expr):
                raise click.ClickException(f"Invalid --transform path: {expression}")
            continue
        if current == "[":
            end = expr.find("]", index + 1)
            if end == -1:
                raise click.ClickException(f"Invalid --transform path: {expression}")
            raw_index = expr[index + 1 : end].strip()
            if not raw_index.isdigit():
                raise click.ClickException(
                    "--transform only supports numeric list indexes inside brackets."
                )
            tokens.append(int(raw_index))
            index = end + 1
            continue

        end = index
        while end < len(expr) and expr[end] not in ".[":
            end += 1
        token = expr[index:end].strip()
        if not token:
            raise click.ClickException(f"Invalid --transform path: {expression}")
        tokens.append(token)
        index = end
    return tokens


def _apply_nimble_transform(payload: Any, expression: str | None) -> Any:
    if not expression:
        return payload

    current = payload
    for token in _parse_nimble_transform_tokens(expression):
        if isinstance(token, int):
            if not isinstance(current, list):
                raise click.ClickException(
                    f"--transform index [{token}] requires a list value, got {type(current).__name__}."
                )
            if token >= len(current):
                raise click.ClickException(f"--transform index [{token}] is out of range.")
            current = current[token]
            continue

        if not isinstance(current, Mapping):
            raise click.ClickException(
                f"--transform key '{token}' requires an object value, got {type(current).__name__}."
            )
        if token not in current:
            raise click.ClickException(f"--transform key '{token}' was not found.")
        current = current[token]
    return current


def _emit_nimble_output(
    payload: Any,
    *,
    display_format: str,
    display_transform: str | None = None,
) -> None:
    rendered = _apply_nimble_transform(payload, display_transform)
    normalized = str(display_format or "pretty").strip().lower()

    if normalized == "json":
        click.echo(json.dumps(rendered, indent=2))
        return

    if normalized == "yaml":
        try:
            import yaml
        except ImportError as exc:  # pragma: no cover
            raise click.ClickException("PyYAML is required for --display-format yaml.") from exc
        text = yaml.safe_dump(
            rendered,
            sort_keys=False,
            default_flow_style=False,
            allow_unicode=False,
        )
        click.echo(text.rstrip())
        return

    if normalized == "raw":
        if rendered is None:
            return
        if isinstance(rendered, bool):
            click.echo("true" if rendered else "false")
            return
        if isinstance(rendered, (str, int, float)):
            click.echo(str(rendered))
            return
        click.echo(json.dumps(rendered, separators=(",", ":")))
        return

    if rendered is None:
        return
    if isinstance(rendered, bool):
        click.echo("true" if rendered else "false")
        return
    if isinstance(rendered, (str, int, float)):
        click.echo(str(rendered))
        return
    click.echo(json.dumps(rendered, indent=2))


def _print_nimble_task_summary(task: Mapping[str, Any]) -> None:
    task_id = task.get("id")
    state = task.get("state") or "unknown"
    if task_id:
        click.echo(f"Task ID: {task_id}")
    click.echo(f"State: {state}")
    for key, label in (
        ("api_type", "API Type"),
        ("created_at", "Created"),
        ("modified_at", "Modified"),
        ("status_code", "HTTP"),
        ("download_url", "Download URL"),
        ("output_url", "Output URL"),
        ("status_url", "Status URL"),
    ):
        value = task.get(key)
        if value not in (None, ""):
            click.echo(f"{label}: {value}")
    error = task.get("error")
    if error:
        click.echo(f"Error: {error}")


def _print_nimble_agent_summary(agent: Mapping[str, Any]) -> None:
    display_name = agent.get("display_name") or agent.get("name") or "(unnamed)"
    template_name = agent.get("name")
    privacy = "public" if agent.get("is_public") else "private"
    click.echo(f"Name: {display_name}")
    if template_name and template_name != display_name:
        click.echo(f"Template: {template_name}")
    click.echo(f"Privacy: {privacy}")
    for key, label in (
        ("managed_by", "Managed By"),
        ("domain", "Domain"),
        ("vertical", "Vertical"),
        ("entity_type", "Entity Type"),
    ):
        value = agent.get(key)
        if value not in (None, ""):
            click.echo(f"{label}: {value}")
    description = agent.get("description")
    if description:
        click.echo(f"Description: {description}")


def _coerce_nimble_param_value(raw_value: str) -> Any:
    value = str(raw_value).strip()
    if not value:
        return ""
    lowered = value.lower()
    if lowered in {"true", "false", "null"}:
        return json.loads(lowered)
    if value.startswith("{") or value.startswith("[") or value.startswith('"'):
        try:
            return json.loads(value)
        except Exception:
            return value
    return value


def _parse_nimble_param_items(items: tuple[str, ...], params_file: Path | None = None) -> dict[str, Any]:
    params: dict[str, Any] = {}
    if params_file is not None:
        try:
            loaded = json.loads(params_file.read_text(encoding="utf-8"))
        except Exception as exc:  # noqa: BLE001
            raise click.ClickException(f"Invalid --params-file JSON: {exc}") from exc
        if not isinstance(loaded, Mapping):
            raise click.ClickException("--params-file must contain a JSON object.")
        params.update(dict(loaded))

    for item in items:
        raw = str(item).strip()
        if not raw:
            raise click.ClickException("--params entries cannot be empty.")
        if raw.startswith("{"):
            try:
                loaded = json.loads(raw)
            except Exception as exc:  # noqa: BLE001
                raise click.ClickException(f"Invalid JSON object for --params: {exc}") from exc
            if not isinstance(loaded, Mapping):
                raise click.ClickException("--params JSON input must be an object.")
            params.update(dict(loaded))
            continue
        if "=" not in raw:
            raise click.ClickException("--params must use key=value syntax or a JSON object.")
        key, value = raw.split("=", 1)
        normalized_key = key.strip()
        if not normalized_key:
            raise click.ClickException("--params key cannot be empty.")
        params[normalized_key] = _coerce_nimble_param_value(value)
    return params


def _get_nimble_agent_run_primary_payload(response: Mapping[str, Any]) -> Any:
    data = response.get("data") or {}
    if not isinstance(data, Mapping):
        return data
    parsing = data.get("parsing")
    if parsing not in (None, {}, []):
        return parsing
    markdown = data.get("markdown")
    if markdown not in (None, ""):
        return markdown
    html = data.get("html")
    if html not in (None, ""):
        return html
    return data


@apify.command("connect")
@click.option(
    "--api-token",
    "api_token",
    required=True,
    help="Apify API token (from https://console.apify.com/settings/integrations).",
)
def apify_connect(api_token: str) -> None:
    """
    Store Apify API credentials locally.
    """
    token_clean = api_token.strip()
    if not token_clean:
        click.echo("Error: --api-token is required.")
        sys.exit(1)

    # Verify token works by creating a client (will fail on first API call if invalid)
    config = _get_apify_helpers().ApifyConfig(api_token=token_clean)
    _get_apify_helpers().save_apify_config(config)

    click.echo(f"Apify credentials saved to {_get_apify_helpers().APIFY_CONFIG_PATH}")
    click.echo("Test with: g-gremlin apify validate")


@apify.command("validate")
def apify_validate() -> None:
    """
    Validate Apify credentials and test actor access.
    """
    try:
        client = _get_apify_helpers().get_apify_client()
        # Test by getting user info
        user = client.user().get()
        click.echo(f"Connected to Apify as: {user.get('username', 'unknown')}")
        click.echo(f"Email: {user.get('email', 'unknown')}")
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)


@apify.command("enrich")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV file to enrich.",
)
@click.option(
    "--company-col",
    "company_col",
    default="company",
    help="Column containing company names (default: company).",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output CSV path for enriched data.",
)
@click.option(
    "--country",
    default="US",
    help="Country code for search context (default: US).",
)
@click.option(
    "--limit",
    default=0,
    type=int,
    help="Limit rows to process (0 = all).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Show what would be processed without calling API.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def apify_enrich(
    csv_path: Path,
    company_col: str,
    output_path: Path,
    country: str,
    limit: int,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Enrich company names with domains and social links via Apify.

    Uses apioracle/company-domain actor (~$19/1000 results).
    """
    import csv as csv_module

    # Read input
    with csv_path.open(newline="", encoding="utf-8-sig") as f:
        rows = list(csv_module.DictReader(f))

    if not rows:
        click.echo("Error: No rows found in input file.")
        sys.exit(1)

    if company_col not in rows[0]:
        error = error_helpers.column_not_found_error(
            company_col, list(rows[0].keys()), context="input CSV"
        )
        click.echo(str(error), err=True)
        sys.exit(1)

    if limit > 0:
        rows = rows[:limit]

    estimated_cost = len(rows) / 1000 * 19
    click.echo(f"Loaded {len(rows)} rows from {csv_path}")
    click.echo(f"Estimated cost: ${estimated_cost:.2f}")

    if dry_run:
        click.echo("\n[DRY RUN] Would process:")
        for idx, row in enumerate(rows[:10]):
            company = row.get(company_col, "").strip()
            source_id = _get_apify_helpers().make_source_record_id(idx, company)
            click.echo(f"  {source_id}: {company[:50]}")
        if len(rows) > 10:
            click.echo(f"  ... and {len(rows) - 10} more")
        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin apify enrich", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "rows_planned": len(rows),
                    "company_col": company_col,
                    "output": str(output_path),
                    "limit": limit,
                    "country": country,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    # Enrich
    results = []
    success_count = 0
    start_time = time.time()

    for idx, row in enumerate(rows):
        company_raw = row.get(company_col, "").strip()
        source_id = _get_apify_helpers().make_source_record_id(idx, company_raw)

        elapsed = time.time() - start_time
        rate = (idx + 1) / elapsed if elapsed > 0 else 0
        eta = (len(rows) - idx - 1) / rate if rate > 0 else 0
        click.echo(f"[{idx + 1}/{len(rows)}] {company_raw[:40]:40} (ETA: {eta:.0f}s)")

        if not company_raw:
            results.append(_get_apify_helpers().normalize_enrichment_result(
                source_id, company_raw, {}, error="Empty company name"
            ))
            continue

        enrichment = _get_apify_helpers().enrich_company_sync(company_raw, country)
        result = _get_apify_helpers().normalize_enrichment_result(
            source_id,
            company_raw,
            enrichment.get("data", {}),
            run_id=enrichment.get("run_id"),
            dataset_id=enrichment.get("dataset_id"),
            error=enrichment.get("error"),
        )
        results.append(result)

        if result["domain"]:
            success_count += 1
            click.echo(f"    -> {result['domain']}")
        elif result["error"]:
            click.echo(f"    -> ERROR: {result['error'][:50]}")
        else:
            click.echo("    -> (no result)")

    # Write output
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = list(results[0].keys()) if results else []
    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv_module.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(results)

    elapsed_total = time.time() - start_time
    click.echo(f"\nDone! {success_count}/{len(results)} enriched in {elapsed_total:.1f}s")
    click.echo(f"Output: {output_path}")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin apify enrich", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "rows_processed": len(results),
                "success_count": success_count,
                "output": str(output_path),
                "country": country,
                "limit": limit,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@nimbleway.command("doctor")
@_nimble_output_options
@click.option(
    "--url",
    "test_url",
    default=None,
    help="Probe URL for the doctor extract ping (default: https://example.com).",
)
@click.option(
    "--country",
    default=None,
    help="Optional ISO country code for the doctor probe.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_doctor(
    test_url: str | None,
    country: str | None,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Validate Nimbleway credentials and probe extract capability."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        doctor = nimble_mod.run_nimbleway_doctor(
            test_url=test_url or nimble_mod.DEFAULT_DOCTOR_URL,
            country=country,
            profile=profile,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    payload = doctor.to_dict()
    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            payload,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Nimbleway doctor: {doctor.overall_status}")
    for check in doctor.checks:
        click.echo(f"  [{check.status}] {check.name}: {check.detail}")
    if doctor.identity:
        click.echo("Identity:")
        for key in sorted(doctor.identity.keys()):
            value = doctor.identity.get(key)
            if value not in (None, ""):
                click.echo(f"  {key}: {value}")
    if doctor.overall_status not in {"ok", "degraded"}:
        sys.exit(EXIT_REMOTE_ERROR)


@nimbleway.command("extract")
@_nimble_output_options
@click.option("--url", required=True, help="URL to extract.")
@click.option("--country", default=None, help="Optional ISO country code.")
@click.option("--render", is_flag=True, help="Enable browser rendering for JS-heavy pages.")
@click.option("--parse", is_flag=True, help="Parse structured output instead of returning raw content.")
@click.option(
    "--instructions",
    default=None,
    help="Natural-language extraction instructions passed as the Nimbleway parser prompt.",
)
@click.option(
    "--format",
    "formats",
    multiple=True,
    help="Requested output format(s), e.g. markdown, html.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_extract(
    url: str,
    country: str | None,
    render: bool,
    parse: bool,
    instructions: str | None,
    formats: tuple[str, ...],
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Extract content from a web page with Nimbleway."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        parse_enabled = parse or bool(instructions)
        parser = instructions.strip() if instructions else None
        response = client.extract(
            url=url,
            country=country,
            render=render,
            parse=parse_enabled,
            parser=parser,
            formats=list(formats) or None,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Status: {response.get('status') or 'unknown'}")
    if response.get("status_code") is not None:
        click.echo(f"HTTP: {response['status_code']}")
    if response.get("task_id"):
        click.echo(f"Task ID: {response['task_id']}")
    if response.get("url"):
        click.echo(f"URL: {response['url']}")

    payload = nimble_mod.get_primary_extract_payload(response)
    if isinstance(payload, Mapping):
        click.echo(json.dumps(payload, indent=2))
    elif payload:
        click.echo(str(payload))


@nimbleway.command("company")
@_nimble_output_options
@click.option("--url", required=True, help="Company website or public social/company profile URL.")
@click.option("--country", default=None, help="Optional ISO country code.")
@click.option(
    "--render",
    is_flag=True,
    help="Enable browser rendering. LinkedIn URLs auto-enable rendering even without this flag.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_company(
    url: str,
    country: str | None,
    render: bool,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Extract a structured company profile from a website or public social profile."""
    nimble_mod = _get_nimbleway_helpers()
    is_linkedin = "linkedin.com" in url.lower()
    effective_render = render or is_linkedin
    warnings: list[str] = []
    transport = "extract_rendered" if effective_render else "extract"

    try:
        client = nimble_mod.NimblewayClient(
            profile=profile,
            timeout=90.0 if is_linkedin else nimble_mod.DEFAULT_TIMEOUT_SECS,
        )
        response = client.extract(
            url=url,
            country=country,
            render=effective_render,
            formats=["html", "markdown"],
            request_timeout_ms=90000 if is_linkedin else 45000,
        )
    except nimble_mod.NimblewayError as exc:
        if effective_render:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        warnings.append(f"initial extract failed: {exc}")
        try:
            response = client.extract(
                url=url,
                country=country,
                render=True,
                formats=["html", "markdown"],
                request_timeout_ms=90000,
            )
            transport = "extract_render_retry"
        except nimble_mod.NimblewayError as retry_exc:
            click.echo(f"Error: {retry_exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

    structured = nimble_mod.extract_company_profile(response, source_url=url)
    payload = {
        "status": response.get("status"),
        "status_code": response.get("status_code"),
        "task_id": response.get("task_id"),
        "url": response.get("url"),
        "transport": transport,
        "structured": structured,
    }
    if warnings:
        payload["warnings"] = warnings

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            payload,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Status: {payload.get('status') or 'unknown'}")
    if payload.get("status_code") is not None:
        click.echo(f"HTTP: {payload['status_code']}")
    if payload.get("task_id"):
        click.echo(f"Task ID: {payload['task_id']}")
    if payload.get("url"):
        click.echo(f"URL: {payload['url']}")
    click.echo(f"Transport: {transport}")
    for key in ("company_name", "description", "category", "website", "linkedin_url", "hq_location", "follower_count"):
        value = structured.get(key)
        if value:
            click.echo(f"{key}: {value}")
    if warnings:
        click.echo(f"Warnings: {'; '.join(warnings)}")


@nimbleway.command("person")
@_nimble_output_options
@click.option(
    "--linkedin",
    "linkedin_ref",
    required=True,
    help="LinkedIn profile URL or public identifier.",
)
@click.option("--country", default=None, help="Optional ISO country code for fallback extraction.")
@click.option(
    "--render",
    is_flag=True,
    help="Force rendered-page extraction when falling back from the social API.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_person(
    linkedin_ref: str,
    country: str | None,
    render: bool,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Extract a structured LinkedIn person profile."""
    nimble_mod = _get_nimbleway_helpers()
    social_ref = nimble_mod.normalize_linkedin_person_reference(linkedin_ref)
    if social_ref.get("platform") != "linkedin" or not social_ref.get("identifier"):
        raise click.ClickException("--linkedin must be a LinkedIn profile URL or public identifier.")

    source_url = str(social_ref.get("source_url") or "")
    warnings: list[str] = []

    try:
        client = nimble_mod.NimblewayClient(profile=profile, timeout=90.0)
        response = client.social_linkedin_person(str(social_ref.get("identifier") or ""))
        transport = "social_api"
    except nimble_mod.NimblewayError as exc:
        warnings.append(f"social api failed: {exc}")
        try:
            response = client.extract(
                url=source_url or linkedin_ref,
                country=country,
                render=True,
                formats=["html", "markdown"],
                request_timeout_ms=90000,
            )
            transport = "extract_fallback"
        except nimble_mod.NimblewayError as fallback_exc:
            click.echo(f"Error: {fallback_exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

    structured = nimble_mod.extract_person_profile(response, source_url=source_url or linkedin_ref)
    payload = {
        "status": response.get("status"),
        "status_code": response.get("status_code"),
        "task_id": response.get("task_id"),
        "url": source_url or linkedin_ref,
        "transport": transport,
        "structured": structured,
    }
    if warnings:
        payload["warnings"] = warnings

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            payload,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Status: {payload.get('status') or 'unknown'}")
    if payload.get("status_code") is not None:
        click.echo(f"HTTP: {payload['status_code']}")
    if payload.get("task_id"):
        click.echo(f"Task ID: {payload['task_id']}")
    if payload.get("url"):
        click.echo(f"URL: {payload['url']}")
    click.echo(f"Transport: {transport}")
    for key in ("person_name", "headline_or_bio", "current_role", "current_company", "location", "source_url"):
        value = structured.get(key)
        if value:
            click.echo(f"{key}: {value}")
    if warnings:
        click.echo(f"Warnings: {'; '.join(warnings)}")


@nimbleway.command("search")
@_nimble_output_options
@click.option("--query", required=True, help="Search query.")
@click.option("--country", default=None, help="Optional ISO country code.")
@click.option("--max-results", default=10, show_default=True, type=int, help="Maximum number of results.")
@click.option(
    "--search-depth",
    type=click.Choice(["lite", "fast", "deep"], case_sensitive=False),
    default="fast",
    show_default=True,
    help="Controls result richness and latency.",
)
@click.option(
    "--fast",
    "fast_alias",
    is_flag=True,
    hidden=True,
    help="Deprecated alias for --search-depth fast.",
)
@click.option(
    "--focus",
    default=None,
    help="Optional search focus, e.g. social, news, shopping.",
)
@click.option(
    "--content-type",
    "content_types",
    multiple=True,
    help="Restrict results to one or more content types, e.g. documents, pdf, xlsx.",
)
@click.option(
    "--include-domain",
    "include_domains",
    multiple=True,
    help="Restrict results to one or more domains.",
)
@click.option(
    "--exclude-domain",
    "exclude_domains",
    multiple=True,
    help="Exclude one or more domains.",
)
@click.option("--answer", "include_answer", is_flag=True, help="Include Nimbleway answer synthesis when supported.")
@click.option("--locale", default=None, help="Optional language/locale code, e.g. en or fr.")
@click.option("--start-date", default=None, help="Filter results after this date (YYYY-MM-DD or YYYY).")
@click.option("--end-date", default=None, help="Filter results before this date (YYYY-MM-DD or YYYY).")
@click.option(
    "--time-range",
    type=click.Choice(["hour", "day", "week", "month", "year"], case_sensitive=False),
    default=None,
    help="Relative time filter passed through to Nimble search.",
)
@click.option(
    "--max-subagents",
    type=int,
    default=None,
    help="Maximum number of WSA subagents to execute in parallel.",
)
@click.option(
    "--output-format",
    default="plain_text",
    show_default=True,
    help="Nimbleway output format.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_search(
    query: str,
    country: str | None,
    max_results: int,
    search_depth: str,
    fast_alias: bool,
    focus: str | None,
    content_types: tuple[str, ...],
    include_domains: tuple[str, ...],
    exclude_domains: tuple[str, ...],
    include_answer: bool,
    locale: str | None,
    start_date: str | None,
    end_date: str | None,
    time_range: str | None,
    max_subagents: int | None,
    output_format: str,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Run a Nimbleway web search."""
    normalized_search_depth = str(search_depth or "fast").lower()
    if fast_alias and normalized_search_depth != "fast":
        raise click.ClickException("Use either --fast or --search-depth, not both.")
    if fast_alias:
        normalized_search_depth = "fast"

    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.search(
            query=query,
            country=country,
            max_results=max_results,
            search_depth=normalized_search_depth,
            focus=focus,
            content_type=list(content_types) or None,
            include_domains=list(include_domains) or None,
            exclude_domains=list(exclude_domains) or None,
            locale=locale,
            start_date=start_date,
            end_date=end_date,
            time_range=time_range,
            max_subagents=max_subagents,
            include_answer=include_answer,
            output_format=output_format,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    request_id = response.get("request_id")
    total_results = response.get("total_results")
    if request_id:
        click.echo(f"Request ID: {request_id}")
    if total_results is not None:
        click.echo(f"Total results: {total_results}")

    results = response.get("results") or []
    if not results:
        click.echo("No results.")
        return

    for idx, item in enumerate(results, start=1):
        title = item.get("title") or item.get("name") or "(untitled)"
        url = item.get("url") or item.get("link") or ""
        snippet = item.get("description") or item.get("snippet") or item.get("content") or ""
        click.echo(f"{idx}. {title}")
        if url:
            click.echo(f"   {url}")
        if snippet:
            click.echo(f"   {snippet}")


@nimbleway.command("map")
@_nimble_output_options
@click.option("--url", required=True, help="URL to map.")
@click.option("--country", default=None, help="Optional ISO country code.")
@click.option(
    "--domain-filter",
    type=click.Choice(["domain", "subdomain", "all"], case_sensitive=False),
    default=None,
    help="Scope of domain inclusion for discovered links.",
)
@click.option("--limit", type=int, default=None, help="Maximum number of links to return.")
@click.option("--locale", default=None, help="Optional locale for browser language and region.")
@click.option(
    "--sitemap",
    type=click.Choice(["skip", "include", "only"], case_sensitive=False),
    default=None,
    help="Whether to include sitemap discovery.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_map(
    url: str,
    country: str | None,
    domain_filter: str | None,
    limit: int | None,
    locale: str | None,
    sitemap: str | None,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Discover site links through Nimbleway's map endpoint."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.map(
            url=url,
            country=country,
            domain_filter=domain_filter,
            limit=limit,
            locale=locale,
            sitemap=sitemap,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Success: {response.get('success')}")
    if response.get("task_id"):
        click.echo(f"Task ID: {response['task_id']}")

    links = response.get("links") or []
    if not links:
        click.echo("No links returned.")
        return

    for idx, link in enumerate(links, start=1):
        if not isinstance(link, Mapping):
            click.echo(f"{idx}. {link}")
            continue
        title = link.get("title") or "(untitled)"
        target_url = link.get("url") or ""
        description = link.get("description") or ""
        click.echo(f"{idx}. {title}")
        if target_url:
            click.echo(f"   {target_url}")
        if description:
            click.echo(f"   {description}")


@nimbleway.group("tasks")
def nimbleway_tasks() -> None:
    """Inspect Nimbleway task state and results."""


@nimbleway_tasks.command("list")
@_nimble_output_options
@click.option("--cursor", default=None, help="Pagination cursor from a previous response.")
@click.option("--limit", type=int, default=None, help="Maximum number of tasks to return.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_tasks_list(
    cursor: str | None,
    limit: int | None,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """List Nimbleway tasks for the authenticated account."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.tasks_list(cursor=cursor, limit=limit)
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    pagination = response.get("pagination") or {}
    click.echo(f"Total: {pagination.get('total') if isinstance(pagination, Mapping) else 'unknown'}")
    if isinstance(pagination, Mapping):
        click.echo(f"Has next: {pagination.get('has_next')}")
        if pagination.get("next_cursor"):
            click.echo(f"Next cursor: {pagination['next_cursor']}")

    tasks = response.get("data") or []
    if not tasks:
        click.echo("No tasks returned.")
        return

    for task in tasks:
        if not isinstance(task, Mapping):
            click.echo(str(task))
            continue
        task_id = task.get("id") or "unknown"
        state = task.get("state") or "unknown"
        api_type = task.get("api_type") or "unknown"
        created_at = task.get("created_at") or "unknown"
        click.echo(f"{task_id} [{state}] {api_type} created={created_at}")


@nimbleway_tasks.command("get")
@_nimble_output_options
@click.option("--task-id", required=True, help="Task identifier.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_tasks_get(
    task_id: str,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Retrieve task details."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.task_get(task_id)
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    task = response.get("task") or {}
    if not isinstance(task, Mapping):
        click.echo(json.dumps(response, indent=2))
        return
    _print_nimble_task_summary(task)


@nimbleway_tasks.command("results")
@_nimble_output_options
@click.option("--task-id", required=True, help="Task identifier.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_tasks_results(
    task_id: str,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Retrieve completed task results."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.task_results(task_id)
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Task ID: {task_id}")
    if isinstance(response, Mapping):
        click.echo(json.dumps(response, indent=2))
        return
    click.echo(str(response))


@nimbleway.group("agent")
def nimbleway_agent() -> None:
    """Inspect available Nimbleway agent templates."""


@nimbleway_agent.command("list")
@_nimble_output_options
@click.option("--limit", type=int, default=None, help="Maximum number of templates to return.")
@click.option(
    "--managed-by",
    type=click.Choice(["nimble", "community"], case_sensitive=False),
    default=None,
    help="Filter templates by attribution.",
)
@click.option("--offset", type=int, default=None, help="Pagination offset.")
@click.option(
    "--privacy",
    type=click.Choice(["public", "private", "all"], case_sensitive=False),
    default=None,
    help="Filter templates by privacy level.",
)
@click.option("--search", default=None, help="Search templates by name or description.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_agent_list(
    limit: int | None,
    managed_by: str | None,
    offset: int | None,
    privacy: str | None,
    search: str | None,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """List Nimbleway agent templates."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.agent_list(
            limit=limit,
            managed_by=managed_by,
            offset=offset,
            privacy=privacy,
            search=search,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    if not response:
        click.echo("No agent templates returned.")
        return

    for item in response:
        if not isinstance(item, Mapping):
            click.echo(str(item))
            continue
        display_name = item.get("display_name") or item.get("name") or "(unnamed)"
        template_name = item.get("name") or "unknown"
        privacy_label = "public" if item.get("is_public") else "private"
        summary = f"{template_name} [{privacy_label}]"
        if display_name != template_name:
            summary = f"{summary} {display_name}"
        if item.get("managed_by"):
            summary = f"{summary} managed_by={item['managed_by']}"
        click.echo(summary)
        detail_parts: list[str] = []
        if item.get("domain"):
            detail_parts.append(f"domain={item['domain']}")
        if item.get("vertical"):
            detail_parts.append(f"vertical={item['vertical']}")
        if item.get("entity_type"):
            detail_parts.append(f"entity_type={item['entity_type']}")
        if detail_parts:
            click.echo(f"   {' '.join(detail_parts)}")
        if item.get("description"):
            click.echo(f"   {item['description']}")


@nimbleway_agent.command("get")
@_nimble_output_options
@click.option("--template-name", required=True, help="Agent template name.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_agent_get(
    template_name: str,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Retrieve Nimbleway agent template details."""
    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.agent_get(template_name)
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    _print_nimble_agent_summary(response)

    feature_flags = response.get("feature_flags") or {}
    if isinstance(feature_flags, Mapping) and feature_flags:
        click.echo("Feature Flags:")
        for key, label in (
            ("is_localization_supported", "Localization"),
            ("is_pagination_supported", "Pagination"),
        ):
            value = feature_flags.get(key)
            if value is not None:
                click.echo(f"  {label}: {value}")

    input_properties = response.get("input_properties") or []
    if input_properties:
        click.echo("Input Properties:")
        for prop in input_properties:
            if not isinstance(prop, Mapping):
                click.echo(f"  {prop}")
                continue
            prop_name = prop.get("name") or "(unnamed)"
            prop_type = prop.get("type") or "unknown"
            required_label = "required" if prop.get("required") else "optional"
            click.echo(f"  {prop_name} ({prop_type}, {required_label})")
            if prop.get("description"):
                click.echo(f"    {prop['description']}")

    output_schema = response.get("output_schema")
    if output_schema not in (None, {}, []):
        click.echo("Output Schema:")
        click.echo(json.dumps(output_schema, indent=2))


@nimbleway_agent.command("run")
@_nimble_output_options
@click.option("--agent", "agent_name", required=True, help="Agent template name.")
@click.option(
    "--params",
    "params_items",
    multiple=True,
    help="Agent input parameter as key=value. Repeat as needed.",
)
@click.option(
    "--params-file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="JSON file containing the full agent params object.",
)
@click.option("--localization", is_flag=True, help="Enable localized execution when supported.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_agent_run(
    agent_name: str,
    params_items: tuple[str, ...],
    params_file: Path | None,
    localization: bool,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Execute a Nimbleway agent template synchronously."""
    params = _parse_nimble_param_items(params_items, params_file=params_file)
    if not params:
        raise click.ClickException("Provide at least one --params entry or --params-file.")

    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.agent_run(
            agent=agent_name,
            params=params,
            localization=localization,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Status: {response.get('status') or 'unknown'}")
    metadata = response.get("metadata") or {}
    if isinstance(metadata, Mapping):
        if metadata.get("agent"):
            click.echo(f"Agent: {metadata['agent']}")
        if metadata.get("query_time"):
            click.echo(f"Query Time: {metadata['query_time']}")
        if metadata.get("query_duration") is not None:
            click.echo(f"Query Duration: {metadata['query_duration']}")
    if response.get("status_code") is not None:
        click.echo(f"HTTP: {response['status_code']}")
    if response.get("task_id"):
        click.echo(f"Task ID: {response['task_id']}")
    if response.get("url"):
        click.echo(f"URL: {response['url']}")
    warnings_list = response.get("warnings") or []
    if warnings_list:
        click.echo(f"Warnings: {'; '.join(str(item) for item in warnings_list)}")

    primary_payload = _get_nimble_agent_run_primary_payload(response)
    if primary_payload in (None, {}, []):
        return
    if isinstance(primary_payload, str):
        click.echo(primary_payload)
        return
    click.echo(json.dumps(primary_payload, indent=2))


@nimbleway_agent.command("run-async")
@_nimble_output_options
@click.option("--agent", "agent_name", required=True, help="Agent template name.")
@click.option(
    "--params",
    "params_items",
    multiple=True,
    help="Agent input parameter as key=value. Repeat as needed.",
)
@click.option(
    "--params-file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="JSON file containing the full agent params object.",
)
@click.option("--callback-url", default=None, help="URL to notify when the async run completes.")
@click.option("--localization", is_flag=True, help="Enable localized execution when supported.")
@click.option("--storage-compress", is_flag=True, help="Compress stored async output.")
@click.option("--storage-object-name", default=None, help="Custom object name for stored results.")
@click.option("--storage-type", default=None, help="Storage backend type.")
@click.option("--storage-url", default=None, help="Storage destination URL.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def nimbleway_agent_run_async(
    agent_name: str,
    params_items: tuple[str, ...],
    params_file: Path | None,
    callback_url: str | None,
    localization: bool,
    storage_compress: bool,
    storage_object_name: str | None,
    storage_type: str | None,
    storage_url: str | None,
    profile: str,
    display_transform: str | None,
    display_format: str,
    json_output: bool,
) -> None:
    """Execute a Nimbleway agent template asynchronously."""
    params = _parse_nimble_param_items(params_items, params_file=params_file)
    if not params:
        raise click.ClickException("Provide at least one --params entry or --params-file.")

    nimble_mod = _get_nimbleway_helpers()
    try:
        client = nimble_mod.NimblewayClient(profile=profile)
        response = client.agent_run_async(
            agent=agent_name,
            params=params,
            callback_url=callback_url,
            localization=localization,
            storage_compress=storage_compress,
            storage_object_name=storage_object_name,
            storage_type=storage_type,
            storage_url=storage_url,
        )
    except nimble_mod.NimblewayError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    resolved_display_format = _resolve_nimble_display_format(json_output, display_format)
    if resolved_display_format != "pretty" or display_transform:
        _emit_nimble_output(
            response,
            display_format=resolved_display_format,
            display_transform=display_transform,
        )
        return

    click.echo(f"Status: {response.get('status') or 'unknown'}")
    task = response.get("task") or {}
    if not isinstance(task, Mapping):
        click.echo(json.dumps(response, indent=2))
        return
    _print_nimble_task_summary(task)


@cli.command("enrich")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV.",
)
@click.option(
    "--missing",
    required=False,
    default=None,
    help="Comma-separated list of fields to fill. Not required if --config is provided.",
)
@click.option(
    "--source",
    required=False,
    default=None,
    help="Provider(s) to use (comma-separated): apollo, zoominfo, apify, foundrygraph, pdl, clearbit. Overrides config provider order.",
)
@click.option(
    "--company-column",
    default="company",
    show_default=True,
    help="Column containing company names.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output CSV path.",
)
@click.option(
    "--overwrite",
    is_flag=True,
    help="Allow overwriting existing output file.",
)
@click.option(
    "--waterfall",
    is_flag=True,
    help="Try providers in order until fields are filled. Implied by --config.",
)
@click.option(
    "--config",
    "config_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="YAML/JSON config for waterfall settings (implies --waterfall). See config/waterfall_defaults.yaml.",
)
@click.option(
    "--ignore-cache",
    is_flag=True,
    help="Skip cache lookups, force fresh provider calls.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview what would happen without making changes.",
)
@click.option(
    "--estimate",
    is_flag=True,
    help="Show cost estimate only (no provider calls, no files written).",
)
@click.option(
    "--max-rows",
    type=int,
    default=None,
    help="Limit number of rows to process.",
)
@click.option(
    "--sample",
    type=int,
    default=None,
    help="Randomly sample N rows from input.",
)
@click.option(
    "--fail-fast",
    is_flag=True,
    help="Stop on first provider error.",
)
@click.option(
    "--cache-ttl",
    type=int,
    default=86400,
    show_default=True,
    help="Cache TTL in seconds (for legacy mode without --config).",
)
@click.option(
    "--budget",
    type=float,
    default=None,
    help="Maximum spend in USD. Stops enrichment when budget is reached.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def enrich_command(
    csv_path: Path,
    missing: str | None,
    source: str | None,
    company_column: str,
    output_path: Path,
    overwrite: bool,
    waterfall: bool,
    config_path: Path | None,
    ignore_cache: bool,
    dry_run: bool,
    estimate: bool,
    max_rows: int | None,
    sample: int | None,
    fail_fast: bool,
    cache_ttl: int,
    budget: float | None,
    json_summary: bool,
) -> None:
    """
    Enrich CSV using Apollo/ZoomInfo/Apify/FoundryGraph providers with caching.

    \b
    Waterfall Enrichment Options:
      --waterfall          Enable multi-provider waterfall (try providers until fields filled)
      --config PATH        YAML/JSON config for waterfall settings (implies --waterfall)
                           See config/waterfall_defaults.yaml for template
      --ignore-cache       Skip cache lookups, force fresh provider calls
      --estimate           Show projected cost only (no provider calls, no files written)

    \b
    When using --config:
      - Provider order controlled by 'priority' field (lower = earlier)
      - Stop condition evaluated per-row; enrichment stops when expression is true
      - CLI --source overrides config provider order
      - Metadata columns (_enriched_by, _fields_filled) always written to output CSV
      - Provenance sidecar written to <output>.provenance.jsonl

    \b
    Example:
      g-gremlin enrich --csv leads.csv --config config/waterfall_contact_activation.yaml --output enriched.csv

    Note: This is a licensed command. Requires a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command - check for valid license
    _check_license()

    if output_path.exists() and not overwrite:
        click.echo(f"Output file {output_path} exists. Use --overwrite to replace.")
        sys.exit(1)

    # Determine mode: config-driven waterfall vs. legacy
    use_waterfall_runner = config_path is not None or waterfall or estimate

    if use_waterfall_runner:
        # Use new waterfall runner
        _run_waterfall_enrichment(
            csv_path=csv_path,
            output_path=output_path,
            config_path=config_path,
            source_override=source,
            company_column=company_column,
            ignore_cache=ignore_cache,
            dry_run=dry_run,
            estimate=estimate,
            max_rows=max_rows,
            sample=sample,
            fail_fast=fail_fast,
            budget_usd=budget,
            json_summary=json_summary,
        )
    else:
        # Legacy mode - requires --source and --missing
        if not source or not missing:
            click.echo("Error: --source and --missing are required without --config or --waterfall.")
            click.echo("Hint: Use --config for advanced waterfall enrichment.")
            sys.exit(1)

        providers = [p.strip().lower() for p in source.split(",") if p.strip()]
        missing_fields = [f.strip() for f in missing.split(",") if f.strip()]

        rows_total, rows_enriched, _ = (0, 0, [])
        try:
            rows_total, rows_enriched, _ = _get_enrich_helpers().enrich_csv(
                csv_path,
                missing_fields,
                providers,
                company_column=company_column,
                cache_ttl=cache_ttl,
                waterfall=False,  # Legacy mode doesn't use waterfall
                output_path=output_path,
            )
        except Exception as exc:
            click.echo(f"Enrichment failed: {exc}")
            sys.exit(1)

        click.echo(
            f"Enrichment complete. Processed {rows_total} rows; filled missing for {rows_enriched} rows. Output: {output_path}"
        )

        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin enrich", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={
                    "rows_processed": rows_total,
                    "rows_enriched": rows_enriched,
                    "missing_fields": missing_fields,
                    "providers": providers,
                    "output": str(output_path),
                    "waterfall": False,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))


def _run_waterfall_enrichment(
    csv_path: Path,
    output_path: Path,
    config_path: Path | None,
    source_override: str | None,
    company_column: str,
    ignore_cache: bool,
    dry_run: bool,
    estimate: bool,
    max_rows: int | None,
    sample: int | None,
    fail_fast: bool,
    budget_usd: float | None,
    json_summary: bool,
) -> None:
    """Run waterfall enrichment using the new runner."""
    import csv as csv_module
    from g_gremlin.waterfall import (
        WaterfallRunner,
        SafetyFlags,
        load_config,
        create_default_config,
        merge_cli_overrides,
        ConfigError,
        WaterfallError,
    )

    # Load or create config
    try:
        if config_path:
            config = load_config(config_path)
            click.echo(f"Loaded waterfall config: {config.name} from {config_path}")
        else:
            # Create default config based on source override
            providers = None
            if source_override:
                providers = [p.strip().lower() for p in source_override.split(",") if p.strip()]
            config = create_default_config(providers)
            click.echo("Using default waterfall configuration")
    except ConfigError as e:
        click.echo(f"Configuration error: {e}")
        sys.exit(1)

    # Apply CLI overrides
    if source_override:
        source_list = [p.strip().lower() for p in source_override.split(",") if p.strip()]
        config = merge_cli_overrides(config, source_override=source_list)
        click.echo(f"Provider order overridden: {source_list}")

    # Create safety flags
    safety = SafetyFlags(
        dry_run=dry_run,
        max_rows=max_rows,
        sample=sample,
        fail_fast=fail_fast,
        ignore_cache=ignore_cache,
        budget_usd=budget_usd,
    )

    # Count rows for cost estimation
    with csv_path.open(newline="", encoding="utf-8-sig") as f:
        input_row_count = sum(1 for _ in csv_module.DictReader(f))

    # Apply max_rows and sample limits to row count for estimation
    row_count = input_row_count
    if sample:
        row_count = min(sample, row_count)
    if max_rows:
        row_count = min(max_rows, row_count)

    # Run waterfall
    try:
        runner = WaterfallRunner(config, safety, company_column=company_column)

        def _print_cost_estimate(prefix: str, estimate_obj, effective_rows: int) -> None:
            label = f"[{prefix}] " if prefix else ""
            click.echo(f"\n{label}Cost estimate for {effective_rows} rows:")
            if effective_rows != input_row_count:
                click.echo(f"  (from {input_row_count} input rows after sample/max-rows)")
            click.echo(f"  {estimate_obj}")
            click.echo(f"  ({estimate_obj.cache_assumption})")
            click.echo("")
            for provider, stats in estimate_obj.provider_breakdown.items():
                if stats["max_cost"] > 0:
                    click.echo(
                        f"  {provider}: ${stats['min_cost']:.2f}-${stats['max_cost']:.2f} "
                        f"({stats['min_calls']}-{stats['max_calls']} calls @ ${stats['cost_per_call']:.3f}/call)"
                    )
            click.echo("")

        if estimate:
            estimate_obj = runner.estimate_cost(row_count)
            _print_cost_estimate("ESTIMATE", estimate_obj, row_count)
            if json_summary:
                payload = {
                    "input_rows": input_row_count,
                    "estimated_rows": row_count,
                    "min_cost_usd": estimate_obj.min_cost_usd,
                    "max_cost_usd": estimate_obj.max_cost_usd,
                    "avg_cost_per_row": estimate_obj.avg_cost_per_row,
                    "provider_breakdown": estimate_obj.provider_breakdown,
                    "cache_assumption": estimate_obj.cache_assumption,
                }
                click.echo(json.dumps(payload, indent=2))
            return

        # Show cost estimate for dry runs
        if dry_run:
            estimate_obj = runner.estimate_cost(row_count)
            _print_cost_estimate("DRY RUN", estimate_obj, row_count)

        summary = runner.run(csv_path, output_path)
    except WaterfallError as e:
        click.echo(f"Waterfall enrichment failed: {e}")
        sys.exit(1)

    # Output results
    if dry_run:
        click.echo("[DRY RUN] No changes made.")

    click.echo(
        f"Waterfall complete: {summary.rows_enriched}/{summary.rows_total} rows enriched, "
        f"{summary.rows_complete} complete, {summary.rows_failed} failed"
    )
    click.echo(f"Cache: {summary.cache_stats.get('hits', 0)} hits, {summary.cache_stats.get('misses', 0)} misses")
    click.echo(f"Estimated cost: ${summary.estimated_cost_usd:.2f}")
    click.echo(f"Output: {output_path}")

    if config.output.provenance_mode == "sidecar" and not dry_run:
        provenance_path = output_path.with_suffix(output_path.suffix + ".provenance.jsonl")
        click.echo(f"Provenance: {provenance_path}")

    if json_summary:
        agentic = summary.to_agentic_result(
            command="g-gremlin enrich",
            correlation_id=str(uuid.uuid4()),
            dry_run=dry_run,
        )
        click.echo(json.dumps(agentic, indent=2))


@cli.command("enrich-doctor")
@click.option(
    "--config",
    "config_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="YAML/JSON config to check providers from. If not specified, checks all known providers.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit JSON output.",
)
def enrich_doctor(config_path: Path | None, json_summary: bool) -> None:
    """
    Check provider health and credential status for enrichment.

    Shows which providers are available, credential status, and remaining credits/quotas.

    \b
    Example:
      g-gremlin enrich-doctor
      g-gremlin enrich-doctor --config config/waterfall_defaults.yaml
    """
    from g_gremlin.waterfall import (
        load_config,
        PROVIDER_COST_ESTIMATES,
    )

    # Load config to determine which providers to check
    if config_path:
        config = load_config(config_path)
        providers_to_check = [p.name for p in config.providers if p.enabled]
    else:
        # Check all known providers
        providers_to_check = list(PROVIDER_COST_ESTIMATES.keys())
        providers_to_check = [p for p in providers_to_check if p != "cache"]

    click.echo("Provider Health Check\n" + "=" * 40)

    results = {}
    all_healthy = True

    for provider_name in providers_to_check:
        status = _check_provider_health(provider_name)
        results[provider_name] = status

        # Format output
        if status["available"]:
            icon = click.style("✓", fg="green")
            details = status.get("details", "")
        else:
            icon = click.style("✗", fg="red")
            details = status.get("error", "unavailable")
            all_healthy = False

        cost = PROVIDER_COST_ESTIMATES.get(provider_name, 0.0)
        cost_str = f"(${cost:.3f}/call)" if cost > 0 else "(free)"

        click.echo(f"{icon} {provider_name}: {details} {cost_str}")

    click.echo("")
    if all_healthy:
        click.echo(click.style("All providers healthy!", fg="green"))
    else:
        click.echo(click.style("Some providers unavailable. Check credentials.", fg="yellow"))

    if json_summary:
        click.echo("\n" + json.dumps(results, indent=2))


def _check_provider_health(provider_name: str) -> dict:
    """Check health of a single provider."""
    result = {"provider": provider_name, "available": False, "error": None, "details": ""}

    try:
        if provider_name == "apollo":
            # Check Apollo credentials
            from g_gremlin.apollo import get_apollo_api_key
            try:
                key = get_apollo_api_key()
                if key:
                    result["available"] = True
                    result["details"] = f"API key configured ({key[:8]}...)"
                else:
                    result["error"] = "No API key found"
            except Exception as e:
                result["error"] = str(e)

        elif provider_name == "zoominfo":
            # Check ZoomInfo OAuth credentials
            try:
                from g_gremlin.zoominfo import ZoomInfoClient
                # Just check if we can instantiate (doesn't call API)
                client = ZoomInfoClient()
                if client._client_id and client._client_secret:
                    result["available"] = True
                    result["details"] = "OAuth credentials configured"
                else:
                    result["error"] = "Missing client_id or client_secret"
            except Exception as e:
                result["error"] = str(e)

        elif provider_name == "apify":
            # Check Apify credentials
            try:
                from g_gremlin.apify import get_apify_token
                token = get_apify_token()
                if token:
                    result["available"] = True
                    result["details"] = f"API token configured ({token[:8]}...)"
                else:
                    result["error"] = "No API token found"
            except Exception as e:
                result["error"] = str(e)

        elif provider_name == "foundrygraph":
            try:
                from g_gremlin.foundrygraph import FoundryGraphClient

                fg = FoundryGraphClient()
                if fg.ping():
                    result["available"] = True
                    backend = fg._get_backend()
                    result["details"] = f"{backend} backend reachable"
                else:
                    result["error"] = "FoundryGraph unreachable"
            except Exception as e:
                result["error"] = str(e)

        else:
            result["error"] = f"Unknown provider: {provider_name}"

    except ImportError as e:
        result["error"] = f"Module not available: {e}"

    return result


@cli.group()
def apollo() -> None:
    """
    Apollo FoundryOps bridge commands (backend-held tokens).

    These commands authenticate the FoundryOps bridge, not Apollo directly.

    AI/automation flow:
      1) g-gremlin auth login
      2) g-gremlin auth explain apollo --surface bridge --json
      3) g-gremlin apollo auth --workspace-id ...
    """
    _check_license()


@apollo.command("setup")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--show-redirect-urls/--no-show-redirect-urls",
    default=True,
    show_default=True,
    help="Print the redirect URL to register in Apollo.",
)
def apollo_setup(api_base: str, show_redirect_urls: bool) -> None:
    """
    Print Apollo OAuth setup details for the current API base.
    """
    base = _apollo_api_base(api_base)
    redirect_url = f"{base}/api/v1/oauth/apollo/callback"

    click.echo("Apollo OAuth setup")
    click.echo("-" * 20)
    if show_redirect_urls:
        click.echo("Register this redirect URL in Apollo:")
        click.echo(f"  {redirect_url}")
        click.echo("")
    click.echo("Required env vars for the backend:")
    click.echo("  APOLLO_CLIENT_ID")
    click.echo("  APOLLO_CLIENT_SECRET")
    click.echo("")
    click.echo("Optional overrides (for tunnels/local dev):")
    click.echo("  APOLLO_REDIRECT_URL")


@apollo.command("auth")
@click.option(
    "--workspace-id",
    envvar="FOUNDRYMATCH_WORKSPACE_ID",
    required=True,
    help="Workspace/org ID to connect Apollo for.",
)
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--no-browser",
    is_flag=True,
    help="Do not open the browser automatically.",
)
def apollo_auth(workspace_id: str, api_base: str, no_browser: bool) -> None:
    """
    Authenticate the Apollo FoundryOps bridge via OAuth (browser flow).

    Requires a workspace ID (FOUNDRYMATCH_WORKSPACE_ID) for the connection.
    Use `g-gremlin apollo setup --show-redirect-urls` for app registration.
    """
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    session_id = str(uuid.uuid4())
    base = _apollo_api_base(api_base)

    try:
        resp = requests.get(
            f"{base}/api/v1/oauth/apollo/cli/authorize",
            params={"session_id": session_id, "workspace_id": workspace_id},
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error starting Apollo auth: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code != 200:
        click.echo(
            f"Error starting Apollo auth ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        auth_url = resp.json().get("auth_url")
    except Exception:
        auth_url = None

    if not auth_url:
        click.echo("Error: Missing auth_url from server.", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo("Opening browser for Apollo authorization...")
    click.echo(f"\nIf browser doesn't open, visit:\n{auth_url}\n")
    if not no_browser:
        try:
            webbrowser.open(auth_url)
        except Exception:
            pass

    for _ in range(60):
        time.sleep(5)
        try:
            poll = requests.get(
                f"{base}/api/v1/oauth/apollo/cli/poll",
                params={"session_id": session_id},
                timeout=15,
            )
        except Exception as exc:
            click.echo(f"Error polling Apollo auth: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if poll.status_code != 200:
            click.echo(
                f"Poll failed ({poll.status_code}): {poll.text[:300]}",
                err=True,
            )
            sys.exit(EXIT_REMOTE_ERROR)

        try:
            payload = poll.json()
        except Exception:
            payload = {}

        status = payload.get("status")
        if status == "complete":
            click.echo(click.style("✓ ", fg="green") + "Apollo authenticated.")
            return
        if status == "error":
            message = payload.get("message") or "Authorization failed."
            click.echo(click.style("✗ ", fg="red") + message, err=True)
            sys.exit(EXIT_ISSUES)

        click.echo(".", nl=False)

    click.echo("")
    click.echo(
        click.style("✗ ", fg="red") + "Timeout waiting for Apollo authorization.",
        err=True,
    )
    sys.exit(EXIT_ISSUES)


@apollo.command("doctor")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
def apollo_doctor(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
) -> None:
    """
    Verify the Apollo FoundryOps bridge connection (server-side token check).

    Use --dev (or G_GREMLIN_DEV_MODE=1) for local development.
    """
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    headers = _resolve_apollo_auth_headers(auth_token, api_key, dev=dev)
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "use --dev for local dev, or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    base = _apollo_api_base(api_base)
    try:
        resp = requests.get(
            f"{base}/api/v1/oauth/apollo/cli/token",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error checking Apollo connection: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code == 200:
        try:
            payload = resp.json()
        except Exception:
            payload = {}
        expires_at = payload.get("expires_at")
        message = "Apollo connection OK."
        if expires_at:
            message += f" Token expires at {expires_at}."
        click.echo(message)
        return

    if resp.status_code in (401, 403):
        click.echo(
            "Apollo not connected or unauthorized. Run 'g-gremlin apollo auth'.",
            err=True,
        )
        sys.exit(EXIT_ISSUES)

    click.echo(
        f"Error checking Apollo connection ({resp.status_code}): {resp.text[:300]}",
        err=True,
    )
    if resp.status_code >= 500:
        sys.exit(EXIT_REMOTE_ERROR)
    sys.exit(EXIT_ISSUES)


@apollo.command("logout")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--yes",
    is_flag=True,
    help="Skip confirmation prompt.",
)
def apollo_logout(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    yes: bool,
) -> None:
    """
    Disconnect the Apollo FoundryOps bridge for the current workspace.

    Use --dev (or G_GREMLIN_DEV_MODE=1) for local development.
    """
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    headers = _resolve_apollo_auth_headers(auth_token, api_key, dev=dev)
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "use --dev for local dev, or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    if not yes and sys.stdin.isatty():
        if not click.confirm("Disconnect Apollo for this workspace?", default=False):
            click.echo("Aborted.")
            return

    base = _apollo_api_base(api_base)
    try:
        resp = requests.delete(
            f"{base}/api/v1/oauth/apollo",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error disconnecting Apollo: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code in (200, 204):
        try:
            payload = resp.json()
        except Exception:
            payload = {}
        message = payload.get("message") or "Apollo disconnected."
        click.echo(message)
        return

    click.echo(
        f"Error disconnecting Apollo ({resp.status_code}): {resp.text[:300]}",
        err=True,
    )
    if resp.status_code >= 500:
        sys.exit(EXIT_REMOTE_ERROR)
    sys.exit(EXIT_ISSUES)


@cli.group()
def zoominfo() -> None:
    """
    ZoomInfo integration commands.

    OAuth is handled through the FoundryOps backend and cached locally.
    """
    _check_license()


@zoominfo.command("setup")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
def zoominfo_setup(api_base: str) -> None:
    """
    Print ZoomInfo OAuth setup details for the current API base.
    """
    base = _apollo_api_base(api_base)
    redirect_url = f"{base}/api/v2/integrations/zoominfo/callback"

    click.echo("ZoomInfo OAuth setup")
    click.echo("-" * 22)
    click.echo("Register this redirect URL in ZoomInfo DevPortal:")
    click.echo(f"  {redirect_url}")
    click.echo("")
    click.echo("Required env vars for the backend:")
    click.echo("  ZOOMINFO_CLIENT_ID")
    click.echo("  ZOOMINFO_CLIENT_SECRET")
    click.echo("  ZOOMINFO_OAUTH_REDIRECT_URI")
    click.echo("")
    click.echo("Optional overrides:")
    click.echo("  ZOOMINFO_OAUTH_SCOPES")
    click.echo("  ZOOMINFO_OAUTH_AUTHORIZE_URL")
    click.echo("  ZOOMINFO_OAUTH_TOKEN_URL")
    click.echo("  ZOOMINFO_OAUTH_USERINFO_URL")
    click.echo("  ZOOMINFO_OAUTH_REVOKE_URL")


@zoominfo.group("oauth")
def zoominfo_oauth() -> None:
    """
    ZoomInfo OAuth helpers (recommended).

    These commands use FoundryOps bridge auth to open the browser flow, then
    store a server-managed ZoomInfo runtime session.

    \b
    AI/automation flow:
      1) g-gremlin auth login
      2) g-gremlin auth explain zoominfo --surface bridge --json
      3) g-gremlin zoominfo oauth connect
      4) g-gremlin auth explain zoominfo --surface runtime --json
    """


@zoominfo_oauth.command("connect")
@click.option(
    "--workspace-id",
    envvar="FOUNDRYMATCH_WORKSPACE_ID",
    default=None,
    help="Workspace/org ID to connect ZoomInfo for.",
)
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--no-browser",
    is_flag=True,
    help="Do not open browser automatically.",
)
def zoominfo_oauth_connect(
    workspace_id: str | None,
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    no_browser: bool,
) -> None:
    """Start browser OAuth for ZoomInfo and wait for completion."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="zoominfo",
        auth_token=auth_token,
        api_key=api_key,
        workspace_id=workspace_id,
        api_base=api_base,
        dev=dev,
    )
    base = resolution.api_base or _apollo_api_base(api_base)
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    session_id = str(uuid.uuid4())
    try:
        authorize_params = {"session_id": session_id}
        if resolution.workspace_id:
            authorize_params["workspace_id"] = resolution.workspace_id
        resp = requests.get(
            f"{base}/api/v2/integrations/zoominfo/cli/authorize",
            params=authorize_params,
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error starting ZoomInfo OAuth: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code != 200:
        click.echo(
            f"Error starting ZoomInfo OAuth ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code in (401, 403):
            click.echo("Tip: re-authenticate with 'g-gremlin auth login'.", err=True)
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        authorize_payload = resp.json()
    except Exception:
        authorize_payload = {}
    if not isinstance(authorize_payload, dict):
        authorize_payload = {}

    auth_url = authorize_payload.get("auth_url")
    authorize_workspace_id = str(authorize_payload.get("workspace_id") or resolution.workspace_id or "").strip()

    if not auth_url:
        click.echo("Error: Missing auth_url from server.", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo("Opening browser for ZoomInfo authorization...")
    click.echo(f"\nIf browser doesn't open, visit:\n{auth_url}\n")
    if not no_browser:
        try:
            webbrowser.open(auth_url)
        except Exception:
            pass

    for _ in range(60):
        time.sleep(5)
        try:
            poll = requests.get(
                f"{base}/api/v2/integrations/zoominfo/cli/poll",
                params={"session_id": session_id},
                headers=headers,
                timeout=15,
            )
        except Exception as exc:
            click.echo(f"Error polling ZoomInfo OAuth: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if poll.status_code != 200:
            click.echo(f"Poll failed ({poll.status_code}): {poll.text[:300]}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        try:
            payload = poll.json()
        except Exception:
            payload = {}

        status = payload.get("status")
        if status == "complete":
            workspace_for_token = payload.get("workspace_id") or authorize_workspace_id or resolution.workspace_id
            token_params = {"session_id": session_id}
            if workspace_for_token:
                token_params["workspace_id"] = workspace_for_token
            try:
                token_resp = requests.get(
                    f"{base}/api/v2/integrations/zoominfo/cli/token",
                    params=token_params,
                    headers=headers,
                    timeout=20,
                )
            except Exception as exc:
                click.echo(
                    click.style("X ", fg="red")
                    + f"ZoomInfo OAuth finished, but failed to retrieve local token: {exc}",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

            if token_resp.status_code != 200:
                click.echo(
                    click.style("X ", fg="red")
                    + (
                        "ZoomInfo OAuth finished, but failed to retrieve local token "
                        f"({token_resp.status_code}): {token_resp.text[:300]}"
                    ),
                    err=True,
                )
                sys.exit(EXIT_ISSUES)

            try:
                token_payload = token_resp.json()
            except Exception:
                token_payload = {}

            access_token = str(token_payload.get("access_token") or "")
            if not access_token:
                click.echo(
                    click.style("X ", fg="red")
                    + "ZoomInfo OAuth finished, but token payload was invalid.",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

            zi_mod = _get_zoominfo_helpers()
            config = zi_mod._load_config()
            config["zoominfo_access_token"] = access_token
            config["zoominfo_token_expires_at"] = token_payload.get("expires_at") or (time.time() + 3600)
            config["zoominfo_auth_mode"] = "server"
            config["zoominfo_foundryops_api_base"] = base
            if workspace_for_token:
                config["zoominfo_workspace_id"] = workspace_for_token
            if token_payload.get("org_id") is not None:
                config["zoominfo_org_id"] = token_payload.get("org_id")
            if token_payload.get("org_name") is not None:
                config["zoominfo_org_name"] = token_payload.get("org_name")
            config.pop("zoominfo_refresh_token", None)
            config.pop("zoominfo_client_id", None)
            config.pop("zoominfo_client_secret", None)
            zi_mod._save_config(config)

            click.echo(click.style("OK ", fg="green") + "ZoomInfo OAuth connected.")
            click.echo("Local ZoomInfo token cached (server-managed refresh enabled).")
            return
        if status == "error":
            message = payload.get("message") or "ZoomInfo authorization failed."
            click.echo(click.style("X ", fg="red") + message, err=True)
            sys.exit(EXIT_ISSUES)

        click.echo(".", nl=False)

    click.echo("")
    click.echo(click.style("X ", fg="red") + "Timeout waiting for ZoomInfo authorization.", err=True)
    sys.exit(EXIT_ISSUES)


@zoominfo_oauth.command("status")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON status output.",
)
def zoominfo_oauth_status(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    json_output: bool,
) -> None:
    """Check server-side ZoomInfo OAuth status."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="zoominfo",
        auth_token=auth_token,
        api_key=api_key,
        api_base=api_base,
        dev=dev,
    )
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    base = resolution.api_base or _apollo_api_base(api_base)
    try:
        resp = requests.get(
            f"{base}/api/v2/integrations/zoominfo/status",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error checking ZoomInfo OAuth status: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code != 200:
        click.echo(
            f"Error checking ZoomInfo OAuth status ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code in (401, 403):
            click.echo("Tip: re-authenticate with 'g-gremlin auth login'.", err=True)
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        payload = resp.json()
    except Exception:
        payload = {}

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    connected = bool(payload.get("connected"))
    status = payload.get("status") or ("connected" if connected else "disconnected")
    click.echo(f"Status: {status}")
    if payload.get("org_name") or payload.get("org_id"):
        click.echo(f"Org: {payload.get('org_name') or 'unknown'} ({payload.get('org_id') or 'n/a'})")
    if payload.get("expires_at"):
        click.echo(f"Expires: {payload.get('expires_at')}")
    if payload.get("error"):
        err = payload.get("error") or {}
        if isinstance(err, dict):
            click.echo(f"Error: {err.get('code')}: {err.get('message')}", err=True)


@zoominfo_oauth.command("disconnect")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--yes",
    is_flag=True,
    help="Skip confirmation prompt.",
)
def zoominfo_oauth_disconnect(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    yes: bool,
) -> None:
    """Disconnect ZoomInfo OAuth and clear local cached auth state."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="zoominfo",
        auth_token=auth_token,
        api_key=api_key,
        api_base=api_base,
        dev=dev,
    )
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    if not yes and sys.stdin.isatty():
        if not click.confirm("Disconnect ZoomInfo for this workspace?", default=False):
            click.echo("Aborted.")
            return

    base = resolution.api_base or _apollo_api_base(api_base)
    try:
        resp = requests.delete(
            f"{base}/api/v2/integrations/zoominfo/disconnect",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error disconnecting ZoomInfo OAuth: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code not in (200, 204):
        click.echo(
            f"Error disconnecting ZoomInfo OAuth ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    zi_mod = _get_zoominfo_helpers()
    config = zi_mod._load_config()
    for key in [
        "zoominfo_access_token",
        "zoominfo_refresh_token",
        "zoominfo_token_expires_at",
        "zoominfo_client_id",
        "zoominfo_client_secret",
        "zoominfo_auth_mode",
        "zoominfo_workspace_id",
        "zoominfo_foundryops_api_base",
        "auth_mode",
        "workspace_id",
        "foundryops_api_base",
        "zoominfo_org_id",
        "zoominfo_org_name",
    ]:
        config.pop(key, None)
    zi_mod._save_config(config)
    click.echo("ZoomInfo disconnected.")


@cli.group()
def dedup() -> None:
    """
    Deduplicate CSVs (engine-first; local fallback with --local).

    Note: Dedup commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


@dedup.command("plan")
@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--key-columns",
    required=True,
    help="Comma-separated key columns to match on.",
)
@click.option(
    "--threshold",
    type=int,
    default=80,
    show_default=True,
    help="Similarity threshold (0-100).",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output merge plan JSON.",
)
@click.option(
    "--local",
    is_flag=True,
    help="Force local dedup (non-canonical).",
)
@click.option(
    "--overwrite",
    is_flag=True,
    help="Overwrite existing output file.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def dedup_plan(
    csv_path: Path,
    key_columns: str,
    threshold: int,
    output_path: Path,
    local: bool,
    overwrite: bool,
    json_summary: bool,
) -> None:
    """
    Build a merge plan for duplicates (local fallback).
    """
    keys = [k.strip() for k in key_columns.split(",") if k.strip()]
    if not keys:
        click.echo("Error: --key-columns must include at least one column.")
        sys.exit(1)

    engine_mode = not local
    plan = None

    if engine_mode:
        try:
            plan = _get_dedup_helpers().dedup_engine(
                csv_path,
                key_columns=keys,
                threshold=threshold,
                output_path=output_path,
                overwrite=overwrite,
            )
        except FileExistsError as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(1)
        except Exception as exc:
            click.echo(f"Engine dedup failed ({exc}); falling back to --local mode.")
            engine_mode = False

    if plan is None:
        try:
            plan = _get_dedup_helpers().dedup_local(
                csv_path,
                key_columns=keys,
                threshold=threshold,
                output_path=output_path,
                overwrite=overwrite,
            )
        except FileExistsError as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(1)
        except Exception as exc:
            click.echo(f"Error building dedup plan: {exc}")
            sys.exit(1)

    click.echo(
        f"Merge plan created at {output_path}. "
        f"Clusters: {len(plan.get('clusters', []))}, Duplicates: {plan.get('stats', {}).get('duplicates_identified', 0)}."
    )

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin dedup plan", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "clusters": len(plan.get("clusters", [])),
                "duplicates_identified": plan.get("stats", {}).get("duplicates_identified", 0),
                "output": str(output_path),
                "threshold": threshold,
                "engine_mode": engine_mode,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@dedup.command("enterprise-plan")
@click.option(
    "--source",
    "source_specs",
    multiple=True,
    required=True,
    help="Repeat as ObjectType=path/to/export.csv (for example Contact=contacts.csv).",
)
@click.option(
    "--key-columns",
    default=None,
    help="Comma-separated canonical or raw columns used for scoring. Defaults depend on --profile.",
)
@click.option(
    "--blocking-keys",
    default=None,
    help="Comma-separated exact anchors used to generate candidate blocks. Defaults depend on --profile.",
)
@click.option(
    "--threshold",
    type=int,
    default=None,
    help="Similarity threshold (0-100). Defaults depend on --profile.",
)
@click.option(
    "--profile",
    type=click.Choice(["default", "b2b_person"], case_sensitive=False),
    default="default",
    show_default=True,
    help="Built-in dedupe profile that controls safer defaults for blocking and scoring.",
)
@click.option(
    "--survivor-policy",
    type=click.Choice(["crm_person", "most_complete"], case_sensitive=False),
    default="crm_person",
    show_default=True,
    help="How Gremlin should recommend the survivor record inside each cluster.",
)
@click.option(
    "--block-size-cap",
    type=int,
    default=5000,
    show_default=True,
    help="Skip oversized blocking groups beyond this many records.",
)
@click.option(
    "--limit",
    "limit_per_source",
    type=int,
    help="Cap rows loaded per source for incremental validation runs.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output enterprise merge plan JSON.",
)
@click.option(
    "--review-output",
    "review_output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional review worksheet CSV.",
)
@click.option(
    "--local",
    is_flag=True,
    help="Force local scoring instead of the matching engine.",
)
@click.option(
    "--overwrite",
    is_flag=True,
    help="Overwrite existing output files.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def dedup_enterprise_plan(
    source_specs: tuple[str, ...],
    key_columns: str | None,
    blocking_keys: str | None,
    threshold: int | None,
    profile: str,
    survivor_policy: str,
    block_size_cap: int,
    limit_per_source: int | None,
    output_path: Path,
    review_output_path: Path | None,
    local: bool,
    overwrite: bool,
    json_summary: bool,
) -> None:
    """
    Build a blocking-first enterprise dedupe plan across multiple CRM exports.
    """
    parsed_sources: list[tuple[str, Path]] = []
    for source_spec in source_specs:
        label, sep, raw_path = source_spec.partition("=")
        object_type = label.strip()
        csv_path = Path(raw_path.strip()).expanduser()
        if not sep or not object_type or not raw_path.strip():
            click.echo(
                f"Error: invalid --source '{source_spec}'. Expected ObjectType=path/to/export.csv.",
                err=True,
            )
            sys.exit(1)
        if not csv_path.exists() or csv_path.is_dir():
            click.echo(f"Error: source file not found: {csv_path}", err=True)
            sys.exit(1)
        parsed_sources.append((object_type, csv_path))

    keys = [key.strip() for key in key_columns.split(",") if key.strip()] if key_columns is not None else None
    if key_columns is not None and not keys:
        click.echo("Error: --key-columns must include at least one column.", err=True)
        sys.exit(1)

    parsed_blocking_keys = (
        [key.strip() for key in blocking_keys.split(",") if key.strip()]
        if blocking_keys is not None
        else None
    )
    if blocking_keys is not None and not parsed_blocking_keys:
        click.echo("Error: --blocking-keys must include at least one anchor.", err=True)
        sys.exit(1)

    try:
        plan = _get_dedup_helpers().dedup_enterprise(
            parsed_sources,
            key_columns=keys,
            threshold=threshold,
            output_path=output_path,
            review_output_path=review_output_path,
            overwrite=overwrite,
            local=local,
            profile=profile.lower(),
            survivor_policy=survivor_policy.lower(),
            blocking_keys=parsed_blocking_keys,
            block_size_cap=block_size_cap,
            limit_per_source=limit_per_source,
        )
    except FileExistsError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error building enterprise dedupe plan: {exc}", err=True)
        sys.exit(1)

    stats = plan.get("stats", {}) or {}
    actions = stats.get("actions", {}) or {}
    click.echo(
        f"Enterprise dedupe plan created at {output_path}. "
        f"Profile: {(plan.get('matching_profile', {}) or {}).get('profile', profile.lower())}. "
        f"Candidate rows: {stats.get('candidate_rows', 0)}/{stats.get('records_total', 0)}, "
        f"Clusters: {stats.get('clusters_found', 0)}, "
        f"Lead->Contact: {actions.get('lead_to_contact_resolution', 0)}."
    )
    if review_output_path:
        click.echo(f"Review worksheet created at {review_output_path}.")
    if plan.get("warnings"):
        click.echo(f"Warnings: {len(plan.get('warnings', []))}.")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin dedup enterprise-plan", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "output": str(output_path),
                "review_output": str(review_output_path) if review_output_path else None,
                "sources": len(parsed_sources),
                "records_total": stats.get("records_total", 0),
                "candidate_rows": stats.get("candidate_rows", 0),
                "clusters": stats.get("clusters_found", 0),
                "duplicates_identified": stats.get("duplicates_identified", 0),
                "lead_to_contact_clusters": actions.get("lead_to_contact_resolution", 0),
                "profile": (plan.get("matching_profile", {}) or {}).get("profile"),
                "blocking_keys": (plan.get("matching_profile", {}) or {}).get("blocking_keys"),
                "threshold": (plan.get("matching_profile", {}) or {}).get("threshold"),
                "limit_per_source": limit_per_source,
                "mode_used": (plan.get("matching_profile", {}) or {}).get("mode_used"),
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@dedup.command("apply")
@click.argument("plan_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--source",
    "source_csv",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Source CSV to apply merge plan to.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output deduped CSV.",
)
@click.option(
    "--overwrite",
    is_flag=True,
    help="Overwrite existing output file.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def dedup_apply(
    plan_path: Path,
    source_csv: Path,
    output_path: Path,
    overwrite: bool,
    json_summary: bool,
) -> None:
    """
    Apply a merge plan to a source CSV to create a deduped file.
    """
    try:
        written = _get_dedup_helpers().apply_merge_plan(plan_path, source_csv, output_path, overwrite=overwrite)
    except FileExistsError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error applying merge plan: {exc}")
        sys.exit(1)

    click.echo(f"Wrote deduped CSV to {output_path} ({written} rows).")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin dedup apply", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "output": str(output_path),
                "rows_written": written,
                "plan": str(plan_path),
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


# =============================================================================
# FoundryGraph Commands
# =============================================================================


@cli.group()
def fg() -> None:
    """
    FoundryGraph company enrichment commands.

    Uses the FoundryGraph remote API (Cloud Run -> BigQuery) to
    resolve company domains, profiles, and parent relationships.

    Note: FoundryGraph commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


@fg.command("stats")
def fg_stats() -> None:
    """
    Show FoundryGraph database statistics.

    DEPRECATED: FoundryGraph now uses BigQuery via the remote API.
    Use 'fg lookup' or 'fg search' to verify connectivity.
    """
    click.echo("DEPRECATED: Local DuckDB stats are no longer available.", err=True)
    click.echo("", err=True)
    click.echo("FoundryGraph now uses BigQuery via the remote API.", err=True)
    click.echo("", err=True)
    click.echo("To verify connectivity, use:", err=True)
    click.echo("  g-gremlin fg lookup --domain slack.com", err=True)
    click.echo("  g-gremlin fg search \"Acme Corp\"", err=True)
    sys.exit(EXIT_USAGE)


@fg.command("lookup")
@click.option(
    "--domain",
    required=True,
    help="Domain to lookup (e.g., 'slack.com').",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Output as JSON.",
)
def fg_lookup(domain: str, as_json: bool) -> None:
    """
    Lookup company by domain.
    """
    try:
        profile = _get_fg_helpers().lookup_by_domain(domain)
        if not profile:
            click.echo(f"No company found for domain: {domain}")
            sys.exit(1)

        if as_json:
            click.echo(json.dumps(profile.to_dict(), indent=2))
        else:
            click.echo(f"Domain: {profile.domain}")
            click.echo(f"Company: {profile.canonical_name}")
            if profile.wikidata_id:
                click.echo(f"Wikidata ID: {profile.wikidata_id}")
            if profile.industries:
                click.echo(f"Industries: {', '.join(profile.industries)}")
            if profile.headquarters:
                click.echo(f"Headquarters: {profile.headquarters}")
            click.echo(f"Confidence: {profile.confidence}")
            click.echo(f"Source: {profile.source}")
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)


@fg.command("search")
@click.argument("query")
@click.option(
    "--limit",
    default=10,
    type=int,
    help="Max results to return (default: 10).",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Output as JSON.",
)
def fg_search(query: str, limit: int, as_json: bool) -> None:
    """
    Search companies by name.
    """
    try:
        results = _get_fg_helpers().search_by_name(query, limit=limit)
        if not results:
            click.echo(f"No companies found matching: {query}")
            return

        if as_json:
            click.echo(json.dumps([r.to_dict() for r in results], indent=2))
        else:
            click.echo(f"Found {len(results)} matches for '{query}':\n")
            for i, profile in enumerate(results, 1):
                click.echo(f"{i}. {profile.canonical_name}")
                click.echo(f"   Domain: {profile.domain or '(none)'}")
                if profile.wikidata_id:
                    click.echo(f"   Wikidata: {profile.wikidata_id}")
                click.echo()
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)


@fg.command("parent")
@click.option(
    "--domain",
    required=True,
    help="Domain to lookup parent for.",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Output as JSON.",
)
def fg_parent(domain: str, as_json: bool) -> None:
    """
    Get ultimate parent company for a domain.
    """
    try:
        parent_info = _get_fg_helpers().get_ultimate_parent(domain)
        if not parent_info:
            click.echo(f"No parent relationship found for: {domain}")
            return

        if as_json:
            click.echo(json.dumps(parent_info, indent=2))
        else:
            click.echo(f"Child: {parent_info.get('child_name', domain)}")
            click.echo(f"Child Domain: {parent_info.get('child_domain', domain)}")
            click.echo(f"Parent: {parent_info.get('parent_name', 'unknown')}")
            click.echo(f"Parent Domain: {parent_info.get('parent_domain', 'unknown')}")
            click.echo(f"Confidence: {parent_info.get('confidence', 0)}")
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)


@fg.command("enrich")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV file to enrich.",
)
@click.option(
    "--domain-col",
    "domain_col",
    default=None,
    help="Column containing domains (if available).",
)
@click.option(
    "--name-col",
    "name_col",
    default=None,
    help="Column containing company names.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output CSV path for enriched data.",
)
@click.option(
    "--limit",
    default=0,
    type=int,
    help="Limit rows to process (0 = all).",
)
@click.option(
    "--mode",
    default="balanced",
    type=click.Choice(["fast", "balanced", "strict"]),
    help="Resolution mode (default: balanced).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Show what would be processed without enriching.",
)
def fg_enrich(
    csv_path: Path,
    domain_col: str | None,
    name_col: str | None,
    output_path: Path,
    limit: int,
    mode: str,
    dry_run: bool,
) -> None:
    """
    Bulk enrich CSV with FoundryGraph company data.

    Adds columns: fg_wikidata_id, fg_canonical_name, fg_domain,
    fg_industries, fg_ultimate_parent, fg_parent_domain, etc.
    """
    import csv as csv_module

    if not domain_col and not name_col:
        click.echo("Error: Must specify --domain-col or --name-col (or both).")
        sys.exit(1)

    # Read input
    with csv_path.open(newline="", encoding="utf-8-sig") as f:
        rows = list(csv_module.DictReader(f))

    if not rows:
        click.echo("Error: No rows found in input file.")
        sys.exit(1)

    # Validate columns exist
    if domain_col and domain_col not in rows[0]:
        error = error_helpers.column_not_found_error(
            domain_col, list(rows[0].keys()), context="input CSV"
        )
        click.echo(str(error), err=True)
        sys.exit(1)
    if name_col and name_col not in rows[0]:
        error = error_helpers.column_not_found_error(
            name_col, list(rows[0].keys()), context="input CSV"
        )
        click.echo(str(error), err=True)
        sys.exit(1)

    if limit > 0:
        rows = rows[:limit]

    click.echo(f"Loaded {len(rows)} rows from {csv_path}")
    if domain_col:
        click.echo(f"Domain column: {domain_col}")
    if name_col:
        click.echo(f"Name column: {name_col}")
    click.echo(f"Mode: {mode}")

    if dry_run:
        click.echo("\n[DRY RUN] Would process:")
        for idx, row in enumerate(rows[:10]):
            domain = row.get(domain_col, "") if domain_col else ""
            name = row.get(name_col, "") if name_col else ""
            click.echo(f"  {idx+1}: {name[:40] or domain[:40]}")
        if len(rows) > 10:
            click.echo(f"  ... and {len(rows) - 10} more")
        return

    # Enrich using batch resolver if name column provided
    enriched_rows = []
    start_time = time.time()

    if name_col:
        # Use batch resolver for efficiency
        names = [row.get(name_col, "").strip() for row in rows]
        click.echo(f"\nResolving {len(names)} company names...")

        try:
            results, metrics = _get_fg_helpers().resolve_many(names, mode=mode)
            click.echo(f"Resolution complete in {metrics.get('elapsed_ms', 0):.0f}ms")
            click.echo(f"  Registry hits: {metrics.get('registry_hits', 0)}")
            click.echo(f"  Wikidata hits: {metrics.get('wikidata_hits', 0)}")
            click.echo(f"  Heuristic: {metrics.get('heuristic_hits', 0)}")
            click.echo(f"  Unresolved: {metrics.get('unresolved', 0)}")
        except Exception as exc:
            click.echo(f"Warning: Batch resolve failed, falling back to row-by-row: {exc}")
            results = None

        for idx, row in enumerate(rows):
            enrichment = {}

            if results and idx < len(results):
                value = results[idx].get("value", {})
                enrichment = {
                    "fg_wikidata_id": "",
                    "fg_canonical_name": "",
                    "fg_domain": value.get("domain", ""),
                    "fg_industries": "",
                    "fg_headquarters": "",
                    "fg_stock_ticker": "",
                    "fg_ultimate_parent": "",
                    "fg_parent_domain": "",
                    "fg_confidence": value.get("confidence_score", 0) or (
                        0.9 if value.get("confidence") == "high" else
                        0.6 if value.get("confidence") == "medium" else 0.3
                    ),
                    "fg_source": value.get("source", ""),
                    "fg_error": "",
                }

                # Try to get full profile if we have a domain
                if enrichment["fg_domain"]:
                    profile = _get_fg_helpers().lookup_by_domain(enrichment["fg_domain"])
                    if profile:
                        enrichment["fg_wikidata_id"] = profile.wikidata_id or ""
                        enrichment["fg_canonical_name"] = profile.canonical_name
                        enrichment["fg_industries"] = ", ".join(profile.industries) if profile.industries else ""
                        enrichment["fg_headquarters"] = profile.headquarters or ""
            else:
                enrichment = _get_fg_helpers().enrich_row(row, domain_col=domain_col, name_col=name_col)

            enriched_row = {**row, **enrichment}
            enriched_rows.append(enriched_row)

            # Progress
            if (idx + 1) % 100 == 0 or idx == len(rows) - 1:
                click.echo(f"  Processed {idx + 1}/{len(rows)} rows...")
    else:
        # Domain-only enrichment (row by row)
        for idx, row in enumerate(rows):
            enrichment = _get_fg_helpers().enrich_row(row, domain_col=domain_col)
            enriched_row = {**row, **enrichment}
            enriched_rows.append(enriched_row)

            if (idx + 1) % 100 == 0 or idx == len(rows) - 1:
                click.echo(f"  Processed {idx + 1}/{len(rows)} rows...")

    # Write output
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = list(enriched_rows[0].keys()) if enriched_rows else []
    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv_module.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(enriched_rows)

    # Summary
    elapsed_total = time.time() - start_time
    success_count = sum(1 for r in enriched_rows if r.get("fg_domain"))
    click.echo(f"\nDone! {success_count}/{len(enriched_rows)} enriched in {elapsed_total:.1f}s")
    click.echo(f"Output: {output_path}")


@zendesk.command("connect")
@click.option(
    "--subdomain",
    required=True,
    help="Zendesk subdomain (the part before .zendesk.com).",
)
@click.option(
    "--email",
    required=True,
    help="Zendesk user email associated with the API token.",
)
@click.option(
    "--api-token",
    "api_token",
    required=True,
    help="Zendesk API token.",
)
def zendesk_connect(subdomain: str, email: str, api_token: str) -> None:
    """
    Validate Zendesk credentials and store them locally.
    """
    subdomain_clean = subdomain.strip()
    email_clean = email.strip()
    token_clean = api_token.strip()
    if not subdomain_clean or not email_clean or not token_clean:
        click.echo("Error: all of --subdomain, --email, and --api-token are required.")
        sys.exit(1)

    cfg = _get_zendesk_helpers().ZendeskConfig(
        subdomain=subdomain_clean, email=email_clean, api_token=token_clean
    )
    try:
        _get_zendesk_helpers().run_zendesk_get("users/me.json", config=cfg)
        _get_zendesk_helpers().save_zendesk_config(cfg)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Configured Zendesk subdomain '{subdomain_clean}'. "
        f"Config saved to {_get_zendesk_helpers().ZENDESK_CONFIG_PATH}."
    )


def _zendesk_parse_ymd(value: str | None, flag: str) -> datetime | None:
    if not value:
        return None
    cleaned = value.strip()
    if not cleaned:
        return None
    if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", cleaned):
        raise click.ClickException(f"{flag} must be in YYYY-MM-DD format.")
    try:
        return datetime.strptime(cleaned, "%Y-%m-%d").replace(tzinfo=timezone.utc)
    except ValueError as exc:
        raise click.ClickException(f"{flag} must be in YYYY-MM-DD format.") from exc


def _zendesk_normalize_domain(value: str) -> str:
    cleaned = (value or "").strip().lower()
    if not cleaned:
        return ""
    cleaned = re.sub(r"^https?://", "", cleaned)
    cleaned = re.sub(r"^www\.", "", cleaned)
    cleaned = re.split(r"[/?#]", cleaned, maxsplit=1)[0]
    return cleaned.strip().rstrip(".")


def _zendesk_parse_domain_list(value: str | None) -> set[str]:
    if not value:
        return set()
    return {normalized for normalized in (_zendesk_normalize_domain(v) for v in value.split(",")) if normalized}


def _zendesk_batch_get_users(user_ids: Iterable[int]) -> Dict[int, Dict[str, Any]]:
    ids = sorted({int(uid) for uid in user_ids if uid is not None})
    if not ids:
        return {}
    resolved: Dict[int, Dict[str, Any]] = {}
    for start in range(0, len(ids), 100):
        batch = ids[start : start + 100]
        id_param = ",".join(str(i) for i in batch)
        try:
            payload = _get_zendesk_helpers().run_zendesk_get("users/show_many.json", params={"ids": id_param})
        except Exception:
            continue
        users = payload.get("users", []) if isinstance(payload, dict) else []
        for user in users:
            user_id = user.get("id")
            if isinstance(user_id, int):
                resolved[user_id] = user
    return resolved


def _zendesk_batch_get_organizations(org_ids: Iterable[int]) -> Dict[int, Dict[str, Any]]:
    ids = sorted({int(org_id) for org_id in org_ids if org_id is not None})
    if not ids:
        return {}
    resolved: Dict[int, Dict[str, Any]] = {}
    for start in range(0, len(ids), 100):
        batch = ids[start : start + 100]
        id_param = ",".join(str(i) for i in batch)
        try:
            payload = _get_zendesk_helpers().run_zendesk_get("organizations/show_many.json", params={"ids": id_param})
        except Exception:
            continue
        orgs = payload.get("organizations", []) if isinstance(payload, dict) else []
        for org in orgs:
            org_id = org.get("id")
            if isinstance(org_id, int):
                resolved[org_id] = org
    return resolved


def _zendesk_emit_records_text(records: List[Dict[str, Any]], label: str) -> None:
    if not records:
        click.echo(f"No matching {label} found.")
        return
    click.echo(f"Found {len(records)} {label}:")
    for row in records:
        click.echo(json.dumps(row))


def _zendesk_read_csv(path: Path) -> tuple[List[Dict[str, str]], List[str]]:
    with path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            raise click.ClickException(f"CSV {path} is missing a header row.")
        return [dict(row) for row in reader], list(reader.fieldnames)


def _zendesk_extract_email_domain(email: str | None) -> str:
    raw = (email or "").strip().lower()
    if "@" not in raw:
        return ""
    return raw.rsplit("@", 1)[-1]


def _zendesk_org_domain_set(org: Mapping[str, Any]) -> set[str]:
    raw_domains = org.get("domain_names") or []
    if not isinstance(raw_domains, list):
        return set()
    parsed = [_zendesk_normalize_domain(str(value)) for value in raw_domains]
    return {value for value in parsed if value}


def _zendesk_search_users_by_query(query: str, limit: int = 0) -> List[Dict[str, Any]]:
    query_clean = (query or "").strip()
    if not query_clean:
        return []
    results: List[Dict[str, Any]] = []
    seen_ids: set[int] = set()
    for user in _get_zendesk_helpers().paginate_zendesk(
        "users/search.json",
        params={"query": query_clean, "per_page": 100},
        result_keys=["users"],
    ):
        user_id = user.get("id")
        if isinstance(user_id, int):
            if user_id in seen_ids:
                continue
            seen_ids.add(user_id)
        results.append(user)
        if limit and len(results) >= limit:
            break
    return results


def _zendesk_find_orgs_by_domain(domain: str) -> List[Dict[str, Any]]:
    target_domain = _zendesk_normalize_domain(domain)
    if not target_domain:
        return []
    matches: List[Dict[str, Any]] = []
    for org in _get_zendesk_helpers().paginate_zendesk(
        "organizations.json",
        params={"per_page": 100},
        result_keys=["organizations"],
    ):
        if target_domain in _zendesk_org_domain_set(org):
            matches.append(org)
    return matches


@zendesk.group("user")
def zendesk_user() -> None:
    """Zendesk user helpers."""


@zendesk.group("org")
def zendesk_org() -> None:
    """Zendesk organization helpers."""


@zendesk.group("ticket")
def zendesk_ticket() -> None:
    """Zendesk ticket helpers."""


@zendesk.group("trace")
def zendesk_trace() -> None:
    """Zendesk trace helpers."""


@zendesk_user.command("search")
@click.option("--query", required=False, help="Search text (name/email).")
@click.option("--email", required=False, help="Exact email lookup.")
@click.option("--org-id", required=False, help="Filter to users in this organization ID.")
@click.option("--limit", type=int, default=50, show_default=True, help="Max records to return (0 = no limit).")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def zendesk_user_search(
    query: str | None,
    email: str | None,
    org_id: str | None,
    limit: int,
    json_output: bool,
) -> None:
    """Search Zendesk users by query, email, or organization."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    if not any([query, email, org_id]):
        raise click.ClickException("Provide at least one filter: --query, --email, or --org-id.")

    users: List[Dict[str, Any]] = []
    try:
        if org_id and not query and not email:
            users = list(
                _get_zendesk_helpers().paginate_zendesk(
                    f"organizations/{org_id}/users.json",
                    params={"per_page": 100},
                    result_keys=["users"],
                )
            )
        else:
            search_query = (email or query or "").strip()
            payload = _get_zendesk_helpers().run_zendesk_get("users/search.json", params={"query": search_query})
            users = payload.get("users", []) if isinstance(payload, dict) else []
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    email_filter = (email or "").strip().lower()
    org_filter = (org_id or "").strip()
    filtered: List[Dict[str, Any]] = []
    for user in users:
        if email_filter:
            user_email = str(user.get("email") or "").strip().lower()
            if user_email != email_filter:
                continue
        if org_filter:
            candidate_org = str(user.get("organization_id") or "").strip()
            if candidate_org != org_filter:
                continue
        filtered.append(user)
        if limit and len(filtered) >= limit:
            break

    org_map = _zendesk_batch_get_organizations(
        int(u["organization_id"]) for u in filtered if isinstance(u.get("organization_id"), int)
    )
    rows = []
    for user in filtered:
        org_identifier = user.get("organization_id")
        org_name = ""
        if isinstance(org_identifier, int):
            org_name = str(org_map.get(org_identifier, {}).get("name") or "")
        row = {
            "user_id": user.get("id"),
            "name": user.get("name"),
            "email": user.get("email"),
            "organization_id": org_identifier,
            "organization_name": org_name,
            "role": user.get("role"),
        }
        rows.append(row)

    if json_output:
        click.echo(json.dumps(rows, indent=2))
        return
    _zendesk_emit_records_text(rows, "user(s)")


@zendesk_org.command("search")
@click.option("--name", required=False, help="Organization name contains match.")
@click.option("--external-id", "external_id", required=False, help="Exact external_id match.")
@click.option("--domain", required=False, help="Domain(s), comma-delimited.")
@click.option("--limit", type=int, default=50, show_default=True, help="Max records to return (0 = no limit).")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def zendesk_org_search(
    name: str | None,
    external_id: str | None,
    domain: str | None,
    limit: int,
    json_output: bool,
) -> None:
    """Search Zendesk organizations by name, external ID, or domain."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    if not any([name, external_id, domain]):
        raise click.ClickException("Provide at least one filter: --name, --external-id, or --domain.")

    name_filter = (name or "").strip().lower()
    external_id_filter = (external_id or "").strip().lower()
    domain_filters = _zendesk_parse_domain_list(domain)

    try:
        organizations_iter = _get_zendesk_helpers().paginate_zendesk(
            "organizations.json",
            params={"per_page": 100},
            result_keys=["organizations"],
        )
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    rows: List[Dict[str, Any]] = []
    try:
        for org in organizations_iter:
            org_name = str(org.get("name") or "")
            org_external = str(org.get("external_id") or "")
            org_domains_raw = org.get("domain_names") or []
            org_domains = [_zendesk_normalize_domain(str(d)) for d in org_domains_raw if str(d).strip()]
            org_domain_set = {d for d in org_domains if d}

            if name_filter and name_filter not in org_name.lower():
                continue
            if external_id_filter and org_external.lower() != external_id_filter:
                continue
            if domain_filters and not domain_filters.issubset(org_domain_set):
                continue

            rows.append(
                {
                    "org_id": org.get("id"),
                    "name": org_name,
                    "domain_names": org_domains_raw,
                    "external_id": org.get("external_id"),
                    "notes": org.get("notes"),
                    "organization_fields": org.get("organization_fields", {}),
                }
            )
            if limit and len(rows) >= limit:
                break
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(rows, indent=2))
        return
    _zendesk_emit_records_text(rows, "organization(s)")


@zendesk_org.command("audit")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV containing Salesforce account IDs and expected Zendesk domains.",
)
@click.option(
    "--account-id-column",
    default="AccountId",
    show_default=True,
    help="Column containing Salesforce Account IDs (mapped to Zendesk external_id).",
)
@click.option(
    "--domain-column",
    default="Zendesk_Domain__c",
    show_default=True,
    help="Column containing expected Zendesk domain(s), comma-delimited.",
)
@click.option(
    "--include-orphans",
    is_flag=True,
    help="Also flag Zendesk orgs whose external_id is missing from the CSV (requires full org crawl).",
)
@click.option(
    "--output",
    "output_path",
    required=False,
    type=click.Path(dir_okay=False, path_type=Path),
    help="CSV path for discrepancy output (default: .g_gremlin/zendesk_org_audit.csv).",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON summary output.")
def zendesk_org_audit(
    csv_path: Path,
    account_id_column: str,
    domain_column: str,
    include_orphans: bool,
    output_path: Path | None,
    json_output: bool,
) -> None:
    """Audit Zendesk organizations against SFDC account IDs and expected domains."""
    rows, headers = _zendesk_read_csv(csv_path)
    if account_id_column not in headers:
        raise click.ClickException(f"Column '{account_id_column}' not found in {csv_path}.")
    if domain_column not in headers:
        raise click.ClickException(f"Column '{domain_column}' not found in {csv_path}.")

    expected_by_external: Dict[str, set[str]] = {}
    for row in rows:
        external_id = str(row.get(account_id_column) or "").strip()
        if not external_id:
            continue
        expected_domains = _zendesk_parse_domain_list(row.get(domain_column))
        bucket = expected_by_external.setdefault(external_id, set())
        bucket.update(expected_domains)

    discrepancy_rows: List[Dict[str, Any]] = []
    search_supported = True
    all_orgs_cache: List[Dict[str, Any]] | None = None

    def _load_all_orgs() -> List[Dict[str, Any]]:
        nonlocal all_orgs_cache
        if all_orgs_cache is None:
            all_orgs_cache = list(
                _get_zendesk_helpers().paginate_zendesk(
                    "organizations.json",
                    params={"per_page": 100},
                    result_keys=["organizations"],
                )
            )
        return all_orgs_cache

    for external_id, expected_domains in expected_by_external.items():
        matched_orgs: List[Dict[str, Any]] = []

        if search_supported:
            try:
                payload = _get_zendesk_helpers().run_zendesk_get(
                    "organizations/search.json",
                    params={"external_id": external_id},
                )
                orgs = payload.get("organizations", []) if isinstance(payload, dict) else []
                if isinstance(orgs, list):
                    matched_orgs = [
                        org
                        for org in orgs
                        if str(org.get("external_id") or "").strip() == external_id
                    ]
            except Exception:
                search_supported = False

        if not search_supported:
            try:
                matched_orgs = [
                    org
                    for org in _load_all_orgs()
                    if str(org.get("external_id") or "").strip() == external_id
                ]
            except Exception as exc:
                click.echo(f"Error: {exc}", err=True)
                sys.exit(EXIT_REMOTE_ERROR)

        if not matched_orgs:
            discrepancy_rows.append(
                {
                    "issue_type": "missing_org",
                    "account_id": external_id,
                    "expected_domains": ",".join(sorted(expected_domains)),
                    "org_id": "",
                    "org_name": "",
                    "org_external_id": external_id,
                    "org_domains": "",
                    "missing_domains": ",".join(sorted(expected_domains)),
                    "details": "No Zendesk organization found for external_id.",
                }
            )
            continue

        if len(matched_orgs) > 1:
            ids = [str(org.get("id") or "") for org in matched_orgs]
            discrepancy_rows.append(
                {
                    "issue_type": "duplicate_external_id",
                    "account_id": external_id,
                    "expected_domains": ",".join(sorted(expected_domains)),
                    "org_id": ",".join(i for i in ids if i),
                    "org_name": "",
                    "org_external_id": external_id,
                    "org_domains": "",
                    "missing_domains": "",
                    "details": "Multiple Zendesk organizations share this external_id.",
                }
            )
            continue

        org = matched_orgs[0]
        org_domains = _zendesk_org_domain_set(org)
        missing_domains = expected_domains - org_domains
        if missing_domains:
            discrepancy_rows.append(
                {
                    "issue_type": "domain_mismatch",
                    "account_id": external_id,
                    "expected_domains": ",".join(sorted(expected_domains)),
                    "org_id": org.get("id"),
                    "org_name": org.get("name"),
                    "org_external_id": org.get("external_id"),
                    "org_domains": ",".join(sorted(org_domains)),
                    "missing_domains": ",".join(sorted(missing_domains)),
                    "details": "Expected domain(s) missing from Zendesk org domain_names.",
                }
            )

    if include_orphans:
        try:
            all_orgs = _load_all_orgs()
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        expected_external_ids = set(expected_by_external.keys())
        for org in all_orgs:
            org_external_id = str(org.get("external_id") or "").strip()
            if org_external_id and org_external_id in expected_external_ids:
                continue
            issue = "orphan_org"
            details = "Organization external_id not present in input CSV."
            if not org_external_id:
                details = "Organization missing external_id."
            discrepancy_rows.append(
                {
                    "issue_type": issue,
                    "account_id": "",
                    "expected_domains": "",
                    "org_id": org.get("id"),
                    "org_name": org.get("name"),
                    "org_external_id": org_external_id,
                    "org_domains": ",".join(sorted(_zendesk_org_domain_set(org))),
                    "missing_domains": "",
                    "details": details,
                }
            )

    output_target = output_path or Path(".g_gremlin/zendesk_org_audit.csv")
    headers_out = [
        "issue_type",
        "account_id",
        "expected_domains",
        "org_id",
        "org_name",
        "org_external_id",
        "org_domains",
        "missing_domains",
        "details",
    ]
    _write_csv_with_headers(output_target, discrepancy_rows, headers_out)

    issue_counts: Dict[str, int] = {}
    for row in discrepancy_rows:
        issue = str(row.get("issue_type") or "unknown")
        issue_counts[issue] = issue_counts.get(issue, 0) + 1

    summary = {
        "status": "success",
        "input_csv": str(csv_path),
        "output": str(output_target),
        "accounts_checked": len(expected_by_external),
        "discrepancies": len(discrepancy_rows),
        "issue_counts": issue_counts,
        "include_orphans": include_orphans,
    }
    if json_output:
        click.echo(json.dumps(summary, indent=2))
        return
    click.echo(
        f"Audited {len(expected_by_external)} account(s). "
        f"Found {len(discrepancy_rows)} discrepancy row(s). "
        f"Report written to {output_target}."
    )


@zendesk_ticket.command("search")
@click.option("--requester", required=False, help="Requester email.")
@click.option("--org-id", required=False, help="Organization ID.")
@click.option("--since", required=False, help="Start date (YYYY-MM-DD, UTC inclusive).")
@click.option("--until", required=False, help="End date (YYYY-MM-DD, UTC inclusive).")
@click.option("--status", required=False, help="Ticket status (e.g. new, open, pending, solved).")
@click.option("--limit", type=int, default=100, show_default=True, help="Max records to return (0 = no limit).")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
@click.option(
    "--output",
    "output_path",
    required=False,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write CSV output.",
)
def zendesk_ticket_search(
    requester: str | None,
    org_id: str | None,
    since: str | None,
    until: str | None,
    status: str | None,
    limit: int,
    json_output: bool,
    output_path: Path | None,
) -> None:
    """Search Zendesk tickets by requester, org, date range, and status."""
    if json_output and output_path:
        raise click.ClickException("Use either --json or --output, not both.")
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")
    if not any([requester, org_id, since, until, status]):
        raise click.ClickException(
            "Provide at least one filter: --requester, --org-id, --since, --until, or --status."
        )

    since_dt = _zendesk_parse_ymd(since, "--since")
    until_dt = _zendesk_parse_ymd(until, "--until")
    until_exclusive = (until_dt + timedelta(days=1)) if until_dt else None
    if since_dt and until_exclusive and since_dt >= until_exclusive:
        raise click.ClickException("--since must be earlier than or equal to --until.")

    search_terms = ["type:ticket"]
    if requester:
        search_terms.append(f"requester:{requester.strip()}")
    if org_id:
        search_terms.append(f"organization:{org_id.strip()}")
    if status:
        search_terms.append(f"status:{status.strip().lower()}")
    if since_dt:
        search_terms.append(f"created>={since_dt.strftime('%Y-%m-%d')}")
    if until_exclusive:
        search_terms.append(f"created<{until_exclusive.strftime('%Y-%m-%d')}")

    query = " ".join(search_terms)
    filtered_tickets: List[Dict[str, Any]] = []
    try:
        ticket_results_iter = _get_zendesk_helpers().paginate_zendesk(
            "search.json",
            params={"query": query, "per_page": 100},
            result_keys=["results"],
        )
        for item in ticket_results_iter:
            if item.get("result_type") and item.get("result_type") != "ticket":
                continue
            filtered_tickets.append(item)
            if limit and len(filtered_tickets) >= limit:
                break
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    user_map = _zendesk_batch_get_users(
        int(t["requester_id"]) for t in filtered_tickets if isinstance(t.get("requester_id"), int)
    )
    org_map = _zendesk_batch_get_organizations(
        int(t["organization_id"]) for t in filtered_tickets if isinstance(t.get("organization_id"), int)
    )

    rows: List[Dict[str, Any]] = []
    for ticket in filtered_tickets:
        requester_id = ticket.get("requester_id")
        org_identifier = ticket.get("organization_id")
        requester_value = requester or ""
        if isinstance(requester_id, int):
            requester_value = str(user_map.get(requester_id, {}).get("email") or requester_value or requester_id)
        org_name = ""
        if isinstance(org_identifier, int):
            org_name = str(org_map.get(org_identifier, {}).get("name") or "")
        rows.append(
            {
                "ticket_id": ticket.get("id"),
                "subject": ticket.get("subject"),
                "requester": requester_value,
                "requester_id": requester_id,
                "organization_id": org_identifier,
                "organization_name": org_name,
                "status": ticket.get("status"),
                "created_at": ticket.get("created_at"),
            }
        )

    if output_path:
        headers = [
            "ticket_id",
            "subject",
            "requester",
            "requester_id",
            "organization_id",
            "organization_name",
            "status",
            "created_at",
        ]
        _write_csv_with_headers(output_path, rows, headers=headers)
        click.echo(f"Wrote {len(rows)} ticket(s) to {output_path}.")
        return
    if json_output:
        click.echo(json.dumps(rows, indent=2))
        return
    _zendesk_emit_records_text(rows, "ticket(s)")


@zendesk_ticket.command("audit")
@click.option("--org-id", required=True, help="Zendesk organization ID to audit tickets for.")
@click.option("--since", required=False, help="Start date (YYYY-MM-DD, UTC inclusive).")
@click.option("--until", required=False, help="End date (YYYY-MM-DD, UTC inclusive).")
@click.option("--limit", type=int, default=0, show_default=True, help="Max flagged tickets to return (0 = no limit).")
@click.option(
    "--output",
    "output_path",
    required=False,
    type=click.Path(dir_okay=False, path_type=Path),
    help="CSV path for audit output (default: .g_gremlin/zendesk_ticket_audit.csv).",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON summary output.")
def zendesk_ticket_audit(
    org_id: str,
    since: str | None,
    until: str | None,
    limit: int,
    output_path: Path | None,
    json_output: bool,
) -> None:
    """Audit ticket org attribution for users matching an org's email domains."""
    if limit < 0:
        raise click.ClickException("--limit must be >= 0.")

    since_dt = _zendesk_parse_ymd(since, "--since")
    until_dt = _zendesk_parse_ymd(until, "--until")
    until_exclusive = (until_dt + timedelta(days=1)) if until_dt else None
    if since_dt and until_exclusive and since_dt >= until_exclusive:
        raise click.ClickException("--since must be earlier than or equal to --until.")

    try:
        org_payload = _get_zendesk_helpers().run_zendesk_get(f"organizations/{org_id}.json")
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    org = org_payload.get("organization") if isinstance(org_payload, dict) else None
    if not org:
        click.echo("No matching organization found.", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    org_domains = _zendesk_org_domain_set(org)
    users_by_id: Dict[int, Dict[str, Any]] = {}
    try:
        for domain in sorted(org_domains):
            for user in _zendesk_search_users_by_query(domain):
                user_id = user.get("id")
                if not isinstance(user_id, int):
                    continue
                if _zendesk_extract_email_domain(str(user.get("email") or "")) != domain:
                    continue
                users_by_id[user_id] = user
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    issue_rows: List[Dict[str, Any]] = []
    reached_limit = False
    users_scanned = 0
    for user in users_by_id.values():
        users_scanned += 1
        user_id = user.get("id")
        if not isinstance(user_id, int):
            continue
        try:
            ticket_iter = _get_zendesk_helpers().paginate_zendesk(
                f"users/{user_id}/tickets/requested.json",
                params={"per_page": 100},
                result_keys=["tickets"],
            )
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        for ticket in ticket_iter:
            created_at = str(ticket.get("created_at") or "")
            created_dt = _parse_iso8601(created_at) if created_at else None
            if since_dt and (created_dt is None or created_dt < since_dt):
                continue
            if until_exclusive and (created_dt is None or created_dt >= until_exclusive):
                continue

            ticket_org_id = ticket.get("organization_id")
            issue_type = ""
            if ticket_org_id in (None, ""):
                issue_type = "ticket_org_null_domain_match"
            elif str(ticket_org_id).strip() != str(org_id).strip():
                issue_type = "ticket_org_mismatch_domain_match"
            if not issue_type:
                continue

            issue_rows.append(
                {
                    "ticket_id": ticket.get("id"),
                    "subject": ticket.get("subject"),
                    "requester_email": user.get("email"),
                    "requester_id": user_id,
                    "created_at": created_at,
                    "status": ticket.get("status"),
                    "ticket_org_id": ticket_org_id,
                    "ticket_org_name": "",
                    "expected_org_id": org.get("id"),
                    "expected_org_name": org.get("name"),
                    "issue_type": issue_type,
                }
            )
            if limit and len(issue_rows) >= limit:
                reached_limit = True
                break
        if reached_limit:
            break

    ticket_org_lookup = _zendesk_batch_get_organizations(
        int(r["ticket_org_id"]) for r in issue_rows if isinstance(r.get("ticket_org_id"), int)
    )
    for row in issue_rows:
        ticket_org_id = row.get("ticket_org_id")
        if isinstance(ticket_org_id, int):
            row["ticket_org_name"] = str(ticket_org_lookup.get(ticket_org_id, {}).get("name") or "")

    output_target = output_path or Path(".g_gremlin/zendesk_ticket_audit.csv")
    headers_out = [
        "ticket_id",
        "subject",
        "requester_email",
        "requester_id",
        "created_at",
        "status",
        "ticket_org_id",
        "ticket_org_name",
        "expected_org_id",
        "expected_org_name",
        "issue_type",
    ]
    _write_csv_with_headers(output_target, issue_rows, headers_out)

    issue_counts: Dict[str, int] = {}
    for row in issue_rows:
        issue = str(row.get("issue_type") or "unknown")
        issue_counts[issue] = issue_counts.get(issue, 0) + 1

    summary = {
        "status": "success",
        "org_id": org.get("id"),
        "org_name": org.get("name"),
        "org_domains": sorted(org_domains),
        "users_scanned": users_scanned,
        "tickets_flagged": len(issue_rows),
        "issue_counts": issue_counts,
        "output": str(output_target),
        "since": since or "",
        "until": until or "",
        "limit_reached": reached_limit,
    }
    if json_output:
        summary["rows"] = issue_rows
        click.echo(json.dumps(summary, indent=2))
        return
    click.echo(
        f"Audited ticket attribution for org {org.get('id')}. "
        f"Flagged {len(issue_rows)} ticket(s). "
        f"Report written to {output_target}."
    )


@zendesk_trace.command("user")
@click.option("--email", required=True, help="User email to trace.")
@click.option("--limit", type=int, default=10, show_default=True, help="Max recent tickets to include.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON trace output.")
def zendesk_trace_user(email: str, limit: int, json_output: bool) -> None:
    """Trace user linkage state, matching org(s), and recent tickets."""
    if limit <= 0:
        raise click.ClickException("--limit must be > 0.")
    target_email = email.strip().lower()
    if not target_email:
        raise click.ClickException("--email is required.")

    try:
        payload = _get_zendesk_helpers().run_zendesk_get("users/search.json", params={"query": target_email})
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    users = payload.get("users", []) if isinstance(payload, dict) else []
    matched_user = None
    for user in users:
        if str(user.get("email") or "").strip().lower() == target_email:
            matched_user = user
            break

    if matched_user is None:
        result = {
            "status": "not_found",
            "email": target_email,
            "diagnosis_code": "user_not_found",
            "diagnosis": "No Zendesk user found for this email.",
        }
        if json_output:
            click.echo(json.dumps(result, indent=2))
            return
        click.echo(result["diagnosis"])
        return

    user_id = matched_user.get("id")
    email_domain = _zendesk_extract_email_domain(target_email)
    try:
        matching_orgs = _zendesk_find_orgs_by_domain(email_domain) if email_domain else []
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    matching_org_ids = {
        int(org.get("id"))
        for org in matching_orgs
        if isinstance(org.get("id"), int)
    }

    current_org_id = matched_user.get("organization_id")
    current_org_map = _zendesk_batch_get_organizations(
        [int(current_org_id)] if isinstance(current_org_id, int) else []
    )
    current_org = current_org_map.get(current_org_id) if isinstance(current_org_id, int) else None

    recent_tickets: List[Dict[str, Any]] = []
    try:
        if isinstance(user_id, int):
            ticket_iter = _get_zendesk_helpers().paginate_zendesk(
                f"users/{user_id}/tickets/requested.json",
                params={"per_page": 100},
                result_keys=["tickets"],
            )
            for ticket in ticket_iter:
                recent_tickets.append(ticket)
                if len(recent_tickets) >= limit:
                    break
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    ticket_org_map = _zendesk_batch_get_organizations(
        int(t["organization_id"]) for t in recent_tickets if isinstance(t.get("organization_id"), int)
    )
    ticket_rows = []
    for ticket in recent_tickets:
        ticket_org_id = ticket.get("organization_id")
        ticket_org_name = ""
        if isinstance(ticket_org_id, int):
            ticket_org_name = str(ticket_org_map.get(ticket_org_id, {}).get("name") or "")
        ticket_rows.append(
            {
                "ticket_id": ticket.get("id"),
                "subject": ticket.get("subject"),
                "status": ticket.get("status"),
                "created_at": ticket.get("created_at"),
                "organization_id": ticket_org_id,
                "organization_name": ticket_org_name,
            }
        )

    diagnosis_code = "unknown"
    diagnosis_text = "Unable to infer a definitive root cause from current signals."
    if current_org is None and matching_org_ids:
        diagnosis_code = "unassigned_user_with_matching_org_domain"
        diagnosis_text = (
            "User has no organization assignment, but at least one org matches the email domain. "
            "Likely created before org/domain mapping existed or missed by backfill."
        )
    elif current_org is None and not matching_org_ids:
        diagnosis_code = "unassigned_user_no_matching_org_domain"
        diagnosis_text = "User has no organization assignment and no org matches this email domain."
    elif isinstance(current_org_id, int) and current_org_id in matching_org_ids:
        diagnosis_code = "user_assigned_to_matching_org"
        diagnosis_text = "User is assigned to an organization matching their email domain."
    elif isinstance(current_org_id, int) and matching_org_ids:
        diagnosis_code = "user_assigned_to_nonmatching_org"
        diagnosis_text = "User is assigned, but not to the org(s) matching their email domain."

    trace_payload = {
        "status": "success",
        "user": {
            "user_id": matched_user.get("id"),
            "name": matched_user.get("name"),
            "email": matched_user.get("email"),
            "organization_id": matched_user.get("organization_id"),
            "organization_name": current_org.get("name") if current_org else "",
            "role": matched_user.get("role"),
        },
        "email_domain": email_domain,
        "matching_organizations": [
            {
                "org_id": org.get("id"),
                "name": org.get("name"),
                "domain_names": org.get("domain_names") or [],
                "external_id": org.get("external_id"),
            }
            for org in matching_orgs
        ],
        "recent_tickets": ticket_rows,
        "diagnosis_code": diagnosis_code,
        "diagnosis": diagnosis_text,
    }

    if json_output:
        click.echo(json.dumps(trace_payload, indent=2))
        return
    click.echo(f"User: {trace_payload['user']['email']} (id={trace_payload['user']['user_id']})")
    click.echo(f"Diagnosis: {diagnosis_code}")
    click.echo(diagnosis_text)
    click.echo(f"Matching orgs: {len(matching_orgs)}")
    click.echo(f"Recent tickets inspected: {len(ticket_rows)}")


@zendesk_user.command("audit")
@click.option("--org-id", required=False, help="Zendesk organization ID to audit users for.")
@click.option("--domain", required=False, help="Domain used to resolve matching Zendesk organization(s).")
@click.option("--email-domain", required=False, help="Audit users by exact email domain.")
@click.option(
    "--output",
    "output_path",
    required=False,
    type=click.Path(dir_okay=False, path_type=Path),
    help="CSV path for audit output (default: .g_gremlin/zendesk_user_audit.csv).",
)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON summary output.")
def zendesk_user_audit(
    org_id: str | None,
    domain: str | None,
    email_domain: str | None,
    output_path: Path | None,
    json_output: bool,
) -> None:
    """Audit Zendesk user org assignments and flag email-domain mismatches."""
    selector_count = sum(bool(v) for v in [org_id, domain, email_domain])
    if selector_count != 1:
        raise click.ClickException("Provide exactly one of --org-id, --domain, or --email-domain.")

    organizations: List[Dict[str, Any]] = []
    if email_domain:
        target_domain = _zendesk_normalize_domain(email_domain)
        if not target_domain:
            raise click.ClickException("--email-domain must be a valid domain.")
        try:
            users = _zendesk_search_users_by_query(target_domain)
            organizations = _zendesk_find_orgs_by_domain(target_domain)
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        inferred_org_ids = [str(org.get("id")) for org in organizations if org.get("id") is not None]
        inferred_org_names = [str(org.get("name") or "") for org in organizations]

        assigned_org_map = _zendesk_batch_get_organizations(
            int(u["organization_id"]) for u in users if isinstance(u.get("organization_id"), int)
        )

        audit_rows: List[Dict[str, Any]] = []
        for user in users:
            email_value = str(user.get("email") or "")
            user_email_domain = _zendesk_extract_email_domain(email_value)
            if user_email_domain != target_domain:
                continue
            assigned_org_id = user.get("organization_id")
            assigned_org_name = ""
            assigned_org_domains: set[str] = set()
            if isinstance(assigned_org_id, int):
                assigned_org = assigned_org_map.get(assigned_org_id, {})
                assigned_org_name = str(assigned_org.get("name") or "")
                assigned_org_domains = _zendesk_org_domain_set(assigned_org)

            issue = ""
            suggested_action = ""
            if assigned_org_id in (None, ""):
                issue = "unassigned_user"
                if len(inferred_org_ids) == 1:
                    suggested_action = f"assign_user_to_org:{inferred_org_ids[0]}"
                elif len(inferred_org_ids) > 1:
                    suggested_action = "review_multiple_matching_orgs"
                else:
                    suggested_action = "create_or_fix_org_domain_mapping"
            elif target_domain not in assigned_org_domains:
                issue = "assigned_to_nonmatching_org"
                if len(inferred_org_ids) == 1:
                    suggested_action = f"move_user_to_org:{inferred_org_ids[0]}"
                elif len(inferred_org_ids) > 1:
                    suggested_action = "review_multiple_matching_orgs"
                else:
                    suggested_action = "review_org_domain_configuration"

            audit_rows.append(
                {
                    "org_id": "",
                    "org_name": "",
                    "org_domains": target_domain,
                    "user_id": user.get("id"),
                    "user_name": user.get("name"),
                    "email": email_value,
                    "email_domain": user_email_domain,
                    "organization_id": assigned_org_id,
                    "organization_name": assigned_org_name,
                    "domain_match": bool(assigned_org_id and target_domain in assigned_org_domains),
                    "issue_type": issue,
                    "inferred_matching_org_ids": ",".join(inferred_org_ids),
                    "inferred_matching_org_names": ",".join(inferred_org_names),
                    "suggested_action": suggested_action,
                }
            )

        output_target = output_path or Path(".g_gremlin/zendesk_user_audit.csv")
        headers_out = [
            "org_id",
            "org_name",
            "org_domains",
            "user_id",
            "user_name",
            "email",
            "email_domain",
            "organization_id",
            "organization_name",
            "domain_match",
            "issue_type",
            "inferred_matching_org_ids",
            "inferred_matching_org_names",
            "suggested_action",
        ]
        _write_csv_with_headers(output_target, audit_rows, headers_out)
        mismatch_count = sum(1 for row in audit_rows if row.get("issue_type"))
        summary = {
            "status": "success",
            "organizations_checked": len(organizations),
            "users_checked": len(audit_rows),
            "mismatches": mismatch_count,
            "output": str(output_target),
        }
        if json_output:
            summary["rows"] = audit_rows
            click.echo(json.dumps(summary, indent=2))
            return
        click.echo(
            f"Audited {len(audit_rows)} user(s) for domain {target_domain}. "
            f"Flagged {mismatch_count} mismatch(es). "
            f"Report written to {output_target}."
        )
        return

    if org_id:
        try:
            payload = _get_zendesk_helpers().run_zendesk_get(f"organizations/{org_id}.json")
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        organization = payload.get("organization") if isinstance(payload, dict) else None
        if not organization:
            click.echo("No matching organization found.", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        organizations = [organization]
    else:
        assert domain is not None
        target_domain = _zendesk_normalize_domain(domain)
        try:
            org_iter = _get_zendesk_helpers().paginate_zendesk(
                "organizations.json",
                params={"per_page": 100},
                result_keys=["organizations"],
            )
            for org in org_iter:
                org_domains = _zendesk_org_domain_set(org)
                if target_domain in org_domains:
                    organizations.append(org)
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

    audit_rows: List[Dict[str, Any]] = []
    org_count = 0
    for org in organizations:
        current_org_id = org.get("id")
        if current_org_id is None:
            continue
        org_count += 1
        org_domains = _zendesk_org_domain_set(org)
        try:
            users = list(
                _get_zendesk_helpers().paginate_zendesk(
                    f"organizations/{current_org_id}/users.json",
                    params={"per_page": 100},
                    result_keys=["users"],
                )
            )
        except Exception as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        for user in users:
            email = str(user.get("email") or "")
            email_domain = _zendesk_extract_email_domain(email)
            domain_match = bool(email_domain and email_domain in org_domains)
            issue = ""
            if not domain_match:
                issue = "email_domain_not_in_org_domains"
            audit_rows.append(
                {
                    "org_id": current_org_id,
                    "org_name": org.get("name"),
                    "org_domains": ",".join(sorted(org_domains)),
                    "user_id": user.get("id"),
                    "user_name": user.get("name"),
                    "email": email,
                    "email_domain": email_domain,
                    "organization_id": user.get("organization_id"),
                    "organization_name": org.get("name"),
                    "domain_match": domain_match,
                    "issue_type": issue,
                    "inferred_matching_org_ids": "",
                    "inferred_matching_org_names": "",
                    "suggested_action": "review_user_assignment" if issue else "",
                }
            )

    output_target = output_path or Path(".g_gremlin/zendesk_user_audit.csv")
    headers_out = [
        "org_id",
        "org_name",
        "org_domains",
        "user_id",
        "user_name",
        "email",
        "email_domain",
        "organization_id",
        "organization_name",
        "domain_match",
        "issue_type",
        "inferred_matching_org_ids",
        "inferred_matching_org_names",
        "suggested_action",
    ]
    _write_csv_with_headers(output_target, audit_rows, headers_out)

    mismatch_count = sum(1 for row in audit_rows if row.get("issue_type"))
    summary = {
        "status": "success",
        "organizations_checked": org_count,
        "users_checked": len(audit_rows),
        "mismatches": mismatch_count,
        "output": str(output_target),
    }
    if json_output:
        summary["rows"] = audit_rows
        click.echo(json.dumps(summary, indent=2))
        return
    click.echo(
        f"Audited {len(audit_rows)} user(s) across {org_count} org(s). "
        f"Flagged {mismatch_count} mismatch(es). "
        f"Report written to {output_target}."
    )


@sfdc.command("doctor")
@click.option("--json", "json_output", is_flag=True)
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
def sfdc_doctor(json_output: bool, org_alias: str | None) -> None:
    """
    Diagnose Salesforce CLI availability, commands, and auth.
    """
    checks: List[CapabilityCheck] = []
    identity: Dict[str, Any] = {}
    overall_status = "ok"

    try:
        sf_path = _get_sfdc_helpers()._find_sf_cli()
        version = _get_sfdc_helpers().get_cli_version()
        identity["sf_path"] = sf_path
        identity["sf_version"] = version
        checks.append(CapabilityCheck("sf_cli", "ok", f"{sf_path} ({version})"))
    except Exception as exc:
        checks.append(CapabilityCheck("sf_cli", "error", str(exc)))
        overall_status = "error"

    for name, cmd, level in [
        ("data_query", ["data", "query"], "error"),
        ("data_update_record", ["data", "update", "record"], "error"),
    ]:
        try:
            available = _get_sfdc_helpers().check_command_available(cmd)
            status = "ok" if available else ("warn" if level == "warn" else "error")
            detail = "available" if available else "not found"
            checks.append(CapabilityCheck(f"sfdc_{name}", status, detail))
            if status == "error":
                overall_status = "error"
            elif status == "warn" and overall_status != "error":
                overall_status = "warn"
        except Exception as exc:
            status = "warn" if level == "warn" else "error"
            checks.append(CapabilityCheck(f"sfdc_{name}", status, str(exc)))
            if status == "error":
                overall_status = "error"
            elif overall_status != "error":
                overall_status = "warn"

    try:
        describe_cmd = _get_sfdc_helpers().get_describe_command_path()
        if describe_cmd:
            detail = f"available ({' '.join(describe_cmd)})"
            checks.append(CapabilityCheck("sfdc_schema_describe", "ok", detail))
        else:
            checks.append(CapabilityCheck("sfdc_schema_describe", "warn", "not found"))
            if overall_status == "ok":
                overall_status = "warn"
    except Exception as exc:
        checks.append(CapabilityCheck("sfdc_schema_describe", "warn", str(exc)))
        if overall_status == "ok":
            overall_status = "warn"

    configured_alias = _get_sfdc_helpers().get_configured_org()
    identity["configured_org_alias"] = configured_alias
    alias = org_alias or configured_alias
    if alias:
        identity["target_org_alias"] = alias

    try:
        org_display = _get_sfdc_helpers().get_org_display(org_alias=alias)
        identity["org"] = org_display.get("result") or {}
        checks.append(CapabilityCheck("sfdc_auth", "ok", "authenticated"))
    except Exception as exc:
        checks.append(CapabilityCheck("sfdc_auth", "error", str(exc)))
        overall_status = "error"

    doctor = DoctorResult(provider="sfdc", checks=checks, identity=identity, overall_status=overall_status)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(doctor.to_dict(), context)
        return
    _print_sfdc_header(context)
    click.echo(f"Status: {doctor.overall_status}")
    for check in doctor.checks:
        click.echo(f"{check.name:<20} {check.status} {check.detail}")


@sfdc.command("whoami")
@click.option("--json", "json_output", is_flag=True)
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
def sfdc_whoami(json_output: bool, org_alias: str | None) -> None:
    """
    Show current Salesforce org identity via sf CLI.
    """
    alias = org_alias or _get_sfdc_helpers().get_configured_org()
    try:
        payload = _get_sfdc_helpers().get_org_display(org_alias=alias)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(payload, context)
        return
    _print_sfdc_header(context)
    result = payload.get("result") or {}
    click.echo(json.dumps(result, indent=2))


@sfdc.command("describe")
@click.argument("sobject")
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
@click.option("--fields-only", is_flag=True, help="Show only field names and types.")
def sfdc_describe(
    sobject: str,
    org_alias: str | None,
    json_output: bool,
    fields_only: bool,
) -> None:
    """Describe a Salesforce SObject (fields, record types, etc.)."""
    context = _sfdc_context(org_alias)
    try:
        result = _get_sfdc_helpers().describe_sobject(sobject, org_alias=org_alias)
    except Exception as exc:
        if json_output:
            _emit_sfdc_json({"status": "error", "error": str(exc)}, context)
        else:
            click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        payload = result
        if fields_only:
            payload = {"result": {"fields": (result.get("result") or {}).get("fields", [])}}
        _emit_sfdc_json(payload, context)
        return

    _print_sfdc_header(context)
    obj_result = result.get("result") or {}
    fields = obj_result.get("fields") or []
    click.echo(f"Object: {obj_result.get('name', sobject)}")
    click.echo(f"Label:  {obj_result.get('label', 'n/a')}")
    click.echo(f"Fields: {len(fields)}")
    click.echo("")
    for f in fields:
        ftype = f.get("type", "?")
        name = f.get("name", "?")
        label = f.get("label", "")
        updateable = "rw" if f.get("updateable") else "ro"
        click.echo(f"  {name:<40} {ftype:<15} {updateable}  {label}")


@sfdc.group("reports")
def sfdc_reports() -> None:
    """Salesforce report helpers."""


@sfdc_reports.command("list")
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_reports_list(org_alias: str | None, json_output: bool) -> None:
    context = _sfdc_context(org_alias)
    try:
        reports = _get_sfdc_helpers().list_reports(org_alias=org_alias)
    except Exception as exc:
        if json_output:
            _emit_sfdc_json({"status": "error", "error": str(exc)}, context)
        else:
            click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    payload = {"count": len(reports), "reports": reports}
    if json_output:
        _emit_sfdc_json(payload, context)
        return

    _print_sfdc_header(context)
    click.echo(f"Found {len(reports)} report(s)")
    for report in reports:
        click.echo(
            f"{report.get('Id', '')}  {report.get('Name', '')}  "
            f"({report.get('FolderName', '')})  {report.get('Format', '')}"
        )


@sfdc_reports.command("export")
@click.argument("report_id")
@click.option("--out", "out_path", type=click.Path(path_type=Path), required=False)
@click.option("--api-version", default="62.0", show_default=True)
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_reports_export(
    report_id: str,
    out_path: Path | None,
    api_version: str,
    org_alias: str | None,
    json_output: bool,
) -> None:
    context = _sfdc_context(org_alias)
    try:
        result = _get_sfdc_helpers().export_report(
            report_id,
            org_alias=org_alias,
            api_version=api_version,
        )
    except Exception as exc:
        if json_output:
            _emit_sfdc_json({"status": "error", "error": str(exc)}, context)
        else:
            click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    rows = list(result.get("rows") or [])
    columns = list(result.get("columns") or [])
    if out_path:
        headers = [str(col.get("key") or "") for col in columns if str(col.get("key") or "")]
        if not headers and rows:
            headers = list(rows[0].keys())
        csv_rows = [{h: row.get(h, "") for h in headers} for row in rows] if headers else rows
        out_path.parent.mkdir(parents=True, exist_ok=True)
        _write_rows_to_csv(out_path, csv_rows)

    if json_output:
        payload = dict(result)
        if out_path:
            payload["output"] = str(out_path)
        _emit_sfdc_json(payload, context)
        return

    _print_sfdc_header(context)
    click.echo(f"Report: {result.get('name') or report_id}")
    click.echo(f"Rows: {len(rows)}")
    if out_path:
        click.echo(f"Wrote CSV: {out_path}")


@sfdc.group("docs")
def sfdc_docs() -> None:
    """Salesforce documentation helpers."""


@sfdc_docs.command("generate")
@click.option(
    "--source-dir",
    "source_dir",
    type=click.Path(exists=True, path_type=Path),
    default=Path("force-app"),
    show_default=True,
)
@click.option(
    "--objects",
    required=False,
    help="Comma-separated object API names to describe (e.g., Account,Opportunity).",
)
@click.option("--out", "out_path", type=click.Path(path_type=Path), required=False)
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_docs_generate(
    source_dir: Path,
    objects: str | None,
    out_path: Path | None,
    org_alias: str | None,
    json_output: bool,
) -> None:
    docs_mod = _get_sfdc_docs_helpers()
    object_list = _split_comma_list(objects or "")
    try:
        result = docs_mod.generate_docs(
            source_dir=source_dir,
            objects=object_list,
            org_alias=org_alias,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    markdown = str(result.get("markdown") or "")
    if out_path:
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(markdown, encoding="utf-8")

    context = _sfdc_context(org_alias)
    if json_output:
        payload = dict(result)
        if out_path:
            payload["output"] = str(out_path)
        _emit_sfdc_json(payload, context)
        return

    if out_path:
        _print_sfdc_header(context)
        click.echo(f"Wrote Salesforce docs to {out_path}")
        return
    click.echo(markdown)


@sfdc.command("snapshot")
@click.option("--out", "out_dir", required=True, type=click.Path(path_type=Path))
@click.option("--spec", "spec_path", type=click.Path(path_type=Path), help="Snapshot spec (YAML/JSON).")
@click.option("--object", "object_name", required=False, help="Salesforce object name.")
@click.option("--fields", "fields", required=False, help="Comma-separated fields.")
@click.option("--where", "where", required=False, help="Optional WHERE clause.")
@click.option("--order-by", "order_by", required=False, help="Optional ORDER BY clause.")
@click.option("--limit", "limit", required=False, type=int, help="Optional LIMIT.")
@click.option("--schema", "include_schema", is_flag=True, help="Include schema describes.")
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_snapshot(
    out_dir: Path,
    spec_path: Path | None,
    object_name: str | None,
    fields: str | None,
    where: str | None,
    order_by: str | None,
    limit: int | None,
    include_schema: bool,
    org_alias: str | None,
    json_output: bool,
) -> None:
    """
    Create a deterministic SFDC data snapshot with manifest + hashes.
    """
    snapshot_mod = _get_sfdc_snapshot_helpers()
    if spec_path:
        spec = snapshot_mod.load_snapshot_spec(spec_path)
    else:
        if not object_name or not fields:
            click.echo("Error: provide --spec or --object + --fields.", err=True)
            sys.exit(EXIT_USAGE)
        field_list = [f.strip() for f in fields.split(",") if f.strip()]
        spec = {
            "version": 1,
            "objects": [
                {
                    "name": object_name,
                    "fields": field_list,
                    "where": where,
                    "order_by": order_by,
                    "limit": limit,
                }
            ],
        }

    try:
        result = snapshot_mod.snapshot(out_dir, spec=spec, org_alias=org_alias, include_schema=include_schema)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(result.to_dict(), context)
        return
    _print_sfdc_header(context)
    click.echo(f"Snapshot written to {result.out_dir}")
    click.echo(f"Manifest: {result.manifest_path}")
    if result.warnings:
        click.echo(f"Warnings: {len(result.warnings)}")


@sfdc.group("pack")
def sfdc_pack() -> None:
    """
    Salesforce data pack helpers (plan -> review -> apply).

    \b
    Workflow:
      1) g-gremlin sfdc pack init my_pack          # scaffold pack directory
      2) g-gremlin sfdc pack plan my_pack --json    # generate change plan + plan_hash
      3) # Review the plan output
      4) g-gremlin sfdc pack apply my_pack --yes [--plan-hash HASH]  # apply with receipt

    \b
    The apply step requires --yes and optionally --plan-hash for CI/MCP handshake.
    Use --replan if the org has drifted since the last plan.
    """


@sfdc_pack.command("init")
@click.argument("dir_path", type=click.Path())
@click.option("--json", "json_output", is_flag=True)
def sfdc_pack_init(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_sfdc_pack_helpers()
    pack_mod.init_pack(Path(dir_path))
    context = _sfdc_context(None)
    if json_output:
        _emit_sfdc_json({"status": "ok", "path": dir_path}, context)
        return
    _print_sfdc_header(context)
    click.echo(f"Initialized pack in {dir_path}")


@sfdc_pack.command("plan")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_pack_plan(dir_path: str, org_alias: str | None, json_output: bool) -> None:
    pack_mod = _get_sfdc_pack_helpers()
    try:
        plan = pack_mod.plan_pack(Path(dir_path), org_alias=org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(plan.to_dict(), context)
        if plan.payload.get("validation", {}).get("blocked"):
            sys.exit(EXIT_USAGE)
        return
    _print_sfdc_header(context)
    summary = plan.payload.get("summary") or {}
    click.echo(
        f"Plan: {summary.get('records_with_changes', 0)} change(s) out of "
        f"{summary.get('total_records', 0)} record(s)."
    )
    validation = plan.payload.get("validation") or {}
    if validation.get("warnings"):
        click.echo(f"Warnings: {len(validation.get('warnings'))}")
    if validation.get("blocked"):
        for err in validation.get("errors") or []:
            click.echo(f"ERROR: {err}")
        sys.exit(EXIT_USAGE)


@sfdc_pack.command("apply")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--yes", is_flag=True, required=True, help="Required to apply.")
@click.option("--replan", is_flag=True, help="Regenerate plan if drifted.")
@click.option("--plan", "plan_path", type=click.Path(path_type=Path), help="Path to plan.json.")
@click.option(
    "--plan-hash",
    "expected_plan_hash",
    required=False,
    help="Expected plan hash (for CI/MCP handshake).",
)
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_pack_apply(
    dir_path: str,
    yes: bool,
    replan: bool,
    plan_path: Path | None,
    expected_plan_hash: str | None,
    org_alias: str | None,
    json_output: bool,
) -> None:
    safety_ctx = SafetyContext(command="sfdc.pack.apply", remote_mutation=True)
    try:
        safety_ctx.enforce_apply(yes)
    except click.ClickException as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    pack_mod = _get_sfdc_pack_helpers()
    try:
        result = pack_mod.apply_pack(
            Path(dir_path),
            org_alias=org_alias,
            plan_path=plan_path,
            yes=yes,
            replan=replan,
            expected_plan_hash=expected_plan_hash,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(result.to_dict(), context)
        return
    _print_sfdc_header(context)
    summary = result.payload.get("summary") or {}
    click.echo(
        f"Applied {summary.get('updated', 0)} updates "
        f"({summary.get('failed', 0)} failed)."
    )


@sfdc.group("metadata-pack")
def sfdc_metadata_pack() -> None:
    """
    Salesforce metadata pack helpers (plan -> review -> apply -> verify).

    \b
    Workflow:
      1) g-gremlin sfdc metadata-pack init my_pack       # scaffold
      2) g-gremlin sfdc metadata-pack plan my_pack --json # generate plan + plan_hash
      3) # Review the plan output
      4) g-gremlin sfdc metadata-pack apply my_pack --yes --plan-hash HASH
      5) g-gremlin sfdc metadata-pack verify my_pack      # confirm deployment
      6) g-gremlin sfdc metadata-pack drift my_pack       # detect org drift later

    \b
    The apply step requires --yes and --plan-hash (from the plan output).
    Use --check-only to validate without deploying.
    Use --replan if the org has drifted since the last plan.

    \b
    Do NOT use 'sf project deploy' directly -- it bypasses plan-hash
    verification, receipt generation, and drift detection.
    """


@sfdc_metadata_pack.command("init")
@click.argument("dir_path", type=click.Path())
@click.option("--json", "json_output", is_flag=True)
def sfdc_metadata_pack_init(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_sfdc_metadata_pack_helpers()
    pack_mod.init_pack(Path(dir_path))
    context = _sfdc_context(None)
    if json_output:
        _emit_sfdc_json({"status": "ok", "path": dir_path}, context)
        return
    _print_sfdc_header(context)
    click.echo(f"Initialized metadata pack in {dir_path}")


@sfdc_metadata_pack.command("plan")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_metadata_pack_plan(dir_path: str, org_alias: str | None, json_output: bool) -> None:
    pack_mod = _get_sfdc_metadata_pack_helpers()
    try:
        plan = pack_mod.plan_pack(Path(dir_path), org_alias=org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(plan.to_dict(), context)
        if plan.payload.get("validation", {}).get("blocked"):
            sys.exit(EXIT_USAGE)
        return
    _print_sfdc_header(context)
    summary = plan.payload.get("summary") or {}
    click.echo(
        f"Plan: {summary.get('added', 0)} add(s), "
        f"{summary.get('changed', 0)} change(s), "
        f"{summary.get('removed', 0)} remove(s)."
    )
    validation = plan.payload.get("validation") or {}
    if validation.get("blocked"):
        for err in validation.get("errors") or []:
            click.echo(f"ERROR: {err}")
        sys.exit(EXIT_USAGE)


@sfdc_metadata_pack.command("apply")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--yes", is_flag=True, required=True, help="Required to apply.")
@click.option("--check-only", is_flag=True, help="Validation-only deploy (dry-run).")
@click.option("--replan", is_flag=True, help="Regenerate plan if drifted.")
@click.option("--plan", "plan_path", type=click.Path(path_type=Path), help="Path to plan.json.")
@click.option(
    "--plan-hash",
    "expected_plan_hash",
    required=False,
    help="Expected plan hash (for CI/MCP handshake).",
)
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_metadata_pack_apply(
    dir_path: str,
    yes: bool,
    check_only: bool,
    replan: bool,
    plan_path: Path | None,
    expected_plan_hash: str | None,
    org_alias: str | None,
    json_output: bool,
) -> None:
    safety_ctx = SafetyContext(command="sfdc.metadata_pack.apply", remote_mutation=True)
    try:
        safety_ctx.enforce_apply(yes)
    except click.ClickException as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    pack_mod = _get_sfdc_metadata_pack_helpers()
    try:
        result = pack_mod.apply_pack(
            Path(dir_path),
            org_alias=org_alias,
            plan_path=plan_path,
            yes=yes,
            replan=replan,
            check_only=check_only,
            expected_plan_hash=expected_plan_hash,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(result.to_dict(), context)
        return
    _print_sfdc_header(context)
    summary = result.payload.get("summary") or {}
    click.echo(
        f"Applied {summary.get('deployed', 0)} component(s) "
        f"({summary.get('failed', 0)} failed)."
    )


@sfdc_metadata_pack.command("drift")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_metadata_pack_drift(dir_path: str, org_alias: str | None, json_output: bool) -> None:
    pack_mod = _get_sfdc_metadata_pack_helpers()
    try:
        drift = pack_mod.compute_drift(Path(dir_path), org_alias=org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(drift.to_dict(), context)
        return
    _print_sfdc_header(context)
    payload = drift.payload
    click.echo(f"Drift: {'yes' if payload.get('has_drift') else 'no'}")


@sfdc_metadata_pack.command("verify")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_metadata_pack_verify(dir_path: str, org_alias: str | None, json_output: bool) -> None:
    pack_mod = _get_sfdc_metadata_pack_helpers()
    try:
        report = pack_mod.verify_pack(Path(dir_path), org_alias=org_alias)
    except ValueError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(report.to_dict(), context)
        return
    _print_sfdc_header(context)
    summary = report.summary
    click.echo(
        f"Smoke: {summary['passed']} passed, {summary['failed']} failed, {summary['skipped']} skipped"
    )
    for result in report.results:
        status = "PASS" if result.passed else ("SKIP" if result.skipped else "FAIL")
        detail = f" - {result.detail}" if result.detail else ""
        click.echo(f"{status} {result.assertion.type}{detail}")
    if not report.all_passed:
        sys.exit(EXIT_REMOTE_ERROR)


@sfdc_metadata_pack.command("doctor")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--json", "json_output", is_flag=True)
def sfdc_metadata_pack_doctor(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_sfdc_metadata_pack_helpers()
    result = pack_mod.pack_doctor(Path(dir_path))
    context = _sfdc_context(None)
    if json_output:
        _emit_sfdc_json(result, context)
        return
    _print_sfdc_header(context)
    if result.get("ok"):
        click.echo("Metadata pack structure looks good.")
    else:
        click.echo(f"Missing: {', '.join(result.get('missing_files') or [])}")
    for note in result.get("notes") or []:
        click.echo(f"Note: {note}")


@sfdc.command("manifest-generate")
@click.argument("source_dir", type=click.Path(exists=True, path_type=Path))
@click.option("--output", type=click.Path(path_type=Path))
@click.option("--api-version", default="62.0")
@click.option(
    "--include",
    "include_types",
    multiple=True,
    help="Metadata types to include (e.g., CustomField, Flow). Repeat for multiple.",
)
@click.option("--json", "json_output", is_flag=True)
def sfdc_manifest_generate(
    source_dir: Path,
    output: Path | None,
    api_version: str,
    include_types: tuple[str, ...],
    json_output: bool,
) -> None:
    from g_gremlin.sfdc_metadata_components import MetadataType, scan_source_dir
    from g_gremlin.sfdc_metadata_manifest import generate_package_xml, write_package_xml

    include_set = None
    if include_types:
        include_set = set()
        for raw in include_types:
            token = str(raw or "").strip()
            if not token:
                continue
            matched = next(
                (
                    mtype
                    for mtype in MetadataType
                    if token.lower() in {mtype.value.lower(), mtype.name.lower()}
                ),
                None,
            )
            if matched is None:
                choices = ", ".join(sorted(m.value for m in MetadataType))
                click.echo(f"Error: Unknown metadata type '{token}'. Valid values: {choices}", err=True)
                sys.exit(EXIT_USAGE)
            include_set.add(matched)

    try:
        inventory = scan_source_dir(source_dir, include=include_set)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    if output:
        write_package_xml(output, inventory.components, api_version)
        xml_text = None
    else:
        xml_text = generate_package_xml(inventory.components, api_version)

    if json_output:
        payload = {
            "component_count": len(inventory.components),
            "types": sorted({comp.type.value for comp in inventory.components}),
            "api_version": str(api_version),
        }
        if output:
            payload["output"] = str(output)
        if include_set is not None:
            payload["include"] = sorted(mtype.value for mtype in include_set)
        _emit_sfdc_json(payload, {"target": None, "local_only": True})
        return

    if output:
        click.echo(f"Wrote package.xml with {len(inventory.components)} component(s) to {output}")
        return
    click.echo(xml_text or "", nl=False)


@sfdc.command("drift")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_drift(dir_path: str, org_alias: str | None, json_output: bool) -> None:
    pack_mod = _get_sfdc_pack_helpers()
    try:
        drift = pack_mod.compute_drift(Path(dir_path), org_alias=org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(drift.to_dict(), context)
        return
    _print_sfdc_header(context)
    payload = drift.payload
    click.echo(f"Drift: {'yes' if payload.get('has_drift') else 'no'}")
    if payload.get("csv_changed"):
        click.echo("CSV changed since plan.")


@sfdc.command("smoke")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--target", "org_alias", required=False, help="Override configured org alias.")
@click.option("--org-alias", "org_alias", required=False, hidden=True)
@click.option("--json", "json_output", is_flag=True)
def sfdc_smoke(dir_path: str, org_alias: str | None, json_output: bool) -> None:
    pack_mod = _get_sfdc_pack_helpers()
    smoke_mod = _get_sfdc_smoke_helpers()
    pack = pack_mod.load_pack_yaml(Path(dir_path) / "pack.yaml")
    assertions = smoke_mod.load_smoke_assertions(pack)
    try:
        report = smoke_mod.run_smoke(assertions, org_alias=org_alias)
    except ValueError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    if json_output:
        _emit_sfdc_json(report.to_dict(), context)
    else:
        _print_sfdc_header(context)
        summary = report.summary
        click.echo(
            f"Smoke: {summary['passed']} passed, {summary['failed']} failed, {summary['skipped']} skipped"
        )
        for result in report.results:
            status = "PASS" if result.passed else ("SKIP" if result.skipped else "FAIL")
            detail = f" - {result.detail}" if result.detail else ""
            click.echo(f"{status} {result.assertion.type}{detail}")
    if not report.all_passed:
        sys.exit(EXIT_REMOTE_ERROR)


@sfdc.command("connect")
@click.option(
    "--target",
    "org_alias",
    required=True,
    help="Salesforce org alias configured in sf CLI (e.g., 'canopy').",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    hidden=True,
)
@click.option(
    "--default",
    "set_default",
    is_flag=True,
    help="(Optional) mark this org as the default (currently a no-op placeholder).",
)
def sfdc_connect(org_alias: str, set_default: bool) -> None:
    """
    Connect to a Salesforce org via the sf CLI and store the alias.
    """
    try:
        _get_sfdc_helpers().configure_org(org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    suffix = " (set as default)" if set_default else ""
    context = _sfdc_context(org_alias)
    _print_sfdc_header(context)
    click.echo(f"Configured Salesforce org alias '{org_alias}'{suffix}. Config saved to .g_gremlin/sfdc.json.")


@sfdc.group("orgs")
def sfdc_orgs() -> None:
    """Salesforce org helpers."""


@sfdc_orgs.command("list")
@click.option("--json", "json_output", is_flag=True)
def sfdc_orgs_list(json_output: bool) -> None:
    try:
        payload = _get_sfdc_helpers().list_orgs()
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(None)
    if json_output:
        _emit_sfdc_json(payload, context)
        return
    _print_sfdc_header(context)
    click.echo(json.dumps(payload.get("result") or payload, indent=2))


@sfdc.group("target")
def sfdc_target() -> None:
    """Manage the configured target org alias."""


@sfdc_target.command("set")
@click.argument("org_alias")
@click.option("--json", "json_output", is_flag=True)
def sfdc_target_set(org_alias: str, json_output: bool) -> None:
    previous = _get_sfdc_helpers().get_configured_org()
    try:
        _get_sfdc_helpers().configure_org(org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    context = _sfdc_context(org_alias)
    payload = {
        "previous": previous,
        "current": org_alias,
        "changed": previous != org_alias,
    }
    if json_output:
        _emit_sfdc_json(payload, context)
        return
    _print_sfdc_header(context)
    if previous == org_alias:
        click.echo(f"Target org remains '{org_alias}'.")
    else:
        click.echo(f"Target org set to '{org_alias}' (was '{previous}').")


@sfdc.command("enrich")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV to enrich.",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="Column containing Salesforce Ids (15/18 char).",
)
@click.option(
    "--object",
    "sobject",
    required=True,
    help="Salesforce object name (e.g., Account, Contact).",
)
@click.option(
    "--fields",
    "fields",
    required=True,
    help="Comma-separated list of fields to pull (e.g., Type,Industry,BillingCity).",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the enriched CSV.",
)
@click.option(
    "--target",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    hidden=True,
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sfdc_enrich(
    csv_path: Path,
    id_column: str,
    sobject: str,
    fields: str,
    output_path: Path,
    org_alias: str | None,
    json_summary: bool,
) -> None:
    """
    Enrich a CSV by pulling fields from Salesforce using sf CLI.
    """
    field_list_raw = [f.strip() for f in fields.split(",") if f.strip()]
    if not field_list_raw:
        click.echo("Error: --fields must include at least one field.")
        sys.exit(1)
    context = _sfdc_context(org_alias)
    if not json_summary:
        _print_sfdc_header(context)
    field_specs = [_parse_field_spec(f) for f in field_list_raw]
    try:
        _get_sfdc_helpers().enrich_csv_with_sfdc(
            csv_path, id_column, sobject, field_specs, output_path, org_alias
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Enriched {csv_path} with fields {', '.join(field_list_raw)} from {sobject}. "
        f"Output written to {output_path}."
    )

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sfdc enrich", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "context": context,
                "csv": str(csv_path),
                "output": str(output_path),
                "object": sobject,
                "fields": field_list_raw,
                "id_column": id_column,
                "org_alias": org_alias,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@sfdc.command("audit")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV to audit.",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="Column containing Salesforce Ids (15/18 char).",
)
@click.option(
    "--object",
    "sobject",
    required=True,
    help="Salesforce object name (e.g., Account, Contact).",
)
@click.option(
    "--report",
    "report_path",
    required=False,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional path to write JSON report (default: .g_gremlin/sfdc_audit.json).",
)
@click.option(
    "--target",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    hidden=True,
)
def sfdc_audit(
    csv_path: Path,
    id_column: str,
    sobject: str,
    report_path: Path | None,
    org_alias: str | None,
) -> None:
    """
    Audit IDs in a CSV: format, prefix, duplicates, and existence in Salesforce.
    """
    report_target = report_path or Path(".g_gremlin/sfdc_audit.json")
    context = _sfdc_context(org_alias)
    _print_sfdc_header(context)
    try:
        report = _get_sfdc_helpers().audit_ids(csv_path, id_column, sobject, org_alias=org_alias)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    report_target.parent.mkdir(parents=True, exist_ok=True)
    report_target.write_text(json.dumps(report, indent=2), encoding="utf-8")

    issues = report.get("issues", {})
    click.echo(
        f"Audited {report.get('checked_ids', 0)} IDs from {csv_path}. "
        f"Invalid format: {len(issues.get('invalid_format', []))}, "
        f"Wrong prefix: {len(issues.get('wrong_prefix', []))}, "
        f"Missing in SFDC: {len(issues.get('missing_in_sfdc', []))}, "
        f"Duplicates: {len(issues.get('duplicates', []))}. "
        f"Report written to {report_target}."
    )


@sfdc.command("merge-apply-plan")
@click.option(
    "--plan",
    "plan_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to crm_dedupe MergePlan/v2 JSON generated by dedup enterprise-plan.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute planned merges (default is dry-run preview).",
)
@click.option(
    "--converted-status",
    default=None,
    help="LeadStatus value used for convertLead operations when applying mixed Lead/Contact clusters.",
)
@click.option(
    "--do-not-create-opportunity/--create-opportunity",
    default=True,
    show_default=True,
    help="Whether convertLead operations should suppress opportunity creation.",
)
@click.option(
    "--continue-on-error/--fail-fast",
    default=True,
    show_default=True,
    help="Continue applying remaining Salesforce operations when one operation fails.",
)
@click.option(
    "--state-file",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Optional JSON state file for resumable execution.",
)
@click.option(
    "--resume",
    is_flag=True,
    help="Resume from --state-file progress (requires --apply).",
)
@click.option(
    "--receipt-file",
    type=click.Path(dir_okay=False, path_type=Path),
    default=None,
    help="Optional JSON receipt written after dry-run or apply.",
)
@click.option(
    "--max-retries",
    type=int,
    default=3,
    show_default=True,
    help="Retry attempts for retryable merge failures (401/409/429/5xx).",
)
@click.option(
    "--retry-base-delay",
    type=float,
    default=1.0,
    show_default=True,
    help="Initial backoff delay in seconds.",
)
@click.option(
    "--retry-max-delay",
    type=float,
    default=20.0,
    show_default=True,
    help="Maximum backoff delay in seconds.",
)
@click.option(
    "--progress-every",
    type=int,
    default=25,
    show_default=True,
    help="Emit a progress line every N operations during apply (0 disables periodic progress).",
)
@click.option(
    "--target",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    hidden=True,
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sfdc_merge_apply_plan(
    plan_path: Path,
    apply: bool,
    converted_status: str | None,
    do_not_create_opportunity: bool,
    continue_on_error: bool,
    state_file: Path | None,
    resume: bool,
    receipt_file: Path | None,
    max_retries: int,
    retry_base_delay: float,
    retry_max_delay: float,
    progress_every: int,
    org_alias: str | None,
    json_summary: bool,
) -> None:
    """Apply Salesforce dedupe operations from a crm_dedupe enterprise plan."""
    safety_ctx = SafetyContext(command="g-gremlin sfdc merge-apply-plan", remote_mutation=True)
    context = _sfdc_context(org_alias)
    if not json_summary:
        _print_sfdc_header(context)
    if max_retries < 0:
        raise click.ClickException("--max-retries must be >= 0.")
    if retry_base_delay <= 0:
        raise click.ClickException("--retry-base-delay must be > 0.")
    if retry_max_delay <= 0:
        raise click.ClickException("--retry-max-delay must be > 0.")
    if retry_base_delay > retry_max_delay:
        raise click.ClickException("--retry-base-delay must be <= --retry-max-delay.")
    if progress_every < 0:
        raise click.ClickException("--progress-every must be >= 0.")
    if resume and state_file is None:
        raise click.ClickException("--resume requires --state-file.")
    if resume and not apply:
        raise click.ClickException("--resume can only be used with --apply.")

    dedupe_apply = _get_sfdc_dedupe_apply_helpers()
    try:
        plan_text = plan_path.read_text(encoding="utf-8")
        payload = json.loads(plan_text)
    except Exception as exc:
        raise click.ClickException(f"Invalid merge plan JSON: {exc}") from exc

    try:
        merge_plan = dedupe_apply.build_dedupe_operations(
            payload,
            converted_status=converted_status,
            do_not_create_opportunity=do_not_create_opportunity,
        )
    except Exception as exc:
        raise click.ClickException(str(exc)) from exc

    operations = list(merge_plan.get("operations") or [])
    skipped = list(merge_plan.get("skipped") or [])
    skipped_by_reason = dict((merge_plan.get("stats") or {}).get("skipped_by_reason") or {})
    plan_digest_value = dedupe_apply.plan_digest(plan_text)
    target_org = str(org_alias or context.get("target") or "").strip()
    sfdc_helpers = _get_sfdc_helpers()

    def _operation_type(entry: Dict[str, Any]) -> str:
        return str(entry.get("operation_type") or "merge").strip().lower() or "merge"

    def _operation_type_counts(entries: List[Dict[str, Any]]) -> Dict[str, int]:
        counts: Dict[str, int] = {}
        for candidate in entries:
            if not isinstance(candidate, dict):
                continue
            op_type = _operation_type(candidate)
            counts[op_type] = counts.get(op_type, 0) + 1
        return counts

    def _operation_type_label(op_type: str) -> str:
        return "convertLead" if op_type == "convert_lead" else op_type

    def _operation_mix_summary(entries: List[Dict[str, Any]]) -> str:
        counts = _operation_type_counts(entries)
        if not counts:
            return ""
        parts = [f"{_operation_type_label(op_type)}={counts[op_type]}" for op_type in sorted(counts)]
        return ", ".join(parts)

    operation_mix_summary = _operation_mix_summary(operations)
    missing_convert_status = [
        entry
        for entry in operations
        if isinstance(entry, dict)
        and _operation_type(entry) == "convert_lead"
        and not str(entry.get("converted_status") or "").strip()
    ]

    if not apply:
        click.echo(f"[DRY-RUN] Would apply {len(operations)} Salesforce dedupe operation(s) from {plan_path}.")
        if operation_mix_summary:
            click.echo(f"Operation mix: {operation_mix_summary}.")
        click.echo(f"Skipped {len(skipped)} cluster(s) during execution planning.")
        if skipped_by_reason.get("requires_manual_cross_object_resolution"):
            click.echo(
                "Complex Lead-to-Contact clusters still require manual review: "
                f"{int(skipped_by_reason['requires_manual_cross_object_resolution'])}."
            )
        if receipt_file is not None:
            receipt = dedupe_apply.make_receipt(
                command="g-gremlin sfdc merge-apply-plan",
                plan_path=plan_path,
                plan_digest_value=plan_digest_value,
                org_alias=target_org,
                dry_run=True,
                operations=operations,
                skipped=skipped,
                succeeded=[],
                failed=[],
                resume=resume,
                state_file=state_file,
            )
            dedupe_apply.write_receipt(receipt_file, receipt)
            click.echo(f"Receipt written to {receipt_file}.")
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "context": context,
                    "plan": str(plan_path),
                    "operations_planned": len(operations),
                    "operation_mix": operation_mix_summary,
                    "skipped": skipped,
                    "skipped_by_reason": skipped_by_reason,
                    "receipt_file": str(receipt_file) if receipt_file else "",
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if missing_convert_status:
        raise click.ClickException(
            "--converted-status is required to apply convertLead operations from mixed Lead/Contact clusters."
        )

    click.echo(
        f"Applying {len(operations)} Salesforce dedupe operation(s)"
        + (f" to org {target_org}." if target_org else ".")
    )
    if operation_mix_summary:
        click.echo(f"Operation mix: {operation_mix_summary}.")

    state_payload: Dict[str, Any] | None = None
    run_started_at = datetime.now(timezone.utc).isoformat()
    start_index = 0
    succeeded_by_index: Dict[int, Dict[str, Any]] = {}
    failed_by_index: Dict[int, Dict[str, Any]] = {}

    def _merge_state_index(value: Dict[str, Any], *, label: str) -> int | None:
        try:
            return int(value.get("index"))
        except Exception:
            if state_file is not None:
                click.echo(
                    f"Warning: ignoring {label} state entry with invalid index in {state_file}.",
                    err=True,
                )
            return None

    def _state_entries_by_index(entries: List[Dict[str, Any]], *, label: str) -> Dict[int, Dict[str, Any]]:
        mapped: Dict[int, Dict[str, Any]] = {}
        for entry in entries:
            if not isinstance(entry, dict):
                if state_file is not None:
                    click.echo(
                        f"Warning: ignoring non-object {label} state entry in {state_file}.",
                        err=True,
                    )
                continue
            entry_index = _merge_state_index(entry, label=label)
            if entry_index is None:
                continue
            mapped[entry_index] = dict(entry)
        return mapped

    def _ordered_state_entries(entries_by_index: Dict[int, Dict[str, Any]]) -> List[Dict[str, Any]]:
        return [entries_by_index[index] for index in sorted(entries_by_index)]

    def _persist_state(next_index: int, *, interrupted_run: bool = False) -> None:
        if state_payload is None:
            return
        updated_at = datetime.now(timezone.utc).isoformat()
        state_payload["succeeded"] = _ordered_state_entries(succeeded_by_index)
        state_payload["failed"] = _ordered_state_entries(failed_by_index)
        state_payload["updated_at"] = updated_at
        state_payload["next_index"] = next_index
        if interrupted_run:
            state_payload["interrupted_at"] = updated_at
        else:
            state_payload.pop("interrupted_at", None)
        dedupe_apply.write_state(state_file, state_payload)

    def _emit_progress(*, force: bool = False) -> None:
        if progress_every == 0:
            return
        processed_total = len(succeeded_by_index) + len(failed_by_index)
        if processed_total == 0:
            return
        if not force and processed_total % progress_every != 0:
            return
        click.echo(
            f"Progress: processed {processed_total}/{len(operations)} dedupe operation(s); "
            f"succeeded {len(succeeded_by_index)}, failed {len(failed_by_index)}."
        )

    if state_file:
        if resume:
            try:
                state_payload = dedupe_apply.load_state(state_file)
            except Exception as exc:
                raise click.ClickException(str(exc)) from exc
            state_plan_digest = str(state_payload.get("plan_digest") or "").strip()
            if state_plan_digest and state_plan_digest != plan_digest_value:
                raise click.ClickException(
                    f"State file plan digest does not match {plan_path}. Use a matching plan or remove --resume."
                )
            state_total_operations = int(state_payload.get("total_operations") or 0)
            if state_total_operations and state_total_operations != len(operations):
                raise click.ClickException(
                    f"State file total_operations {state_total_operations} does not match plan operations {len(operations)}."
                )
            state_org_alias = str(state_payload.get("org_alias") or "").strip()
            if state_org_alias and target_org and state_org_alias != target_org:
                raise click.ClickException(
                    f"State file org_alias '{state_org_alias}' does not match target org '{target_org}'."
                )
            start_index = max(0, min(int(state_payload.get("next_index") or 0), len(operations)))
            succeeded_by_index = _state_entries_by_index(list(state_payload.get("succeeded") or []), label="succeeded")
            failed_by_index = _state_entries_by_index(list(state_payload.get("failed") or []), label="failed")
            click.echo(f"Resuming Salesforce dedupe plan from index {start_index} using {state_file}.")
        else:
            state_payload = {
                "version": 1,
                "command": "g-gremlin sfdc merge-apply-plan",
                "plan": str(plan_path),
                "plan_digest": plan_digest_value,
                "org_alias": target_org,
                "total_operations": len(operations),
                "skipped": skipped,
                "continue_on_error": continue_on_error,
                "max_retries": max_retries,
                "retry_base_delay_seconds": retry_base_delay,
                "retry_max_delay_seconds": retry_max_delay,
                "converted_status": str(converted_status or "").strip(),
                "do_not_create_opportunity": bool(do_not_create_opportunity),
                "started_at": run_started_at,
                "updated_at": run_started_at,
                "next_index": 0,
                "succeeded": [],
                "failed": [],
            }
            dedupe_apply.write_state(state_file, state_payload)

    interrupted = False
    for idx in range(start_index, len(operations)):
        entry = operations[idx]
        if not isinstance(entry, dict):
            failed_by_index[idx] = {"index": idx, "error": "invalid_entry", "retryable": False, "attempts": 0}
            succeeded_by_index.pop(idx, None)
            _persist_state(idx if not continue_on_error else idx + 1)
            _emit_progress(force=(not continue_on_error or idx == len(operations) - 1))
            if not continue_on_error:
                break
            continue

        operation_type = _operation_type(entry)
        base_entry: Dict[str, Any] = {
            "index": idx,
            "operation_id": entry.get("operation_id"),
            "cluster_id": entry.get("cluster_id"),
            "operation_type": operation_type,
        }
        execute_operation = None
        operation_preview = ""
        success_payload: Dict[str, Any] = {}

        if operation_type == "merge":
            object_type = str(entry.get("object_type") or "").strip()
            master_id = str(entry.get("master_id") or "").strip()
            victim_ids = [str(value).strip() for value in (entry.get("victim_ids") or []) if str(value).strip()]
            success_payload = {
                "object_type": object_type,
                "master_id": master_id,
                "victim_ids": victim_ids,
            }
            operation_preview = f"{master_id} <- {', '.join(victim_ids)}"
            if not object_type or not master_id or not victim_ids:
                failed_by_index[idx] = (
                    {
                        **base_entry,
                        "error": "missing_object_type_master_or_victim_ids",
                        "retryable": False,
                        "attempts": 0,
                    }
                )
                succeeded_by_index.pop(idx, None)
                _persist_state(idx if not continue_on_error else idx + 1)
                _emit_progress(force=(not continue_on_error or idx == len(operations) - 1))
                if not continue_on_error:
                    break
                continue

            execute_operation = partial(
                sfdc_helpers.merge_records,
                object_type,
                master_id,
                victim_ids,
                org_alias=org_alias,
            )
        elif operation_type == "convert_lead":
            lead_id = str(entry.get("lead_id") or "").strip()
            converted_contact_id = str(entry.get("converted_contact_id") or "").strip()
            converted_account_id = str(entry.get("converted_account_id") or "").strip()
            operation_converted_status = str(entry.get("converted_status") or "").strip()
            operation_do_not_create = bool(entry.get("do_not_create_opportunity", do_not_create_opportunity))
            success_payload = {
                "lead_id": lead_id,
                "converted_contact_id": converted_contact_id,
                "converted_account_id": converted_account_id,
                "converted_status": operation_converted_status,
                "do_not_create_opportunity": operation_do_not_create,
            }
            operation_preview = f"{lead_id} -> {converted_contact_id}"
            if not lead_id or not converted_contact_id or not operation_converted_status:
                failed_by_index[idx] = (
                    {
                        **base_entry,
                        **success_payload,
                        "error": "missing_lead_id_converted_contact_id_or_converted_status",
                        "retryable": False,
                        "attempts": 0,
                    }
                )
                succeeded_by_index.pop(idx, None)
                _persist_state(idx if not continue_on_error else idx + 1)
                _emit_progress(force=(not continue_on_error or idx == len(operations) - 1))
                if not continue_on_error:
                    break
                continue

            execute_operation = partial(
                sfdc_helpers.convert_lead,
                lead_id,
                converted_contact_id=converted_contact_id,
                converted_account_id=converted_account_id or None,
                converted_status=operation_converted_status,
                do_not_create_opportunity=operation_do_not_create,
                org_alias=org_alias,
            )
        else:
            succeeded_by_index.pop(idx, None)
            failed_by_index[idx] = {
                **base_entry,
                "error": f"unsupported_operation_type:{operation_type or 'unknown'}",
                "retryable": False,
                "attempts": 0,
            }
            _persist_state(idx if not continue_on_error else idx + 1)
            _emit_progress(force=(not continue_on_error or idx == len(operations) - 1))
            if not continue_on_error:
                break
            continue

        attempt_num = 0
        operation_succeeded = False
        while True:
            try:
                response = execute_operation()
                succeeded_by_index[idx] = (
                    {
                        **base_entry,
                        **success_payload,
                        "response": response,
                        "attempts": attempt_num + 1,
                    }
                )
                failed_by_index.pop(idx, None)
                operation_succeeded = True
                break
            except KeyboardInterrupt:
                interrupted = True
                break
            except Exception as exc:
                retryable = dedupe_apply.is_retryable_merge_error(exc)
                if retryable and attempt_num < max_retries:
                    attempt_num += 1
                    delay = dedupe_apply.retry_delay(attempt_num, retry_base_delay, retry_max_delay)
                    click.echo(
                        f"Retryable Salesforce operation failure for index {idx} "
                        f"({_operation_type_label(operation_type)} {operation_preview}); "
                        f"retry {attempt_num}/{max_retries} in {delay:.1f}s: {exc}"
                    )
                    time.sleep(delay)
                    continue
                failed_by_index[idx] = (
                    {
                        **base_entry,
                        **success_payload,
                        "error": str(exc),
                        "retryable": retryable,
                        "attempts": attempt_num + 1,
                    }
                )
                succeeded_by_index.pop(idx, None)
                break

        if interrupted:
            _persist_state(idx, interrupted_run=True)
        elif operation_succeeded or continue_on_error:
            _persist_state(idx + 1)
        else:
            _persist_state(idx)
        if not interrupted:
            _emit_progress(force=(idx == len(operations) - 1 or (not operation_succeeded and not continue_on_error)))

        if interrupted:
            break
        if not operation_succeeded and not continue_on_error:
            break

    if interrupted:
        click.echo(
            "Salesforce dedupe apply interrupted. Progress was saved to state file."
            if state_file
            else "Salesforce dedupe apply interrupted."
        )
        raise click.ClickException("Interrupted. Re-run with --resume to continue.")

    succeeded = _ordered_state_entries(succeeded_by_index)
    failed = _ordered_state_entries(failed_by_index)
    if state_payload is not None:
        state_payload["finished_at"] = datetime.now(timezone.utc).isoformat()
        if state_payload.get("next_index") is None:
            state_payload["next_index"] = len(operations)
        if int(state_payload.get("next_index") or 0) >= len(operations):
            state_payload["completed"] = True
            state_payload["next_index"] = len(operations)
        else:
            state_payload["completed"] = False
        dedupe_apply.write_state(state_file, state_payload)

    receipt_payload = dedupe_apply.make_receipt(
        command="g-gremlin sfdc merge-apply-plan",
        plan_path=plan_path,
        plan_digest_value=plan_digest_value,
        org_alias=target_org,
        dry_run=False,
        operations=operations,
        skipped=skipped,
        succeeded=succeeded,
        failed=failed,
        resume=resume,
        state_file=state_file,
    )
    if receipt_file is not None:
        dedupe_apply.write_receipt(receipt_file, receipt_payload)

    click.echo(
        "Salesforce merge-apply-plan finished. "
        f"Succeeded: {len(succeeded)}, Failed: {len(failed)}, Skipped: {len(skipped)}."
    )
    if skipped_by_reason.get("requires_manual_cross_object_resolution"):
        click.echo(
            "Complex Lead-to-Contact clusters still pending manual review: "
            f"{int(skipped_by_reason['requires_manual_cross_object_resolution'])}."
        )
    if state_file:
        next_index = int((state_payload or {}).get("next_index") or 0)
        click.echo(f"State saved to {state_file} (next_index={next_index}).")
    if receipt_file is not None:
        click.echo(f"Receipt written to {receipt_file}.")

    status = "success" if not failed else ("partial_success" if succeeded else "error")
    if json_summary:
        agentic = safety_ctx.build_result(
            status=status,
            dry_run=False,
            result={
                "context": context,
                "plan": str(plan_path),
                "org_alias": target_org,
                "operations_planned": len(operations),
                "operation_mix": operation_mix_summary,
                "skipped": skipped,
                "skipped_by_reason": skipped_by_reason,
                "succeeded": succeeded,
                "failed": failed,
                "state_file": str(state_file) if state_file else "",
                "receipt_file": str(receipt_file) if receipt_file else "",
                "resume": resume,
                "start_index": start_index,
                "next_index": int((state_payload or {}).get("next_index") or 0),
                "max_retries": max_retries,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


def _write_rows_to_csv(path: Path, rows: List[Dict[str, Any]]) -> None:
    if not rows:
        path.write_text("", encoding="utf-8")
        return
    headers = list(rows[0].keys())
    with path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=headers)
        writer.writeheader()
        writer.writerows(rows)


@sfdc.command("query")
@click.argument("soql_arg", required=False)
@click.option(
    "--soql",
    "soql_opt",
    required=False,
    help="SOQL query string (optional if provided positionally).",
)
@click.option(
    "--out",
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the CSV results.",
)
@click.option(
    "--target",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    hidden=True,
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def sfdc_query(
    soql_arg: str | None,
    soql_opt: str | None,
    output_path: Path,
    org_alias: str | None,
    json_output: bool,
) -> None:
    """
    Run a SOQL query and write results to CSV.
    """
    soql = (soql_opt or soql_arg or "").strip()
    if not soql:
        click.echo("Error: Provide SOQL via --soql or as a positional argument.", err=True)
        sys.exit(EXIT_USAGE)
    if soql_opt and soql_arg and soql_opt.strip() != soql_arg.strip():
        click.echo("Error: Provide SOQL either positionally or with --soql (not both).", err=True)
        sys.exit(EXIT_USAGE)

    context = _sfdc_context(org_alias)
    if not json_output:
        _print_sfdc_header(context)

    try:
        rows = _get_sfdc_helpers().run_soql(soql, org_alias=org_alias)
    except Exception as exc:
        if json_output:
            _emit_sfdc_json({"status": "error", "error": str(exc)}, context)
        else:
            click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    _write_rows_to_csv(output_path, rows)

    if json_output:
        payload = {
            "status": "success",
            "rows": len(rows),
            "output": str(output_path),
        }
        _emit_sfdc_json(payload, context)
        return

    click.echo(f"Wrote {len(rows)} row(s) to {output_path}.")


@sfdc.command("preview-push")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV containing records to compare against SFDC.",
)
@click.option(
    "--object",
    "sobject",
    required=True,
    help="Salesforce object name (e.g., Account).",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="Column containing Salesforce Ids (15/18 char).",
)
@click.option(
    "--fields",
    "fields",
    required=True,
    help="Comma-separated list of fields to compare.",
)
@click.option(
    "--output",
    "output_prefix",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Prefix for output files (writes <prefix>.json and <prefix>.md).",
)
@click.option(
    "--target",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    hidden=True,
)
def sfdc_preview_push(
    csv_path: Path,
    sobject: str,
    id_column: str,
    fields: str,
    output_prefix: Path,
    org_alias: str | None,
) -> None:
    """
    Preview SFDC updates by comparing CSV values to current SFDC values.
    """
    field_list = _split_comma_list(fields)
    if not field_list:
        click.echo("Error: --fields must include at least one field.")
        sys.exit(1)
    context = _sfdc_context(org_alias)
    _print_sfdc_header(context)
    try:
        result = _get_sfdc_preview_helpers().preview_push(
            csv_path, sobject, id_column, field_list, output_prefix, org_alias=org_alias
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    base = output_prefix.with_suffix("") if output_prefix.suffix else output_prefix
    click.echo(
        f"Preview complete. {result.summary.records_with_changes} of {result.summary.total_records} record(s) "
        f"would change. Outputs: {base.with_suffix('.json')} and {base.with_suffix('.md')}."
    )


@cli.command()
@click.argument("spreadsheet_id")
@click.option(
    "--tab",
    "tab_name",
    required=True,
    help="Tab name to write into (creates if missing).",
)
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to the CSV file to write.",
)
@click.option(
    "--mode",
    "mode",
    type=click.Choice(["overwrite", "append"], case_sensitive=False),
    default="overwrite",
    show_default=True,
    help="Whether to replace the tab or append rows after the last non-empty row.",
)
@click.option(
    "--clear-all",
    "clear_all",
    is_flag=True,
    help="When overwriting, also clear formatting and notes in the target range (not the whole tab). Default preserves formatting/notes.",
)
@click.option(
    "--backup/--no-backup",
    "backup",
    default=True,
    show_default=True,
    help="Create a local snapshot before writing (default ON). Use --no-backup to skip.",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Automatically restore from snapshot on error (for CI/agents).",
)
@click.option(
    "--formulas",
    is_flag=True,
    help="Treat cells starting with '=' as formulas (USER_ENTERED). Without this flag, values are written raw.",
)
@click.option(
    "--user-entered",
    "user_entered",
    is_flag=True,
    help="Use USER_ENTERED mode to coerce numeric strings into numbers (e.g., '123' -> 123).",
)
@click.option(
    "--apply/--no-apply",
    default=True,
    show_default=True,
    help="Execute the write (default true for compatibility). Use --no-apply for dry-run preview only.",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format for summary (text|json).",
)
@click.option(
    "--json",
    "json_flag",
    is_flag=True,
    help="Shortcut for --format json.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write JSON summary to a file (use - for stdout).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def write(
    spreadsheet_id: str,
    tab_name: str,
    csv_path: Path,
    mode: str,
    clear_all: bool,
    backup: bool,
    auto_rollback: bool,
    formulas: bool,
    user_entered: bool,
    apply: bool,
    output_format: str,
    json_flag: bool,
    output_path: Path | None,
    json_summary: bool,
) -> None:
    """
    Write a CSV into a Sheet tab (overwrite or append).

    By default, creates a local snapshot before writing for rollback safety.
    Use --no-backup to skip snapshot creation.

    In overwrite mode, by default only cell values are cleared (formatting and
    notes are preserved). Use --clear-all to also clear formatting and notes.
    """
    safety_ctx = SafetyContext(command="g-gremlin write", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    emit_json = _should_emit_json(output_format, json_flag, json_summary) or output_path is not None
    formula_count = _count_formulas_in_csv(csv_path) if formulas else 0
    user_entered_effective = user_entered or formulas

    if not apply:
        rows = sum(1 for _ in csv_path.open("r", encoding="utf-8"))
        click.echo(
            f"[DRY-RUN] Would {'overwrite' if mode == 'overwrite' else 'append'} ~{rows} rows into '{tab_name}' "
            f"in spreadsheet {spreadsheet_id}."
        )
        if emit_json:
            result_summary = {
                "rows_planned": rows,
                "mode": mode,
                "formulas_detected": formula_count,
                "backup_enabled": backup,
            }
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result=result_summary,
            )
            _emit_json_payload(json.dumps(agentic.to_dict(), indent=2), output_path)
        return

    # Create local snapshot before write if backup is enabled
    snapshot_result = None
    if backup:
        snapshot_mod = _get_snapshot()
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        snapshot_name = f"pre-write-{tab_name}-{timestamp}"
        metadata = snapshot_mod.collect_command_metadata()

        click.echo(f"Creating backup snapshot: {snapshot_name}...")
        snapshot_result = snapshot_mod.create_snapshot(
            spreadsheet_id=spreadsheet_id,
            name=snapshot_name,
            tabs=[tab_name],
            metadata=metadata,
        )
        if not snapshot_result.success:
            click.echo(f"Warning: Failed to create backup snapshot: {'; '.join(snapshot_result.errors)}")
            if not click.confirm("Continue without backup?", default=False):
                sys.exit(1)
            snapshot_result = None
        else:
            click.echo(f"Snapshot created: {snapshot_result.snapshot_path}")

    try:
        result = _get_sheets_io().write_csv_to_sheet(
            spreadsheet_id,
            tab_name,
            csv_path,
            mode,
            backup=False,  # In-sheet backup disabled; use --in-sheet-backup when added
            formulas=formulas,
            user_entered=user_entered_effective,
            clear_all=clear_all,
        )
    except _get_http_error() as err:
        _handle_restore_on_error(err, snapshot_result, auto_rollback, emit_json)
        click.echo(f"Error writing to sheet: {err}")
        sys.exit(1)
    except ValueError as exc:
        _handle_restore_on_error(exc, snapshot_result, auto_rollback, emit_json)
        click.echo(f"Error: {exc}")
        sys.exit(1)
    except Exception as exc:
        _handle_restore_on_error(exc, snapshot_result, auto_rollback, emit_json)
        click.echo(f"Unexpected error: {exc}")
        sys.exit(1)

    if result.mode == "append" and result.rows_written == 0:
        click.echo(
            f"No data rows to append for {tab_name}. "
            "CSV appears to contain only a header."
        )
        return

    action = "Overwrote" if result.mode == "overwrite" else "Appended"
    messages = []
    if result.created_sheet:
        messages.append(f"Created tab '{tab_name}'.")
    if snapshot_result and snapshot_result.success:
        messages.append(f"Snapshot: {snapshot_result.snapshot_path.name}")
    messages.append(f"{action} {result.rows_written} rows in '{tab_name}'.")
    messages.append(
        f"Rows before: {result.existing_rows_before}, now: {result.total_rows_after}."
    )
    click.echo(" ".join(messages))
    impact = _get_build_impact()(
        action="Protected write with snapshot" if (snapshot_result and snapshot_result.success) else "Write",
        stats={
            "rows_written": result.rows_written,
            "formulas_written": formula_count,
            "backups_created": 1 if (snapshot_result and snapshot_result.success) else 0,
        },
        scenario="write_complete",
        trophy="💾" if (snapshot_result and snapshot_result.success) else "✅",
        rows=result.rows_written,
        formulas=formula_count,
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("write", impact["stats"])

    if emit_json:
        result_dict = {
            "rows_written": result.rows_written,
            "mode": result.mode,
            "tab": tab_name,
            "formulas_written": formula_count,
        }
        # Add snapshot info per plan spec
        if snapshot_result and snapshot_result.success:
            result_dict["snapshot_name"] = snapshot_result.snapshot_path.stem.rsplit("_", 2)[0]
            result_dict["snapshot_path"] = str(snapshot_result.snapshot_path)
            result_dict["restore_command"] = f"g-gremlin snapshot restore --name {result_dict['snapshot_name']}"
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result=result_dict,
        )
        _emit_json_payload(json.dumps(agentic.to_dict(), indent=2), output_path)


@cli.command("copy-tab")
@click.argument("spreadsheet_id")
@click.option("--source", "-s", "source_tab", required=True, help="Source tab name to copy from.")
@click.option("--dest", "-d", "dest_tab", required=True, help="Destination tab name.")
@click.option(
    "--overwrite",
    is_flag=True,
    help="If destination exists, rename it to <dest>_old_<timestamp> before copying.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the copy (default is dry-run preview).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@click.option(
    "--backup/--no-backup",
    default=True,
    show_default=True,
    help="Create snapshot before copying (default on for safety).",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Automatically restore from backup on error (for CI/agents).",
)
def copy_tab_cmd(
    spreadsheet_id: str,
    source_tab: str,
    dest_tab: str,
    overwrite: bool,
    apply: bool,
    json_summary: bool,
    backup: bool,
    auto_rollback: bool,
) -> None:
    """
    Copy a tab within a spreadsheet (preserves cross-sheet references).

    By default, runs in dry-run mode. Use --apply to execute.
    """
    safety_ctx = SafetyContext(command="g-gremlin copy-tab", remote_mutation=True)
    safety_ctx.enforce_apply(apply)

    try:
        service = _get_google_client().get_sheets_service()
    except Exception as exc:
        click.echo(f"Error initializing Google Sheets client: {exc}", err=True)
        sys.exit(1)

    source_id = _get_sheets_helpers().get_sheet_id_by_name(service, spreadsheet_id, source_tab)
    if source_id is None:
        error = error_helpers.sheet_tab_not_found_error(source_tab, spreadsheet_id)
        click.echo(str(error), err=True)
        sys.exit(1)

    dest_id = _get_sheets_helpers().get_sheet_id_by_name(service, spreadsheet_id, dest_tab)
    dest_exists = dest_id is not None

    if dest_exists and not overwrite:
        click.echo(
            f"ERROR: Destination tab '{dest_tab}' already exists. Use --overwrite to replace.",
            err=True,
        )
        sys.exit(1)

    # Dry-run preview
    if not apply:
        action_desc = "rename existing + copy" if dest_exists and overwrite else "copy"
        click.echo(
            f"[DRY-RUN] Would {action_desc} '{source_tab}' → '{dest_tab}' in spreadsheet {spreadsheet_id}."
        )
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "source_tab": source_tab,
                    "dest_tab": dest_tab,
                    "dest_exists": dest_exists,
                    "would_rename_existing": dest_exists and overwrite,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    # Create snapshot before copy operation
    snapshot_result = None
    if backup:
        snap_mod = _get_snapshot()
        from datetime import datetime as dt_snap
        timestamp_snap = dt_snap.now().strftime("%Y%m%d-%H%M%S")
        snapshot_name = f"pre-copy-tab-{timestamp_snap}"
        tabs_to_backup = [source_tab]
        if dest_exists:
            tabs_to_backup.append(dest_tab)
        try:
            cmd_metadata = snap_mod.collect_command_metadata()
            snapshot_result = snap_mod.create_snapshot(
                spreadsheet_id=spreadsheet_id,
                tabs=tabs_to_backup,
                name=snapshot_name,
                metadata=cmd_metadata,
            )
            click.echo(f"Created backup snapshot: {snapshot_name}")
        except Exception as e:
            click.echo(f"Warning: Failed to create backup snapshot: {e}", err=True)

    try:
        renamed_to: str | None = None
        if dest_exists and overwrite:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M")
            renamed_to = f"{dest_tab}_old_{timestamp}"
            try:
                _get_sheets_helpers().rename_tab(service, spreadsheet_id, dest_id, renamed_to)
            except _get_http_error() as err:
                click.echo(f"Error renaming existing tab '{dest_tab}': {err}", err=True)
                sys.exit(1)
            click.echo(f"Renamed existing '{dest_tab}' → '{renamed_to}'")

        try:
            new_id = _get_sheets_helpers().copy_tab(service, spreadsheet_id, source_id, dest_tab)
        except _get_http_error() as err:
            click.echo(f"Error copying tab: {err}", err=True)
            sys.exit(1)
    except Exception as e:
        _handle_restore_on_error(e, snapshot_result, auto_rollback, json_summary)
        raise

    click.echo(f"Copied '{source_tab}' → '{dest_tab}' (sheet_id: {new_id})")
    impact = _get_build_impact()(
        action="Safe tab duplication",
        stats={
            "cross_sheet_refs_preserved": 0,
            "issues_found": 0,
        },
        scenario="copy_tab",
        trophy="📋",
        refs=0,
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("copy_tab", impact["stats"])

    if json_summary:
        result_data = {
            "source_tab": source_tab,
            "dest_tab": dest_tab,
            "new_sheet_id": new_id,
            "renamed_existing_to": renamed_to,
        }
        # Add snapshot info if backup was created
        if snapshot_result and snapshot_result.success:
            result_data["snapshot_name"] = snapshot_result.snapshot_path.stem.rsplit("_", 2)[0]
            result_data["snapshot_path"] = str(snapshot_result.snapshot_path)
            result_data["restore_command"] = f"g-gremlin snapshot restore --name {result_data['snapshot_name']}"
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result=result_data,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@cli.command()
@click.option(
    "--sources",
    "sources",
    required=True,
    help="Comma-separated list of CSV files to merge.",
)
@click.option(
    "--key",
    "key_column",
    required=True,
    help='Column name to use as the merge key (e.g., "Account ID").',
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the merged CSV.",
)
@click.option(
    "--source-flag-col",
    "source_flag_col",
    required=False,
    help="Optional column to record which source(s) contributed a row.",
)
def merge(sources: str, key_column: str, output_path: Path, source_flag_col: str | None) -> None:
    """
    Merge multiple CSVs by key, unioning columns and keeping source flags if requested.
    """
    source_paths = [Path(p.strip()) for p in sources.split(",") if p.strip()]
    missing = [str(p) for p in source_paths if not p.exists()]
    if missing:
        click.echo(f"Missing source file(s): {', '.join(missing)}")
        sys.exit(1)

    try:
        result = _get_merge_helpers().merge_csv_sources(
            source_paths, key_column, output_path, source_flag_col
        )
    except ValueError as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error: {exc}")
        sys.exit(1)

    click.echo(
        f"Merged {len(source_paths)} source file(s) into {output_path} "
        f"with {result.rows_written} merged rows across {len(result.columns)} columns."
    )
    for warning in result.warnings:
        click.echo(f"Warning: {warning}")


@cli.command()
@click.option(
    "--source",
    "source_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Source CSV containing names to match.",
)
@click.option(
    "--source-col",
    "source_col",
    required=True,
    help='Column in the source CSV to match (e.g., "Account Name").',
)
@click.option(
    "--target",
    "target_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Target CSV to match against.",
)
@click.option(
    "--target-col",
    "target_col",
    required=True,
    help='Column in the target CSV to match against (e.g., "AccountName").',
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the matched CSV.",
)
@click.option(
    "--threshold",
    default=0.8,
    show_default=True,
    type=float,
    help="Similarity threshold (0-1) to mark matches as confident.",
)
@click.option(
    "--use-engine",
    "use_engine",
    is_flag=True,
    help="Use Engine API for enhanced matching (requires API access).",
)
@click.option(
    "--local",
    "force_local",
    is_flag=True,
    help="Force local fuzzy matching (SequenceMatcher).",
)
def fuzzy_match(
    source_path: Path,
    source_col: str,
    target_path: Path,
    target_col: str,
    output_path: Path,
    threshold: float,
    use_engine: bool,
    force_local: bool,
) -> None:
    """
    Fuzzy-match names between two CSVs using normalization + similarity.
    """
    import asyncio

    def _count_rows(path: Path) -> int:
        with path.open("r", newline="", encoding="utf-8-sig") as handle:
            reader = csv.reader(handle)
            next(reader, None)
            return sum(1 for _ in reader)

    max_local_rows = 500

    try:
        if force_local and use_engine:
            click.echo("Error: --local and --use-engine cannot be used together.")
            sys.exit(1)
        if force_local:
            click.echo("Local fuzzy matching is disabled. Use --use-engine.")
            sys.exit(1)

        source_rows = _count_rows(source_path)
        target_rows = _count_rows(target_path)
        max_rows = max(source_rows, target_rows)

        if not use_engine:
            click.echo("Engine matching is required; enabling MatchGateway.")
            use_engine = True

        if use_engine and not _get_fuzzy_match_helpers().is_engine_available():
            click.echo("Engine API not available; local matching is disabled.")
            sys.exit(1)

        if use_engine:
            summary = asyncio.run(
                _get_fuzzy_match_helpers().run_fuzzy_match_engine(
                    source_path, source_col, target_path, target_col, output_path, threshold
                )
            )
            click.echo("Engine-powered fuzzy match complete.")
        else:
            summary = _get_fuzzy_match_helpers().run_fuzzy_match(
                source_path, source_col, target_path, target_col, output_path, threshold
            )
            click.echo("Basic fuzzy match complete.")
    except ValueError as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Unexpected error: {exc}")
        sys.exit(1)

    if getattr(summary, "available", True) is False:
        reason = getattr(summary, "reason", "MatchGateway unavailable.")
        click.echo(f"MatchGateway unavailable: {reason}")
        sys.exit(1)

    click.echo(
        f"Matched {summary.total} rows. "
        f"Matched: {summary.matched}, low_confidence: {summary.low_confidence}, "
        f"no_match: {summary.no_match}. Output: {output_path}."
    )


@cli.group()
def recipe() -> None:
    """
    Data recipes built on top of core primitives.

    Note: Recipe commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


@recipe.command("build-account-dim")
@click.option(
    "--opp-sources",
    "opp_sources",
    required=True,
    help="Comma-separated list of opportunity export CSVs.",
)
@click.option(
    "--key-column",
    "key_column",
    required=True,
    help='Column containing Account IDs (e.g., "Account ID").',
)
@click.option(
    "--name-column",
    "name_column",
    required=True,
    help='Column containing Account names (e.g., "Account Name").',
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write account_dim CSV.",
)
@click.option(
    "--master-list",
    "master_list",
    required=False,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Optional master list CSV to match against.",
)
@click.option(
    "--master-name-col",
    "master_name_col",
    required=False,
    help="Column in master list containing account names.",
)
@click.option(
    "--match-threshold",
    "match_threshold",
    default=0.85,
    show_default=True,
    type=float,
    help="Similarity threshold when matching to master list.",
)
@click.option(
    "--stage-column",
    "stage_column",
    default="StageName",
    show_default=True,
    help="Stage column used to detect Closed Won.",
)
@click.option(
    "--close-date-column",
    "close_date_column",
    default="CloseDate",
    show_default=True,
    help="Close date column for latest close date.",
)
@click.option(
    "--end-date-column",
    "end_date_column",
    required=False,
    help="Optional end date column for latest end date.",
)
@click.option(
    "--amount-column",
    "amount_column",
    default="Amount",
    show_default=True,
    help="Amount/ARR column used for ARR estimate.",
)
@click.option(
    "--closed-won-values",
    "closed_won_values",
    default="Closed Won,7 - Closed Won",
    show_default=True,
    help="Comma-separated stage values treated as Closed Won.",
)
def build_account_dim(
    opp_sources: str,
    key_column: str,
    name_column: str,
    output_path: Path,
    master_list: Path | None,
    master_name_col: str | None,
    match_threshold: float,
    stage_column: str,
    close_date_column: str,
    end_date_column: str | None,
    amount_column: str,
    closed_won_values: str,
) -> None:
    """
    Build a simple account dimension from opportunity exports (with optional master-list attach).
    """
    source_paths = [Path(p.strip()) for p in opp_sources.split(",") if p.strip()]
    missing = [str(p) for p in source_paths if not p.exists()]
    if missing:
        click.echo(f"Missing source file(s): {', '.join(missing)}")
        sys.exit(1)

    closed_won_list = _split_comma_list(closed_won_values)
    try:
        result = _get_recipes()["account_dim"].build_account_dim(
            source_paths,
            key_column,
            name_column,
            output_path,
            master_list=master_list,
            master_name_col=master_name_col,
            match_threshold=match_threshold,
            stage_column=stage_column,
            close_date_column=close_date_column,
            end_date_column=end_date_column,
            amount_column=amount_column,
            closed_won_values=closed_won_list,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    suffix = ""
    if master_list:
        suffix = f" (matched to master list {master_list})"
    click.echo(
        f"Wrote {result.accounts} account(s) from {len(source_paths)} source(s){suffix} to {output_path}."
    )


@recipe.command("enrich-from-sfdc")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV to enrich.",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="Column containing Salesforce Ids (15/18 char).",
)
@click.option(
    "--object",
    "sobject",
    required=True,
    help="Salesforce object name (e.g., Account, Contact).",
)
@click.option(
    "--fields",
    "fields",
    required=True,
    help="Comma-separated list of fields to pull (e.g., Type,Industry,BillingCity).",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the enriched CSV.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
def enrich_from_sfdc(
    csv_path: Path,
    id_column: str,
    sobject: str,
    fields: str,
    output_path: Path,
    org_alias: str | None,
) -> None:
    """
    Recipe wrapper for SFDC enrich (same behavior as `g-gremlin sfdc enrich`).
    """
    field_list_raw = [f.strip() for f in fields.split(",") if f.strip()]
    if not field_list_raw:
        click.echo("Error: --fields must include at least one field.")
        sys.exit(1)
    field_specs = [_parse_field_spec(f) for f in field_list_raw]
    try:
        result = _get_recipes()["enrich_from_sfdc"].enrich_from_sfdc(
            csv_path, id_column, sobject, field_specs, output_path, org_alias
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Enriched {csv_path} with fields {', '.join(field_list_raw)} from {sobject}. "
        f"Wrote {result.rows_written} data row(s) to {output_path}."
    )


@recipe.command("resolve-unmatched")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV containing IDs and names.",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="ID column to check for empties.",
)
@click.option(
    "--name-column",
    "name_column",
    required=True,
    help="Name column used to build SOQL hints.",
)
@click.option(
    "--object",
    "object_name",
    default="Account",
    show_default=True,
    help="Salesforce object to query in suggested SOQL.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write unmatched rows CSV.",
)
def resolve_unmatched(
    csv_path: Path,
    id_column: str,
    name_column: str,
    object_name: str,
    output_path: Path,
) -> None:
    """
    Extract rows missing IDs and generate suggested SOQL for manual resolution.
    """
    try:
        result = _get_recipes()["resolve_unmatched"].resolve_unmatched(
            csv_path, id_column, name_column, output_path, object_name=object_name
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Found {result.unmatched_rows} unmatched row(s); wrote {result.rows_written} suggestion row(s) to {output_path}."
    )


@recipe.command("detect-open-renewals")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV containing Account IDs.",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="Column containing Account IDs.",
)
@click.option(
    "--renewal-col",
    "renewal_column",
    required=True,
    help='Column to write Yes/No flag (e.g., "Open Renewal?").',
)
@click.option(
    "--renewal-id-col",
    "renewal_id_column",
    required=True,
    help='Column to write the chosen renewal Opportunity Id.',
)
@click.option(
    "--renewal-types",
    "renewal_types",
    default="Renewal,Renewal + Upsell,FastForward Renewal",
    show_default=True,
    help="Comma-separated Opportunity Type values considered renewals.",
)
@click.option(
    "--closed-stages",
    "closed_stages",
    default="7 - Closed Won,Closed Lost",
    show_default=True,
    help="Comma-separated StageName values considered closed.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the output CSV.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
def detect_open_renewals(
    csv_path: Path,
    id_column: str,
    renewal_column: str,
    renewal_id_column: str,
    renewal_types: str,
    closed_stages: str,
    output_path: Path,
    org_alias: str | None,
) -> None:
    """
    Flag accounts that have open renewal opportunities.
    """
    renewal_type_values = _split_comma_list(renewal_types)
    closed_stage_values = _split_comma_list(closed_stages)
    if not renewal_type_values:
        click.echo("Error: --renewal-types must include at least one value.")
        sys.exit(1)
    if not closed_stage_values:
        click.echo("Error: --closed-stages must include at least one value.")
        sys.exit(1)

    try:
        result = _get_recipes()["detect_open_renewals"].detect_open_renewals(
            csv_path,
            id_column,
            renewal_column,
            renewal_id_column,
            output_path,
            renewal_type_values,
            closed_stage_values,
            org_alias=org_alias,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Renewal scan complete. Queried {result.accounts_queried} account(s), "
        f"found renewals for {result.renewals_found} (ambiguous: {result.ambiguous}). "
        f"Wrote {result.rows_written} rows to {output_path}."
    )


@recipe.command("attach-products")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV containing Account IDs.",
)
@click.option(
    "--id-column",
    "id_column",
    required=True,
    help="Column containing Account IDs.",
)
@click.option(
    "--products-column",
    "products_column",
    required=True,
    help='Column to write distinct product names (e.g., "Direct Products").',
)
@click.option(
    "--stage",
    "stage",
    default="7 - Closed Won",
    show_default=True,
    help="Opportunity StageName to filter OLIs on.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the output CSV.",
)
@click.option(
    "--org-alias",
    "org_alias",
    required=False,
    help="Override configured org alias.",
)
def attach_products(
    csv_path: Path,
    id_column: str,
    products_column: str,
    stage: str,
    output_path: Path,
    org_alias: str | None,
) -> None:
    """
    Attach distinct product names per Account from OpportunityLineItems.
    """
    try:
        result = _get_recipes()["attach_products"].attach_products(
            csv_path,
            id_column,
            products_column,
            output_path,
            stage,
            org_alias=org_alias,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Attached products for {result.with_products} account(s) "
        f"out of {result.accounts_queried} queried. "
        f"Wrote {result.rows_written} rows to {output_path}."
    )


@recipe.command("zendesk-user-stats")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV containing user emails.",
)
@click.option(
    "--email-column",
    "email_column",
    required=True,
    help="Column containing user email addresses.",
)
@click.option(
    "--tickets-count-col",
    "tickets_count_col",
    required=True,
    help='Column to write total Zendesk ticket count (e.g., "ZD Ticket Count").',
)
@click.option(
    "--open-tickets-col",
    "open_tickets_col",
    required=True,
    help='Column to write open/pending ticket count (e.g., "ZD Open Tickets").',
)
@click.option(
    "--last-ticket-col",
    "last_ticket_col",
    required=True,
    help='Column to write the most recent ticket created_at timestamp (e.g., "ZD Last Ticket At").',
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the enriched CSV.",
)
def zendesk_user_stats(
    csv_path: Path,
    email_column: str,
    tickets_count_col: str,
    open_tickets_col: str,
    last_ticket_col: str,
    output_path: Path,
) -> None:
    """
    Enrich a CSV with Zendesk ticket stats per user email.
    """
    try:
        result = _get_recipes()["zendesk_user_stats"].zendesk_user_stats(
            csv_path,
            email_column,
            tickets_count_col,
            open_tickets_col,
            last_ticket_col,
            output_path,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    for warning in result.warnings:
        click.echo(f"Warning: {warning}")
    click.echo(
        f"Zendesk user stats complete. Processed {result.emails_requested} email(s), "
        f"found {result.users_found} user(s). Wrote {result.rows_written} rows to {output_path}."
    )


@recipe.command("zendesk-org-stats")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV containing Zendesk org URLs or IDs.",
)
@click.option(
    "--org-column",
    "org_column",
    required=True,
    help="Column containing a Zendesk organization URL or raw org ID.",
)
@click.option(
    "--total-tickets-col",
    "total_tickets_col",
    required=True,
    help='Column to write total ticket count (e.g., "ZD_Total_Tickets").',
)
@click.option(
    "--open-tickets-col",
    "open_tickets_col",
    required=True,
    help='Column to write open/pending ticket count (e.g., "ZD_Open_Tickets").',
)
@click.option(
    "--last-ticket-col",
    "last_ticket_col",
    required=True,
    help='Column to write the most recent ticket created_at timestamp (e.g., "ZD_Last_Ticket_At").',
)
@click.option(
    "--org-created-col",
    "org_created_col",
    required=True,
    help='Column to write the organization created_at timestamp (e.g., "ZD_Created_At").',
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the enriched CSV.",
)
def zendesk_org_stats(
    csv_path: Path,
    org_column: str,
    total_tickets_col: str,
    open_tickets_col: str,
    last_ticket_col: str,
    org_created_col: str,
    output_path: Path,
) -> None:
    """
    Enrich a CSV with Zendesk organization-level ticket stats.
    """
    try:
        result = _get_recipes()["zendesk_org_stats"].zendesk_org_stats(
            csv_path,
            org_column,
            total_tickets_col,
            open_tickets_col,
            last_ticket_col,
            org_created_col,
            output_path,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    for warning in result.warnings:
        click.echo(f"Warning: {warning}")
    click.echo(
        f"Zendesk org stats complete. Processed {result.orgs_requested} org id(s), "
        f"found {result.orgs_found} org(s). Wrote {result.rows_written} rows to {output_path}."
    )



@cli.command("filter")
@click.option(
    "--input",
    "input_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV to filter.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Filtered CSV output.",
)
@click.option(
    "--expression",
    required=True,
    help="Boolean expression evaluated per row (e.g., activation_eligible == true).",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
def filter_rows(
    input_path: Path,
    output_path: Path,
    expression: str,
    json_summary: bool,
) -> None:
    """
    Filter a CSV by evaluating a Python expression against each row.
    """

    def _coerce(value: Any) -> Any:
        if isinstance(value, str):
            lower = value.lower()
            if lower in {"true", "false"}:
                return lower == "true"
            try:
                return float(value) if "." in value else int(value)
            except Exception:
                return value
        return value

    rows_kept = 0
    with input_path.open("r", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        records = list(reader)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=reader.fieldnames or [])
        if writer.fieldnames:
            writer.writeheader()
        for row in records:
            env: Dict[str, Any] = {"true": True, "false": False, "row": row}
            for key, value in row.items():
                safe_key = re.sub(r"\W+", "_", key).lower()
                env[safe_key] = _coerce(value)
            try:
                if bool(eval(expression, {"__builtins__": {}}, env)):
                    writer.writerow(row)
                    rows_kept += 1
            except Exception as exc:
                click.echo(f"Error evaluating expression on row {row}: {exc}", err=True)
                sys.exit(EXIT_USAGE)

    click.echo(f"Filtered {len(records)} rows -> {rows_kept} rows into {output_path}")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin filter", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "input": str(input_path),
                "output": str(output_path),
                "rows_in": len(records),
                "rows_out": rows_kept,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))

@cli.command()
@click.option(
    "--input",
    "input_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Input CSV to transform.",
)
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write transformed CSV.",
)
@click.option(
    "--rename",
    "renames",
    multiple=True,
    help="Column rename in form Old=New (can be repeated).",
)
@click.option(
    "--add-column",
    "add_columns",
    multiple=True,
    help="Add blank column(s) (can be repeated).",
)
@click.option(
    "--calc",
    "calculations",
    multiple=True,
    help="Calculated column in form NewCol=expression (e.g., Days=EndDate-TODAY).",
)
def transform(
    input_path: Path,
    output_path: Path,
    renames: tuple[str, ...],
    add_columns: tuple[str, ...],
    calculations: tuple[str, ...],
) -> None:
    """
    Apply simple transforms: rename columns, add blank columns, and calculated columns.
    """
    rename_map = _parse_renames(renames)
    try:
        _get_transform_helpers().apply_transforms(
            input_path,
            output_path,
            renames=rename_map,
            add_blank=add_columns,
            calculations=calculations,
        )
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    click.echo(
        f"Transformed {input_path} -> {output_path} "
        f"(renames={len(rename_map)}, add_blank={len(add_columns)}, calcs={len(calculations)})."
    )


@cli.command()
@click.option("--spreadsheet-id", required=False, help="Spreadsheet ID (optional if state has one).")
@click.option("--pull-range", required=False, help="A1 range to pull before processing.")
@click.option(
    "--input",
    "input_path",
    required=False,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Starting CSV (skip pull).",
)
@click.option(
    "--enrich-object",
    "enrich_object",
    required=False,
    help="Optional Salesforce object to enrich with.",
)
@click.option(
    "--enrich-fields",
    "enrich_fields",
    required=False,
    help="Comma-separated fields (with optional aliases) for enrich (e.g., \"Type,Industry\" or \"CS_Scorecard__c AS 'CS Scorecard'\").",
)
@click.option(
    "--enrich-id-column",
    "enrich_id_column",
    default="Id",
    show_default=True,
    help="ID column for enrichment.",
)
@click.option(
    "--rename",
    "renames",
    multiple=True,
    help="Column rename in form Old=New (can be repeated).",
)
@click.option(
    "--add-column",
    "add_columns",
    multiple=True,
    help="Add blank column(s).",
)
@click.option(
    "--calc",
    "calculations",
    multiple=True,
    help="Calculated column in form NewCol=expression (e.g., Days=EndDate-TODAY).",
)
@click.option(
    "--write-tab",
    "write_tab",
    required=False,
    help="If provided, write final CSV to this tab.",
)
@click.option(
    "--write-mode",
    "write_mode",
    type=click.Choice(["overwrite", "append"], case_sensitive=False),
    default="overwrite",
    show_default=True,
    help="Write mode for Sheets tab.",
)
@click.option(
    "--output",
    "output_path",
    required=False,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Final CSV output path (default: .g_gremlin/chain_output.csv).",
)
@click.option(
    "--backup/--no-backup",
    default=True,
    show_default=True,
    help="Create snapshot before writing to sheet (default on for safety).",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Automatically restore from backup on error (for CI/agents).",
)
@click.option(
    "--sink",
    "sink_type",
    type=click.Choice(["bq", "s3", "gcs"]),
    required=False,
    help="Sink final output to external system (BigQuery, S3, or GCS).",
)
@click.option(
    "--sink-project",
    required=False,
    help="BigQuery project ID (for sink=bq).",
)
@click.option(
    "--sink-dataset",
    required=False,
    help="BigQuery dataset name (for sink=bq).",
)
@click.option(
    "--sink-table",
    required=False,
    help="BigQuery table name (for sink=bq).",
)
@click.option(
    "--sink-bucket",
    required=False,
    help="S3/GCS bucket name (for sink=s3 or sink=gcs).",
)
@click.option(
    "--sink-prefix",
    default="",
    help="Bucket prefix/folder for S3/GCS sink.",
)
@click.option(
    "--sink-mode",
    type=click.Choice(["replace", "append"]),
    default="replace",
    help="Write mode for BQ sink (default: replace).",
)
@click.option(
    "--sink-compress",
    is_flag=True,
    help="Gzip compress before uploading to S3/GCS.",
)
def chain(
    spreadsheet_id: str | None,
    pull_range: str | None,
    input_path: Path | None,
    enrich_object: str | None,
    enrich_fields: str | None,
    enrich_id_column: str,
    renames: tuple[str, ...],
    add_columns: tuple[str, ...],
    calculations: tuple[str, ...],
    write_tab: str | None,
    write_mode: str,
    output_path: Path | None,
    backup: bool,
    auto_rollback: bool,
    sink_type: str | None,
    sink_project: str | None,
    sink_dataset: str | None,
    sink_table: str | None,
    sink_bucket: str | None,
    sink_prefix: str,
    sink_mode: str,
    sink_compress: bool,
) -> None:
    """
    Chain pull -> enrich -> transforms -> write in one command.
    """
    work_dir = Path(".g_gremlin/chain")
    work_dir.mkdir(parents=True, exist_ok=True)
    final_output = output_path or (work_dir / "chain_output.csv")
    state = _get_local_state().load_state()

    if not pull_range and not input_path:
        click.echo("Error: provide --pull-range or --input as a starting point.")
        sys.exit(1)

    current = None
    if pull_range:
        source_spreadsheet = spreadsheet_id or (state.spreadsheet_id if state else None)
        if not source_spreadsheet:
            click.echo("Error: --spreadsheet-id is required when no state.json is available.")
            sys.exit(1)
        pulled = work_dir / "pulled.csv"
        try:
            _pull_to_csv(source_spreadsheet, pull_range, pulled)
        except Exception as exc:
            click.echo(f"Error pulling range {pull_range}: {exc}")
            sys.exit(1)
        current = pulled
    else:
        current = input_path

    if enrich_object:
        if not enrich_fields:
            click.echo("Error: --enrich-fields is required when using --enrich-object.")
            sys.exit(1)
        field_specs = [_parse_field_spec(f) for f in _split_comma_list(enrich_fields)]
        enriched = work_dir / "enriched.csv"
        try:
            _get_sfdc_helpers().enrich_csv_with_sfdc(
                current, enrich_id_column, enrich_object, field_specs, enriched, None
            )
        except Exception as exc:
            click.echo(f"Error during SFDC enrich: {exc}")
            sys.exit(1)
        current = enriched

    if renames or add_columns or calculations:
        rename_map = _parse_renames(renames)
        transformed = work_dir / "transformed.csv"
        try:
            _get_transform_helpers().apply_transforms(
                current,
                transformed,
                renames=rename_map,
                add_blank=add_columns,
                calculations=calculations,
            )
        except Exception as exc:
            click.echo(f"Error during transform: {exc}")
            sys.exit(1)
        current = transformed

    # Final copy to requested output
    if current != final_output:
        final_output.parent.mkdir(parents=True, exist_ok=True)
        final_output.write_bytes(Path(current).read_bytes())

    if write_tab:
        target_spreadsheet = spreadsheet_id or (state.spreadsheet_id if state else None)
        if not target_spreadsheet:
            click.echo("Error: --spreadsheet-id is required to write to Sheets.")
            sys.exit(1)

        # Create snapshot before writing
        snapshot_result = None
        if backup:
            snap_mod = _get_snapshot()
            from datetime import datetime
            timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
            snapshot_name = f"pre-chain-{timestamp}"
            try:
                cmd_metadata = snap_mod.collect_command_metadata()
                snapshot_result = snap_mod.create_snapshot(
                    spreadsheet_id=target_spreadsheet,
                    tabs=[write_tab],
                    name=snapshot_name,
                    metadata=cmd_metadata,
                )
                click.echo(f"Created backup snapshot: {snapshot_name}")
            except Exception as e:
                click.echo(f"Warning: Failed to create backup snapshot: {e}", err=True)

        try:
            _get_sheets_io().write_csv_to_sheet(
                target_spreadsheet,
                write_tab,
                final_output,
                write_mode,
            )
        except Exception as exc:
            _handle_restore_on_error(exc, snapshot_result, auto_rollback, False)
            click.echo(f"Error writing to tab {write_tab}: {exc}")
            sys.exit(1)
        click.echo(f"Wrote chained output to tab '{write_tab}' ({write_mode}).")

    # Sink to external system if requested
    if sink_type:
        sink_mod = _get_sink()
        click.echo(f"Sinking output to {sink_type}...")

        try:
            if sink_type == "bq":
                # Validate required BQ options
                project = sink_project or sink_mod.get_default_project()
                if not project:
                    click.echo("Error: --sink-project is required for BQ sink (or set G_GREMLIN_BQ_PROJECT).", err=True)
                    sys.exit(1)
                if not sink_dataset or not sink_table:
                    click.echo("Error: --sink-dataset and --sink-table are required for BQ sink.", err=True)
                    sys.exit(1)

                result = sink_mod.sink_to_bigquery(
                    csv_path=final_output,
                    project=project,
                    dataset=sink_dataset,
                    table=sink_table,
                    mode=sink_mode,
                    dry_run=False,
                )

            elif sink_type == "s3":
                bucket = sink_bucket or sink_mod.get_default_s3_bucket()
                if not bucket:
                    click.echo("Error: --sink-bucket is required for S3 sink (or set G_GREMLIN_S3_BUCKET).", err=True)
                    sys.exit(1)

                result = sink_mod.sink_to_s3(
                    csv_path=final_output,
                    bucket=bucket,
                    prefix=sink_prefix,
                    compress=sink_compress,
                    dry_run=False,
                )

            elif sink_type == "gcs":
                bucket = sink_bucket or sink_mod.get_default_gcs_bucket()
                if not bucket:
                    click.echo("Error: --sink-bucket is required for GCS sink (or set G_GREMLIN_GCS_BUCKET).", err=True)
                    sys.exit(1)

                result = sink_mod.sink_to_gcs(
                    csv_path=final_output,
                    bucket=bucket,
                    prefix=sink_prefix,
                    compress=sink_compress,
                    dry_run=False,
                )

            if result.success:
                click.echo(f"Sunk {result.rows_written} rows to {result.target_uri}")
            else:
                for error in result.errors:
                    click.echo(f"Sink error: {error}", err=True)
                sys.exit(1)

        except Exception as exc:
            click.echo(f"Error during sink: {exc}", err=True)
            sys.exit(1)

    click.echo(f"Chain complete. Final CSV at {final_output}.")


@cli.command()
@click.argument("spreadsheet_id")
@click.option(
    "--range",
    "a1_range",
    required=True,
    help="A1 notation range to pull, e.g. 'Leads!A1:Z'.",
)
@click.option(
    "--output",
    "output_file",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the CSV output.",
)
@click.option(
    "--include-scripts",
    is_flag=True,
    help="Also pull the bound Apps Script project into --scripts-dir.",
)
@click.option(
    "--scripts-dir",
    default=Path("scripts"),
    show_default=True,
    type=click.Path(file_okay=False, path_type=Path),
    help="Directory to write Apps Script files when using --include-scripts.",
)
@click.option(
    "--script-id",
    "script_id_opt",
    help="Apps Script project ID override (defaults to bound script for the sheet).",
)
@click.option(
    "--create-script",
    is_flag=True,
    help="Create a bound Apps Script project if none exists (only with --include-scripts).",
)
@click.option(
    "--formulas-only",
    is_flag=True,
    help="Pull formulas instead of computed values (uses FORMULA render option).",
)
def pull(
    spreadsheet_id: str,
    a1_range: str,
    output_file: Path,
    include_scripts: bool,
    scripts_dir: Path,
    script_id_opt: str | None,
    create_script: bool,
    formulas_only: bool,
) -> None:
    """
    Export Google Sheet data to local CSV file.

    Pulls specified range and saves as CSV. Can optionally pull formulas instead
    of computed values, and include bound Apps Script files.

    \b
    Common Usage:
      # Pull all data from a tab
      g-gremlin pull SHEET_ID --range "Leads!A:Z" --output leads.csv

      # Pull formulas instead of values
      g-gremlin pull SHEET_ID --range "Calculator!A1:D100" --output calc.csv --formulas-only

      # Pull data + bound Apps Script
      g-gremlin pull SHEET_ID --range "Data!A:Z" --output data.csv --include-scripts

    \b
    For AI Agents:
      - Returns exit code 0 on success, 1 on failure
      - Output is always CSV format
      - Use --formulas-only to debug formula issues
    """
    # Fix shell escaping: bash escapes ! as \! for history expansion
    a1_range = a1_range.replace("\\!", "!")

    try:
        sheets_service = _get_google_client().get_sheets_service()
    except Exception as exc:  # OAuth issues surface here.
        click.echo(f"Authentication failed: {exc}")
        sys.exit(1)

    try:
        render_option = "FORMULA" if formulas_only else "UNFORMATTED_VALUE"
        values_response = (
            sheets_service.spreadsheets()
            .values()
            .get(
                spreadsheetId=spreadsheet_id,
                range=a1_range,
                valueRenderOption=render_option,
            )
            .execute()
        )
        values = values_response.get("values", [])
    except _get_http_error() as err:
        click.echo(f"Error fetching sheet values: {err}")
        sys.exit(1)

    try:
        metadata = _get_google_client().get_sheet_metadata(spreadsheet_id)
    except _get_http_error() as err:
        click.echo(f"Error fetching sheet metadata: {err}")
        sys.exit(1)

    output_file.parent.mkdir(parents=True, exist_ok=True)
    with output_file.open("w", newline="", encoding="utf-8") as csv_file:
        writer = csv.writer(csv_file)
        writer.writerows(values)

    pulled_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
    state = SheetState(
        spreadsheet_id=spreadsheet_id,
        range=a1_range,
        output_file=str(output_file),
        concurrency_token=metadata.get("headRevisionId") or metadata.get("modifiedTime"),
        pulled_at=pulled_at,
    )
    _get_local_state().save_state(state)

    row_count = len(values)
    marker = state.concurrency_token or "unknown"
    mode_label = "formulas" if formulas_only else "values"
    click.echo(
        f"Pulled {row_count} rows ({mode_label}) to {output_file}. "
        f"Stored concurrency token: {marker}. State saved to .g_gremlin/state.json."
    )

    if include_scripts:
        try:
            script_id = _get_apps_script().resolve_script_id(
                spreadsheet_id,
                explicit_script_id=script_id_opt,
                create_if_missing=create_script,
            )
            written = _get_apps_script().pull_script(script_id, scripts_dir)
            click.echo(
                f"Pulled {len(written)} Apps Script file(s) to {scripts_dir} (scriptId={script_id})."
            )
        except _get_script_resolution_error() as exc:
            click.echo(f"Apps Script error: {exc}", err=True)
            sys.exit(1)
        except _get_http_error() as err:
            click.echo(f"Error pulling Apps Script project: {err}", err=True)
            sys.exit(1)
        except Exception as exc:
            click.echo(f"Unexpected Apps Script error: {exc}", err=True)
            sys.exit(1)


@cli.group()
def diff() -> None:
    """
    Diff utilities for comparing data.
    """


@diff.command("csv")
@click.option(
    "--original",
    "original_file",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to the original CSV (from g-gremlin pull).",
)
@click.option(
    "--modified",
    "modified_file",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to the modified CSV.",
)
@click.option(
    "--key-col",
    "key_col",
    required=False,
    help="Column name to use as the row key. Defaults to 'id' if present, else row index.",
)
@click.option(
    "--limit",
    "limit",
    default=20,
    show_default=True,
    type=int,
    help="Maximum number of changed rows to preview.",
)
def diff_csv(
    original_file: Path, modified_file: Path, key_col: str | None, limit: int
) -> None:
    """
    Compare two CSV files and print a summary of changes.

    \b
    Examples:
      g-gremlin diff csv --original before.csv --modified after.csv
      g-gremlin diff csv --original data.csv --modified updated.csv --key-col id
    """
    try:
        sheet_diff = compute_csv_diff(
            str(original_file), str(modified_file), key_col=key_col
        )
    except ValueError as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    summary = sheet_diff.summary
    click.echo(
        "Summary: "
        f"{summary.added_rows} rows added, "
        f"{summary.removed_rows} rows removed, "
        f"{summary.changed_rows} rows changed, "
        f"{summary.changed_cells} cells changed"
    )

    _print_key_list("Added rows", sheet_diff.added_keys)
    _print_key_list("Removed rows", sheet_diff.removed_keys)

    if sheet_diff.row_diffs:
        click.echo()
        click.echo(f"Showing up to {limit} changed rows:")
        for row_diff in sheet_diff.row_diffs[:limit]:
            click.echo(f"Row key: {row_diff.row_key} (row {row_diff.row_index + 1})")
            for change in row_diff.changes:
                old_val = _format_value(change.old)
                new_val = _format_value(change.new)
                click.echo(f'  [{change.column_name}] "{old_val}" -> "{new_val}"')
            click.echo()

    _get_local_state().STATE_DIR.mkdir(parents=True, exist_ok=True)
    diff_path = _get_local_state().STATE_DIR / "diff.json"
    diff_path.write_text(json.dumps(asdict(sheet_diff), indent=2), encoding="utf-8")
    click.echo(f"Diff written to {diff_path}")


@diff.command("tabs")
@click.argument("spreadsheet_id")
@click.option(
    "--tabs",
    required=True,
    help="Comma-separated pair of tab names to compare (e.g., 'Tab1,Tab2').",
)
@click.option(
    "--column",
    required=True,
    help="Column to compare values (letter like 'C' or header name).",
)
@click.option(
    "--key-column",
    default="A",
    show_default=True,
    help="Column to use as row key (letter like 'A' or header name).",
)
@click.option(
    "--all",
    "show_all",
    is_flag=True,
    help="Show all rows, not just those with differences.",
)
def diff_tabs(
    spreadsheet_id: str,
    tabs: str,
    column: str,
    key_column: str,
    show_all: bool,
) -> None:
    """
    Compare the same metric across two tabs within a spreadsheet.

    Useful for comparing scenarios, versions, or A/B tests side-by-side.

    \b
    Examples:
      g-gremlin diff tabs SHEET_ID --tabs "Base,Scenario1" --column C
      g-gremlin diff tabs SHEET_ID --tabs "Q1,Q2" --column Revenue --key-column Metric
      g-gremlin diff tabs SHEET_ID --tabs "Budget,Actual" --column D --all
    """
    from g_gremlin.diffing import compute_cross_tab_diff, format_cross_tab_table

    # Parse tab names
    tab_names = [t.strip() for t in tabs.split(",")]
    if len(tab_names) != 2:
        click.echo("Error: --tabs must specify exactly two tab names separated by comma.", err=True)
        sys.exit(1)

    tab1_name, tab2_name = tab_names

    # Fetch data from both tabs
    try:
        service = _get_google_client().get_sheets_service()

        # Get tab1 data
        range1 = f"'{tab1_name}'"
        result1 = service.spreadsheets().values().get(
            spreadsheetId=spreadsheet_id,
            range=range1,
            valueRenderOption="UNFORMATTED_VALUE",
        ).execute()
        tab1_data = result1.get("values", [])

        # Get tab2 data
        range2 = f"'{tab2_name}'"
        result2 = service.spreadsheets().values().get(
            spreadsheetId=spreadsheet_id,
            range=range2,
            valueRenderOption="UNFORMATTED_VALUE",
        ).execute()
        tab2_data = result2.get("values", [])

    except _get_http_error() as err:
        click.echo(f"Error fetching sheets data: {err}", err=True)
        sys.exit(1)

    if not tab1_data:
        error = error_helpers.sheet_tab_not_found_error(tab1_name, spreadsheet_id)
        click.echo(str(error), err=True)
        sys.exit(1)

    if not tab2_data:
        error = error_helpers.sheet_tab_not_found_error(tab2_name, spreadsheet_id)
        click.echo(str(error), err=True)
        sys.exit(1)

    # Compute diff
    try:
        diff_result = compute_cross_tab_diff(
            tab1_data=tab1_data,
            tab2_data=tab2_data,
            tab1_name=tab1_name,
            tab2_name=tab2_name,
            key_column=key_column,
            value_column=column,
        )
    except ValueError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(1)

    # Format and display
    table_output = format_cross_tab_table(diff_result, show_all=show_all)
    click.echo(table_output)

    # Summary stats
    diff_count = sum(1 for r in diff_result.rows if r.tab1_value != r.tab2_value)
    click.echo()
    click.echo(f"Total rows compared: {len(diff_result.rows)}")
    click.echo(f"Rows with differences: {diff_count}")


@cli.command()
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV to validate.",
)
@click.option(
    "--rules",
    "rules_path",
    required=False,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="YAML rules file defining validation checks.",
)
@click.option(
    "--output",
    "output_path",
    default=None,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Where to write JSON validation results (optional).",
)
@click.option(
    "--refs",
    "allowed_refs",
    required=False,
    help="Comma-separated list of allowed sheet references for formula validation.",
)
def validate(csv_path: Path, rules_path: Path | None, output_path: Path | None, allowed_refs: str | None) -> None:
    """
    Validate a CSV. If --rules is provided, runs YAML rules. Otherwise runs formula pre-flight checks.
    """
    if rules_path:
        try:
            result = _get_validate_helpers().run_validation(csv_path, rules_path)
        except Exception as exc:
            click.echo(f"Error: {exc}")
            sys.exit(1)

        output_target = output_path or Path(".g_gremlin/validate_result.json")
        _get_validate_helpers().write_result_json(result, output_target)

        for check in result.checks:
            icon = "[PASS]" if check.status == "pass" else "[FAIL]"
            click.echo(f"{icon} {check.name} ({check.message})")

        if result.summary.checks_failed > 0:
            click.echo(
                f"FAILED: {result.summary.checks_failed} of {result.summary.checks_total} checks failed."
            )
            sys.exit(1)

        click.echo(
            f"PASSED: {result.summary.checks_passed} of {result.summary.checks_total} checks passed."
        )
        return

    refs_list = _split_comma_list(allowed_refs) if allowed_refs else []
    report = _get_lint_helpers().validate_csv_file(csv_path, refs_list)
    summary = report["summary"]
    issues = report["issues"]
    click.echo(f"Validating {csv_path}...")
    click.echo(f"{summary['total_formulas']} formula cells detected")
    click.echo(f"{summary['cross_sheet_refs']} cross-sheet references")
    if issues:
        click.echo("\nIssues:")
        for issue in issues[:10]:
            cell = issue.get("cell", "?")
            click.echo(
                f"  [FAIL] {cell}: {issue.get('type','issue')} - {issue.get('message','') or issue.get('formula','')}"
            )
        if len(issues) > 10:
            click.echo(f"  ... ({len(issues)-10} more)")
        click.echo(f"\nValidation FAILED ({len(issues)} issues)")
        if output_path:
            Path(output_path).write_text(json.dumps(report, indent=2), encoding="utf-8")
        sys.exit(1)
    click.echo("[OK] Validation passed (no issues)")
    if output_path:
        Path(output_path).write_text(json.dumps(report, indent=2), encoding="utf-8")
    impact = _get_build_impact()(
        action="Pre-flight inspection",
        stats={
            "formulas_inspected": summary["total_formulas"],
            "issues_found": len(issues),
            "cross_sheet_refs_preserved": summary.get("cross_sheet_refs", 0),
        },
        scenario="validate_caught" if issues else "validate_clean",
        trophy="🛡️" if issues else "✅",
        count=summary.get("total_formulas", 0),
        refs=summary.get("cross_sheet_refs", 0),
        issues=len(issues),
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("validate", impact["stats"])


@cli.command("check-bands")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV to check.",
)
@click.option(
    "--band",
    "bands",
    required=True,
    multiple=True,
    help='Band specification like "J:M=FY26_Assumptions". Can be provided multiple times.',
)
def check_bands(csv_path: Path, bands: tuple[str, ...]) -> None:
    """
    Verify that column bands reference the correct sheets in formulas.
    """
    try:
        band_specs = [_parse_band_spec(b) for b in bands]
    except ValueError as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    report = _get_lint_helpers().check_bands(csv_path, band_specs)
    mismatches = report.get("mismatches", [])
    for result in report.get("results", []):
        click.echo(
            f"Band {result['band']} ({result['expected_sheet']}): "
            f"{result['formulas_checked']} formulas checked"
        )
        if result["mismatches"]:
            click.echo(f"  {len(result['mismatches'])} mismatches:")
            for mm in result["mismatches"][:5]:
                click.echo(
                    f"    {mm['cell']}: references '{mm['found']}' (expected {mm['expected']})"
                )
            if len(result["mismatches"]) > 5:
                click.echo("    ...")
        else:
            click.echo("  All references match expected [OK]")

    if mismatches:
        click.echo(f"FAILED ({len(mismatches)} mismatches)")
        impact = _get_build_impact()(
            action="Column-band verification",
            stats={
                "bands_checked": len(bands),
                "formulas_inspected": sum(r.get("formulas_checked", 0) for r in report.get("results", [])),
                "issues_found": len(mismatches),
            },
            scenario="bands_mismatch",
            trophy="⚠️",
            mismatches=len(mismatches),
            count=sum(r.get("formulas_checked", 0) for r in report.get("results", [])),
        )
        _get_print_impact_box()(impact)
        _get_update_stats()("check_bands", impact["stats"])
        sys.exit(1)
    click.echo("[OK] Bands check passed (no mismatches)")
    impact = _get_build_impact()(
        action="Column-band verification",
        stats={
            "bands_checked": len(bands),
            "formulas_inspected": sum(r.get("formulas_checked", 0) for r in report.get("results", [])),
            "issues_found": 0,
        },
        scenario="bands_clean",
        trophy="📊",
        mismatches=0,
        count=sum(r.get("formulas_checked", 0) for r in report.get("results", [])),
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("check_bands", impact["stats"])


@cli.command("generate-lag")
@click.option(
    "--source",
    "source_range",
    required=True,
    help='Source range like "E5:Q5".',
)
@click.option("--lag", required=True, type=int, help="Lag to apply as negative column offset.")
@click.option(
    "--output",
    "output_path",
    required=True,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Path to write the generated CSV row.",
)
@click.option(
    "--labels",
    required=False,
    help="Optional comma-separated labels to prefix before formulas.",
)
def generate_lag(source_range: str, lag: int, output_path: Path, labels: str | None) -> None:
    """
    Generate a single CSV row of IFERROR(OFFSET()) formulas for lagged waterfalls.
    """
    label_list = _split_comma_list(labels) if labels else []
    try:
        row = _get_lint_helpers().generate_lag_row(source_range, lag, label_list)
    except ValueError as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.writer(handle)
        writer.writerow(row)
    click.echo(f"Wrote lag row to {output_path}")
    impact = _get_build_impact()(
        action="OFFSET formula generation",
        stats={
            "lag_formulas_generated": len(row) - (len(label_list) if label_list else 0),
            "issues_found": 0,
        },
        scenario="generate_lag",
        trophy="⚡",
        count=len(row) - (len(label_list) if label_list else 0),
        time=0.1,
        saved=len(row) - (len(label_list) if label_list else 0),
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("generate_lag", impact["stats"])


@cli.command()
@click.option("--reset", is_flag=True, help="Reset stats to zero.")
def stats(reset: bool) -> None:
    """
    Show cumulative gremlin stats and achievements.
    """
    if reset:
        _get_stats_file().unlink(missing_ok=True)
        click.echo("Stats reset. The gremlin's memory has been wiped.")
        return
    click.echo(_get_stats_display()())

@cli.command()
@click.option(
    "--spreadsheet-id",
    "-s",
    help="Spreadsheet ID. If omitted, uses .g_gremlin/state.json.",
)
@click.option(
    "--range",
    "-r",
    "a1_range",
    help="A1 range to inspect. If omitted, uses .g_gremlin/state.json.",
)
@click.option(
    "--output",
    "-o",
    "output_path",
    default=".g_gremlin/profile.json",
    show_default=True,
    help="Where to write the JSON profile.",
)
@click.option(
    "--structure",
    is_flag=True,
    help="Analyze formula dependency graph and structural mechanics.",
)
@click.option(
    "--lint",
    is_flag=True,
    help="Check formulas for common issues.",
)
@click.option(
    "--fix",
    is_flag=True,
    help="Show proposed fixes for safe issues (requires --lint). Use --apply to execute.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute proposed fixes (requires --fix). Creates a local JSON backup file before applying (not an in-sheet snapshot).",
)
def inspect(
    spreadsheet_id: str | None,
    a1_range: str | None,
    output_path: str,
    structure: bool,
    lint: bool,
    fix: bool,
    apply: bool,
) -> None:
    """
    Analyze a sheet's structure, column types, and formulas and write a JSON profile.
    """
    if fix and not lint:
        raise click.UsageError("--fix requires --lint")
    if apply and not fix:
        raise click.UsageError("--apply requires --fix")
    state = _get_local_state().load_state()
    if spreadsheet_id and a1_range:
        resolved_spreadsheet_id = spreadsheet_id
        resolved_range = a1_range
    elif state:
        resolved_spreadsheet_id = spreadsheet_id or state.spreadsheet_id
        resolved_range = a1_range or state.range
    else:
        click.echo(
            "No spreadsheet ID or range provided and no state.json found. "
            "Run g-gremlin pull first or provide --spreadsheet-id and --range."
        )
        sys.exit(1)

    try:
        service = _get_google_client().get_sheets_service()
        values_grid = formulas_grid = formatted_grid = None
        if structure or lint:
            values_grid, formulas_grid, formatted_grid = _fetch_sheet_grids(
                service, resolved_spreadsheet_id, resolved_range
            )
        if values_grid is not None and formulas_grid is not None:
            profile = _get_profile()(
                service,
                resolved_spreadsheet_id,
                resolved_range,
                values_grid=values_grid,
                formulas_grid=formulas_grid,
            )
        else:
            profile = _get_profile()(
                service, resolved_spreadsheet_id, resolved_range
            )

        if structure and values_grid is not None and formulas_grid is not None:
            profile.structural_profile = _get_graph_helpers().compute_structural_profile(
                resolved_spreadsheet_id,
                resolved_range,
                values_grid,
                formulas_grid,
                formatted_grid,
            )

        if lint:
            issues = _run_lint(profile, resolved_spreadsheet_id)
            report = _get_lint_helpers().generate_lint_report(issues)
            fixes_applied = 0
            fixes_available = report["lint_results"].get("fixes_available", [])

            if fix:
                # Show proposed fixes
                if fixes_available:
                    click.echo(f"\nProposed fixes ({len(fixes_available)} available):")
                    for i, fix_item in enumerate(fixes_available[:10], 1):
                        cell = fix_item.get("cell", "?")
                        fix_type = fix_item.get("type", "unknown")
                        click.echo(f"  {i}. {cell}: {fix_type}")
                    if len(fixes_available) > 10:
                        click.echo(f"  ... and {len(fixes_available) - 10} more")

                    if apply:
                        # Create local JSON backup before applying fixes
                        # (This is a CLI backup file, not an in-sheet snapshot)
                        import json
                        from pathlib import Path
                        output_p = Path(output_path)
                        timestamp = int(time.time())
                        if output_p.suffix.lower() == ".json":
                            backup_path = str(output_p.with_stem(f"{output_p.stem}_backup_{timestamp}"))
                        else:
                            backup_path = f"{output_path}_backup_{timestamp}.json"

                        # Ensure parent directory exists
                        backup_p = Path(backup_path)
                        backup_p.parent.mkdir(parents=True, exist_ok=True)

                        click.echo(f"\nCreating backup: {backup_path}")
                        with open(backup_path, "w") as backup_file:
                            json.dump({
                                "spreadsheet_id": resolved_spreadsheet_id,
                                "fixes_to_apply": fixes_available,
                                "original_formulas": [
                                    {"cell": f.get("cell"), "original": f.get("original")}
                                    for f in fixes_available
                                ],
                                "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
                            }, backup_file, indent=2)

                        # Execute fixes
                        applied = _get_sheets_helpers().apply_lint_fixes(
                            resolved_spreadsheet_id, fixes_available
                        )
                        report["lint_results"]["auto_fix_applied"] = applied > 0
                        report["lint_results"]["backup_path"] = backup_path
                        fixes_applied = applied
                        click.echo(f"\nApplied {fixes_applied} fixes.")
                        click.echo(f"Backup saved to: {backup_path}")
                    else:
                        click.echo("\nTo apply these fixes, run with --fix --apply")
                else:
                    click.echo("\nNo auto-fixable issues found.")

            profile_dict = asdict(profile)
            profile_dict["lint_results"] = report["lint_results"]
            action_desc = "Formula lint"
            if fix and apply:
                action_desc += " with auto-fix applied"
            elif fix:
                action_desc += " with proposed fixes"
            impact = _get_build_impact()(
                action=action_desc,
                stats={
                    "formulas_inspected": len(profile.formula_cells),
                    "issues_found": len(issues),
                    "issues_fixed": fixes_applied,
                    "issues_remaining": max(0, len(issues) - fixes_applied),
                },
                scenario="lint_fixed" if fixes_applied else ("lint_found" if issues else "lint_clean"),
                trophy="🔧" if fixes_applied else "🔍",
                count=len(profile.formula_cells),
                issues=len(issues),
                fixed=fixes_applied,
                remaining=max(0, len(issues) - fixes_applied),
            )
            profile_dict["impact"] = impact
            _get_print_impact_box()(impact)
            _get_update_stats()("lint", impact["stats"])
        else:
            profile_dict = asdict(profile)
    except _get_http_error() as err:
        click.echo(f"Error inspecting sheet: {err}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    out_path = Path(output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    if not structure:
        profile_dict.pop("structural_profile", None)
    out_path.write_text(json.dumps(profile_dict, indent=2), encoding="utf-8")

    formula_count = len(profile.formula_cells)
    click.echo(
        f"Profile written to {out_path}\n"
        f"Sheet: {profile.sheet_name}!{resolved_range.split('!',1)[-1]}\n"
        f"Rows: {profile.row_count}, Columns: {len(profile.header)}, "
        f"Formulas: {formula_count} cells"
    )
    if structure and profile.structural_profile:
        struct_summary = profile.structural_profile.get("summary", {})
        pattern_summary = profile.structural_profile.get("formula_patterns", {})
        offset_count = pattern_summary.get("offset_lag", {}).get("count", 0)
        cross_sheet = pattern_summary.get("cross_sheet", {}).get("count", 0)
        click.echo(
            f"Structural profile: drivers={struct_summary.get('drivers', 0)}, "
            f"intermediaries={struct_summary.get('intermediaries', 0)}, "
            f"outputs={struct_summary.get('outputs', 0)}, "
            f"offset_formulas={offset_count}, cross_sheet_formulas={cross_sheet}."
        )
    if lint and profile_dict.get("lint_results"):
        lint_summary = profile_dict["lint_results"].get("summary", {})
        click.echo(
            f"Lint: errors={lint_summary.get('errors',0)}, "
            f"warnings={lint_summary.get('warnings',0)}, "
            f"fixable={lint_summary.get('fixable',0)}."
        )

@cli.command("inspect-formulas")
@click.argument("spreadsheet_id")
@click.option(
    "--range",
    "a1_range",
    required=True,
    help="A1 range to inspect (required).",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional JSON output path.",
)
@click.option(
    "--limit",
    default=200,
    show_default=True,
    help="Max formulas to print to stdout (0 for no limit).",
)
def inspect_formulas(spreadsheet_id: str, a1_range: str, output_path: Path | None, limit: int) -> None:
    """
    Show formulas within a range without pulling values.
    """
    try:
        service = _get_google_client().get_sheets_service()
    except Exception as exc:
        click.echo(f"Auth error: {exc}", err=True)
        sys.exit(1)

    try:
        parsed = parse_a1_range(a1_range)
    except Exception as exc:
        click.echo(f"Invalid range: {exc}", err=True)
        sys.exit(1)

    _, formulas_grid, _ = _fetch_sheet_grids(service, spreadsheet_id, a1_range, include_formatted=False)

    entries: list[dict[str, str]] = []
    for r_idx, row in enumerate(formulas_grid):
        for c_idx, formula in enumerate(row):
            if isinstance(formula, str) and formula.startswith("="):
                a1 = cell_a1_from_offsets(parsed, r_idx, c_idx)
                entries.append({"a1": a1, "formula": formula})

    if output_path:
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(json.dumps(entries, indent=2), encoding="utf-8")
        click.echo(f"Wrote {len(entries)} formulas to {output_path}")

    to_print = entries if limit == 0 else entries[:limit]
    for entry in to_print:
        click.echo(f"{entry['a1']}: {entry['formula']}")
    click.echo(f"Total formulas in range: {len(entries)}")

@cli.group()
def repair() -> None:
    """
    Repair broken spreadsheets - the gremlin's specialty.
    """


@repair.command("analyze")
@click.option(
    "--spreadsheet-id",
    "-s",
    required=False,
    help="Spreadsheet ID. If omitted, uses .g_gremlin/state.json.",
)
@click.option(
    "--range",
    "-r",
    "a1_range",
    required=False,
    help="A1 range to analyze. If omitted, uses .g_gremlin/state.json.",
)
@click.option(
    "--output",
    "-o",
    "output_path",
    default=".g_gremlin/repair_plan.json",
    show_default=True,
    help="Where to write the repair plan JSON.",
)
@click.option(
    "--band",
    "bands",
    multiple=True,
    help='Optional band spec like "J:M=FY26_Assumptions" to suppress expected cross-sheet anomalies.',
)
def repair_analyze(spreadsheet_id: str | None, a1_range: str | None, output_path: str, bands: tuple[str, ...]) -> None:
    """
    Analyze a range for #REF! errors, missing sheets, and pattern anomalies.
    """
    resolved_spreadsheet_id, resolved_range = _resolve_sheet_and_range(spreadsheet_id, a1_range)
    expected_sheet_map = _bands_to_expected_map(bands)
    try:
        service = _get_google_client().get_sheets_service()
        values_grid, formulas_grid, _ = _fetch_sheet_grids(service, resolved_spreadsheet_id, resolved_range)
        cells = _get_repair_helpers().cells_from_grids(resolved_range, values_grid, formulas_grid)
        existing_sheets = _get_sheets_helpers().get_all_sheet_names(resolved_spreadsheet_id)
        plan = _get_repair_helpers().analyze_sheet(cells, existing_sheets, expected_sheet_map=expected_sheet_map or None)
    except _get_http_error() as err:
        click.echo(f"Error analyzing sheet: {err}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    out_path = Path(output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    plan_dict = plan.to_dict()
    out_path.write_text(json.dumps(plan_dict, indent=2), encoding="utf-8")
    click.echo(f"Repair plan written to {out_path}")
    _print_repair_summary(plan, cells)

    impact = _get_build_impact()(
        action="Repair analysis",
        stats={
            "cells_analyzed": len(cells),
            "formulas_inspected": len([c for c in cells if str(c.get("formula", "")).startswith("=")]),
            "issues_found": len(plan.issues),
            "auto_fixable": plan_dict.get("summary", {}).get("auto_fixable", 0),
        },
        scenario="repair_analyze",
        trophy="RPR",
        count=len(cells),
        issues=len(plan.issues),
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("repair_analyze", impact["stats"])


@repair.command("fix")
@click.option(
    "--spreadsheet-id",
    "-s",
    required=False,
    help="Spreadsheet ID. If omitted, uses .g_gremlin/state.json.",
)
@click.option(
    "--range",
    "-r",
    "a1_range",
    required=False,
    help="A1 range to analyze (ignored if --plan is provided).",
)
@click.option(
    "--plan",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Use an existing repair plan JSON.",
)
@click.option(
    "--auto",
    is_flag=True,
    help="Apply high-confidence fixes without prompting.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Show what would change without applying.",
)
@click.option(
    "--band",
    "bands",
    multiple=True,
    help='Optional band spec like "J:M=FY26_Assumptions" used when generating a plan on the fly.',
)
@click.option(
    "--apply/--no-apply",
    default=True,
    show_default=True,
    help="Execute fixes (default true for compatibility). Use --no-apply to preview only.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@click.option(
    "--backup/--no-backup",
    default=True,
    show_default=True,
    help="Create a snapshot before applying fixes (default on for safety).",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Automatically restore from backup on error (for CI/agents).",
)
def repair_fix(
    spreadsheet_id: str | None,
    a1_range: str | None,
    plan: Path | None,
    auto: bool,
    dry_run: bool,
    bands: tuple[str, ...],
    apply: bool,
    json_summary: bool,
    backup: bool,
    auto_rollback: bool,
) -> None:
    """
    Apply fixes from a repair plan (auto or interactive).
    """
    effective_dry_run = dry_run or not apply
    apply_effective = not effective_dry_run
    safety_ctx = SafetyContext(command="g-gremlin repair fix", remote_mutation=True)
    if apply_effective:
        safety_ctx.enforce_apply(apply_effective)

    resolved_spreadsheet_id, resolved_range = _resolve_sheet_and_range(
        spreadsheet_id, a1_range, allow_missing_range=bool(plan)
    )
    expected_sheet_map = _bands_to_expected_map(bands)
    if plan:
        plan_obj = _load_repair_plan(plan)
    else:
        service = _get_google_client().get_sheets_service()
        values_grid, formulas_grid, _ = _fetch_sheet_grids(service, resolved_spreadsheet_id, resolved_range)
        cells = _get_repair_helpers().cells_from_grids(resolved_range, values_grid, formulas_grid)
        existing_sheets = _get_sheets_helpers().get_all_sheet_names(resolved_spreadsheet_id)
        plan_obj = _get_repair_helpers().analyze_sheet(cells, existing_sheets, expected_sheet_map=expected_sheet_map or None)

    # Backup before applying fixes
    snapshot_result = None
    if backup and apply_effective:
        snap_mod = _get_snapshot()
        # Determine tabs to backup from the range or plan
        tabs_to_backup = []
        if resolved_range and "!" in resolved_range:
            tab_name = resolved_range.split("!")[0].strip("'")
            tabs_to_backup = [tab_name]
        from datetime import datetime
        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        snapshot_name = f"pre-repair-fix-{timestamp}"
        try:
            cmd_metadata = snap_mod.collect_command_metadata()
            snapshot_result = snap_mod.create_snapshot(
                spreadsheet_id=resolved_spreadsheet_id,
                tabs=tabs_to_backup if tabs_to_backup else None,
                name=snapshot_name,
                metadata=cmd_metadata,
            )
            click.echo(f"Created backup snapshot: {snapshot_name}")
        except Exception as e:
            click.echo(f"Warning: Failed to create backup snapshot: {e}", err=True)

    try:
        if auto:
            applied = _apply_auto_fixes(resolved_spreadsheet_id, plan_obj, effective_dry_run)
            click.echo(f"Auto-applied {applied} fixes" + (" (dry run)" if effective_dry_run else ""))
            scenario = "repair_fix_auto"
        else:
            applied = _interactive_repair(resolved_spreadsheet_id, plan_obj, effective_dry_run)
            scenario = "repair_fix_manual"
    except Exception as e:
        _handle_restore_on_error(e, snapshot_result, auto_rollback, json_summary)
        raise
    remaining = max(0, len(plan_obj.issues) - applied)
    impact = _get_build_impact()(
        action="Repair fixes" + (" (dry run)" if dry_run else ""),
        stats={
            "issues_found": len(plan_obj.issues),
            "issues_fixed": applied,
            "issues_remaining": remaining,
        },
        scenario=scenario,
        trophy="RFX",
        issues=len(plan_obj.issues),
        fixed=applied,
        remaining=remaining,
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("repair_fix", impact["stats"])

    if json_summary:
        result_data = {
            "issues_found": len(plan_obj.issues),
            "issues_fixed": applied,
            "issues_remaining": remaining,
            "auto": auto,
            "used_plan": bool(plan),
        }
        # Add snapshot info if backup was created
        if snapshot_result:
            result_data["snapshot_name"] = snapshot_result.name
            result_data["snapshot_path"] = snapshot_result.file_path
            result_data["restore_command"] = f"g-gremlin snapshot restore --name {snapshot_result.name}"
            if snapshot_result.concurrency_token:
                result_data["etag"] = snapshot_result.concurrency_token
            if snapshot_result.shape:
                result_data["shape"] = snapshot_result.shape
            if snapshot_result.correlation_id:
                result_data["correlation_id"] = snapshot_result.correlation_id
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=not apply_effective,
            result=result_data,
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@repair.command("stub-sheets")
@click.option(
    "--spreadsheet-id",
    "-s",
    required=True,
    help="Spreadsheet ID to inspect for missing sheets.",
)
@click.option(
    "--range",
    "-r",
    "a1_range",
    required=False,
    help="Range to scan for missing sheet references (defaults to state.json).",
)
@click.option(
    "--analyze",
    is_flag=True,
    help="Only analyze and print missing sheet info; do not create anything.",
)
@click.option(
    "--auto",
    is_flag=True,
    help="Create stubs with inferred values without prompting.",
)
@click.option(
    "--apply/--no-apply",
    default=True,
    show_default=True,
    help="Actually create stub sheets (default true). Use --no-apply to preview only.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def repair_stub_sheets(
    spreadsheet_id: str,
    a1_range: str | None,
    analyze: bool,
    auto: bool,
    apply: bool,
    json_summary: bool,
) -> None:
    """
    Analyze missing sheet references and optionally create stub tabs.
    """
    apply_effective = apply and not analyze
    safety_ctx = SafetyContext(command="g-gremlin repair stub-sheets", remote_mutation=True)
    if apply_effective:
        safety_ctx.enforce_apply(apply_effective)

    resolved_spreadsheet_id, resolved_range = _resolve_sheet_and_range(spreadsheet_id, a1_range, allow_missing_range=True)
    if not resolved_range:
        click.echo("Provide --range or pull state.json so the gremlin knows what to scan.")
        sys.exit(1)
    try:
        service = _get_google_client().get_sheets_service()
        values_grid, formulas_grid, _ = _fetch_sheet_grids(service, resolved_spreadsheet_id, resolved_range)
        cells = _get_repair_helpers().cells_from_grids(resolved_range, values_grid, formulas_grid)
        existing_sheets = _get_sheets_helpers().get_all_sheet_names(resolved_spreadsheet_id)
        missing = _get_repair_helpers().analyze_missing_sheets(cells, existing_sheets)
    except _get_http_error() as err:
        click.echo(f"Error analyzing sheet: {err}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    if not missing:
        click.echo("No missing sheet references detected.")
        return
    _print_missing_sheet_summary(missing)
    if (analyze and not auto) or (not apply_effective):
        if json_summary:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "issues_found": len(missing),
                    "sheets_created": 0,
                    "stub_values": 0,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    created = 0
    values_written = 0
    for entry in missing:
        sheet_name = entry.get("sheet_name")
        referenced_cells = entry.get("referenced_cells", {})
        new_tab, written = _create_stub_sheet(
            service=service,
            spreadsheet_id=resolved_spreadsheet_id,
            sheet_name=sheet_name,
            referenced_cells=referenced_cells,
        )
        created += 1 if new_tab else 0
        values_written += written
    click.echo(f"Created or updated {created} stub sheet(s); wrote {values_written} inferred values.")
    impact = _get_build_impact()(
        action="Stub missing sheets",
        stats={
            "issues_found": len(missing),
            "sheets_created": created,
            "stub_values": values_written,
        },
        scenario="repair_stub",
        trophy="RST",
        count=len(missing),
        created=created,
        values=values_written,
    )
    _get_print_impact_box()(impact)
    _get_update_stats()("repair_stub", impact["stats"])

    if json_summary:
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=not apply_effective,
            result={
                "issues_found": len(missing),
                "sheets_created": created,
                "stub_values": values_written,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@repair.command("patterns")
@click.option(
    "--spreadsheet-id",
    "-s",
    required=True,
    help="Spreadsheet ID to scan.",
)
@click.option(
    "--range",
    "-r",
    "a1_range",
    required=True,
    help="A1 range to scan for pattern anomalies.",
)
@click.option(
    "--output",
    "-o",
    "output_path",
    default=".g_gremlin/patterns.json",
    show_default=True,
    help="Where to write the pattern analysis JSON.",
)
@click.option(
    "--band",
    "bands",
    multiple=True,
    help='Optional band spec like "J:M=FY26_Assumptions" to suppress expected cross-sheet anomalies.',
)
def repair_patterns(spreadsheet_id: str, a1_range: str, output_path: str, bands: tuple[str, ...]) -> None:
    """
    Detect row/column pattern anomalies only.
    """
    expected_sheet_map = _bands_to_expected_map(bands)
    try:
        service = _get_google_client().get_sheets_service()
        values_grid, formulas_grid, _ = _fetch_sheet_grids(service, spreadsheet_id, a1_range)
        cells = _get_repair_helpers().cells_from_grids(a1_range, values_grid, formulas_grid)
        issues, pattern_summary = _get_repair_helpers().find_pattern_anomalies(
            cells, expected_sheet_map=expected_sheet_map or None
        )
    except _get_http_error() as err:
        click.echo(f"Error scanning patterns: {err}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    payload = {"pattern_anomalies": pattern_summary, "issues": [i.to_dict() for i in issues]}
    out_path = Path(output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    click.echo(f"Pattern analysis written to {out_path} ({len(issues)} anomalies)")


@repair.command("diff")
@click.option(
    "--spreadsheet-id",
    "-s",
    required=True,
    help="Spreadsheet ID to compare.",
)
@click.option(
    "--range",
    "-r",
    "a1_range",
    required=True,
    help="A1 range to compare.",
)
@click.option(
    "--baseline",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Baseline CSV to compare against.",
)
@click.option(
    "--baseline-tab",
    help="Baseline tab name in the same spreadsheet.",
)
@click.option(
    "--baseline-sheet",
    help="Baseline spreadsheet ID to compare against (uses same range).",
)
@click.option(
    "--output",
    "-o",
    "output_path",
    default=".g_gremlin/repair_diff.json",
    show_default=True,
    help="Where to write the diff JSON.",
)
def repair_diff(
    spreadsheet_id: str,
    a1_range: str,
    baseline: Path | None,
    baseline_tab: str | None,
    baseline_sheet: str | None,
    output_path: str,
) -> None:
    """
    Compare current state against a baseline (file, tab, or spreadsheet).
    """
    if sum(bool(x) for x in [baseline, baseline_tab, baseline_sheet]) != 1:
        raise click.UsageError("Provide exactly one baseline source: --baseline, --baseline-tab, or --baseline-sheet.")
    try:
        service = _get_google_client().get_sheets_service()
        cur_values, cur_formulas, _ = _fetch_sheet_grids(service, spreadsheet_id, a1_range)
        base_values, base_formulas = _load_baseline_grids(
            service=service,
            a1_range=a1_range,
            baseline=baseline,
            baseline_tab=baseline_tab,
            baseline_sheet=baseline_sheet,
            current_spreadsheet_id=spreadsheet_id,
        )
    except _get_http_error() as err:
        click.echo(f"Error fetching sheet: {err}")
        sys.exit(1)
    except Exception as exc:
        click.echo(f"Error: {exc}")
        sys.exit(1)

    diff_report = _diff_grids(
        a1_range=a1_range,
        current_values=cur_values,
        current_formulas=cur_formulas,
        baseline_values=base_values,
        baseline_formulas=base_formulas,
    )
    out_path = Path(output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(diff_report, indent=2), encoding="utf-8")
    click.echo(
        f"Diff written to {out_path}: {diff_report['summary']['formula_changes']} formula changes, "
        f"{diff_report['summary']['value_changes']} value changes."
    )


def _resolve_sheet_and_range(
    spreadsheet_id: str | None, a1_range: str | None, allow_missing_range: bool = False
) -> tuple[str, str | None]:
    state = _get_local_state().load_state()
    if spreadsheet_id and a1_range:
        return spreadsheet_id, a1_range
    if state:
        resolved_id = spreadsheet_id or state.spreadsheet_id
        resolved_range = a1_range or state.range
        if resolved_range or allow_missing_range:
            return resolved_id, resolved_range
    if allow_missing_range and spreadsheet_id:
        return spreadsheet_id, a1_range
    click.echo(
        "No spreadsheet ID or range provided and no state.json found. "
        "Run g-gremlin pull first or provide --spreadsheet-id and --range."
    )
    sys.exit(1)


def _load_repair_plan(plan_path: Path) -> _get_repair_helpers().RepairPlan:
    payload = json.loads(plan_path.read_text(encoding="utf-8"))
    plan_dict = payload.get("repair_plan", payload)
    return _get_repair_helpers().RepairPlan.from_dict(plan_dict)


def _bands_to_expected_map(bands: tuple[str, ...]) -> dict[str, str]:
    expected: dict[str, str] = {}
    for spec in bands:
        try:
            start_idx, end_idx, sheet = _parse_band_spec(spec)
        except ValueError as exc:
            raise click.ClickException(str(exc))
        for idx in range(start_idx, end_idx + 1):
            expected[col_index_to_letter(idx + 1)] = sheet
    return expected


def _handle_missing_sheet_issue(service, spreadsheet_id: str, issue, auto: bool) -> int:
    suggestion = issue.suggestion or {}
    options = suggestion.get("options") or []
    if not options:
        return 0
    chosen = None
    if auto:
        # Prefer remap if available; otherwise stub
        for opt in options:
            if opt.get("action") == "remap":
                chosen = opt
                break
        if chosen is None:
            chosen = options[0]
    else:
        click.echo(f"Missing sheet in {issue.cell}: {issue.current_formula}")
        for idx, opt in enumerate(options, start=1):
            desc = f"{opt.get('action')}"
            if opt.get("target_sheet"):
                desc += f" -> {opt['target_sheet']}"
            if opt.get("create_sheet"):
                desc += f" (create {opt['create_sheet']})"
            click.echo(f"  [{idx}] {desc}")
        choice = click.prompt("Choice", type=int, default=1)
        if choice < 1 or choice > len(options):
            click.echo("Invalid choice; skipping.")
            return 0
        chosen = options[choice - 1]

    action = chosen.get("action")
    if action == "remap":
        target = chosen.get("target_sheet")
        refs = _get_repair_helpers().extract_sheet_refs(issue.current_formula)
        old_sheet = refs[0][0] if refs else None
        new_formula = _remap_formula_sheet(issue.current_formula, old_sheet, target)
        if not new_formula:
            return 0
        service.spreadsheets().values().update(
            spreadsheetId=spreadsheet_id,
            range=issue.cell,
            valueInputOption="USER_ENTERED",
            body={"values": [[new_formula]]},
        ).execute()
        return 1
    if action == "stub":
        sheet_name = chosen.get("create_sheet")
        if not sheet_name:
            return 0
        _ensure_sheet_exists(service, spreadsheet_id, sheet_name)
        return 1
    return 0


def _ensure_sheet_exists(service, spreadsheet_id: str, sheet_name: str) -> None:
    sheets = (
        service.spreadsheets()
        .get(spreadsheetId=spreadsheet_id, fields="sheets.properties.title")
        .execute()
        .get("sheets", [])
    )
    existing = {s.get("properties", {}).get("title") for s in sheets}
    if sheet_name in existing:
        return
    body = {"requests": [{"addSheet": {"properties": {"title": sheet_name}}}]}
    service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()


def _remap_formula_sheet(formula: str, old_sheet: str | None, new_sheet: str | None) -> str | None:
    if not old_sheet or not new_sheet:
        return None
    quoted_old = re.escape(old_sheet)
    pattern = rf"('{quoted_old}'!|{quoted_old}!)"
    return re.sub(pattern, f"'{new_sheet}'!", formula)


def _print_rescue_banner() -> None:
    click.echo("\n" + "=" * 64)
    click.echo("GREMLIN RESCUE")
    click.echo("=" * 64)


def _print_rescue_damage(plan_summaries: list[dict[str, Any]], total_issues: int, health: int, grade: str) -> None:
    click.echo(f"Issues found: {total_issues}")
    click.echo(f"Health score: {health}/100 (Grade: {grade})")
    for entry in plan_summaries:
        summary = _get_repair_helpers().summarize_issues(entry["plan"].issues)
        click.echo(
            f"  {entry['sheet']}: {summary.get('total_issues',0)} issues "
            f"(auto: {summary.get('auto_fixable',0)}, review: {summary.get('needs_review',0)}) "
            f"Health: {entry['plan'].health_score}"
        )


def _print_rescue_complete(
    before_health: int, after_health: int, before_grade: str, after_grade: str, fixes: int, duration_seconds: int
) -> None:
    click.echo("\n" + "=" * 64)
    click.echo("RESCUE COMPLETE")
    click.echo("=" * 64)
    click.echo(f"Health: {before_health} -> {after_health}  Grade: {before_grade} -> {after_grade}")
    click.echo(f"Fixes applied: {fixes}")
    click.echo(f"Time: {duration_seconds} seconds")


def _write_rescue_receipt(
    spreadsheet_id: str,
    before_health: int,
    before_grade: str,
    after_health: int,
    after_grade: str,
    fixes_applied: int,
    issues_before: int,
    issues_after: int,
    duration_seconds: int,
) -> None:
    payload = {
        "rescue_id": f"rescue_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}",
        "spreadsheet_id": spreadsheet_id,
        "timestamp": datetime.utcnow().isoformat() + "Z",
        "duration_seconds": duration_seconds,
        "before": {
            "health_score": before_health,
            "grade": before_grade,
            "total_issues": issues_before,
        },
        "after": {
            "health_score": after_health,
            "grade": after_grade,
            "total_issues": issues_after,
        },
        "fixes_applied": fixes_applied,
    }
    dest = Path(".g_gremlin/rescues")
    dest.mkdir(parents=True, exist_ok=True)
    out_path = dest / f"{payload['rescue_id']}.json"
    out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def _hex_to_color(hex_str: str) -> dict[str, float]:
    value = hex_str.strip().lstrip("#")
    if len(value) == 3:
        value = "".join(ch * 2 for ch in value)
    if len(value) != 6:
        return {"red": 1, "green": 1, "blue": 1}
    r = int(value[0:2], 16) / 255.0
    g = int(value[2:4], 16) / 255.0
    b = int(value[4:6], 16) / 255.0
    return {"red": r, "green": g, "blue": b}


def _get_sheet_id_and_size(service, spreadsheet_id: str, sheet_name: str) -> tuple[int, int, int]:
    resp = (
        service.spreadsheets()
        .get(spreadsheetId=spreadsheet_id, fields="sheets.properties.sheetId,sheets.properties.title,sheets.properties.gridProperties")
        .execute()
    )
    for sheet in resp.get("sheets", []):
        props = sheet.get("properties", {}) or {}
        if props.get("title") == sheet_name:
            grid = props.get("gridProperties", {}) or {}
            return props.get("sheetId"), grid.get("rowCount", 1000), grid.get("columnCount", 26)
    raise ValueError(f"Sheet '{sheet_name}' not found")


def _build_grid_range(parsed, sheet_id: int, row_count: int, col_count: int) -> dict[str, Any]:
    start_row_idx = max(parsed.start_row - 1, 0)
    start_col_idx = max(parsed.start_col - 1, 0)
    end_row_idx = parsed.end_row if parsed.end_row is not None else row_count
    end_col_idx = parsed.end_col if parsed.end_col is not None else col_count
    return {
        "sheetId": sheet_id,
        "startRowIndex": start_row_idx,
        "endRowIndex": end_row_idx,
        "startColumnIndex": start_col_idx,
        "endColumnIndex": end_col_idx,
    }


def _apply_auto_fixes(spreadsheet_id: str, plan: _get_repair_helpers().RepairPlan, dry_run: bool) -> int:
    updates: list[dict[str, Any]] = []
    for issue in plan.issues:
        if not issue.auto_fixable:
            continue
        suggestion = issue.suggestion or {}
        formula = suggestion.get("suggested_formula")
        if not formula:
            continue
        updates.append({"range": issue.cell, "values": [[formula]]})
    if not updates:
        click.echo("No auto-fixable suggestions found.")
        return 0
    if dry_run:
        _print_preview([(u["range"], None, u["values"][0][0]) for u in updates], limit=10)
        click.echo("Dry run only; no changes applied.")
        return len(updates)
    service = _get_google_client().get_sheets_service()
    service.spreadsheets().values().batchUpdate(
        spreadsheetId=spreadsheet_id,
        body={"valueInputOption": "USER_ENTERED", "data": updates},
    ).execute()
    return len(updates)


def _interactive_repair(spreadsheet_id: str, plan: _get_repair_helpers().RepairPlan, dry_run: bool) -> int:
    updates: list[dict[str, Any]] = []
    for issue in plan.issues:
        suggestion = issue.suggestion or {}
        formula = suggestion.get("suggested_formula")
        if not formula:
            continue
        click.echo(f"\n{issue.cell}: {issue.diagnosis}")
        if issue.current_formula:
            click.echo(f"  Current: {issue.current_formula}")
        click.echo(f"  Suggested: {formula}")
        apply_fix = click.confirm("Apply this fix?", default=issue.auto_fixable)
        if not apply_fix:
            continue
        updates.append({"range": issue.cell, "values": [[formula]]})
    if not updates:
        click.echo("No fixes selected.")
        return 0
    if dry_run:
        _print_preview([(u["range"], None, u["values"][0][0]) for u in updates], limit=10)
        click.echo("Dry run only; no changes applied.")
        return len(updates)
    service = _get_google_client().get_sheets_service()
    service.spreadsheets().values().batchUpdate(
        spreadsheetId=spreadsheet_id,
        body={"valueInputOption": "USER_ENTERED", "data": updates},
    ).execute()
    return len(updates)


def _print_repair_summary(plan: _get_repair_helpers().RepairPlan, cells: list[dict[str, Any]]) -> None:
    summary = plan.summary or _get_repair_helpers().summarize_issues(plan.issues)
    grade = _get_repair_helpers().health_grade(plan.health_score)
    click.echo(
        f"Issues: {summary.get('total_issues', 0)} "
        f"(auto-fixable: {summary.get('auto_fixable', 0)}, needs review: {summary.get('needs_review', 0)})"
    )
    click.echo(f"Health score: {plan.health_score}/100 (Grade: {grade})")
    missing_count = len(plan.missing_sheets)
    if missing_count:
        click.echo(f"Missing sheets referenced: {missing_count}")


def _print_missing_sheet_summary(missing: list[dict[str, Any]]) -> None:
    click.echo("Missing sheets:")
    for entry in missing:
        refs = entry.get("referenced_cells", {})
        click.echo(
            f"  - {entry.get('sheet_name')} (refs: {len(entry.get('referencing_formulas', []))}, cells: {len(refs)})"
        )


def _create_stub_sheet(
    service,
    spreadsheet_id: str,
    sheet_name: str | None,
    referenced_cells: dict[str, dict[str, Any]],
) -> tuple[bool, int]:
    if not sheet_name:
        return False, 0
    existing = _get_sheets_helpers().get_all_sheet_names(spreadsheet_id)
    created = False
    if sheet_name not in existing:
        body = {"requests": [{"addSheet": {"properties": {"title": sheet_name}}}]}
        service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
        created = True
    updates: list[dict[str, Any]] = []
    for cell_ref, meta in referenced_cells.items():
        value = meta.get("suggested_value")
        if value is None:
            continue
        updates.append({"range": f"'{sheet_name}'!{cell_ref}", "values": [[value]]})
    if updates:
        service.spreadsheets().values().batchUpdate(
            spreadsheetId=spreadsheet_id,
            body={"valueInputOption": "USER_ENTERED", "data": updates},
        ).execute()
    return created, len(updates)


def _load_baseline_grids(
    service,
    a1_range: str,
    baseline: Path | None,
    baseline_tab: str | None,
    baseline_sheet: str | None,
    current_spreadsheet_id: str | None = None,
) -> tuple[list[list[Any]], list[list[Any]]]:
    if baseline:
        rows: list[list[str]] = []
        with baseline.open("r", newline="", encoding="utf-8-sig") as handle:
            reader = csv.reader(handle)
            rows = [list(r) for r in reader]
        return rows, rows
    if baseline_tab:
        if "!" in a1_range:
            _, tail = a1_range.split("!", 1)
        else:
            tail = a1_range
        base_range = f"{baseline_tab}!{tail}"
        values, formulas, _ = _fetch_sheet_grids(service, current_spreadsheet_id or "", base_range)
        return values, formulas
    if baseline_sheet:
        values, formulas, _ = _fetch_sheet_grids(service, baseline_sheet, a1_range)
        return values, formulas
    return [], []


def _diff_grids(
    a1_range: str,
    current_values: list[list[Any]],
    current_formulas: list[list[Any]],
    baseline_values: list[list[Any]],
    baseline_formulas: list[list[Any]],
) -> dict[str, Any]:
    base_formulas = baseline_formulas or baseline_values
    current_cells = _get_repair_helpers().cells_from_grids(a1_range, current_values, current_formulas)
    baseline_cells = _get_repair_helpers().cells_from_grids(a1_range, baseline_values, base_formulas)
    current_map = {c["a1"]: c for c in current_cells}
    baseline_map = {c["a1"]: c for c in baseline_cells}
    all_cells = sorted(set(current_map.keys()) | set(baseline_map.keys()))

    formula_changes: list[dict[str, Any]] = []
    value_changes: list[dict[str, Any]] = []
    for cell in all_cells:
        cur = current_map.get(cell, {})
        base = baseline_map.get(cell, {})
        cur_formula = cur.get("formula")
        base_formula = base.get("formula")
        if cur_formula and base_formula and cur_formula != base_formula:
            formula_changes.append({"cell": cell, "baseline": base_formula, "current": cur_formula})
        cur_value = cur.get("value")
        base_value = base.get("value")
        if cur_value != base_value:
            value_changes.append({"cell": cell, "baseline": base_value, "current": cur_value})

    return {
        "summary": {
            "formula_changes": len(formula_changes),
            "value_changes": len(value_changes),
        },
        "formula_changes": formula_changes,
        "value_changes": value_changes,
    }


def _get_values_grid(
    service,
    spreadsheet_id: str,
    a1_range: str,
    value_render_option: str,
    throttle: bool = False,
    retries: int = 3,
) -> dict[str, Any]:
    backoff = 1.0
    for attempt in range(retries):
        try:
            _rate_limit(throttle)
            return (
                service.spreadsheets()
                .values()
                .get(
                    spreadsheetId=spreadsheet_id,
                    range=a1_range,
                    valueRenderOption=value_render_option,
                    majorDimension="ROWS",
                )
                .execute()
            )
        except _get_http_error() as err:
            if _is_quota_error(err) and attempt < retries - 1:
                time.sleep(backoff)
                backoff *= 2
                continue
            raise
    return {"values": []}


def _get_all_sheet_names_throttled(service, spreadsheet_id: str, throttle: bool = False, retries: int = 3) -> list[str]:
    backoff = 1.0
    for attempt in range(retries):
        try:
            _rate_limit(throttle)
            metadata = (
                service.spreadsheets()
                .get(spreadsheetId=spreadsheet_id, fields="sheets.properties.title")
                .execute()
            )
            return [s.get("properties", {}).get("title", "") for s in metadata.get("sheets", []) or []]
        except _get_http_error() as err:
            if _is_quota_error(err) and attempt < retries - 1:
                time.sleep(backoff)
                backoff *= 2
                continue
            raise
    return []


def _is_quota_error(err: Any) -> bool:
    reason = getattr(err, "reason", "") or ""
    message = getattr(err, "error_details", "") or ""
    text = f"{reason} {message}".lower()
    return "quota" in text or "per minute" in text or "rate limit" in text or getattr(err, "status_code", 0) == 429


_LAST_REQUEST_TS = 0.0


def _rate_limit(throttle: bool) -> None:
    global _LAST_REQUEST_TS
    if not throttle:
        return
    interval = 0.3  # ~3 req/sec
    now = time.time()
    elapsed = now - _LAST_REQUEST_TS
    if elapsed < interval:
        time.sleep(interval - elapsed)
    _LAST_REQUEST_TS = time.time()


def _format_value(value: str | None) -> str:
    return "<none>" if value is None else str(value)


def _print_key_list(label: str, keys: list[str], max_display: int = 5) -> None:
    if not keys:
        return
    preview = keys[:max_display]
    suffix = " ..." if len(keys) > max_display else ""
    click.echo(f"{label} ({len(keys)}): {', '.join(preview)}{suffix}")


def _print_preview(
    preview: list[tuple[str, str | None, str | None]], limit: int = 20
) -> None:
    if not preview:
        return
    click.echo(f"Previewing {min(limit, len(preview))} of {len(preview)} changes:")
    for entry in preview[:limit]:
        a1, old, new = entry
        click.echo(
            f'{a1}: "{_format_value(old)}" -> "{_format_value(new)}"'
        )


def _warning(msg: str, code: str = "WARN", severity: str = "warning") -> AgenticWarning:
    return AgenticWarning(code=code, message=msg, severity=severity)


def _fetch_sheet_grids(
    service, spreadsheet_id: str, a1_range: str, throttle: bool = False, include_formatted: bool = True
) -> tuple[list[list[Any]], list[list[Any]], list[list[Any]]]:
    """
    Fetch values, formulas, and formatted values for a range in one place.
    """
    values_resp = _get_values_grid(
        service, spreadsheet_id, a1_range, value_render_option="UNFORMATTED_VALUE", throttle=throttle
    )
    formulas_resp = _get_values_grid(service, spreadsheet_id, a1_range, value_render_option="FORMULA", throttle=throttle)
    formatted_resp = (
        _get_values_grid(service, spreadsheet_id, a1_range, value_render_option="FORMATTED_VALUE", throttle=throttle)
        if include_formatted
        else {"values": []}
    )
    return (
        values_resp.get("values", []) or [],
        formulas_resp.get("values", []) or [],
        formatted_resp.get("values", []) or [],
    )


def _run_lint(profile, spreadsheet_id: str) -> list[dict[str, Any]]:
    """
    Run lint checks across all formula cells in a profile.
    """
    issues: list[dict[str, Any]] = []
    sheet_names = _get_sheets_helpers().get_all_sheet_names(spreadsheet_id)
    for fc in profile.formula_cells:
        cell_issues = _get_lint_helpers().lint_formula(
            cell=fc.a1,
            formula=fc.formula,
            value=fc.value,
            existing_sheets=sheet_names,
        )
        issues.extend(cell_issues)
    return issues


def _parse_field_spec(raw: str) -> tuple[str, str | None]:
    """
    Parse field specs like "Field", "Field AS Alias", or "Field as 'Alias Name'".
    """
    parts = raw.split(" as ", 1)
    if len(parts) == 1:
        parts = raw.split(" AS ", 1)
    api = raw
    alias = None
    if len(parts) > 1:
        api = parts[0].strip()
        alias = parts[1].strip().strip("'\"")
    else:
        api = raw.strip()
    return api, alias


def _parse_renames(entries: tuple[str, ...]) -> dict[str, str]:
    mapping: dict[str, str] = {}
    for entry in entries:
        if "=" not in entry:
            raise click.ClickException("Rename must be in form Old=New")
        old, new = entry.split("=", 1)
        mapping[old.strip()] = new.strip()
    return mapping


def _split_comma_list(raw: str) -> list[str]:
    return [p.strip() for p in raw.split(",") if p.strip()]


def _parse_band_spec(raw: str) -> tuple[int, int, str]:
    """
    Parse band spec like 'J:M=TabName' into (start_idx, end_idx, sheet).
    """
    if "=" not in raw or ":" not in raw:
        raise ValueError(f"Invalid band spec '{raw}'. Use format J:M=TabName")
    cols_part, sheet = raw.split("=", 1)
    start_col, end_col = cols_part.split(":", 1)
    return _letters_to_index(start_col) - 1, _letters_to_index(end_col) - 1, sheet


def _letters_to_index(letters: str) -> int:
    value = 0
    for ch in letters.strip().upper():
        if not ch.isalpha():
            raise ValueError(f"Invalid column letter '{letters}'")
        value = value * 26 + (ord(ch) - ord("A") + 1)
    return value


def _count_formulas_in_csv(csv_path: Path) -> int:
    count = 0
    with csv_path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.reader(handle)
        for row in reader:
            for cell in row:
                if isinstance(cell, str) and cell.startswith("="):
                    count += 1
    return count


def _pull_to_csv(spreadsheet_id: str, a1_range: str, output_file: Path) -> None:
    service = _get_google_client().get_sheets_service()
    values_response = (
        service.spreadsheets()
        .values()
        .get(
            spreadsheetId=spreadsheet_id,
            range=a1_range,
            valueRenderOption="UNFORMATTED_VALUE",
        )
        .execute()
    )
    values = values_response.get("values", [])
    output_file.parent.mkdir(parents=True, exist_ok=True)
    with output_file.open("w", newline="", encoding="utf-8") as csv_file:
        writer = csv.writer(csv_file)
        writer.writerows(values)


@cli.command()
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview updates without sending them to Google Sheets.",
)
@click.option(
    "--force",
    is_flag=True,
    help="Proceed even if remote ETag/revision has changed since pull.",
)
@click.option(
    "--backup/--no-backup",
    "backup",
    default=True,
    show_default=True,
    help="Create a local snapshot before pushing (default ON).",
)
@click.option(
    "--auto-rollback",
    is_flag=True,
    help="Automatically restore from snapshot on error (for CI/agents).",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format for summary (text|json).",
)
@click.option(
    "--json",
    "json_flag",
    is_flag=True,
    help="Shortcut for --format json.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write JSON summary to a file (use - for stdout).",
)
def push(
    dry_run: bool,
    force: bool,
    backup: bool,
    auto_rollback: bool,
    json_summary: bool,
    output_format: str,
    json_flag: bool,
    output_path: Path | None,
) -> None:
    """
    Apply changes from diff.json back to the Sheet with ETag safety.

    By default, creates a local snapshot before pushing for rollback safety.
    """
    apply = not dry_run
    safety_ctx = SafetyContext(command="g-gremlin push", remote_mutation=True)
    if apply:
        safety_ctx.enforce_apply(apply, force=force)
    emit_json = _should_emit_json(output_format, json_flag, json_summary) or output_path is not None

    state = _get_local_state().load_state()
    if state is None:
        click.echo("No state found at .g_gremlin/state.json. Run g-gremlin pull first.")
        sys.exit(1)
    if not state.concurrency_token:
        click.echo(
            "No concurrency token found in state.json. Run 'g-gremlin pull' again before pushing."
        )
        sys.exit(1)

    try:
        sheet_diff = _get_push_helpers().load_sheet_diff()
    except FileNotFoundError as exc:
        click.echo(str(exc))
        sys.exit(1)

    if not _get_push_helpers().diff_has_changes(sheet_diff):
        click.echo("No changes to push.")
        return

    try:
        updates, warnings, skipped, preview = _get_push_helpers().build_value_updates(
            state, sheet_diff
        )
    except ValueError as exc:
        click.echo(f"Error preparing updates: {exc}")
        sys.exit(1)

    for warning in warnings:
        click.echo(warning)
    if skipped:
        click.echo(f"Skipped {len(skipped)} changes:")
        for line in skipped[:5]:
            click.echo(f"  {line}")
        if len(skipped) > 5:
            click.echo("  ...")

    # Extract tab name from state range for snapshot
    tab_name = state.range.split("!")[0].strip("'") if "!" in state.range else "Sheet1"
    snapshot_result = None

    if not updates:
        click.echo("No changes to push (only additions/removals or invalid offsets).")
        return

    change_count = len(updates)
    preview_limit = 20
    if dry_run:
        click.echo(
            f"Would update {change_count} cells in spreadsheet "
            f"{state.spreadsheet_id} (range {state.range})."
        )
        _print_preview(preview, limit=preview_limit)
        if emit_json:
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "changes": change_count,
                    "spreadsheet_id": state.spreadsheet_id,
                    "range": state.range,
                    "backup_enabled": backup,
                },
                warnings=[_warning(w) for w in warnings],
            )
            _emit_json_payload(json.dumps(agentic.to_dict(), indent=2), output_path)
        return

    # Create local snapshot before push if backup is enabled
    if backup:
        snapshot_mod = _get_snapshot()
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        snapshot_name = f"pre-push-{tab_name}-{timestamp}"
        metadata = snapshot_mod.collect_command_metadata()

        click.echo(f"Creating backup snapshot: {snapshot_name}...")
        snapshot_result = snapshot_mod.create_snapshot(
            spreadsheet_id=state.spreadsheet_id,
            name=snapshot_name,
            tabs=[tab_name],
            metadata=metadata,
        )
        if not snapshot_result.success:
            click.echo(f"Warning: Failed to create backup snapshot: {'; '.join(snapshot_result.errors)}")
            if not click.confirm("Continue without backup?", default=False):
                sys.exit(1)
            snapshot_result = None
        else:
            click.echo(f"Snapshot created: {snapshot_result.snapshot_path}")

    try:
        unchanged, stored_marker, remote_marker = _get_push_helpers().check_concurrency(state)
    except _get_http_error() as err:
        click.echo(f"Error checking remote version: {err}")
        sys.exit(1)

    if not unchanged:
        if not remote_marker:
            msg = (
                "Remote version could not be verified (missing concurrency token). "
                "Aborting. Please run g-gremlin pull again."
            )
        else:
            msg = (
                "Remote file has changed since pull. Aborting. "
                "Please run g-gremlin pull again."
            )
        if not force:
            click.echo(msg)
            sys.exit(1)
        click.echo(
            "Warning: remote file has changed since pull; proceeding due to --force."
        )

    try:
        _get_push_helpers().send_updates(state.spreadsheet_id, updates)
    except _get_http_error() as err:
        _handle_restore_on_error(err, snapshot_result, auto_rollback, emit_json)
        click.echo(f"Error pushing updates: {err}")
        sys.exit(1)

    click.echo(f"Updated {change_count} cells in spreadsheet {state.spreadsheet_id}.")
    _print_preview(preview, limit=preview_limit)

    if emit_json:
        result_dict = {
            "changes": change_count,
            "spreadsheet_id": state.spreadsheet_id,
            "range": state.range,
        }
        if snapshot_result and snapshot_result.success:
            result_dict["snapshot_name"] = snapshot_result.snapshot_path.stem.rsplit("_", 2)[0]
            result_dict["snapshot_path"] = str(snapshot_result.snapshot_path)
            result_dict["restore_command"] = f"g-gremlin snapshot restore --name {result_dict['snapshot_name']}"
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result=result_dict,
            warnings=[_warning(w) for w in warnings],
        )
        _emit_json_payload(json.dumps(agentic.to_dict(), indent=2), output_path)


# =============================================================================
# Sink command group - land data to BQ/S3/GCS
# =============================================================================


def _get_sink():
    """Lazy import sink module."""
    from g_gremlin import sink
    return sink


@cli.group()
def sink_cmd() -> None:
    """
    Land CSV data to external systems (BigQuery, S3, GCS).

    \b
    Examples:
      # BigQuery
      g-gremlin sink bq --csv data.csv --project myproj --dataset revops --table accounts

      # S3
      g-gremlin sink s3 --csv data.csv --bucket my-bucket --prefix exports/

      # GCS
      g-gremlin sink gcs --csv data.csv --bucket my-bucket --prefix exports/

    Note: Sink commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


# Rename the group to 'sink' for CLI usage
sink_cmd.name = "sink"


@sink_cmd.command("bq")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to CSV file to upload.",
)
@click.option(
    "--project",
    required=False,
    help="GCP project ID. Uses G_GREMLIN_BQ_PROJECT env var or config if not specified.",
)
@click.option(
    "--dataset",
    required=True,
    help="BigQuery dataset name.",
)
@click.option(
    "--table",
    required=True,
    help="BigQuery table name.",
)
@click.option(
    "--mode",
    type=click.Choice(["replace", "append", "truncate"], case_sensitive=False),
    default="replace",
    show_default=True,
    help="Write mode: replace (truncate+load), append, or truncate.",
)
@click.option(
    "--schema",
    "schema_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Schema YAML/JSON file for validation.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate without writing to BigQuery.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sink_bq(
    csv_path: Path,
    project: str | None,
    dataset: str,
    table: str,
    mode: str,
    schema_path: Path | None,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Sink CSV data to BigQuery.

    \b
    Examples:
      # Replace table with CSV contents
      g-gremlin sink bq --csv accounts.csv --project myproj --dataset revops --table accounts

      # Append to existing table
      g-gremlin sink bq --csv new_leads.csv --project myproj --dataset revops --table leads --mode append

      # Dry run with schema validation
      g-gremlin sink bq --csv data.csv --project myproj --dataset revops --table data --schema schema.yaml --dry-run
    """
    sink_mod = _get_sink()

    # Resolve project
    effective_project = project or sink_mod.get_default_project()
    if not effective_project:
        click.echo(
            "Error: --project is required (or set G_GREMLIN_BQ_PROJECT env var or config)",
            err=True,
        )
        sys.exit(1)

    result = sink_mod.sink_to_bigquery(
        csv_path=csv_path,
        project=effective_project,
        dataset=dataset,
        table=table,
        mode=mode,
        schema_path=schema_path,
        dry_run=dry_run,
    )

    if result.success:
        action = "Would write" if dry_run else "Wrote"
        click.echo(f"{action} {result.rows_written} rows to {result.target_uri}")
        if result.warnings:
            for warning in result.warnings:
                click.echo(f"Warning: {warning}", err=True)
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sink bq", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status="success" if result.success else "fail",
            dry_run=dry_run,
            result=result.to_dict(),
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@sink_cmd.command("s3")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to CSV file to upload.",
)
@click.option(
    "--bucket",
    required=False,
    help="S3 bucket name. Uses G_GREMLIN_S3_BUCKET env var or config if not specified.",
)
@click.option(
    "--prefix",
    default="",
    help="Key prefix (folder path) in bucket.",
)
@click.option(
    "--filename",
    help="Override filename in S3 (default: original filename).",
)
@click.option(
    "--compress",
    is_flag=True,
    help="Gzip compress before uploading.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate without uploading to S3.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sink_s3(
    csv_path: Path,
    bucket: str | None,
    prefix: str,
    filename: str | None,
    compress: bool,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Sink CSV data to Amazon S3.

    \b
    Examples:
      # Upload CSV to S3
      g-gremlin sink s3 --csv accounts.csv --bucket my-bucket --prefix exports/2025/

      # Upload with compression
      g-gremlin sink s3 --csv data.csv --bucket my-bucket --compress

      # Dry run
      g-gremlin sink s3 --csv data.csv --bucket my-bucket --dry-run
    """
    sink_mod = _get_sink()

    # Resolve bucket
    effective_bucket = bucket or sink_mod.get_default_s3_bucket()
    if not effective_bucket:
        click.echo(
            "Error: --bucket is required (or set G_GREMLIN_S3_BUCKET env var or config)",
            err=True,
        )
        sys.exit(1)

    result = sink_mod.sink_to_s3(
        csv_path=csv_path,
        bucket=effective_bucket,
        prefix=prefix,
        filename=filename,
        compress=compress,
        dry_run=dry_run,
    )

    if result.success:
        action = "Would upload" if dry_run else "Uploaded"
        click.echo(f"{action} {result.rows_written} rows to {result.target_uri}")
        if result.warnings:
            for warning in result.warnings:
                click.echo(f"Warning: {warning}", err=True)
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sink s3", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success" if result.success else "fail",
            dry_run=dry_run,
            result=result.to_dict(),
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@sink_cmd.command("gcs")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to CSV file to upload.",
)
@click.option(
    "--bucket",
    required=False,
    help="GCS bucket name. Uses G_GREMLIN_GCS_BUCKET env var or config if not specified.",
)
@click.option(
    "--prefix",
    default="",
    help="Blob prefix (folder path) in bucket.",
)
@click.option(
    "--filename",
    help="Override filename in GCS (default: original filename).",
)
@click.option(
    "--compress",
    is_flag=True,
    help="Gzip compress before uploading.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate without uploading to GCS.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sink_gcs(
    csv_path: Path,
    bucket: str | None,
    prefix: str,
    filename: str | None,
    compress: bool,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Sink CSV data to Google Cloud Storage.

    \b
    Examples:
      # Upload CSV to GCS
      g-gremlin sink gcs --csv accounts.csv --bucket my-bucket --prefix exports/2025/

      # Upload with compression
      g-gremlin sink gcs --csv data.csv --bucket my-bucket --compress

      # Dry run
      g-gremlin sink gcs --csv data.csv --bucket my-bucket --dry-run
    """
    sink_mod = _get_sink()

    # Resolve bucket
    effective_bucket = bucket or sink_mod.get_default_gcs_bucket()
    if not effective_bucket:
        click.echo(
            "Error: --bucket is required (or set G_GREMLIN_GCS_BUCKET env var or config)",
            err=True,
        )
        sys.exit(1)

    result = sink_mod.sink_to_gcs(
        csv_path=csv_path,
        bucket=effective_bucket,
        prefix=prefix,
        filename=filename,
        compress=compress,
        dry_run=dry_run,
    )

    if result.success:
        action = "Would upload" if dry_run else "Uploaded"
        click.echo(f"{action} {result.rows_written} rows to {result.target_uri}")
        if result.warnings:
            for warning in result.warnings:
                click.echo(f"Warning: {warning}", err=True)
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sink gcs", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success" if result.success else "fail",
            dry_run=dry_run,
            result=result.to_dict(),
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@sink_cmd.command("snowflake")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to CSV file to load.",
)
@click.option(
    "--stage-bucket",
    required=True,
    help="S3 or GCS bucket for staging the file.",
)
@click.option(
    "--database",
    required=True,
    help="Snowflake database name.",
)
@click.option(
    "--schema",
    required=True,
    help="Snowflake schema name.",
)
@click.option(
    "--table",
    required=True,
    help="Snowflake table name.",
)
@click.option(
    "--mode",
    type=click.Choice(["replace", "append", "truncate"]),
    default="replace",
    help="Write mode (default: replace).",
)
@click.option(
    "--stage-prefix",
    default="g_gremlin_staging",
    help="Prefix in staging bucket (default: g_gremlin_staging).",
)
@click.option(
    "--use-gcs",
    is_flag=True,
    help="Use GCS for staging instead of S3.",
)
@click.option(
    "--execute-copy/--no-execute-copy",
    default=True,
    help="Execute COPY INTO after staging (requires Snowflake creds).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate and generate COPY command without executing.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sink_snowflake(
    csv_path: Path,
    stage_bucket: str,
    database: str,
    schema: str,
    table: str,
    mode: str,
    stage_prefix: str,
    use_gcs: bool,
    execute_copy: bool,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Sink CSV data to Snowflake via S3/GCS staging.

    \b
    Flow:
    1. Upload CSV to staging bucket (S3 or GCS)
    2. Generate COPY INTO command
    3. Execute COPY if Snowflake credentials are available

    \b
    Environment variables:
      SNOWFLAKE_ACCOUNT   Snowflake account identifier
      SNOWFLAKE_USER      Snowflake username
      SNOWFLAKE_PASSWORD  Snowflake password
      SNOWFLAKE_WAREHOUSE (optional) Warehouse to use

    \b
    Examples:
      # Stage to S3 and run COPY
      g-gremlin sink snowflake --csv accounts.csv \\
        --stage-bucket my-staging-bucket \\
        --database ANALYTICS --schema REVOPS --table ACCOUNTS

      # Stage to GCS with dry run
      g-gremlin sink snowflake --csv data.csv \\
        --stage-bucket my-gcs-bucket --use-gcs \\
        --database PROD --schema PUBLIC --table DATA --dry-run

      # Stage only, don't execute COPY
      g-gremlin sink snowflake --csv data.csv \\
        --stage-bucket my-bucket \\
        --database DB --schema SCH --table TBL --no-execute-copy
    """
    sink_mod = _get_sink()

    result = sink_mod.sink_to_snowflake(
        csv_path=csv_path,
        stage_bucket=stage_bucket,
        database=database,
        schema=schema,
        table=table,
        mode=mode,
        stage_prefix=stage_prefix,
        use_gcs=use_gcs,
        dry_run=dry_run,
        execute_copy=execute_copy,
    )

    if result.success:
        action = "Would load" if dry_run else "Loaded"
        click.echo(f"{action} {result.rows_written} rows to {result.target_uri}")
        click.echo(f"Staged at: {result.stage_uri}")
        if result.manifest_path:
            click.echo(f"Manifest: {result.manifest_path}")
        if result.warnings:
            for warning in result.warnings:
                click.echo(f"Warning: {warning}", err=True)
        if dry_run or not execute_copy:
            click.echo("\nCOPY command:")
            click.echo(result.copy_command)
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sink snowflake", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status="success" if result.success else "fail",
            dry_run=dry_run,
            result={
                **result.to_dict(),
                "stage_uri": result.stage_uri,
                "manifest_path": result.manifest_path,
                "copy_command": result.copy_command,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@sink_cmd.command("redshift")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to CSV file to load.",
)
@click.option(
    "--stage-bucket",
    required=True,
    help="S3 bucket for staging the file.",
)
@click.option(
    "--host",
    required=True,
    help="Redshift cluster endpoint.",
)
@click.option(
    "--database",
    required=True,
    help="Redshift database name.",
)
@click.option(
    "--schema",
    required=True,
    help="Redshift schema name.",
)
@click.option(
    "--table",
    required=True,
    help="Redshift table name.",
)
@click.option(
    "--iam-role",
    required=True,
    help="IAM role ARN for COPY command.",
)
@click.option(
    "--mode",
    type=click.Choice(["replace", "append", "truncate"]),
    default="replace",
    help="Write mode (default: replace).",
)
@click.option(
    "--stage-prefix",
    default="g_gremlin_staging",
    help="Prefix in staging bucket (default: g_gremlin_staging).",
)
@click.option(
    "--port",
    default=5439,
    type=int,
    help="Redshift port (default: 5439).",
)
@click.option(
    "--execute-copy/--no-execute-copy",
    default=True,
    help="Execute COPY after staging (requires Redshift creds).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate and generate COPY command without executing.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit AgenticResult-style JSON summary.",
)
def sink_redshift(
    csv_path: Path,
    stage_bucket: str,
    host: str,
    database: str,
    schema: str,
    table: str,
    iam_role: str,
    mode: str,
    stage_prefix: str,
    port: int,
    execute_copy: bool,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Sink CSV data to Redshift via S3 staging.

    \b
    Flow:
    1. Upload CSV to S3 staging bucket
    2. Generate COPY command with IAM role
    3. Execute COPY if Redshift credentials are available

    \b
    Environment variables:
      REDSHIFT_USER      Redshift username
      REDSHIFT_PASSWORD  Redshift password

    \b
    Examples:
      # Stage to S3 and run COPY
      g-gremlin sink redshift --csv accounts.csv \\
        --stage-bucket my-staging-bucket \\
        --host my-cluster.region.redshift.amazonaws.com \\
        --database analytics --schema revops --table accounts \\
        --iam-role arn:aws:iam::123456789:role/RedshiftCopyRole

      # Dry run to see COPY command
      g-gremlin sink redshift --csv data.csv \\
        --stage-bucket my-bucket \\
        --host cluster.redshift.amazonaws.com \\
        --database prod --schema public --table data \\
        --iam-role arn:aws:iam::123456789:role/Role --dry-run

      # Stage only without executing COPY
      g-gremlin sink redshift --csv data.csv \\
        --stage-bucket my-bucket \\
        --host cluster.redshift.amazonaws.com \\
        --database db --schema sch --table tbl \\
        --iam-role arn:aws:iam::123456789:role/Role --no-execute-copy
    """
    sink_mod = _get_sink()

    result = sink_mod.sink_to_redshift(
        csv_path=csv_path,
        stage_bucket=stage_bucket,
        host=host,
        database=database,
        schema=schema,
        table=table,
        iam_role=iam_role,
        mode=mode,
        stage_prefix=stage_prefix,
        port=port,
        dry_run=dry_run,
        execute_copy=execute_copy,
    )

    if result.success:
        action = "Would load" if dry_run else "Loaded"
        click.echo(f"{action} {result.rows_written} rows to {result.target_uri}")
        click.echo(f"Staged at: {result.stage_uri}")
        if result.manifest_path:
            click.echo(f"Manifest: {result.manifest_path}")
        if result.warnings:
            for warning in result.warnings:
                click.echo(f"Warning: {warning}", err=True)
        if dry_run or not execute_copy:
            click.echo("\nCOPY command:")
            click.echo(result.copy_command)
    else:
        for error in result.errors:
            click.echo(f"Error: {error}", err=True)
        sys.exit(1)

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin sink redshift", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status="success" if result.success else "fail",
            dry_run=dry_run,
            result={
                **result.to_dict(),
                "stage_uri": result.stage_uri,
                "manifest_path": result.manifest_path,
                "copy_command": result.copy_command,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@sink_cmd.command("cleanup")
@click.option(
    "--older-than",
    default=7,
    type=int,
    help="Delete files older than this many days (default: 7).",
)
@click.option(
    "--delete-from-cloud",
    is_flag=True,
    help="Also delete staged files from S3/GCS (not just manifests).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Show what would be deleted without deleting.",
)
@click.option(
    "--json-summary",
    "json_summary",
    is_flag=True,
    help="Emit JSON summary of cleanup results.",
)
def sink_cleanup(
    older_than: int,
    delete_from_cloud: bool,
    dry_run: bool,
    json_summary: bool,
) -> None:
    """
    Clean up old staging files and manifests.

    By default, only deletes local manifest files. Use --delete-from-cloud
    to also delete the staged files from S3/GCS.

    \b
    Examples:
      # Delete manifests older than 7 days (default)
      g-gremlin sink cleanup

      # Delete manifests older than 30 days
      g-gremlin sink cleanup --older-than 30

      # Delete manifests and staged files from cloud
      g-gremlin sink cleanup --delete-from-cloud

      # Dry run to see what would be deleted
      g-gremlin sink cleanup --delete-from-cloud --dry-run
    """
    sink_mod = _get_sink()

    result = sink_mod.cleanup_staged_files(
        older_than_days=older_than,
        delete_from_cloud=delete_from_cloud,
        dry_run=dry_run,
    )

    action = "Would delete" if dry_run else "Deleted"
    click.echo(f"{action} {result['manifests_deleted']} manifest(s)")
    if delete_from_cloud:
        click.echo(f"{action} {result['staged_files_deleted']} staged file(s) from cloud")

    if result["errors"]:
        for error in result["errors"]:
            click.echo(f"Error: {error}", err=True)

    if json_summary:
        click.echo(json.dumps(result, indent=2))


# =============================================================================
# Auth command group - credential management
# =============================================================================


@cli.group()
def auth() -> None:
    """
    Manage authentication and credentials.

    Store secrets securely in OS keychain (macOS Keychain Access or
    Windows Credential Manager) instead of plaintext files.

    For AI/automation:
      1) g-gremlin auth status
      2) g-gremlin auth explain <integration> --surface runtime --json
      3) For browser OAuth bootstrap commands, inspect bridge auth:
         g-gremlin auth explain <integration> --surface bridge --json

    \b
    Examples:
      g-gremlin auth status           # Show configured integrations
      g-gremlin auth set apollo       # Store Apollo API key
      g-gremlin auth export           # Print env var names for CI
      g-gremlin auth explain outreach --surface runtime --json
      g-gremlin auth explain zoominfo --surface bridge --json

    \b
    Exit codes:
      0 = Success
      1 = Issues found (e.g., plaintext secrets detected)
      2 = Usage error (missing prereqs, no keychain)
      3 = Remote/API error
    """


@auth.command("status")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output as JSON.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Profile to check.",
)
def auth_status(json_output: bool, profile: str) -> None:
    """
    Show status of all configured integrations.

    Displays which integrations have credentials configured and their
    source (environment variable, keychain, legacy file). Never reveals
    actual secret values.

    \b
    Exit codes:
      0 = All OK (no issues)
      1 = Issues found (plaintext secrets detected)
      2 = No integrations configured
    """
    provider = _get_credential_provider()
    statuses = provider.list_integrations(profile)

    has_plaintext = any(s.is_plaintext for s in statuses if s.configured)
    configured_count = sum(1 for s in statuses if s.configured)

    if json_output:
        output = {
            "profile": profile,
            "integrations": [s.to_dict() for s in statuses],
            "has_plaintext_warning": has_plaintext,
            "configured_count": configured_count,
        }
        click.echo(json.dumps(output, indent=2))
    else:
        click.echo(f"Integration Status (profile: {profile})\n")

        for status in statuses:
            if status.configured:
                icon = "!" if status.is_plaintext else "ok"
                source = status.source.value
                keys = ", ".join(status.keys_present)
                click.echo(f"  [{icon}] {status.integration}: {source} ({keys})")
                if status.keys_missing:
                    click.echo(f"      Missing: {', '.join(status.keys_missing)}")
                if status.is_plaintext:
                    click.echo("      Warning: stored in plaintext file")
            else:
                click.echo(f"  [ ] {status.integration}: not configured")

        click.echo()

        if has_plaintext:
            click.echo("Warning: Some secrets are stored in plaintext files.")
            click.echo("Run 'g-gremlin auth migrate' to move them to secure storage.")

    # Exit codes per spec
    if configured_count == 0:
        sys.exit(EXIT_USAGE)  # 2 = no integrations configured
    elif has_plaintext:
        sys.exit(EXIT_ISSUES)  # 1 = issues found


@auth.command("explain")
@click.argument("integration")
@click.option(
    "--surface",
    type=click.Choice(["bridge", "runtime"]),
    default="runtime",
    show_default=True,
    help="Which auth path to explain.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output as JSON.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Profile to inspect.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="Override FoundryOps bearer token (bridge surface only).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="Override FoundryOps API key (bridge surface only).",
)
@click.option(
    "--workspace-id",
    envvar="FOUNDRYMATCH_WORKSPACE_ID",
    help="Override workspace ID (bridge surface only).",
)
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=None,
    help="FoundryOps API base URL (bridge surface only).",
)
@click.option(
    "--client-id",
    default=None,
    help="Explicit client ID (runtime surface only).",
)
@click.option(
    "--client-secret",
    default=None,
    help="Explicit client secret (runtime surface only).",
)
@click.option(
    "--refresh-token",
    default=None,
    help="Explicit refresh token (runtime surface only).",
)
@click.option(
    "--access-token",
    default=None,
    help="Explicit access token (runtime surface only).",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Allow dev-mode bridge auth resolution.",
)
def auth_explain(
    integration: str,
    surface: str,
    json_output: bool,
    profile: str,
    auth_token: str | None,
    api_key: str | None,
    workspace_id: str | None,
    api_base: str | None,
    client_id: str | None,
    client_secret: str | None,
    refresh_token: str | None,
    access_token: str | None,
    dev: bool,
) -> None:
    """
    Explain which auth source wins and why.

    Examples:
      g-gremlin auth explain outreach --surface runtime --json
      g-gremlin auth explain zoominfo --surface bridge --json

    Use `bridge` for FoundryOps-backed browser OAuth bootstrap commands and
    `runtime` for the credentials later used by integration API calls.
    """
    config = _load_auth_explain_config(integration)

    if surface == "bridge":
        resolution = resolve_bridge_auth(
            integration=integration,
            auth_token=auth_token,
            api_key=api_key,
            workspace_id=workspace_id,
            api_base=api_base,
            profile=profile,
            allow_dev=dev,
            credential_provider=_get_credential_provider(),
            config=config,
        )
    else:
        resolution = resolve_runtime_auth(
            integration=integration,
            explicit_credentials={
                "client_id": client_id,
                "client_secret": client_secret,
                "refresh_token": refresh_token,
                "access_token": access_token,
            },
            profile=profile,
            credential_provider=_get_credential_provider(),
            config=config,
        )

    click.echo(format_auth_resolution(resolution, json_output=json_output))


@auth.command("export")
@click.option(
    "--format",
    "fmt",
    type=click.Choice(["env", "shell", "docker"]),
    default="env",
    show_default=True,
    help="Output format.",
)
@click.option(
    "--integration",
    help="Export only this integration.",
)
def auth_export(fmt: str, integration: str | None) -> None:
    """
    Print environment variable names for CI configuration.

    Does NOT print secret values - only the variable names needed.
    Use this output to configure your CI secrets.

    \b
    Formats:
      env    - VAR_NAME= (default)
      shell  - export VAR_NAME=""
      docker - -e VAR_NAME=${VAR_NAME}

    \b
    Examples:
      g-gremlin auth export                   # List all required env vars
      g-gremlin auth export --format shell    # Export format for bash
      g-gremlin auth export --integration apollo

    Exit code: 0 = Success
    """
    registry = _get_integration_registry()

    if integration:
        if integration not in registry:
            click.echo(f"Error: Unknown integration '{integration}'", err=True)
            click.echo(f"Available: {', '.join(sorted(registry.keys()))}", err=True)
            sys.exit(EXIT_USAGE)
        specs = [registry[integration]]
    else:
        specs = list(registry.values())

    for spec in specs:
        if fmt == "env":
            click.echo(f"# {spec.display_name}")
            for key in spec.required_keys:
                click.echo(f"{spec.get_env_var_name(key)}=")
            for key in spec.optional_keys:
                click.echo(f"# {spec.get_env_var_name(key)}=  # optional")
            click.echo()
        elif fmt == "shell":
            for key in spec.required_keys:
                click.echo(f"export {spec.get_env_var_name(key)}=\"\"")
            for key in spec.optional_keys:
                click.echo(f"# export {spec.get_env_var_name(key)}=\"\"  # optional")
        elif fmt == "docker":
            for key in spec.required_keys:
                env_name = spec.get_env_var_name(key)
                click.echo(f"-e {env_name}=${{{env_name}}}")


@auth.command("set")
@click.argument("integration")
@click.option(
    "--key",
    help="Specific key to set (prompted if integration has multiple keys).",
)
@click.option(
    "--value",
    help="Secret value (prompted securely if not provided). Required for non-interactive mode.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Profile to use.",
)
def auth_set(integration: str, key: str | None, value: str | None, profile: str) -> None:
    """
    Store a secret securely in the OS keychain.

    Prompts for the value securely (hidden input) unless --value is provided.
    In non-interactive mode (CI), --value is required.

    \b
    Examples:
      g-gremlin auth set apollo                    # Prompted for api_key
      g-gremlin auth set apollo --key api_key      # Prompted for value
      g-gremlin auth set zendesk --key api_token   # Prompted for value

    \b
    Exit codes:
      0 = Secret stored successfully
      2 = Invalid integration, no keychain available, or non-TTY without --value
    """
    registry = _get_integration_registry()

    spec = registry.get(integration)
    if spec is None:
        click.echo(f"Error: Unknown integration '{integration}'", err=True)
        click.echo(f"Available: {', '.join(sorted(registry.keys()))}", err=True)
        sys.exit(EXIT_USAGE)

    # Determine which key to set
    if key is None:
        if len(spec.required_keys) == 1:
            key = spec.required_keys[0]
            click.echo(f"Setting {integration}.{key}")
        elif len(spec.required_keys) == 0 and len(spec.optional_keys) == 1:
            key = spec.optional_keys[0]
            click.echo(f"Setting {integration}.{key}")
        else:
            click.echo(f"Available keys for {integration}:")
            for k in spec.required_keys:
                click.echo(f"  {k} (required)")
            for k in spec.optional_keys:
                click.echo(f"  {k} (optional)")

            # Check if interactive
            if not sys.stdin.isatty():
                click.echo("Error: Specify --key in non-interactive mode.", err=True)
                sys.exit(EXIT_USAGE)

            key = click.prompt("Key to set")

    # Get value
    if value is None:
        # Check if interactive
        if not sys.stdin.isatty():
            click.echo("Error: Use --value in non-interactive mode.", err=True)
            sys.exit(EXIT_USAGE)
        value = click.prompt(f"Enter value for {integration}.{key}", hide_input=True)

    if not value or not value.strip():
        click.echo("Error: Value cannot be empty.", err=True)
        sys.exit(EXIT_USAGE)

    provider = _get_credential_provider()

    # Check if keychain is available
    if not provider.has_keychain_backend():
        click.echo("Error: No keychain backend available.", err=True)
        click.echo("Keyring is installed but no OS credential store detected. See: g-gremlin doctor", err=True)
        sys.exit(EXIT_USAGE)

    try:
        source = provider.set_secret(integration, key, value.strip(), profile)
        click.echo(f"Stored {integration}.{key} in {source.value}")
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)


@auth.command("unset")
@click.argument("integration")
@click.option(
    "--key",
    help="Specific key to remove (all keys if not specified).",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Profile to use.",
)
@click.option(
    "--yes", "-y",
    is_flag=True,
    help="Skip confirmation prompt.",
)
def auth_unset(integration: str, key: str | None, profile: str, yes: bool) -> None:
    """
    Remove credentials from the keychain.

    \b
    Examples:
      g-gremlin auth unset apollo             # Remove all apollo keys
      g-gremlin auth unset apollo --key api_key

    \b
    Exit codes:
      0 = Removed successfully
      1 = Secret not found (advisory)
    """
    registry = _get_integration_registry()

    spec = registry.get(integration)
    if spec is None:
        click.echo(f"Error: Unknown integration '{integration}'", err=True)
        click.echo(f"Available: {', '.join(sorted(registry.keys()))}", err=True)
        sys.exit(EXIT_USAGE)

    provider = _get_credential_provider()

    keys_to_remove = [key] if key else spec.get_all_keys()

    if not yes and sys.stdin.isatty():
        keys_str = ", ".join(keys_to_remove)
        if not click.confirm(f"Remove {integration} keys: {keys_str}?"):
            click.echo("Aborted.")
            return

    removed_any = False
    for k in keys_to_remove:
        if provider.delete_secret(integration, k, profile):
            click.echo(f"Removed {integration}.{k}")
            removed_any = True

    if not removed_any:
        click.echo(f"No secrets found for {integration}")
        sys.exit(EXIT_ISSUES)


@auth.command("migrate")
@click.option(
    "--dry-run",
    is_flag=True,
    help="Show what would be migrated without making changes.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Profile to migrate to.",
)
@click.option(
    "--yes", "-y",
    is_flag=True,
    help="Skip confirmation prompt. Required for non-interactive mode.",
)
def auth_migrate(dry_run: bool, profile: str, yes: bool) -> None:
    """
    Migrate plaintext credentials to secure keychain storage.

    Reads existing credentials from ~/.g_gremlin/*.json files and
    COPIES them to the OS keychain. Original files are preserved
    (not deleted) - you can remove them after verifying migration.

    \b
    Examples:
      g-gremlin auth migrate --dry-run   # Preview migration
      g-gremlin auth migrate             # Migrate interactively
      g-gremlin auth migrate --yes       # Migrate without prompts (CI)

    \b
    Exit codes:
      0 = Migration successful (or nothing to migrate)
      1 = Partial migration (some failed)
      2 = No keychain available or non-TTY without --yes
      3 = Migration failed
    """
    provider = _get_credential_provider()
    secrets = provider.get_plaintext_secrets(profile)

    if not secrets:
        click.echo("No plaintext credentials found to migrate.")
        return

    click.echo(f"Found {len(secrets)} credentials to migrate:\n")
    for integ, key, _ in secrets:
        click.echo(f"  - {integ}.{key}")

    if dry_run:
        click.echo("\nDry-run complete. No changes made.")
        click.echo("Run without --dry-run to migrate to secure keychain storage.")
        return

    # Non-TTY check
    if not yes and not sys.stdin.isatty():
        click.echo("\nError: Use --yes for non-interactive migration.", err=True)
        sys.exit(EXIT_USAGE)

    if not yes:
        if not click.confirm("\nMigrate these to secure keychain storage?"):
            click.echo("Aborted.")
            return

    # Check if keychain is available
    if not provider.has_keychain_backend():
        click.echo("Error: No keychain backend available.", err=True)
        click.echo("Keyring is installed but no OS credential store detected. See: g-gremlin doctor", err=True)
        sys.exit(EXIT_USAGE)

    migrated = 0
    failed = 0
    for integ, key, value in secrets:
        try:
            provider.set_secret(integ, key, value, profile)
            click.echo(f"  Migrated {integ}.{key}")
            migrated += 1
        except Exception as exc:
            click.echo(f"  Failed {integ}.{key}: {exc}", err=True)
            failed += 1

    click.echo(f"\nMigration complete: {migrated} succeeded, {failed} failed")

    if migrated > 0:
        click.echo("\nNote: Original plaintext files are preserved but will no longer")
        click.echo("be used (keychain takes precedence). You may delete them after")
        click.echo("verifying the migration:")
        click.echo("  rm ~/.g_gremlin/config.json ~/.g_gremlin/hubspot.json ...")

    if failed > 0:
        if migrated > 0:
            sys.exit(EXIT_ISSUES)  # 1 = partial
        else:
            sys.exit(EXIT_REMOTE_ERROR)  # 3 = all failed


def _is_trial_license_result(license_mod, result) -> bool:
    """Compatibility wrapper for test doubles and legacy license modules."""
    lic = getattr(result, "license", None)
    tier = getattr(lic, "tier", None)

    checker = getattr(license_mod, "_is_trial_tier", None)
    if callable(checker):
        try:
            if checker(tier):
                return True
        except Exception:
            pass

    return bool(getattr(result, "trial", False)) or str(tier or "").lower() in {
        "agent_trial",
        "trial",
    }


def _attempt_auth_device_login(license_mod) -> Literal["success", "unavailable", "failed"]:
    """
    Attempt browser/device login flow with server-side session polling.

    This path works in non-interactive agent terminals because it does not
    require stdin token paste.
    """
    try:
        import requests
    except Exception:
        return "unavailable"

    configured_site = (os.getenv("FOUNDRYOPS_SITE_URL") or "").strip().rstrip("/")
    site_candidates: list[str] = []
    if configured_site:
        site_candidates.append(configured_site)
    site_candidates.extend(["https://app.foundryops.io", "https://foundryops.io"])
    seen_sites: set[str] = set()
    site_candidates = [s for s in site_candidates if s and not (s in seen_sites or seen_sites.add(s))]
    session_id = str(uuid.uuid4())

    site_base: str | None = None
    auth_payload: dict[str, object] | None = None
    last_status: int | None = None
    for candidate in site_candidates:
        try:
            auth_resp = requests.get(
                f"{candidate}/api/v1/agent/cli/authorize",
                params={"session_id": session_id},
                timeout=20,
            )
        except Exception:
            continue

        last_status = auth_resp.status_code
        if auth_resp.status_code in (404, 405):
            continue
        if not auth_resp.ok:
            # Explicitly configured URL should fail fast and visible.
            if configured_site and candidate == configured_site:
                click.echo(f"[Gremlin] Device auth unavailable ({auth_resp.status_code}).", err=True)
                return "unavailable"
            continue

        try:
            parsed_payload = auth_resp.json()
        except Exception:
            continue
        if not isinstance(parsed_payload, dict):
            continue
        auth_url_candidate = str(parsed_payload.get("auth_url") or "").strip()
        if not auth_url_candidate:
            continue
        site_base = candidate
        auth_payload = parsed_payload
        break

    if not site_base or not auth_payload:
        if last_status and configured_site:
            click.echo(f"[Gremlin] Device auth unavailable ({last_status}).", err=True)
        return "unavailable"

    auth_url = str(auth_payload.get("auth_url") or "").strip()
    if not auth_url:
        return "unavailable"

    click.echo("Opening browser for FoundryOps authentication...")
    try:
        opened = webbrowser.open(auth_url, new=2, autoraise=True)
        if not opened:
            click.echo(f"Open this URL to continue: {auth_url}")
    except Exception:
        click.echo(f"Open this URL to continue: {auth_url}")

    expires_in_raw = auth_payload.get("expires_in")
    timeout_seconds = 300
    if isinstance(expires_in_raw, int) and expires_in_raw > 0:
        timeout_seconds = min(max(expires_in_raw, 30), 900)

    poll_url = f"{site_base}/api/v1/agent/cli/poll"
    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        try:
            poll_resp = requests.get(
                poll_url,
                params={"session_id": session_id},
                timeout=20,
            )
            if poll_resp.ok:
                poll_payload = poll_resp.json()
                status = str(poll_payload.get("status") or "pending").lower()
                if status == "complete":
                    issued_token = str(poll_payload.get("token") or "")
                    if not issued_token:
                        click.echo("[Gremlin] Auth flow completed but no token was returned.", err=True)
                        return "failed"
                    license_data = poll_payload.get("license")
                    if not isinstance(license_data, dict):
                        license_data = None
                    refresh_token = poll_payload.get("refresh_token")
                    if not isinstance(refresh_token, str):
                        refresh_token = None
                    result = license_mod.cache_license_token(
                        issued_token,
                        license_data,
                        refresh_token=refresh_token,
                    )
                    if not result.valid:
                        license_mod.print_license_error(result)
                        return "failed"
                    license_mod.print_license_warnings(result)
                    lic = result.license
                    if lic:
                        if _is_trial_license_result(license_mod, result):
                            days_msg = f" ({lic.days_remaining} days remaining)" if lic.days_remaining else ""
                            click.secho(f"Authenticated! Trial license active{days_msg}.", fg="green")
                        else:
                            click.secho("Authenticated! Paid license active.", fg="green")
                    click.echo()
                    click.echo("You're all set! Run 'g-gremlin help' to see available commands.")
                    return "success"
                if status == "claimed":
                    click.echo("[Gremlin] This login session was already claimed. Run 'g-gremlin auth login' again.", err=True)
                    return "failed"
                if status == "error":
                    message = str(poll_payload.get("message") or "Authentication failed.")
                    click.echo(f"[Gremlin] {message}", err=True)
                    return "failed"
        except Exception:
            pass
        time.sleep(2)

    click.echo("[Gremlin] Timed out waiting for browser authentication.", err=True)
    return "failed"


@auth.command("login")
@click.option(
    "--token",
    envvar="FOUNDRYOPS_AUTH_TOKEN",
    help="Auth token from FoundryOps. If not provided, opens browser to authenticate.",
)
def auth_login(token: str | None) -> None:
    """
    Authenticate with FoundryOps to use licensed features.

    This command obtains a license token for features like enrichment,
    sync, and FoundryGraph. The token is cached locally at
    ~/.g_gremlin/license.jwt and refreshed automatically.

    \b
    Authentication methods (tried in order):
      1. Browser device flow (default - opens browser, no paste needed)
      2. --token flag or FOUNDRYOPS_AUTH_TOKEN env var (for CI / sandboxed agents)
      3. Interactive paste (if browser flow is unavailable)

    \b
    To start a trial:
      Visit https://app.foundryops.io/agent/quickstart

    \b
    Exit codes:
      0 = Successfully authenticated
      1 = Authentication failed (invalid token, expired trial)
      2 = No token provided
    """
    license_mod = _get_license()

    if not token:
        device_login_result = _attempt_auth_device_login(license_mod)
        if device_login_result == "success":
            return
        if device_login_result == "failed":
            sys.exit(EXIT_ISSUES)

        if not sys.stdin.isatty():
            click.echo("Error: No authentication token provided.", err=True)
            click.echo()
            click.echo("This terminal is non-interactive, so token paste is unavailable.", err=True)
            click.echo("Use one of these:", err=True)
            click.echo("  - Run g-gremlin auth login --token <your-token>", err=True)
            click.echo("  - Set FOUNDRYOPS_AUTH_TOKEN and rerun", err=True)
            click.echo()
            click.echo("To get started:", err=True)
            click.echo("  1. Visit https://app.foundryops.io/agent/quickstart to start a trial", err=True)
            click.echo("  2. Copy your auth token from the dashboard", err=True)
            click.echo("  3. Run: g-gremlin auth login --token <your-token>", err=True)
            click.echo()
            click.echo("Or set FOUNDRYOPS_AUTH_TOKEN environment variable.", err=True)
            sys.exit(EXIT_USAGE)

        auth_url = "https://app.foundryops.io/agent/quickstart"
        click.echo("No authentication token provided.")
        click.echo(f"Opening {auth_url} ...")
        try:
            webbrowser.open(auth_url, new=2, autoraise=True)
        except Exception:
            pass

        token = click.prompt("Paste your FoundryOps auth token", default="", show_default=False).strip()
        if not token:
            click.echo("Error: No authentication token provided.", err=True)
            sys.exit(EXIT_USAGE)

    click.echo("Authenticating with FoundryOps...")
    result = license_mod.refresh_license_token(token)

    if not result.valid:
        license_mod.print_license_error(result)
        sys.exit(EXIT_ISSUES)

    # Print success and license info
    license_mod.print_license_warnings(result)
    lic = result.license
    if lic:
        if _is_trial_license_result(license_mod, result):
            days_msg = f" ({lic.days_remaining} days remaining)" if lic.days_remaining else ""
            click.secho(f"Authenticated! Trial license active{days_msg}.", fg="green")
        else:
            click.secho("Authenticated! Paid license active.", fg="green")

    click.echo()
    click.echo("You're all set! Run 'g-gremlin help' to see available commands.")


@auth.command("logout")
def auth_logout() -> None:
    """
    Log out and remove cached license tokens.

    Removes the cached license JWT, refresh token, signed license file,
    and associated metadata. Integration credentials (API keys for Apollo,
    HubSpot, etc.) are NOT removed — use 'g-gremlin auth unset' for those.

    \b
    Exit codes:
      0 = Successfully logged out
    """
    from g_gremlin.credentials.license_v2 import LICENSE_PATH as LICENSE_V2_PATH

    license_mod = _get_license()

    removed: list[str] = []
    for path, label in [
        (license_mod.LICENSE_TOKEN_PATH, "license JWT"),
        (license_mod.LICENSE_META_PATH, "license metadata"),
        (license_mod.REFRESH_TOKEN_PATH, "refresh token"),
        (LICENSE_V2_PATH, "signed license"),
    ]:
        try:
            if path.exists():
                path.unlink()
                removed.append(label)
        except Exception:
            pass

    if removed:
        click.echo(f"Removed: {', '.join(removed)}.")
    else:
        click.echo("No cached license tokens found.")

    click.echo("Logged out. Run 'g-gremlin auth login' to re-authenticate.")


@auth.command("refresh")
def auth_refresh() -> None:
    """
    Refresh your license token manually.

    The CLI automatically refreshes tokens when they're about to expire
    (< 1 hour remaining). Use this command to force a refresh, for example
    after upgrading from trial to paid.

    \b
    Exit codes:
      0 = Token refreshed successfully
      1 = Refresh failed (re-authenticate with 'auth login')
      2 = No existing token (need to authenticate first)
    """
    license_mod = _get_license()

    click.echo("Refreshing license token...")

    result = license_mod.attempt_silent_refresh()
    if not result.valid:
        # Bootstrap path: if user has a still-valid JWT but no refresh token yet,
        # the legacy /agent/token endpoint can issue a refresh token.
        token = license_mod.get_license_token()
        if token:
            result = license_mod.refresh_license_token(token)

    if not result.valid:
        license_mod.print_license_error(result)
        sys.exit(EXIT_ISSUES)

    license_mod.print_license_warnings(result)
    lic = result.license
    if lic and _is_trial_license_result(license_mod, result):
        days_msg = f" ({lic.days_remaining} days remaining)" if lic.days_remaining else ""
        click.secho(f"Refreshed! Trial license active{days_msg}.", fg="green")
    else:
        click.secho("Refreshed! License active.", fg="green")


@auth.command("license")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output as JSON.",
)
def auth_license_status(json_output: bool) -> None:
    """
    Show current FoundryOps license status.

    Displays your current license tier, status, and expiration info
    without making any API calls (reads cached token only).

    \b
    Exit codes:
      0 = License active
      1 = License expired or invalid
      2 = No license found
    """
    license_mod = _get_license()
    result = license_mod.check_license_unified()

    if json_output:
        output = {
            "valid": result.valid,
            "source": result.source,
            "plan": result.plan,
            "trial": result.trial,
            "error": result.error,
            "warnings": result.warnings,
            "entitlements": result.entitlements,
            "limits": result.limits,
        }
        if result.license:
            output["license"] = {
                "tier": result.license.tier,
                "status": result.license.status,
                "trial_ends_at": result.license.trial_ends_at,
                "days_remaining": result.license.days_remaining,
                "token_expires_at": result.license.token_expires_at,
            }
        click.echo(json.dumps(output, indent=2))
    else:
        if not result.valid:
            license_mod.print_license_error(result)
            sys.exit(EXIT_ISSUES if result.error else EXIT_USAGE)

        lic = result.license
        click.echo("FoundryOps License Status")
        click.echo()
        click.echo(f"  Plan: {result.plan or (lic.tier if lic else 'local_free')}")
        click.echo(f"  Source: {result.source}")
        if lic and lic.trial_ends_at:
            click.echo(f"  Trial ends: {lic.trial_ends_at}")
        if result.expires_at:
            click.echo(f"  Expires: {result.expires_at}")
        if result.license_id:
            click.echo(f"  License ID: {result.license_id}")
        license_mod.print_license_warnings(result)


# ---------------------------------------------------------------------------
# Common Room intent ingestion
# ---------------------------------------------------------------------------


@cli.group()
def commonroom() -> None:
    """
    Common Room intent signal ingestion (CSV-first).

    Note: Common Room commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


@commonroom.command("doctor")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def commonroom_doctor(profile: str, json_output: bool) -> None:
    """Validate Common Room configuration."""
    cred_provider = _get_credential_provider()

    webhook_secret = None
    api_key = None
    try:
        webhook_secret = cred_provider.get_secret("commonroom", "webhook_secret", profile).value
    except Exception:
        webhook_secret = None
    try:
        api_key = cred_provider.get_secret("commonroom", "api_key", profile).value
    except Exception:
        api_key = None

    client = _get_commonroom_helpers().CommonRoomClient(
        webhook_secret=webhook_secret,
        api_key=api_key,
        profile=profile,
    )
    status = client.test_connection()

    click.echo(f"Profile: {profile}")
    click.echo(f"Webhook secret configured: {'yes' if webhook_secret else 'no'}")
    click.echo(f"API key configured (optional): {'yes' if api_key else 'no'}")
    click.echo(f"Status: {status.get('status')}")

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin commonroom doctor", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "profile": profile,
                "webhook_secret": bool(webhook_secret),
                "api_key": bool(api_key),
                "status": status.get("status"),
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@commonroom.command("import")
@click.option(
    "--source",
    required=True,
    help="CSV path (local or s3://bucket/key) from Common Room export.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output CSV for normalized signals (only when --apply).",
)
@click.option(
    "--since",
    help="ISO8601 timestamp; only include signals on/after this time.",
)
@click.option(
    "--signal-types",
    help="Comma-separated list of signal types to include.",
)
@click.option(
    "--min-intent-score",
    type=float,
    default=0.0,
    show_default=True,
    help="Minimum intent score threshold.",
)
@click.option(
    "--resolve-companies",
    is_flag=True,
    help="Stub resolution flag (marks provider/score for demo).",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Write output CSV (default: dry-run).",
)
def commonroom_import(
    source: str,
    output_path: Optional[Path],
    since: Optional[str],
    signal_types: Optional[str],
    min_intent_score: float,
    resolve_companies: bool,
    json_summary: bool,
    apply: bool,
) -> None:
    """
    Normalize a Common Room CSV export and flag activation-eligible signals.
    """
    helpers = _get_commonroom_helpers()

    if source.startswith("s3://"):
        click.echo("S3 imports are not implemented in this build; download locally first.", err=True)
        sys.exit(EXIT_USAGE)

    source_path = Path(source)
    since_dt = None
    if since:
        try:
            since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")).astimezone(timezone.utc)
        except Exception:
            click.echo("Invalid --since; expected ISO8601 timestamp.", err=True)
            sys.exit(EXIT_USAGE)

    signal_types_list = None
    if signal_types:
        signal_types_list = [s.strip() for s in signal_types.split(",") if s.strip()]

    try:
        signals, stats = helpers.load_signals_from_csv(
            source_path,
            since=since_dt,
            min_intent_score=min_intent_score,
            signal_types=signal_types_list,
            resolve_companies=resolve_companies,
        )
    except Exception as exc:
        click.echo(f"Error reading {source}: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    eligible = stats["eligible"]
    total = stats["deduped"]

    if not apply:
        click.echo(f"[DRY-RUN] Would import {total} signals ({eligible} eligible for activation)")
        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin commonroom import", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "source": stats["source"],
                    "rows_read": stats["rows_read"],
                    "deduped": stats["deduped"],
                    "eligible": eligible,
                    "batch_hash": stats["batch_hash"],
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if output_path is None:
        click.echo("Error: --output is required when applying.", err=True)
        sys.exit(EXIT_USAGE)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(signals[0].to_dict().keys()) if signals else [])
        if writer.fieldnames:
            writer.writeheader()
        for signal in signals:
            writer.writerow(signal.to_dict())

    click.echo(f"Wrote {len(signals)} normalized signals to {output_path} ({eligible} eligible)")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin commonroom import", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "output": str(output_path),
                "eligible": eligible,
                "deduped": total,
                "batch_hash": stats["batch_hash"],
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


# ---------------------------------------------------------------------------
# HeyReach activation
# ---------------------------------------------------------------------------


@cli.group()
def heyreach() -> None:
    """
    HeyReach activation helpers (doctor, campaigns, enroll).

    Note: HeyReach commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


@heyreach.command("doctor")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def heyreach_doctor(profile: str, json_output: bool) -> None:
    """Validate HeyReach credentials and connectivity."""
    cred_provider = _get_credential_provider()
    hr = _get_heyreach_helpers()

    try:
        api_key = cred_provider.get_secret("heyreach", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        workspace_id = cred_provider.get_secret("heyreach", "workspace_id", profile).value
    except Exception:
        workspace_id = None

    if not api_key:
        click.echo(f"Error: HeyReach API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    client = hr.HeyReachClient(api_key=api_key, workspace_id=workspace_id)
    try:
        status = client.test_connection()
    except Exception as exc:
        click.echo(f"Connection failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo(f"Profile: {profile}")
    click.echo("API key: configured")
    click.echo(f"Workspace ID: {'configured' if workspace_id else 'not set'}")
    click.echo(f"Status: {status.get('status', 'unknown')}")
    click.echo(
        f"Rate limits: {client.rate_limit.daily_limit}/day, "
        f"{client.rate_limit.batch_size}/batch, "
        f"{client.rate_limit.delay_between_batches_ms}ms delay, "
        f"{client.rate_limit.requests_per_second} rps"
    )

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin heyreach doctor", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "profile": profile,
                "workspace_id": bool(workspace_id),
                "rate_limits": asdict(client.rate_limit),
                "connection": status,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@heyreach.command("campaigns")
@click.option(
    "--status",
    help="Filter campaigns by status.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Emit JSON.",
)
def heyreach_campaigns(status: Optional[str], profile: str, as_json: bool) -> None:
    """List HeyReach campaigns."""
    cred_provider = _get_credential_provider()
    hr = _get_heyreach_helpers()
    try:
        api_key = cred_provider.get_secret("heyreach", "api_key", profile).value
    except Exception:
        click.echo(f"Error: HeyReach API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        workspace_id = cred_provider.get_secret("heyreach", "workspace_id", profile).value
    except Exception:
        workspace_id = None

    client = hr.HeyReachClient(api_key=api_key, workspace_id=workspace_id)
    try:
        campaigns = client.list_campaigns(status=status)
    except Exception as exc:
        click.echo(f"Error listing campaigns: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if as_json:
        click.echo(json.dumps([asdict(c) for c in campaigns], indent=2))
        return

    for c in campaigns:
        click.echo(f"{c.id}: {c.name} ({c.status})")


@heyreach.command("enroll")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with prospects.",
)
@click.option(
    "--campaign-id",
    required=True,
    help="Target campaign ID.",
)
@click.option(
    "--email-column",
    default="email",
    show_default=True,
    help="Column containing prospect email.",
)
@click.option(
    "--linkedin-column",
    default="person_linkedin_url",
    show_default=True,
    help="Column containing LinkedIn URL.",
)
@click.option(
    "--mapping",
    "mapping_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Optional YAML mapping for column->field names.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--batch-size",
    type=int,
    default=None,
    help="Override batch size (default: 10).",
)
@click.option(
    "--delay-ms",
    type=int,
    default=None,
    help="Delay between batches in ms (default: 500).",
)
@click.option(
    "--daily-limit",
    type=int,
    default=None,
    help="Max enrollments per day (default: 100).",
)
@click.option(
    "--rps",
    type=int,
    default=None,
    help="Requests per second cap (default: 2).",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute enrollment (default: dry-run).",
)
def heyreach_enroll(
    csv_path: Path,
    campaign_id: str,
    email_column: str,
    linkedin_column: str,
    mapping_path: Optional[Path],
    profile: str,
    batch_size: Optional[int],
    delay_ms: Optional[int],
    daily_limit: Optional[int],
    rps: Optional[int],
    json_summary: bool,
    apply: bool,
) -> None:
    """
    Enroll prospects into a HeyReach campaign (dry-run by default).
    """
    import yaml

    cred_provider = _get_credential_provider()
    hr = _get_heyreach_helpers()

    try:
        api_key = cred_provider.get_secret("heyreach", "api_key", profile).value
    except Exception:
        click.echo(f"Error: HeyReach API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        workspace_id = cred_provider.get_secret("heyreach", "workspace_id", profile).value
    except Exception:
        workspace_id = None

    mapping = {}
    if mapping_path:
        try:
            mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8")) or {}
        except Exception as exc:
            click.echo(f"Invalid mapping file: {exc}", err=True)
            sys.exit(EXIT_USAGE)

    prospects: List[hr.HeyReachProspect] = []
    with csv_path.open("r", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        for row in reader:
            email = row.get(mapping.get("email", email_column)) or ""
            if not email:
                continue
            prospect = hr.HeyReachProspect(
                email=email,
                linkedin_url=row.get(mapping.get("linkedin_url", linkedin_column)),
                first_name=row.get(mapping.get("first_name", "first_name")),
                last_name=row.get(mapping.get("last_name", "last_name")),
                company=row.get(mapping.get("company", "company")),
                title=row.get(mapping.get("title", "title")),
            )
            prospects.append(prospect)

    if not prospects:
        click.echo("No prospects found in CSV.", err=True)
        sys.exit(EXIT_USAGE)

    rate_limit = hr.RateLimitConfig(
        requests_per_second=rps or hr.HEYREACH_RATE_LIMIT.requests_per_second,
        burst_limit=hr.HEYREACH_RATE_LIMIT.burst_limit,
        daily_limit=daily_limit or hr.HEYREACH_RATE_LIMIT.daily_limit,
        retry_after_429=hr.HEYREACH_RATE_LIMIT.retry_after_429,
        batch_size=batch_size or hr.HEYREACH_RATE_LIMIT.batch_size,
        delay_between_batches_ms=delay_ms or hr.HEYREACH_RATE_LIMIT.delay_between_batches_ms,
    )
    client = hr.HeyReachClient(api_key=api_key, workspace_id=workspace_id, rate_limit=rate_limit)
    idempotency_keys = [hr.build_idempotency_key(campaign_id, p.email) for p in prospects]

    if not apply:
        click.echo(
            f"[DRY-RUN] Would enroll {len(prospects)} prospect(s) to campaign {campaign_id} "
            f"in batches of {rate_limit.batch_size}"
        )
        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin heyreach enroll", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "prospects": len(prospects),
                    "campaign_id": campaign_id,
                    "profile": profile,
                    "batch_size": rate_limit.batch_size,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    outcome = client.enroll_prospects(prospects, campaign_id, idempotency_keys=idempotency_keys)
    succeeded = len(outcome.succeeded)
    failed = len(outcome.failed)
    retryable = len(outcome.retryable_failed)

    click.echo(
        f"Enrollment complete: {succeeded} enrolled, {failed} failed, {retryable} retryable. "
        f"Campaign: {campaign_id}"
    )

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin heyreach enroll", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status=outcome.status,
            dry_run=False,
            batch_outcome=outcome,
            result={
                "campaign_id": campaign_id,
                "profile": profile,
                "prospects_processed": len(prospects),
                "enrolled": succeeded,
                "failed": failed,
                "retryable_failed": retryable,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


# ---------------------------------------------------------------------------
# FullEnrich helpers
# ---------------------------------------------------------------------------


@cli.group()
def fullenrich() -> None:
    """
    FullEnrich waterfall enrichment commands (doctor, enrich, status).

    FullEnrich aggregates 15+ data providers for higher enrichment rates.
    """


@fullenrich.command("doctor")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
@click.option(
    "--api-version",
    type=click.Choice(["v1", "v2"], case_sensitive=False),
    default="v2",
    show_default=True,
    help="FullEnrich API version to use.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def fullenrich_doctor(profile: str, api_version: str, json_output: bool) -> None:
    """Validate FullEnrich credentials and check credit balance."""
    cred_provider = _get_credential_provider()
    fe = _get_fullenrich_helpers()

    try:
        api_key = cred_provider.get_secret("fullenrich", "api_key", profile).value
    except Exception:
        api_key = None

    if not api_key:
        click.echo(f"Error: FullEnrich API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    client = fe.FullEnrichClient(api_key=api_key, api_version=api_version)
    try:
        status = client.test_connection()
    except Exception as exc:
        click.echo(f"Connection failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo(f"Profile: {profile}")
    click.echo("API key: configured")
    click.echo(f"Credits: {status.get('credits', 'unknown')}")
    click.echo(f"Status: {status.get('status', 'unknown')}")

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin fullenrich doctor", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "profile": profile,
                "credits": status.get("credits"),
                "connection": status,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@fullenrich.command("enrich")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with contacts to enrich.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output CSV path (default: enriched_<input>.csv).",
)
@click.option(
    "--firstname-column",
    default="firstname",
    show_default=True,
    help="Column containing first name.",
)
@click.option(
    "--lastname-column",
    default="lastname",
    show_default=True,
    help="Column containing last name.",
)
@click.option(
    "--email-column",
    default="email",
    show_default=True,
    help="Column containing email.",
)
@click.option(
    "--domain-column",
    default="domain",
    show_default=True,
    help="Column containing company domain.",
)
@click.option(
    "--company-column",
    default="company_name",
    show_default=True,
    help="Column containing company name.",
)
@click.option(
    "--linkedin-column",
    default="linkedin_url",
    show_default=True,
    help="Column containing LinkedIn URL.",
)
@click.option(
    "--api-version",
    type=click.Choice(["v1", "v2"], case_sensitive=False),
    default="v2",
    show_default=True,
    help="FullEnrich API version (direct mode).",
)
@click.option(
    "--saas",
    "saas_mode",
    is_flag=True,
    help="Force FoundryMatch SaaS mode (webhook-first).",
)
@click.option(
    "--direct",
    "direct_mode",
    is_flag=True,
    help="Force direct FullEnrich API mode.",
)
@click.option(
    "--webhook-url",
    default=None,
    envvar="G_GREMLIN_FULLENRICH_WEBHOOK_URL",
    help="Webhook URL to receive results. Falls back to G_GREMLIN_FULLENRICH_WEBHOOK_URL env var.",
)
@click.option(
    "--wait/--no-wait",
    default=False,
    show_default=True,
    help="Wait for enrichment to complete.",
)
@click.option(
    "--max-wait",
    type=float,
    default=None,
    help="Max seconds to wait (default: auto-scaled ~90s/contact, capped at 900s). Use 0 for no cap.",
)
@click.option(
    "--poll-interval",
    type=float,
    default=None,
    help="Seconds between status checks (default: 30 for v1 direct, 300 for v2 direct).",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
def fullenrich_enrich(
    csv_path: Path,
    output_path: Optional[Path],
    firstname_column: str,
    lastname_column: str,
    email_column: str,
    domain_column: str,
    company_column: str,
    linkedin_column: str,
    api_version: str,
    saas_mode: bool,
    direct_mode: bool,
    webhook_url: Optional[str],
    wait: bool,
    max_wait: Optional[float],
    poll_interval: Optional[float],
    profile: str,
    json_summary: bool,
) -> None:
    """Enrich contacts from a CSV file using FullEnrich waterfall."""
    import csv as csv_module
    from fmatch.core.fullenrich import constants as fe_constants
    from fmatch.core.fullenrich import validation as fe_validation

    fe = _get_fullenrich_helpers()
    fm = _get_foundrymatch_helpers()
    cred_provider = _get_credential_provider()

    if saas_mode and direct_mode:
        click.echo("Error: choose either --saas or --direct, not both.", err=True)
        sys.exit(EXIT_USAGE)

    fm_creds = _resolve_foundrymatch_credentials(cred_provider, profile)
    use_saas = saas_mode or (fm_creds is not None and not direct_mode)
    if saas_mode and fm_creds is None:
        click.echo(
            "Error: FoundryMatch SaaS credentials not configured. "
            "Set G_GREMLIN_FM_API_BASE, G_GREMLIN_FM_API_KEY, and G_GREMLIN_FM_WORKSPACE_ID "
            "or use --direct.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    # Build output path if not provided
    if not output_path:
        output_path = csv_path.parent / f"enriched_{csv_path.name}"

    click.echo(f"Reading contacts from {csv_path}...")

    strict_email_names = True
    prepared_rows: List[Dict[str, Any]] = []
    contact_count = 0
    derived_from_email = 0
    skipped_alias = 0
    skipped_missing = 0

    with open(csv_path, newline="", encoding="utf-8") as f:
        reader = csv_module.DictReader(f)
        for idx, row in enumerate(reader):
            firstname = (row.get(firstname_column) or "").strip()
            lastname = (row.get(lastname_column) or "").strip()
            email = (row.get(email_column) or "").strip()
            domain = (row.get(domain_column) or "").strip()
            company = (row.get(company_column) or "").strip()
            linkedin = (row.get(linkedin_column) or "").strip()

            if not firstname or not lastname:
                if email and (not strict_email_names or fe._looks_personal_email(email)):
                    derived = fe_validation.derive_contact_name(
                        first_name=firstname or None,
                        last_name=lastname or None,
                        email=email,
                        linkedin_url=linkedin or None,
                        custom=row,
                    )
                    if derived:
                        firstname, lastname = derived
                        derived_from_email += 1
                if not firstname or not lastname:
                    if linkedin:
                        pass
                    elif email and strict_email_names:
                        skipped_alias += 1
                        continue
                    else:
                        skipped_missing += 1
                        continue

            if not fe_validation.is_valid_contact_input(
                email=email or None,
                linkedin_url=linkedin or None,
                first_name=firstname,
                last_name=lastname,
                domain=domain or None,
                company_name=company or None,
            ):
                skipped_missing += 1
                continue

            contact_count += 1
            if use_saas:
                payload_row = dict(row)
                payload_row["__fm_row_index__"] = idx
                payload_row[firstname_column] = firstname
                payload_row[lastname_column] = lastname
                if email_column:
                    payload_row[email_column] = email
                if domain_column:
                    payload_row[domain_column] = domain
                if company_column:
                    payload_row[company_column] = company
                if linkedin_column:
                    payload_row[linkedin_column] = linkedin
                prepared_rows.append(payload_row)

    if contact_count == 0:
        click.echo("No valid contacts found in CSV.", err=True)
        sys.exit(EXIT_USAGE)

    if derived_from_email:
        click.echo(f"Derived names from {derived_from_email} email(s).")
    if skipped_alias:
        click.echo(f"Skipped {skipped_alias} row(s) with role/alias emails.", err=True)
    if skipped_missing:
        click.echo(f"Skipped {skipped_missing} row(s) missing valid name inputs.", err=True)

    effective_max_wait = max_wait
    if wait:
        max_wait_count = contact_count if use_saas else min(contact_count, 100)
        effective_max_wait, exceeded_cap = fe.compute_scaled_max_wait(max_wait_count, max_wait)
        if exceeded_cap:
            estimated_minutes = int(contact_count * fe.SECONDS_PER_CONTACT / 60)
            cap_minutes = int(fe.DEFAULT_MAX_WAIT_CAP / 60)
            click.echo(
                f"Warning: Estimated wait ~{estimated_minutes} min exceeds default cap ({cap_minutes} min).\n"
                f"  Use --max-wait to increase, or --no-wait + 'fullenrich status <id>' for large batches.",
                err=True,
            )

    if use_saas:
        if webhook_url:
            click.echo("Note: --webhook-url is ignored in SaaS mode.", err=True)
        if not fm_creds:
            click.echo("Error: FoundryMatch SaaS credentials not configured.", err=True)
            sys.exit(EXIT_USAGE)

        client = fm.FoundryMatchClient(
            api_base=fm_creds["api_base"],
            api_key=fm_creds["api_key"],
            workspace_id=fm_creds["workspace_id"],
            credential_provider=cred_provider,
        )

        run_id = f"g-gremlin-{uuid.uuid4().hex}"
        payload = {
            "rows": prepared_rows,
            "input_mapping": {
                "firstname": firstname_column,
                "lastname": lastname_column,
                "domain": domain_column,
                "company_name": company_column,
                "email": email_column,
                "linkedin_url": linkedin_column,
            },
            "output_fields": list(fe_constants.DEFAULT_CONTACT_OUTPUT_FIELDS),
            "provider": "fullenrich",
            "enrichment_type": "contact",
            "run_id": run_id,
        }

        try:
            response = client.run_enrichment(payload)
        except fm.FoundryMatchError as exc:
            click.echo(f"Enrichment failed: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if not isinstance(response, dict) or not response.get("ok"):
            detail = response.get("detail") if isinstance(response, dict) else None
            error = response.get("error") if isinstance(response, dict) else None
            message = detail or error or "Unknown error from FoundryMatch"
            click.echo(f"Enrichment failed: {message}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        data = response.get("data") or {}
        job_id = data.get("job_id")
        status = data.get("status") or "unknown"

        click.echo("Mode: SaaS (FoundryMatch)")
        click.echo(f"Enrichment ID: {job_id or 'unknown'}")
        click.echo(f"Status: {status}")
        click.echo(f"Total requested: {contact_count}")

        final_status: Optional[Dict[str, Any]] = None
        if wait and job_id:
            effective_poll_interval = poll_interval if poll_interval is not None else 30.0
            if effective_poll_interval <= 0:
                effective_poll_interval = 1.0
            deadline = None
            if effective_max_wait is not None:
                deadline = time.time() + effective_max_wait
            while True:
                try:
                    status_response = client.get_job_status(job_id)
                except fm.FoundryMatchError as exc:
                    click.echo(f"Status check failed: {exc}", err=True)
                    sys.exit(EXIT_REMOTE_ERROR)

                if not isinstance(status_response, dict) or not status_response.get("ok"):
                    detail = status_response.get("detail") if isinstance(status_response, dict) else None
                    error = status_response.get("error") if isinstance(status_response, dict) else None
                    message = detail or error or "Unknown error from FoundryMatch"
                    click.echo(f"Status check failed: {message}", err=True)
                    sys.exit(EXIT_REMOTE_ERROR)

                status_data = status_response.get("data") or {}
                job_status = str(status_data.get("status") or "").lower()
                if job_status in {"completed", "failed"}:
                    final_status = status_data
                    break
                if deadline and time.time() >= deadline:
                    click.echo("Timed out waiting for SaaS job to complete.", err=True)
                    break
                time.sleep(effective_poll_interval)

        if final_status:
            click.echo(f"Rows processed: {final_status.get('rows_processed', 0)}")
            click.echo(f"Rows enriched: {final_status.get('rows_enriched', 0)}")
            click.echo(f"Rows skipped: {final_status.get('rows_skipped', 0)}")
            click.echo(f"Rows errored: {final_status.get('rows_errored', 0)}")

            summary = final_status.get("summary") or {}
            results = summary.get("results") or []
            if results and output_path:
                def _write_saas_results(rows: List[Dict[str, Any]], path: Path) -> None:
                    fieldnames = [
                        "first_name",
                        "last_name",
                        "emails",
                        "phones",
                        "linkedin_url",
                        "company_name",
                        "domain",
                        "status",
                        "error",
                    ]
                    with path.open("w", newline="", encoding="utf-8") as handle:
                        writer = csv_module.DictWriter(handle, fieldnames=fieldnames)
                        writer.writeheader()
                        for row in rows:
                            emails = row.get("emails") or []
                            if not emails and row.get("email"):
                                emails = [row.get("email")]
                            phones = row.get("phones") or []
                            if not phones and row.get("phone"):
                                phones = [row.get("phone")]
                            writer.writerow({
                                "first_name": row.get("first_name") or row.get("firstname") or "",
                                "last_name": row.get("last_name") or row.get("lastname") or "",
                                "emails": ";".join([e for e in emails if e]),
                                "phones": ";".join([p for p in phones if p]),
                                "linkedin_url": row.get("linkedin_url") or "",
                                "company_name": row.get("company_name") or "",
                                "domain": row.get("company_domain") or row.get("domain") or "",
                                "status": row.get("status") or "",
                                "error": row.get("error_message") or row.get("error") or "",
                            })

                _write_saas_results(results, output_path)
                click.echo(f"Output written to: {output_path}")
            elif output_path:
                click.echo("No result rows returned in SaaS summary.")

        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin fullenrich enrich", remote_mutation=False)
            payload = {
                "mode": "saas",
                "job": data,
            }
            if final_status:
                payload["final_status"] = final_status
            agentic = safety_ctx.build_result(
                status="success" if (final_status or {}).get("status") == "completed" else "pending",
                dry_run=False,
                result=payload,
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    # Direct FullEnrich API mode
    api_version = (api_version or "v2").lower()
    if wait:
        if poll_interval is None:
            poll_interval = 300.0 if api_version == "v2" else 30.0
        elif api_version == "v2" and poll_interval < 300:
            click.echo("Note: FullEnrich v2 recommends polling every 300s or longer.", err=True)

    try:
        result = fe.enrich_contacts_from_csv(
            csv_path=str(csv_path),
            output_path=str(output_path) if wait else None,
            firstname_col=firstname_column,
            lastname_col=lastname_column,
            email_col=email_column,
            domain_col=domain_column,
            company_col=company_column,
            linkedin_col=linkedin_column,
            webhook_url=webhook_url,
            wait_for_result=wait,
            max_wait=effective_max_wait,
            poll_interval=poll_interval,
            api_version=api_version,
            strict_email_names=True,
        )
    except fe.FullEnrichError as exc:
        click.echo(f"Enrichment failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo("Mode: Direct (FullEnrich API)")
    click.echo(f"Enrichment ID: {result.enrichment_id}")
    click.echo(f"Status: {result.status}")
    click.echo(f"Total requested: {result.total_requested}")

    if wait:
        click.echo(f"Total enriched: {result.total_enriched}")
        click.echo(f"Output written to: {output_path}")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin fullenrich enrich", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success" if result.status == "completed" else "pending",
            dry_run=False,
            result=result.to_dict(),
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@fullenrich.command("status")
@click.argument("enrichment_id")
@click.option(
    "--api-version",
    type=click.Choice(["v1", "v2"], case_sensitive=False),
    default="v2",
    show_default=True,
    help="FullEnrich API version (direct mode).",
)
@click.option(
    "--saas",
    "saas_mode",
    is_flag=True,
    help="Force FoundryMatch SaaS mode (webhook-first).",
)
@click.option(
    "--direct",
    "direct_mode",
    is_flag=True,
    help="Force direct FullEnrich API mode.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def fullenrich_status(
    enrichment_id: str,
    api_version: str,
    saas_mode: bool,
    direct_mode: bool,
    profile: str,
    json_output: bool,
) -> None:
    """Check the status of a FullEnrich enrichment request."""
    cred_provider = _get_credential_provider()
    fe = _get_fullenrich_helpers()
    fm = _get_foundrymatch_helpers()

    if saas_mode and direct_mode:
        click.echo("Error: choose either --saas or --direct, not both.", err=True)
        sys.exit(EXIT_USAGE)

    fm_creds = _resolve_foundrymatch_credentials(cred_provider, profile)
    use_saas = saas_mode or (fm_creds is not None and not direct_mode)
    if saas_mode and fm_creds is None:
        click.echo(
            "Error: FoundryMatch SaaS credentials not configured. "
            "Set G_GREMLIN_FM_API_BASE, G_GREMLIN_FM_API_KEY, and G_GREMLIN_FM_WORKSPACE_ID "
            "or use --direct.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    if use_saas:
        client = fm.FoundryMatchClient(
            api_base=fm_creds["api_base"] if fm_creds else None,
            api_key=fm_creds["api_key"] if fm_creds else None,
            workspace_id=fm_creds["workspace_id"] if fm_creds else None,
            credential_provider=cred_provider,
        )
        try:
            response = client.get_job_status(enrichment_id)
        except fm.FoundryMatchError as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if not isinstance(response, dict) or not response.get("ok"):
            detail = response.get("detail") if isinstance(response, dict) else None
            error = response.get("error") if isinstance(response, dict) else None
            message = detail or error or "Unknown error from FoundryMatch"
            click.echo(f"Error: {message}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        data = response.get("data") or {}
        if json_output:
            click.echo(json.dumps(data, indent=2))
        else:
            click.echo("Mode: SaaS (FoundryMatch)")
            click.echo(f"Enrichment ID: {data.get('job_id') or enrichment_id}")
            click.echo(f"Status: {data.get('status') or 'unknown'}")
            click.echo(f"Progress: {data.get('progress', 0)}%")
            click.echo(f"Rows processed: {data.get('rows_processed', 0)}")
            click.echo(f"Rows total: {data.get('rows_total', 0)}")
            click.echo(f"Rows enriched: {data.get('rows_enriched', 0)}")
            click.echo(f"Rows skipped: {data.get('rows_skipped', 0)}")
            click.echo(f"Rows errored: {data.get('rows_errored', 0)}")
            if data.get("credits_consumed") is not None:
                click.echo(f"Credits used: {data.get('credits_consumed')}")
        return

    try:
        api_key = cred_provider.get_secret("fullenrich", "api_key", profile).value
    except Exception:
        click.echo(f"Error: FullEnrich API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    client = fe.FullEnrichClient(api_key=api_key, api_version=api_version)
    try:
        result = client.get_enrichment_status(enrichment_id)
    except fe.FullEnrichError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result.to_dict(), indent=2))
    else:
        click.echo(f"Enrichment ID: {result.enrichment_id}")
        click.echo(f"Name: {result.name}")
        click.echo(f"Status: {result.status}")
        click.echo(f"Total requested: {result.total_requested}")
        click.echo(f"Total enriched: {result.total_enriched}")
        if result.credits_used:
            click.echo(f"Credits used: {result.credits_used}")


# ---------------------------------------------------------------------------
# Amplemarket helpers
# ---------------------------------------------------------------------------


@cli.group()
def amplemarket() -> None:
    """
    Amplemarket commands (doctor, whoami, sequences, enroll).

    Manage sequences and enroll leads in Amplemarket.
    """


def _get_amplemarket_client_or_exit():
    am_mod = _get_amplemarket_helpers()
    try:
        client = am_mod.AmplemarketClient()
    except Exception:
        click.echo("Error: Amplemarket API key not configured.", err=True)
        click.echo("Set G_GREMLIN_AMPLEMARKET_API_KEY or run g-gremlin auth set amplemarket.", err=True)
        sys.exit(EXIT_USAGE)
    return am_mod, client


def _load_json_request(raw: str) -> Dict[str, Any]:
    if not raw:
        return {}
    if raw.strip() == "-":
        raw = sys.stdin.read()
    path = Path(raw)
    if path.exists():
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except Exception as exc:
            raise click.ClickException(f"Invalid JSON in {path}: {exc}") from exc
    try:
        return json.loads(raw)
    except json.JSONDecodeError as exc:
        raise click.ClickException(f"Invalid JSON payload: {exc}") from exc

@amplemarket.command("doctor")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def amplemarket_doctor(json_output: bool) -> None:
    """Validate Amplemarket credentials and probe capabilities."""
    am_mod = _get_amplemarket_helpers()

    try:
        client = am_mod.AmplemarketClient()
    except Exception:
        click.echo("Error: Amplemarket API key not configured.", err=True)
        click.echo("Set G_GREMLIN_AMPLEMARKET_API_KEY or run g-gremlin auth set amplemarket.", err=True)
        sys.exit(EXIT_USAGE)

    provider = _get_credential_provider()
    secret = provider.get_secret_or_none("amplemarket", "api_key")
    source = secret.source.value if secret else None
    source_label = _format_source_label(source)
    token_line = "configured" if secret else "not configured"
    if secret:
        token_line = f"{token_line} ({source_label})"

    identity: Dict[str, Any] = {}
    identity_error = None
    try:
        identity = client.whoami()
    except Exception as exc:
        identity_error = str(exc)
        identity = {"error": identity_error}

    checks = client.probe_capabilities()
    overall_status = _overall_status_from_checks(checks, identity_ok=identity_error is None)

    doctor = DoctorResult(
        provider="amplemarket",
        checks=checks,
        identity={
            **identity,
            "credential_source": source_label if secret else None,
        },
        overall_status=overall_status,
    )

    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return

    click.echo(f"API Key:       {token_line}")
    if identity_error:
        click.echo(f"Account:       unavailable ({identity_error})")
    else:
        account_name = identity.get("account_name") or identity.get("name") or identity.get("account") or "unknown"
        email = identity.get("email") or identity.get("user", {}).get("email") or "unknown"
        click.echo(f"Account:       {account_name}")
        click.echo(f"User:          {email}")
    click.echo("Capabilities:")
    for check in checks:
        click.echo(f"  {check.name:<12} {check.status}")
    click.echo(f"Status:        {overall_status}")


@amplemarket.command("whoami")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def amplemarket_whoami(json_output: bool) -> None:
    """Show current Amplemarket account info."""
    am_mod = _get_amplemarket_helpers()
    try:
        client = am_mod.AmplemarketClient()
    except Exception:
        click.echo("Error: Amplemarket API key not configured.", err=True)
        click.echo("Set G_GREMLIN_AMPLEMARKET_API_KEY or run g-gremlin auth set amplemarket.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        identity = client.whoami()
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    doctor = DoctorResult(
        provider="amplemarket",
        checks=[],
        identity=identity,
        overall_status="ok",
    )

    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return

    click.echo(json.dumps(identity, indent=2))


@amplemarket.group("sequences", invoke_without_command=True)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Emit JSON output.",
)
@click.pass_context
def amplemarket_sequences(ctx: click.Context, as_json: bool) -> None:
    """List or manage Amplemarket sequences."""
    if ctx.invoked_subcommand is None:
        _amplemarket_sequences_list(as_json)


def _amplemarket_sequences_list(as_json: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        sequences = client.list_sequences()
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if as_json:
        payload = [s.__dict__ for s in sequences]
        click.echo(json.dumps(payload, indent=2))
        return

    if not sequences:
        click.echo("No sequences found.")
        return

    for seq in sequences:
        status = seq.status or "unknown"
        click.echo(f"{seq.id}: {seq.name} ({status}, {seq.step_count} steps)")


@amplemarket_sequences.command("list")
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Emit JSON output.",
)
def amplemarket_sequences_list(as_json: bool) -> None:
    """List Amplemarket sequences."""
    _amplemarket_sequences_list(as_json)


@amplemarket_sequences.command("add")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with leads to enroll.",
)
@click.option(
    "--sequence-id",
    required=True,
    help="Target sequence ID.",
)
@click.option(
    "--email-column",
    default="email",
    show_default=True,
    help="Column containing email.",
)
@click.option(
    "--linkedin-column",
    default="linkedin_url",
    show_default=True,
    help="Column containing LinkedIn URL.",
)
@click.option(
    "--data-column",
    "data_columns",
    multiple=True,
    help="Column to include in lead data fields (repeatable).",
)
@click.option(
    "--leads-distribution",
    default=None,
    help="Leads distribution strategy (e.g., round_robin_mailboxes, custom_mailboxes).",
)
@click.option(
    "--mailbox",
    "mailboxes",
    multiple=True,
    help="Mailbox email/ID to use for enrollment (repeatable).",
)
@click.option(
    "--scheduler-start-time",
    default=None,
    help="Override scheduler start time (ISO-8601).",
)
@click.option("--ignore-recently-contacted", is_flag=True, help="Ignore recently contacted checks.")
@click.option("--ignore-exclusion-list", is_flag=True, help="Ignore exclusion list checks.")
@click.option(
    "--ignore-duplicate-leads-in-other-draft-sequences",
    is_flag=True,
    help="Ignore duplicates in other draft sequences.",
)
@click.option(
    "--ignore-duplicate-leads-in-other-active-sequences",
    is_flag=True,
    help="Ignore duplicates in other active sequences.",
)
@click.option("--skip-email-validation", is_flag=True, help="Skip email validation.")
@click.option("--skip-email-checks", is_flag=True, help="Skip email checks.")
@click.option("--skip-phone-checks", is_flag=True, help="Skip phone checks.")
@pacing_option
def amplemarket_sequences_add(
    csv_path: Path,
    sequence_id: str,
    email_column: str,
    linkedin_column: str,
    data_columns: tuple[str, ...],
    leads_distribution: Optional[str],
    mailboxes: tuple[str, ...],
    scheduler_start_time: Optional[str],
    ignore_recently_contacted: bool,
    ignore_exclusion_list: bool,
    ignore_duplicate_leads_in_other_draft_sequences: bool,
    ignore_duplicate_leads_in_other_active_sequences: bool,
    skip_email_validation: bool,
    skip_email_checks: bool,
    skip_phone_checks: bool,
    safe_mode: bool,
) -> None:
    """Enroll leads from a CSV into an Amplemarket sequence."""
    am_mod, client = _get_amplemarket_client_or_exit()

    click.echo(f"Reading leads from {csv_path}...")
    click.echo(f"Target sequence: {sequence_id}")

    try:
        leads = am_mod._load_sequence_leads_from_csv(
            csv_path=str(csv_path),
            email_col=email_column,
            linkedin_col=linkedin_column,
            data_columns=list(data_columns),
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Enrollment failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    profile = get_amplemarket_profile("safe" if safe_mode else "turbo")
    try:
        result = client.add_to_sequence_bulk(
            leads,
            sequence_id=sequence_id,
            pacing_profile=profile,
            leads_distribution=leads_distribution,
            mailboxes=list(mailboxes) or None,
            scheduler_start_time=scheduler_start_time,
            ignore_recently_contacted=ignore_recently_contacted or None,
            ignore_exclusion_list=ignore_exclusion_list or None,
            ignore_duplicate_leads_in_other_draft_sequences=ignore_duplicate_leads_in_other_draft_sequences or None,
            ignore_duplicate_leads_in_other_active_sequences=ignore_duplicate_leads_in_other_active_sequences or None,
            skip_email_validation=skip_email_validation or None,
            skip_email_checks=skip_email_checks or None,
            skip_phone_checks=skip_phone_checks or None,
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Enrollment failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    succeeded = len(result.succeeded)
    failed = len(result.failed)
    retryable = len(result.retryable_failed)

    click.echo(f"Enrolled: {succeeded}")
    click.echo(f"Failed: {failed}")
    if retryable:
        click.echo(f"Retryable failures: {retryable}")


def _emit_search_page(page, json_output: bool) -> None:
    if json_output:
        click.echo(json.dumps(page.raw, indent=2))
        return
    count = len(page.results)
    click.echo(f"Results: {count}")
    pagination = page.pagination or {}
    next_cursor = pagination.get("next") or pagination.get("next_cursor") or pagination.get("after")
    if next_cursor:
        click.echo(f"Next cursor: {next_cursor}")


def _load_people_from_csv(
    csv_path: Path,
    email_column: str,
    linkedin_column: str,
    firstname_column: str,
    lastname_column: str,
    title_column: str,
    company_column: str,
    domain_column: str,
) -> List[Dict[str, Any]]:
    import csv as csv_module

    leads: List[Dict[str, Any]] = []
    with csv_path.open(newline="", encoding="utf-8") as handle:
        reader = csv_module.DictReader(handle)
        for row in reader:
            lead = {
                "email": (row.get(email_column) or "").strip() or None,
                "linkedin_url": (row.get(linkedin_column) or "").strip() or None,
                "first_name": (row.get(firstname_column) or "").strip() or None,
                "last_name": (row.get(lastname_column) or "").strip() or None,
                "title": (row.get(title_column) or "").strip() or None,
                "company_name": (row.get(company_column) or "").strip() or None,
                "company_domain": (row.get(domain_column) or "").strip() or None,
            }
            lead = {k: v for k, v in lead.items() if v}
            if not lead:
                continue
            if not lead.get("email") and not lead.get("linkedin_url"):
                if not (lead.get("first_name") and lead.get("last_name") and (lead.get("company_name") or lead.get("company_domain"))):
                    continue
            leads.append(lead)
    if not leads:
        raise click.ClickException("No valid people rows found in CSV.")
    return leads


def _load_companies_from_csv(
    csv_path: Path,
    name_column: str,
    domain_column: str,
    linkedin_column: str,
) -> List[Dict[str, Any]]:
    import csv as csv_module

    leads: List[Dict[str, Any]] = []
    with csv_path.open(newline="", encoding="utf-8") as handle:
        reader = csv_module.DictReader(handle)
        for row in reader:
            lead = {
                "name": (row.get(name_column) or "").strip() or None,
                "domain": (row.get(domain_column) or "").strip() or None,
                "linkedin_url": (row.get(linkedin_column) or "").strip() or None,
            }
            lead = {k: v for k, v in lead.items() if v}
            if not lead:
                continue
            leads.append(lead)
    if not leads:
        raise click.ClickException("No valid company rows found in CSV.")
    return leads


def _poll_enrichment(client, path: str, wait: bool, poll_interval: float, max_wait: Optional[float]) -> Dict[str, Any] | None:
    if not wait:
        return None
    return client.wait_for_enrichment(path, poll_interval=poll_interval, max_wait=max_wait)


@amplemarket.group("people")
def amplemarket_people() -> None:
    """Amplemarket people search and enrichment."""


@amplemarket_people.command("search")
@click.option("--request", required=True, help="JSON payload or path to JSON file (use '-' for stdin).")
@click.option("--page-size", type=int, default=None, help="Override page size.")
@click.option("--page-after", default=None, help="Cursor for next page.")
@click.option("--page-before", default=None, help="Cursor for previous page.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_people_search(
    request: str,
    page_size: Optional[int],
    page_after: Optional[str],
    page_before: Optional[str],
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    payload = _load_json_request(request)
    if not isinstance(payload, dict):
        raise click.ClickException("People search request must be a JSON object.")
    page = payload.get("page") if isinstance(payload.get("page"), dict) else {}
    if page_size:
        page["size"] = page_size
    if page_after:
        page["after"] = page_after
    if page_before:
        page["before"] = page_before
    if page:
        payload["page"] = page

    try:
        result = client.search_people(payload)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    _emit_search_page(result, json_output)


@amplemarket_people.command("find")
@click.option("--email", default=None, help="Email address.")
@click.option("--linkedin-url", default=None, help="LinkedIn URL.")
@click.option("--first-name", default=None, help="First name.")
@click.option("--last-name", default=None, help="Last name.")
@click.option("--title", default=None, help="Job title.")
@click.option("--company-name", default=None, help="Company name.")
@click.option("--company-domain", default=None, help="Company domain.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_people_find(
    email: Optional[str],
    linkedin_url: Optional[str],
    first_name: Optional[str],
    last_name: Optional[str],
    title: Optional[str],
    company_name: Optional[str],
    company_domain: Optional[str],
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    if not any([email, linkedin_url, first_name, last_name, title, company_name, company_domain]):
        raise click.ClickException("Provide at least one search parameter.")
    try:
        result = client.find_person(
            email=email,
            linkedin_url=linkedin_url,
            first_name=first_name,
            last_name=last_name,
            title=title,
            company_name=company_name,
            company_domain=company_domain,
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return
    if not result:
        click.echo("No person found.")
        return
    click.echo(json.dumps(result, indent=2))


@amplemarket_people.command("batch")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with people to enrich.",
)
@click.option("--email-column", default="email", show_default=True)
@click.option("--linkedin-column", default="linkedin_url", show_default=True)
@click.option("--firstname-column", default="first_name", show_default=True)
@click.option("--lastname-column", default="last_name", show_default=True)
@click.option("--title-column", default="title", show_default=True)
@click.option("--company-column", default="company_name", show_default=True)
@click.option("--domain-column", default="company_domain", show_default=True)
@click.option("--reveal-email", is_flag=True, help="Reveal email when possible.")
@click.option("--reveal-phone-numbers", is_flag=True, help="Reveal phone numbers when possible.")
@click.option("--wait/--no-wait", default=False, show_default=True, help="Wait for enrichment to complete.")
@click.option("--poll-interval", default=30.0, show_default=True, type=float)
@click.option("--max-wait", default=None, type=float, help="Max seconds to wait for completion.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_people_batch(
    csv_path: Path,
    email_column: str,
    linkedin_column: str,
    firstname_column: str,
    lastname_column: str,
    title_column: str,
    company_column: str,
    domain_column: str,
    reveal_email: bool,
    reveal_phone_numbers: bool,
    wait: bool,
    poll_interval: float,
    max_wait: Optional[float],
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    leads = _load_people_from_csv(
        csv_path,
        email_column,
        linkedin_column,
        firstname_column,
        lastname_column,
        title_column,
        company_column,
        domain_column,
    )
    try:
        result = client.start_person_enrichment_batch(
            leads,
            reveal_email=reveal_email or None,
            reveal_phone_numbers=reveal_phone_numbers or None,
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        output = result.raw if result.raw else result.__dict__
        click.echo(json.dumps(output, indent=2))
    else:
        click.echo(f"Request ID: {result.id}")
        click.echo(f"Status: {result.status or 'unknown'}")

    if wait and result.id:
        payload = _poll_enrichment(
            client,
            f"/people/enrichment-requests/{result.id}",
            wait=wait,
            poll_interval=poll_interval,
            max_wait=max_wait,
        )
        if payload:
            click.echo(json.dumps(payload, indent=2) if json_output else f"Final status: {payload.get('status')}")


@amplemarket_people.command("status")
@click.argument("request_id")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_people_status(request_id: str, json_output: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        payload = client.get_person_enrichment_batch(request_id)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps(payload, indent=2))
    else:
        status = payload.get("status") or payload.get("state") or payload.get("data", {}).get("status")
        click.echo(f"Status: {status}")


@amplemarket_people.command("cancel")
@click.argument("request_id")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_people_cancel(request_id: str, json_output: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        payload = client.cancel_person_enrichment_batch(request_id)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps(payload, indent=2))
    else:
        status = payload.get("status") or payload.get("state") or payload.get("data", {}).get("status")
        click.echo(f"Status: {status}")


@amplemarket.group("companies")
def amplemarket_companies() -> None:
    """Amplemarket company search and enrichment."""


@amplemarket_companies.command("search")
@click.option("--request", required=True, help="JSON payload or path to JSON file (use '-' for stdin).")
@click.option("--page-size", type=int, default=None, help="Override page size.")
@click.option("--page-after", default=None, help="Cursor for next page.")
@click.option("--page-before", default=None, help="Cursor for previous page.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_companies_search(
    request: str,
    page_size: Optional[int],
    page_after: Optional[str],
    page_before: Optional[str],
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    payload = _load_json_request(request)
    if not isinstance(payload, dict):
        raise click.ClickException("Company search request must be a JSON object.")
    page = payload.get("page") if isinstance(payload.get("page"), dict) else {}
    if page_size:
        page["size"] = page_size
    if page_after:
        page["after"] = page_after
    if page_before:
        page["before"] = page_before
    if page:
        payload["page"] = page

    try:
        result = client.search_companies(payload)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    _emit_search_page(result, json_output)


@amplemarket_companies.command("find")
@click.option("--domain", default=None, help="Company domain.")
@click.option("--name", default=None, help="Company name.")
@click.option("--linkedin-url", default=None, help="LinkedIn URL.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_companies_find(
    domain: Optional[str],
    name: Optional[str],
    linkedin_url: Optional[str],
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    if not any([domain, name, linkedin_url]):
        raise click.ClickException("Provide at least one search parameter.")
    try:
        result = client.find_company(domain=domain, name=name, linkedin_url=linkedin_url)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return
    if not result:
        click.echo("No company found.")
        return
    click.echo(json.dumps(result, indent=2))


@amplemarket_companies.command("batch")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with companies to enrich.",
)
@click.option("--name-column", default="name", show_default=True)
@click.option("--domain-column", default="domain", show_default=True)
@click.option("--linkedin-column", default="linkedin_url", show_default=True)
@click.option("--wait/--no-wait", default=False, show_default=True, help="Wait for enrichment to complete.")
@click.option("--poll-interval", default=30.0, show_default=True, type=float)
@click.option("--max-wait", default=None, type=float, help="Max seconds to wait for completion.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_companies_batch(
    csv_path: Path,
    name_column: str,
    domain_column: str,
    linkedin_column: str,
    wait: bool,
    poll_interval: float,
    max_wait: Optional[float],
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    leads = _load_companies_from_csv(csv_path, name_column, domain_column, linkedin_column)
    try:
        result = client.start_company_enrichment_batch(leads)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        output = result.raw if result.raw else result.__dict__
        click.echo(json.dumps(output, indent=2))
    else:
        click.echo(f"Request ID: {result.id}")
        click.echo(f"Status: {result.status or 'unknown'}")

    if wait and result.id:
        payload = _poll_enrichment(
            client,
            f"/companies/enrichment-requests/{result.id}",
            wait=wait,
            poll_interval=poll_interval,
            max_wait=max_wait,
        )
        if payload:
            click.echo(json.dumps(payload, indent=2) if json_output else f"Final status: {payload.get('status')}")


@amplemarket_companies.command("status")
@click.argument("request_id")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_companies_status(request_id: str, json_output: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        payload = client.get_company_enrichment_batch(request_id)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps(payload, indent=2))
    else:
        status = payload.get("status") or payload.get("state") or payload.get("data", {}).get("status")
        click.echo(f"Status: {status}")


@amplemarket_companies.command("cancel")
@click.argument("request_id")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_companies_cancel(request_id: str, json_output: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        payload = client.cancel_company_enrichment_batch(request_id)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps(payload, indent=2))
    else:
        status = payload.get("status") or payload.get("state") or payload.get("data", {}).get("status")
        click.echo(f"Status: {status}")


@amplemarket.group("lead-lists")
def amplemarket_lead_lists() -> None:
    """Amplemarket lead list helpers."""


@amplemarket_lead_lists.command("list")
@click.option("--limit", default=100, show_default=True, type=int)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_lead_lists_list(limit: int, json_output: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        lists = client.list_lead_lists(limit=limit)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps([l.__dict__ for l in lists], indent=2))
        return
    if not lists:
        click.echo("No lead lists found.")
        return
    for lst in lists:
        status = lst.status or "unknown"
        click.echo(f"{lst.id}: {lst.name} ({status}, type={lst.lead_type})")


@amplemarket_lead_lists.command("create")
@click.option("--name", required=True, help="Lead list name.")
@click.option(
    "--type",
    "lead_type",
    required=True,
    type=click.Choice(["linkedin", "email", "titles_and_company", "title_and_company"]),
    help="Lead list type.",
)
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with leads to seed the list.",
)
@click.option("--email-column", default="email", show_default=True)
@click.option("--linkedin-column", default="linkedin_url", show_default=True)
@click.option("--title-column", default="title", show_default=True)
@click.option("--company-column", default="company_name", show_default=True)
@click.option("--domain-column", default="company_domain", show_default=True)
@click.option("--owner-email", default=None, help="Owner email.")
@click.option("--shared/--private", default=False, show_default=True, help="Share list with team.")
@click.option("--visible/--hidden", default=True, show_default=True, help="Show list in UI.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_lead_lists_create(
    name: str,
    lead_type: str,
    csv_path: Path,
    email_column: str,
    linkedin_column: str,
    title_column: str,
    company_column: str,
    domain_column: str,
    owner_email: Optional[str],
    shared: bool,
    visible: bool,
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    leads = _build_lead_list_entries(
        csv_path,
        lead_type=lead_type,
        email_column=email_column,
        linkedin_column=linkedin_column,
        title_column=title_column,
        company_column=company_column,
        domain_column=domain_column,
    )
    try:
        result = client.create_lead_list(
            name=name,
            lead_type=lead_type,
            leads=leads,
            owner_email=owner_email,
            shared=shared,
            visible=visible,
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result.raw or result.__dict__, indent=2))
    else:
        click.echo(f"Created lead list {result.id}: {result.name}")


@amplemarket_lead_lists.command("get")
@click.argument("list_id")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_lead_lists_get(list_id: str, json_output: bool) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    try:
        payload = client.get_lead_list(list_id)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return
    status = payload.get("status") or payload.get("state")
    click.echo(f"Lead list {list_id}: {status or 'unknown'}")


def _build_lead_list_entries(
    csv_path: Path,
    *,
    lead_type: str,
    email_column: str,
    linkedin_column: str,
    title_column: str,
    company_column: str,
    domain_column: str,
) -> List[Dict[str, Any]]:
    import csv as csv_module

    leads: List[Dict[str, Any]] = []
    with csv_path.open(newline="", encoding="utf-8") as handle:
        reader = csv_module.DictReader(handle)
        for row in reader:
            if lead_type in ("linkedin",):
                value = (row.get(linkedin_column) or "").strip()
                leads.append({"linkedin_url": value})
            elif lead_type in ("email",):
                value = (row.get(email_column) or "").strip()
                leads.append({"email": value})
            else:
                title = (row.get(title_column) or "").strip()
                company = (row.get(company_column) or "").strip()
                domain = (row.get(domain_column) or "").strip()
                leads.append(
                    {
                        "title": title,
                        "company_name": company or None,
                        "company_domain": domain or None,
                    }
                )
    if not leads:
        raise click.ClickException("No lead rows found in CSV.")
    return leads


@amplemarket_lead_lists.command("add")
@click.option("--list-id", required=True, help="Target lead list ID.")
@click.option(
    "--type",
    "lead_type",
    required=True,
    type=click.Choice(["linkedin", "email", "titles_and_company", "title_and_company"]),
    help="Lead list type.",
)
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with leads to add.",
)
@click.option("--email-column", default="email", show_default=True)
@click.option("--linkedin-column", default="linkedin_url", show_default=True)
@click.option("--title-column", default="title", show_default=True)
@click.option("--company-column", default="company_name", show_default=True)
@click.option("--domain-column", default="company_domain", show_default=True)
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def amplemarket_lead_lists_add(
    list_id: str,
    lead_type: str,
    csv_path: Path,
    email_column: str,
    linkedin_column: str,
    title_column: str,
    company_column: str,
    domain_column: str,
    json_output: bool,
) -> None:
    am_mod, client = _get_amplemarket_client_or_exit()
    leads = _build_lead_list_entries(
        csv_path,
        lead_type=lead_type,
        email_column=email_column,
        linkedin_column=linkedin_column,
        title_column=title_column,
        company_column=company_column,
        domain_column=domain_column,
    )
    try:
        result = client.add_leads_to_list(list_id=list_id, leads=leads, lead_type=lead_type)
    except am_mod.AmplemarketError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    if json_output:
        click.echo(json.dumps(result, indent=2))
        return
    click.echo(f"Submitted {result.get('total_submitted')} leads in {result.get('chunks')} batch(es).")


@amplemarket.command("enroll")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with leads to enroll.",
)
@click.option(
    "--sequence-id",
    required=True,
    help="Target sequence ID.",
)
@click.option(
    "--email-column",
    default="email",
    show_default=True,
    help="Column containing email.",
)
@click.option(
    "--linkedin-column",
    default="linkedin_url",
    show_default=True,
    help="Column containing LinkedIn URL.",
)
@click.option(
    "--data-column",
    "data_columns",
    multiple=True,
    help="Column to include in lead data fields (repeatable).",
)
@pacing_option
def amplemarket_enroll(
    csv_path: Path,
    sequence_id: str,
    email_column: str,
    linkedin_column: str,
    data_columns: tuple[str, ...],
    safe_mode: bool,
) -> None:
    """Enroll leads from a CSV into an Amplemarket sequence (alias for sequences add)."""
    am_mod, client = _get_amplemarket_client_or_exit()

    click.echo(f"Reading leads from {csv_path}...")
    click.echo(f"Target sequence: {sequence_id}")

    try:
        leads = am_mod._load_sequence_leads_from_csv(
            csv_path=str(csv_path),
            email_col=email_column,
            linkedin_col=linkedin_column,
            data_columns=list(data_columns),
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Enrollment failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    profile = get_amplemarket_profile("safe" if safe_mode else "turbo")
    try:
        result = client.add_to_sequence_bulk(
            leads,
            sequence_id=sequence_id,
            pacing_profile=profile,
        )
    except am_mod.AmplemarketError as exc:
        click.echo(f"Enrollment failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    succeeded = len(result.succeeded)
    failed = len(result.failed)
    retryable = len(result.retryable_failed)

    click.echo(f"Enrolled: {succeeded}")
    click.echo(f"Failed: {failed}")
    if retryable:
        click.echo(f"Retryable failures: {retryable}")


# ---------------------------------------------------------------------------
# Outreach.io helpers
# ---------------------------------------------------------------------------


@cli.group()
def outreach() -> None:
    """
    Outreach.io sales engagement commands (doctor, sequences, enroll).

    Manage prospects and sequences in Outreach.
    """


@outreach.command("doctor")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def outreach_doctor(profile: str, json_output: bool) -> None:
    """Validate Outreach credentials and test connection."""
    or_mod = _get_outreach_helpers()

    # Try to create client - it loads from config file (~/.g_gremlin/outreach.json)
    try:
        client = or_mod.OutreachClient()
    except Exception:
        click.echo(f"Error: Outreach credentials not configured for profile '{profile}'.", err=True)
        click.echo("Required: client_id, client_secret, refresh_token", err=True)
        click.echo(
            "Run 'g-gremlin outreach oauth connect' (or 'g-gremlin outreach auth --manual') to authenticate.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    provider = _get_credential_provider()
    outreach_config = or_mod._load_config()
    active_profile = outreach_config.get("scope_profile") or profile
    source = None
    for key in ["client_id", "client_secret", "refresh_token"]:
        secret = provider.get_secret_or_none("outreach", key)
        if secret:
            source = secret.source.value
            break
    if not source:
        cfg = or_mod._load_config()
        resolution = resolve_runtime_auth(
            integration="outreach",
            credential_provider=provider,
            config=cfg,
        )
        selected_source = getattr(resolution.selected_source, "value", resolution.selected_source)
        if selected_source in ("server_session", "config"):
            source = "server_oauth"
        elif cfg.get("client_id") and cfg.get("client_secret") and cfg.get("refresh_token"):
            source = "legacy_file"

    source_label = _format_source_label(source)
    credentials_line = "configured" if source else "not configured"
    if source:
        credentials_line = f"{credentials_line} ({source_label})"

    identity: Dict[str, Any] = {}
    identity_error = None
    try:
        identity = client.whoami()
    except Exception as exc:
        identity_error = str(exc)
        identity = {"error": identity_error}

    checks = client.probe_capabilities()
    overall_status = _overall_status_from_checks(checks, identity_ok=identity_error is None)

    token_expires_at = client.config.get("token_expires_at") or getattr(client, "_token_expires_at", None)
    token_line = "unknown"
    try:
        if token_expires_at:
            expires_at = float(token_expires_at)
            delta = expires_at - time.time()
            if delta > 0:
                token_line = f"valid (expires in {_format_duration(delta)})"
            else:
                token_line = f"expired ({_format_duration(abs(delta))} ago)"
    except Exception:
        token_line = "unknown"

    doctor = DoctorResult(
        provider="outreach",
        checks=checks,
        identity={
            **identity,
            "credential_source": source_label if source else None,
            "token_status": token_line,
            "token_expires_at": token_expires_at,
            "profile": active_profile,
        },
        overall_status=overall_status,
    )

    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return

    click.echo(f"Profile:       {active_profile}")
    click.echo(f"Credentials:   {credentials_line}")
    if identity_error:
        click.echo(f"User:          unavailable ({identity_error})")
        click.echo("Org:           unknown")
    else:
        click.echo(f"User:          {identity.get('name', 'unknown')} ({identity.get('email', 'unknown')})")
        click.echo(f"Org:           {identity.get('org_name', 'unknown')} ({identity.get('org_id', 'unknown')})")
    click.echo(f"Token:         {token_line}")
    click.echo("Capabilities:")
    for check in checks:
        click.echo(f"  {check.name:<12} {check.status}")
    click.echo(f"Status:        {overall_status}")


@outreach.command("whoami")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def outreach_whoami(json_output: bool) -> None:
    """Show current Outreach user and org."""
    or_mod = _get_outreach_helpers()
    try:
        client = or_mod.OutreachClient()
    except Exception:
        click.echo("Error: Outreach credentials not configured.", err=True)
        click.echo(
            "Run 'g-gremlin outreach oauth connect' (or 'g-gremlin outreach auth --manual').",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    try:
        identity = client.whoami()
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    doctor = DoctorResult(
        provider="outreach",
        checks=[],
        identity=identity,
        overall_status="ok",
    )

    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return

    click.echo(f"User: {identity.get('name', 'unknown')} ({identity.get('email', 'unknown')})")
    click.echo(f"Org: {identity.get('org_name', 'unknown')} ({identity.get('org_id', 'unknown')})")


@outreach.command("scopes")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def outreach_scopes(json_output: bool) -> None:
    """Show active Outreach scope profile and capability probes."""
    or_mod = _get_outreach_helpers()
    try:
        client = or_mod.OutreachClient()
    except Exception:
        click.echo("Error: Outreach credentials not configured.", err=True)
        click.echo(
            "Run 'g-gremlin outreach oauth connect' (or 'g-gremlin outreach auth --manual').",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    config = or_mod._load_config()
    profile_name = config.get("scope_profile") or "default"
    requested_scopes = config.get("requested_scopes") or or_mod.SCOPE_PROFILES.get(profile_name, or_mod.OUTREACH_SCOPES)
    checks = client.probe_capabilities()

    if json_output:
        payload = {
            "profile": profile_name,
            "requested_scopes": requested_scopes,
            "capabilities": [c.to_dict() for c in checks],
        }
        click.echo(json.dumps(payload, indent=2))
        return

    click.echo(f"Active profile: {profile_name}")
    click.echo("Requested scopes:")
    for scope in requested_scopes:
        click.echo(f"  {scope}")
    click.echo("Capability probes:")
    for check in checks:
        click.echo(f"  {check.name:<12} {check.status}")
    click.echo("Note: Scopes may vary by Outreach app configuration; these are g-gremlin defaults.")
    click.echo("Reconnect with approved scopes: g-gremlin outreach oauth connect --scope-profile default")
    click.echo("Request broader scopes later: g-gremlin outreach oauth connect --scope-profile admin")


@outreach.group("oauth")
def outreach_oauth() -> None:
    """
    Outreach OAuth helpers (recommended).

    These commands use FoundryOps bridge auth to open the browser flow, then
    store a server-managed Outreach runtime session.

    \b
    AI/automation flow:
      1) g-gremlin auth login
      2) g-gremlin auth explain outreach --surface bridge --json
      3) g-gremlin outreach oauth connect
      4) g-gremlin auth explain outreach --surface runtime --json
    """


@outreach_oauth.command("connect")
@click.option(
    "--scope-profile",
    default="default",
    show_default=True,
    type=click.Choice(["default", "read_only", "admin"]),
    help="Scope profile to request.",
)
@click.option(
    "--workspace-id",
    envvar="FOUNDRYMATCH_WORKSPACE_ID",
    help="Optional workspace/org ID override. Defaults to authenticated workspace.",
)
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--no-browser",
    is_flag=True,
    help="Do not open browser automatically.",
)
def outreach_oauth_connect(
    scope_profile: str,
    workspace_id: str | None,
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    no_browser: bool,
) -> None:
    """Start browser OAuth for Outreach and wait for completion."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="outreach",
        auth_token=auth_token,
        api_key=api_key,
        workspace_id=workspace_id,
        api_base=api_base,
        dev=dev,
    )
    base = resolution.api_base or _apollo_api_base(api_base)
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    session_id = str(uuid.uuid4())

    try:
        authorize_params = {
            "session_id": session_id,
            "scope_profile": scope_profile,
        }
        if resolution.workspace_id:
            authorize_params["workspace_id"] = resolution.workspace_id
        resp = requests.get(
            f"{base}/api/v2/integrations/outreach/cli/authorize",
            params=authorize_params,
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error starting Outreach OAuth: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code != 200:
        click.echo(
            f"Error starting Outreach OAuth ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code in (401, 403):
            click.echo("Tip: re-authenticate with 'g-gremlin auth login'.", err=True)
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        authorize_payload = resp.json()
    except Exception:
        authorize_payload = {}
    if not isinstance(authorize_payload, dict):
        authorize_payload = {}
    auth_url = authorize_payload.get("auth_url")
    authorize_workspace_id = (
        str(authorize_payload.get("workspace_id") or resolution.workspace_id or "").strip()
    )
    resolved_scope_profile = str(authorize_payload.get("scope_profile") or scope_profile or "default")

    if not auth_url:
        click.echo("Error: Missing auth_url from server.", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo("Opening browser for Outreach authorization...")
    click.echo(f"\nIf browser doesn't open, visit:\n{auth_url}\n")
    if not no_browser:
        try:
            webbrowser.open(auth_url)
        except Exception:
            pass

    for _ in range(60):
        time.sleep(5)
        try:
            poll = requests.get(
                f"{base}/api/v2/integrations/outreach/cli/poll",
                params={"session_id": session_id},
                timeout=15,
            )
        except Exception as exc:
            click.echo(f"Error polling Outreach OAuth: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if poll.status_code != 200:
            click.echo(f"Poll failed ({poll.status_code}): {poll.text[:300]}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        try:
            payload = poll.json()
        except Exception:
            payload = {}

        status = payload.get("status")
        if status == "complete":
            workspace_for_token = (
                payload.get("workspace_id")
                or authorize_workspace_id
                or resolution.workspace_id
            )
            token_params = {"session_id": session_id}
            if workspace_for_token:
                token_params["workspace_id"] = workspace_for_token
            try:
                token_resp = requests.get(
                    f"{base}/api/v2/integrations/outreach/cli/token",
                    params=token_params,
                    headers=headers,
                    timeout=20,
                )
            except Exception as exc:
                click.echo(
                    click.style("X ", fg="red")
                    + f"Outreach OAuth finished, but failed to retrieve local token: {exc}",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

            if token_resp.status_code != 200:
                click.echo(
                    click.style("X ", fg="red")
                    + (
                        "Outreach OAuth finished, but failed to retrieve local token "
                        f"({token_resp.status_code}): {token_resp.text[:300]}"
                    ),
                    err=True,
                )
                sys.exit(EXIT_ISSUES)

            try:
                token_payload = token_resp.json()
            except Exception:
                token_payload = {}
            access_token = str(token_payload.get("access_token") or "")
            if not access_token:
                click.echo(
                    click.style("X ", fg="red")
                    + "Outreach OAuth finished, but token payload was invalid.",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

            or_mod = _get_outreach_helpers()
            config = or_mod._load_config()
            config["scope_profile"] = token_payload.get("scope_profile") or resolved_scope_profile
            scope_profiles = getattr(or_mod, "SCOPE_PROFILES", {}) or {}
            default_scopes = getattr(or_mod, "OUTREACH_SCOPES", []) or []
            config["requested_scopes"] = list(
                scope_profiles.get(config["scope_profile"], default_scopes)
            )
            config["outreach_auth_mode"] = "server"
            config["outreach_foundryops_api_base"] = base
            if workspace_for_token:
                config["outreach_workspace_id"] = workspace_for_token
            if hasattr(or_mod, "_store_server_session"):
                or_mod._store_server_session(
                    {
                        "access_token": access_token,
                        "expires_at": token_payload.get("expires_at") or (time.time() + 7200),
                        "scope_profile": config.get("scope_profile"),
                        "org_id": token_payload.get("org_id"),
                        "org_name": token_payload.get("org_name"),
                        "workspace_id": workspace_for_token or None,
                        "api_base": base,
                    }
                )
            config.pop("auth_mode", None)
            config.pop("foundryops_api_base", None)
            config.pop("workspace_id", None)
            config.pop("access_token", None)
            config.pop("token_expires_at", None)
            config.pop("client_secret", None)
            config.pop("refresh_token", None)
            config.pop("client_id", None)
            or_mod._save_config(config)

            click.echo(click.style("OK ", fg="green") + "Outreach OAuth connected.")
            click.echo("Local Outreach session cached (server-managed refresh enabled).")
            return
        if status == "error":
            message = payload.get("message") or "Outreach authorization failed."
            click.echo(click.style("X ", fg="red") + message, err=True)
            sys.exit(EXIT_ISSUES)

        click.echo(".", nl=False)

    click.echo("")
    click.echo(click.style("X ", fg="red") + "Timeout waiting for Outreach authorization.", err=True)
    sys.exit(EXIT_ISSUES)


@outreach_oauth.command("status")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON status output.",
)
def outreach_oauth_status(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    json_output: bool,
) -> None:
    """Check server-side Outreach OAuth status."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="outreach",
        auth_token=auth_token,
        api_key=api_key,
        api_base=api_base,
        dev=dev,
    )
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    base = resolution.api_base or _apollo_api_base(api_base)
    try:
        resp = requests.get(
            f"{base}/api/v2/integrations/outreach/status",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error checking Outreach OAuth status: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code in (401, 403):
        click.echo("Unauthorized. Re-authenticate with 'g-gremlin auth login'.", err=True)
        sys.exit(EXIT_ISSUES)
    if resp.status_code != 200:
        click.echo(
            f"Error checking Outreach OAuth status ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    try:
        payload = resp.json()
    except Exception:
        payload = {}

    if json_output:
        click.echo(json.dumps(payload, indent=2))
        return

    connected = bool(payload.get("connected"))
    status = payload.get("status") or ("connected" if connected else "disconnected")
    click.echo(f"Status: {status}")
    if connected:
        click.echo(f"Org: {payload.get('org_name') or 'unknown'} ({payload.get('org_id') or 'unknown'})")
        click.echo(f"Scope profile: {payload.get('scope_profile') or 'default'}")
        if payload.get("expires_at"):
            click.echo(f"Expires at: {payload.get('expires_at')}")
        if payload.get("needs_refresh"):
            click.echo("Token refresh: due soon")
    elif payload.get("error"):
        error = payload.get("error") or {}
        click.echo(f"Error: {error.get('message') or error}", err=True)


@outreach_oauth.command("disconnect")
@click.option(
    "--api-base",
    envvar=["FOUNDRYOPS_API_URL", "FOUNDRYMATCH_API_URL"],
    default=APOLLO_DEFAULT_API_BASE,
    show_default=True,
    help="FoundryOps API base URL.",
)
@click.option(
    "--auth-token",
    envvar="FOUNDRYMATCH_AUTH_TOKEN",
    help="FoundryOps auth token (Bearer).",
)
@click.option(
    "--api-key",
    envvar="FOUNDRYMATCH_API_KEY",
    help="FoundryOps API key.",
)
@click.option(
    "--dev",
    is_flag=True,
    help="Use dev-token auth (local development only).",
)
@click.option(
    "--yes",
    is_flag=True,
    help="Skip confirmation prompt.",
)
def outreach_oauth_disconnect(
    api_base: str,
    auth_token: str | None,
    api_key: str | None,
    dev: bool,
    yes: bool,
) -> None:
    """Disconnect Outreach OAuth and clear local cached auth state."""
    try:
        import requests
    except Exception as exc:
        click.echo(f"Missing requests dependency: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    resolution = _resolve_bridge_auth_resolution(
        integration="outreach",
        auth_token=auth_token,
        api_key=api_key,
        api_base=api_base,
        dev=dev,
    )
    headers = resolution.headers
    if not headers:
        click.echo(
            "Error: Authentication required. Provide --auth-token, --api-key, "
            "or run 'g-gremlin auth login'.",
            err=True,
        )
        sys.exit(EXIT_USAGE)

    if not yes and sys.stdin.isatty():
        if not click.confirm("Disconnect Outreach for this workspace?", default=False):
            click.echo("Aborted.")
            return

    base = resolution.api_base or _apollo_api_base(api_base)
    try:
        resp = requests.delete(
            f"{base}/api/v2/integrations/outreach/disconnect",
            headers=headers,
            timeout=20,
        )
    except Exception as exc:
        click.echo(f"Error disconnecting Outreach OAuth: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if resp.status_code not in (200, 204):
        click.echo(
            f"Error disconnecting Outreach OAuth ({resp.status_code}): {resp.text[:300]}",
            err=True,
        )
        if resp.status_code >= 500:
            sys.exit(EXIT_REMOTE_ERROR)
        sys.exit(EXIT_ISSUES)

    or_mod = _get_outreach_helpers()
    config = or_mod._load_config()
    if hasattr(or_mod, "_clear_server_session"):
        try:
            or_mod._clear_server_session()
        except Exception:
            pass
    for key in (
        "outreach_auth_mode",
        "outreach_workspace_id",
        "outreach_foundryops_api_base",
        "access_token",
        "token_expires_at",
        "refresh_token",
        "client_secret",
        "client_id",
        "auth_mode",
        "workspace_id",
        "foundryops_api_base",
        "org_id",
        "org_name",
    ):
        config.pop(key, None)
    or_mod._save_config(config)

    click.echo("Outreach disconnected.")


@outreach.command("auth")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    type=click.Choice(["default", "read_only", "admin"]),
    help="Scope profile to request during OAuth.",
)
@click.option(
    "--scopes",
    "scopes_override",
    default=None,
    hidden=True,
    help="Override OAuth scopes (space-separated).",
)
@click.option(
    "--client-id",
    "client_id",
    default=None,
    help="OAuth client ID (prompts if not provided).",
)
@click.option(
    "--client-secret",
    "client_secret",
    default=None,
    help="OAuth client secret (prompts if not provided).",
)
@click.option(
    "--refresh-token",
    "refresh_token",
    default=None,
    help="Refresh token (for manual mode).",
)
@click.option(
    "--manual",
    is_flag=True,
    help="Manual mode: paste credentials directly (no OAuth flow).",
)
@click.option(
    "--port",
    "redirect_port",
    default=None,
    type=int,
    help="Port for OAuth callback server (default: 8484).",
)
@click.option(
    "--redirect-uri",
    "redirect_uri",
    default=None,
    help=(
        "Custom redirect URI. Default uses the FoundryOps HTTPS bridge "
        "(https://api.foundryops.io/api/v1/outreach/callback). "
        "Override for your own tunnel or app."
    ),
)
@click.option(
    "--no-browser",
    is_flag=True,
    help="Don't automatically open browser (print URL instead).",
)
def outreach_auth(
    profile: str,
    scopes_override: Optional[str],
    client_id: Optional[str],
    client_secret: Optional[str],
    refresh_token: Optional[str],
    manual: bool,
    redirect_port: Optional[int],
    redirect_uri: Optional[str],
    no_browser: bool,
) -> None:
    """
    Authenticate with Outreach via OAuth2.

    \b
    TWO MODES:

    1. MANUAL MODE (recommended for most users):
       g-gremlin outreach auth --manual
       Prompts for client_id, client_secret, and refresh_token.
       Get these from your Outreach admin or existing integration.

    2. OAUTH FLOW MODE:
       g-gremlin outreach auth
       Runs browser-based OAuth (default uses FoundryOps HTTPS bridge).

    After successful authentication, credentials are saved to
    ~/.g_gremlin/outreach.json and can be used by other commands.

    \b
    Example (manual - recommended):
        g-gremlin outreach auth --manual
        g-gremlin outreach auth --manual --client-id X --client-secret Y --refresh-token Z

    \b
    Example (OAuth flow - advanced):
        g-gremlin outreach auth --redirect-uri https://your-tunnel.com/callback --port 8000
    """
    or_mod = _get_outreach_helpers()
    scopes = scopes_override.split() if scopes_override else None

    # Manual mode - just save credentials directly
    if manual:
        click.echo("\n=== Outreach Manual Credential Setup ===")
        click.echo("Enter your Outreach OAuth credentials.")
        click.echo("Get these from your Outreach admin or existing integration.\n")

        if not client_id:
            client_id = click.prompt("Client ID", type=str).strip()
        if not client_secret:
            client_secret = click.prompt("Client Secret", type=str, hide_input=True).strip()
        if not refresh_token:
            refresh_token = click.prompt("Refresh Token", type=str, hide_input=True).strip()

        or_mod.save_manual_credentials(
            client_id,
            client_secret,
            refresh_token,
            scope_profile=profile,
            scopes=scopes,
        )
        click.echo("\n✓ Credentials saved!")
        click.echo("\nTest with: g-gremlin outreach doctor")
        return

    # OAuth flow mode
    click.echo(
        "Warning: 'g-gremlin outreach auth' OAuth mode is deprecated. "
        "Use 'g-gremlin outreach oauth connect'.",
        err=True,
    )
    time.sleep(5)
    try:
        tokens = or_mod.authenticate(
            client_id=client_id,
            client_secret=client_secret,
            save_to_config=True,
            redirect_port=redirect_port,
            redirect_uri=redirect_uri,
            open_browser=not no_browser,
            scope_profile=profile,
            scopes=scopes,
        )
        click.echo("\n✓ Authentication successful!")
        click.echo(f"  Access token expires in: {tokens.get('expires_in', 7200)} seconds")
        click.echo(f"  Refresh token: {'obtained' if tokens.get('refresh_token') else 'not provided'}")
        click.echo("\nYou can now use other Outreach commands like:")
        click.echo("  g-gremlin outreach doctor")
        click.echo("  g-gremlin outreach sequences")
    except or_mod.OutreachError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except KeyboardInterrupt:
        click.echo("\nAuthentication cancelled.", err=True)
        sys.exit(EXIT_USAGE)


@outreach.command("sequences")
@click.option(
    "--all",
    "show_all",
    is_flag=True,
    help="Show all sequences including disabled.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Emit JSON output.",
)
def outreach_sequences(show_all: bool, profile: str, as_json: bool) -> None:
    """List Outreach sequences."""
    or_mod = _get_outreach_helpers()
    client = or_mod.OutreachClient()

    try:
        sequences = client.list_sequences(enabled_only=not show_all)
    except or_mod.OutreachError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if as_json:
        click.echo(json.dumps([s.to_dict() for s in sequences], indent=2))
        return

    if not sequences:
        click.echo("No sequences found.")
        return

    for seq in sequences:
        status = "enabled" if seq.enabled else "disabled"
        click.echo(f"{seq.id}: {seq.name} ({status}, {seq.step_count} steps)")


@outreach.command("enroll")
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV with prospects to enroll.",
)
@click.option(
    "--sequence-id",
    required=True,
    help="Target sequence ID.",
)
@click.option(
    "--email-column",
    default="email",
    show_default=True,
    help="Column containing email.",
)
@click.option(
    "--firstname-column",
    default="firstname",
    show_default=True,
    help="Column containing first name.",
)
@click.option(
    "--lastname-column",
    default="lastname",
    show_default=True,
    help="Column containing last name.",
)
@click.option(
    "--company-column",
    default="company_name",
    show_default=True,
    help="Column containing company name.",
)
@click.option(
    "--title-column",
    default="title",
    show_default=True,
    help="Column containing job title.",
)
@click.option(
    "--create/--no-create",
    default=True,
    show_default=True,
    help="Create prospects if not found in Outreach.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
@pacing_option
def outreach_enroll(
    csv_path: Path,
    sequence_id: str,
    email_column: str,
    firstname_column: str,
    lastname_column: str,
    company_column: str,
    title_column: str,
    create: bool,
    profile: str,
    json_summary: bool,
    safe_mode: bool,
) -> None:
    """Enroll prospects from a CSV file into an Outreach sequence."""
    or_mod = _get_outreach_helpers()

    click.echo(f"Reading prospects from {csv_path}...")
    click.echo(f"Target sequence: {sequence_id}")

    try:
        result = or_mod.enroll_from_csv(
            csv_path=str(csv_path),
            sequence_id=sequence_id,
            email_col=email_column,
            firstname_col=firstname_column,
            lastname_col=lastname_column,
            company_col=company_column,
            title_col=title_column,
            create_if_missing=create,
            pacing_profile=get_outreach_profile("safe" if safe_mode else "turbo"),
        )
    except or_mod.OutreachError as exc:
        click.echo(f"Enrollment failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    succeeded = len(result.succeeded)
    failed = len(result.failed)
    retryable = len(result.retryable_failed)

    click.echo(f"Enrolled: {succeeded}")
    click.echo(f"Failed: {failed}")
    if retryable:
        click.echo(f"Retryable failures: {retryable}")

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin outreach enroll", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status=result.status,
            dry_run=False,
            result={
                "sequence_id": sequence_id,
                "enrolled": succeeded,
                "failed": failed,
                "retryable_failed": retryable,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


# ---------------------------------------------------------------------------
# Google Docs helpers
# ---------------------------------------------------------------------------


@cli.group("gdocs")
def gdocs() -> None:
    """
    Google Docs commands (create, read, update, append).

    Create and manage Google Docs programmatically.
    Markdown is auto-detected and converted to Docs styles (headings, bold, italic,
    bullets, numbered lists, inline code, horizontal rules).
    """


def _emit_gdocs_dry_run_report(
    report: Dict[str, Any],
    json_output: bool,
) -> None:
    """Emit markdown dry-run diagnostics in JSON or ANSI text."""
    if json_output:
        click.echo(json.dumps(report, indent=2))
        return

    click.echo("Markdown dry-run report")
    click.echo(f"  mode: {report.get('mode')}")
    click.echo(f"  format: {report.get('format')}")
    click.echo(f"  is_markdown: {report.get('is_markdown')}")
    click.echo(f"  total_lines: {report.get('total_lines')}")
    click.echo("  block_counts:")
    for key, value in (report.get("block_counts") or {}).items():
        click.echo(f"    {key}: {value}")
    warnings = report.get("warnings") or []
    click.echo(f"  warnings: {len(warnings)}")
    for warning in warnings:
        click.echo(
            "    "
            f"L{warning.get('line', 0)}:{warning.get('column', 1)} "
            f"{warning.get('code', 'warning')} - {warning.get('message', '')}"
        )


def _build_gdocs_dry_run_report(
    gd: Any,
    content: str,
    content_format: str,
    markdown_mode: str,
) -> Dict[str, Any]:
    preview = gd.preview_markdown(
        content=content,
        content_format=content_format,
        markdown_mode=markdown_mode,
    )
    return {
        "mode": markdown_mode,
        "format": content_format,
        "is_markdown": preview.is_markdown,
        "total_lines": preview.total_lines,
        "block_counts": preview.block_counts,
        "warnings": [warning.to_dict() for warning in preview.warnings],
    }


@gdocs.command("create")
@click.argument("title")
@click.option(
    "--content",
    "content_file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="File containing initial content (text or markdown).",
)
@click.option(
    "--text",
    "inline_text",
    help="Inline text content (text or markdown).",
)
@click.option(
    "--format",
    "content_format",
    type=click.Choice(["auto", "markdown", "text"], case_sensitive=False),
    default="auto",
    show_default=True,
    help="Input interpretation mode.",
)
@click.option(
    "--markdown-mode",
    type=click.Choice(["strict", "extended"], case_sensitive=False),
    default="extended",
    show_default=True,
    help="Markdown parser mode used for markdown conversion.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview markdown parse diagnostics without creating the document.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gdocs_create(
    title: str,
    content_file: Optional[Path],
    inline_text: Optional[str],
    content_format: str,
    markdown_mode: str,
    dry_run: bool,
    json_output: bool,
) -> None:
    """Create a new Google Doc."""
    gd = _get_google_docs_helpers()

    content = None
    if content_file:
        content = content_file.read_text(encoding="utf-8")
    elif inline_text:
        content = inline_text

    if dry_run:
        try:
            report = _build_gdocs_dry_run_report(
                gd=gd,
                content=content or "",
                content_format=content_format,
                markdown_mode=markdown_mode,
            )
        except gd.GoogleDocsError as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        _emit_gdocs_dry_run_report(report, json_output=json_output)
        return

    try:
        doc = gd.create_doc(
            title,
            content,
            content_format=content_format,
            markdown_mode=markdown_mode,
        )
    except gd.GoogleDocsError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(doc.to_dict(), indent=2))
    else:
        click.echo(f"Created: {doc.title}")
        click.echo(f"ID: {doc.id}")
        click.echo(f"URL: {doc.url}")


@gdocs.command("read")
@click.argument("doc_id")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gdocs_read(doc_id: str, json_output: bool) -> None:
    """Read a Google Doc by ID or URL."""
    gd = _get_google_docs_helpers()

    try:
        doc = gd.read_doc(doc_id)
    except gd.GoogleDocsError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(doc.to_dict(), indent=2))
    else:
        click.echo(f"Title: {doc.title}")
        click.echo(f"ID: {doc.id}")
        click.echo(f"URL: {doc.url}")
        click.echo("---")
        click.echo(doc.body_content)


@gdocs.command("update")
@click.argument("doc_id")
@click.option(
    "--content",
    "content_file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="File containing new content (text or markdown).",
)
@click.option(
    "--text",
    "inline_text",
    help="Inline text content (text or markdown).",
)
@click.option(
    "--append/--replace",
    default=False,
    help="Append to existing content instead of replacing.",
)
@click.option(
    "--format",
    "content_format",
    type=click.Choice(["auto", "markdown", "text"], case_sensitive=False),
    default="auto",
    show_default=True,
    help="Input interpretation mode.",
)
@click.option(
    "--markdown-mode",
    type=click.Choice(["strict", "extended"], case_sensitive=False),
    default="extended",
    show_default=True,
    help="Markdown parser mode used for markdown conversion.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview markdown parse diagnostics without writing to the document.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gdocs_update(
    doc_id: str,
    content_file: Optional[Path],
    inline_text: Optional[str],
    append: bool,
    content_format: str,
    markdown_mode: str,
    dry_run: bool,
    json_output: bool,
) -> None:
    """Update a Google Doc's content."""
    gd = _get_google_docs_helpers()

    if not content_file and not inline_text:
        click.echo("Error: Provide --content or --text.", err=True)
        sys.exit(EXIT_USAGE)

    content = ""
    if content_file:
        content = content_file.read_text(encoding="utf-8")
    elif inline_text:
        content = inline_text

    if dry_run:
        try:
            report = _build_gdocs_dry_run_report(
                gd=gd,
                content=content,
                content_format=content_format,
                markdown_mode=markdown_mode,
            )
        except gd.GoogleDocsError as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        report["action"] = "append" if append else "replace"
        _emit_gdocs_dry_run_report(report, json_output=json_output)
        return

    try:
        if append:
            doc = gd.append_to_doc(
                doc_id,
                content,
                content_format=content_format,
                markdown_mode=markdown_mode,
            )
        else:
            doc = gd.update_doc(
                doc_id,
                content,
                replace=True,
                content_format=content_format,
                markdown_mode=markdown_mode,
            )
    except gd.GoogleDocsError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(doc.to_dict(), indent=2))
    else:
        action = "appended to" if append else "updated"
        click.echo(f"Successfully {action}: {doc.title}")
        click.echo(f"URL: {doc.url}")

    # Surface table warnings so users know about skipped tables
    for warning in getattr(doc, "table_warnings", []):
        click.echo(f"WARNING: {warning}", err=True)


@gdocs.command("append")
@click.argument("doc_id")
@click.option(
    "--content",
    "content_file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="File containing content to append (text or markdown).",
)
@click.option(
    "--text",
    "inline_text",
    help="Inline text to append (text or markdown).",
)
@click.option(
    "--format",
    "content_format",
    type=click.Choice(["auto", "markdown", "text"], case_sensitive=False),
    default="auto",
    show_default=True,
    help="Input interpretation mode.",
)
@click.option(
    "--markdown-mode",
    type=click.Choice(["strict", "extended"], case_sensitive=False),
    default="extended",
    show_default=True,
    help="Markdown parser mode used for markdown conversion.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview markdown parse diagnostics without writing to the document.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gdocs_append(
    doc_id: str,
    content_file: Optional[Path],
    inline_text: Optional[str],
    content_format: str,
    markdown_mode: str,
    dry_run: bool,
    json_output: bool,
) -> None:
    """Append content to a Google Doc."""
    gd = _get_google_docs_helpers()

    if not content_file and not inline_text:
        click.echo("Error: Provide --content or --text.", err=True)
        sys.exit(EXIT_USAGE)

    content = ""
    if content_file:
        content = content_file.read_text(encoding="utf-8")
    elif inline_text:
        content = inline_text

    if dry_run:
        try:
            report = _build_gdocs_dry_run_report(
                gd=gd,
                content=content,
                content_format=content_format,
                markdown_mode=markdown_mode,
            )
        except gd.GoogleDocsError as exc:
            click.echo(f"Error: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        report["action"] = "append"
        _emit_gdocs_dry_run_report(report, json_output=json_output)
        return

    try:
        doc = gd.append_to_doc(
            doc_id,
            content,
            content_format=content_format,
            markdown_mode=markdown_mode,
        )
    except gd.GoogleDocsError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(doc.to_dict(), indent=2))
    else:
        click.echo(f"Appended to: {doc.title}")
        click.echo(f"URL: {doc.url}")

    # Surface table warnings so users know about skipped tables
    for warning in getattr(doc, "table_warnings", []):
        click.echo(f"WARNING: {warning}", err=True)


# ---------------------------------------------------------------------------
# Google Slides helpers
# ---------------------------------------------------------------------------


@cli.group("gslides")
def gslides() -> None:
    """
    Google Slides commands (read, export, create).

    Read presentations, export them, or create new decks from markdown.
    """


@gslides.command("read")
@click.argument("presentation_id")
@click.option(
    "--markdown",
    "markdown_output",
    is_flag=True,
    help="Emit markdown output.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gslides_read(presentation_id: str, markdown_output: bool, json_output: bool) -> None:
    """Read a Google Slides presentation by ID or URL."""
    gs = _get_google_slides_helpers()

    try:
        presentation = gs.read_presentation(presentation_id)
    except gs.GoogleSlidesError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(presentation.to_dict(), indent=2))
        return

    if markdown_output:
        click.echo(presentation.to_markdown())
        return

    click.echo(f"Title: {presentation.title}")
    click.echo(f"ID: {presentation.id}")
    click.echo(f"URL: {presentation.url}")
    click.echo("---")
    click.echo(presentation.to_markdown())


@gslides.command("export")
@click.argument("presentation_id")
@click.option(
    "--format",
    "export_format",
    type=click.Choice(["pdf", "pptx", "png"], case_sensitive=False),
    default="pdf",
    show_default=True,
    help="Export format.",
)
@click.option(
    "-o",
    "--output",
    "output_path",
    type=click.Path(path_type=Path),
    help="Output file path (or directory for png).",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gslides_export(
    presentation_id: str,
    export_format: str,
    output_path: Optional[Path],
    json_output: bool,
) -> None:
    """Export a Google Slides presentation to PDF, PPTX, or PNGs."""
    gs = _get_google_slides_helpers()

    try:
        normalized_id = gs.extract_presentation_id(presentation_id)
    except gs.GoogleSlidesError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    client = gs.GoogleSlidesClient()
    export_format = export_format.lower()

    try:
        if export_format == "pdf":
            output = output_path or Path(f"{normalized_id}.pdf")
            result = client.export_pdf(presentation_id, output)
        elif export_format == "pptx":
            output = output_path or Path(f"{normalized_id}.pptx")
            result = client.export_pptx(presentation_id, output)
        else:
            output_dir = output_path or Path(f"{normalized_id}_slides")
            result = client.export_all_slides_png(presentation_id, output_dir)
    except gs.GoogleSlidesError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        if isinstance(result, list):
            payload: Dict[str, Any] = {
                "format": export_format,
                "output_dir": str(output_dir),
                "files": [str(path) for path in result],
            }
        else:
            payload = {
                "format": export_format,
                "output": str(result),
            }
        click.echo(json.dumps(payload, indent=2))
        return

    if isinstance(result, list):
        click.echo(f"Exported {len(result)} slide(s) to {output_dir}")
    else:
        click.echo(f"Exported to {result}")


@gslides.command("create")
@click.argument("title")
@click.option(
    "--content",
    "content_file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Markdown file for slide content.",
)
@click.option(
    "--text",
    "inline_text",
    help="Inline markdown content.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON output.",
)
def gslides_create(
    title: str,
    content_file: Optional[Path],
    inline_text: Optional[str],
    json_output: bool,
) -> None:
    """Create a Google Slides presentation from markdown."""
    gs = _get_google_slides_helpers()

    markdown = None
    if content_file:
        markdown = content_file.read_text(encoding="utf-8")
    elif inline_text:
        markdown = inline_text

    try:
        presentation = gs.create_presentation(title, markdown)
    except gs.GoogleSlidesError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(presentation.to_dict(), indent=2))
    else:
        click.echo(f"Created: {presentation.title}")
        click.echo(f"ID: {presentation.id}")
        click.echo(f"URL: {presentation.url}")


@gslides.group("chart")
def gslides_chart() -> None:
    """
    Embed Google Sheets charts into Slides.
    """


@gslides_chart.command("add")
@click.argument("presentation_id")
@click.option("--spreadsheet-id", required=True, help="Source spreadsheet ID.")
@click.option("--chart-id", required=True, type=int, help="Sheet chart ID.")
@click.option(
    "--slide-index",
    type=int,
    default=0,
    show_default=True,
    help="0-based slide index to insert into.",
)
@click.option(
    "--layout",
    type=click.Choice(["full", "left", "right"], case_sensitive=False),
    default="full",
    show_default=True,
    help="Chart layout on the slide.",
)
@click.option("--tag", help="Optional tag for replace matching.")
@click.option("--replace", is_flag=True, help="Replace existing element with matching tag.")
@click.option("--json", "json_output", is_flag=True, help="Emit JSON output.")
def gslides_chart_add(
    presentation_id: str,
    spreadsheet_id: str,
    chart_id: int,
    slide_index: int,
    layout: str,
    tag: Optional[str],
    replace: bool,
    json_output: bool,
) -> None:
    """
    Insert a linked Sheets chart into a slide.
    """
    gs = _get_google_slides_helpers()

    try:
        result = gs.insert_linked_chart(
            presentation_id,
            spreadsheet_id,
            chart_id,
            slide_index=slide_index,
            layout=layout,
            replace=replace,
            tag=tag,
        )
    except gs.GoogleSlidesError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        click.echo(json.dumps(result, indent=2))
        return

    replaced = result.get("replaced", 0)
    message = f"Inserted chart {chart_id} on slide {slide_index}."
    if replaced:
        message = f"Replaced {replaced} element(s). " + message
    click.echo(message)


# ---------------------------------------------------------------------------
# Pub/Sub helpers
# ---------------------------------------------------------------------------


@cli.group()
def pubsub() -> None:
    """
    Pub/Sub helpers (publish, pull).
    """


@pubsub.command("publish")
@click.option(
    "--topic",
    required=True,
    help="Topic name or full resource path.",
)
@click.option(
    "--message",
    "messages",
    multiple=True,
    help="Message payload (repeatable).",
)
@click.option(
    "--json-input",
    is_flag=True,
    help="Parse message payloads as JSON.",
)
@click.option(
    "--attribute",
    "attributes",
    multiple=True,
    help="Message attribute key=value (repeatable).",
)
@click.option(
    "--ordering-key",
    default=None,
    help="Ordering key for ordered delivery.",
)
@click.option(
    "--project-id",
    default=None,
    help="Override GCP project ID.",
)
@click.option(
    "--credentials-path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Service account JSON path (optional).",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Emit JSON output.",
)
def pubsub_publish(
    topic: str,
    messages: tuple[str, ...],
    json_input: bool,
    attributes: tuple[str, ...],
    ordering_key: Optional[str],
    project_id: Optional[str],
    credentials_path: Optional[Path],
    as_json: bool,
) -> None:
    """
    Publish one or more messages to a Pub/Sub topic.
    """
    if not messages:
        click.echo("Error: Provide at least one --message.", err=True)
        sys.exit(EXIT_USAGE)

    attrs: Dict[str, str] = {}
    for item in attributes:
        if "=" not in item:
            click.echo(f"Invalid attribute '{item}'. Expected key=value.", err=True)
            sys.exit(EXIT_USAGE)
        key, value = item.split("=", 1)
        attrs[key] = value

    payloads: List[Any] = []
    for msg in messages:
        if json_input:
            try:
                payloads.append(json.loads(msg))
            except json.JSONDecodeError as exc:
                click.echo(f"Invalid JSON message: {exc}", err=True)
                sys.exit(EXIT_USAGE)
        else:
            payloads.append(msg)

    helper = _get_pubsub_helpers()
    try:
        client = helper.PubSubClient(
            project_id=project_id,
            credentials_path=str(credentials_path) if credentials_path else None,
        )
        message_ids = client.publish(
            topic,
            payloads,
            attributes=attrs or None,
            ordering_key=ordering_key,
        )
    except Exception as exc:
        click.echo(f"Publish failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if as_json:
        click.echo(json.dumps({"message_ids": message_ids}, indent=2))
    else:
        click.echo(f"Published {len(message_ids)} message(s).")
        if message_ids:
            click.echo(", ".join(message_ids))


@pubsub.command("pull")
@click.option(
    "--subscription",
    required=True,
    help="Subscription name or full resource path.",
)
@click.option(
    "--max-messages",
    type=int,
    default=10,
    show_default=True,
    help="Maximum messages to pull.",
)
@click.option(
    "--ack",
    is_flag=True,
    help="Acknowledge messages after pull.",
)
@click.option(
    "--decode/--raw",
    default=True,
    show_default=True,
    help="Decode message data from base64.",
)
@click.option(
    "--project-id",
    default=None,
    help="Override GCP project ID.",
)
@click.option(
    "--credentials-path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Service account JSON path (optional).",
)
@click.option(
    "--json",
    "as_json",
    is_flag=True,
    help="Emit JSON output.",
)
def pubsub_pull(
    subscription: str,
    max_messages: int,
    ack: bool,
    decode: bool,
    project_id: Optional[str],
    credentials_path: Optional[Path],
    as_json: bool,
) -> None:
    """
    Pull messages from a Pub/Sub subscription.
    """
    helper = _get_pubsub_helpers()
    try:
        client = helper.PubSubClient(
            project_id=project_id,
            credentials_path=str(credentials_path) if credentials_path else None,
        )
        if ack:
            messages = client.pull_and_ack(
                subscription,
                max_messages=max_messages,
                decode=decode,
            )
        else:
            messages = client.pull(
                subscription,
                max_messages=max_messages,
                decode=decode,
            )
    except Exception as exc:
        click.echo(f"Pull failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if as_json:
        click.echo(json.dumps(messages, indent=2))
        return

    if not messages:
        click.echo("No messages.")
        return

    for msg in messages:
        msg_id = msg.get("message_id") or "(unknown)"
        click.echo(f"{msg_id}: {msg.get('data')}")
        if msg.get("attributes"):
            click.echo(json.dumps(msg.get("attributes"), indent=2))


# ---------------------------------------------------------------------------
# Docs sinks (Notion, Outline, Confluence, Word)
# ---------------------------------------------------------------------------


@cli.group()
def docs() -> None:
    """
    Documentation sinks (Notion, Outline, Confluence, Word).

    Note: Docs publishing commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.

    Word provider writes .docx files locally (no credentials needed).
    Save to your OneDrive/SharePoint sync folder for M365 integration.
    """
    # Licensed command group - check for valid license
    _check_license()


def _parse_markdown_frontmatter(markdown_text: str) -> tuple[Dict[str, Any], str]:
    if not markdown_text:
        return {}, ""
    lines = markdown_text.splitlines()
    if not lines or lines[0].strip() != "---":
        return {}, markdown_text

    for idx in range(1, len(lines)):
        if lines[idx].strip() == "---":
            frontmatter_text = "\n".join(lines[1:idx])
            body = "\n".join(lines[idx + 1 :])
            try:
                import yaml

                data = yaml.safe_load(frontmatter_text) or {}
                if not isinstance(data, dict):
                    data = {}
            except Exception:
                data = {}
            return data, body
    return {}, markdown_text


def _extract_title_from_markdown(markdown_text: str) -> Optional[str]:
    for line in markdown_text.splitlines():
        if line.startswith("# "):
            return line[2:].strip()
    return None


def _normalize_tags(value: Any) -> List[str]:
    if value is None:
        return []
    if isinstance(value, list):
        return [str(v).strip() for v in value if str(v).strip()]
    if isinstance(value, str):
        return [v.strip() for v in value.split(",") if v.strip()]
    return [str(value).strip()]


_DOCS_MARKDOWN_SUFFIXES = {".md", ".markdown", ".mmd", ".mermaid"}
_DOCS_MERMAID_SUFFIXES = {".mmd", ".mermaid"}
_DOCS_FRONTMATTER_CONTROL_KEYS = {
    "title",
    "external_id",
    "id",
    "tags",
    "labels",
    "database_id",
    "database",
    "databaseId",
    "space_key",
    "space",
    "spaceKey",
    "parent_id",
    "parent",
    "parentId",
    "collection_id",
    "collection",
    "collectionId",
}


def _discover_docs_markdown_files(markdown_dir: Path, recursive: bool) -> List[Path]:
    iterator = markdown_dir.rglob("*") if recursive else markdown_dir.glob("*")
    files = [path for path in iterator if path.is_file() and path.suffix.lower() in _DOCS_MARKDOWN_SUFFIXES]
    return sorted(files)


def _default_title_from_path(path: Path) -> str:
    base = re.sub(r"[_\-]+", " ", path.stem).strip()
    return base or "Untitled"


def _default_external_id_for_path(path: Path, root: Optional[Path] = None) -> str:
    if root:
        try:
            rel = path.relative_to(root)
        except ValueError:
            rel = path
    else:
        rel = path
    return rel.with_suffix("").as_posix()


def _normalize_docs_markdown_content(source_path: Optional[Path], content: str) -> str:
    if not source_path or source_path.suffix.lower() not in _DOCS_MERMAID_SUFFIXES:
        return content
    stripped = content.strip()
    if not stripped:
        return "```mermaid\n```"
    if stripped.startswith("```"):
        return content
    return f"```mermaid\n{content.rstrip()}\n```"


def _space_key_from_frontmatter(frontmatter: Mapping[str, Any]) -> Optional[str]:
    value = (
        frontmatter.get("space_key")
        or frontmatter.get("space")
        or frontmatter.get("spaceKey")
    )
    return str(value).strip() if value else None


def _parent_id_from_frontmatter(frontmatter: Mapping[str, Any]) -> Optional[str]:
    value = (
        frontmatter.get("parent_id")
        or frontmatter.get("parent")
        or frontmatter.get("parentId")
    )
    return str(value).strip() if value else None


def _collection_id_from_frontmatter(frontmatter: Mapping[str, Any]) -> Optional[str]:
    value = (
        frontmatter.get("collection_id")
        or frontmatter.get("collection")
        or frontmatter.get("collectionId")
    )
    return str(value).strip() if value else None


def _database_id_from_frontmatter(frontmatter: Mapping[str, Any]) -> Optional[str]:
    value = (
        frontmatter.get("database_id")
        or frontmatter.get("database")
        or frontmatter.get("databaseId")
    )
    return str(value).strip() if value else None


def _frontmatter_properties(frontmatter: Mapping[str, Any]) -> Dict[str, Any]:
    return {
        key: value
        for key, value in frontmatter.items()
        if key not in _DOCS_FRONTMATTER_CONTROL_KEYS
    }


@docs.command("doctor")
@click.option(
    "--provider",
    type=click.Choice(["notion", "outline", "confluence", "word"], case_sensitive=False),
    required=True,
    help="Docs provider to validate.",
)
@click.option(
    "--output-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Output directory for Word files (required for word provider).",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def docs_doctor(provider: str, output_dir: Optional[Path], profile: str, json_output: bool) -> None:
    """Validate docs provider credentials."""
    cred_provider = _get_credential_provider()
    sink_helpers = _get_docs_sink_helpers()

    if provider.lower() == "word":
        # Word provider - no credentials needed, just validate output directory
        if not output_dir:
            click.echo("Error: --output-dir required for word provider.", err=True)
            sys.exit(EXIT_USAGE)

        from g_gremlin.word_sink import WordSinkProvider

        client = WordSinkProvider(output_dir=output_dir)
        status = client.test_connection()

        click.echo("Provider: Word (local .docx)")
        click.echo(f"Output directory: {output_dir}")
        click.echo(f"Writable: {status.get('writable', False)}")
        click.echo(f"Status: {status.get('status', 'unknown')}")

        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs doctor", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success" if status.get("status") == "ok" else "fail",
                dry_run=False,
                result={
                    "provider": "word",
                    "output_dir": str(output_dir),
                    "writable": status.get("writable", False),
                    "status": status.get("status"),
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider.lower() == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Error: Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_db = None
        try:
            default_db = cred_provider.get_secret("notion", "default_database_id", profile).value
        except Exception:
            default_db = None

        client = sink_helpers.NotionClient(token=token, database_id=default_db)
        try:
            status = client.test_connection()
        except Exception as exc:
            click.echo(f"Notion connection failed: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        schema_summary: Dict[str, Any] = {}
        if default_db:
            try:
                schema = client.get_database_schema(default_db)
            except Exception as exc:
                click.echo(f"Notion database schema lookup failed: {exc}", err=True)
                sys.exit(EXIT_REMOTE_ERROR)
            schema_summary = {
                "title_property": schema.get("title_property"),
                "external_id_property": schema.get("external_id_property"),
                "tags_property": schema.get("tags_property"),
                "property_count": len(schema.get("properties") or {}),
            }

        click.echo(f"Provider: Notion (profile {profile})")
        click.echo("Token: configured")
        click.echo(f"Default database: {default_db or '(not set)'}")
        click.echo(f"Status: {status.get('status', 'unknown')}")
        if schema_summary:
            click.echo(f"Title property: {schema_summary.get('title_property') or '(missing)'}")
            click.echo(f"External ID:    {schema_summary.get('external_id_property') or '(missing)'}")
            click.echo(f"Tags property:  {schema_summary.get('tags_property') or '(not found)'}")
            click.echo(f"Property count: {schema_summary.get('property_count')}")

        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs doctor", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={
                    "provider": "notion",
                    "profile": profile,
                    "default_database": default_db,
                    "status": status.get("status"),
                    "schema": schema_summary or None,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider.lower() == "confluence":
        try:
            api_base = cred_provider.get_secret("confluence", "api_base", profile).value
        except Exception:
            click.echo(f"Error: Confluence api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            email = cred_provider.get_secret("confluence", "email", profile).value
        except Exception:
            click.echo(f"Error: Confluence email not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_token = cred_provider.get_secret("confluence", "api_token", profile).value
        except Exception:
            click.echo(f"Error: Confluence api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_space = None
        try:
            default_space = cred_provider.get_secret("confluence", "default_space_key", profile).value
        except Exception:
            default_space = None

        default_parent = None
        try:
            default_parent = cred_provider.get_secret("confluence", "default_parent_id", profile).value
        except Exception:
            default_parent = None

        client = sink_helpers.ConfluenceClient(
            api_base=api_base,
            email=email,
            api_token=api_token,
            default_space_key=default_space,
            default_parent_id=default_parent,
        )
        try:
            status = client.test_connection()
        except Exception as exc:
            click.echo(f"Confluence connection failed: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        click.echo(f"Provider: Confluence (profile {profile})")
        click.echo("Auth: configured")
        click.echo(f"API base: {api_base}")
        click.echo(f"Default space: {default_space or '(not set)'}")
        click.echo(f"Default parent: {default_parent or '(not set)'}")
        click.echo(f"Status: {status.get('status', 'unknown')}")

        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs doctor", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={
                    "provider": "confluence",
                    "profile": profile,
                    "api_base": api_base,
                    "default_space": default_space,
                    "default_parent": default_parent,
                    "status": status.get("status"),
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    # Outline
    try:
        token = cred_provider.get_secret("outline", "api_token", profile).value
    except Exception:
        click.echo(f"Error: Outline api_token not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        api_base = cred_provider.get_secret("outline", "api_base", profile).value
    except Exception:
        click.echo(f"Error: Outline api_base not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    default_collection = None
    try:
        default_collection = cred_provider.get_secret("outline", "default_collection_id", profile).value
    except Exception:
        default_collection = None

    client = sink_helpers.OutlineClient(
        api_token=token,
        api_base=api_base,
        default_collection_id=default_collection,
    )
    try:
        status = client.test_connection()
    except Exception as exc:
        click.echo(f"Outline connection failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    click.echo(f"Provider: Outline (profile {profile})")
    click.echo("Token: configured")
    click.echo(f"API base: {api_base}")
    click.echo(f"Default collection: {default_collection or '(not set)'}")
    click.echo(f"Status: {status.get('status', 'unknown')}")

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin docs doctor", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "provider": "outline",
                "profile": profile,
                "api_base": api_base,
                "default_collection": default_collection,
                "status": status.get("status"),
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@docs.command("collections")
@click.option(
    "--provider",
    type=click.Choice(["outline", "notion"], case_sensitive=False),
    required=True,
    help="Docs provider.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def docs_collections(provider: str, profile: str, json_output: bool) -> None:
    """List available collections for a docs provider."""
    cred_provider = _get_credential_provider()
    sink_helpers = _get_docs_sink_helpers()

    if provider.lower() == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.NotionClient(token=token)

        try:
            databases = client.list_databases(limit=25)
        except Exception as exc:
            click.echo(f"Failed to list databases: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs collections", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={"provider": "notion", "databases": databases},
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
            return

        if not databases:
            click.echo("No databases found.")
            return

        click.echo(f"{'Name':<40} {'Edited':<22} {'ID'}")
        click.echo("-" * 100)
        for database in databases:
            name = (database.get("title") or "")[:39]
            edited = (database.get("last_edited_time") or "")[:21]
            database_id = database.get("id", "")
            click.echo(f"{name:<40} {edited:<22} {database_id}")
        return

    try:
        api_token = cred_provider.get_secret("outline", "api_token", profile).value
    except Exception:
        click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        api_base = cred_provider.get_secret("outline", "api_base", profile).value
    except Exception:
        click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    client = sink_helpers.OutlineClient(api_token=api_token, api_base=api_base)

    try:
        collections = client.list_collections()
    except Exception as exc:
        click.echo(f"Failed to list collections: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin docs collections", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={"provider": "outline", "collections": collections},
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if not collections:
        click.echo("No collections found.")
        return

    click.echo(f"{'Name':<30} {'URL ID':<15} {'UUID'}")
    click.echo("-" * 80)
    for col in collections:
        name = col.get("name", "")[:29]
        url_id = col.get("urlId", "")[:14]
        uuid = col.get("id", "")
        click.echo(f"{name:<30} {url_id:<15} {uuid}")


def _parse_outline_doc_id(value: str) -> str:
    """Extract document urlId from an Outline URL or pass through bare IDs."""
    import re as _re

    value = value.strip()
    url_match = _re.match(r"https?://.+/doc/(.+?)/?$", value)
    if url_match:
        slug_path = url_match.group(1)
        parts = slug_path.split("-")
        return parts[-1]
    return value


def _parse_notion_page_id(value: str) -> str:
    """Extract a Notion page UUID from a URL or pass through bare IDs."""
    value = value.strip()
    match = re.search(
        r"([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})",
        value,
    )
    if not match:
        return value
    raw = match.group(1).replace("-", "")
    return f"{raw[0:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:32]}"


@docs.command("fetch")
@click.argument("document", required=True)
@click.option(
    "--provider",
    type=click.Choice(["outline", "notion"], case_sensitive=False),
    default="outline",
    show_default=True,
    help="Docs provider.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Save fetched content to file.",
)
@click.option(
    "--metadata",
    is_flag=True,
    help="Include document metadata (revision, dates, collection) in output.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit full document as JSON.",
)
def docs_fetch(
    document: str,
    provider: str,
    profile: str,
    output_path: Optional[Path],
    metadata: bool,
    json_output: bool,
) -> None:
    """Fetch a document by URL or ID."""
    cred_provider = _get_credential_provider()
    sink_helpers = _get_docs_sink_helpers()

    provider_lower = provider.lower()

    if provider_lower == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.NotionClient(token=token)
        page_id = _parse_notion_page_id(document)

        try:
            page = client.get_page(page_id)
            blocks = client.get_page_content(page_id)
        except Exception as exc:
            click.echo(f"Failed to fetch document: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        page_summary = client.simplify_page(page)
        text = client.blocks_to_plain_text(blocks)

        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs fetch", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={
                    **page_summary,
                    "content": blocks,
                    "block_count": len(blocks),
                    "text": text,
                    "word_count": len(text.split()),
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
            return

        if output_path:
            output_path.parent.mkdir(parents=True, exist_ok=True)
            output_path.write_text(text, encoding="utf-8")
            click.echo(f"Saved {len(text)} chars to {output_path}")
            return

        if metadata:
            click.echo(f"Title:      {page_summary.get('title')}")
            click.echo(f"ID:         {page_summary.get('id')}")
            click.echo(f"Database:   {page_summary.get('database_id')}")
            click.echo(f"Created:    {page_summary.get('created_time')}")
            click.echo(f"Updated:    {page_summary.get('last_edited_time')}")
            click.echo(f"Words:      {len(text.split())}")
            click.echo("---")

        click.echo(text)
        return

    try:
        api_token = cred_provider.get_secret("outline", "api_token", profile).value
    except Exception:
        click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        api_base = cred_provider.get_secret("outline", "api_base", profile).value
    except Exception:
        click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    client = sink_helpers.OutlineClient(api_token=api_token, api_base=api_base)
    doc_id = _parse_outline_doc_id(document)

    try:
        doc = client.get_document(doc_id)
    except Exception as exc:
        click.echo(f"Failed to fetch document: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    text = doc.get("text", "")

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin docs fetch", remote_mutation=False)
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "id": doc.get("id"),
                "title": doc.get("title"),
                "urlId": doc.get("urlId"),
                "collectionId": doc.get("collectionId"),
                "revision": doc.get("revision"),
                "createdAt": doc.get("createdAt"),
                "updatedAt": doc.get("updatedAt"),
                "createdBy": doc.get("createdBy", {}).get("name")
                if isinstance(doc.get("createdBy"), dict)
                else None,
                "updatedBy": doc.get("updatedBy", {}).get("name")
                if isinstance(doc.get("updatedBy"), dict)
                else None,
                "text": text,
                "word_count": len(text.split()),
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if output_path:
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(text, encoding="utf-8")
        click.echo(f"Saved {len(text)} chars to {output_path}")
        return

    if metadata:
        click.echo(f"Title:      {doc.get('title')}")
        click.echo(f"ID:         {doc.get('id')}")
        click.echo(f"URL ID:     {doc.get('urlId')}")
        click.echo(f"Collection: {doc.get('collectionId')}")
        click.echo(f"Revision:   {doc.get('revision')}")
        click.echo(f"Created:    {doc.get('createdAt')}")
        click.echo(f"Updated:    {doc.get('updatedAt')}")
        click.echo(f"Words:      {len(text.split())}")
        click.echo("---")

    click.echo(text)


@docs.command("search")
@click.argument("query", required=True)
@click.option(
    "--provider",
    type=click.Choice(["outline", "notion"], case_sensitive=False),
    default="outline",
    show_default=True,
    help="Docs provider.",
)
@click.option(
    "--collection-id",
    help="Scope search to a specific Outline collection.",
)
@click.option(
    "--database-id",
    help="Scope search to a specific Notion database.",
)
@click.option(
    "--limit",
    type=int,
    default=25,
    show_default=True,
    help="Maximum results to return.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def docs_search(
    query: str,
    provider: str,
    collection_id: Optional[str],
    database_id: Optional[str],
    limit: int,
    profile: str,
    json_output: bool,
) -> None:
    """Search docs by keyword."""
    cred_provider = _get_credential_provider()
    sink_helpers = _get_docs_sink_helpers()
    provider_lower = provider.lower()

    if provider_lower == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_database = None
        try:
            default_database = cred_provider.get_secret("notion", "default_database_id", profile).value
        except Exception:
            pass

        effective_database = database_id or default_database
        client = sink_helpers.NotionClient(token=token, database_id=effective_database)

        try:
            results = client.search_pages(query, database_id=effective_database, limit=limit)
        except Exception as exc:
            click.echo(f"Search failed: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        documents = [client.simplify_page(page) for page in results]
        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs search", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={
                    "provider": "notion",
                    "query": query,
                    "database_id": effective_database,
                    "count": len(documents),
                    "documents": documents,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
            return

        if not documents:
            click.echo("No documents found.")
            return

        click.echo(f"{'Title':<40} {'Updated':<22} {'ID'}")
        click.echo("-" * 100)
        for document in documents:
            title = (document.get("title") or "")[:39]
            updated = (document.get("last_edited_time") or "")[:21]
            doc_id = document.get("id", "")
            click.echo(f"{title:<40} {updated:<22} {doc_id}")
        return

    try:
        api_token = cred_provider.get_secret("outline", "api_token", profile).value
    except Exception:
        click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        api_base = cred_provider.get_secret("outline", "api_base", profile).value
    except Exception:
        click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    default_collection = None
    try:
        default_collection = cred_provider.get_secret("outline", "default_collection_id", profile).value
    except Exception:
        pass

    client = sink_helpers.OutlineClient(
        api_token=api_token,
        api_base=api_base,
        default_collection_id=default_collection,
    )

    try:
        results = client.search_documents(query, collection_id=collection_id, limit=limit)
    except Exception as exc:
        click.echo(f"Search failed: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin docs search", remote_mutation=False)
        documents = []
        for item in results:
            d = item.get("document") or item
            documents.append(
                {
                    "id": d.get("id"),
                    "title": d.get("title"),
                    "urlId": d.get("urlId"),
                    "collectionId": d.get("collectionId"),
                    "updatedAt": d.get("updatedAt"),
                    "ranking": item.get("ranking"),
                    "context": item.get("context"),
                }
            )
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={"provider": "outline", "query": query, "count": len(documents), "documents": documents},
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if not results:
        click.echo("No documents found.")
        return

    click.echo(f"{'Title':<40} {'Updated':<22} {'ID'}")
    click.echo("-" * 90)
    for item in results:
        d = item.get("document") or item
        title = (d.get("title") or "")[:39]
        updated = (d.get("updatedAt") or "")[:21]
        doc_id = d.get("id", "")
        click.echo(f"{title:<40} {updated:<22} {doc_id}")


@docs.command("list")
@click.option(
    "--provider",
    type=click.Choice(["outline", "notion"], case_sensitive=False),
    default="outline",
    show_default=True,
    help="Docs provider.",
)
@click.option(
    "--collection-id",
    help="Outline collection to list (uses default if not specified).",
)
@click.option(
    "--database-id",
    help="Notion database to list (uses default if not specified).",
)
@click.option(
    "--limit",
    type=int,
    default=25,
    show_default=True,
    help="Maximum documents to return.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Emit JSON summary.",
)
def docs_list(
    provider: str,
    collection_id: Optional[str],
    database_id: Optional[str],
    limit: int,
    profile: str,
    json_output: bool,
) -> None:
    """List documents for a docs provider."""
    cred_provider = _get_credential_provider()
    sink_helpers = _get_docs_sink_helpers()
    provider_lower = provider.lower()

    if provider_lower == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_database = None
        try:
            default_database = cred_provider.get_secret("notion", "default_database_id", profile).value
        except Exception:
            pass

        effective_database = database_id or default_database
        if not effective_database:
            click.echo("Provide --database-id or configure notion.default_database_id.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.NotionClient(token=token, database_id=effective_database)

        try:
            documents = client.query_database_pages(database_id=effective_database, limit=limit)
        except Exception as exc:
            click.echo(f"Failed to list documents: {exc}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

        doc_summaries = [client.simplify_page(page) for page in documents]
        if json_output:
            safety_ctx = SafetyContext(command="g-gremlin docs list", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=False,
                result={
                    "provider": "notion",
                    "database_id": effective_database,
                    "count": len(doc_summaries),
                    "documents": doc_summaries,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
            return

        if not doc_summaries:
            click.echo("No documents found.")
            return

        click.echo(f"{'Title':<40} {'Updated':<22} {'ID'}")
        click.echo("-" * 100)
        for document in doc_summaries:
            title = (document.get("title") or "")[:39]
            updated = (document.get("last_edited_time") or "")[:21]
            doc_id = document.get("id", "")
            click.echo(f"{title:<40} {updated:<22} {doc_id}")
        return

    try:
        api_token = cred_provider.get_secret("outline", "api_token", profile).value
    except Exception:
        click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    try:
        api_base = cred_provider.get_secret("outline", "api_base", profile).value
    except Exception:
        click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    default_collection = None
    try:
        default_collection = cred_provider.get_secret("outline", "default_collection_id", profile).value
    except Exception:
        pass

    effective_collection = collection_id or default_collection

    client = sink_helpers.OutlineClient(
        api_token=api_token,
        api_base=api_base,
        default_collection_id=effective_collection,
    )

    try:
        documents = client.list_documents(collection_id=effective_collection, limit=limit)
    except Exception as exc:
        click.echo(f"Failed to list documents: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if json_output:
        safety_ctx = SafetyContext(command="g-gremlin docs list", remote_mutation=False)
        doc_summaries = []
        for d in documents:
            doc_summaries.append(
                {
                    "id": d.get("id"),
                    "title": d.get("title"),
                    "urlId": d.get("urlId"),
                    "collectionId": d.get("collectionId"),
                    "revision": d.get("revision"),
                    "updatedAt": d.get("updatedAt"),
                }
            )
        agentic = safety_ctx.build_result(
            status="success",
            dry_run=False,
            result={
                "collection_id": effective_collection,
                "count": len(doc_summaries),
                "documents": doc_summaries,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if not documents:
        click.echo("No documents found.")
        return

    click.echo(f"{'Title':<40} {'Rev':<6} {'Updated':<22} {'ID'}")
    click.echo("-" * 100)
    for d in documents:
        title = (d.get("title") or "")[:39]
        revision = str(d.get("revision", ""))[:5]
        updated = (d.get("updatedAt") or "")[:21]
        doc_id = d.get("id", "")
        click.echo(f"{title:<40} {revision:<6} {updated:<22} {doc_id}")


@docs.command("template")
@click.option(
    "--init",
    "init_template",
    is_flag=True,
    help="Initialize a sample intent brief template.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    required=True,
    help="Destination for the template.",
)
def docs_template(init_template: bool, output_path: Path) -> None:
    """Initialize a Markdown template for docs publishing."""
    if not init_template:
        click.echo("Use --init to create a starter template.")
        return

    sample = """## Intent Brief: {{ company }}

- Signal Type: {{ signal_type }}
- Intent Score: {{ intent_score }}
- Timestamp: {{ timestamp }}
- Person: {{ person_name }} ({{ person_email }})

### Summary
{{ summary or 'Add context here.' }}
"""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(sample, encoding="utf-8")
    click.echo(f"Wrote template to {output_path}")


@docs.command("publish")
@click.option(
    "--provider",
    type=click.Choice(["notion", "outline", "confluence", "word"], case_sensitive=False),
    required=True,
    help="Docs provider.",
)
@click.option(
    "--output-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Output directory for Word files (required for word provider).",
)
@click.option(
    "--database-id",
    help="Notion database ID.",
)
@click.option(
    "--collection-id",
    help="Outline collection ID.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile for docs provider.",
)
@click.option(
    "--template",
    "template_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Jinja2 template path for content.",
)
@click.option(
    "--data",
    "data_json",
    help="JSON payload for template variables.",
)
@click.option(
    "--markdown-file",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Markdown/Mermaid file to publish (Notion, Confluence, and Outline).",
)
@click.option(
    "--markdown",
    "markdown_text",
    help="Inline markdown content (Notion, Confluence, and Outline).",
)
@click.option(
    "--external-id",
    help="External ID for idempotent upsert.",
)
@click.option(
    "--space-key",
    help="Confluence space key.",
)
@click.option(
    "--parent-id",
    help="Confluence parent page ID.",
)
@click.option(
    "--title",
    help="Override page title (defaults to rendered title from template data).",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the upsert (default: dry-run).",
)
def docs_publish(
    provider: str,
    output_dir: Optional[Path],
    database_id: Optional[str],
    collection_id: Optional[str],
    profile: str,
    template_path: Optional[Path],
    data_json: Optional[str],
    markdown_file: Optional[Path],
    markdown_text: Optional[str],
    external_id: Optional[str],
    space_key: Optional[str],
    parent_id: Optional[str],
    title: Optional[str],
    json_summary: bool,
    apply: bool,
) -> None:
    """Publish a single page to the docs provider."""
    from jinja2 import Template

    sink_helpers = _get_docs_sink_helpers()
    cred_provider = _get_credential_provider()

    provider_lower = provider.lower()
    use_markdown = bool(markdown_file or markdown_text)
    data: Dict[str, Any] = {}

    if markdown_file and markdown_text:
        click.echo("Error: Provide only one of --markdown-file or --markdown.", err=True)
        sys.exit(EXIT_USAGE)

    if use_markdown and provider_lower not in ("notion", "confluence", "outline"):
        click.echo("Error: --markdown-file/--markdown supported only for notion, confluence, and outline providers.", err=True)
        sys.exit(EXIT_USAGE)

    if use_markdown:
        if template_path or data_json:
            click.echo("Error: --template/--data cannot be used with --markdown-file/--markdown.", err=True)
            sys.exit(EXIT_USAGE)
        raw_markdown_text = markdown_file.read_text(encoding="utf-8") if markdown_file else (markdown_text or "")
        frontmatter, content = _parse_markdown_frontmatter(raw_markdown_text)
        content = _normalize_docs_markdown_content(markdown_file, content)
        data = frontmatter.copy()
        tags = _normalize_tags(frontmatter.get("tags") or frontmatter.get("labels"))
        database_id = database_id or _database_id_from_frontmatter(frontmatter)
        space_key = space_key or _space_key_from_frontmatter(frontmatter)
        parent_id = parent_id or _parent_id_from_frontmatter(frontmatter)
        collection_id = collection_id or _collection_id_from_frontmatter(frontmatter)
        external_id = external_id or frontmatter.get("external_id") or frontmatter.get("id")
        if not external_id and markdown_file:
            external_id = _default_external_id_for_path(markdown_file)
        page_title = (
            title
            or frontmatter.get("title")
            or _extract_title_from_markdown(content)
            or (_default_title_from_path(markdown_file) if markdown_file else "Untitled")
        )
    else:
        if not template_path or not data_json:
            click.echo("Error: --template and --data required unless using --markdown-file/--markdown.", err=True)
            sys.exit(EXIT_USAGE)
        try:
            data = json.loads(data_json)
        except Exception as exc:
            click.echo(f"Invalid --data JSON: {exc}", err=True)
            sys.exit(EXIT_USAGE)
        template_text = template_path.read_text(encoding="utf-8")
        content = Template(template_text).render(**data)
        page_title = title or data.get("title") or data.get("company") or "Intent Signal"
        tags = _normalize_tags(data.get("tags"))
        if provider_lower == "confluence":
            space_key = space_key or data.get("space_key") or data.get("space")
            parent_id = parent_id or data.get("parent_id") or data.get("parent")
        if not external_id:
            external_id = data.get("external_id") or data.get("id")

    if not external_id:
        click.echo("Error: --external-id required for idempotent upsert.", err=True)
        sys.exit(EXIT_USAGE)
    external_id = str(external_id)

    target_db: Optional[str] = None
    target_collection: Optional[str] = None

    # Word provider - local .docx output
    if provider_lower == "word":
        if not output_dir:
            click.echo("Error: --output-dir required for word provider.", err=True)
            sys.exit(EXIT_USAGE)

        from g_gremlin.word_sink import WordSinkProvider

        client = WordSinkProvider(output_dir=output_dir)
        page = sink_helpers.DocsPage(
            external_id=external_id,
            title=page_title,
            content_markdown=content,
            parent_id=None,
            properties={k: v for k, v in data.items() if k not in {"content", "title"}},
            tags=tags,
        )
        dry_run_target = f"directory: {output_dir}"

        if not apply:
            click.echo(
                f"[DRY-RUN] Would publish page '{page_title}' to word provider "
                f"({dry_run_target})"
            )
            if json_summary:
                safety_ctx = SafetyContext(command="g-gremlin docs publish", remote_mutation=False)
                agentic = safety_ctx.build_result(
                    status="success",
                    dry_run=True,
                    result={
                        "provider": "word",
                        "output_dir": str(output_dir),
                        "external_id": external_id,
                        "title": page_title,
                    },
                )
                click.echo(json.dumps(agentic.to_dict(), indent=2))
            return

        result = client.upsert_page(page)
        click.echo(
            f"{result.status.capitalize()} page {result.external_id} -> {result.page_url or result.page_id or 'unknown'}"
        )

        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin docs publish", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status=result.status if result.status in {"created", "updated"} else "partial",
                dry_run=False,
                result=result.to_dict(),
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider_lower == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            default_db = cred_provider.get_secret("notion", "default_database_id", profile).value
        except Exception:
            default_db = None

        target_db = database_id or default_db
        if not target_db:
            click.echo("Provide --database-id or configure notion.default_database_id.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.NotionClient(token=token, database_id=target_db)
        notion_properties = _frontmatter_properties(data) if use_markdown else {
            k: v for k, v in data.items() if k not in {"content", "title"}
        }
        page = sink_helpers.DocsPage(
            external_id=external_id,
            title=page_title,
            content_markdown=content,
            parent_id=target_db,
            properties=notion_properties,
            tags=tags,
        )
        dry_run_target = f"database: {target_db or '(default)'}"
    elif provider_lower == "confluence":
        try:
            api_base = cred_provider.get_secret("confluence", "api_base", profile).value
        except Exception:
            click.echo(f"Confluence api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            email = cred_provider.get_secret("confluence", "email", profile).value
        except Exception:
            click.echo(f"Confluence email not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_token = cred_provider.get_secret("confluence", "api_token", profile).value
        except Exception:
            click.echo(f"Confluence api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_space = None
        try:
            default_space = cred_provider.get_secret("confluence", "default_space_key", profile).value
        except Exception:
            default_space = None

        default_parent = None
        try:
            default_parent = cred_provider.get_secret("confluence", "default_parent_id", profile).value
        except Exception:
            default_parent = None

        resolved_space = space_key or default_space
        if not resolved_space:
            click.echo("Provide --space-key or configure confluence.default_space_key.", err=True)
            sys.exit(EXIT_USAGE)
        resolved_parent = parent_id or default_parent

        client = sink_helpers.ConfluenceClient(
            api_base=api_base,
            email=email,
            api_token=api_token,
            default_space_key=default_space,
            default_parent_id=default_parent,
        )
        page = sink_helpers.DocsPage(
            external_id=external_id,
            title=page_title,
            content_markdown=content,
            parent_id=resolved_parent,
            properties={"space_key": resolved_space},
            tags=tags,
        )
        dry_run_target = f"space: {resolved_space}"
        if resolved_parent:
            dry_run_target += f", parent: {resolved_parent}"
    else:
        try:
            api_token = cred_provider.get_secret("outline", "api_token", profile).value
        except Exception:
            click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_base = cred_provider.get_secret("outline", "api_base", profile).value
        except Exception:
            click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_collection = None
        try:
            default_collection = cred_provider.get_secret("outline", "default_collection_id", profile).value
        except Exception:
            default_collection = None

        target_collection = collection_id or default_collection
        if not target_collection:
            click.echo("Provide --collection-id or configure outline.default_collection_id.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.OutlineClient(
            api_token=api_token,
            api_base=api_base,
            default_collection_id=target_collection,
        )
        page = sink_helpers.DocsPage(
            external_id=external_id,
            title=page_title,
            content_markdown=content,
            parent_id=target_collection,
            properties={k: v for k, v in data.items() if k not in {"content", "title", "tags"}},
            tags=tags,
        )
        dry_run_target = f"collection: {target_collection}"

    resolved_db = target_db if provider_lower == "notion" else None
    resolved_collection = target_collection if provider_lower == "outline" else None

    if not apply:
        click.echo(
            f"[DRY-RUN] Would publish page '{page_title}' to provider {provider} "
            f"({dry_run_target})"
        )
        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin docs publish", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "provider": provider,
                    "database_id": resolved_db,
                    "collection_id": resolved_collection,
                    "external_id": external_id,
                    "title": page_title,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider_lower == "confluence":
        if page.properties.get("space_key"):
            if not client.get_space(page.properties["space_key"]):
                click.echo(
                    f"Error: Space '{page.properties['space_key']}' not found or access denied.",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)
        if page.parent_id and not client.get_content(page.parent_id):
            click.echo(
                f"Error: Parent page '{page.parent_id}' not found or access denied.",
                err=True,
            )
            sys.exit(EXIT_REMOTE_ERROR)

    result = client.upsert_page(page)
    click.echo(
        f"{result.status.capitalize()} page {result.external_id} -> {result.page_url or result.page_id or 'unknown'}"
    )

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin docs publish", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status=result.status if result.status in {"created", "updated"} else "partial",
            dry_run=False,
            result=result.to_dict(),
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@docs.command("publish-dir")
@click.option(
    "--provider",
    type=click.Choice(["notion", "outline", "confluence", "word"], case_sensitive=False),
    required=True,
    help="Docs provider.",
)
@click.option(
    "--markdown-dir",
    required=True,
    type=click.Path(exists=True, file_okay=False, path_type=Path),
    help="Directory containing .md/.markdown/.mmd/.mermaid files.",
)
@click.option(
    "--recursive/--no-recursive",
    default=True,
    show_default=True,
    help="Include markdown files in subdirectories.",
)
@click.option(
    "--collection-id",
    help="Outline collection ID (fallback if not set in frontmatter).",
)
@click.option(
    "--database-id",
    help="Notion database ID (fallback if not set in frontmatter).",
)
@click.option(
    "--space-key",
    help="Confluence space key (fallback if not set in frontmatter).",
)
@click.option(
    "--parent-id",
    help="Confluence parent page ID (fallback if not set in frontmatter).",
)
@click.option(
    "--output-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Output directory for Word files (required for word provider).",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile for docs provider.",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute upserts (default: dry-run).",
)
def docs_publish_dir(
    provider: str,
    markdown_dir: Path,
    recursive: bool,
    collection_id: Optional[str],
    database_id: Optional[str],
    space_key: Optional[str],
    parent_id: Optional[str],
    output_dir: Optional[Path],
    profile: str,
    json_summary: bool,
    apply: bool,
) -> None:
    """
    Publish Markdown/Mermaid files from a directory.

    Supports frontmatter keys:
      title, external_id, tags/labels
      database_id (notion)
      collection_id (outline)
      space_key + parent_id (confluence)
    """
    sink_helpers = _get_docs_sink_helpers()
    cred_provider = _get_credential_provider()

    source_files = _discover_docs_markdown_files(markdown_dir, recursive=recursive)
    if not source_files:
        click.echo(f"No markdown sources found in {markdown_dir}.", err=True)
        sys.exit(EXIT_USAGE)

    provider_lower = provider.lower()
    pages: List[Any] = []
    dry_run_target = ""
    default_database: Optional[str] = None
    default_space: Optional[str] = None
    default_parent: Optional[str] = None
    default_collection: Optional[str] = None

    if provider_lower == "word":
        if not output_dir:
            click.echo("Error: --output-dir required for word provider.", err=True)
            sys.exit(EXIT_USAGE)
        from g_gremlin.word_sink import WordSinkProvider

        client = WordSinkProvider(output_dir=output_dir)
        dry_run_target = f"directory: {output_dir}"
    elif provider_lower == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            default_database = cred_provider.get_secret("notion", "default_database_id", profile).value
        except Exception:
            default_database = None

        client = sink_helpers.NotionClient(
            token=token,
            database_id=database_id or default_database,
        )
        dry_run_target = f"database: {database_id or default_database or '(per-file frontmatter)'}"
    elif provider_lower == "confluence":
        try:
            api_base = cred_provider.get_secret("confluence", "api_base", profile).value
        except Exception:
            click.echo(f"Confluence api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            email = cred_provider.get_secret("confluence", "email", profile).value
        except Exception:
            click.echo(f"Confluence email not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_token = cred_provider.get_secret("confluence", "api_token", profile).value
        except Exception:
            click.echo(f"Confluence api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            default_space = cred_provider.get_secret("confluence", "default_space_key", profile).value
        except Exception:
            default_space = None

        try:
            default_parent = cred_provider.get_secret("confluence", "default_parent_id", profile).value
        except Exception:
            default_parent = None

        client = sink_helpers.ConfluenceClient(
            api_base=api_base,
            email=email,
            api_token=api_token,
            default_space_key=default_space,
            default_parent_id=default_parent,
        )
        dry_run_target = f"space: {space_key or default_space or '(per-file frontmatter)'}"
    else:
        try:
            api_token = cred_provider.get_secret("outline", "api_token", profile).value
        except Exception:
            click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_base = cred_provider.get_secret("outline", "api_base", profile).value
        except Exception:
            click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            default_collection = cred_provider.get_secret("outline", "default_collection_id", profile).value
        except Exception:
            default_collection = None

        client = sink_helpers.OutlineClient(
            api_token=api_token,
            api_base=api_base,
            default_collection_id=collection_id or default_collection,
        )
        dry_run_target = f"collection: {collection_id or default_collection or '(per-file frontmatter)'}"

    for source_path in source_files:
        raw_text = source_path.read_text(encoding="utf-8")
        frontmatter, content = _parse_markdown_frontmatter(raw_text)
        content = _normalize_docs_markdown_content(source_path, content)

        tags = _normalize_tags(frontmatter.get("tags") or frontmatter.get("labels"))
        page_external_id = str(
            frontmatter.get("external_id")
            or frontmatter.get("id")
            or _default_external_id_for_path(source_path, root=markdown_dir)
        )
        page_title = str(
            frontmatter.get("title")
            or _extract_title_from_markdown(content)
            or _default_title_from_path(source_path)
        )

        properties = _frontmatter_properties(frontmatter)
        properties["source_file"] = source_path.relative_to(markdown_dir).as_posix()

        if provider_lower == "notion":
            page_database = database_id or _database_id_from_frontmatter(frontmatter) or default_database
            if not page_database:
                rel = source_path.relative_to(markdown_dir).as_posix()
                click.echo(
                    f"Provide --database-id, configure notion.default_database_id, or set database_id frontmatter ({rel}).",
                    err=True,
                )
                sys.exit(EXIT_USAGE)
            pages.append(
                sink_helpers.DocsPage(
                    external_id=page_external_id,
                    title=page_title,
                    content_markdown=content,
                    parent_id=page_database,
                    properties=properties,
                    tags=tags,
                )
            )
        elif provider_lower == "confluence":
            page_space = space_key or _space_key_from_frontmatter(frontmatter) or default_space
            if not page_space:
                rel = source_path.relative_to(markdown_dir).as_posix()
                click.echo(
                    f"Provide --space-key, configure confluence.default_space_key, or set space_key frontmatter ({rel}).",
                    err=True,
                )
                sys.exit(EXIT_USAGE)

            page_parent = parent_id or _parent_id_from_frontmatter(frontmatter) or default_parent
            page_properties = dict(properties)
            page_properties["space_key"] = page_space
            pages.append(
                sink_helpers.DocsPage(
                    external_id=page_external_id,
                    title=page_title,
                    content_markdown=content,
                    parent_id=page_parent,
                    properties=page_properties,
                    tags=tags,
                )
            )
        elif provider_lower == "outline":
            page_collection = collection_id or _collection_id_from_frontmatter(frontmatter) or default_collection
            if not page_collection:
                rel = source_path.relative_to(markdown_dir).as_posix()
                click.echo(
                    f"Provide --collection-id, configure outline.default_collection_id, or set collection_id frontmatter ({rel}).",
                    err=True,
                )
                sys.exit(EXIT_USAGE)

            pages.append(
                sink_helpers.DocsPage(
                    external_id=page_external_id,
                    title=page_title,
                    content_markdown=content,
                    parent_id=page_collection,
                    properties=properties,
                    tags=tags,
                )
            )
        else:
            pages.append(
                sink_helpers.DocsPage(
                    external_id=page_external_id,
                    title=page_title,
                    content_markdown=content,
                    parent_id=None,
                    properties=properties,
                    tags=tags,
                )
            )

    if not apply:
        click.echo(
            f"[DRY-RUN] Would publish {len(pages)} page(s) to {provider} ({dry_run_target})"
        )
        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin docs publish-dir", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "provider": provider,
                    "source_dir": str(markdown_dir),
                    "recursive": recursive,
                    "pages": len(pages),
                    "database_id": database_id if provider_lower == "notion" else None,
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider_lower == "confluence":
        resolved_spaces = sorted({
            str(page.properties.get("space_key"))
            for page in pages
            if page.properties.get("space_key")
        })
        for resolved_space in resolved_spaces:
            if not client.get_space(resolved_space):
                click.echo(
                    f"Error: Space '{resolved_space}' not found or access denied.",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

        parent_ids = sorted({str(page.parent_id) for page in pages if page.parent_id})
        for page_parent_id in parent_ids:
            if not client.get_content(page_parent_id):
                click.echo(
                    f"Error: Parent page '{page_parent_id}' not found or access denied.",
                    err=True,
                )
                sys.exit(EXIT_REMOTE_ERROR)

    outcome = client.upsert_pages_batch(pages)
    click.echo(
        f"Docs publish complete: {len(outcome.succeeded)} succeeded, "
        f"{len(outcome.failed)} failed, {len(outcome.retryable_failed)} retryable"
    )

    if json_summary:
        safety_ctx = SafetyContext(
            command="g-gremlin docs publish-dir",
            remote_mutation=provider_lower != "word",
        )
        agentic = safety_ctx.build_result(
            status=outcome.status,
            dry_run=False,
            batch_outcome=outcome,
            result={
                "provider": provider,
                "source_dir": str(markdown_dir),
                "recursive": recursive,
                "pages_processed": len(pages),
                "database_id": database_id if provider_lower == "notion" else None,
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


@docs.command("publish-batch")
@click.option(
    "--provider",
    type=click.Choice(["notion", "outline", "confluence", "word"], case_sensitive=False),
    required=True,
    help="Docs provider.",
)
@click.option(
    "--output-dir",
    type=click.Path(file_okay=False, path_type=Path),
    help="Output directory for Word files (required for word provider).",
)
@click.option(
    "--database-id",
    help="Notion database ID.",
)
@click.option(
    "--collection-id",
    help="Outline collection ID.",
)
@click.option(
    "--space-key",
    help="Confluence space key.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile for docs provider.",
)
@click.option(
    "--csv",
    "csv_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="CSV containing signals to publish.",
)
@click.option(
    "--template",
    "template_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    required=True,
    help="Jinja2 template for content.",
)
@click.option(
    "--external-id-column",
    required=True,
    help="Column containing external IDs.",
)
@click.option(
    "--title-column",
    help="Optional title column.",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute upserts (default: dry-run).",
)
def docs_publish_batch(
    provider: str,
    output_dir: Optional[Path],
    database_id: Optional[str],
    collection_id: Optional[str],
    space_key: Optional[str],
    profile: str,
    csv_path: Path,
    template_path: Path,
    external_id_column: str,
    title_column: Optional[str],
    json_summary: bool,
    apply: bool,
) -> None:
    """
    Publish a batch of pages from a CSV file.
    """
    from jinja2 import Template

    sink_helpers = _get_docs_sink_helpers()
    cred_provider = _get_credential_provider()

    template_text = template_path.read_text(encoding="utf-8")
    provider_lower = provider.lower()
    target_collection: Optional[str] = None

    # Word provider - local .docx output
    if provider_lower == "word":
        if not output_dir:
            click.echo("Error: --output-dir required for word provider.", err=True)
            sys.exit(EXIT_USAGE)

        from g_gremlin.word_sink import WordSinkProvider

        client = WordSinkProvider(output_dir=output_dir)
        dry_run_target = f"directory {output_dir}"

        pages: List[sink_helpers.DocsPage] = []
        with csv_path.open("r", encoding="utf-8") as handle:
            reader = csv.DictReader(handle)
            for row in reader:
                external_id = row.get(external_id_column)
                if not external_id:
                    continue
                page_title = row.get(title_column) or row.get("title") or row.get("company") or "Intent Signal"
                content = Template(template_text).render(**row)
                pages.append(
                    sink_helpers.DocsPage(
                        external_id=external_id,
                        title=page_title,
                        content_markdown=content,
                        parent_id=None,
                        properties=row,
                    )
                )

        if not pages:
            click.echo("No rows found to publish.", err=True)
            sys.exit(EXIT_USAGE)

        if not apply:
            click.echo(f"[DRY-RUN] Would publish {len(pages)} page(s) to word ({dry_run_target})")
            if json_summary:
                safety_ctx = SafetyContext(command="g-gremlin docs publish-batch", remote_mutation=False)
                agentic = safety_ctx.build_result(
                    status="success",
                    dry_run=True,
                    result={
                        "provider": "word",
                        "output_dir": str(output_dir),
                        "pages": len(pages),
                    },
                )
                click.echo(json.dumps(agentic.to_dict(), indent=2))
            return

        outcome = client.upsert_pages_batch(pages)
        click.echo(
            f"Word docs publish complete: {len(outcome.succeeded)} succeeded, "
            f"{len(outcome.failed)} failed"
        )

        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin docs publish-batch", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status=outcome.status,
                dry_run=False,
                batch_outcome=outcome,
                result={
                    "provider": "word",
                    "output_dir": str(output_dir),
                    "pages_processed": len(pages),
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider_lower == "notion":
        try:
            token = cred_provider.get_secret("notion", "token", profile).value
        except Exception:
            click.echo(f"Notion token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        if not database_id:
            try:
                database_id = cred_provider.get_secret("notion", "default_database_id", profile).value
            except Exception:
                database_id = None

        if not database_id:
            click.echo("Provide --database-id or configure notion.default_database_id.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.NotionClient(token=token, database_id=database_id)
        dry_run_target = f"database {database_id}"
    elif provider_lower == "confluence":
        try:
            api_base = cred_provider.get_secret("confluence", "api_base", profile).value
        except Exception:
            click.echo(f"Confluence api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            email = cred_provider.get_secret("confluence", "email", profile).value
        except Exception:
            click.echo(f"Confluence email not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_token = cred_provider.get_secret("confluence", "api_token", profile).value
        except Exception:
            click.echo(f"Confluence api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_space = None
        try:
            default_space = cred_provider.get_secret("confluence", "default_space_key", profile).value
        except Exception:
            default_space = None

        default_parent = None
        try:
            default_parent = cred_provider.get_secret("confluence", "default_parent_id", profile).value
        except Exception:
            default_parent = None

        resolved_space = space_key or default_space
        if not resolved_space:
            click.echo("Provide --space-key or configure confluence.default_space_key.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.ConfluenceClient(
            api_base=api_base,
            email=email,
            api_token=api_token,
            default_space_key=default_space,
            default_parent_id=default_parent,
        )
        dry_run_target = f"space {resolved_space}"

        pages = []
        with csv_path.open("r", encoding="utf-8") as handle:
            reader = csv.DictReader(handle)
            for row in reader:
                external_id = row.get(external_id_column)
                if not external_id:
                    continue
                page_title = row.get(title_column) or row.get("title") or row.get("company") or "Intent Signal"
                content = Template(template_text).render(**row)
                tags = _normalize_tags(row.get("tags") or row.get("labels"))
                pages.append(
                    sink_helpers.DocsPage(
                        external_id=external_id,
                        title=page_title,
                        content_markdown=content,
                        parent_id=default_parent,
                        properties={"space_key": resolved_space},
                        tags=tags,
                    )
                )
    else:
        try:
            api_token = cred_provider.get_secret("outline", "api_token", profile).value
        except Exception:
            click.echo(f"Outline api_token not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        try:
            api_base = cred_provider.get_secret("outline", "api_base", profile).value
        except Exception:
            click.echo(f"Outline api_base not configured for profile '{profile}'.", err=True)
            sys.exit(EXIT_USAGE)

        default_collection = None
        try:
            default_collection = cred_provider.get_secret("outline", "default_collection_id", profile).value
        except Exception:
            default_collection = None

        target_collection = collection_id or default_collection
        if not target_collection:
            click.echo("Provide --collection-id or configure outline.default_collection_id.", err=True)
            sys.exit(EXIT_USAGE)

        client = sink_helpers.OutlineClient(
            api_token=api_token,
            api_base=api_base,
            default_collection_id=target_collection,
        )
        dry_run_target = f"collection {target_collection}"

    if provider_lower in {"notion", "outline"}:
        parent_id = database_id if provider_lower == "notion" else target_collection
        pages = []
        with csv_path.open("r", encoding="utf-8") as handle:
            reader = csv.DictReader(handle)
            for row in reader:
                external_id = row.get(external_id_column)
                if not external_id:
                    continue
                page_title = row.get(title_column) or row.get("title") or row.get("company") or "Intent Signal"
                content = Template(template_text).render(**row)
                pages.append(
                    sink_helpers.DocsPage(
                        external_id=external_id,
                        title=page_title,
                        content_markdown=content,
                        parent_id=parent_id,
                        properties=row,
                    )
                )

    if not pages:
        click.echo("No rows found to publish.", err=True)
        sys.exit(EXIT_USAGE)

    if not apply:
        click.echo(f"[DRY-RUN] Would publish {len(pages)} page(s) to {provider} ({dry_run_target})")
        if json_summary:
            safety_ctx = SafetyContext(command="g-gremlin docs publish-batch", remote_mutation=False)
            agentic = safety_ctx.build_result(
                status="success",
                dry_run=True,
                result={
                    "provider": provider,
                    "database_id": database_id if provider_lower == "notion" else None,
                    "collection_id": target_collection if provider_lower == "outline" else None,
                    "pages": len(pages),
                },
            )
            click.echo(json.dumps(agentic.to_dict(), indent=2))
        return

    if provider_lower == "confluence":
        if not client.get_space(resolved_space):
            click.echo(
                f"Error: Space '{resolved_space}' not found or access denied.",
                err=True,
            )
            sys.exit(EXIT_REMOTE_ERROR)
        if default_parent and not client.get_content(default_parent):
            click.echo(
                f"Error: Parent page '{default_parent}' not found or access denied.",
                err=True,
            )
            sys.exit(EXIT_REMOTE_ERROR)

    outcome = client.upsert_pages_batch(pages)
    click.echo(
        f"Docs publish complete: {len(outcome.succeeded)} succeeded, "
        f"{len(outcome.failed)} failed, {len(outcome.retryable_failed)} retryable"
    )

    if json_summary:
        safety_ctx = SafetyContext(command="g-gremlin docs publish-batch", remote_mutation=True)
        agentic = safety_ctx.build_result(
            status=outcome.status,
            dry_run=False,
            batch_outcome=outcome,
            result={
                "provider": provider,
                "database_id": database_id,
                "collection_id": collection_id,
                "pages_processed": len(pages),
            },
        )
        click.echo(json.dumps(agentic.to_dict(), indent=2))


# ---------------------------------------------------------------------------
# Sequencer commands (Instantly, Smartlead)
# ---------------------------------------------------------------------------

def _get_sequencer_provider():
    """Lazy import sequencer module to avoid loading on --help."""
    from g_gremlin import sequencer
    return sequencer


def _get_sequencer_mapping():
    """Lazy import sequencer mapping module."""
    from g_gremlin.sequencer import mapping
    return mapping


def _redact_email_for_display(email: str) -> str:
    """Redact email for display in errors: john@example.com -> j***@example.com"""
    if not email or "@" not in email:
        return email
    local, domain = email.split("@", 1)
    if len(local) <= 2:
        return f"{local[0]}***@{domain}"
    return f"{local[0]}***@{domain}"


@cli.group()
def sequencer() -> None:
    """
    Sales sequencer integrations (Instantly, Smartlead, Apollo).

    Push contacts to sequences and receive engagement events.
    Default: dry-run unless --apply is provided.

    Note: Sequencer commands require a valid FoundryOps trial or subscription.
    Run 'g-gremlin auth login' to authenticate.
    """
    # Licensed command group - check for valid license
    _check_license()


# ---------------------------------------------------------------------------
# JustCall commands (SMS/WhatsApp + call logs)
# ---------------------------------------------------------------------------


@cli.group()
def justcall() -> None:
    """
    JustCall integrations (SMS, WhatsApp, call logs).

    Requires: g-gremlin auth set justcall
    """
    # TODO: Re-enable after testing: _check_license()
    pass


@justcall.command("doctor")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def justcall_doctor(profile: str) -> None:
    """Check JustCall API connectivity."""
    cred_provider = _get_credential_provider()
    jc_mod = _get_justcall_helpers()

    try:
        api_key = cred_provider.get_secret("justcall", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        api_secret = cred_provider.get_secret("justcall", "api_secret", profile).value
    except Exception:
        api_secret = None

    if not api_key or not api_secret:
        click.echo(f"Error: JustCall credentials not configured for profile '{profile}'.", err=True)
        click.echo("Set via G_GREMLIN_JUSTCALL_API_KEY + G_GREMLIN_JUSTCALL_API_SECRET", err=True)
        click.echo("  or: g-gremlin auth set justcall", err=True)
        sys.exit(EXIT_USAGE)

    client = jc_mod.JustCallClient(api_key=api_key, api_secret=api_secret)
    try:
        client.doctor()
        click.echo("JustCall connectivity: OK")
    except jc_mod.JustCallAuthError as e:
        click.echo(f"Auth failed: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallRateLimitError as e:
        click.echo(f"Rate limit: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@justcall.command("calls")
@click.option("--from-datetime", help="Start datetime (yyyy-mm-dd or yyyy-mm-dd hh:mm:ss) in user TZ.")
@click.option("--to-datetime", help="End datetime (yyyy-mm-dd or yyyy-mm-dd hh:mm:ss) in user TZ.")
@click.option("--contact-number", help="Filter by contact number (E.164).")
@click.option("--justcall-number", help="Filter by JustCall number (E.164).")
@click.option("--agent-id", help="Filter by agent ID.")
@click.option("--call-direction", type=click.Choice(["Incoming", "Outgoing"], case_sensitive=False))
@click.option(
    "--call-type",
    type=click.Choice(
        ["answered", "unanswered", "missed", "voicemail", "abandoned", "busy", "failed", "blocked", "rejected", "spam"],
        case_sensitive=False,
    ),
)
@click.option("--page", type=int, default=1, show_default=True)
@click.option("--per-page", type=int, default=20, show_default=True)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def justcall_calls(
    from_datetime: str | None,
    to_datetime: str | None,
    contact_number: str | None,
    justcall_number: str | None,
    agent_id: str | None,
    call_direction: str | None,
    call_type: str | None,
    page: int,
    per_page: int,
    output_format: str,
    profile: str,
) -> None:
    """List JustCall calls with optional filters."""
    cred_provider = _get_credential_provider()
    jc_mod = _get_justcall_helpers()
    try:
        api_key = cred_provider.get_secret("justcall", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        api_secret = cred_provider.get_secret("justcall", "api_secret", profile).value
    except Exception:
        api_secret = None

    if not api_key or not api_secret:
        click.echo(f"Error: JustCall credentials not configured for profile '{profile}'.", err=True)
        click.echo("Set via G_GREMLIN_JUSTCALL_API_KEY + G_GREMLIN_JUSTCALL_API_SECRET", err=True)
        sys.exit(EXIT_USAGE)

    client = jc_mod.JustCallClient(api_key=api_key, api_secret=api_secret)
    params: dict[str, object] = {
        "page": page,
        "per_page": per_page,
    }
    if from_datetime:
        params["from_datetime"] = from_datetime
    if to_datetime:
        params["to_datetime"] = to_datetime
    if contact_number:
        params["contact_number"] = contact_number
    if justcall_number:
        params["justcall_number"] = justcall_number
    if agent_id:
        params["agent_id"] = agent_id
    if call_direction:
        params["call_direction"] = call_direction.capitalize()
    if call_type:
        params["call_type"] = call_type.lower()

    try:
        resp = client.list_calls(params)
    except jc_mod.JustCallAuthError as e:
        click.echo(f"Auth failed: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallRateLimitError as e:
        click.echo(f"Rate limit: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if output_format == "json":
        click.echo(json.dumps(resp, indent=2))
    else:
        calls = resp.get("data") or resp.get("calls") or []
        click.echo(f"Calls returned: {len(calls)}")
        for call in calls[:20]:
            direction = call.get("call_direction") or call.get("direction")
            ctype = call.get("call_type") or call.get("type")
            number = call.get("contact_number") or call.get("number")
            ts = call.get("datetime") or call.get("created_at")
            status = call.get("status") or call.get("disposition")
            click.echo(f"- {ts} [{direction}/{ctype}] {number} status={status}")
        if len(calls) > 20:
            click.echo(f"... and {len(calls) - 20} more (use --format json for full output)")


@justcall.command("sms")
@click.option("--justcall-number", required=True, help="JustCall number to send from (E.164).")
@click.option("--contact-number", required=True, help="Recipient number (E.164).")
@click.option("--body", required=True, help="Message body (max 1600 chars).")
@click.option("--media-url", help="Comma-separated media URLs for MMS.")
@click.option("--restrict-once", type=click.Choice(["Yes", "No"], case_sensitive=False), help="Prevent repeat within 24h.")
@click.option("--schedule-at", help="Schedule send time (YYYY-MM-DD HH:mm:ss).")
@click.option("--apply", is_flag=True, help="Send the SMS. Omit to dry-run.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def justcall_sms(
    justcall_number: str,
    contact_number: str,
    body: str,
    media_url: str | None,
    restrict_once: str | None,
    schedule_at: str | None,
    apply: bool,
    profile: str,
) -> None:
    """Send SMS/MMS via JustCall (dry-run by default)."""
    cred_provider = _get_credential_provider()
    jc_mod = _get_justcall_helpers()
    try:
        api_key = cred_provider.get_secret("justcall", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        api_secret = cred_provider.get_secret("justcall", "api_secret", profile).value
    except Exception:
        api_secret = None

    if not api_key or not api_secret:
        click.echo(f"Error: JustCall credentials not configured for profile '{profile}'.", err=True)
        click.echo("Set via G_GREMLIN_JUSTCALL_API_KEY + G_GREMLIN_JUSTCALL_API_SECRET", err=True)
        sys.exit(EXIT_USAGE)

    client = jc_mod.JustCallClient(api_key=api_key, api_secret=api_secret)
    payload = {
        "justcall_number": justcall_number,
        "contact_number": contact_number,
        "body": body,
        "media_url": media_url,
        "restrict_once": restrict_once,
        "schedule_at": schedule_at,
    }

    if not apply:
        click.echo("DRY-RUN: Would send SMS with payload:")
        click.echo(json.dumps({k: v for k, v in payload.items() if v}, indent=2))
        click.echo("Run with --apply to send.")
        return

    try:
        resp = client.send_sms(
            justcall_number=justcall_number,
            contact_number=contact_number,
            body=body,
            media_url=media_url,
            restrict_once=restrict_once,
            schedule_at=schedule_at,
        )
        click.echo("SMS sent.")
        click.echo(json.dumps(resp, indent=2))
    except jc_mod.JustCallAuthError as e:
        click.echo(f"Auth failed: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallRateLimitError as e:
        click.echo(f"Rate limit: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@justcall.command("whatsapp")
@click.option("--justcall-number", required=True, help="WhatsApp-integrated JustCall number (E.164).")
@click.option("--contact-number", required=True, help="Recipient number (E.164).")
@click.option("--body", help="Message body (non-template).")
@click.option("--template-id", type=int, help="Approved WhatsApp template ID.")
@click.option("--template-var", "template_vars", multiple=True, help="Template variable (repeatable).")
@click.option("--media-url", help="Media URL (single).")
@click.option("--media-file-name", help="Media file name.")
@click.option("--restrict-once", type=click.Choice(["Yes", "No"], case_sensitive=False), help="Prevent repeat within 24h.")
@click.option("--apply", is_flag=True, help="Send the WhatsApp message. Omit to dry-run.")
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
def justcall_whatsapp(
    justcall_number: str,
    contact_number: str,
    body: str | None,
    template_id: int | None,
    template_vars: tuple[str, ...],
    media_url: str | None,
    media_file_name: str | None,
    restrict_once: str | None,
    apply: bool,
    profile: str,
) -> None:
    """Send WhatsApp message via JustCall (dry-run by default)."""
    cred_provider = _get_credential_provider()
    jc_mod = _get_justcall_helpers()
    try:
        api_key = cred_provider.get_secret("justcall", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        api_secret = cred_provider.get_secret("justcall", "api_secret", profile).value
    except Exception:
        api_secret = None

    if not api_key or not api_secret:
        click.echo(f"Error: JustCall credentials not configured for profile '{profile}'.", err=True)
        click.echo("Set via G_GREMLIN_JUSTCALL_API_KEY + G_GREMLIN_JUSTCALL_API_SECRET", err=True)
        sys.exit(EXIT_USAGE)

    if not template_id and not body and not media_url:
        click.echo("Error: Provide at least one of --template-id, --body, or --media-url.", err=True)
        sys.exit(EXIT_USAGE)

    client = jc_mod.JustCallClient(api_key=api_key, api_secret=api_secret)
    payload = {
        "justcall_number": justcall_number,
        "contact_number": contact_number,
        "body": body,
        "template_id": template_id,
        "template_variables": list(template_vars) if template_vars else None,
        "media_url": media_url,
        "media_file_name": media_file_name,
        "restrict_once": restrict_once,
    }

    if not apply:
        click.echo("DRY-RUN: Would send WhatsApp message with payload:")
        click.echo(json.dumps({k: v for k, v in payload.items() if v is not None}, indent=2))
        click.echo("Run with --apply to send.")
        return

    try:
        resp = client.send_whatsapp(
            justcall_number=justcall_number,
            contact_number=contact_number,
            body=body,
            template_id=template_id,
            template_variables=list(template_vars) if template_vars else None,
            media_url=media_url,
            media_file_name=media_file_name,
            restrict_once=restrict_once,
        )
        click.echo("WhatsApp message sent.")
        click.echo(json.dumps(resp, indent=2))
    except jc_mod.JustCallAuthError as e:
        click.echo(f"Auth failed: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallRateLimitError as e:
        click.echo(f"Rate limit: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@justcall.command("campaigns")
@click.option("--status", type=click.Choice(["active", "paused", "completed"]), help="Filter by status.")
@click.option("--page", type=int, default=1, show_default=True)
@click.option("--per-page", type=int, default=50, show_default=True)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
)
@click.option("--profile", default="default", show_default=True, help="Credential profile to use.")
def justcall_campaigns(
    status: str | None,
    page: int,
    per_page: int,
    output_format: str,
    profile: str,
) -> None:
    """List Sales Dialer campaigns."""
    cred_provider = _get_credential_provider()
    jc_mod = _get_justcall_helpers()

    try:
        api_key = cred_provider.get_secret("justcall", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        api_secret = cred_provider.get_secret("justcall", "api_secret", profile).value
    except Exception:
        api_secret = None

    if not api_key or not api_secret:
        click.echo(f"Error: JustCall credentials not configured for profile '{profile}'.", err=True)
        click.echo("Set via G_GREMLIN_JUSTCALL_API_KEY + G_GREMLIN_JUSTCALL_API_SECRET", err=True)
        sys.exit(EXIT_USAGE)

    client = jc_mod.JustCallClient(api_key=api_key, api_secret=api_secret)

    try:
        campaigns = client.list_campaigns(page=page, per_page=per_page, status=status)

        if output_format == "json":
            click.echo(json.dumps([{
                "id": c.id,
                "name": c.name,
                "status": c.status,
                "type": c.type,
                "contact_count": c.contact_count,
                "created_at": c.created_at,
            } for c in campaigns], indent=2))
        else:
            if not campaigns:
                click.echo("No campaigns found.")
            else:
                click.echo(f"{'ID':<12} {'Name':<30} {'Status':<10} {'Type':<12} {'Contacts':<10}")
                click.echo("-" * 80)
                for c in campaigns:
                    click.echo(f"{c.id:<12} {c.name[:28]:<30} {c.status:<10} {c.type:<12} {c.contact_count:<10}")

    except jc_mod.JustCallAuthError as e:
        click.echo(f"Auth failed: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@justcall.command("push-callblock")
@click.option(
    "--csv",
    "csv_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    required=True,
    help="CSV file with contacts to push.",
)
@click.option("--campaign-id", help="Existing campaign ID to add contacts to.")
@click.option("--campaign-name", help="Create new campaign with this name if --campaign-id not provided.")
@click.option(
    "--campaign-type",
    type=click.Choice(["autodialer", "predictive", "dynamic"], case_sensitive=False),
    default="autodialer",
    show_default=True,
    help="Type of campaign to create (if creating new).",
)
@click.option("--phone-column", default="phone", show_default=True, help="Column name for phone number.")
@click.option("--first-name-column", default="first_name", help="Column name for first name.")
@click.option("--last-name-column", default="last_name", help="Column name for last name.")
@click.option("--email-column", default="email", help="Column name for email.")
@click.option("--company-column", default="company", help="Column name for company.")
@click.option("--default-country", default="US", show_default=True, help="Default country for phone normalization.")
@click.option("--batch-size", type=int, default=250, show_default=True, help="Batch size for import (max 250).")
@click.option("--apply", is_flag=True, default=False, help="Actually push contacts (dry-run by default).")
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
)
@click.option("--profile", default="default", show_default=True, help="Credential profile to use.")
def justcall_push_callblock(
    csv_path: Path,
    campaign_id: str | None,
    campaign_name: str | None,
    campaign_type: str,
    phone_column: str,
    first_name_column: str,
    last_name_column: str,
    email_column: str,
    company_column: str,
    default_country: str,
    batch_size: int,
    apply: bool,
    output_format: str,
    profile: str,
) -> None:
    """
    Push contacts to a JustCall Sales Dialer campaign.

    \b
    This command:
    1. Reads contacts from a CSV file
    2. Validates and normalizes phone numbers to E.164
    3. Creates a campaign (if --campaign-name) or uses existing (--campaign-id)
    4. Bulk imports contacts in batches of up to 250

    \b
    Example:
      g-gremlin justcall push-callblock --csv leads.csv --campaign-name "CES 2025"
      g-gremlin justcall push-callblock --csv leads.csv --campaign-id 12345 --apply
    """
    import csv as csv_mod

    cred_provider = _get_credential_provider()
    jc_mod = _get_justcall_helpers()

    # Validate args
    if not campaign_id and not campaign_name:
        click.echo("Error: Provide either --campaign-id or --campaign-name.", err=True)
        sys.exit(EXIT_USAGE)

    # Get credentials
    try:
        api_key = cred_provider.get_secret("justcall", "api_key", profile).value
    except Exception:
        api_key = None

    try:
        api_secret = cred_provider.get_secret("justcall", "api_secret", profile).value
    except Exception:
        api_secret = None

    if not api_key or not api_secret:
        click.echo(f"Error: JustCall credentials not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    client = jc_mod.JustCallClient(api_key=api_key, api_secret=api_secret)

    # Read CSV
    try:
        with open(csv_path, "r", encoding="utf-8-sig") as f:
            reader = csv_mod.DictReader(f)
            rows = list(reader)
    except Exception as e:
        click.echo(f"Error reading CSV: {e}", err=True)
        sys.exit(EXIT_USAGE)

    if not rows:
        click.echo("CSV is empty.", err=True)
        sys.exit(EXIT_USAGE)

    # Validate phone column exists
    if phone_column not in rows[0]:
        click.echo(f"Error: Phone column '{phone_column}' not found in CSV.", err=True)
        click.echo(f"Available columns: {', '.join(rows[0].keys())}", err=True)
        sys.exit(EXIT_USAGE)

    # Normalize phones and build contacts
    contacts: list[jc_mod.CampaignContact] = []
    invalid_phones: list[dict[str, str]] = []

    for row in rows:
        phone_raw = row.get(phone_column, "").strip()
        if not phone_raw:
            invalid_phones.append({"phone": "", "reason": "empty"})
            continue

        normalized, extension, is_valid = jc_mod.normalize_phone_e164(phone_raw, default_country)

        if not is_valid:
            invalid_phones.append({"phone": phone_raw, "reason": "invalid format"})
            continue

        contact = jc_mod.CampaignContact(
            phone=normalized,
            first_name=row.get(first_name_column, "").strip() or None,
            last_name=row.get(last_name_column, "").strip() or None,
            email=row.get(email_column, "").strip() or None,
            company=row.get(company_column, "").strip() or None,
        )

        # Store extension as custom field if present
        if extension:
            contact.custom_fields["extension"] = extension

        contacts.append(contact)

    # Build summary
    summary = {
        "csv_path": str(csv_path),
        "total_rows": len(rows),
        "valid_contacts": len(contacts),
        "invalid_phones": len(invalid_phones),
        "campaign_id": campaign_id,
        "campaign_name": campaign_name,
        "batch_size": min(batch_size, 250),
        "dry_run": not apply,
    }

    if output_format == "json":
        if not apply:
            summary["invalid_phone_details"] = invalid_phones[:10]  # First 10
            click.echo(json.dumps(summary, indent=2))
            click.echo("\nDRY-RUN: Use --apply to push contacts.")
            return
    else:
        click.echo(f"CSV: {csv_path}")
        click.echo(f"Total rows: {len(rows)}")
        click.echo(f"Valid contacts: {len(contacts)}")
        click.echo(f"Invalid phones: {len(invalid_phones)}")
        if invalid_phones:
            click.echo(f"  (first few: {[p['phone'] for p in invalid_phones[:3]]})")
        click.echo()

    if not contacts:
        click.echo("No valid contacts to push.", err=True)
        sys.exit(EXIT_USAGE)

    if not apply:
        click.echo("DRY-RUN: Would push to JustCall. Use --apply to execute.")
        return

    # Create campaign if needed
    try:
        if campaign_name and not campaign_id:
            click.echo(f"Creating campaign: {campaign_name}")
            campaign = client.create_campaign(campaign_name, campaign_type)  # type: ignore
            campaign_id = campaign.id
            click.echo(f"Created campaign ID: {campaign_id}")
        else:
            # Verify campaign exists
            campaign = client.get_campaign(campaign_id)  # type: ignore
            click.echo(f"Using campaign: {campaign.name} (ID: {campaign_id})")
    except jc_mod.JustCallError as e:
        click.echo(f"Campaign error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    # Bulk import
    try:
        click.echo(f"Importing {len(contacts)} contacts in batches of {min(batch_size, 250)}...")
        result = client.bulk_import_contacts_batched(
            campaign_id=campaign_id,  # type: ignore
            contacts=contacts,
            batch_size=min(batch_size, 250),
        )

        summary["import_result"] = {
            "success_count": result.success_count,
            "failed_count": result.failed_count,
            "errors": result.errors[:5] if result.errors else [],
        }

        if output_format == "json":
            click.echo(json.dumps(summary, indent=2))
        else:
            click.echo()
            click.echo("Import complete:")
            click.echo(f"  Success: {result.success_count}")
            click.echo(f"  Failed: {result.failed_count}")
            if result.errors:
                click.echo(f"  First error: {result.errors[0]}")

    except jc_mod.JustCallRateLimitError as e:
        click.echo(f"Rate limit hit: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except jc_mod.JustCallError as e:
        click.echo(f"Import error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@sequencer.command("push")
@click.option(
    "--provider",
    type=click.Choice(["instantly", "smartlead", "apollo"], case_sensitive=False),
    required=True,
    help="Sequencer provider to use.",
)
@click.option(
    "--csv",
    "csv_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    required=True,
    help="CSV file with contacts to push.",
)
@click.option(
    "--campaign-id",
    required=True,
    help="Campaign/sequence ID to enroll contacts in.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use (dev, prod, etc.).",
)
@click.option(
    "--mapping",
    "mapping_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Custom field mapping YAML file.",
)
@click.option(
    "--batch-size",
    type=int,
    default=None,
    help="Batch size for API calls (default: provider-specific).",
)
@click.option(
    "--concurrency",
    type=int,
    default=2,
    show_default=True,
    help="Number of concurrent batch requests.",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format.",
)
@click.option(
    "--output",
    "output_path",
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output file for results (default: stdout).",
)
@click.option(
    "--json-summary",
    is_flag=True,
    help="Emit AgenticResult JSON to stdout.",
)
@click.option(
    "--apply",
    is_flag=True,
    help="Execute the push (default is dry-run preview).",
)
def sequencer_push(
    provider: str,
    csv_path: Path,
    campaign_id: str,
    profile: str,
    mapping_path: Path | None,
    batch_size: int | None,
    concurrency: int,
    output_format: str,
    output_path: Path | None,
    json_summary: bool,
    apply: bool,
) -> None:
    """
    Push contacts from CSV to a sequencer campaign.

    Reads contacts from a CSV file, maps fields to the provider format,
    and enrolls them in the specified campaign.

    Examples:

      # Dry-run preview
      g-gremlin sequencer push --provider instantly --csv leads.csv --campaign-id ABC123

      # Actually push contacts
      g-gremlin sequencer push --provider instantly --csv leads.csv --campaign-id ABC123 --apply

      # Use prod credentials
      g-gremlin sequencer push --provider smartlead --csv leads.csv --campaign-id X --profile prod --apply
    """
    sequencer_mod = _get_sequencer_provider()
    mapping_mod = _get_sequencer_mapping()
    cred_provider = _get_credential_provider()

    correlation_id = str(uuid.uuid4())

    # Get API key from credential provider
    try:
        api_key = cred_provider.get_secret(provider, "api_key", profile).value
    except Exception:
        api_key = None

    if not api_key:
        click.echo(f"Error: {provider} API key not configured for profile '{profile}'.", err=True)
        click.echo(f"Set via: G_GREMLIN_{provider.upper()}_API_KEY or g-gremlin auth set {provider}", err=True)
        sys.exit(EXIT_USAGE)

    # Parse CSV
    try:
        prospects = mapping_mod.parse_csv(csv_path, mapping_path)
    except sequencer_mod.SequencerValidationError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_USAGE)

    # Validate prospects
    validation_errors = mapping_mod.validate_prospects(prospects)
    if validation_errors:
        click.echo(f"Validation errors found ({len(validation_errors)} issues):", err=True)
        for err in validation_errors[:5]:
            click.echo(f"  - {_redact_email_for_display(err['email'])}: {err['error']}", err=True)
        if len(validation_errors) > 5:
            click.echo(f"  ... and {len(validation_errors) - 5} more", err=True)
        sys.exit(EXIT_USAGE)

    # Compute plan hash for idempotency
    plan_hash = mapping_mod.compute_plan_hash(csv_path, campaign_id, provider, profile)

    # Get provider-specific batch size
    provider_instance = sequencer_mod.get_provider(provider, api_key)
    if batch_size is None:
        batch_size = provider_instance.rate_limit.batch_size

    # Chunk prospects
    batches = mapping_mod.chunk_list(prospects, batch_size)
    total_prospects = len(prospects)
    total_batches = len(batches)

    # Dry-run preview
    if not apply:
        click.echo(f"DRY-RUN: Would push {total_prospects} contacts in {total_batches} batch(es)")
        click.echo(f"  Provider: {provider}")
        click.echo(f"  Campaign: {campaign_id}")
        click.echo(f"  Profile: {profile}")
        click.echo(f"  Batch size: {batch_size}")
        click.echo(f"  Plan hash: {plan_hash[:12]}...")
        click.echo()
        click.echo("Run with --apply to execute.")

        if json_summary or output_format == "json":
            result = {
                "command": "sequencer push",
                "status": "dry_run",
                "dry_run": True,
                "provider": provider,
                "campaign_id": campaign_id,
                "profile": profile,
                "plan_hash": plan_hash,
                "batch_outcome": {
                    "total": total_prospects,
                    "batches": total_batches,
                },
                "correlation_id": correlation_id,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
            output_json = json.dumps(result, indent=2)
            if output_path:
                output_path.write_text(output_json)
            else:
                click.echo(output_json)
        return

    # Execute push
    click.echo(f"Pushing {total_prospects} contacts to {provider} campaign {campaign_id}...")

    from g_gremlin.sequencer.models import BatchOutcome, EnrollmentResult

    combined_outcome = BatchOutcome()
    first_errors: list[dict] = []

    for i, batch in enumerate(batches, 1):
        click.echo(f"  Batch {i}/{total_batches} ({len(batch)} contacts)...", nl=False)
        try:
            outcome = provider_instance.upsert_prospects(batch, campaign_id)
            combined_outcome.succeeded.extend(outcome.succeeded)
            combined_outcome.failed.extend(outcome.failed)
            combined_outcome.skipped.extend(outcome.skipped)
            combined_outcome.last_status_code = outcome.last_status_code

            # Collect first errors
            for result in outcome.failed:
                if len(first_errors) < 10 and result.error:
                    first_errors.append({
                        "email": _redact_email_for_display(result.email),
                        "error": result.error,
                    })

            click.echo(f" OK ({len(outcome.succeeded)} ok, {len(outcome.skipped)} skipped, {len(outcome.failed)} failed)")

        except sequencer_mod.SequencerRateLimitError:
            click.echo(" RATE LIMITED", err=True)
            # Mark remaining in batch as failed
            for p in batch:
                combined_outcome.failed.append(EnrollmentResult(
                    email=p.email,
                    campaign_id=campaign_id,
                    status="failed",
                    error="Rate limit exceeded",
                ))
            if len(first_errors) < 10:
                first_errors.append({"email": "(batch)", "error": "Rate limit exceeded"})

        except sequencer_mod.SequencerError as e:
            click.echo(f" ERROR: {e}", err=True)
            for p in batch:
                combined_outcome.failed.append(EnrollmentResult(
                    email=p.email,
                    campaign_id=campaign_id,
                    status="failed",
                    error=str(e),
                ))
            if len(first_errors) < 10:
                first_errors.append({"email": "(batch)", "error": str(e)})

    # Summary
    click.echo()
    click.echo(f"Push complete: {len(combined_outcome.succeeded)} succeeded, "
               f"{len(combined_outcome.skipped)} skipped, "
               f"{len(combined_outcome.failed)} failed")

    # Determine exit code
    if combined_outcome.failed:
        if combined_outcome.succeeded:
            exit_code = EXIT_ISSUES  # Partial success
        else:
            exit_code = EXIT_REMOTE_ERROR  # All failed
    else:
        exit_code = EXIT_SUCCESS

    # JSON output
    if json_summary or output_format == "json":
        result = {
            "command": "sequencer push",
            "status": combined_outcome.status,
            "dry_run": False,
            "provider": provider,
            "campaign_id": campaign_id,
            "profile": profile,
            "plan_hash": plan_hash,
            "batch_outcome": combined_outcome.to_dict(),
            "first_errors": first_errors,
            "correlation_id": correlation_id,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        output_json = json.dumps(result, indent=2)
        if output_path:
            output_path.write_text(output_json)
        elif json_summary:
            click.echo(output_json)

    provider_instance.close()
    sys.exit(exit_code)


@sequencer.command("campaigns")
@click.option(
    "--provider",
    type=click.Choice(["instantly", "smartlead", "apollo"], case_sensitive=False),
    required=True,
    help="Sequencer provider to use.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format.",
)
def sequencer_campaigns(
    provider: str,
    profile: str,
    output_format: str,
) -> None:
    """
    List available campaigns from a sequencer provider.

    Examples:

      g-gremlin sequencer campaigns --provider instantly
      g-gremlin sequencer campaigns --provider smartlead --format json
    """
    sequencer_mod = _get_sequencer_provider()
    cred_provider = _get_credential_provider()

    # Get API key
    try:
        api_key = cred_provider.get_secret(provider, "api_key", profile).value
    except Exception:
        api_key = None

    if not api_key:
        click.echo(f"Error: {provider} API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    # Get provider and list campaigns
    try:
        provider_instance = sequencer_mod.get_provider(provider, api_key)
        campaigns = provider_instance.list_campaigns()
        provider_instance.close()
    except sequencer_mod.SequencerAuthError:
        click.echo(f"Error: Authentication failed for {provider}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except sequencer_mod.SequencerError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if output_format == "json":
        result = [c.to_dict() for c in campaigns]
        click.echo(json.dumps(result, indent=2))
    else:
        if not campaigns:
            click.echo("No campaigns found.")
        else:
            click.echo(f"Found {len(campaigns)} campaign(s):\n")
            for c in campaigns:
                status = f" ({c.status})" if c.status else ""
                click.echo(f"  {c.id}: {c.name}{status}")


@sequencer.command("create")
@click.option(
    "--provider",
    type=click.Choice(["instantly", "smartlead", "apollo"], case_sensitive=False),
    required=True,
    help="Sequencer provider to use.",
)
@click.option(
    "--name",
    required=True,
    help="Name for the new campaign.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to use.",
)
@click.option(
    "--timezone",
    default="America/New_York",
    show_default=True,
    help="Timezone for campaign schedule (Instantly only).",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    show_default=True,
    help="Output format.",
)
def sequencer_create(
    provider: str,
    name: str,
    profile: str,
    timezone: str,
    output_format: str,
) -> None:
    """
    Create a new campaign in a sequencer provider.

    Examples:

      g-gremlin sequencer create --provider smartlead --name "My Campaign"
      g-gremlin sequencer create --provider instantly --name "Outreach Q1" --timezone "America/Los_Angeles"
    """
    sequencer_mod = _get_sequencer_provider()
    cred_provider = _get_credential_provider()

    # Get API key
    try:
        api_key = cred_provider.get_secret(provider, "api_key", profile).value
    except Exception:
        api_key = None

    if not api_key:
        click.echo(f"Error: {provider} API key not configured for profile '{profile}'.", err=True)
        sys.exit(EXIT_USAGE)

    # Get provider and create campaign
    try:
        provider_instance = sequencer_mod.get_provider(provider, api_key)
        campaign = provider_instance.create_campaign(name=name, timezone=timezone)
        provider_instance.close()
    except sequencer_mod.SequencerAuthError:
        click.echo(f"Error: Authentication failed for {provider}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    except sequencer_mod.SequencerError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)

    if output_format == "json":
        click.echo(json.dumps(campaign.to_dict(), indent=2))
    else:
        click.echo(f"Created campaign: {campaign.name}")
        click.echo(f"  ID: {campaign.id}")
        if campaign.status:
            click.echo(f"  Status: {campaign.status}")


@sequencer.command("doctor")
@click.option(
    "--provider",
    type=click.Choice(["instantly", "smartlead", "apollo"], case_sensitive=False),
    required=True,
    help="Sequencer provider to check.",
)
@click.option(
    "--profile",
    default="default",
    show_default=True,
    help="Credential profile to check.",
)
def sequencer_doctor(
    provider: str,
    profile: str,
) -> None:
    """
    Check sequencer provider connectivity and credentials.

    Examples:

      g-gremlin sequencer doctor --provider instantly
      g-gremlin sequencer doctor --provider smartlead --profile prod
    """
    sequencer_mod = _get_sequencer_provider()
    cred_provider = _get_credential_provider()

    profile_display = f"({profile})" if profile != "default" else "(default)"
    click.echo(f"{provider.title()} {profile_display}:")

    # Check API key
    try:
        secret = cred_provider.get_secret(provider, "api_key", profile)
        api_key = secret.value
        click.echo(f"  API key: present (from {secret.source.value})")
    except Exception:
        api_key = None
        click.echo("  API key: MISSING")
        click.echo(f"  Set via: G_GREMLIN_{provider.upper()}_API_KEY or g-gremlin auth set {provider}")
        sys.exit(EXIT_USAGE)

    # Test connection
    click.echo("  Connectivity: testing...")
    try:
        provider_instance = sequencer_mod.get_provider(provider, api_key)
        result = provider_instance.test_connection()
        provider_instance.close()

        if result.get("status") == "ok":
            click.echo("  Connectivity: OK")
            if "campaign_count" in result:
                click.echo(f"  Campaigns: {result['campaign_count']} found")
            if "workspace_count" in result:
                click.echo(f"  Workspaces: {result['workspace_count']} found")
            if result.get("webhooks_available"):
                click.echo("  Webhooks: available")
            else:
                click.echo("  Webhooks: requires plan upgrade")
        elif result.get("status") == "partial":
            click.echo("  Connectivity: PARTIAL")
            if result.get("sequences_available") is False:
                click.echo("  Sequences: not available (requires Organization/Enterprise plan)")
            if result.get("enrichment_available"):
                click.echo("  Enrichment: OK")
            if result.get("note"):
                click.echo(f"  Note: {result['note']}")
        else:
            click.echo(f"  Connectivity: ERROR - {result.get('error', 'Unknown error')}")
            sys.exit(EXIT_REMOTE_ERROR)

    except sequencer_mod.SequencerAuthError:
        click.echo("  Connectivity: ERROR - Authentication failed")
        click.echo("  Check your API key is valid and not expired.")
        sys.exit(EXIT_REMOTE_ERROR)
    except sequencer_mod.SequencerError as e:
        click.echo(f"  Connectivity: ERROR - {e}")
        sys.exit(EXIT_REMOTE_ERROR)


# =============================================================================
# Webhook-Out Command Group
# =============================================================================


def _get_webhook_emitter():
    """Lazy import webhook emitter to avoid loading httpx on --help."""
    from g_gremlin.webhook_out.emitter import WebhookEmitter
    return WebhookEmitter


def _get_webhook_profiles():
    """Lazy import webhook profile functions."""
    from g_gremlin.webhook_out import profiles
    return profiles


def _get_webhook_envelope():
    """Lazy import webhook envelope utilities."""
    from g_gremlin.webhook_out import envelope
    return envelope


def _emit_to_webhook(
    result,
    webhook_out: str | None,
    webhook_format: str | None,
    strict_webhook: bool,
    webhook_summary: bool,
    webhook_full: bool,
) -> None:
    """
    Emit AgenticResult to webhook AFTER stdout JSON is finalized.

    - All output to stderr
    - Never alters JSON output
    - Warn by default, fail only if strict_webhook
    """
    if not webhook_out:
        return

    envelope_mod = _get_webhook_envelope()
    WebhookEmitter = _get_webhook_emitter()

    emitter = WebhookEmitter()
    try:
        delivery = emitter.emit(
            result,
            webhook_out,
            format=webhook_format,
            force_summary=webhook_summary,
            force_full=webhook_full,
        )

        # Mask URL for logging
        masked_url = envelope_mod.mask_url(webhook_out) if not webhook_out.startswith("@") else webhook_out

        if delivery.success:
            click.echo(f"Webhook delivered: {delivery.delivery_id} -> {masked_url}", err=True)
        elif strict_webhook:
            click.echo(f"Webhook failed: {delivery.error} -> {masked_url}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)
        else:
            click.echo(f"[WARN] Webhook failed: {delivery.error} -> {masked_url}", err=True)
            # Exit 0 - core command succeeded

    except ValueError as e:
        # Profile not found or invalid URL - redact any sensitive data in error
        click.echo(f"Webhook error: {envelope_mod.redact_sensitive(str(e))}", err=True)
        if strict_webhook:
            sys.exit(EXIT_USAGE)
    except Exception as e:
        # Network or other errors - redact any sensitive data (URLs, tokens)
        click.echo(f"Webhook error: {envelope_mod.redact_sensitive(str(e))}", err=True)
        if strict_webhook:
            sys.exit(EXIT_REMOTE_ERROR)


@cli.group()
def webhook() -> None:
    """
    Manage webhook profiles and test connectivity.

    Webhook profiles store reusable webhook configurations for
    forwarding command results to external services.

    \b
    Examples:
      g-gremlin webhook profile set my-zapier --url https://hooks.zapier.com/...
      g-gremlin webhook profile list
      g-gremlin webhook test @my-zapier
    """


@webhook.group()
def profile() -> None:
    """
    CRUD operations for webhook profiles.

    Profiles are stored in .g_gremlin/webhooks/ (project-level)
    or ~/.g_gremlin/webhooks/ (user-level).
    """


@profile.command("set")
@click.argument("name")
@click.option(
    "--url",
    required=True,
    help="Webhook URL to POST to.",
)
@click.option(
    "--format",
    "webhook_format",
    type=click.Choice(["flat", "wrapped", "generic"], case_sensitive=False),
    help="Payload format preset.",
)
@click.option(
    "--header",
    "headers",
    multiple=True,
    help="Custom header in 'Key: Value' format (can be repeated).",
)
@click.option(
    "--timeout",
    type=float,
    default=10.0,
    show_default=True,
    help="Request timeout in seconds.",
)
@click.option(
    "--max-retries",
    type=int,
    default=3,
    show_default=True,
    help="Maximum retry attempts on failure.",
)
@click.option(
    "--global",
    "global_profile",
    is_flag=True,
    help="Save to ~/.g_gremlin/webhooks/ (user-level) instead of project.",
)
def profile_set_cmd(
    name: str,
    url: str,
    webhook_format: str | None,
    headers: tuple,
    timeout: float,
    max_retries: int,
    global_profile: bool,
) -> None:
    """
    Create or update a webhook profile.

    \b
    Examples:
      g-gremlin webhook profile set my-zapier --url https://hooks.zapier.com/abc
      g-gremlin webhook profile set make-sync --url https://hook.make.com/xyz --format wrapped
      g-gremlin webhook profile set n8n-prod --url https://n8n.example.com/webhook/123 --header "X-Api-Key: secret"
    """
    profiles_mod = _get_webhook_profiles()

    # Parse headers
    header_dict = {}
    for h in headers:
        if ": " in h:
            key, value = h.split(": ", 1)
            header_dict[key] = value
        else:
            click.echo(f"Invalid header format: {h} (expected 'Key: Value')", err=True)
            sys.exit(EXIT_USAGE)

    profile_obj = profiles_mod.WebhookProfile(
        name=name,
        url=url,
        format=webhook_format,
        headers=header_dict,
        timeout=timeout,
        max_retries=max_retries,
    )

    path = profiles_mod.profile_set(profile_obj, project_level=not global_profile)
    scope = "user" if global_profile else "project"
    click.echo(f"Saved webhook profile @{name} ({scope}): {path}")


@profile.command("get")
@click.argument("name")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output as JSON.",
)
def profile_get_cmd(name: str, json_output: bool) -> None:
    """
    Show a webhook profile.

    \b
    Examples:
      g-gremlin webhook profile get my-zapier
      g-gremlin webhook profile get my-zapier --json
    """
    profiles_mod = _get_webhook_profiles()
    envelope_mod = _get_webhook_envelope()

    profile_obj = profiles_mod.profile_get(name)
    if profile_obj is None:
        click.echo(f"Profile not found: @{name}", err=True)
        sys.exit(EXIT_USAGE)

    if json_output:
        # to_dict() masks URL by default for safe output
        click.echo(json.dumps(profile_obj.to_dict(), indent=2))
    else:
        click.echo(f"Profile: @{profile_obj.name}")
        click.echo(f"  URL: {envelope_mod.mask_url(profile_obj.url)}")
        if profile_obj.format:
            click.echo(f"  Format: {profile_obj.format}")
        if profile_obj.headers:
            click.echo(f"  Headers: {len(profile_obj.headers)} custom header(s)")
        click.echo(f"  Timeout: {profile_obj.timeout}s")
        click.echo(f"  Max retries: {profile_obj.max_retries}")


@profile.command("list")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output as JSON.",
)
def profile_list_cmd(json_output: bool) -> None:
    """
    List all webhook profiles.

    \b
    Examples:
      g-gremlin webhook profile list
      g-gremlin webhook profile list --json
    """
    profiles_mod = _get_webhook_profiles()
    envelope_mod = _get_webhook_envelope()

    profile_list = profiles_mod.profile_list()

    if json_output:
        # to_dict() masks URL by default for safe output
        click.echo(json.dumps([p.to_dict() for p in profile_list], indent=2))
    else:
        if not profile_list:
            click.echo("No webhook profiles found.")
            click.echo("  Create one with: g-gremlin webhook profile set <name> --url <url>")
            return

        click.echo("Webhook profiles:")
        for p in profile_list:
            format_str = f" ({p.format})" if p.format else ""
            click.echo(f"  @{p.name}{format_str}: {envelope_mod.mask_url(p.url)}")


@profile.command("delete")
@click.argument("name")
@click.option(
    "--yes",
    is_flag=True,
    help="Skip confirmation prompt.",
)
def profile_delete_cmd(name: str, yes: bool) -> None:
    """
    Delete a webhook profile.

    \b
    Examples:
      g-gremlin webhook profile delete my-zapier
      g-gremlin webhook profile delete my-zapier --yes
    """
    profiles_mod = _get_webhook_profiles()

    profile_obj = profiles_mod.profile_get(name)
    if profile_obj is None:
        click.echo(f"Profile not found: @{name}", err=True)
        sys.exit(EXIT_USAGE)

    if not yes:
        if not click.confirm(f"Delete webhook profile @{name}?"):
            click.echo("Cancelled.")
            return

    deleted = profiles_mod.profile_delete(name)
    if deleted:
        click.echo(f"Deleted webhook profile @{name}")
    else:
        click.echo(f"Failed to delete profile @{name}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@webhook.command("test")
@click.argument("target")
@click.option(
    "--format",
    "webhook_format",
    type=click.Choice(["flat", "wrapped", "generic"], case_sensitive=False),
    help="Override format preset for this test.",
)
def webhook_test(target: str, webhook_format: str | None) -> None:
    """
    Test webhook connectivity with a sample payload.

    TARGET can be a URL or @profile-name.

    \b
    Examples:
      g-gremlin webhook test @my-zapier
      g-gremlin webhook test https://hooks.zapier.com/... --format flat
    """
    from g_gremlin.contracts import AgenticResult

    envelope_mod = _get_webhook_envelope()
    WebhookEmitter = _get_webhook_emitter()

    # Create test payload
    test_result = AgenticResult(
        command="g-gremlin webhook test",
        status="success",
        dry_run=True,
        result={
            "test": True,
            "message": "Webhook connectivity test from g-gremlin",
        },
    )

    # Mask target for display
    masked_target = envelope_mod.mask_url(target) if not target.startswith("@") else target

    click.echo(f"Testing webhook: {masked_target}")

    emitter = WebhookEmitter()
    try:
        delivery = emitter.emit(
            test_result,
            target,
            format=webhook_format,
        )

        if delivery.success:
            click.echo(f"Success: Delivered test payload (HTTP {delivery.status_code})")
            click.echo(f"Delivery ID: {delivery.delivery_id}")
        else:
            click.echo(f"Failed: {delivery.error}", err=True)
            if delivery.attempts > 1:
                click.echo(f"  Attempts: {delivery.attempts}", err=True)
            sys.exit(EXIT_REMOTE_ERROR)

    except ValueError as e:
        # Redact any sensitive data in error messages
        click.echo(f"Error: {envelope_mod.redact_sensitive(str(e))}", err=True)
        sys.exit(EXIT_USAGE)
    except Exception as e:
        # Redact any sensitive data in error messages
        click.echo(f"Error: {envelope_mod.redact_sensitive(str(e))}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


# =============================================================================
# Autopilot - Localhost Web UI
# =============================================================================


def _get_autopilot():
    """Lazy import autopilot to avoid loading FastAPI on --help."""
    from g_gremlin import autopilot

    return autopilot


@cli.group()
def autopilot_cmd() -> None:
    """
    Gremlin Autopilot - pre-built workflow recipes for RevOps.

    \b
    Plays are pre-built workflows that extract data and metadata from
    CRM systems into structured artifacts for documentation and analysis.
    They run without AI assistance - just configure and execute.

    \b
    Quick Start:
      1) g-gremlin autopilot play list               # See available plays
      2) g-gremlin autopilot play run <name> --dry-run  # Preview commands
      3) g-gremlin autopilot play run <name> -p key=value  # Execute

    \b
    Output:
      - artifacts/<play>/<timestamp>/run_summary.json  # Execution status
      - artifacts/<play>/<timestamp>/logs/             # Per-step logs
      - artifacts/<play>/<timestamp>/*.csv,*.md        # Extracted data

    \b
    Exit Codes:
      0 = success, 1 = play failed (see failed_step), 2 = usage error

    \b
    CRM Documentation Plays (require salesforce credential):
      document_cpq, document_lead_routing, document_case_routing,
      document_renewals, document_revenue_process

    \b
    See also:
      - docs/PLAYBOOKS.md             # User guide
      - docs/PLAY_AUTHORING.md        # Create custom plays
    """


# Rename the command for CLI (autopilot instead of autopilot_cmd)
autopilot_cmd.name = "autopilot"

# Deal Desk subcommands (lazy import to keep CLI light)
autopilot_cmd.add_command(
    LazyGroup(
        "g_gremlin.autopilot.dealdesk_cli:dealdesk_group",
        name="dealdesk",
        help="Deal desk automation (close verification, renewals, stamps).",
    )
)


@autopilot_cmd.command(
    "serve",
    hidden=os.getenv("G_GREMLIN_AUTOPILOT_ENABLED", "") != "1",
)
@click.option(
    "--port",
    default=7777,
    type=int,
    help="Port to listen on (default: 7777).",
)
@click.option(
    "--allow-remote",
    is_flag=True,
    help="Bind to all interfaces (with pairing code). Use only on trusted networks.",
)
@click.option(
    "--policy",
    "policy_path",
    default=None,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path. Falls back to G_GREMLIN_POLICY_PATH or ./autopilot_policy.yaml.",
)
def autopilot_serve(port: int, allow_remote: bool, policy_path: Path | None) -> None:
    """
    Start the Autopilot web server.

    By default, binds to localhost only. Use --allow-remote for LAN access.

    \b
    Includes an Admin dashboard at /admin with:
      - Runs & Receipts: browse execution history and decision paths
      - Pending Approvals: review plans, inspect steps, approve or reject
      - Policy Viewer: read-only view of the active AutonomyPolicy

    \b
    Examples:
      g-gremlin autopilot serve                   # Start on localhost:7777
      g-gremlin autopilot serve --port 8080       # Custom port
      g-gremlin autopilot serve --allow-remote    # LAN access with pairing
    """
    autopilot = _get_autopilot()
    autopilot.run_server(port=port, allow_remote=allow_remote, policy_path=policy_path)


@autopilot_cmd.command("plan")
@click.option(
    "--from",
    "from_path",
    required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Scout scan JSON file (ScoutScanResult/v1).",
)
@click.option(
    "--output",
    "-o",
    default=None,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Write Plan/v1 JSON to a file (default: stdout).",
)
def autopilot_plan(from_path: Path, output: Path | None) -> None:
    """
    Build an Autopilot Plan/v1 from a scout scan result.

    \b
    Examples:
      g-gremlin autopilot plan --from scout_scan.json
      g-gremlin autopilot plan --from scout_scan.json --output plan.json
    """
    from g_gremlin.autopilot.planner import build_plan
    from g_gremlin.autopilot.tools import get_default_registry
    from g_gremlin.scout.models import ScoutScanResult

    try:
        with open(from_path, encoding="utf-8") as f:
            payload = json.load(f)
        scan_result = ScoutScanResult.from_dict(payload)
    except Exception as exc:
        click.echo(f"Error loading scout scan: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    try:
        registry = get_default_registry()
        plan = build_plan(scan_result, registry)
        plan_payload = plan.to_dict()
    except Exception as exc:
        click.echo(f"Error building plan: {exc}", err=True)
        sys.exit(EXIT_ISSUES)

    if output:
        try:
            output.parent.mkdir(parents=True, exist_ok=True)
            with open(output, "w", encoding="utf-8") as f:
                json.dump(plan_payload, f, indent=2)
        except Exception as exc:
            click.echo(f"Error writing plan: {exc}", err=True)
            sys.exit(EXIT_ISSUES)
    else:
        click.echo(json.dumps(plan_payload, indent=2))


@autopilot_cmd.group("policy")
def autopilot_policy() -> None:
    """
    Manage Autopilot policy files.
    """


@autopilot_policy.command("init")
@click.option(
    "--org-alias",
    default=None,
    help="Org alias to embed in the policy.",
)
@click.option(
    "--output",
    "-o",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output policy YAML path (default: ./autopilot_policy.yaml).",
)
def autopilot_policy_init(org_alias: str | None, output: Path) -> None:
    """
    Generate a default Autopilot policy YAML.
    """
    from g_gremlin.autopilot.policy import generate_default_policy

    try:
        import yaml
    except Exception as exc:
        click.echo(f"Error: PyYAML is required to write policy files ({exc}).", err=True)
        sys.exit(EXIT_ISSUES)

    if not org_alias:
        org_alias = click.prompt("Org alias")

    policy = generate_default_policy(org_alias)
    payload = policy.model_dump(by_alias=True, exclude_none=True)

    try:
        output.parent.mkdir(parents=True, exist_ok=True)
        with open(output, "w", encoding="utf-8") as f:
            yaml.safe_dump(payload, f, sort_keys=False, default_flow_style=False, allow_unicode=False)
        click.echo(f"Policy written to: {output}")
    except Exception as exc:
        click.echo(f"Error writing policy: {exc}", err=True)
        sys.exit(EXIT_ISSUES)


@autopilot_cmd.command("apply")
@click.argument(
    "plan_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--approve-token",
    default=None,
    help="Approval token (required for approval_required steps).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Run policy checks and emit a receipt without executing tools.",
)
def autopilot_apply(
    plan_path: Path,
    policy_path: Path,
    approve_token: str | None,
    dry_run: bool,
) -> None:
    """
    Apply a Plan/v1 and write a receipt.
    """
    from g_gremlin.autopilot.contracts import PlanV1
    from g_gremlin.autopilot.executor import PlanExecutor
    from g_gremlin.autopilot.policy import apply_policy_overlays, load_policy
    from g_gremlin.autopilot.approvals import ApprovalTokenStore, default_approval_ttl_seconds
    from g_gremlin.autopilot.receipts import ReceiptWriter
    from g_gremlin.autopilot.state import resolve_state_dir
    from g_gremlin.autopilot.tools import get_default_registry

    try:
        with open(plan_path, encoding="utf-8") as f:
            plan_payload = json.load(f)
        plan = PlanV1.from_dict(plan_payload)
    except Exception as exc:
        click.echo(f"Error loading plan: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    try:
        policy = load_policy(policy_path)
    except Exception as exc:
        click.echo(f"Error loading policy: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    state_dir = resolve_state_dir(policy)
    policy = apply_policy_overlays(policy, state_dir)
    executor = PlanExecutor(policy=policy, registry=get_default_registry(), state_dir=state_dir)
    receipt = executor.execute(plan, approve_token=approve_token, dry_run=dry_run)

    writer = ReceiptWriter(state_dir)
    receipt_dir = writer.write(receipt, plan)

    approval_token = None
    pending_plan_path = None
    if receipt.outcome == "approval_required":
        pending_dir = state_dir / "pending" / plan.run_id
        pending_dir.mkdir(parents=True, exist_ok=True)
        pending_plan_path = pending_dir / "plan.json"
        try:
            with open(pending_plan_path, "w", encoding="utf-8") as f:
                json.dump(plan.to_dict(), f, indent=2)
        except Exception as exc:
            click.echo(f"Error writing pending plan: {exc}", err=True)
        else:
            store = ApprovalTokenStore(state_dir / "db" / "approval_tokens.sqlite")
            approval_token = store.create_token(
                plan_hash=plan.plan_hash or "",
                policy_hash=plan.policy_hash or "",
                run_id=plan.run_id,
                ttl_seconds=default_approval_ttl_seconds(),
            ).token

    summary = {
        "run_id": receipt.run_id,
        "outcome": receipt.outcome,
        "receipt_dir": str(receipt_dir),
        "approval_token": approval_token,
        "pending_plan_path": str(pending_plan_path) if pending_plan_path else None,
    }
    click.echo(json.dumps(summary, indent=2))


@autopilot_cmd.group("receipt")
def autopilot_receipt() -> None:
    """
    Manage Autopilot receipts.
    """


@autopilot_receipt.command("show")
@click.argument("run_id")
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (default: from policy or ~/.g_gremlin/autopilot).",
)
def autopilot_receipt_show(run_id: str, state_dir: Path | None) -> None:
    """
    Show a receipt by run_id.
    """
    from g_gremlin.autopilot.receipts import load_receipt
    from g_gremlin.autopilot.state import resolve_state_dir
    from g_gremlin.autopilot.policy import load_policy

    resolved_state_dir = state_dir
    if resolved_state_dir is None:
        policy_path = Path("autopilot_policy.yaml")
        if policy_path.exists():
            policy = load_policy(policy_path)
            resolved_state_dir = resolve_state_dir(policy, create=False)
        else:
            resolved_state_dir = gremlin_path("autopilot").resolve()

    receipt = load_receipt(resolved_state_dir, run_id)
    click.echo(json.dumps(receipt.to_dict(), indent=2))


def _resolve_autopilot_state_dir(
    policy_path: Path | None,
    state_dir: Path | None,
    *,
    create: bool = False,
) -> Path:
    if state_dir is not None:
        return Path(state_dir).resolve()

    if policy_path and policy_path.exists():
        from g_gremlin.autopilot.policy import load_policy
        from g_gremlin.autopilot.state import resolve_state_dir

        policy = load_policy(policy_path)
        return resolve_state_dir(policy, create=create)

    return gremlin_path("autopilot").resolve()


def _archive_pending_dir(state_dir: Path, run_id: str, *, status: str) -> None:
    import shutil

    pending_dir = Path(state_dir) / "pending" / run_id
    if not pending_dir.exists():
        return
    target_root = Path(state_dir) / "pending_archive" / status
    target_root.mkdir(parents=True, exist_ok=True)
    target_dir = target_root / run_id
    if target_dir.exists():
        shutil.rmtree(target_dir, ignore_errors=True)
    try:
        shutil.move(str(pending_dir), str(target_dir))
    except Exception:
        shutil.rmtree(pending_dir, ignore_errors=True)


def _safe_parse_iso(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except Exception:
        return None


@autopilot_cmd.group("admin")
def autopilot_admin() -> None:
    """
    Admin helpers for local Autopilot state (receipts, pending, policy).
    """


@autopilot_admin.group("receipts")
def autopilot_admin_receipts() -> None:
    """
    List and inspect Autopilot receipts.
    """


@autopilot_admin_receipts.command("list")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy or ~/.g_gremlin/autopilot).",
)
def autopilot_admin_receipts_list(policy_path: Path, state_dir: Path | None) -> None:
    """
    List recent receipts with summary metadata.
    """
    resolved_state_dir = _resolve_autopilot_state_dir(policy_path, state_dir, create=False)
    receipts_dir = resolved_state_dir / "receipts"
    items = []
    if receipts_dir.exists():
        for run_dir in receipts_dir.iterdir():
            if not run_dir.is_dir():
                continue
            receipt_path = run_dir / "receipt.json"
            if not receipt_path.exists():
                continue
            try:
                data = json.loads(receipt_path.read_text(encoding="utf-8"))
            except Exception:
                continue
            items.append({
                "run_id": data.get("run_id") or run_dir.name,
                "outcome": data.get("outcome"),
                "started_at": data.get("started_at"),
                "completed_at": data.get("completed_at"),
                "change_count": len(data.get("changes") or []),
            })
    items.sort(
        key=lambda x: _safe_parse_iso(x.get("started_at")) or datetime.min,
        reverse=True,
    )
    click.echo(json.dumps(items[:100], indent=2))


@autopilot_admin_receipts.command("show")
@click.argument("run_id")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy or ~/.g_gremlin/autopilot).",
)
def autopilot_admin_receipts_show(run_id: str, policy_path: Path, state_dir: Path | None) -> None:
    """
    Show receipt + plan JSON for a run_id.
    """
    resolved_state_dir = _resolve_autopilot_state_dir(policy_path, state_dir, create=False)
    run_dir = resolved_state_dir / "receipts" / run_id
    receipt_path = run_dir / "receipt.json"
    plan_path = run_dir / "plan.json"
    if not receipt_path.exists():
        click.echo(f"Receipt not found for run_id={run_id}", err=True)
        sys.exit(EXIT_USAGE)
    receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
    plan = json.loads(plan_path.read_text(encoding="utf-8")) if plan_path.exists() else {}
    click.echo(json.dumps({"receipt": receipt, "plan": plan}, indent=2))


@autopilot_admin.group("pending")
def autopilot_admin_pending() -> None:
    """
    List and manage pending approval plans.
    """


@autopilot_admin_pending.command("list")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy or ~/.g_gremlin/autopilot).",
)
def autopilot_admin_pending_list(policy_path: Path, state_dir: Path | None) -> None:
    """
    List pending plans awaiting approval.
    """
    resolved_state_dir = _resolve_autopilot_state_dir(policy_path, state_dir, create=False)
    pending_dir = resolved_state_dir / "pending"
    items = []
    if pending_dir.exists():
        for run_dir in pending_dir.iterdir():
            if not run_dir.is_dir():
                continue
            plan_path = run_dir / "plan.json"
            if not plan_path.exists():
                continue
            try:
                data = json.loads(plan_path.read_text(encoding="utf-8"))
            except Exception:
                continue
            created_at = datetime.fromtimestamp(plan_path.stat().st_mtime, timezone.utc).isoformat()
            items.append({
                "run_id": data.get("run_id") or run_dir.name,
                "summary": data.get("summary", ""),
                "risk": data.get("risk", {}),
                "step_count": len(data.get("steps") or []),
                "created_at": created_at,
            })
    items.sort(
        key=lambda x: _safe_parse_iso(x.get("created_at")) or datetime.min,
        reverse=True,
    )
    click.echo(json.dumps(items, indent=2))


@autopilot_admin_pending.command("show")
@click.argument("run_id")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy or ~/.g_gremlin/autopilot).",
)
def autopilot_admin_pending_show(run_id: str, policy_path: Path, state_dir: Path | None) -> None:
    """
    Show a pending plan JSON by run_id.
    """
    resolved_state_dir = _resolve_autopilot_state_dir(policy_path, state_dir, create=False)
    plan_path = resolved_state_dir / "pending" / run_id / "plan.json"
    if not plan_path.exists():
        click.echo(f"Pending plan not found for run_id={run_id}", err=True)
        sys.exit(EXIT_USAGE)
    plan = json.loads(plan_path.read_text(encoding="utf-8"))
    click.echo(json.dumps(plan, indent=2))


@autopilot_admin_pending.command("approve")
@click.argument("run_id")
@click.option(
    "--approve-token",
    default=None,
    help="Approval token (optional; if omitted a local token is generated).",
)
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy state_dir).",
)
def autopilot_admin_pending_approve(
    run_id: str,
    approve_token: str | None,
    policy_path: Path,
    state_dir: Path | None,
) -> None:
    """
    Approve and execute a pending plan.
    """
    from g_gremlin.autopilot.approvals import ApprovalTokenStore, default_approval_ttl_seconds
    from g_gremlin.autopilot.contracts import PlanV1
    from g_gremlin.autopilot.executor import PlanExecutor
    from g_gremlin.autopilot.policy import apply_policy_overlays, compute_policy_hash, load_policy
    from g_gremlin.autopilot.receipts import ReceiptWriter
    from g_gremlin.autopilot.tools import get_default_registry

    policy = load_policy(policy_path)
    resolved_state_dir = _resolve_autopilot_state_dir(policy_path, state_dir, create=True)
    policy = apply_policy_overlays(policy, resolved_state_dir)
    policy_hash = compute_policy_hash(policy)

    plan_path = resolved_state_dir / "pending" / run_id / "plan.json"
    if not plan_path.exists():
        click.echo(f"Pending plan not found for run_id={run_id}", err=True)
        sys.exit(EXIT_USAGE)
    plan = PlanV1.from_dict(json.loads(plan_path.read_text(encoding="utf-8")))
    if plan.policy_hash and plan.policy_hash != policy_hash:
        click.echo("Policy hash mismatch; refusing to execute.", err=True)
        sys.exit(EXIT_ISSUES)

    token = approve_token.strip() if approve_token else ""
    store = ApprovalTokenStore(resolved_state_dir / "db" / "approval_tokens.sqlite")
    if not token:
        token = store.create_token(
            plan.plan_hash or "",
            policy_hash,
            plan.run_id,
            ttl_seconds=default_approval_ttl_seconds(),
        ).token
    consumed = store.consume_token(token, plan.plan_hash or "", policy_hash, plan.run_id, metadata={"action": "approve_cli"})
    if not consumed:
        click.echo("Invalid or expired approval token.", err=True)
        sys.exit(EXIT_ISSUES)

    executor = PlanExecutor(policy=policy, registry=get_default_registry(), state_dir=resolved_state_dir)
    receipt = executor.execute(plan, approve_token=token)
    writer = ReceiptWriter(resolved_state_dir)
    writer.write(receipt, plan)
    _archive_pending_dir(resolved_state_dir, run_id, status="approved")
    click.echo(json.dumps(receipt.to_dict(), indent=2))


@autopilot_admin_pending.command("reject")
@click.argument("run_id")
@click.option(
    "--approve-token",
    default=None,
    help="Approval token (optional; if omitted a local token is generated).",
)
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy state_dir).",
)
def autopilot_admin_pending_reject(
    run_id: str,
    approve_token: str | None,
    policy_path: Path,
    state_dir: Path | None,
) -> None:
    """
    Reject a pending plan (consumes approval token).
    """
    from g_gremlin.autopilot.approvals import ApprovalTokenStore, default_approval_ttl_seconds
    from g_gremlin.autopilot.contracts import PlanV1
    from g_gremlin.autopilot.policy import apply_policy_overlays, compute_policy_hash, load_policy

    policy = load_policy(policy_path)
    resolved_state_dir = _resolve_autopilot_state_dir(policy_path, state_dir, create=True)
    policy = apply_policy_overlays(policy, resolved_state_dir)
    policy_hash = compute_policy_hash(policy)

    plan_path = resolved_state_dir / "pending" / run_id / "plan.json"
    if not plan_path.exists():
        click.echo(f"Pending plan not found for run_id={run_id}", err=True)
        sys.exit(EXIT_USAGE)
    plan = PlanV1.from_dict(json.loads(plan_path.read_text(encoding="utf-8")))

    token = approve_token.strip() if approve_token else ""
    store = ApprovalTokenStore(resolved_state_dir / "db" / "approval_tokens.sqlite")
    if not token:
        token = store.create_token(
            plan.plan_hash or "",
            policy_hash,
            plan.run_id,
            ttl_seconds=default_approval_ttl_seconds(),
        ).token
    consumed = store.consume_token(token, plan.plan_hash or "", policy_hash, plan.run_id, metadata={"action": "reject_cli"})
    if not consumed:
        click.echo("Invalid or expired approval token.", err=True)
        sys.exit(EXIT_ISSUES)
    _archive_pending_dir(resolved_state_dir, run_id, status="rejected")
    click.echo(json.dumps({"status": "rejected", "run_id": run_id}, indent=2))


@autopilot_admin.group("policy")
def autopilot_admin_policy() -> None:
    """
    Inspect the Autopilot policy.
    """


@autopilot_admin_policy.command("show")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
def autopilot_admin_policy_show(policy_path: Path) -> None:
    """
    Show the current policy JSON.
    """
    from g_gremlin.autopilot.policy import load_policy

    policy = load_policy(policy_path)
    click.echo(json.dumps(policy.model_dump(by_alias=True, exclude_none=True), indent=2))


@autopilot_cmd.group("slack")
def autopilot_slack() -> None:
    """
    Run Autopilot Slack transports.
    """


@autopilot_slack.command("start")
@click.option(
    "--app-token",
    default=None,
    help="Slack Socket Mode app token (xapp-...).",
)
@click.option(
    "--bot-token",
    default=None,
    help="Slack bot token (xoxb-...).",
)
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--state-dir",
    default=None,
    type=click.Path(file_okay=False, path_type=Path),
    help="State directory (optional; defaults to policy state_dir).",
)
def autopilot_slack_start(
    app_token: str | None,
    bot_token: str | None,
    policy_path: Path,
    state_dir: Path | None,
) -> None:
    """
    Start Slack Socket Mode listener for Autopilot approvals.
    """
    from g_gremlin.autopilot.slack_socket import SlackSocketModeRunner
    from g_gremlin.autopilot.slack_interactions import handle_interactive

    app_token = app_token or os.getenv("SLACK_APP_TOKEN")
    bot_token = bot_token or os.getenv("SLACK_BOT_TOKEN")

    if not app_token or not bot_token:
        click.echo("Slack tokens are required (--app-token/--bot-token or env).", err=True)
        sys.exit(EXIT_USAGE)

    def _handler(payload: dict) -> None:
        result = handle_interactive(payload, policy_path=policy_path, state_dir=state_dir)
        click.echo(json.dumps(result, indent=2))

    try:
        runner = SlackSocketModeRunner(app_token=app_token, bot_token=bot_token, handler=_handler)
        runner.start()
    except Exception as exc:
        click.echo(f"Slack Socket Mode failed: {exc}", err=True)
        sys.exit(EXIT_ISSUES)


@autopilot_cmd.group("daemon")
def autopilot_daemon() -> None:
    """
    Run the Autopilot daemon (scheduled scout scans).
    """


@autopilot_daemon.command("start")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
@click.option(
    "--profile",
    default="nightly",
    help="Schedule profile (nightly|hourly).",
)
@click.option(
    "--once",
    is_flag=True,
    help="Run a single scan and exit.",
)
@click.option(
    "--config",
    "config_path",
    default=None,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Scout scan config path (optional).",
)
@click.option(
    "--slack-bot-token",
    default=None,
    help="Slack bot token (xoxb-...) for approval/digest posting.",
)
def autopilot_daemon_start(
    policy_path: Path,
    profile: str,
    once: bool,
    config_path: Path | None,
    slack_bot_token: str | None,
) -> None:
    """
    Start the Autopilot daemon.
    """
    from g_gremlin.autopilot.daemon import AutopilotDaemon
    from g_gremlin.autopilot.policy import load_policy

    try:
        policy = load_policy(policy_path)
        daemon = AutopilotDaemon(
            policy,
            profile=profile,
            once=once,
            config_path=config_path,
            bot_token=slack_bot_token,
        )
        daemon.run()
    except Exception as exc:
        click.echo(f"Daemon failed: {exc}", err=True)
        sys.exit(EXIT_ISSUES)


@autopilot_daemon.command("stop")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
def autopilot_daemon_stop(policy_path: Path) -> None:
    """
    Stop the Autopilot daemon.
    """
    from g_gremlin.autopilot.daemon import stop_daemon
    from g_gremlin.autopilot.policy import load_policy
    from g_gremlin.autopilot.state import resolve_state_dir

    policy = load_policy(policy_path)
    state_dir = resolve_state_dir(policy, create=False)
    lock_path = state_dir / "locks" / f"{policy.org_alias}.lock"
    if stop_daemon(lock_path):
        click.echo("Daemon stop signal sent.")
    else:
        click.echo("Daemon not running or could not be stopped.")


@autopilot_daemon.command("status")
@click.option(
    "--policy",
    "policy_path",
    default=Path("autopilot_policy.yaml"),
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Policy YAML path (default: ./autopilot_policy.yaml).",
)
def autopilot_daemon_status(policy_path: Path) -> None:
    """
    Show Autopilot daemon status.
    """
    from g_gremlin.autopilot.daemon import daemon_status
    from g_gremlin.autopilot.policy import load_policy
    from g_gremlin.autopilot.state import resolve_state_dir

    policy = load_policy(policy_path)
    state_dir = resolve_state_dir(policy, create=False)
    lock_path = state_dir / "locks" / f"{policy.org_alias}.lock"
    status = daemon_status(lock_path)
    click.echo(json.dumps(status, indent=2))


@autopilot_cmd.group("play")
def autopilot_play() -> None:
    """
    Manage and run Autopilot plays (workflow templates).

    \b
    Plays execute multi-step workflows defined in YAML. They extract data
    via SOQL queries and SF CLI metadata retrieval, producing structured
    artifacts for documentation.

    \b
    Workflow:
      1) list    - See available plays and credential status
      2) validate - Check play YAML for errors
      3) run --dry-run - Preview commands without executing
      4) run    - Execute and produce artifacts

    \b
    Examples:
      g-gremlin autopilot play list
      g-gremlin autopilot play list --json
      g-gremlin autopilot play validate document_cpq
      g-gremlin autopilot play run test_echo --dry-run
      g-gremlin autopilot play run document_cpq -p output_format=markdown

    \b
    Play locations:
      - config/plays/           # Built-in plays
      - ~/.g_gremlin/plays/     # User-defined plays
    """


@autopilot_play.command("list")
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output as JSON.",
)
@click.option(
    "--category",
    default=None,
    help="Filter plays by category (e.g., crm_documentation).",
)
def autopilot_play_list(json_output: bool, category: str | None) -> None:
    """
    List available plays.

    \b
    Shows all plays with credential status:
      [Y] = credentials configured, play is runnable
      [N] = missing required credentials

    \b
    JSON output includes:
      - name, description, icon, category
      - credentials_configured (boolean)

    \b
    Examples:
      g-gremlin autopilot play list
      g-gremlin autopilot play list --json
      g-gremlin autopilot play list --category crm_documentation
    """
    import yaml

    from g_gremlin.autopilot.plays import DEFAULT_PLAYS_DIR

    # Get user credentials
    try:
        cred_provider = _get_credential_provider()
        user_creds = set(cred_provider.list_configured_integrations())
    except Exception:
        user_creds = set()

    # Load all plays raw to get requires_credentials
    all_plays_raw = []
    if DEFAULT_PLAYS_DIR.exists():
        for yaml_file in DEFAULT_PLAYS_DIR.glob("*.yaml"):
            try:
                with open(yaml_file, encoding="utf-8") as f:
                    data = yaml.safe_load(f)
                    if not data or not data.get("name"):
                        continue
                    play_category = data.get("category", "general")
                    if category and play_category.lower() != category.lower():
                        continue
                    all_plays_raw.append(data)
            except Exception:
                continue

    if not all_plays_raw:
        if category:
            click.echo(f"No plays found for category: {category}")
        else:
            click.echo("No plays found.")
        return

    if json_output:
        output = []
        for play in all_plays_raw:
            required = play.get("requires_credentials", [])
            # Use ALL - all required credentials must be present
            has_creds = not required or all(c in user_creds for c in required)
            output.append({
                "name": play["name"],
                "description": play.get("description", ""),
                "icon": play.get("icon", "📋"),
                "category": play.get("category", "general"),
                "credentials_configured": has_creds,
            })
        click.echo(json.dumps(output, indent=2))
    else:
        click.echo("Available plays:")
        for play_raw in all_plays_raw:
            required = play_raw.get("requires_credentials", [])
            has_creds = not required or all(c in user_creds for c in required)
            status = "[Y]" if has_creds else "[N]"
            # Use safe ASCII for Windows console compatibility
            icon = play_raw.get("icon", "*")
            try:
                icon.encode("ascii")
            except UnicodeEncodeError:
                icon = "*"
            play_category = play_raw.get("category", "general")
            click.echo(f"  {icon} {play_raw['name']} {status} [{play_category}]")
            click.echo(f"     {play_raw.get('description', '')}")


@autopilot_play.command("validate")
@click.argument("play_name")
def autopilot_play_validate(play_name: str) -> None:
    """
    Validate a play definition.

    \b
    Checks:
      - YAML schema validity
      - All referenced commands exist
      - All template variables have parameters or defaults
      - Required credentials are specified

    \b
    Examples:
      g-gremlin autopilot play validate document_cpq
      g-gremlin autopilot play validate test_echo
    """
    import re
    import yaml

    from g_gremlin.autopilot.plays import DEFAULT_PLAYS_DIR, PlayLoader

    errors = []
    warnings = []

    # Find play file
    play_path = DEFAULT_PLAYS_DIR / f"{play_name}.yaml"
    if not play_path.exists():
        # Try to find by play name in YAML
        for f in DEFAULT_PLAYS_DIR.glob("*.yaml"):
            try:
                with open(f, encoding="utf-8") as fp:
                    data = yaml.safe_load(fp)
                    if data and data.get("name") == play_name:
                        play_path = f
                        break
            except Exception:
                continue

    if not play_path.exists():
        click.echo(f"Error: Play '{play_name}' not found", err=True)
        sys.exit(EXIT_USAGE)

    # Validate schema by loading
    play = None
    try:
        # Load with all credentials to skip filtering
        loader = PlayLoader(
            plays_dirs=[play_path.parent],
            user_credentials={"*", "salesforce", "google", "apollo"},
        )
        play = loader._load_play(play_path)
        if not play:
            errors.append("Failed to parse play definition")
    except Exception as e:
        errors.append(f"Schema error: {e}")

    if play:
        # Validate commands/tools
        for step in play.steps:
            if step.command:
                # Extract command name (first word)
                cmd = step.command.strip().split()[0]
                if cmd not in ("g-gremlin", "echo", "cat", "sf", "python"):
                    warnings.append(
                        f"Step '{step.name or step.label}' uses external command: {cmd}"
                    )
            elif step.tool:
                # Tool-based step - check if mapped
                from g_gremlin.autopilot.runner import TOOL_COMMAND_MAP
                if step.tool not in TOOL_COMMAND_MAP:
                    warnings.append(
                        f"Step '{step.name or step.label}' uses unmapped tool: {step.tool}"
                    )

        # Validate all template vars have parameters
        param_names = {p.name for p in play.parameters}
        # Add built-in params
        param_names.update(["output_dir", "play_name", "timestamp"])

        for step in play.steps:
            template_str = step.command or json.dumps(step.args)
            vars_used = re.findall(r"\{\{\s*([^}=<>!]+)\s*", template_str)
            for var in vars_used:
                var_name = var.strip().split()[0]
                if var_name not in param_names:
                    errors.append(
                        f"Step '{step.name or step.label}' uses undefined variable: {var_name}"
                    )

        # Validate choice parameters have choices
        for param in play.parameters:
            if param.type == "choice" and not param.choices:
                errors.append(f"Parameter '{param.name}' is type 'choice' but has no choices")

    # Print results
    if errors:
        click.echo("ERRORS:", err=True)
        for e in errors:
            click.echo(f"  ✗ {e}", err=True)
    if warnings:
        click.echo("WARNINGS:")
        for w in warnings:
            click.echo(f"  ⚠ {w}")
    if not errors:
        click.echo(f"✓ Play '{play_name}' is valid")
        sys.exit(EXIT_SUCCESS)
    else:
        sys.exit(EXIT_ISSUES)


@autopilot_play.command("run")
@click.argument("play_name")
@click.option(
    "--param",
    "-p",
    multiple=True,
    help="Play parameter in key=value format. Can be used multiple times.",
)
@click.option(
    "--output-dir",
    default=None,
    help="Output directory for artifacts (auto-generated if not specified).",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview commands without executing them.",
)
def autopilot_play_run(
    play_name: str,
    param: tuple[str, ...],
    output_dir: str | None,
    dry_run: bool,
) -> None:
    """
    Run a play with specified parameters.

    \b
    Output:
      - artifacts/<play>/<timestamp>/run_summary.json  # Check status, failed_step
      - artifacts/<play>/<timestamp>/logs/*.log        # Per-step command logs
      - artifacts/<play>/<timestamp>/*.csv,*.md        # Extracted data

    \b
    Exit Codes:
      0 = success
      1 = play failed (check run_summary.json for failed_step)
      2 = usage error (invalid args, missing required params)

    \b
    Reserved Built-ins (always available in templates):
      {{ output_dir }}   - Output directory path
      {{ play_name }}    - Current play name
      {{ timestamp }}    - Execution timestamp (YYYYMMDD_HHMMSS)

    \b
    Examples:
      g-gremlin autopilot play run test_echo
      g-gremlin autopilot play run test_echo --dry-run
      g-gremlin autopilot play run document_cpq -p output_format=markdown
      g-gremlin autopilot play run document_cpq --output-dir ./my_output
    """
    from g_gremlin.autopilot.runner import PlayRunner

    # Parse parameters
    params = {}
    for p in param:
        if "=" in p:
            key, value = p.split("=", 1)
            # Try to parse as boolean or number
            if value.lower() == "true":
                params[key] = True
            elif value.lower() == "false":
                params[key] = False
            else:
                try:
                    params[key] = float(value) if "." in value else int(value)
                except ValueError:
                    params[key] = value
        else:
            click.echo(f"Invalid parameter format: {p} (expected key=value)", err=True)
            sys.exit(EXIT_USAGE)

    try:
        runner = PlayRunner()
        result = runner.run(
            play_name=play_name,
            params=params,
            output_dir=output_dir,
            dry_run=dry_run,
        )

        if result.status == "success":
            click.echo("\nPlay completed successfully")
            click.echo(f"  Output: {result.output_dir}")
            if result.artifacts:
                click.echo(f"  Artifacts: {len(result.artifacts)} files")
            sys.exit(EXIT_SUCCESS)
        else:
            click.echo(f"\nPlay failed at step: {result.failed_step}", err=True)
            sys.exit(EXIT_ISSUES)
    except ValueError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(EXIT_USAGE)
    except Exception as e:
        click.echo(f"Error running play: {e}", err=True)
        sys.exit(EXIT_ISSUES)


@autopilot_cmd.group("architect")
def autopilot_architect() -> None:
    """
    Architect - blueprint-driven project builder for complex RevOps implementations.
    """


def _parse_architect_params(param: tuple[str, ...]) -> dict[str, Any]:
    params: dict[str, Any] = {}
    for p in param:
        if "=" not in p:
            raise click.ClickException(f"Invalid parameter format: {p} (expected key=value)")
        key, value = p.split("=", 1)
        if value.lower() == "true":
            params[key] = True
        elif value.lower() == "false":
            params[key] = False
        else:
            try:
                params[key] = float(value) if "." in value else int(value)
            except ValueError:
                params[key] = value
    return params


@autopilot_architect.command("list")
@click.option("--json", "json_output", is_flag=True, help="Output JSON.")
def autopilot_architect_list(json_output: bool) -> None:
    """List available Architect blueprints."""
    from g_gremlin.autopilot.blueprints import BlueprintLoader
    from g_gremlin.autopilot.tools import get_default_registry

    try:
        cred_provider = _get_credential_provider()
        user_creds = set(cred_provider.list_configured_integrations())
    except Exception:
        user_creds = set()

    loader = BlueprintLoader(user_credentials=user_creds)
    registry = get_default_registry()
    blueprints = loader.list_blueprints()
    if json_output:
        payload = []
        for blueprint in blueprints:
            entry = {
                "name": blueprint.name,
                "description": blueprint.description,
                "phases": list(blueprint.phases.keys()),
            }
            if blueprint.variants:
                availability = {}
                for variant in blueprint.variants:
                    required_tools = (blueprint.requires_tools or {}).get(variant) or []
                    missing_tools = [tool for tool in required_tools if not registry.get_handler(tool)]
                    availability[variant] = {
                        "available": not missing_tools,
                        "missing_tools": missing_tools,
                    }
                entry["variants"] = blueprint.variants
                entry["availability"] = availability
            payload.append(entry)
        click.echo(json.dumps(payload, indent=2))
        return

    if not blueprints:
        click.echo("No blueprints found.")
        return
    click.echo("Available blueprints:")
    for blueprint in blueprints:
        if blueprint.variants:
            variants = []
            for variant in blueprint.variants:
                required_tools = (blueprint.requires_tools or {}).get(variant) or []
                missing_tools = [tool for tool in required_tools if not registry.get_handler(tool)]
                if missing_tools:
                    variants.append(f"{variant} missing: {', '.join(missing_tools)}")
                else:
                    variants.append(f"{variant} ok")
            click.echo(f"  {blueprint.name}  [{', '.join(variants)}]")
        else:
            click.echo(f"  {blueprint.name}")
        if blueprint.description:
            click.echo(f"    {blueprint.description}")


@autopilot_architect.command("init")
@click.argument("blueprint_name")
@click.option("--variant", default=None, help="CRM variant (salesforce or hubspot).")
@click.option("--prod-only", "prod_only", is_flag=True, help="HubSpot single-profile mode.")
@click.option(
    "--param",
    "-p",
    multiple=True,
    help="Blueprint parameter in key=value format. Can be used multiple times.",
)
def autopilot_architect_init(
    blueprint_name: str,
    variant: str | None,
    prod_only: bool,
    param: tuple[str, ...],
) -> None:
    """Initialize a new Architect project."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    try:
        params = _parse_architect_params(param)
        if variant:
            params["crm"] = variant
        if prod_only:
            params["prod_only"] = True
    except click.ClickException as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    orchestrator = ArchitectOrchestrator()
    try:
        project = orchestrator.init_project(blueprint_name, params)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    click.echo(json.dumps({
        "project_id": project.project_id,
        "blueprint": project.blueprint_name,
        "project_dir": project.project_dir,
        "status": project.status.value,
    }, indent=2))


@autopilot_architect.command("discover")
@click.argument("project_id")
def autopilot_architect_discover(project_id: str) -> None:
    """Run discovery phase for a project."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    try:
        result = orchestrator.run_phase(project_id, "discover")
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps(result.model_dump(), indent=2))


@autopilot_architect.command("propose")
@click.argument("project_id")
@click.option("--no-llm", is_flag=True, help="Generate skeleton proposal without LLM.")
def autopilot_architect_propose(project_id: str, no_llm: bool) -> None:
    """Generate proposal for a project."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    try:
        result = orchestrator.run_phase(project_id, "propose", no_llm=no_llm)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps(result.model_dump(), indent=2))


@autopilot_architect.command("token")
@click.argument("project_id")
@click.option(
    "--phase",
    "phase_name",
    required=True,
    type=click.Choice(["deploy_sandbox", "deploy_prod"], case_sensitive=False),
    help="Phase to authorize.",
)
@click.option("--ttl", "ttl_seconds", default=600, type=int, show_default=True)
def autopilot_architect_token(project_id: str, phase_name: str, ttl_seconds: int) -> None:
    """Issue an approval token for deploy/promote phases."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    try:
        token = orchestrator.issue_token(project_id, phase_name, ttl_seconds=ttl_seconds)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps({"project_id": project_id, "phase": phase_name, "token": token}, indent=2))


@autopilot_architect.command("deploy")
@click.argument("project_id")
@click.option("--approve-token", default=None, help="Approval token.")
def autopilot_architect_deploy(project_id: str, approve_token: str | None) -> None:
    """Deploy metadata pack to sandbox (approval required)."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    if not approve_token:
        approve_token = click.prompt("Approval token", hide_input=True)
    try:
        result = orchestrator.run_phase(project_id, "deploy_sandbox", approve_token=approve_token)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps(result.model_dump(), indent=2))


@autopilot_architect.command("verify")
@click.argument("project_id")
def autopilot_architect_verify(project_id: str) -> None:
    """Verify sandbox deployment."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    try:
        result = orchestrator.run_phase(project_id, "verify_sandbox")
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps(result.model_dump(), indent=2))


@autopilot_architect.command("promote")
@click.argument("project_id")
@click.option("--approve-token", default=None, help="Approval token.")
def autopilot_architect_promote(project_id: str, approve_token: str | None) -> None:
    """Alias for deploy-prod."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    if not approve_token:
        approve_token = click.prompt("Approval token", hide_input=True)
    try:
        result = orchestrator.run_phase(project_id, "deploy_prod", approve_token=approve_token)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps(result.model_dump(), indent=2))


@autopilot_architect.command("deploy-prod")
@click.argument("project_id")
@click.option("--approve-token", default=None, help="Approval token.")
def autopilot_architect_deploy_prod(project_id: str, approve_token: str | None) -> None:
    """Deploy metadata pack to production (approval required)."""
    from g_gremlin.autopilot.architect import ArchitectOrchestrator

    orchestrator = ArchitectOrchestrator()
    if not approve_token:
        approve_token = click.prompt("Approval token", hide_input=True)
    try:
        result = orchestrator.run_phase(project_id, "deploy_prod", approve_token=approve_token)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_ISSUES)
    click.echo(json.dumps(result.model_dump(), indent=2))


@autopilot_architect.command("status")
@click.argument("project_id")
def autopilot_architect_status(project_id: str) -> None:
    """Show project status and phase history."""
    from g_gremlin.autopilot.architect_state import ArchitectStateStore

    store = ArchitectStateStore()
    project = store.get_project(project_id)
    if not project:
        click.echo(f"Project not found: {project_id}", err=True)
        sys.exit(EXIT_USAGE)
    click.echo(json.dumps(project.model_dump(), indent=2))


@autopilot_architect.command("projects")
def autopilot_architect_projects() -> None:
    """List all Architect projects."""
    from g_gremlin.autopilot.architect_state import ArchitectStateStore

    store = ArchitectStateStore()
    projects = store.list_projects()
    payload = [
        {
            "project_id": p.project_id,
            "blueprint": p.blueprint_name,
            "status": p.status.value,
            "project_dir": p.project_dir,
        }
        for p in projects
    ]
    click.echo(json.dumps(payload, indent=2))


@autopilot_cmd.group("audit")
def autopilot_audit() -> None:
    """
    Manage the Autopilot audit log.
    """


def _parse_audit_timestamp(value: str) -> datetime | None:
    """Parse audit timestamps in ISO format (with or without Z)."""
    if not value:
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None


@autopilot_audit.command("stats")
def autopilot_audit_stats() -> None:
    """
    Show audit log statistics.

    \b
    Examples:
      g-gremlin autopilot audit stats
    """
    audit_path = gremlin_path("autopilot", "audit.jsonl")

    if not audit_path.exists():
        click.echo("No audit log found.")
        return

    # Count entries and get date range
    entry_count = 0
    oldest_dt = None
    newest_dt = None

    with open(audit_path) as f:
        for line in f:
            entry_count += 1
            try:
                entry = json.loads(line)
                ts = entry.get("timestamp", "")
                ts_dt = _parse_audit_timestamp(ts)
                if ts_dt:
                    if oldest_dt is None or ts_dt < oldest_dt:
                        oldest_dt = ts_dt
                    if newest_dt is None or ts_dt > newest_dt:
                        newest_dt = ts_dt
            except json.JSONDecodeError:
                continue

    file_size = audit_path.stat().st_size
    size_str = f"{file_size / 1024 / 1024:.1f}MB" if file_size > 1024 * 1024 else f"{file_size / 1024:.1f}KB"

    click.echo(f"Audit log: {audit_path}")
    click.echo(f"  Entries: {entry_count:,}")
    click.echo(f"  Size: {size_str}")
    if oldest_dt:
        click.echo(f"  Oldest: {oldest_dt.date().isoformat()}")
    if newest_dt:
        click.echo(f"  Newest: {newest_dt.date().isoformat()}")


@autopilot_audit.command("cleanup")
@click.option(
    "--days",
    default=90,
    type=int,
    help="Remove entries older than N days (default: 90).",
)
@click.option(
    "--yes",
    is_flag=True,
    help="Skip confirmation prompt.",
)
def autopilot_audit_cleanup(days: int, yes: bool) -> None:
    """
    Clean up old audit log entries.

    \b
    Examples:
      g-gremlin autopilot audit cleanup --days 30
      g-gremlin autopilot audit cleanup --days 30 --yes
    """
    from datetime import timedelta

    audit_path = gremlin_path("autopilot", "audit.jsonl")

    if not audit_path.exists():
        click.echo("No audit log found.")
        return

    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
    # Count entries to be removed
    keep_count = 0
    remove_count = 0

    with open(audit_path) as f:
        for line in f:
            try:
                entry = json.loads(line)
                ts = _parse_audit_timestamp(entry.get("timestamp", ""))
                if ts and ts >= cutoff:
                    keep_count += 1
                else:
                    remove_count += 1
            except json.JSONDecodeError:
                remove_count += 1  # Remove malformed entries

    if remove_count == 0:
        click.echo(f"No entries older than {days} days.")
        return

    if not yes:
        if not click.confirm(f"Remove {remove_count} entries older than {days} days?"):
            click.echo("Cancelled.")
            return

    # Write filtered entries to temp file
    temp_path = audit_path.with_suffix(".jsonl.tmp")
    with open(audit_path) as src, open(temp_path, "w") as dst:
        for line in src:
            try:
                entry = json.loads(line)
                ts = _parse_audit_timestamp(entry.get("timestamp", ""))
                if ts and ts >= cutoff:
                    dst.write(line)
            except json.JSONDecodeError:
                continue

    temp_path.replace(audit_path)
    click.echo(f"Removed {remove_count} entries, kept {keep_count}.")


# ---------------------------------------------------------------------------
# Dynamics 365 / Power Platform
# ---------------------------------------------------------------------------


@cli.group()
@click.option("--profile", help="g-gremlin Dynamics profile (uses current if omitted).")
@click.option(
    "--environment",
    "--env",
    "environment",
    help="Dynamics environment URL or GUID (overrides profile default).",
)
@click.pass_context
def dynamics(ctx: click.Context, profile: str | None, environment: str | None) -> None:
    """
    Dynamics 365 / Power Platform admin CLI (auth, snapshots, packs).

    \b
    IMPORTANT: When a g-gremlin dynamics command exists for an operation, use it
    instead of raw 'pac' CLI calls. g-gremlin wraps pac with snapshots,
    planned apply flows, before/after diffs, and receipt generation.
    """
    ctx.ensure_object(dict)
    if profile:
        ctx.obj["dynamics_profile"] = profile
    if environment:
        ctx.obj["dynamics_environment"] = environment


@dynamics.group("auth")
def dynamics_auth() -> None:
    """Dynamics pac auth helpers."""


@dynamics_auth.command("list")
@click.option("--json", "json_output", is_flag=True)
def dynamics_auth_list(json_output: bool) -> None:
    pac = _get_dynamics_pac()
    profiles = pac.auth_list()
    if json_output:
        click.echo(json.dumps([p.to_dict() for p in profiles], indent=2))
        return
    if not profiles:
        click.echo("No pac profiles found. Run: pac auth create or pac auth list.", err=True)
        return
    for profile in profiles:
        marker = "*" if profile.active else " "
        env = f" ({profile.environment_url})" if profile.environment_url else ""
        click.echo(f"[{marker}] {profile.index}: {profile.name} {profile.user} {profile.cloud}{env}")


@dynamics_auth.command("use")
@click.argument("profile_index", type=int)
def dynamics_auth_use(profile_index: int) -> None:
    dynamics_mod = _get_dynamics_helpers()
    pac = _get_dynamics_pac()
    pac.auth_select(profile_index)
    profiles = pac.auth_list()
    active = next((p for p in profiles if p.active), None)

    cfg = dynamics_mod.DynamicsConfig.load(profile=_get_dynamics_profile())
    cfg.pac_profile_index = active.index if active else profile_index
    cfg.pac_profile_name = active.name if active else None
    cfg.org_url = active.environment_url if active and active.environment_url else cfg.org_url
    cfg.verified = True
    cfg.verified_at = datetime.now(timezone.utc).isoformat()
    try:
        who = pac.auth_who()
        cfg.user_email = str(who.get("user") or who.get("user_email") or who.get("email") or "")
        cfg.tenant_id = str(who.get("tenant_id") or who.get("tenant") or "")
    except Exception:
        pass
    cfg.save(set_current=True)
    click.echo(f"Selected pac profile index {profile_index}.")


@dynamics_auth.command("status")
@click.option("--json", "json_output", is_flag=True)
def dynamics_auth_status(json_output: bool) -> None:
    pac = _get_dynamics_pac()
    identity = pac.auth_who()
    doctor = DoctorResult(provider="dynamics", checks=[], identity=identity, overall_status="ok")
    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return
    click.echo(json.dumps(identity, indent=2))


@dynamics.command("doctor")
@click.option("--json", "json_output", is_flag=True)
def dynamics_doctor(json_output: bool) -> None:
    dynamics_mod = _get_dynamics_helpers()
    doctor = dynamics_mod.run_dynamics_doctor(
        pac_path=_get_dynamics_pac_path(),
        environment=_get_dynamics_environment(),
    )
    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return
    click.echo(f"pac CLI: {doctor.identity.get('pac_path') or 'unknown'}")
    click.echo(f"Version: {doctor.identity.get('pac_version') or 'unknown'}")
    active = doctor.identity.get("active_profile") or {}
    if active:
        click.echo(f"Active:  {active.get('name')} ({active.get('user')})")
    click.echo("Checks:")
    for check in doctor.checks:
        click.echo(f"  {check.name:<16} {check.status}")
    click.echo(f"Status: {doctor.overall_status}")


@dynamics.command("whoami")
@click.option("--json", "json_output", is_flag=True)
def dynamics_whoami(json_output: bool) -> None:
    pac = _get_dynamics_pac()
    try:
        identity = pac.auth_who()
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)
    doctor = DoctorResult(provider="dynamics", checks=[], identity=identity, overall_status="ok")
    if json_output:
        click.echo(json.dumps(doctor.to_dict(), indent=2))
        return
    click.echo(json.dumps(identity, indent=2))


@dynamics.command("open")
@click.option("--path", default="", help="Path within make.powerapps.com")
def dynamics_open(path: str) -> None:
    target_path = path or ""
    if not target_path.startswith("/"):
        target_path = f"/{target_path}" if target_path else ""
    url = f"https://make.powerapps.com{target_path}"
    webbrowser.open(url)
    click.echo(f"Opened {url}")


@dynamics.command("snapshot")
@click.option("--solution", required=True, help="Solution unique name.")
@click.option("--out", required=True, type=click.Path(), help="Output directory.")
@click.option("--managed", is_flag=True, help="Export as managed solution.")
@click.option("--json", "json_output", is_flag=True)
def dynamics_snapshot(solution: str, out: str, managed: bool, json_output: bool) -> None:
    snapshot_mod = _get_dynamics_snapshot_helpers()
    pac = _get_dynamics_pac()
    snapshotter = snapshot_mod.DynamicsSnapshotter(pac)
    result = snapshotter.snapshot(Path(out), solution, managed=managed)
    if json_output:
        click.echo(json.dumps(result.to_dict(), indent=2))
        return
    click.echo(f"Snapshot written to {result.out_dir}")


@dynamics.command("compare-snapshots")
@click.argument("a_dir", type=click.Path(exists=True))
@click.argument("b_dir", type=click.Path(exists=True))
@click.option("--json", "json_output", is_flag=True)
def dynamics_compare_snapshots(a_dir: str, b_dir: str, json_output: bool) -> None:
    compare_mod = _get_dynamics_snapshot_compare_helpers()
    diff = compare_mod.compare_snapshots(Path(a_dir), Path(b_dir))
    if json_output:
        click.echo(json.dumps(diff.to_dict(), indent=2))
        return
    click.echo(compare_mod.format_summary(diff))


@dynamics.group("pack")
def dynamics_pack() -> None:
    """
    Dynamics pack helpers (plan -> review -> apply).

    \b
    Workflow:
      1) g-gremlin dynamics pack init my_pack        # scaffold pack directory
      2) g-gremlin dynamics pack plan my_pack --json  # generate change plan + plan_hash
      3) # Review the plan output
      4) g-gremlin dynamics pack apply my_pack --yes [--plan-hash HASH]  # apply with receipt

    \b
    The apply step requires --yes and can verify a reviewed plan with --plan-hash.
    Use --plan <path> to apply a specific saved plan artifact.
    Set `options.require_plan_hash: true` in pack.yaml to enforce --plan-hash.
    Use --replan if the org has drifted since the reviewed plan.

    \b
    Do NOT use 'pac solution import' directly -- it bypasses plan
    verification, receipt generation, and drift detection.
    """


@dynamics_pack.command("init")
@click.argument("dir_path", type=click.Path())
def dynamics_pack_init(dir_path: str) -> None:
    pack_mod = _get_dynamics_pack_helpers()
    pack_mod.init_pack(Path(dir_path))
    click.echo(f"Initialized pack in {dir_path}")


@dynamics_pack.command("plan")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--json", "json_output", is_flag=True)
def dynamics_pack_plan(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_dynamics_pack_helpers()
    pac = _get_dynamics_pac()
    plan = pack_mod.plan_pack(pac, Path(dir_path))
    if json_output:
        click.echo(json.dumps(plan.to_dict(), indent=2))
        return
    comp = plan.components or {}
    files = comp.get("files") or {}
    counts = files.get("counts") or {}
    added = counts.get("added", len(files.get("added") or []))
    removed = counts.get("removed", len(files.get("removed") or []))
    changed = counts.get("changed", len(files.get("changed") or []))
    click.echo(f"Solution: {plan.solution_name}")
    click.echo(f"Files: +{added} -{removed} ~{changed}")
    categories = comp.get("categories") or {}
    for tag in sorted(categories.keys()):
        delta = categories[tag] or {}
        click.echo(
            f"{tag}: +{delta.get('added', 0)} -{delta.get('removed', 0)} ~{delta.get('changed', 0)}"
        )
    if plan.warnings:
        click.echo(f"Warnings: {len(plan.warnings)}")


@dynamics_pack.command("apply")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--yes", is_flag=True, required=True, help="Required to apply.")
@click.option("--replan", is_flag=True, help="Regenerate plan if baseline drifted.")
@click.option(
    "--plan",
    "plan_path",
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="Path to plan.json from a prior pack plan run.",
)
@click.option(
    "--plan-hash",
    "expected_plan_hash",
    help="Verify the reviewed plan hash before applying. Required when pack.yaml sets options.require_plan_hash=true.",
)
@click.option("--json", "json_output", is_flag=True)
def dynamics_pack_apply(
    dir_path: str,
    yes: bool,
    replan: bool,
    plan_path: Path | None,
    expected_plan_hash: str | None,
    json_output: bool,
) -> None:
    if not yes:
        click.echo("Refusing to apply without --yes.", err=True)
        sys.exit(EXIT_USAGE)
    pack_mod = _get_dynamics_pack_helpers()
    pac = _get_dynamics_pac()
    result = pack_mod.apply_pack(
        pac,
        Path(dir_path),
        None,
        replan=replan,
        plan_path=plan_path,
        expected_plan_hash=expected_plan_hash,
    )
    if json_output:
        click.echo(json.dumps(result, indent=2))
        return
    click.echo(f"Applied pack. Artifacts: {result.get('artifact_dir')}")


@dynamics_pack.command("doctor")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--json", "json_output", is_flag=True)
def dynamics_pack_doctor(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_dynamics_pack_helpers()
    report = pack_mod.pack_doctor(Path(dir_path))
    if json_output:
        click.echo(json.dumps(report, indent=2))
        return
    if report.get("ok"):
        click.echo("Pack looks ok.")
    else:
        click.echo(f"Missing: {', '.join(report.get('missing_files') or [])}")


@dynamics.command("drift")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--json", "json_output", is_flag=True)
def dynamics_drift(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_dynamics_pack_helpers()
    pac = _get_dynamics_pac()
    drift = pack_mod.compute_drift(pac, Path(dir_path))
    if json_output:
        click.echo(json.dumps(drift.to_dict(), indent=2))
        return
    click.echo(f"Files: +{drift.files_added} -{drift.files_removed} ~{drift.files_changed}")
    for tag in sorted(drift.category_delta.keys()):
        delta = drift.category_delta[tag] or {}
        click.echo(
            f"{tag}: +{delta.get('added', 0)} -{delta.get('removed', 0)} ~{delta.get('changed', 0)}"
        )


@dynamics.command("smoke")
@click.argument("dir_path", type=click.Path(exists=True))
@click.option("--json", "json_output", is_flag=True)
def dynamics_smoke(dir_path: str, json_output: bool) -> None:
    pack_mod = _get_dynamics_pack_helpers()
    smoke_mod = _get_dynamics_smoke_helpers()
    pac = _get_dynamics_pac()
    pack = pack_mod.load_pack_yaml(Path(dir_path) / "pack.yaml")
    assertions = smoke_mod.load_smoke_assertions(pack)
    try:
        report = smoke_mod.run_smoke(pac, assertions, solution_folder=Path(dir_path) / "solution")
    except ValueError as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)
    if json_output:
        click.echo(json.dumps(report.to_dict(), indent=2))
    else:
        summary = report.summary
        click.echo(
            f"Smoke: {summary['passed']} passed, {summary['failed']} failed, {summary['skipped']} skipped"
        )
        for result in report.results:
            status = "PASS" if result.passed else ("SKIP" if result.skipped else "FAIL")
            detail = f" - {result.detail}" if result.detail else ""
            click.echo(f"{status} {result.assertion.type}{detail}")
    if not report.all_passed:
        sys.exit(EXIT_REMOTE_ERROR)


@cli.group("mcp")
def mcp() -> None:
    """Model Context Protocol helpers."""


@mcp.command("serve")
@click.option(
    "--enable-writes",
    is_flag=True,
    help="Expose write/apply tools. Read/analyze tools are always enabled.",
)
@click.option(
    "--provider",
    type=click.Choice(["sfdc", "dynamics", "apollo", "outreach", "zoominfo"], case_sensitive=False),
    default="sfdc",
    show_default=True,
    help="MCP tool namespace to expose.",
)
@click.option(
    "--dynamics-profile",
    default=None,
    help="Default Dynamics profile when provider=dynamics.",
)
def mcp_serve(enable_writes: bool, provider: str, dynamics_profile: str | None) -> None:
    try:
        from g_gremlin.mcp.server import run_server
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    provider_normalized = str(provider or "sfdc").strip().lower()
    if provider_normalized == "dynamics" and dynamics_profile:
        os.environ["G_GREMLIN_MCP_DYNAMICS_PROFILE"] = str(dynamics_profile).strip()

    try:
        run_server(enable_writes=enable_writes, provider=provider_normalized)
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


@mcp.command("serve-dynamics")
@click.option(
    "--enable-writes",
    is_flag=True,
    help="Expose write tools (apply endpoints). Read/analyze tools are always enabled.",
)
@click.option(
    "--profile",
    "dynamics_profile",
    default=None,
    help="Default Dynamics profile for Dataverse tools.",
)
def mcp_serve_dynamics(enable_writes: bool, dynamics_profile: str | None) -> None:
    try:
        from g_gremlin.mcp.server import run_server
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_USAGE)

    if dynamics_profile:
        os.environ["G_GREMLIN_MCP_DYNAMICS_PROFILE"] = str(dynamics_profile).strip()

    try:
        run_server(enable_writes=enable_writes, provider="dynamics")
    except Exception as exc:
        click.echo(f"Error: {exc}", err=True)
        sys.exit(EXIT_REMOTE_ERROR)


# Register split command modules (side-effect: click decorators attach to cli/groups).
from g_gremlin.cli_commands import apify as _cli_apify  # noqa: F401
from g_gremlin.cli_commands import doctor as _cli_doctor  # noqa: F401
from g_gremlin.cli_commands.license_cmd import license_group
from g_gremlin.cli_commands import rescue as _cli_rescue  # noqa: F401
from g_gremlin.cli_commands import style as _cli_style  # noqa: F401
from g_gremlin.cli_commands import sync as _cli_sync  # noqa: F401

cli.add_command(license_group)


cli.add_command(
    LazyGroup(
        "g_gremlin.cli_commands.chart:chart",
        name="chart",
        help="Google Sheets chart helpers.",
    )
)
cli.add_command(
    LazyGroup(
        "g_gremlin.visualization.cli:viz",
        name="viz",
        help="Plotly visualization helpers.",
    )
)
cli.add_command(
    LazyGroup(
        "g_gremlin.scout.cli:scout",
        name="scout",
        help="CRM hygiene scouts (diagnostic checks).",
    )
)
cli.add_command(
    LazyGroup(
        "g_gremlin.tableau.cli:tableau",
        name="tableau",
        help="Tableau Cloud helpers.",
    )
)
cli.add_command(
    LazyGroup(
        "g_gremlin.looker.cli:looker",
        name="looker",
        help="Looker helpers for dashboards and queries.",
    )
)
cli.add_command(
    LazyGroup(
        "g_gremlin.cli_commands.platform:platform",
        name="platform",
        help="Self-hosted Docker platform commands.",
    )
)


if __name__ == "__main__":
    cli()
