"""
Lightweight HubSpot client for g-gremlin CLI.

Refactored to use BaseIntegration for consistent retries/rate limits.
Public function signatures preserved for backward compatibility.
"""

from __future__ import annotations

import csv
import json
import logging
import os
import time
from datetime import datetime, timezone
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import quote

import click

from g_gremlin.base import AuthenticationError, BaseIntegration, IntegrationError, RateLimitError, RetryConfig, TransientError
from g_gremlin.contracts import BatchOutcome, CapabilityCheck
from g_gremlin.credentials.provider import CredentialProvider, SecretNotFoundError
from g_gremlin.pacing import BatchPacer, PacingProfile, get_hubspot_profile
from g_gremlin.paths import gremlin_path
from g_gremlin.profile_manager import ProfileNotFoundError, ProfileStore

CONFIG_PATH = gremlin_path("hubspot.json")
DEFAULT_API_VERSION = "v3"
DEFAULT_BATCH_SIZE = 100
DEFAULT_ASSOC_BATCH_SIZE = 500  # under v4 2000 limit for safety
DEFAULT_RETRY_CONFIG = RetryConfig(max_attempts=4, base_delay=1.2, max_delay=20.0)
DEFAULT_PROFILE = "default"
SEARCH_PAGE_LIMIT = 100
SEARCH_TOTAL_CAP = 10000
EXPORTS_BASE_URL = "https://api.hubapi.com/crm/v3/exports/export/async"
IMPORTS_BASE_URL = "https://api.hubapi.com/crm/v3/imports"
EXPORT_TERMINAL_SUCCESS_STATES = {"COMPLETE", "DONE"}
EXPORT_TERMINAL_FAILURE_STATES = {"CANCELED", "FAILED"}
IMPORT_TERMINAL_STATES = {"DONE", "FAILED", "CANCELED", "REVERTED"}

ENGAGEMENT_TYPE_ALIASES: Dict[str, str] = {
    "email": "emails",
    "emails": "emails",
    "call": "calls",
    "calls": "calls",
    "meeting": "meetings",
    "meetings": "meetings",
    "note": "notes",
    "notes": "notes",
    "task": "tasks",
    "tasks": "tasks",
}
DEFAULT_ENGAGEMENT_TYPES = ["emails", "calls", "meetings", "notes", "tasks"]
DEFAULT_ENGAGEMENT_PROPERTIES = [
    "hs_timestamp",
    "hs_createdate",
    "hs_lastmodifieddate",
    "hubspot_owner_id",
    "hs_activity_type",
    "hs_call_title",
    "hs_call_status",
    "hs_call_disposition",
    "hs_call_direction",
    "hs_email_subject",
    "hs_email_direction",
    "hs_meeting_title",
    "hs_meeting_outcome",
    "hs_task_subject",
    "hs_task_status",
    "hs_task_priority",
    "hs_task_type",
    "hs_note_body",
]

EXPORT_OBJECT_ALIASES: Dict[str, str] = {
    "contact": "CONTACT",
    "contacts": "CONTACT",
    "company": "COMPANY",
    "companies": "COMPANY",
    "deal": "DEAL",
    "deals": "DEAL",
    "ticket": "TICKET",
    "tickets": "TICKET",
    "lead": "LEAD",
    "leads": "LEAD",
}

MERGE_OBJECT_ALIASES: Dict[str, str] = {
    "contact": "contacts",
    "contacts": "contacts",
    "company": "companies",
    "companies": "companies",
    "deal": "0-3",
    "deals": "0-3",
    "ticket": "0-5",
    "tickets": "0-5",
}


def _resolve_profile(profile: str | None) -> str | None:
    env_profile = os.getenv("G_GREMLIN_HUBSPOT_PROFILE")
    return profile or (env_profile.strip() if env_profile else "") or None


def _parse_iso8601_to_epoch_ms(value: str | None) -> int | None:
    if not value:
        return None
    raw = str(value).strip()
    if not raw:
        return None
    try:
        if raw.endswith("Z"):
            raw = raw[:-1] + "+00:00"
        dt = datetime.fromisoformat(raw)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        else:
            dt = dt.astimezone(timezone.utc)
        return int(dt.timestamp() * 1000)
    except Exception:
        return None


def _to_iso8601(value: Any) -> str:
    if value is None:
        return ""
    raw = str(value).strip()
    if not raw:
        return ""
    # HubSpot datetime fields are typically epoch millis strings.
    if raw.isdigit():
        try:
            numeric = int(raw)
            if numeric > 1_000_000_000_000:
                dt = datetime.fromtimestamp(numeric / 1000, tz=timezone.utc)
            else:
                dt = datetime.fromtimestamp(numeric, tz=timezone.utc)
            return dt.isoformat()
        except Exception:
            return ""
    try:
        if raw.endswith("Z"):
            raw = raw[:-1] + "+00:00"
        dt = datetime.fromisoformat(raw)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        else:
            dt = dt.astimezone(timezone.utc)
        return dt.isoformat()
    except Exception:
        return ""


def suggest_sub_ranges(
    since: str | None,
    until: str | None,
) -> List[Dict[str, str]]:
    """Return monthly sub-ranges between *since* and *until*.

    Each item is ``{"since": "<ISO>", "until": "<ISO>"}``.  Returns an empty
    list when either bound is missing or unparseable.
    """
    if not since or not until:
        return []
    start_ms = _parse_iso8601_to_epoch_ms(since)
    end_ms = _parse_iso8601_to_epoch_ms(until)
    if start_ms is None or end_ms is None or start_ms >= end_ms:
        return []

    start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
    end_dt = datetime.fromtimestamp(end_ms / 1000, tz=timezone.utc)

    ranges: List[Dict[str, str]] = []
    cursor = start_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    while cursor < end_dt:
        # Advance to the 1st of the next month.
        if cursor.month == 12:
            next_month = cursor.replace(year=cursor.year + 1, month=1)
        else:
            next_month = cursor.replace(month=cursor.month + 1)
        chunk_start = max(cursor, start_dt)
        chunk_end = min(next_month, end_dt)
        if chunk_start < chunk_end:
            ranges.append({
                "since": chunk_start.strftime("%Y-%m-%dT%H:%M:%SZ"),
                "until": chunk_end.strftime("%Y-%m-%dT%H:%M:%SZ"),
            })
        cursor = next_month
    return ranges


def _coalesce(props: Dict[str, Any], keys: List[str]) -> str:
    for key in keys:
        value = props.get(key)
        if value is None:
            continue
        raw = str(value).strip()
        if raw:
            return raw
    return ""


def normalize_engagement_types(engagement_types: Iterable[str]) -> List[str]:
    normalized: List[str] = []
    seen: set[str] = set()
    for raw in engagement_types:
        token = str(raw or "").strip().lower()
        if not token:
            continue
        resolved = ENGAGEMENT_TYPE_ALIASES.get(token)
        if not resolved:
            allowed = ", ".join(DEFAULT_ENGAGEMENT_TYPES)
            raise HubSpotError(f"Unsupported engagement type '{raw}'. Supported types: {allowed}.")
        if resolved not in seen:
            seen.add(resolved)
            normalized.append(resolved)
    if not normalized:
        normalized = list(DEFAULT_ENGAGEMENT_TYPES)
    return normalized


def normalize_export_object_type(object_type: str) -> str:
    token = str(object_type or "").strip()
    if not token:
        raise HubSpotError("object_type is required for exports.")
    lowered = token.lower()
    if lowered in EXPORT_OBJECT_ALIASES:
        return EXPORT_OBJECT_ALIASES[lowered]
    return token


def _resolve_developer_api_key(developer_api_key: str | None) -> str:
    token = str(developer_api_key or os.getenv("G_GREMLIN_HUBSPOT_DEV_API_KEY") or "").strip()
    if not token:
        raise HubSpotError(
            "HubSpot developer API key is required. Provide developer_api_key or set G_GREMLIN_HUBSPOT_DEV_API_KEY."
        )
    return token


logger = logging.getLogger(__name__)


class HubSpotError(Exception):
    pass


@dataclass
class HubSpotConfig:
    access_token: str
    api_version: str = DEFAULT_API_VERSION
    profile: str = DEFAULT_PROFILE
    portal_id: str = ""
    hub_domain: str = ""
    time_zone: str = ""
    verified: bool = True

    @classmethod
    def load(cls, profile: str | None = None) -> "HubSpotConfig":
        store = ProfileStore.load(CONFIG_PATH, default_profile=DEFAULT_PROFILE)
        profile_name = _resolve_profile(profile) or store.current_profile_name()
        if not CONFIG_PATH.exists():
            raise HubSpotError("HubSpot config not found. Run g-gremlin hubspot connect first.")
        try:
            data = store.get_profile(profile_name)
            return cls(
                access_token=data["access_token"],
                api_version=data.get("api_version", DEFAULT_API_VERSION),
                profile=profile_name,
                portal_id=data.get("portal_id", ""),
                hub_domain=data.get("hub_domain", ""),
                time_zone=data.get("time_zone", ""),
                verified=data.get("verified", True),
            )
        except ProfileNotFoundError as exc:
            raise HubSpotError(str(exc)) from exc
        except Exception as exc:
            raise HubSpotError(f"Invalid hubspot config: {exc}") from exc

    def save(self, *, set_current: bool = True) -> None:
        store = ProfileStore.load(CONFIG_PATH, default_profile=DEFAULT_PROFILE)
        data = {"access_token": self.access_token, "api_version": self.api_version}
        if self.portal_id:
            data["portal_id"] = self.portal_id
        if self.hub_domain:
            data["hub_domain"] = self.hub_domain
        if self.time_zone:
            data["time_zone"] = self.time_zone
        data["verified"] = self.verified
        store.set_profile(
            self.profile or DEFAULT_PROFILE,
            data,
            set_current=set_current,
        )
        store.save()


class HubSpotClient(BaseIntegration):
    SERVICE_NAME = "hubspot"
    BASE_URL = "https://api.hubapi.com"

    def __init__(
        self,
        access_token: Optional[str] = None,
        api_version: str | None = None,
        profile: str | None = None,
        credential_provider: CredentialProvider | None = None,
        retry_config: RetryConfig | None = None,
    ) -> None:
        super().__init__(credential_provider=credential_provider, default_retry_config=retry_config or DEFAULT_RETRY_CONFIG)
        self.profile = _resolve_profile(profile) or DEFAULT_PROFILE
        self._api_version_explicit = api_version is not None
        self.api_version = api_version or DEFAULT_API_VERSION
        self.access_token = access_token or self._load_access_token()

    def _load_access_token(self) -> str:
        # First try credential provider (env/keychain/legacy)
        try:
            secret = self.credential_provider.get_secret("hubspot", "access_token", profile=self.profile)
            return secret.value
        except SecretNotFoundError:
            pass

        # Fallback to legacy file for backward compatibility
        try:
            cfg = HubSpotConfig.load(profile=self.profile)
            if not self._api_version_explicit:
                self.api_version = cfg.api_version or DEFAULT_API_VERSION
            return cfg.access_token
        except HubSpotError as exc:
            raise click.ClickException(
                "HubSpot not configured. Set via env G_GREMLIN_HUBSPOT_ACCESS_TOKEN "
                "or run g-gremlin hubspot connect."
            ) from exc

    # ------------------------------------------------------------------ #
    # Public API methods
    # ------------------------------------------------------------------ #
    def validate(self) -> Dict[str, Any]:
        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/contacts"
        resp = self._get(url, headers=self._headers(), params={"limit": 1})
        return {"ok": True, "sample": resp.json()}

    def search(
        self,
        object_type: str,
        *,
        filter_groups: List[Dict[str, Any]] | None = None,
        properties: List[str] | None = None,
        sorts: List[str] | None = None,
        limit: int = SEARCH_PAGE_LIMIT,
        after: str | None = None,
    ) -> Dict[str, Any]:
        url = f"{self.BASE_URL}/crm/v3/objects/{object_type}/search"
        body: Dict[str, Any] = {
            "filterGroups": filter_groups or [],
            "properties": properties or [],
            "limit": min(limit, SEARCH_PAGE_LIMIT),
        }
        if sorts:
            body["sorts"] = sorts
        if after:
            body["after"] = after
        try:
            resp = self._post(url, headers=self._headers(), json=body)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def search_all(
        self,
        object_type: str,
        *,
        filter_groups: List[Dict[str, Any]] | None = None,
        properties: List[str] | None = None,
        sorts: List[str] | None = None,
        limit: int = 0,
    ) -> List[Dict[str, Any]]:
        results: List[Dict[str, Any]] = []
        after: str | None = None
        while True:
            remaining = None if limit <= 0 else max(limit - len(results), 0)
            if remaining == 0 and limit > 0:
                break
            page_limit = min(SEARCH_PAGE_LIMIT, remaining) if remaining else SEARCH_PAGE_LIMIT
            payload = self.search(
                object_type,
                filter_groups=filter_groups,
                properties=properties,
                sorts=sorts,
                limit=page_limit,
                after=after,
            )
            batch = payload.get("results", []) or []
            results.extend(batch)
            if limit > 0 and len(results) >= limit:
                return results[:limit]
            if len(results) >= SEARCH_TOTAL_CAP:
                return results[:SEARCH_TOTAL_CAP]
            paging = payload.get("paging", {}) or {}
            after = (paging.get("next") or {}).get("after")
            if not after or not batch:
                break
        return results

    def pull_engagements(
        self,
        engagement_types: List[str],
        *,
        properties: List[str] | None = None,
        limit: int = 0,
        since: str | None = None,
        until: str | None = None,
    ) -> Dict[str, List[Dict[str, Any]]]:
        types = normalize_engagement_types(engagement_types or DEFAULT_ENGAGEMENT_TYPES)
        since_ms = _parse_iso8601_to_epoch_ms(since)
        until_ms = _parse_iso8601_to_epoch_ms(until)
        if since and since_ms is None:
            raise HubSpotError(f"Invalid --since value: {since}")
        if until and until_ms is None:
            raise HubSpotError(f"Invalid --until value: {until}")
        if since_ms is not None and until_ms is not None and since_ms >= until_ms:
            raise HubSpotError("--since must be earlier than --until.")

        requested_extra = [p.strip() for p in (properties or []) if str(p).strip()]
        fact_rows: List[Dict[str, Any]] = []
        ids_by_type: Dict[str, List[str]] = {}
        truncated_types: List[str] = []

        for engagement_type in types:
            available = {str(item.get("name")) for item in self.get_properties(engagement_type) if item.get("name")}
            wanted = [p for p in DEFAULT_ENGAGEMENT_PROPERTIES if p in available]
            for extra_prop in requested_extra:
                if extra_prop in available and extra_prop not in wanted:
                    wanted.append(extra_prop)

            filters: List[Dict[str, Any]] = []
            if since_ms is not None:
                filters.append({"propertyName": "hs_timestamp", "operator": "GTE", "value": since_ms})
            if until_ms is not None:
                filters.append({"propertyName": "hs_timestamp", "operator": "LT", "value": until_ms})
            filter_groups = [{"filters": filters}] if filters else None

            remaining = 0
            if limit > 0:
                remaining = max(limit - len(fact_rows), 0)
                if remaining == 0:
                    break

            try:
                results = self.search_all(
                    engagement_type,
                    filter_groups=filter_groups,
                    properties=wanted,
                    sorts=["-hs_timestamp"],
                    limit=remaining,
                )
            except HubSpotError as exc:
                # Some portals reject hs_timestamp filters for specific activity objects.
                if not filters:
                    raise
                logger.warning(
                    "HubSpot %s search with hs_timestamp filter failed (%s); retrying without server-side time filter.",
                    engagement_type,
                    exc,
                )
                results = self.search_all(
                    engagement_type,
                    filter_groups=None,
                    properties=wanted,
                    sorts=["-hs_timestamp"],
                    limit=remaining,
                )

            if len(results) >= SEARCH_TOTAL_CAP:
                truncated_types.append(engagement_type)

            ids_for_type: List[str] = []
            for item in results:
                row = self._normalize_engagement_row(engagement_type, item)
                timestamp_ms = row.get("timestamp_ms")
                if since_ms is not None and timestamp_ms and int(timestamp_ms) < since_ms:
                    continue
                if until_ms is not None and timestamp_ms and int(timestamp_ms) >= until_ms:
                    continue
                fact_rows.append(row)
                ids_for_type.append(str(row["engagement_id"]))
                if limit > 0 and len(fact_rows) >= limit:
                    break
            ids_by_type[engagement_type] = ids_for_type
            if limit > 0 and len(fact_rows) >= limit:
                break

        bridge_contact: List[Dict[str, Any]] = []
        bridge_deal: List[Dict[str, Any]] = []
        bridge_company: List[Dict[str, Any]] = []

        for engagement_type, engagement_ids in ids_by_type.items():
            if not engagement_ids:
                continue
            contact_map = self.read_associations_batch(engagement_type, "contacts", engagement_ids)
            for engagement_id, contact_ids in contact_map.items():
                for contact_id in contact_ids:
                    bridge_contact.append(
                        {
                            "engagement_id": str(engagement_id),
                            "engagement_type": engagement_type,
                            "contact_id": str(contact_id),
                        }
                    )
            deal_map = self.read_associations_batch(engagement_type, "deals", engagement_ids)
            for engagement_id, deal_ids in deal_map.items():
                for deal_id in deal_ids:
                    bridge_deal.append(
                        {
                            "engagement_id": str(engagement_id),
                            "engagement_type": engagement_type,
                            "deal_id": str(deal_id),
                        }
                    )
            company_map = self.read_associations_batch(engagement_type, "companies", engagement_ids)
            for engagement_id, company_ids in company_map.items():
                for company_id in company_ids:
                    bridge_company.append(
                        {
                            "engagement_id": str(engagement_id),
                            "engagement_type": engagement_type,
                            "company_id": str(company_id),
                        }
                    )

        return {
            "fact_engagement": fact_rows,
            "bridge_engagement_contact": bridge_contact,
            "bridge_engagement_deal": bridge_deal,
            "bridge_engagement_company": bridge_company,
            "_truncated_types": truncated_types,
        }

    def _normalize_engagement_row(self, engagement_type: str, record: Dict[str, Any]) -> Dict[str, Any]:
        props = dict(record.get("properties") or {})
        raw_timestamp = _coalesce(props, ["hs_timestamp", "hs_meeting_start_time", "hs_createdate"])
        raw_created = _coalesce(props, ["hs_createdate"])
        raw_updated = _coalesce(props, ["hs_lastmodifieddate"])
        subject = _coalesce(
            props,
            [
                "hs_email_subject",
                "hs_call_title",
                "hs_meeting_title",
                "hs_task_subject",
                "hs_note_body",
            ],
        )
        status = _coalesce(props, ["hs_call_status", "hs_task_status", "hs_task_completion_status"])
        direction = _coalesce(props, ["hs_email_direction", "hs_call_direction"])
        outcome = _coalesce(props, ["hs_call_disposition", "hs_meeting_outcome"])

        timestamp_ms = raw_timestamp if str(raw_timestamp).isdigit() else ""
        if timestamp_ms and int(timestamp_ms) < 1_000_000_000_000:
            # Normalize seconds to milliseconds.
            timestamp_ms = str(int(timestamp_ms) * 1000)

        return {
            "engagement_id": str(record.get("id") or ""),
            "engagement_type": engagement_type,
            "timestamp": _to_iso8601(raw_timestamp),
            "timestamp_ms": int(timestamp_ms) if str(timestamp_ms).isdigit() else "",
            "created_at": _to_iso8601(raw_created),
            "updated_at": _to_iso8601(raw_updated),
            "owner_id": _coalesce(props, ["hubspot_owner_id"]),
            "subject": subject,
            "status": status,
            "direction": direction,
            "outcome": outcome,
            "activity_type": _coalesce(props, ["hs_activity_type"]),
            "raw_properties": json.dumps(props, sort_keys=True),
        }

    def _paginate(
        self,
        url: str,
        params: Dict[str, Any],
        headers: Dict[str, str],
        *,
        pacer: BatchPacer | None = None,
    ) -> Iterable[Dict[str, Any]]:
        cursor: Optional[str] = None
        while True:
            page_params = dict(params)
            if cursor:
                page_params["after"] = cursor
            resp = self._get(url, headers=headers, params=page_params)
            payload = resp.json()
            results = payload.get("results", []) or []
            for item in results:
                yield item
            paging = payload.get("paging", {}) or {}
            next_link = paging.get("next", {}) or {}
            cursor = next_link.get("after")
            if pacer:
                pacer.pace()
            if not cursor:
                break

    def whoami(self) -> Dict[str, Any]:
        """Return current HubSpot account details."""
        url = f"{self.BASE_URL}/account-info/v3/details"
        try:
            resp = self._get(url, headers=self._headers())
            payload = resp.json()
        except AuthenticationError as exc:
            raise HubSpotError("HubSpot authentication failed.") from exc
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

        return {
            "portal_id": str(payload.get("portalId") or ""),
            "hub_domain": payload.get("hubDomain"),
            "time_zone": payload.get("timeZone"),
            "ui_domain": payload.get("uiDomain"),
        }

    def probe_capabilities(self) -> List[CapabilityCheck]:
        """Probe key object endpoints to validate access."""
        probes = [
            ("contacts", f"{self.BASE_URL}/crm/{self.api_version}/objects/contacts"),
            ("companies", f"{self.BASE_URL}/crm/{self.api_version}/objects/companies"),
            ("deals", f"{self.BASE_URL}/crm/{self.api_version}/objects/deals"),
        ]

        try:
            headers = self._headers()
        except (HubSpotError, AuthenticationError) as exc:
            return [CapabilityCheck(name=name, status="error", detail=str(exc)) for name, _ in probes]

        checks: List[CapabilityCheck] = []
        for name, url in probes:
            checks.append(self._probe_endpoint(name, url, headers=headers))
        return checks

    def _probe_endpoint(
        self,
        name: str,
        url: str,
        *,
        headers: Dict[str, str],
    ) -> CapabilityCheck:
        try:
            resp = self._get(url, headers=headers, params={"limit": 1}, allow_statuses=(403,))
            if resp.status_code == 403:
                detail = f"{resp.status_code} {resp.reason}".strip()
                return CapabilityCheck(name=name, status="denied", detail=detail)
            return CapabilityCheck(name=name, status="ok", detail="")
        except AuthenticationError as exc:
            return CapabilityCheck(name=name, status="error", detail=str(exc))
        except (RateLimitError, IntegrationError, TransientError) as exc:
            return CapabilityCheck(name=name, status="error", detail=str(exc))

    def pull_objects(
        self,
        object_type: str,
        properties: List[str],
        limit: int,
        pacing_profile: PacingProfile | None = None,
    ) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/{object_type}"
        profile = pacing_profile or get_hubspot_profile("safe")
        params: Dict[str, Any] = {"limit": profile.batch_size}
        if properties:
            params["properties"] = ",".join(properties)

        records: List[Dict[str, Any]] = []
        pacer = BatchPacer(profile)
        for item in self._paginate(url, params, self._headers(), pacer=pacer):
            rec = {"id": item.get("id")}
            rec.update(item.get("properties", {}) or {})
            records.append(rec)
            if limit and len(records) >= limit:
                break

        return records

    def batch_create(self, object_type: str, records: List[Dict[str, Any]]) -> BatchOutcome:
        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/{object_type}/batch/create"
        inputs = []
        for record in records:
            if "properties" in record:
                inputs.append({"properties": record.get("properties") or {}})
            else:
                inputs.append({"properties": record})

        try:
            resp = self._post(url, headers=self._headers(), json={"inputs": inputs})
            results = resp.json().get("results", [])
            succeeded = [{"id": item.get("id")} for item in results]
            return BatchOutcome(succeeded=succeeded, failed=[], retryable_failed=[])
        except (IntegrationError, AuthenticationError, RateLimitError, TransientError) as exc:
            failed = [{"error": str(exc), "record": record} for record in records]
            return BatchOutcome(succeeded=[], failed=failed, retryable_failed=[])

    def batch_update(
        self,
        object_type: str,
        records: List[Dict[str, Any]],
        id_property: str,
    ) -> BatchOutcome:
        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/{object_type}/batch/update"
        inputs = []
        failed: List[Dict[str, Any]] = []
        for record in records:
            record_id = record.get(id_property)
            if not record_id:
                failed.append({"error": f"missing {id_property}", "record": record})
                continue
            props = record.get("properties") if "properties" in record else {
                k: v for k, v in record.items() if k != id_property
            }
            inputs.append({"idProperty": id_property, "id": record_id, "properties": props})

        if not inputs:
            return BatchOutcome(succeeded=[], failed=failed, retryable_failed=[])

        try:
            resp = self._post(url, headers=self._headers(), json={"inputs": inputs})
            results = resp.json().get("results", [])
            succeeded = [{"id": item.get("id")} for item in results]
            return BatchOutcome(succeeded=succeeded, failed=failed, retryable_failed=[])
        except (IntegrationError, AuthenticationError, RateLimitError, TransientError) as exc:
            failed.extend([{"error": str(exc), "record": record} for record in records])
            return BatchOutcome(succeeded=[], failed=failed, retryable_failed=[])

    def get_schemas(self) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/crm/v3/schemas"
        try:
            resp = self._get(url, headers=self._headers())
            payload = resp.json()
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_properties(self, object_type: str) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/crm/v3/properties/{object_type}"
        try:
            resp = self._get(url, headers=self._headers())
            payload = resp.json()
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_pipelines(self, object_type: str) -> List[Dict[str, Any]]:
        object_token = str(object_type or "").strip().lower()
        if not object_token:
            raise HubSpotError("object_type is required.")
        url = f"{self.BASE_URL}/crm/v3/pipelines/{quote(object_token, safe='')}"
        try:
            resp = self._get(url, headers=self._headers())
            payload = resp.json()
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_pipeline(self, object_type: str, pipeline_id: str) -> Dict[str, Any]:
        object_token = str(object_type or "").strip().lower()
        pipeline_token = str(pipeline_id or "").strip()
        if not object_token:
            raise HubSpotError("object_type is required.")
        if not pipeline_token:
            raise HubSpotError("pipeline_id is required.")
        url = (
            f"{self.BASE_URL}/crm/v3/pipelines/{quote(object_token, safe='')}/"
            f"{quote(pipeline_token, safe='')}"
        )
        try:
            resp = self._get(url, headers=self._headers())
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def list_forms(
        self,
        *,
        limit: int = 100,
        form_types: List[str] | None = None,
        archived: bool = False,
    ) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/marketing/v3/forms/"
        results: List[Dict[str, Any]] = []
        after: str | None = None
        try:
            while True:
                remaining = None if limit <= 0 else max(limit - len(results), 0)
                if remaining == 0 and limit > 0:
                    break
                page_limit = min(100, remaining) if remaining else 100
                params: Dict[str, Any] = {"limit": page_limit}
                if after:
                    params["after"] = after
                if archived:
                    params["archived"] = "true"
                if form_types:
                    params["formTypes"] = [str(item).strip() for item in form_types if str(item).strip()]
                resp = self._get(url, headers=self._headers(), params=params)
                payload = resp.json()
                batch = payload.get("results", []) or []
                results.extend(batch)
                if limit > 0 and len(results) >= limit:
                    return results[:limit]
                paging = payload.get("paging", {}) or {}
                after = (paging.get("next") or {}).get("after")
                if not after or not batch:
                    break
            return results
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def list_form_submissions(
        self,
        form_id: str,
        *,
        limit: int = 100,
    ) -> List[Dict[str, Any]]:
        form_token = str(form_id or "").strip()
        if not form_token:
            raise HubSpotError("form_id is required.")
        url = f"{self.BASE_URL}/form-integrations/v1/submissions/forms/{quote(form_token, safe='')}"
        results: List[Dict[str, Any]] = []
        after: str | None = None
        try:
            while True:
                remaining = None if limit <= 0 else max(limit - len(results), 0)
                if remaining == 0 and limit > 0:
                    break
                page_limit = min(50, remaining) if remaining else 50
                params: Dict[str, Any] = {"limit": page_limit}
                if after:
                    params["after"] = after
                resp = self._get(url, headers=self._headers(), params=params)
                payload = resp.json()
                batch = payload.get("results", []) or []
                results.extend(batch)
                if limit > 0 and len(results) >= limit:
                    return results[:limit]
                paging = payload.get("paging", {}) or {}
                after = (paging.get("next") or {}).get("after")
                if not after or not batch:
                    break
            return results
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def list_marketing_emails(
        self,
        *,
        limit: int = 100,
        include_stats: bool = False,
        archived: bool = False,
    ) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/marketing/v3/emails/"
        results: List[Dict[str, Any]] = []
        after: str | None = None
        try:
            while True:
                remaining = None if limit <= 0 else max(limit - len(results), 0)
                if remaining == 0 and limit > 0:
                    break
                page_limit = min(100, remaining) if remaining else 100
                params: Dict[str, Any] = {"limit": page_limit}
                if after:
                    params["after"] = after
                if include_stats:
                    params["includeStats"] = "true"
                if archived:
                    params["archived"] = "true"
                resp = self._get(url, headers=self._headers(), params=params)
                payload = resp.json()
                batch = payload.get("results", []) or []
                results.extend(batch)
                if limit > 0 and len(results) >= limit:
                    return results[:limit]
                paging = payload.get("paging", {}) or {}
                after = (paging.get("next") or {}).get("after")
                if not after or not batch:
                    break
            return results
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_marketing_email_stats(
        self,
        email_id: str | int,
        *,
        start_timestamp: str | None = None,
        end_timestamp: str | None = None,
        properties: List[str] | None = None,
    ) -> Dict[str, Any]:
        token = str(email_id or "").strip()
        if not token:
            raise HubSpotError("email_id is required.")
        url = f"{self.BASE_URL}/marketing/v3/emails/statistics/list"
        params: Dict[str, Any] = {"emailIds": [token]}
        if start_timestamp:
            params["startTimestamp"] = str(start_timestamp).strip()
        if end_timestamp:
            params["endTimestamp"] = str(end_timestamp).strip()
        if properties:
            params["property"] = [str(item).strip() for item in properties if str(item).strip()]
        try:
            resp = self._get(url, headers=self._headers(), params=params)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def list_campaigns(
        self,
        *,
        limit: int = 100,
    ) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/marketing/v3/campaigns/"
        results: List[Dict[str, Any]] = []
        after: str | None = None
        try:
            while True:
                remaining = None if limit <= 0 else max(limit - len(results), 0)
                if remaining == 0 and limit > 0:
                    break
                page_limit = min(100, remaining) if remaining else 100
                params: Dict[str, Any] = {"limit": page_limit}
                if after:
                    params["after"] = after
                resp = self._get(url, headers=self._headers(), params=params)
                payload = resp.json()
                batch = payload.get("results", []) or []
                results.extend(batch)
                if limit > 0 and len(results) >= limit:
                    return results[:limit]
                paging = payload.get("paging", {}) or {}
                after = (paging.get("next") or {}).get("after")
                if not after or not batch:
                    break
            return results
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_object_with_history(
        self,
        object_type: str,
        object_id: str,
        *,
        properties_with_history: List[str],
        properties: List[str] | None = None,
        associations: List[str] | None = None,
        archived: bool = False,
        id_property: str | None = None,
    ) -> Dict[str, Any]:
        object_token = str(object_type or "").strip().lower()
        if not object_token:
            raise HubSpotError("object_type is required.")
        record_id = str(object_id or "").strip()
        if not record_id:
            raise HubSpotError("object_id is required.")
        history_props = [str(item).strip() for item in (properties_with_history or []) if str(item).strip()]
        if not history_props:
            raise HubSpotError("properties_with_history is required.")

        url = f"{self.BASE_URL}/crm/v3/objects/{object_token}/{quote(record_id, safe='')}"
        params: Dict[str, Any] = {"propertiesWithHistory": history_props}
        plain_props = [str(item).strip() for item in (properties or []) if str(item).strip()]
        if plain_props:
            params["properties"] = plain_props
        assoc_types = [str(item).strip() for item in (associations or []) if str(item).strip()]
        if assoc_types:
            params["associations"] = ",".join(assoc_types)
        if archived:
            params["archived"] = "true"
        if id_property:
            params["idProperty"] = str(id_property).strip()

        try:
            resp = self._get(url, headers=self._headers(), params=params)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_owners(self, include_archived: bool = False) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/crm/v3/owners"
        results: List[Dict[str, Any]] = []
        params: Dict[str, Any] = {"limit": 500}
        if include_archived:
            params["archived"] = "true"
        after: str | None = None
        try:
            while True:
                if after:
                    params["after"] = after
                resp = self._get(url, headers=self._headers(), params=params)
                payload = resp.json()
                batch = payload.get("results", []) or []
                results.extend(batch)
                paging = payload.get("paging") or {}
                next_page = paging.get("next") or {}
                after = next_page.get("after")
                if not after:
                    break
            return results
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_users(self, include_archived: bool = False) -> List[Dict[str, Any]]:
        """
        Return HubSpot user-like principals for governance visibility.

        HubSpot's CRM owners endpoint is the most stable cross-portal source for
        assignable users and includes archived users when requested.
        """
        return self.get_owners(include_archived=include_archived)

    def get_goals(
        self,
        *,
        properties: List[str] | None = None,
        limit: int = 0,
        archived: bool = False,
    ) -> List[Dict[str, Any]]:
        object_type = "goals"
        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/{object_type}"
        params: Dict[str, Any] = {"limit": SEARCH_PAGE_LIMIT}
        prop_list = [str(item).strip() for item in (properties or []) if str(item).strip()]
        if prop_list:
            params["properties"] = ",".join(prop_list)
        if archived:
            params["archived"] = "true"

        records: List[Dict[str, Any]] = []
        for item in self._paginate(url, params, self._headers()):
            rec = {"id": item.get("id")}
            rec.update(item.get("properties", {}) or {})
            records.append(rec)
            if limit > 0 and len(records) >= limit:
                break
        return records

    def get_goal(
        self,
        goal_id: str,
        *,
        properties: List[str] | None = None,
        archived: bool = False,
        id_property: str | None = None,
    ) -> Dict[str, Any]:
        record_id = str(goal_id or "").strip()
        if not record_id:
            raise HubSpotError("goal_id is required.")
        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/goals/{quote(record_id, safe='')}"
        params: Dict[str, Any] = {}
        prop_list = [str(item).strip() for item in (properties or []) if str(item).strip()]
        if prop_list:
            params["properties"] = ",".join(prop_list)
        if archived:
            params["archived"] = "true"
        if id_property:
            params["idProperty"] = str(id_property).strip()
        try:
            resp = self._get(url, headers=self._headers(), params=params)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def start_export(
        self,
        *,
        object_type: str,
        export_type: str = "VIEW",
        file_format: str = "CSV",
        export_name: str | None = None,
        object_properties: List[str] | None = None,
        associated_object_types: List[str] | None = None,
        public_crm_search_request: Dict[str, Any] | None = None,
        language: str = "EN",
        list_id: str | None = None,
        include_association_labels: bool | None = None,
        include_primary_display_property_for_associated_objects: bool | None = None,
    ) -> Dict[str, Any]:
        export_type_token = str(export_type or "VIEW").upper()
        if export_type_token not in {"VIEW", "LIST"}:
            raise HubSpotError("export_type must be VIEW or LIST.")

        payload: Dict[str, Any] = {
            "exportType": export_type_token,
            "format": str(file_format or "CSV").upper(),
            "language": str(language or "EN").upper(),
            "objectType": normalize_export_object_type(object_type),
        }
        if export_name:
            payload["exportName"] = str(export_name)
        if object_properties:
            payload["objectProperties"] = [str(item) for item in object_properties if str(item).strip()]
        if associated_object_types:
            payload["associatedObjectType"] = [
                normalize_export_object_type(item) for item in associated_object_types if str(item).strip()
            ]
        if include_association_labels is not None:
            payload["includeLabeledAssociations"] = bool(include_association_labels)
        if include_primary_display_property_for_associated_objects is not None:
            payload["includePrimaryDisplayPropertyForAssociatedObjects"] = bool(
                include_primary_display_property_for_associated_objects
            )

        if export_type_token == "LIST":
            if not list_id:
                raise HubSpotError("list_id is required when export_type=LIST.")
            payload["listId"] = str(list_id)
        elif public_crm_search_request:
            payload["publicCrmSearchRequest"] = public_crm_search_request

        try:
            resp = self._post(EXPORTS_BASE_URL, headers=self._headers(), json=payload)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_export_status(self, task_id: str) -> Dict[str, Any]:
        task = str(task_id or "").strip()
        if not task:
            raise HubSpotError("task_id is required.")
        url = f"{EXPORTS_BASE_URL}/tasks/{task}/status"
        try:
            resp = self._get(url, headers=self._headers())
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def wait_for_export(
        self,
        task_id: str,
        *,
        timeout_seconds: int = 900,
        poll_interval_seconds: int = 5,
    ) -> Dict[str, Any]:
        deadline = time.monotonic() + max(timeout_seconds, 1)
        while True:
            payload = self.get_export_status(task_id)
            state = str(payload.get("status") or payload.get("exportState") or "").upper()
            if state in EXPORT_TERMINAL_SUCCESS_STATES:
                return payload
            if state in EXPORT_TERMINAL_FAILURE_STATES:
                raise HubSpotError(f"HubSpot export {task_id} ended in terminal state '{state}'.")
            if time.monotonic() >= deadline:
                raise HubSpotError(f"Timed out waiting for HubSpot export {task_id}.")
            self._sleep(max(float(poll_interval_seconds), 1.0))

    @staticmethod
    def extract_export_download_url(status_payload: Dict[str, Any]) -> str:
        direct_result = status_payload.get("result")
        if isinstance(direct_result, str) and direct_result.startswith("http"):
            return direct_result
        if isinstance(direct_result, dict):
            for key in ("url", "downloadUrl", "href"):
                candidate = direct_result.get(key)
                if isinstance(candidate, str) and candidate.startswith("http"):
                    return candidate

        links = status_payload.get("links")
        if isinstance(links, dict):
            for key in ("result", "download", "url", "href"):
                candidate = links.get(key)
                if isinstance(candidate, str) and candidate.startswith("http"):
                    return candidate
                if isinstance(candidate, dict):
                    inner = candidate.get("href") or candidate.get("url")
                    if isinstance(inner, str) and inner.startswith("http"):
                        return inner
        return ""

    def download_export_file(
        self,
        task_id: str,
        out_path: Path,
        *,
        wait: bool = True,
        timeout_seconds: int = 900,
        poll_interval_seconds: int = 5,
    ) -> Dict[str, Any]:
        if wait:
            status_payload = self.wait_for_export(
                task_id,
                timeout_seconds=timeout_seconds,
                poll_interval_seconds=poll_interval_seconds,
            )
        else:
            status_payload = self.get_export_status(task_id)
            state = str(status_payload.get("status") or status_payload.get("exportState") or "").upper()
            if state not in EXPORT_TERMINAL_SUCCESS_STATES:
                raise HubSpotError(
                    f"HubSpot export {task_id} is not complete yet (state='{state or 'unknown'}')."
                )

        download_url = self.extract_export_download_url(status_payload)
        if not download_url:
            raise HubSpotError(f"HubSpot export {task_id} has no download URL yet.")

        try:
            response = self._session.request("GET", download_url, timeout=120.0, stream=True)
        except Exception as exc:
            raise HubSpotError(f"Failed to download HubSpot export file: {exc}") from exc

        if not response.ok:
            snippet = self._response_snippet(response)
            raise HubSpotError(f"Failed to download HubSpot export file ({response.status_code}): {snippet}")

        out_path.parent.mkdir(parents=True, exist_ok=True)
        bytes_written = 0
        with out_path.open("wb") as handle:
            for chunk in response.iter_content(chunk_size=1024 * 1024):
                if not chunk:
                    continue
                handle.write(chunk)
                bytes_written += len(chunk)

        return {
            "task_id": str(task_id),
            "path": str(out_path),
            "bytes_written": bytes_written,
            "download_url": download_url,
            "state": str(status_payload.get("status") or status_payload.get("exportState") or ""),
        }

    def start_import(
        self,
        import_request: Dict[str, Any],
        file_paths: List[Path],
    ) -> Dict[str, Any]:
        if not isinstance(import_request, dict):
            raise HubSpotError("import_request must be a JSON object.")
        if not file_paths:
            raise HubSpotError("At least one file is required for imports.")
        for path in file_paths:
            if not path.exists():
                raise HubSpotError(f"Import file not found: {path}")

        files = []
        opened = []
        try:
            for path in file_paths:
                handle = path.open("rb")
                opened.append(handle)
                files.append(("files", (path.name, handle)))

            response = self._session.request(
                "POST",
                IMPORTS_BASE_URL,
                headers={"Authorization": f"Bearer {self.access_token}"},
                data={"importRequest": json.dumps(import_request)},
                files=files,
                timeout=180.0,
            )
        except Exception as exc:
            raise HubSpotError(f"HubSpot import request failed: {exc}") from exc
        finally:
            for handle in opened:
                try:
                    handle.close()
                except Exception:
                    pass

        if response.status_code in (401, 403):
            raise HubSpotError("HubSpot authentication failed.")
        if response.status_code == 429:
            raise HubSpotError("HubSpot import request was rate limited (429).")
        if not response.ok:
            snippet = self._response_snippet(response)
            raise HubSpotError(f"HubSpot import request failed ({response.status_code}): {snippet}")
        try:
            return response.json()
        except Exception as exc:
            raise HubSpotError(f"HubSpot import response was not JSON: {exc}") from exc

    def get_import_status(self, import_id: str) -> Dict[str, Any]:
        import_token = str(import_id or "").strip()
        if not import_token:
            raise HubSpotError("import_id is required.")
        url = f"{IMPORTS_BASE_URL}/{import_token}"
        try:
            response = self._get(url, headers=self._headers())
            return response.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def wait_for_import(
        self,
        import_id: str,
        *,
        timeout_seconds: int = 1800,
        poll_interval_seconds: int = 5,
    ) -> Dict[str, Any]:
        deadline = time.monotonic() + max(timeout_seconds, 1)
        while True:
            payload = self.get_import_status(import_id)
            state = str(payload.get("state") or payload.get("status") or "").upper()
            if state in IMPORT_TERMINAL_STATES:
                return payload
            if time.monotonic() >= deadline:
                raise HubSpotError(f"Timed out waiting for HubSpot import {import_id}.")
            self._sleep(max(float(poll_interval_seconds), 1.0))

    def get_import_errors(
        self,
        import_id: str,
        *,
        include_warnings: bool = True,
    ) -> Dict[str, Any]:
        import_token = str(import_id or "").strip()
        if not import_token:
            raise HubSpotError("import_id is required.")
        url = f"{IMPORTS_BASE_URL}/{import_token}/errors"
        params = {"includeWarnings": "true" if include_warnings else "false"}
        try:
            response = self._get(url, headers=self._headers(), params=params)
            return response.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def cancel_import(self, import_id: str) -> Dict[str, Any]:
        import_token = str(import_id or "").strip()
        if not import_token:
            raise HubSpotError("import_id is required.")
        url = f"{IMPORTS_BASE_URL}/{import_token}/cancel"
        try:
            response = self._post(url, headers=self._headers())
            return response.json() if response.text else {"ok": True}
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def merge_objects(self, object_type: str, primary_id: str, merge_id: str) -> Dict[str, Any]:
        object_token_raw = str(object_type or "").strip().lower()
        object_token = MERGE_OBJECT_ALIASES.get(object_token_raw, object_token_raw)
        if not object_token:
            raise HubSpotError("object_type is required.")
        primary = str(primary_id or "").strip()
        secondary = str(merge_id or "").strip()
        if not primary or not secondary:
            raise HubSpotError("primary_id and merge_id are required.")
        if primary == secondary:
            raise HubSpotError("primary_id and merge_id must be different.")

        url = f"{self.BASE_URL}/crm/v3/objects/{object_token}/merge"
        body = {"primaryObjectId": primary, "objectIdToMerge": secondary}
        try:
            response = self._post(url, headers=self._headers(), json=body)
            return response.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_association_labels(self, from_type: str, to_type: str) -> List[Dict[str, Any]]:
        url = f"{self.BASE_URL}/crm/v4/associations/{from_type}/{to_type}/labels"
        try:
            resp = self._get(url, headers=self._headers(), allow_statuses=(404,))
            if resp.status_code == 404:
                return []
            payload = resp.json()
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def create_association_label(
        self,
        from_type: str,
        to_type: str,
        *,
        name: str,
        label: str,
        inverse_label: str | None = None,
    ) -> Dict[str, Any]:
        from_token = str(from_type or "").strip().lower()
        to_token = str(to_type or "").strip().lower()
        if not from_token or not to_token:
            raise HubSpotError("from_type and to_type are required.")
        body: Dict[str, Any] = {
            "name": str(name or "").strip(),
            "label": str(label or "").strip(),
        }
        if not body["name"] or not body["label"]:
            raise HubSpotError("name and label are required.")
        if inverse_label:
            body["inverseLabel"] = str(inverse_label).strip()
        url = f"{self.BASE_URL}/crm/v4/associations/{from_token}/{to_token}/labels"
        try:
            resp = self._post(url, headers=self._headers(), json=body)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def update_association_label(
        self,
        from_type: str,
        to_type: str,
        *,
        association_type_id: int,
        label: str,
        inverse_label: str | None = None,
    ) -> Dict[str, Any]:
        from_token = str(from_type or "").strip().lower()
        to_token = str(to_type or "").strip().lower()
        if not from_token or not to_token:
            raise HubSpotError("from_type and to_type are required.")
        body: Dict[str, Any] = {
            "associationTypeId": int(association_type_id),
            "label": str(label or "").strip(),
        }
        if not body["label"]:
            raise HubSpotError("label is required.")
        if inverse_label:
            body["inverseLabel"] = str(inverse_label).strip()
        url = f"{self.BASE_URL}/crm/v4/associations/{from_token}/{to_token}/labels"
        try:
            resp = self._put(url, headers=self._headers(), json=body)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def delete_association_label(
        self,
        from_type: str,
        to_type: str,
        association_type_id: int,
    ) -> Dict[str, Any]:
        from_token = str(from_type or "").strip().lower()
        to_token = str(to_type or "").strip().lower()
        if not from_token or not to_token:
            raise HubSpotError("from_type and to_type are required.")
        type_id = int(association_type_id)
        url = f"{self.BASE_URL}/crm/v4/associations/{from_token}/{to_token}/labels/{type_id}"
        try:
            resp = self._delete(url, headers=self._headers(), allow_statuses=(204,))
            if not resp.text:
                return {"ok": True, "associationTypeId": type_id}
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_association_limits(self, from_type: str, to_type: str) -> Dict[str, Any]:
        from_token = str(from_type or "").strip().lower()
        to_token = str(to_type or "").strip().lower()
        if not from_token or not to_token:
            raise HubSpotError("from_type and to_type are required.")
        url = f"{self.BASE_URL}/crm/associations/v4/definitions/configurations/{from_token}/{to_token}"
        try:
            resp = self._get(url, headers=self._headers())
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def set_association_limits(
        self,
        from_type: str,
        to_type: str,
        *,
        inputs: List[Dict[str, Any]],
        mode: str = "update",
    ) -> Dict[str, Any]:
        from_token = str(from_type or "").strip().lower()
        to_token = str(to_type or "").strip().lower()
        if not from_token or not to_token:
            raise HubSpotError("from_type and to_type are required.")
        mode_token = str(mode or "update").strip().lower()
        if mode_token not in {"create", "update"}:
            raise HubSpotError("mode must be 'create' or 'update'.")
        cleaned_inputs: List[Dict[str, Any]] = []
        for raw in inputs or []:
            if not isinstance(raw, dict):
                continue
            category = str(raw.get("category") or "").strip().upper()
            type_id = int(raw.get("typeId"))
            max_to_object_ids = int(raw.get("maxToObjectIds"))
            cleaned_inputs.append(
                {
                    "category": category,
                    "typeId": type_id,
                    "maxToObjectIds": max_to_object_ids,
                }
            )
        if not cleaned_inputs:
            raise HubSpotError("At least one association limit input is required.")
        url = (
            f"{self.BASE_URL}/crm/associations/v4/definitions/configurations/"
            f"{from_token}/{to_token}/batch/{mode_token}"
        )
        try:
            resp = self._post(url, headers=self._headers(), json={"inputs": cleaned_inputs})
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def get_webhook_subscriptions(
        self,
        app_id: str,
        *,
        developer_api_key: str | None = None,
    ) -> List[Dict[str, Any]]:
        app_token = str(app_id or "").strip()
        if not app_token:
            raise HubSpotError("app_id is required.")
        api_key = _resolve_developer_api_key(developer_api_key)
        url = f"{self.BASE_URL}/webhooks/v3/{app_token}/subscriptions"
        try:
            resp = self._get(url, params={"hapikey": api_key})
            payload = resp.json()
            if isinstance(payload, list):
                return payload
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def create_webhook_subscription(
        self,
        app_id: str,
        *,
        event_type: str,
        property_name: str | None = None,
        active: bool = False,
        developer_api_key: str | None = None,
    ) -> Dict[str, Any]:
        app_token = str(app_id or "").strip()
        if not app_token:
            raise HubSpotError("app_id is required.")
        event_token = str(event_type or "").strip()
        if not event_token:
            raise HubSpotError("event_type is required.")
        api_key = _resolve_developer_api_key(developer_api_key)
        body: Dict[str, Any] = {"eventType": event_token, "active": bool(active)}
        if property_name:
            body["propertyName"] = str(property_name).strip()
        url = f"{self.BASE_URL}/webhooks/v3/{app_token}/subscriptions"
        try:
            resp = self._post(url, params={"hapikey": api_key}, json=body)
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def update_webhook_subscription(
        self,
        app_id: str,
        subscription_id: int,
        *,
        active: bool,
        developer_api_key: str | None = None,
    ) -> Dict[str, Any]:
        app_token = str(app_id or "").strip()
        if not app_token:
            raise HubSpotError("app_id is required.")
        api_key = _resolve_developer_api_key(developer_api_key)
        sub_id = int(subscription_id)
        url = f"{self.BASE_URL}/webhooks/v3/{app_token}/subscriptions/{sub_id}"
        try:
            resp = self._put(url, params={"hapikey": api_key}, json={"active": bool(active)})
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def delete_webhook_subscription(
        self,
        app_id: str,
        subscription_id: int,
        *,
        developer_api_key: str | None = None,
    ) -> Dict[str, Any]:
        app_token = str(app_id or "").strip()
        if not app_token:
            raise HubSpotError("app_id is required.")
        api_key = _resolve_developer_api_key(developer_api_key)
        sub_id = int(subscription_id)
        url = f"{self.BASE_URL}/webhooks/v3/{app_token}/subscriptions/{sub_id}"
        try:
            resp = self._delete(url, params={"hapikey": api_key}, allow_statuses=(204,))
            if not resp.text:
                return {"ok": True, "id": sub_id}
            return resp.json()
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def read_associations_batch(
        self,
        from_type: str,
        to_type: str,
        ids: Iterable[str],
    ) -> Dict[str, List[str]]:
        url = f"{self.BASE_URL}/crm/v4/associations/{from_type}/{to_type}/batch/read"
        inputs = [str(item) for item in ids if item]
        if not inputs:
            return {}
        mapping: Dict[str, List[str]] = {}
        try:
            for chunk_start in range(0, len(inputs), DEFAULT_ASSOC_BATCH_SIZE):
                chunk = inputs[chunk_start : chunk_start + DEFAULT_ASSOC_BATCH_SIZE]
                resp = self._post(
                    url,
                    headers=self._headers(),
                    json={"inputs": [{"id": item_id} for item_id in chunk]},
                )
                payload = resp.json()
                results = payload.get("results", []) or []
                for item in results:
                    from_id = None
                    if isinstance(item, dict):
                        from_obj = item.get("from") or {}
                        if isinstance(from_obj, dict):
                            from_id = from_obj.get("id")
                        if not from_id:
                            from_id = item.get("fromId") or item.get("from_id")
                        to_list = item.get("to") or item.get("toObjectIds") or item.get("toIds") or []
                    else:
                        continue
                    if not from_id:
                        continue
                    ids_out: List[str] = []
                    if isinstance(to_list, list):
                        for entry in to_list:
                            if isinstance(entry, dict):
                                to_id = entry.get("id") or entry.get("toObjectId")
                            else:
                                to_id = entry
                            if to_id:
                                ids_out.append(str(to_id))
                    existing = mapping.setdefault(str(from_id), [])
                    for out_id in ids_out:
                        if out_id not in existing:
                            existing.append(out_id)
            return mapping
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotError(str(exc)) from exc

    def upsert_objects_from_csv(
        self,
        object_type: str,
        csv_path: Path,
        id_column: str,
        batch_size: int | None = None,
        pacing_profile: PacingProfile | None = None,
    ) -> BatchOutcome:
        with csv_path.open(newline="", encoding="utf-8-sig") as f:
            reader = list(csv.DictReader(f))

        if not reader:
            return BatchOutcome(succeeded=[], failed=[{"error": "empty_csv"}], retryable_failed=[])

        if id_column not in reader[0]:
            raise HubSpotError(f"id column '{id_column}' not found in CSV")

        succeeded: List[Dict[str, Any]] = []
        failed: List[Dict[str, Any]] = []
        retryable: List[Dict[str, Any]] = []

        url = f"{self.BASE_URL}/crm/{self.api_version}/objects/{object_type}/batch/upsert"

        profile = pacing_profile or get_hubspot_profile("safe")
        if batch_size:
            profile = PacingProfile(
                name=profile.name,
                batch_size=min(batch_size, 100),
                inter_batch_delay=profile.inter_batch_delay,
                rate_limit_headroom=profile.rate_limit_headroom,
            )
        pacer = BatchPacer(profile)

        for batch in pacer.chunk(reader):
            inputs = []
            for row in batch:
                hs_id = row.get(id_column)
                props = {k: v for k, v in row.items() if k != id_column}
                inputs.append({"idProperty": id_column, "properties": props, "id": hs_id})

            try:
                resp = self._post(
                    url,
                    headers=self._headers(),
                    json={"inputs": inputs},
                )
                body = resp.json()
                results = body.get("results", [])
                for item in results:
                    succeeded.append({"id": item.get("id")})
                # HubSpot batch endpoints can return 200 with partial errors
                for err in body.get("errors", []):
                    err_msg = err.get("message", "unknown error")
                    err_id = err.get("context", {}).get("id") or err.get("id") or "?"
                    failed.append({"id": err_id, "error": err_msg})
            except RateLimitError as exc:
                if pacer.profile.name == "turbo" and not pacer.is_downshifted:
                    logger.warning("HubSpot rate limited; downshifting to safe pacing.")
                    pacer.downshift()
                failed.extend([{"id": r.get(id_column), "error": str(exc)} for r in batch])
            except (IntegrationError, AuthenticationError) as exc:
                failed.extend([{"id": r.get(id_column), "error": str(exc)} for r in batch])
            pacer.pace()

        return BatchOutcome(succeeded=succeeded, failed=failed, retryable_failed=retryable)

    def associate_batch(
        self,
        from_type: str,
        to_type: str,
        mappings: List[Tuple[str, str]],
        batch_size: int | None = None,
        pacing_profile: PacingProfile | None = None,
    ) -> BatchOutcome:
        url = f"{self.BASE_URL}/crm/v4/associations/{from_type}/{to_type}/batch/create"
        succeeded: List[Dict[str, Any]] = []
        failed: List[Dict[str, Any]] = []

        profile = pacing_profile or get_hubspot_profile("safe")
        if batch_size:
            profile = PacingProfile(
                name=profile.name,
                batch_size=min(batch_size, 2000),
                inter_batch_delay=profile.inter_batch_delay,
                rate_limit_headroom=profile.rate_limit_headroom,
            )
        pacer = BatchPacer(profile)

        for batch in pacer.chunk(mappings):
            inputs = [{"from": {"id": f_id}, "to": {"id": t_id}} for f_id, t_id in batch]
            try:
                resp = self._post(
                    url,
                    headers=self._headers(),
                    json={"inputs": inputs},
                )
                results = resp.json().get("results", [])
                for r in results:
                    succeeded.append(
                        {
                            "from_id": r.get("from", {}).get("id"),
                            "to_id": r.get("to", {}).get("id"),
                        }
                    )
            except RateLimitError as exc:
                if pacer.profile.name == "turbo" and not pacer.is_downshifted:
                    logger.warning("HubSpot rate limited; downshifting to safe pacing.")
                    pacer.downshift()
                failed.extend([{"from_id": f, "to_id": t, "error": str(exc)} for f, t in batch])
            except (IntegrationError, AuthenticationError) as exc:
                failed.extend([{"from_id": f, "to_id": t, "error": str(exc)} for f, t in batch])
            pacer.pace()

        return BatchOutcome(succeeded=succeeded, failed=failed, retryable_failed=[])

    def dissociate_batch(
        self,
        from_type: str,
        to_type: str,
        mappings: List[Tuple[str, str]],
        batch_size: int | None = None,
        pacing_profile: PacingProfile | None = None,
    ) -> BatchOutcome:
        url = f"{self.BASE_URL}/crm/v4/associations/{from_type}/{to_type}/batch/archive"
        succeeded: List[Dict[str, Any]] = []
        failed: List[Dict[str, Any]] = []

        profile = pacing_profile or get_hubspot_profile("safe")
        if batch_size:
            profile = PacingProfile(
                name=profile.name,
                batch_size=min(batch_size, 2000),
                inter_batch_delay=profile.inter_batch_delay,
                rate_limit_headroom=profile.rate_limit_headroom,
            )
        pacer = BatchPacer(profile)

        for batch in pacer.chunk(mappings):
            inputs = [{"from": {"id": f_id}, "to": {"id": t_id}} for f_id, t_id in batch]
            try:
                self._post(
                    url,
                    headers=self._headers(),
                    json={"inputs": inputs},
                )
                succeeded.extend([{"from_id": f, "to_id": t} for f, t in batch])
            except RateLimitError as exc:
                if pacer.profile.name == "turbo" and not pacer.is_downshifted:
                    logger.warning("HubSpot rate limited; downshifting to safe pacing.")
                    pacer.downshift()
                failed.extend([{"from_id": f, "to_id": t, "error": str(exc)} for f, t in batch])
            except (IntegrationError, AuthenticationError) as exc:
                failed.extend([{"from_id": f, "to_id": t, "error": str(exc)} for f, t in batch])
            pacer.pace()

        return BatchOutcome(succeeded=succeeded, failed=failed, retryable_failed=[])

    # ------------------------------------------------------------------ #
    # Internal helpers
    # ------------------------------------------------------------------ #
    def _headers(self) -> Dict[str, str]:
        return {
            "Authorization": f"Bearer {self.access_token}",
            "Content-Type": "application/json",
        }


def connect(
    access_token: str,
    api_version: str = DEFAULT_API_VERSION,
    *,
    profile: str | None = None,
    set_current: bool = True,
    portal_id: str = "",
    hub_domain: str = "",
    time_zone: str = "",
    verified: bool = True,
) -> HubSpotConfig:
    """
    Backward-compatible connect helper. Persists token to legacy file
    and syncs to the credential provider (keychain) so the resolution
    chain stays current.
    """
    resolved_profile = _resolve_profile(profile) or DEFAULT_PROFILE
    cfg = HubSpotConfig(
        access_token=access_token,
        api_version=api_version,
        profile=resolved_profile,
        portal_id=portal_id,
        hub_domain=hub_domain,
        time_zone=time_zone,
        verified=verified,
    )
    cfg.save(set_current=set_current)

    # Sync token to the credential provider so the keychain backend
    # (highest-priority writable backend) stays in sync with the file.
    # Without this, a stale keychain entry shadows the freshly-written file.
    try:
        from g_gremlin.credentials.provider import CredentialProvider
        provider = CredentialProvider()
        provider.set_secret("hubspot", "access_token", access_token, profile=resolved_profile)
    except Exception:
        pass  # Keychain unavailable — file-only storage is fine

    return cfg


def validate(profile: str | None = None) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.validate()


def pull_objects(
    object_type: str,
    properties: List[str],
    limit: int,
    cfg: Optional[HubSpotConfig] = None,
    pacing_profile: PacingProfile | None = None,
) -> List[Dict[str, Any]]:
    api_version = cfg.api_version if cfg else DEFAULT_API_VERSION
    access_token = cfg.access_token if cfg else None
    client = HubSpotClient(access_token=access_token, api_version=api_version)
    return client.pull_objects(object_type, properties, limit, pacing_profile=pacing_profile)


def pull_engagements(
    engagement_types: List[str],
    *,
    properties: List[str] | None = None,
    limit: int = 0,
    since: str | None = None,
    until: str | None = None,
    cfg: Optional[HubSpotConfig] = None,
) -> Dict[str, List[Dict[str, Any]]]:
    api_version = cfg.api_version if cfg else DEFAULT_API_VERSION
    access_token = cfg.access_token if cfg else None
    client = HubSpotClient(access_token=access_token, api_version=api_version)
    return client.pull_engagements(
        engagement_types,
        properties=properties,
        limit=limit,
        since=since,
        until=until,
    )


def get_object_with_history(
    object_type: str,
    object_id: str,
    *,
    properties_with_history: List[str],
    properties: List[str] | None = None,
    associations: List[str] | None = None,
    archived: bool = False,
    id_property: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.get_object_with_history(
        object_type,
        object_id,
        properties_with_history=properties_with_history,
        properties=properties,
        associations=associations,
        archived=archived,
        id_property=id_property,
    )


def get_users(*, include_archived: bool = False, profile: str | None = None) -> List[Dict[str, Any]]:
    client = HubSpotClient(profile=profile)
    return client.get_users(include_archived=include_archived)


def get_goals(
    *,
    properties: List[str] | None = None,
    limit: int = 0,
    archived: bool = False,
    profile: str | None = None,
) -> List[Dict[str, Any]]:
    client = HubSpotClient(profile=profile)
    return client.get_goals(properties=properties, limit=limit, archived=archived)


def get_goal(
    goal_id: str,
    *,
    properties: List[str] | None = None,
    archived: bool = False,
    id_property: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.get_goal(
        goal_id,
        properties=properties,
        archived=archived,
        id_property=id_property,
    )


def create_association_label(
    from_type: str,
    to_type: str,
    *,
    name: str,
    label: str,
    inverse_label: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.create_association_label(
        from_type,
        to_type,
        name=name,
        label=label,
        inverse_label=inverse_label,
    )


def update_association_label(
    from_type: str,
    to_type: str,
    *,
    association_type_id: int,
    label: str,
    inverse_label: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.update_association_label(
        from_type,
        to_type,
        association_type_id=association_type_id,
        label=label,
        inverse_label=inverse_label,
    )


def delete_association_label(
    from_type: str,
    to_type: str,
    association_type_id: int,
    *,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.delete_association_label(from_type, to_type, association_type_id)


def get_association_limits(
    from_type: str,
    to_type: str,
    *,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.get_association_limits(from_type, to_type)


def set_association_limits(
    from_type: str,
    to_type: str,
    *,
    inputs: List[Dict[str, Any]],
    mode: str = "update",
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.set_association_limits(from_type, to_type, inputs=inputs, mode=mode)


def get_webhook_subscriptions(
    app_id: str,
    *,
    developer_api_key: str | None = None,
    profile: str | None = None,
) -> List[Dict[str, Any]]:
    # Developer webhooks APIs use app-level auth (hapikey), not portal token.
    # A placeholder access token avoids loading portal creds when not needed.
    client = HubSpotClient(access_token="developer-api-key-only", profile=profile)
    return client.get_webhook_subscriptions(app_id, developer_api_key=developer_api_key)


def create_webhook_subscription(
    app_id: str,
    *,
    event_type: str,
    property_name: str | None = None,
    active: bool = False,
    developer_api_key: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(access_token="developer-api-key-only", profile=profile)
    return client.create_webhook_subscription(
        app_id,
        event_type=event_type,
        property_name=property_name,
        active=active,
        developer_api_key=developer_api_key,
    )


def update_webhook_subscription(
    app_id: str,
    subscription_id: int,
    *,
    active: bool,
    developer_api_key: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(access_token="developer-api-key-only", profile=profile)
    return client.update_webhook_subscription(
        app_id,
        subscription_id,
        active=active,
        developer_api_key=developer_api_key,
    )


def delete_webhook_subscription(
    app_id: str,
    subscription_id: int,
    *,
    developer_api_key: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(access_token="developer-api-key-only", profile=profile)
    return client.delete_webhook_subscription(
        app_id,
        subscription_id,
        developer_api_key=developer_api_key,
    )


def upsert_objects_from_csv(
    object_type: str,
    csv_path: Path,
    id_column: str,
    cfg: Optional[HubSpotConfig] = None,
    batch_size: int | None = None,
    pacing_profile: PacingProfile | None = None,
) -> BatchOutcome:
    api_version = cfg.api_version if cfg else DEFAULT_API_VERSION
    access_token = cfg.access_token if cfg else None
    client = HubSpotClient(access_token=access_token, api_version=api_version)
    return client.upsert_objects_from_csv(
        object_type,
        csv_path,
        id_column,
        batch_size=batch_size,
        pacing_profile=pacing_profile,
    )


def start_export(
    *,
    object_type: str,
    export_type: str = "VIEW",
    file_format: str = "CSV",
    export_name: str | None = None,
    object_properties: List[str] | None = None,
    associated_object_types: List[str] | None = None,
    public_crm_search_request: Dict[str, Any] | None = None,
    language: str = "EN",
    list_id: str | None = None,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.start_export(
        object_type=object_type,
        export_type=export_type,
        file_format=file_format,
        export_name=export_name,
        object_properties=object_properties,
        associated_object_types=associated_object_types,
        public_crm_search_request=public_crm_search_request,
        language=language,
        list_id=list_id,
    )


def get_export_status(task_id: str, *, profile: str | None = None) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.get_export_status(task_id)


def download_export_file(
    task_id: str,
    out_path: Path,
    *,
    wait: bool = True,
    timeout_seconds: int = 900,
    poll_interval_seconds: int = 5,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.download_export_file(
        task_id,
        out_path,
        wait=wait,
        timeout_seconds=timeout_seconds,
        poll_interval_seconds=poll_interval_seconds,
    )


def start_import(
    import_request: Dict[str, Any],
    file_paths: List[Path],
    *,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.start_import(import_request, file_paths)


def get_import_status(import_id: str, *, profile: str | None = None) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.get_import_status(import_id)


def get_import_errors(
    import_id: str,
    *,
    include_warnings: bool = True,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.get_import_errors(import_id, include_warnings=include_warnings)


def cancel_import(import_id: str, *, profile: str | None = None) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.cancel_import(import_id)


def load_csv_mapping(csv_path: Path, from_col: str, to_col: str) -> List[Tuple[str, str]]:
    with csv_path.open(newline="", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        pairs: List[Tuple[str, str]] = []
        for row in reader:
            from_id = row.get(from_col)
            to_id = row.get(to_col)
            if not from_id or not to_id:
                continue
            pairs.append((from_id, to_id))
    return pairs


def associate_batch(
    from_type: str,
    to_type: str,
    mappings: List[Tuple[str, str]],
    cfg: Optional[HubSpotConfig] = None,
    batch_size: int | None = None,
    pacing_profile: PacingProfile | None = None,
) -> BatchOutcome:
    api_version = cfg.api_version if cfg else DEFAULT_API_VERSION
    access_token = cfg.access_token if cfg else None
    client = HubSpotClient(access_token=access_token, api_version=api_version)
    return client.associate_batch(
        from_type,
        to_type,
        mappings,
        batch_size=batch_size,
        pacing_profile=pacing_profile,
    )


def dissociate_batch(
    from_type: str,
    to_type: str,
    mappings: List[Tuple[str, str]],
    cfg: Optional[HubSpotConfig] = None,
    batch_size: int | None = None,
    pacing_profile: PacingProfile | None = None,
) -> BatchOutcome:
    api_version = cfg.api_version if cfg else DEFAULT_API_VERSION
    access_token = cfg.access_token if cfg else None
    client = HubSpotClient(access_token=access_token, api_version=api_version)
    return client.dissociate_batch(
        from_type,
        to_type,
        mappings,
        batch_size=batch_size,
        pacing_profile=pacing_profile,
    )


def merge_objects(
    object_type: str,
    primary_id: str,
    merge_id: str,
    *,
    profile: str | None = None,
) -> Dict[str, Any]:
    client = HubSpotClient(profile=profile)
    return client.merge_objects(object_type, primary_id, merge_id)


def _chunk(iterable: List[Any], size: int) -> Iterable[List[Any]]:
    for i in range(0, len(iterable), size):
        yield iterable[i : i + size]


__all__ = [
    "HubSpotError",
    "HubSpotClient",
    "HubSpotConfig",
    "connect",
    "validate",
    "pull_objects",
    "pull_engagements",
    "get_object_with_history",
    "get_users",
    "get_goals",
    "get_goal",
    "create_association_label",
    "update_association_label",
    "delete_association_label",
    "get_association_limits",
    "set_association_limits",
    "get_webhook_subscriptions",
    "create_webhook_subscription",
    "update_webhook_subscription",
    "delete_webhook_subscription",
    "upsert_objects_from_csv",
    "start_export",
    "get_export_status",
    "download_export_file",
    "start_import",
    "get_import_status",
    "get_import_errors",
    "cancel_import",
    "merge_objects",
    "associate_batch",
    "dissociate_batch",
    "load_csv_mapping",
    "normalize_engagement_types",
    "normalize_export_object_type",
    "DEFAULT_ENGAGEMENT_TYPES",
]
