"""
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
from datetime import datetime, timezone
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

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.profile_manager import ProfileNotFoundError, ProfileStore

CONFIG_PATH = Path.home() / ".g_gremlin" / "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

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",
]


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 _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

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]] = {}

        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,
                )

            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,
        }

    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_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_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 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},
                )
                results = resp.json().get("results", [])
                for item in results:
                    succeeded.append({"id": item.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([{"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 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 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 _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",
    "upsert_objects_from_csv",
    "associate_batch",
    "dissociate_batch",
    "load_csv_mapping",
    "normalize_engagement_types",
    "DEFAULT_ENGAGEMENT_TYPES",
]
