"""
Enrichment Provider Adapters for BYO Provider Locker.

Provides unified interfaces for company and contact enrichment across:
- FoundryGraph (internal, always available)
- FullEnrich (BYO API key, Unleashed tier)

The adapters integrate with SecretCache for decrypted credentials.
"""

from __future__ import annotations

import asyncio
import os
from datetime import datetime, timedelta, timezone
import time
from copy import deepcopy
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, TYPE_CHECKING, Tuple

import httpx
import logging

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from .secret_cache import SecretCache
    from sqlalchemy.ext.asyncio import AsyncSession

from ..models import IntegrationStatus
from .apollo_oauth import get_apollo_integration, refresh_apollo_tokens
from .extraction_utils import (
    clean_str as extraction_clean_str,
    coerce_int as extraction_coerce_int,
    dedupe_strings as extraction_dedupe_strings,
    join_location_parts as extraction_join_location_parts,
    normalize_domain as extraction_normalize_domain,
)
from .secret_storage import get_saas_secret_backend

from fmatch.core.fullenrich import constants as fe_constants
from fmatch.core.fullenrich import payload as fe_payload
from fmatch.core.fullenrich import schema as fe_schema
from fmatch.core.fullenrich import validation as fe_validation
from fmatch.saas.integrations.people_data_labs import (
    PeopleDataLabsAdapter,
    PDLCompanyEnrichmentRequest,
    PDLPersonEnrichmentRequest,
)

CONTACT_OUTPUT_FIELDS = [
    "first_name",
    "last_name",
    "work_email",
    "work_emails",
    "personal_email",
    "personal_emails",
    "phone",
    "phones",
    "title",
    "linkedin_url",
    "location",
    "company_name",
    "company_domain",
]
COMPANY_OUTPUT_FIELDS = [
    "industry",
    "employee_count",
    "employee_range",
    "headquarters",
    "city",
    "state",
    "country",
    "revenue_range",
    "founded_year",
    "website",
    "linkedin_url",
    "ultimate_parent",
    "stock_ticker",
]

PROVIDER_CAPABILITIES: Dict[str, Dict[str, Any]] = {
    "foundrygraph": {
        "provider": "foundrygraph",
        "label": "FoundryGraph",
        "entity_types": ["company"],
        "supported_fields": {
            "company": COMPANY_OUTPUT_FIELDS,
            "contact": [],
        },
        "quality_tier": "baseline",
        "billing": "included",
        "estimated_provider_cost_per_row": 0,
    },
    "fullenrich": {
        "provider": "fullenrich",
        "label": "FullEnrich",
        "entity_types": ["company", "contact"],
        "supported_fields": {
            "company": [
                "industry",
                "employee_count",
                "employee_range",
                "headquarters",
                "city",
                "state",
                "country",
                "revenue_range",
                "founded_year",
                "website",
                "linkedin_url",
                "stock_ticker",
            ],
            "contact": CONTACT_OUTPUT_FIELDS,
        },
        "quality_tier": "comprehensive",
        "billing": "external_api",
        "estimated_provider_cost_per_row": None,
    },
    "pdl": {
        "provider": "pdl",
        "label": "People Data Labs",
        "entity_types": ["company", "contact"],
        "supported_fields": {
            "company": COMPANY_OUTPUT_FIELDS,
            "contact": CONTACT_OUTPUT_FIELDS,
        },
        "quality_tier": "broad",
        "billing": "external_api",
        "estimated_provider_cost_per_row": None,
    },
    "apollo": {
        "provider": "apollo",
        "label": "Apollo",
        "entity_types": ["company", "contact"],
        "supported_fields": {
            "company": COMPANY_OUTPUT_FIELDS,
            "contact": [
                "first_name",
                "last_name",
                "work_email",
                "work_emails",
                "phone",
                "phones",
                "title",
                "linkedin_url",
                "location",
                "company_name",
                "company_domain",
            ],
        },
        "quality_tier": "broad",
        "billing": "external_plan",
        "estimated_provider_cost_per_row": None,
    },
    "amplemarket": {
        "provider": "amplemarket",
        "label": "Amplemarket",
        "entity_types": ["contact"],
        "supported_fields": {
            "company": [],
            "contact": [
                "first_name",
                "last_name",
                "work_email",
                "work_emails",
                "phone",
                "phones",
                "title",
                "linkedin_url",
                "company_name",
                "company_domain",
            ],
        },
        "quality_tier": "targeted",
        "billing": "external_api",
        "estimated_provider_cost_per_row": None,
    },
}


def _dedupe_fields(fields: Optional[List[str]]) -> List[str]:
    deduped: List[str] = []
    seen = set()
    for field in fields or []:
        key = str(field or "").strip()
        if not key or key in seen:
            continue
        seen.add(key)
        deduped.append(key)
    return deduped


def get_provider_capabilities(provider: str) -> Dict[str, Any]:
    provider_key = str(provider or "").strip().lower()
    base = PROVIDER_CAPABILITIES.get(provider_key)
    if base:
        return deepcopy(base)
    return {
        "provider": provider_key or "unknown",
        "label": provider_key or "Unknown",
        "entity_types": [],
        "supported_fields": {"company": [], "contact": []},
        "quality_tier": "unknown",
        "billing": "unknown",
        "estimated_provider_cost_per_row": None,
    }


def get_supported_output_fields(provider: str, enrichment_type: str) -> List[str]:
    capability = get_provider_capabilities(provider)
    entity = "contact" if str(enrichment_type or "").strip().lower() == "contact" else "company"
    fields = capability.get("supported_fields", {}).get(entity, []) or []
    return _dedupe_fields(fields)


def get_unsupported_output_fields(
    provider: str,
    enrichment_type: str,
    selected_fields: Optional[List[str]],
) -> List[str]:
    selected = _dedupe_fields(selected_fields)
    if not selected:
        return []
    supported_set = set(get_supported_output_fields(provider, enrichment_type))
    return [field for field in selected if field not in supported_set]


class EnrichmentStatus(str, Enum):
    """Status of an enrichment result."""
    ENRICHED = "enriched"
    NOT_FOUND = "not_found"
    SKIPPED = "skipped"  # Invalid input (blank domain, etc.)
    ERROR = "error"
    CACHE_HIT = "cache_hit"


@dataclass
class CompanyEnrichmentInput:
    """Input for company enrichment."""
    domain: Optional[str] = None
    company_name: Optional[str] = None
    row_index: int = 0  # Original row index for tracking
    custom: Dict[str, Any] = field(default_factory=dict)

    @property
    def is_valid(self) -> bool:
        """Check if input has required fields for enrichment."""
        return bool(self.domain and self.domain.strip())


@dataclass
class CompanyEnrichmentResult:
    """Result of company enrichment."""
    row_index: int
    status: EnrichmentStatus
    provider: Optional[str] = None
    # Enriched fields
    domain: Optional[str] = None
    company_name: Optional[str] = None
    industry: Optional[str] = None
    employee_count: Optional[int] = None
    employee_range: Optional[str] = None
    headquarters: Optional[str] = None
    country: Optional[str] = None
    city: Optional[str] = None
    state: Optional[str] = None
    revenue_range: Optional[str] = None
    founded_year: Optional[int] = None
    website: Optional[str] = None
    linkedin_url: Optional[str] = None
    ticker: Optional[str] = None
    ultimate_parent_domain: Optional[str] = None
    ultimate_parent_name: Optional[str] = None
    # Error info
    error_message: Optional[str] = None
    # Raw response for debugging
    raw: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "row_index": self.row_index,
            "status": self.status.value,
            "provider": self.provider,
            "domain": self.domain,
            "company_name": self.company_name,
            "industry": self.industry,
            "employee_count": self.employee_count,
            "employee_range": self.employee_range,
            "headquarters": self.headquarters,
            "country": self.country,
            "city": self.city,
            "state": self.state,
            "revenue_range": self.revenue_range,
            "founded_year": self.founded_year,
            "website": self.website,
            "linkedin_url": self.linkedin_url,
            "ticker": self.ticker,
            "ultimate_parent_domain": self.ultimate_parent_domain,
            "ultimate_parent_name": self.ultimate_parent_name,
            "error_message": self.error_message,
        }


ContactEnrichmentInput = fe_schema.ContactEnrichmentInput


@dataclass
class ContactEnrichmentResult:
    """Result of contact enrichment."""
    row_index: int
    status: EnrichmentStatus
    provider: Optional[str] = None
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    # Work email (primary)
    email: Optional[str] = None
    emails: List[str] = field(default_factory=list)
    # Personal email
    personal_email: Optional[str] = None
    personal_emails: List[str] = field(default_factory=list)
    # Phone
    phone: Optional[str] = None
    phones: List[str] = field(default_factory=list)
    linkedin_url: Optional[str] = None
    title: Optional[str] = None
    location: Optional[str] = None
    company_name: Optional[str] = None
    company_domain: Optional[str] = None
    error_message: Optional[str] = None
    raw: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "row_index": self.row_index,
            "status": self.status.value,
            "provider": self.provider,
            "first_name": self.first_name,
            "last_name": self.last_name,
            "work_email": self.email,
            "work_emails": self.emails,
            "email": self.email,
            "emails": self.emails,
            "personal_email": self.personal_email,
            "personal_emails": self.personal_emails,
            "phone": self.phone,
            "phones": self.phones,
            "linkedin_url": self.linkedin_url,
            "title": self.title,
            "location": self.location,
            "company_name": self.company_name,
            "company_domain": self.company_domain,
            "error_message": self.error_message,
        }


class EnrichmentProvider(Protocol):
    """Protocol for enrichment providers."""

    @property
    def name(self) -> str:
        """Provider name."""
        ...

    @property
    def is_available(self) -> bool:
        """Check if provider is available (has required credentials)."""
        ...

    @property
    def capabilities(self) -> Dict[str, Any]:
        """Provider capability metadata for field and entity support."""
        ...

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        """
        Enrich a batch of companies.

        Args:
            inputs: List of company enrichment inputs

        Returns:
            List of enrichment results (same order as inputs)
        """
        ...

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        """
        Enrich a batch of contacts.

        Args:
            inputs: List of contact enrichment inputs

        Returns:
            List of enrichment results (same order as inputs)
        """
        ...


class FoundryGraphAdapter:
    """
    Adapter for FoundryGraph enrichment (internal service).

    Always available for all tiers. Uses the internal FoundryGraph API.
    """
    CAPABILITIES = PROVIDER_CAPABILITIES["foundrygraph"]

    def __init__(
        self,
        api_base: Optional[str] = None,
        api_token: Optional[str] = None,
    ):
        import os
        self.api_base = api_base or os.getenv(
            "FOUNDRYGRAPH_API_BASE",
            os.getenv("FM_FOUNDRYGRAPH_API", "https://fuzzy-matcher-api-5pkec6diha-uc.a.run.app"),
        )
        self.api_token = api_token
        self._client: Optional[httpx.AsyncClient] = None

    @property
    def name(self) -> str:
        return "foundrygraph"

    @property
    def is_available(self) -> bool:
        # FoundryGraph is always available (internal service)
        return True

    @property
    def capabilities(self) -> Dict[str, Any]:
        return deepcopy(self.CAPABILITIES)

    async def _get_client(self) -> httpx.AsyncClient:
        """Get or create HTTP client."""
        if self._client is None:
            headers = {"Content-Type": "application/json"}
            if self.api_token:
                headers["Authorization"] = f"Bearer {self.api_token}"
            self._client = httpx.AsyncClient(
                base_url=self.api_base,
                headers=headers,
                timeout=30.0,
            )
        return self._client

    async def close(self) -> None:
        """Close HTTP client."""
        if self._client:
            await self._client.aclose()
            self._client = None

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        """
        Enrich companies using FoundryGraph.

        Args:
            inputs: List of company enrichment inputs

        Returns:
            List of enrichment results
        """
        results = []
        client = await self._get_client()

        for inp in inputs:
            if not inp.is_valid:
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    error_message="Invalid input: domain required",
                ))
                continue

            try:
                response = await client.get(
                    "/api/v2/foundrygraph/lookup/domain",
                    params={"domain": inp.domain},
                )

                if response.status_code == 404:
                    results.append(CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.NOT_FOUND,
                        provider=self.name,
                        domain=inp.domain,
                    ))
                    continue

                response.raise_for_status()
                data = response.json()

                company = data.get("company", {})
                if not company:
                    results.append(CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.NOT_FOUND,
                        provider=self.name,
                        domain=inp.domain,
                    ))
                    continue

                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ENRICHED,
                    provider=self.name,
                    domain=inp.domain or company.get("domain"),
                    company_name=company.get("name") or company.get("company_name"),
                    industry=company.get("industry"),
                    employee_count=company.get("employee_count"),
                    employee_range=company.get("employee_range"),
                    headquarters=company.get("headquarters"),
                    country=company.get("country"),
                    city=company.get("city"),
                    state=company.get("state"),
                    revenue_range=company.get("revenue_range"),
                    founded_year=company.get("founded_year"),
                    website=company.get("website"),
                    linkedin_url=company.get("linkedin_url"),
                    ticker=company.get("ticker"),
                    ultimate_parent_domain=company.get("ultimate_parent_domain"),
                    ultimate_parent_name=company.get("ultimate_parent_name"),
                    raw=company,
                ))

            except httpx.HTTPStatusError as e:
                logger.warning(f"FoundryGraph HTTP error for {inp.domain}: {e}")
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    domain=inp.domain,
                    error_message=f"HTTP error: {e.response.status_code}",
                ))
            except Exception as e:
                logger.error(f"FoundryGraph error for {inp.domain}: {e}")
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    domain=inp.domain,
                    error_message=str(e),
                ))

        return results

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        """
        Contact enrichment is not supported for FoundryGraph.
        """
        results = []
        for inp in inputs:
            if not inp.is_valid:
                results.append(ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Invalid input: LinkedIn URL or name + company required",
                ))
            else:
                results.append(ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="FoundryGraph does not support contact enrichment",
                ))
        return results


class FullEnrichAdapter:
    """
    Adapter for FullEnrich enrichment (BYO API key).

    Requires Unleashed tier and stored API key.
    Aggregates 15+ data providers for higher enrichment rates.
    """

    CONTACT_DEFAULT_FIELDS = fe_constants.DEFAULT_CONTACT_FIELDS
    CAPABILITIES = PROVIDER_CAPABILITIES["fullenrich"]

    def __init__(
        self,
        api_key: Optional[str] = None,
        secret_cache: Optional["SecretCache"] = None,
        api_version: Optional[str] = None,
    ):
        self._api_key = api_key
        self._secret_cache = secret_cache
        self._client: Optional[httpx.AsyncClient] = None
        self.api_version = fe_constants.normalize_api_version(
            api_version
            or os.getenv("FULLENRICH_API_VERSION")
            or fe_constants.DEFAULT_API_VERSION.value
        )

    @property
    def name(self) -> str:
        return "fullenrich"

    @property
    def api_key(self) -> Optional[str]:
        """Get API key from cache or direct injection."""
        if self._api_key:
            return self._api_key
        if self._secret_cache:
            return self._secret_cache.get("fullenrich", "api_key")
        return None

    @property
    def is_available(self) -> bool:
        """Check if FullEnrich is available (has API key)."""
        return bool(self.api_key)

    @property
    def capabilities(self) -> Dict[str, Any]:
        return deepcopy(self.CAPABILITIES)

    async def _get_client(self) -> httpx.AsyncClient:
        """Get or create HTTP client."""
        if self._client is None:
            key = self.api_key
            if not key:
                raise RuntimeError("FullEnrich API key not available")
            self._client = httpx.AsyncClient(
                base_url=fe_constants.api_base(self.api_version.value),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {key}",
                    "X-API-Key": key,
                },
                timeout=60.0,  # FullEnrich can be slow for bulk
            )
        return self._client

    async def close(self) -> None:
        """Close HTTP client."""
        if self._client:
            await self._client.aclose()
            self._client = None

    async def verify_api_key(self) -> bool:
        """Verify API key is valid."""
        if not self.api_key:
            return False
        try:
            client = await self._get_client()
            response = await client.get("/account/keys/verify")
            return response.status_code == 200
        except Exception as e:
            logger.warning(f"FullEnrich API key verification failed: {e}")
            return False

    async def get_credits(self) -> Optional[int]:
        """Get remaining credits."""
        if not self.is_available:
            return None
        try:
            client = await self._get_client()
            response = await client.get("/account/credits")
            response.raise_for_status()
            data = response.json()
            return fe_constants.parse_credit_balance(data)
        except Exception as e:
            logger.warning(f"Failed to get FullEnrich credits: {e}")
            return None

    @classmethod
    def _extract_company_dict(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
        data = payload if isinstance(payload, dict) else {}
        for key in ("company", "organization", "data", "result"):
            nested = data.get(key)
            if not isinstance(nested, dict):
                continue
            if isinstance(nested.get("company"), dict):
                return nested.get("company") or {}
            if isinstance(nested.get("organization"), dict):
                return nested.get("organization") or {}
            return nested
        return data

    @classmethod
    def _extract_company_payload(
        cls,
        payload: Dict[str, Any],
        *,
        fallback_domain: Optional[str] = None,
        fallback_name: Optional[str] = None,
    ) -> Dict[str, Any]:
        company = cls._extract_company_dict(payload)
        location = company.get("location") if isinstance(company.get("location"), dict) else {}
        headquarters_raw = (
            company.get("headquarters")
            if isinstance(company.get("headquarters"), dict)
            else {}
        )
        address = company.get("address") if isinstance(company.get("address"), dict) else {}
        social_links = company.get("social_links") if isinstance(company.get("social_links"), dict) else {}
        profiles = company.get("profiles")
        parent = (
            company.get("ultimate_parent")
            if isinstance(company.get("ultimate_parent"), dict)
            else company.get("parent")
            if isinstance(company.get("parent"), dict)
            else {}
        )

        city = extraction_clean_str(
            company.get("city")
            or location.get("city")
            or location.get("locality")
            or headquarters_raw.get("city")
            or headquarters_raw.get("locality")
            or address.get("city")
        )
        state = extraction_clean_str(
            company.get("state")
            or company.get("region")
            or location.get("state")
            or location.get("region")
            or headquarters_raw.get("state")
            or headquarters_raw.get("region")
            or address.get("state")
        )
        country = extraction_clean_str(
            company.get("country")
            or company.get("country_name")
            or location.get("country")
            or location.get("country_name")
            or headquarters_raw.get("country")
            or address.get("country")
        )
        headquarters = extraction_clean_str(company.get("headquarters") or company.get("hq"))
        if not headquarters:
            headquarters = extraction_join_location_parts([city, state, country])

        linkedin_url = extraction_clean_str(
            company.get("linkedin_url")
            or company.get("linkedin")
            or social_links.get("linkedin")
            or social_links.get("linkedin_url")
        )
        if not linkedin_url and isinstance(profiles, dict):
            linkedin_url = extraction_clean_str(
                profiles.get("linkedin")
                or profiles.get("linkedin_url")
                or profiles.get("linkedin_profile")
            )
        if not linkedin_url and isinstance(profiles, list):
            for profile in profiles:
                if not isinstance(profile, dict):
                    continue
                profile_url = extraction_clean_str(profile.get("url") or profile.get("link"))
                if not profile_url:
                    continue
                profile_type = extraction_clean_str(profile.get("type"))
                if profile_type and profile_type.lower() == "linkedin":
                    linkedin_url = profile_url
                    break
                if "linkedin." in profile_url.lower():
                    linkedin_url = profile_url
                    break

        return {
            "domain": extraction_normalize_domain(
                company.get("domain")
                or company.get("primary_domain")
                or company.get("company_domain")
                or company.get("website")
                or company.get("website_url")
                or company.get("url")
                or fallback_domain
            ),
            "company_name": extraction_clean_str(
                company.get("name")
                or company.get("company_name")
                or company.get("organization_name")
                or company.get("legal_name")
                or fallback_name
            ),
            "industry": extraction_clean_str(
                company.get("industry")
                or company.get("industry_name")
                or company.get("primary_industry")
            ),
            "employee_count": extraction_coerce_int(
                company.get("employee_count")
                or company.get("employees")
                or company.get("estimated_num_employees")
                or company.get("headcount")
            ),
            "employee_range": extraction_clean_str(
                company.get("employee_range")
                or company.get("employees_range")
                or company.get("employee_size")
            ),
            "headquarters": headquarters,
            "city": city,
            "state": state,
            "country": country,
            "revenue_range": extraction_clean_str(
                company.get("revenue_range")
                or company.get("revenue")
                or company.get("annual_revenue_printed")
                or company.get("estimated_annual_revenue")
            ),
            "founded_year": extraction_coerce_int(
                company.get("founded_year")
                or company.get("founded")
                or company.get("year_founded")
                or company.get("founded_in")
            ),
            "website": extraction_clean_str(company.get("website") or company.get("website_url")),
            "linkedin_url": linkedin_url,
            "ticker": extraction_clean_str(
                company.get("ticker")
                or company.get("stock_ticker")
                or company.get("symbol")
            ),
            "ultimate_parent_name": extraction_clean_str(
                company.get("ultimate_parent_name")
                or parent.get("name")
                or parent.get("organization_name")
            ),
            "ultimate_parent_domain": extraction_normalize_domain(
                company.get("ultimate_parent_domain")
                or parent.get("domain")
                or parent.get("primary_domain")
                or parent.get("website")
                or parent.get("website_url")
            ),
            "raw": company,
        }

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        """
        Enrich companies using FullEnrich.

        Note: FullEnrich is primarily a contact enrichment API.
        For company enrichment, we use domain lookup.

        Args:
            inputs: List of company enrichment inputs

        Returns:
            List of enrichment results
        """
        if not self.is_available:
            return [
                CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    error_message="FullEnrich API key not configured",
                )
                for inp in inputs
            ]

        results = []
        client = await self._get_client()

        for inp in inputs:
            if not inp.is_valid:
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    error_message="Invalid input: domain required",
                ))
                continue

            try:
                # FullEnrich company endpoint
                response = await client.post(
                    "/company/enrich",
                    json={"domain": inp.domain},
                )

                if response.status_code == 404:
                    results.append(CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.NOT_FOUND,
                        provider=self.name,
                        domain=inp.domain,
                    ))
                    continue

                if response.status_code == 402:  # Insufficient credits
                    results.append(CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        domain=inp.domain,
                        error_message="Insufficient FullEnrich credits",
                    ))
                    continue

                response.raise_for_status()
                data = response.json()
                normalized = self._extract_company_payload(
                    data if isinstance(data, dict) else {},
                    fallback_domain=inp.domain,
                    fallback_name=inp.company_name,
                )
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ENRICHED,
                    provider=self.name,
                    domain=normalized.get("domain"),
                    company_name=normalized.get("company_name"),
                    industry=normalized.get("industry"),
                    employee_count=normalized.get("employee_count"),
                    employee_range=normalized.get("employee_range"),
                    headquarters=normalized.get("headquarters"),
                    country=normalized.get("country"),
                    city=normalized.get("city"),
                    state=normalized.get("state"),
                    revenue_range=normalized.get("revenue_range"),
                    founded_year=normalized.get("founded_year"),
                    website=normalized.get("website"),
                    linkedin_url=normalized.get("linkedin_url"),
                    ticker=normalized.get("ticker"),
                    ultimate_parent_domain=normalized.get("ultimate_parent_domain"),
                    ultimate_parent_name=normalized.get("ultimate_parent_name"),
                    raw=normalized.get("raw") or {},
                ))

            except httpx.HTTPStatusError as e:
                logger.warning(f"FullEnrich HTTP error for {inp.domain}: {e}")
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    domain=inp.domain,
                    error_message=f"HTTP error: {e.response.status_code}",
                ))
            except Exception as e:
                logger.error(f"FullEnrich error for {inp.domain}: {e}")
                results.append(CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    domain=inp.domain,
                    error_message=str(e),
                ))

        return results

    def _extract_custom_value(
        self,
        custom: Dict[str, Any],
        keys: List[str],
    ) -> Optional[str]:
        return fe_validation.extract_custom_value(custom, keys)

    def _extract_domain_from_email(self, email: Optional[str]) -> Optional[str]:
        return fe_validation.extract_domain_from_email(email)

    def _derive_contact_name(
        self,
        input_row: ContactEnrichmentInput,
    ) -> Optional[Tuple[str, str]]:
        return fe_validation.derive_contact_name(
            first_name=input_row.firstname,
            last_name=input_row.lastname,
            email=input_row.email,
            linkedin_url=input_row.linkedin_url,
            custom=input_row.custom,
        )

    def _normalize_contact_list(self, value: Any) -> List[str]:
        return fe_payload.normalize_contact_list(value)

    def _parse_contact_payload(
        self,
        raw: Dict[str, Any],
        fallback_input: ContactEnrichmentInput,
        *,
        api_version: Optional[str] = None,
    ) -> ContactEnrichmentResult:
        core_input = fe_schema.ContactEnrichmentInput(
            first_name=fallback_input.firstname,
            last_name=fallback_input.lastname,
            email=fallback_input.email,
            linkedin_url=fallback_input.linkedin_url,
            domain=fallback_input.domain,
            company_name=fallback_input.company_name,
            custom=fallback_input.custom,
            row_index=fallback_input.row_index,
        )
        version = fe_constants.normalize_api_version(api_version) if api_version else None
        if version is None:
            logger.warning("FullEnrich api_version missing in parser; defaulting to v1.")
            version = fe_constants.FullEnrichAPIVersion.V1
        if version == fe_constants.FullEnrichAPIVersion.V2:
            core_result = fe_payload.parse_contact_response_v2(raw, core_input)
        else:
            core_result = fe_payload.parse_contact_response_v1(raw, core_input)
        status_value = core_result.status
        if status_value not in EnrichmentStatus._value2member_map_:
            status_value = EnrichmentStatus.NOT_FOUND.value

        return ContactEnrichmentResult(
            row_index=core_result.row_index or fallback_input.row_index,
            status=EnrichmentStatus(status_value),
            provider=self.name,
            first_name=core_result.first_name or core_result.firstname,
            last_name=core_result.last_name or core_result.lastname,
            email=core_result.email,
            emails=core_result.emails or [],
            personal_email=core_result.personal_email,
            personal_emails=core_result.personal_emails or [],
            phone=core_result.phone,
            phones=core_result.phones or [],
            linkedin_url=core_result.linkedin_url,
            title=core_result.title,
            location=core_result.location,
            company_name=core_result.company_name,
            company_domain=core_result.company_domain,
            error_message=core_result.error_message,
            raw=core_result.raw or raw,
        )

    async def _poll_contact_results(
        self,
        enrichment_id: str,
        *,
        max_wait_seconds: float = 360.0,
        poll_interval_seconds: float = 30.0,
    ) -> Dict[str, Any]:
        client = await self._get_client()
        start = time.monotonic()
        while time.monotonic() - start < max_wait_seconds:
            response = await client.get(f"/contact/enrich/bulk/{enrichment_id}")
            response.raise_for_status()
            data = response.json()
            status = str(data.get("status") or "").upper()
            if status in fe_constants.JOB_TERMINAL_STATUSES:
                return data
            await asyncio.sleep(poll_interval_seconds)
        logger.warning("FullEnrich contact poll timeout for enrichment_id=%s", enrichment_id)
        data = {"status": "timeout", "enrichment_id": enrichment_id}
        return data

    async def fetch_contact_enrichment(
        self,
        enrichment_id: str,
    ) -> Dict[str, Any]:
        """Fetch contact enrichment payload once for the given enrichment_id."""
        client = await self._get_client()
        response = await client.get(f"/contact/enrich/bulk/{enrichment_id}")
        response.raise_for_status()
        return response.json()

    async def submit_contact_enrichment(
        self,
        inputs: List[ContactEnrichmentInput],
        *,
        webhook_url: str,
        job_id: Optional[str] = None,
        run_id: Optional[str] = None,
        workspace_id: Optional[str] = None,
        enrich_fields: Optional[List[str]] = None,
    ) -> str:
        """
        Submit contact enrichment to FullEnrich (async via webhook).

        Args:
            inputs: List of contact enrichment inputs
            webhook_url: URL for FullEnrich to call when enrichment is complete
            job_id: Optional job ID to include in custom payload
            run_id: Optional run ID to include in custom payload
            workspace_id: Optional workspace ID to include in custom payload
            enrich_fields: FullEnrich API enrich_fields to request (e.g., ["contact.emails", "contact.phones"])

        Returns:
            FullEnrich enrichment_id
        """
        if not self.is_available:
            raise RuntimeError("FullEnrich API key not configured")

        # Use provided enrich_fields or fall back to defaults
        fields_to_request = enrich_fields or self.CONTACT_DEFAULT_FIELDS

        client = await self._get_client()
        custom_context: Dict[str, Any] = {}
        if job_id:
            custom_context["job_id"] = job_id
        if run_id:
            custom_context["run_id"] = run_id
        if workspace_id:
            custom_context["workspace_id"] = workspace_id

        payload, accepted, _skipped = fe_payload.build_bulk_payload(
            inputs,
            name=f"BYO Contact Enrichment ({len(inputs)} contacts)",
            webhook_url=webhook_url,
            default_fields=fields_to_request,
            api_version=self.api_version.value,
            custom_context=custom_context,
        )

        if not accepted:
            raise RuntimeError("No valid contact inputs to submit")

        response = await client.post(
            "/contact/enrich/bulk",
            params=fe_constants.CHECK_PARAMS,
            json=payload,
        )
        if response.status_code == 402:
            raise RuntimeError("Insufficient FullEnrich credits")
        response.raise_for_status()
        data = response.json()
        enrichment_id = data.get("enrichment_id") or data.get("id")
        if not enrichment_id:
            raise RuntimeError("FullEnrich response missing enrichment_id")
        return enrichment_id

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        """
        Enrich contacts using FullEnrich bulk endpoint.
        """
        results: Dict[int, ContactEnrichmentResult] = {}
        pending: List[Tuple[ContactEnrichmentInput, Tuple[str, str]]] = []

        for inp in inputs:
            if not inp.is_valid:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Invalid input: email, LinkedIn URL, or name + company required",
                )
                continue

            name = self._derive_contact_name(inp)
            if not name and not inp.linkedin_url:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Unable to derive contact name from input",
                )
                continue

            pending.append((inp, name or ("", "")))

        if not pending:
            return [results[inp.row_index] for inp in inputs]

        if not self.is_available:
            for inp, _ in pending:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="FullEnrich API key not configured",
                )
            return [results[inp.row_index] for inp in inputs]

        client = await self._get_client()
        payload, accepted, _skipped = fe_payload.build_bulk_payload(
            [inp for inp, _ in pending],
            name=f"BYO Contact Enrichment ({len(pending)} contacts)",
            default_fields=self.CONTACT_DEFAULT_FIELDS,
            api_version=self.api_version.value,
        )
        if not accepted:
            return [results[inp.row_index] for inp in inputs]

        try:
            response = await client.post(
                "/contact/enrich/bulk",
                params=fe_constants.CHECK_PARAMS,
                json=payload,
            )
            if response.status_code == 402:
                for inp, _ in pending:
                    results[inp.row_index] = ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        first_name=inp.firstname,
                        last_name=inp.lastname,
                        email=str(inp.email).strip() if inp.email else None,
                        linkedin_url=inp.linkedin_url,
                        company_name=inp.company_name,
                        company_domain=inp.domain,
                        error_message="Insufficient FullEnrich credits",
                    )
                return [results[inp.row_index] for inp in inputs]

            response.raise_for_status()
            data = response.json()

            if self.api_version == fe_constants.FullEnrichAPIVersion.V2:
                has_inline_data = bool(data.get("data"))
            else:
                has_inline_data = bool(data.get("datas"))

            if has_inline_data:
                final_data = data
            else:
                enrichment_id = data.get("enrichment_id") or data.get("id")
                if not enrichment_id:
                    raise RuntimeError("FullEnrich response missing enrichment_id")
                max_wait_seconds = max(180.0, min(360.0, len(pending) * 45.0))
                final_data = await self._poll_contact_results(
                    enrichment_id,
                    max_wait_seconds=max_wait_seconds,
                )

            status_final = str(final_data.get("status") or "").upper()
            if status_final in fe_constants.JOB_FAILURE_STATUSES or status_final == "RATE_LIMIT":
                error_message = final_data.get("error") or "FullEnrich contact enrichment failed"
                logger.warning(
                    "FullEnrich contact job failed: status=%s error=%s",
                    status_final,
                    error_message,
                )
                for inp, _ in pending:
                    results[inp.row_index] = ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        first_name=inp.firstname,
                        last_name=inp.lastname,
                        email=str(inp.email).strip() if inp.email else None,
                        linkedin_url=inp.linkedin_url,
                        company_name=inp.company_name,
                        company_domain=inp.domain,
                        error_message=error_message,
                    )
                return [results[inp.row_index] for inp in inputs]

            if self.api_version == fe_constants.FullEnrichAPIVersion.V2:
                raw_contacts = final_data.get("data") or []
            else:
                raw_contacts = final_data.get("datas") or []
            pending_map = {inp.row_index: inp for inp, _ in pending}

            for idx, raw in enumerate(raw_contacts):
                raw_custom = raw.get("custom") or {}
                row_index = raw_custom.get("row_index")
                try:
                    row_index = int(row_index)
                except (TypeError, ValueError):
                    row_index = None
                if row_index is None:
                    if idx < len(pending):
                        row_index = pending[idx][0].row_index
                fallback_input = pending_map.get(row_index)
                if not fallback_input:
                    continue
                results[row_index] = self._parse_contact_payload(
                    raw,
                    fallback_input,
                    api_version=self.api_version.value,
                )

            # Mark any missing rows as not found
            for inp, _ in pending:
                if inp.row_index not in results:
                    results[inp.row_index] = ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.NOT_FOUND,
                        provider=self.name,
                        first_name=inp.firstname,
                        last_name=inp.lastname,
                        email=str(inp.email).strip() if inp.email else None,
                        linkedin_url=inp.linkedin_url,
                        company_name=inp.company_name,
                        company_domain=inp.domain,
                    )

        except httpx.HTTPStatusError as e:
            logger.warning(f"FullEnrich contact HTTP error: {e}")
            for inp, _ in pending:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message=f"HTTP error: {e.response.status_code}",
                )
        except Exception as e:
            logger.error(f"FullEnrich contact error: {e}")
            for inp, _ in pending:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message=str(e),
                )

        return [results[inp.row_index] for inp in inputs]


class PDLAdapter:
    """Adapter for People Data Labs enrichment (BYO API key)."""

    BATCH_SIZE = 100
    CAPABILITIES = PROVIDER_CAPABILITIES["pdl"]

    def __init__(
        self,
        api_key: Optional[str] = None,
        secret_cache: Optional["SecretCache"] = None,
    ):
        self._api_key = api_key
        self._secret_cache = secret_cache
        self._adapter: Optional[PeopleDataLabsAdapter] = None

    @property
    def name(self) -> str:
        return "pdl"

    @property
    def api_key(self) -> Optional[str]:
        if self._api_key:
            return self._api_key
        if self._secret_cache:
            return self._secret_cache.get("pdl", "api_key")
        return None

    @property
    def is_available(self) -> bool:
        return bool(self.api_key)

    @property
    def capabilities(self) -> Dict[str, Any]:
        return deepcopy(self.CAPABILITIES)

    def _get_adapter(self) -> PeopleDataLabsAdapter:
        if self._adapter is None:
            api_key = self.api_key
            if not api_key:
                raise RuntimeError("PDL API key not available")
            self._adapter = PeopleDataLabsAdapter(api_key=api_key)
        return self._adapter

    async def close(self) -> None:
        if self._adapter:
            await asyncio.to_thread(self._adapter._client.close)
            self._adapter = None

    @staticmethod
    def _chunk_inputs(
        inputs: List[Any],
        size: int,
    ) -> List[List[Any]]:
        return [inputs[i : i + size] for i in range(0, len(inputs), size)]

    @staticmethod
    def _coerce_int(value: Any) -> Optional[int]:
        return extraction_coerce_int(value)

    @staticmethod
    def _normalize_domain(value: Optional[str]) -> Optional[str]:
        return extraction_normalize_domain(value)

    @staticmethod
    def _format_location(location: Any) -> Optional[str]:
        if isinstance(location, dict):
            return extraction_join_location_parts(
                [
                    location.get("locality") or location.get("city"),
                    location.get("region") or location.get("state"),
                    location.get("country"),
                ]
            )
        return extraction_clean_str(location)

    @staticmethod
    def _extract_linkedin_url(data: Dict[str, Any]) -> Optional[str]:
        direct_candidates = [
            data.get("linkedin_url"),
            data.get("linkedin"),
            data.get("profile_url"),
            data.get("profile"),
        ]
        for candidate in direct_candidates:
            url = extraction_clean_str(candidate)
            if url and "linkedin." in url.lower():
                return url

        profiles = data.get("profiles") or []
        if isinstance(profiles, dict):
            for key in ("linkedin", "linkedin_url", "linkedin_profile"):
                url = extraction_clean_str(profiles.get(key))
                if url:
                    return url
            profiles = profiles.get("items") if isinstance(profiles.get("items"), list) else []
        if not isinstance(profiles, list):
            profiles = []

        for profile in profiles:
            if isinstance(profile, str):
                url_clean = extraction_clean_str(profile)
                if url_clean and "linkedin." in url_clean.lower():
                    return url_clean
                continue
            if not isinstance(profile, dict):
                continue
            url = extraction_clean_str(profile.get("url") or profile.get("link"))
            if not url:
                continue
            profile_type = str(profile.get("type") or "").lower()
            if profile_type == "linkedin" or "linkedin." in url.lower():
                return url
        return None

    def _extract_company_fields(
        self,
        data: Dict[str, Any],
        *,
        fallback_domain: Optional[str] = None,
        fallback_company_name: Optional[str] = None,
    ) -> Dict[str, Any]:
        location = data.get("location") if isinstance(data.get("location"), dict) else {}
        hq = data.get("headquarters") if isinstance(data.get("headquarters"), dict) else {}
        address = data.get("address") if isinstance(data.get("address"), dict) else {}
        ultimate_parent = (
            data.get("ultimate_parent")
            if isinstance(data.get("ultimate_parent"), dict)
            else data.get("parent")
            if isinstance(data.get("parent"), dict)
            else {}
        )
        city = extraction_clean_str(
            data.get("city")
            or location.get("locality")
            or location.get("city")
            or hq.get("locality")
            or hq.get("city")
            or address.get("city")
        )
        state = extraction_clean_str(
            data.get("state")
            or data.get("region")
            or location.get("region")
            or location.get("state")
            or hq.get("region")
            or hq.get("state")
            or address.get("state")
        )
        country = extraction_clean_str(
            data.get("country")
            or data.get("country_name")
            or location.get("country")
            or location.get("country_name")
            or hq.get("country")
            or address.get("country")
        )
        headquarters = self._format_location(location)
        if not headquarters:
            headquarters = self._format_location(data.get("headquarters") or data.get("hq") or address)
        if not headquarters:
            headquarters = extraction_join_location_parts([city, state, country])

        return {
            "domain": self._normalize_domain(
                fallback_domain
                or data.get("domain")
                or data.get("website")
                or data.get("website_url")
            ),
            "company_name": extraction_clean_str(
                data.get("name")
                or data.get("company_name")
                or data.get("organization_name")
                or fallback_company_name
            ),
            "industry": extraction_clean_str(data.get("industry") or data.get("industry_name")),
            "employee_count": self._coerce_int(
                data.get("employee_count")
                or data.get("size")
                or data.get("employees")
            ),
            "employee_range": extraction_clean_str(
                data.get("employee_range")
                or data.get("employees_range")
                or data.get("employee_size")
            ),
            "headquarters": headquarters,
            "country": country,
            "city": city,
            "state": state,
            "revenue_range": extraction_clean_str(
                data.get("revenue")
                or data.get("revenue_range")
                or data.get("estimated_revenue")
                or data.get("annual_revenue")
            ),
            "founded_year": self._coerce_int(data.get("founded_year") or data.get("founded")),
            "website": extraction_clean_str(data.get("website") or data.get("website_url")),
            "linkedin_url": self._extract_linkedin_url(data),
            "ticker": extraction_clean_str(data.get("ticker") or data.get("stock_ticker") or data.get("symbol")),
            "ultimate_parent_domain": self._normalize_domain(
                data.get("ultimate_parent_domain")
                or ultimate_parent.get("domain")
                or ultimate_parent.get("primary_domain")
                or ultimate_parent.get("website")
            ),
            "ultimate_parent_name": extraction_clean_str(
                data.get("ultimate_parent_name")
                or ultimate_parent.get("name")
                or ultimate_parent.get("organization_name")
            ),
        }

    @staticmethod
    def _extract_email_list(data: Dict[str, Any]) -> List[str]:
        candidates: List[Any] = [
            data.get("email"),
            data.get("work_email"),
            data.get("email_address"),
            data.get("business_email"),
        ]
        raw_emails = data.get("emails")

        if isinstance(raw_emails, list):
            for item in raw_emails:
                if isinstance(item, dict):
                    candidates.append(
                        item.get("address")
                        or item.get("email")
                        or item.get("value")
                    )
                else:
                    candidates.append(item)
        elif isinstance(raw_emails, dict):
            candidates.extend(
                [
                    raw_emails.get("address"),
                    raw_emails.get("email"),
                    raw_emails.get("value"),
                ]
            )
        else:
            candidates.append(raw_emails)

        return extraction_dedupe_strings(candidates)

    @staticmethod
    def _extract_work_email(data: Dict[str, Any]) -> Optional[str]:
        emails = data.get("emails")
        direct_work = extraction_clean_str(
            data.get("work_email")
            or data.get("email")
            or data.get("email_address")
            or data.get("business_email")
        )
        if not isinstance(emails, list) or not emails:
            return direct_work

        for email_obj in emails:
            if not isinstance(email_obj, dict):
                continue
            email_type = (email_obj.get("type") or "").lower()
            is_current = email_obj.get("current", False)
            address = extraction_clean_str(
                email_obj.get("address")
                or email_obj.get("email")
                or email_obj.get("value")
            )
            if address and email_type == "work" and is_current:
                return address

        for email_obj in emails:
            if isinstance(email_obj, dict) and email_obj.get("current"):
                address = extraction_clean_str(
                    email_obj.get("address")
                    or email_obj.get("email")
                    or email_obj.get("value")
                )
                if address:
                    return address

        first_email = emails[0]
        if isinstance(first_email, dict):
            first_address = extraction_clean_str(
                first_email.get("address")
                or first_email.get("email")
                or first_email.get("value")
            )
            if first_address:
                return first_address
        else:
            first_address = extraction_clean_str(first_email)
            if first_address:
                return first_address

        return direct_work

    @staticmethod
    def _extract_personal_emails(data: Dict[str, Any]) -> List[str]:
        personal = data.get("personal_emails")
        personal_emails: List[Any] = [data.get("personal_email")]
        if isinstance(personal, list):
            for item in personal:
                if isinstance(item, dict):
                    personal_emails.append(
                        item.get("address")
                        or item.get("email")
                        or item.get("value")
                    )
                else:
                    personal_emails.append(item)
        elif personal is not None:
            personal_emails.append(personal)

        extracted = extraction_dedupe_strings(personal_emails)
        if extracted:
            return extracted

        emails = data.get("emails")
        if not isinstance(emails, list):
            return []

        personal_from_emails: List[Any] = []
        for email_obj in emails:
            if not isinstance(email_obj, dict):
                continue
            email_type = (email_obj.get("type") or "").lower()
            address = email_obj.get("address") or email_obj.get("email") or email_obj.get("value")
            if email_type in {"personal", "private"}:
                personal_from_emails.append(address)
        return extraction_dedupe_strings(personal_from_emails)

    @staticmethod
    def _extract_phone_numbers(data: Dict[str, Any]) -> List[str]:
        candidates: List[Any] = [
            data.get("phone"),
            data.get("mobile_phone"),
            data.get("work_phone"),
            data.get("direct_phone"),
            data.get("cell_phone"),
        ]

        for key in ("phone_numbers", "phones", "personal_phones", "work_phones"):
            phone_numbers = data.get(key, [])
            if not isinstance(phone_numbers, list):
                continue
            for phone_obj in phone_numbers:
                if isinstance(phone_obj, dict):
                    candidates.append(
                        phone_obj.get("number")
                        or phone_obj.get("phone")
                        or phone_obj.get("raw_number")
                        or phone_obj.get("sanitized_number")
                        or phone_obj.get("international_number")
                        or phone_obj.get("value")
                    )
                else:
                    candidates.append(phone_obj)

        return extraction_dedupe_strings(candidates)

    @staticmethod
    def _extract_current_title(data: Dict[str, Any]) -> Optional[str]:
        direct_title = extraction_clean_str(
            data.get("job_title")
            or data.get("title")
            or data.get("current_title")
            or data.get("occupation")
        )
        if direct_title:
            return direct_title

        experience = data.get("experience", [])
        if not isinstance(experience, list) or not experience:
            return None

        for exp in experience:
            if not isinstance(exp, dict):
                continue
            is_current = bool(exp.get("current")) or not exp.get("end_date")
            if not is_current:
                continue
            title = exp.get("title", {})
            if isinstance(title, dict):
                title_name = extraction_clean_str(title.get("name") or title.get("value"))
                if title_name:
                    return title_name
            title_name = extraction_clean_str(title)
            if title_name:
                return title_name

        first = experience[0]
        if isinstance(first, dict):
            title = first.get("title", {})
            if isinstance(title, dict):
                title_name = extraction_clean_str(title.get("name") or title.get("value"))
                if title_name:
                    return title_name
            title_name = extraction_clean_str(title)
            if title_name:
                return title_name
        return None

    @staticmethod
    def _extract_name_parts(
        data: Dict[str, Any],
        *,
        fallback_first_name: Optional[str] = None,
        fallback_last_name: Optional[str] = None,
    ) -> Dict[str, Optional[str]]:
        first_name = extraction_clean_str(data.get("first_name") or fallback_first_name)
        last_name = extraction_clean_str(data.get("last_name") or fallback_last_name)
        if first_name and last_name:
            return {"first_name": first_name, "last_name": last_name}

        full_name = extraction_clean_str(data.get("full_name") or data.get("name"))
        if full_name and not first_name and not last_name:
            parts = full_name.split()
            if parts:
                first_name = parts[0]
                last_name = " ".join(parts[1:]) if len(parts) > 1 else None
        return {"first_name": first_name, "last_name": last_name}

    def _extract_contact_location(self, data: Dict[str, Any]) -> Optional[str]:
        location = self._format_location(data.get("location"))
        if location:
            return location

        location_name = extraction_clean_str(data.get("location_name"))
        if location_name:
            return location_name

        return extraction_join_location_parts(
            [
                data.get("city") or data.get("locality"),
                data.get("state") or data.get("region"),
                data.get("country"),
            ]
        )

    def _extract_company_info(
        self,
        data: Dict[str, Any],
        *,
        fallback_company_name: Optional[str] = None,
        fallback_domain: Optional[str] = None,
    ) -> Optional[Dict[str, Optional[str]]]:
        company_name = extraction_clean_str(fallback_company_name)
        company_domain = self._normalize_domain(fallback_domain)

        company_payload = (
            data.get("job_company")
            if isinstance(data.get("job_company"), dict)
            else data.get("company")
            if isinstance(data.get("company"), dict)
            else None
        )
        if company_payload:
            company_name = extraction_clean_str(
                company_payload.get("name")
                or company_payload.get("company_name")
                or company_payload.get("organization_name")
            ) or company_name
            company_domain = self._normalize_domain(
                company_payload.get("domain")
                or company_payload.get("website")
                or company_payload.get("primary_domain")
            ) or company_domain

        company_name_candidates = [
            data.get("job_company_name"),
            data.get("company_name"),
            data.get("work_company_name"),
            data.get("job_company"),
        ]
        for candidate in company_name_candidates:
            value = extraction_clean_str(candidate)
            if value:
                company_name = value
                break

        domain_candidates = [
            data.get("job_company_domain"),
            data.get("job_company_website"),
            data.get("work_company_domain"),
            data.get("company_website"),
            data.get("company_domain"),
        ]
        for candidate in domain_candidates:
            value = self._normalize_domain(candidate)
            if value:
                company_domain = value
                break

        experience = data.get("experience")
        if isinstance(experience, list):
            for exp in experience:
                if not isinstance(exp, dict):
                    continue
                if exp.get("current") is False:
                    continue
                company_obj = exp.get("company")
                if isinstance(company_obj, dict):
                    company_name = extraction_clean_str(
                        company_obj.get("name")
                        or company_obj.get("company_name")
                    ) or company_name
                    company_domain = self._normalize_domain(
                        company_obj.get("website")
                        or company_obj.get("domain")
                        or company_obj.get("primary_domain")
                    ) or company_domain
                elif isinstance(company_obj, str):
                    company_name = extraction_clean_str(company_obj) or company_name

                if company_name and company_domain:
                    break

        if not company_name and not company_domain:
            return None

        return {"name": company_name, "domain": company_domain}

    def _parse_company_response(
        self,
        inp: CompanyEnrichmentInput,
        response: Optional[Dict[str, Any]],
    ) -> CompanyEnrichmentResult:
        if not response or not isinstance(response, dict):
            return CompanyEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.NOT_FOUND,
                provider=self.name,
                domain=inp.domain,
            )

        if "error" in response:
            return CompanyEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.ERROR,
                provider=self.name,
                domain=inp.domain,
                error_message=str(response.get("error")),
            )

        status_code = response.get("status")
        data: Optional[Dict[str, Any]]
        if isinstance(response.get("data"), dict):
            data = response.get("data")
        elif isinstance(response.get("company"), dict):
            data = response.get("company")
        elif any(key in response for key in ("name", "domain", "website", "industry")):
            data = response
        else:
            data = None

        if data and (status_code in (200, 201) or status_code is None):
            extracted = self._extract_company_fields(
                data,
                fallback_domain=inp.domain,
                fallback_company_name=inp.company_name,
            )
            return CompanyEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.ENRICHED,
                provider=self.name,
                domain=extracted.get("domain") or inp.domain,
                company_name=extracted.get("company_name") or inp.company_name,
                industry=extracted.get("industry"),
                employee_count=extracted.get("employee_count"),
                employee_range=extracted.get("employee_range"),
                headquarters=extracted.get("headquarters"),
                country=extracted.get("country"),
                city=extracted.get("city"),
                state=extracted.get("state"),
                revenue_range=extracted.get("revenue_range"),
                founded_year=extracted.get("founded_year"),
                website=extracted.get("website"),
                linkedin_url=extracted.get("linkedin_url"),
                ticker=extracted.get("ticker"),
                ultimate_parent_domain=extracted.get("ultimate_parent_domain"),
                ultimate_parent_name=extracted.get("ultimate_parent_name"),
                raw=data,
            )

        if status_code == 404:
            return CompanyEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.NOT_FOUND,
                provider=self.name,
                domain=inp.domain,
                raw=response,
            )

        if status_code and status_code >= 400:
            return CompanyEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.ERROR,
                provider=self.name,
                domain=inp.domain,
                error_message=f"PDL error: {status_code}",
                raw=response,
            )

        return CompanyEnrichmentResult(
            row_index=inp.row_index,
            status=EnrichmentStatus.NOT_FOUND,
            provider=self.name,
            domain=inp.domain,
            raw=response,
        )

    def _parse_contact_response(
        self,
        inp: ContactEnrichmentInput,
        response: Optional[Dict[str, Any]],
    ) -> ContactEnrichmentResult:
        if not response or not isinstance(response, dict):
            return ContactEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.NOT_FOUND,
                provider=self.name,
                first_name=inp.firstname,
                last_name=inp.lastname,
                email=str(inp.email).strip() if inp.email else None,
                linkedin_url=inp.linkedin_url,
                company_name=inp.company_name,
                company_domain=inp.domain,
            )

        if "error" in response:
            return ContactEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.ERROR,
                provider=self.name,
                first_name=inp.firstname,
                last_name=inp.lastname,
                email=str(inp.email).strip() if inp.email else None,
                linkedin_url=inp.linkedin_url,
                company_name=inp.company_name,
                company_domain=inp.domain,
                error_message=str(response.get("error")),
                raw=response,
            )

        status_code = response.get("status")
        if isinstance(response.get("data"), dict):
            data = response.get("data")
        elif isinstance(response.get("person"), dict):
            data = response.get("person")
        elif any(key in response for key in ("first_name", "last_name", "emails", "email")):
            data = response
        else:
            data = None

        if data and (status_code in (200, 201) or status_code is None):
            emails = self._extract_email_list(data)
            work_email = self._extract_work_email(data)
            personal_emails = self._extract_personal_emails(data)
            phones = self._extract_phone_numbers(data)
            location = self._extract_contact_location(data)
            company_info = self._extract_company_info(
                data,
                fallback_company_name=None,
                fallback_domain=None,
            )
            linkedin_url = self._extract_linkedin_url(data)
            parsed_name = self._extract_name_parts(
                data,
                fallback_first_name=None,
                fallback_last_name=None,
            )

            resolved_work_email = work_email or (emails[0] if emails else None)
            resolved_company_domain = company_info.get("domain") if company_info else None
            if not resolved_company_domain and resolved_work_email:
                resolved_company_domain = self._normalize_domain(
                    fe_validation.extract_domain_from_email(resolved_work_email)
                )
            resolved_company_name = company_info.get("name") if company_info else None
            resolved_title = self._extract_current_title(data)
            has_provider_data = any(
                [
                    bool(resolved_work_email),
                    bool(emails),
                    bool(personal_emails),
                    bool(phones),
                    bool(linkedin_url),
                    bool(resolved_title),
                    bool(location),
                    bool(resolved_company_name),
                    bool(resolved_company_domain),
                ]
            )
            resolved_status = (
                EnrichmentStatus.ENRICHED
                if has_provider_data
                else EnrichmentStatus.NOT_FOUND
            )

            return ContactEnrichmentResult(
                row_index=inp.row_index,
                status=resolved_status,
                provider=self.name,
                first_name=parsed_name.get("first_name"),
                last_name=parsed_name.get("last_name"),
                email=resolved_work_email,
                emails=emails,
                personal_email=personal_emails[0] if personal_emails else None,
                personal_emails=personal_emails,
                phone=phones[0] if phones else None,
                phones=phones,
                linkedin_url=linkedin_url,
                title=resolved_title,
                location=location,
                company_name=resolved_company_name,
                company_domain=resolved_company_domain,
                raw=data,
            )

        if status_code == 404:
            return ContactEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.NOT_FOUND,
                provider=self.name,
                first_name=inp.firstname,
                last_name=inp.lastname,
                email=str(inp.email).strip() if inp.email else None,
                linkedin_url=inp.linkedin_url,
                company_name=inp.company_name,
                company_domain=inp.domain,
                raw=response,
            )

        if status_code and status_code >= 400:
            return ContactEnrichmentResult(
                row_index=inp.row_index,
                status=EnrichmentStatus.ERROR,
                provider=self.name,
                first_name=inp.firstname,
                last_name=inp.lastname,
                email=str(inp.email).strip() if inp.email else None,
                linkedin_url=inp.linkedin_url,
                company_name=inp.company_name,
                company_domain=inp.domain,
                error_message=f"PDL error: {status_code}",
                raw=response,
            )

        return ContactEnrichmentResult(
            row_index=inp.row_index,
            status=EnrichmentStatus.NOT_FOUND,
            provider=self.name,
            first_name=inp.firstname,
            last_name=inp.lastname,
            email=str(inp.email).strip() if inp.email else None,
            linkedin_url=inp.linkedin_url,
            company_name=inp.company_name,
            company_domain=inp.domain,
            raw=response,
        )

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        if not self.is_available:
            return [
                CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    domain=inp.domain,
                    error_message="PDL API key not configured",
                )
                for inp in inputs
            ]

        results: Dict[int, CompanyEnrichmentResult] = {}
        pending: List[CompanyEnrichmentInput] = []

        for inp in inputs:
            if not inp.is_valid:
                results[inp.row_index] = CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    error_message="Invalid input: domain required",
                )
                continue
            pending.append(inp)

        if not pending:
            return [results[inp.row_index] for inp in inputs]

        adapter = self._get_adapter()

        for batch in self._chunk_inputs(pending, self.BATCH_SIZE):
            requests: List[PDLCompanyEnrichmentRequest] = []
            for inp in batch:
                domain = self._normalize_domain(str(inp.domain).strip()) if inp.domain else None
                name = str(inp.company_name).strip() if inp.company_name else None
                requests.append(
                    PDLCompanyEnrichmentRequest(
                        name=name or None,
                        domain=domain or None,
                        pretty=True,
                    )
                )

            try:
                result = await asyncio.to_thread(adapter.bulk_company_enrichment, requests)
            except Exception as e:
                for inp in batch:
                    results[inp.row_index] = CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        domain=inp.domain,
                        error_message=str(e),
                    )
                continue

            if not isinstance(result, dict) or "error" in result:
                error_message = (
                    result.get("error")
                    if isinstance(result, dict)
                    else "PDL bulk enrichment failed"
                )
                for inp in batch:
                    results[inp.row_index] = CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        domain=inp.domain,
                        error_message=str(error_message),
                    )
                continue

            responses = result.get("responses", [])
            for idx, inp in enumerate(batch):
                response = responses[idx] if idx < len(responses) else None
                results[inp.row_index] = self._parse_company_response(inp, response)

        return [results[inp.row_index] for inp in inputs]

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        if not self.is_available:
            return [
                ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="PDL API key not configured",
                )
                for inp in inputs
            ]

        results: Dict[int, ContactEnrichmentResult] = {}
        pending: List[ContactEnrichmentInput] = []

        for inp in inputs:
            if not inp.is_valid:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Invalid input: LinkedIn URL or name + company required",
                )
                continue
            pending.append(inp)

        if not pending:
            return [results[inp.row_index] for inp in inputs]

        adapter = self._get_adapter()

        for batch in self._chunk_inputs(pending, self.BATCH_SIZE):
            requests: List[PDLPersonEnrichmentRequest] = []
            for inp in batch:
                company_name = str(inp.company_name).strip() if inp.company_name else None
                if not company_name and inp.domain:
                    company_name = str(inp.domain).strip()
                requests.append(
                    PDLPersonEnrichmentRequest(
                        first_name=str(inp.firstname).strip() if inp.firstname else None,
                        last_name=str(inp.lastname).strip() if inp.lastname else None,
                        email=str(inp.email).strip() if inp.email else None,
                        profile=str(inp.linkedin_url).strip() if inp.linkedin_url else None,
                        company=company_name or None,
                        pretty=True,
                    )
                )

            try:
                result = await asyncio.to_thread(adapter.bulk_person_enrichment, requests)
            except Exception as e:
                for inp in batch:
                    results[inp.row_index] = ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        first_name=inp.firstname,
                        last_name=inp.lastname,
                        email=str(inp.email).strip() if inp.email else None,
                        linkedin_url=inp.linkedin_url,
                        company_name=inp.company_name,
                        company_domain=inp.domain,
                        error_message=str(e),
                    )
                continue

            if not isinstance(result, dict) or "error" in result:
                error_message = (
                    result.get("error")
                    if isinstance(result, dict)
                    else "PDL bulk enrichment failed"
                )
                for inp in batch:
                    results[inp.row_index] = ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        first_name=inp.firstname,
                        last_name=inp.lastname,
                        email=str(inp.email).strip() if inp.email else None,
                        linkedin_url=inp.linkedin_url,
                        company_name=inp.company_name,
                        company_domain=inp.domain,
                        error_message=str(error_message),
                    )
                continue

            responses = result.get("responses", [])
            for idx, inp in enumerate(batch):
                response = responses[idx] if idx < len(responses) else None
                results[inp.row_index] = self._parse_contact_response(inp, response)

        return [results[inp.row_index] for inp in inputs]


class AmplemarketAdapter:
    """
    Amplemarket contact enrichment via BYO API key stored in WorkspaceSecret.

    Notes:
    - We currently use /people/find per-contact for predictable synchronous behavior.
      This keeps the adapter compatible with the existing EnrichmentService flow.
    - Amplemarket does not support the company enrichment fields used by this wizard.
    """
    CAPABILITIES = PROVIDER_CAPABILITIES["amplemarket"]

    def __init__(self, secret_cache: Optional["SecretCache"] = None):
        self.secret_cache = secret_cache

    @property
    def name(self) -> str:
        return "amplemarket"

    def _get_api_key(self) -> Optional[str]:
        if not self.secret_cache:
            return None
        value = self.secret_cache.get("amplemarket", "api_key")
        if not value:
            return None
        return str(value).strip() or None

    @property
    def is_available(self) -> bool:
        return bool(self._get_api_key())

    @property
    def capabilities(self) -> Dict[str, Any]:
        return deepcopy(self.CAPABILITIES)

    @staticmethod
    def _clean_str(value: Any) -> Optional[str]:
        return extraction_clean_str(value)

    @classmethod
    def _dedupe_strings(cls, values: List[Any]) -> List[str]:
        return extraction_dedupe_strings(values)

    @classmethod
    def _normalize_domain(cls, value: Any) -> Optional[str]:
        return extraction_normalize_domain(value)

    @classmethod
    def _extract_contact_dict(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
        data: Any = payload.get("data") if isinstance(payload.get("data"), dict) else payload
        if isinstance(data, dict) and isinstance(data.get("person"), dict):
            data = data.get("person")
        return data if isinstance(data, dict) else {}

    @classmethod
    def _extract_email_values(cls, data: Dict[str, Any]) -> List[str]:
        candidates: List[Any] = [
            data.get("email"),
            data.get("email_address"),
            data.get("emailAddress"),
            data.get("work_email"),
        ]
        for key in ("emails", "email_addresses", "emailAddresses", "work_emails"):
            raw_values = data.get(key)
            if not isinstance(raw_values, list):
                continue
            for value in raw_values:
                if isinstance(value, dict):
                    candidates.append(value.get("email") or value.get("address") or value.get("value"))
                else:
                    candidates.append(value)
        return cls._dedupe_strings(candidates)

    @classmethod
    def _extract_phone_values(cls, data: Dict[str, Any]) -> List[str]:
        candidates: List[Any] = [
            data.get("phone"),
            data.get("phone_number"),
            data.get("phoneNumber"),
            data.get("mobile_phone"),
        ]
        for key in ("phones", "phone_numbers", "phoneNumbers"):
            raw_values = data.get(key)
            if not isinstance(raw_values, list):
                continue
            for value in raw_values:
                if isinstance(value, dict):
                    candidates.append(
                        value.get("phone")
                        or value.get("number")
                        or value.get("raw_number")
                        or value.get("value")
                    )
                else:
                    candidates.append(value)
        return cls._dedupe_strings(candidates)

    @classmethod
    def _extract_company_fields(
        cls,
        data: Dict[str, Any],
        *,
        fallback_company_name: Optional[str] = None,
        fallback_domain: Optional[str] = None,
    ) -> Dict[str, Optional[str]]:
        company_payload = (
            data.get("company")
            or data.get("organization")
            or data.get("account")
            or data.get("company_name")
            or data.get("companyName")
        )
        company_name = cls._clean_str(fallback_company_name)
        company_domain = cls._normalize_domain(fallback_domain)

        if isinstance(company_payload, dict):
            company_name = (
                cls._clean_str(company_payload.get("name"))
                or cls._clean_str(company_payload.get("company_name"))
                or cls._clean_str(company_payload.get("companyName"))
                or company_name
            )
            company_domain = (
                cls._normalize_domain(
                    company_payload.get("primary_domain")
                    or company_payload.get("domain")
                    or company_payload.get("company_domain")
                    or company_payload.get("website")
                    or company_payload.get("website_url")
                )
                or company_domain
            )
        else:
            company_name = cls._clean_str(company_payload) or company_name

        company_domain = (
            cls._normalize_domain(
                data.get("company_domain")
                or data.get("domain")
                or data.get("website")
                or data.get("website_url")
            )
            or company_domain
        )
        return {"company_name": company_name, "company_domain": company_domain}

    @classmethod
    def _normalize_contact_payload(
        cls,
        data: Dict[str, Any],
        *,
        fallback_email: Optional[str] = None,
        fallback_linkedin_url: Optional[str] = None,
        fallback_first_name: Optional[str] = None,
        fallback_last_name: Optional[str] = None,
        fallback_company_name: Optional[str] = None,
        fallback_domain: Optional[str] = None,
    ) -> Dict[str, Any]:
        first_name = cls._clean_str(data.get("first_name") or data.get("firstName"))
        last_name = cls._clean_str(data.get("last_name") or data.get("lastName"))
        full_name = cls._clean_str(data.get("name") or data.get("full_name"))
        if full_name and not first_name and not last_name:
            parts = full_name.split()
            if parts:
                first_name = parts[0]
                last_name = " ".join(parts[1:]) if len(parts) > 1 else None

        emails = cls._extract_email_values(data)

        phones = cls._extract_phone_values(data)
        linkedin_url = cls._clean_str(data.get("linkedin_url") or data.get("linkedinUrl") or data.get("linkedin"))
        company_fields = cls._extract_company_fields(data)

        return {
            "first_name": first_name,
            "last_name": last_name,
            "title": cls._clean_str(data.get("title") or data.get("job_title")),
            "email": emails[0] if emails else None,
            "emails": emails,
            "phone": phones[0] if phones else None,
            "phones": phones,
            "linkedin_url": linkedin_url,
            "company_name": company_fields["company_name"],
            "company_domain": company_fields["company_domain"],
        }

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        # Company enrichment isn't supported by this adapter (wizard expects firmographics).
        results: List[CompanyEnrichmentResult] = []
        for inp in inputs:
            results.append(
                CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    domain=inp.domain,
                    company_name=inp.company_name,
                    error_message="Amplemarket adapter does not support company enrichment",
                )
            )
        return results

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        api_key = self._get_api_key()
        if not api_key:
            return [
                ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Amplemarket API key not configured",
                )
                for inp in inputs
            ]

        from g_gremlin.amplemarket import (
            AmplemarketAuthError,
            AmplemarketClient,
            AmplemarketError,
        )

        def _sync_enrich() -> List[ContactEnrichmentResult]:
            client: Optional[AmplemarketClient] = None
            results: List[ContactEnrichmentResult] = []
            try:
                client = AmplemarketClient(api_key=api_key)
                for inp in inputs:
                    params: Dict[str, Any] = {}
                    email = str(inp.email).strip() if inp.email else ""
                    linkedin_url = str(inp.linkedin_url).strip() if inp.linkedin_url else ""
                    if email:
                        params["email"] = email
                    if linkedin_url:
                        params["linkedin_url"] = linkedin_url

                    # Best-effort hints. (API accepts additional fields; ignored if unsupported.)
                    if inp.firstname:
                        params["first_name"] = str(inp.firstname).strip()
                    if inp.lastname:
                        params["last_name"] = str(inp.lastname).strip()
                    if inp.company_name:
                        params["company_name"] = str(inp.company_name).strip()
                    if inp.domain:
                        params["domain"] = str(inp.domain).strip()

                    try:
                        payload = client.find_person(**params)
                    except AmplemarketAuthError as exc:
                        raise exc
                    except AmplemarketError as exc:
                        results.append(
                            ContactEnrichmentResult(
                                row_index=inp.row_index,
                                status=EnrichmentStatus.ERROR,
                                provider=self.name,
                                first_name=inp.firstname,
                                last_name=inp.lastname,
                                email=email or None,
                                linkedin_url=linkedin_url or None,
                                company_name=inp.company_name,
                                company_domain=inp.domain,
                                error_message=str(exc),
                            )
                        )
                        continue
                    except Exception as exc:  # noqa: BLE001
                        results.append(
                            ContactEnrichmentResult(
                                row_index=inp.row_index,
                                status=EnrichmentStatus.ERROR,
                                provider=self.name,
                                first_name=inp.firstname,
                                last_name=inp.lastname,
                                email=email or None,
                                linkedin_url=linkedin_url or None,
                                company_name=inp.company_name,
                                company_domain=inp.domain,
                                error_message=str(exc),
                            )
                        )
                        continue

                    if not payload:
                        results.append(
                            ContactEnrichmentResult(
                                row_index=inp.row_index,
                                status=EnrichmentStatus.NOT_FOUND,
                                provider=self.name,
                                first_name=inp.firstname,
                                last_name=inp.lastname,
                                email=email or None,
                                linkedin_url=linkedin_url or None,
                                company_name=inp.company_name,
                                company_domain=inp.domain,
                            )
                        )
                        continue

                    data = self._extract_contact_dict(payload)
                    normalized = self._normalize_contact_payload(
                        data,
                        fallback_email=email or None,
                        fallback_linkedin_url=linkedin_url or None,
                        fallback_first_name=inp.firstname,
                        fallback_last_name=inp.lastname,
                        fallback_company_name=inp.company_name,
                        fallback_domain=inp.domain,
                    )
                    has_provider_data = any(
                        [
                            bool(normalized.get("email")),
                            bool(normalized.get("emails")),
                            bool(normalized.get("phone")),
                            bool(normalized.get("phones")),
                            bool(normalized.get("title")),
                            bool(normalized.get("linkedin_url")),
                            bool(normalized.get("company_name")),
                            bool(normalized.get("company_domain")),
                        ]
                    )
                    resolved_status = (
                        EnrichmentStatus.ENRICHED
                        if has_provider_data
                        else EnrichmentStatus.NOT_FOUND
                    )

                    results.append(
                        ContactEnrichmentResult(
                            row_index=inp.row_index,
                            status=resolved_status,
                            provider=self.name,
                            first_name=normalized.get("first_name"),
                            last_name=normalized.get("last_name"),
                            email=normalized.get("email"),
                            emails=normalized.get("emails") or [],
                            phone=normalized.get("phone"),
                            phones=normalized.get("phones") or [],
                            linkedin_url=normalized.get("linkedin_url"),
                            title=normalized.get("title"),
                            company_name=normalized.get("company_name"),
                            company_domain=normalized.get("company_domain"),
                            raw=data,
                        )
                    )

                return results
            finally:
                try:
                    if client is not None and getattr(client, "_session", None) is not None:
                        client._session.close()
                except Exception:
                    pass

        try:
            return await asyncio.to_thread(_sync_enrich)
        except AmplemarketAuthError:
            return [
                ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.ERROR,
                    provider=self.name,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Amplemarket authentication failed",
                )
                for inp in inputs
            ]


class ApolloAdapter:
    """Apollo enrichment via OAuth tokens stored in WorkspaceSecret."""

    API_BASE = "https://api.apollo.io/api/v1"
    ORG_ENRICH = f"{API_BASE}/organizations/enrich"
    ORG_BULK = f"{API_BASE}/organizations/bulk_enrich"
    PEOPLE_MATCH = f"{API_BASE}/people/match"
    PEOPLE_BULK = f"{API_BASE}/people/bulk_match"
    CAPABILITIES = PROVIDER_CAPABILITIES["apollo"]

    def __init__(self, db: "AsyncSession", account_id: str):
        self._db = db
        self._account_id = str(account_id)
        self._client: Optional[httpx.AsyncClient] = None

    @property
    def name(self) -> str:
        return "apollo"

    @property
    def is_available(self) -> bool:
        return True

    @property
    def capabilities(self) -> Dict[str, Any]:
        return deepcopy(self.CAPABILITIES)

    async def _get_client(self) -> httpx.AsyncClient:
        if self._client is None:
            self._client = httpx.AsyncClient(timeout=30.0)
        return self._client

    async def close(self) -> None:
        if self._client:
            await self._client.aclose()
            self._client = None

    async def _ensure_valid_token(self) -> str:
        integration = await get_apollo_integration(self._db, self._account_id)
        if not integration or integration.status != IntegrationStatus.CONNECTED:
            raise RuntimeError("Apollo not connected")

        config = integration.config or {}
        expires_at = None
        expires_raw = config.get("expires_at")
        if expires_raw:
            try:
                parsed = datetime.fromisoformat(str(expires_raw))
                expires_at = (
                    parsed.astimezone(timezone.utc)
                    if parsed.tzinfo
                    else parsed.replace(tzinfo=timezone.utc)
                )
            except Exception:
                expires_at = None

        now = datetime.now(timezone.utc)
        if not expires_at or expires_at < now + timedelta(seconds=60):
            refreshed = await refresh_apollo_tokens(self._db, self._account_id)
            if not refreshed:
                raise RuntimeError("Apollo token refresh failed")

        secret_backend = get_saas_secret_backend()
        token = await secret_backend.get_secret(
            db=self._db,
            integration="apollo",
            key="access_token",
            workspace_id=self._account_id,
        )
        if not token:
            raise RuntimeError("Apollo access token missing")
        return token.value

    @staticmethod
    def _clean_str(value: Any) -> Optional[str]:
        return extraction_clean_str(value)

    @staticmethod
    def _coerce_int(value: Any) -> Optional[int]:
        return extraction_coerce_int(value)

    @classmethod
    def _dedupe_strings(cls, values: List[Any]) -> List[str]:
        return extraction_dedupe_strings(values)

    @classmethod
    def _normalize_domain(cls, value: Any) -> Optional[str]:
        return extraction_normalize_domain(value)

    @classmethod
    def _join_location_parts(cls, parts: List[Any]) -> Optional[str]:
        return extraction_join_location_parts(parts)

    @classmethod
    def _extract_company_payload(
        cls,
        org_raw: Dict[str, Any],
        *,
        fallback_domain: Optional[str] = None,
        fallback_name: Optional[str] = None,
    ) -> Dict[str, Any]:
        org = org_raw if isinstance(org_raw, dict) else {}
        if isinstance(org.get("organization"), dict):
            org = org.get("organization")
        elif isinstance(org.get("company"), dict):
            org = org.get("company")

        hq = org.get("headquarters") if isinstance(org.get("headquarters"), dict) else {}
        address = org.get("address") if isinstance(org.get("address"), dict) else {}
        location = org.get("location") if isinstance(org.get("location"), dict) else {}
        parent = (
            org.get("ultimate_parent")
            if isinstance(org.get("ultimate_parent"), dict)
            else org.get("parent_organization")
            if isinstance(org.get("parent_organization"), dict)
            else {}
        )
        social_links = org.get("social_links") if isinstance(org.get("social_links"), dict) else {}

        city = cls._clean_str(
            org.get("city")
            or org.get("headquarters_city")
            or hq.get("city")
            or hq.get("locality")
            or address.get("city")
            or location.get("city")
        )
        state = cls._clean_str(
            org.get("state")
            or org.get("region")
            or hq.get("state")
            or hq.get("region")
            or address.get("state")
            or location.get("state")
        )
        country = cls._clean_str(
            org.get("country")
            or org.get("country_name")
            or hq.get("country")
            or address.get("country")
            or location.get("country")
        )
        headquarters = (
            cls._clean_str(org.get("headquarters") or org.get("headquarters_location"))
            or cls._join_location_parts([city, state, country])
        )

        return {
            "domain": cls._normalize_domain(
                fallback_domain
                or org.get("primary_domain")
                or org.get("domain")
                or org.get("website_url")
                or org.get("website")
            ),
            "company_name": cls._clean_str(
                org.get("name")
                or org.get("organization_name")
                or org.get("company_name")
                or fallback_name
            ),
            "industry": cls._clean_str(org.get("industry") or org.get("industry_name")),
            "employee_count": cls._coerce_int(
                org.get("estimated_num_employees")
                or org.get("employee_count")
                or org.get("employees")
            ),
            "employee_range": cls._clean_str(org.get("employee_range") or org.get("employees_range")),
            "headquarters": headquarters,
            "city": city,
            "state": state,
            "country": country,
            "revenue_range": cls._clean_str(
                org.get("revenue_range")
                or org.get("annual_revenue_printed")
                or org.get("estimated_annual_revenue_printed")
                or org.get("estimated_annual_revenue")
            ),
            "founded_year": cls._coerce_int(
                org.get("founded_year")
                or org.get("founded")
                or org.get("year_founded")
            ),
            "website": cls._clean_str(org.get("website_url") or org.get("website")),
            "linkedin_url": cls._clean_str(
                org.get("linkedin_url")
                or org.get("linkedin")
                or social_links.get("linkedin")
                or social_links.get("linkedin_url")
            ),
            "ticker": cls._clean_str(org.get("ticker") or org.get("symbol")),
            "ultimate_parent_name": cls._clean_str(
                org.get("ultimate_parent_name")
                or parent.get("name")
                or parent.get("organization_name")
            ),
            "ultimate_parent_domain": cls._normalize_domain(
                org.get("ultimate_parent_domain")
                or parent.get("primary_domain")
                or parent.get("domain")
                or parent.get("website_url")
            ),
            "raw": org if isinstance(org, dict) else {},
        }

    @classmethod
    def _extract_contact_payload(
        cls,
        person_raw: Dict[str, Any],
        *,
        fallback_input: ContactEnrichmentInput,
        fallback_domain: Optional[str] = None,
    ) -> Dict[str, Any]:
        person = person_raw if isinstance(person_raw, dict) else {}
        if isinstance(person.get("person"), dict):
            person = person.get("person")

        org = person.get("organization") if isinstance(person.get("organization"), dict) else {}
        if isinstance(org.get("organization"), dict):
            org = org.get("organization")

        emails: List[Any] = [
            person.get("email"),
            person.get("work_email"),
            person.get("email_address"),
            person.get("emailAddress"),
        ]
        for key in ("emails", "work_emails", "email_addresses", "emailAddresses"):
            raw_values = person.get(key)
            if not isinstance(raw_values, list):
                continue
            for value in raw_values:
                if isinstance(value, dict):
                    emails.append(value.get("email") or value.get("address") or value.get("value"))
                else:
                    emails.append(value)
        normalized_emails = cls._dedupe_strings(emails)

        phones: List[Any] = [
            person.get("phone_number"),
            person.get("sanitized_phone"),
            person.get("mobile_phone"),
            person.get("phone"),
        ]
        for key in ("phone_numbers", "phoneNumbers", "phones"):
            raw_values = person.get(key)
            if not isinstance(raw_values, list):
                continue
            for value in raw_values:
                if isinstance(value, dict):
                    phones.append(
                        value.get("phone")
                        or value.get("number")
                        or value.get("raw_number")
                        or value.get("value")
                    )
                else:
                    phones.append(value)
        normalized_phones = cls._dedupe_strings(phones)

        first_name = cls._clean_str(person.get("first_name") or person.get("firstName"))
        last_name = cls._clean_str(person.get("last_name") or person.get("lastName"))
        full_name = cls._clean_str(person.get("name"))
        if full_name and not first_name and not last_name:
            parts = full_name.split()
            if parts:
                first_name = parts[0]
                last_name = " ".join(parts[1:]) if len(parts) > 1 else None

        location_raw = person.get("location")
        if isinstance(location_raw, dict):
            location = cls._join_location_parts(
                [
                    location_raw.get("city") or location_raw.get("locality"),
                    location_raw.get("state") or location_raw.get("region"),
                    location_raw.get("country"),
                ]
            )
        else:
            location = cls._clean_str(location_raw)
        if not location:
            location = cls._join_location_parts([person.get("city"), person.get("state"), person.get("country")])

        social_links = person.get("social_links") if isinstance(person.get("social_links"), dict) else {}
        linkedin_url = cls._clean_str(
            person.get("linkedin_url")
            or person.get("linkedin")
            or person.get("linkedinUrl")
            or social_links.get("linkedin")
            or social_links.get("linkedin_url")
        )

        company_name = cls._clean_str(
            org.get("name")
            or org.get("organization_name")
            or person.get("organization_name")
            or person.get("company_name")
        )
        company_domain = cls._normalize_domain(
            org.get("primary_domain")
            or org.get("domain")
            or org.get("website_url")
            or person.get("domain")
            or person.get("company_domain")
        )

        return {
            "first_name": first_name,
            "last_name": last_name,
            "email": normalized_emails[0] if normalized_emails else None,
            "emails": normalized_emails,
            "phone": normalized_phones[0] if normalized_phones else None,
            "phones": normalized_phones,
            "linkedin_url": linkedin_url,
            "title": cls._clean_str(person.get("title") or person.get("job_title")),
            "location": location,
            "company_name": company_name,
            "company_domain": company_domain,
            "raw": person,
        }

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        token = await self._ensure_valid_token()
        results: List[CompanyEnrichmentResult] = []
        client = await self._get_client()

        for inp in inputs:
            if not inp.is_valid:
                results.append(
                    CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.SKIPPED,
                        error_message="Invalid input: domain required",
                    )
                )
                continue

            try:
                response = await client.get(
                    self.ORG_ENRICH,
                    headers={"Authorization": f"Bearer {token}"},
                    params={"domain": inp.domain},
                )

                if response.status_code == 401:
                    await refresh_apollo_tokens(self._db, self._account_id)
                    token = await self._ensure_valid_token()
                    response = await client.get(
                        self.ORG_ENRICH,
                        headers={"Authorization": f"Bearer {token}"},
                        params={"domain": inp.domain},
                    )

                if response.status_code == 404:
                    results.append(
                        CompanyEnrichmentResult(
                            row_index=inp.row_index,
                            status=EnrichmentStatus.NOT_FOUND,
                            provider=self.name,
                            domain=inp.domain,
                        )
                    )
                    continue

                response.raise_for_status()
                data = response.json() or {}
                org = (
                    data.get("organization")
                    or data.get("company")
                    or data.get("data")
                    or {}
                )
                normalized = self._extract_company_payload(
                    org if isinstance(org, dict) else {},
                    fallback_domain=inp.domain,
                    fallback_name=inp.company_name,
                )

                results.append(
                    CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ENRICHED,
                        provider=self.name,
                        domain=normalized.get("domain"),
                        company_name=normalized.get("company_name"),
                        industry=normalized.get("industry"),
                        employee_count=normalized.get("employee_count"),
                        employee_range=normalized.get("employee_range"),
                        headquarters=normalized.get("headquarters"),
                        country=normalized.get("country"),
                        city=normalized.get("city"),
                        state=normalized.get("state"),
                        revenue_range=normalized.get("revenue_range"),
                        founded_year=normalized.get("founded_year"),
                        website=normalized.get("website"),
                        linkedin_url=normalized.get("linkedin_url"),
                        ticker=normalized.get("ticker"),
                        ultimate_parent_domain=normalized.get("ultimate_parent_domain"),
                        ultimate_parent_name=normalized.get("ultimate_parent_name"),
                        raw=normalized.get("raw") or {},
                    )
                )

            except httpx.HTTPStatusError as e:
                logger.warning("Apollo HTTP error for %s: %s", inp.domain, e)
                results.append(
                    CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        domain=inp.domain,
                        error_message=f"HTTP error: {e.response.status_code}",
                    )
                )
            except Exception as e:
                logger.error("Apollo error for %s: %s", inp.domain, e)
                results.append(
                    CompanyEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        domain=inp.domain,
                        error_message=str(e),
                    )
                )

        return results

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        token = await self._ensure_valid_token()
        results: List[ContactEnrichmentResult] = []
        client = await self._get_client()

        for inp in inputs:
            if not inp.is_valid:
                results.append(
                    ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.SKIPPED,
                        first_name=inp.firstname,
                        last_name=inp.lastname,
                        email=str(inp.email).strip() if inp.email else None,
                        linkedin_url=inp.linkedin_url,
                        company_name=inp.company_name,
                        company_domain=inp.domain,
                        error_message="Invalid input: LinkedIn URL or name + company required",
                    )
                )
                continue

            domain = inp.domain
            if not domain and inp.email:
                domain = fe_validation.extract_domain_from_email(inp.email)

            payload: Dict[str, Any] = {}
            if inp.email:
                payload["email"] = inp.email
            if inp.firstname:
                payload["first_name"] = inp.firstname
            if inp.lastname:
                payload["last_name"] = inp.lastname
            if inp.company_name:
                payload["organization_name"] = inp.company_name
            if domain:
                payload["domain"] = domain
            if inp.linkedin_url:
                payload["linkedin_url"] = inp.linkedin_url

            try:
                response = await client.post(
                    self.PEOPLE_MATCH,
                    headers={"Authorization": f"Bearer {token}"},
                    json=payload,
                )

                if response.status_code == 401:
                    await refresh_apollo_tokens(self._db, self._account_id)
                    token = await self._ensure_valid_token()
                    response = await client.post(
                        self.PEOPLE_MATCH,
                        headers={"Authorization": f"Bearer {token}"},
                        json=payload,
                    )

                if response.status_code == 404:
                    results.append(
                        ContactEnrichmentResult(
                            row_index=inp.row_index,
                            status=EnrichmentStatus.NOT_FOUND,
                            provider=self.name,
                        )
                    )
                    continue

                response.raise_for_status()
                data = response.json() or {}
                person = data.get("person") or data.get("data") or data
                if not isinstance(person, dict) or not person:
                    results.append(
                        ContactEnrichmentResult(
                            row_index=inp.row_index,
                            status=EnrichmentStatus.NOT_FOUND,
                            provider=self.name,
                        )
                    )
                    continue

                normalized = self._extract_contact_payload(
                    person,
                    fallback_input=inp,
                    fallback_domain=domain,
                )
                has_provider_data = any(
                    [
                        bool(normalized.get("email")),
                        bool(normalized.get("emails")),
                        bool(normalized.get("phone")),
                        bool(normalized.get("phones")),
                        bool(normalized.get("title")),
                        bool(normalized.get("linkedin_url")),
                        bool(normalized.get("location")),
                        bool(normalized.get("company_name")),
                        bool(normalized.get("company_domain")),
                    ]
                )
                resolved_status = (
                    EnrichmentStatus.ENRICHED
                    if has_provider_data
                    else EnrichmentStatus.NOT_FOUND
                )

                results.append(
                    ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=resolved_status,
                        provider=self.name,
                        first_name=normalized.get("first_name"),
                        last_name=normalized.get("last_name"),
                        email=normalized.get("email"),
                        emails=normalized.get("emails") or [],
                        phone=normalized.get("phone"),
                        phones=normalized.get("phones") or [],
                        linkedin_url=normalized.get("linkedin_url"),
                        title=normalized.get("title"),
                        location=normalized.get("location"),
                        company_name=normalized.get("company_name"),
                        company_domain=normalized.get("company_domain"),
                        raw=normalized.get("raw") or person,
                    )
                )

            except httpx.HTTPStatusError as e:
                logger.warning("Apollo contact HTTP error: %s", e)
                results.append(
                    ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        error_message=f"HTTP error: {e.response.status_code}",
                    )
                )
            except Exception as e:
                logger.error("Apollo contact error: %s", e)
                results.append(
                    ContactEnrichmentResult(
                        row_index=inp.row_index,
                        status=EnrichmentStatus.ERROR,
                        provider=self.name,
                        error_message=str(e),
                    )
                )

        return results


class CascadingEnrichmentProvider:
    """
    Cascading enrichment provider that tries multiple providers in order.

    By default: FoundryGraph (free, fast) -> FullEnrich (BYO, comprehensive)
    """

    def __init__(
        self,
        providers: Optional[List[str]] = None,
        secret_cache: Optional["SecretCache"] = None,
        db: Optional["AsyncSession"] = None,
        account_id: Optional[str] = None,
    ):
        """
        Initialize cascading provider.

        Args:
            providers: List of provider names in priority order.
                      Default: ["foundrygraph", "fullenrich"]
            secret_cache: Secret cache for BYO provider credentials
        """
        self.provider_names = providers or ["foundrygraph", "fullenrich"]
        self.secret_cache = secret_cache
        self._db = db
        self._account_id = account_id
        self._adapters: Dict[str, Any] = {}

    @property
    def name(self) -> str:
        return "cascading"

    def _get_adapter(self, provider: str) -> Optional[Any]:
        """Get or create adapter for provider."""
        if provider in self._adapters:
            return self._adapters[provider]

        if provider == "foundrygraph":
            adapter = FoundryGraphAdapter()
            self._adapters[provider] = adapter
            return adapter
        elif provider == "fullenrich":
            adapter = FullEnrichAdapter(secret_cache=self.secret_cache)
            if adapter.is_available:
                self._adapters[provider] = adapter
                return adapter
            return None
        elif provider == "pdl":
            adapter = PDLAdapter(secret_cache=self.secret_cache)
            if adapter.is_available:
                self._adapters[provider] = adapter
                return adapter
            return None
        elif provider == "amplemarket":
            adapter = AmplemarketAdapter(secret_cache=self.secret_cache)
            if adapter.is_available:
                self._adapters[provider] = adapter
                return adapter
            return None
        elif provider == "apollo":
            if not self._db or not self._account_id:
                raise RuntimeError("Apollo adapter requires db and account_id")
            adapter = ApolloAdapter(db=self._db, account_id=self._account_id)
            self._adapters[provider] = adapter
            return adapter

        return None

    @staticmethod
    def _status_code_from_exception(exc: Exception) -> Optional[int]:
        response = getattr(exc, "response", None)
        status = getattr(response, "status_code", None)
        return int(status) if isinstance(status, int) else None

    @classmethod
    def _is_auth_exception(cls, exc: Exception) -> bool:
        status = cls._status_code_from_exception(exc)
        if status in {401, 403}:
            return True
        text = str(exc).lower()
        return (
            "unauthorized" in text
            or "forbidden" in text
            or "authentication failed" in text
            or "invalid api key" in text
        )

    @classmethod
    def _is_schema_exception(cls, exc: Exception) -> bool:
        if isinstance(exc, (ValueError, TypeError, KeyError)):
            return True
        return exc.__class__.__name__ == "ValidationError"

    @classmethod
    def _is_transient_exception(cls, exc: Exception) -> bool:
        status = cls._status_code_from_exception(exc)
        if status in {408, 429, 500, 502, 503, 504}:
            return True
        return isinstance(exc, (asyncio.TimeoutError, TimeoutError, httpx.TimeoutException))

    @classmethod
    def _result_is_auth_failure(cls, error_message: Optional[str]) -> bool:
        text = str(error_message or "").lower()
        return (
            "401" in text
            or "403" in text
            or "unauthorized" in text
            or "forbidden" in text
            or "authentication failed" in text
        )

    @classmethod
    def _result_is_schema_failure(cls, error_message: Optional[str]) -> bool:
        text = str(error_message or "").lower()
        return any(
            token in text
            for token in (
                "schema",
                "validation",
                "malformed",
                "invalid request",
                "required field",
            )
        )

    @property
    def available_providers(self) -> List[str]:
        """Get list of available providers."""
        available = []
        for name in self.provider_names:
            adapter = self._get_adapter(name)
            if adapter and adapter.is_available:
                available.append(name)
        return available

    async def close(self) -> None:
        """Close all adapters."""
        for adapter in self._adapters.values():
            if hasattr(adapter, "close"):
                await adapter.close()
        self._adapters.clear()

    async def enrich_company(
        self,
        inputs: List[CompanyEnrichmentInput],
    ) -> List[CompanyEnrichmentResult]:
        """
        Enrich companies using cascading providers.

        For each input, tries providers in order until one succeeds.

        Args:
            inputs: List of company enrichment inputs

        Returns:
            List of enrichment results
        """
        # Initialize results with pending status
        results: Dict[int, CompanyEnrichmentResult] = {}
        pending_indices: set = {inp.row_index for inp in inputs if inp.is_valid}

        # Mark invalid inputs as skipped
        for inp in inputs:
            if not inp.is_valid:
                results[inp.row_index] = CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    error_message="Invalid input: domain required",
                )

        # Try each provider in order
        for provider_name in self.provider_names:
            if not pending_indices:
                break

            adapter = self._get_adapter(provider_name)
            if not adapter or not adapter.is_available:
                continue

            # Get inputs that still need enrichment
            pending_inputs = [
                inp for inp in inputs
                if inp.row_index in pending_indices
            ]

            if not pending_inputs:
                break

            # Try to enrich
            try:
                provider_results = await adapter.enrich_company(pending_inputs)

                for result in provider_results:
                    # Only use result if enriched successfully
                    if result.status in {EnrichmentStatus.ENRICHED, EnrichmentStatus.CACHE_HIT}:
                        results[result.row_index] = result
                        pending_indices.discard(result.row_index)
                    elif result.status == EnrichmentStatus.NOT_FOUND:
                        # Mark as not found but continue to next provider
                        if result.row_index not in results:
                            results[result.row_index] = result
                    elif result.status == EnrichmentStatus.ERROR:
                        if self._result_is_auth_failure(result.error_message):
                            raise RuntimeError(
                                f"{provider_name} authentication failed: {result.error_message or 'unauthorized'}"
                            )
                        if self._result_is_schema_failure(result.error_message):
                            raise ValueError(
                                f"{provider_name} request invalid: {result.error_message or 'schema validation failed'}"
                            )

            except Exception as e:
                if self._is_auth_exception(e) or self._is_schema_exception(e):
                    raise
                if self._is_transient_exception(e):
                    logger.warning(
                        "Transient enrichment error in provider %s (falling through): %s",
                        provider_name,
                        e,
                    )
                else:
                    logger.error(f"Error in provider {provider_name}: {e}")
                # Continue to next provider for transient/unknown provider errors

        # Mark remaining pending inputs as not found
        for inp in inputs:
            if inp.row_index not in results:
                results[inp.row_index] = CompanyEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.NOT_FOUND,
                    domain=inp.domain,
                )

        # Return results in original order
        return [results[inp.row_index] for inp in inputs]

    async def enrich_contact(
        self,
        inputs: List[ContactEnrichmentInput],
    ) -> List[ContactEnrichmentResult]:
        """
        Enrich contacts using available providers.
        """
        results: Dict[int, ContactEnrichmentResult] = {}
        pending_indices: set = {inp.row_index for inp in inputs if inp.is_valid}

        # Mark invalid inputs as skipped
        for inp in inputs:
            if not inp.is_valid:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.SKIPPED,
                    first_name=inp.firstname,
                    last_name=inp.lastname,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                    company_name=inp.company_name,
                    company_domain=inp.domain,
                    error_message="Invalid input: LinkedIn URL or name + company required",
                )

        for provider_name in self.provider_names:
            if provider_name not in {"fullenrich", "apollo", "pdl", "amplemarket"}:
                continue
            if not pending_indices:
                break

            adapter = self._get_adapter(provider_name)
            pending_inputs = [
                inp for inp in inputs
                if inp.row_index in pending_indices
            ]
            if not pending_inputs:
                break
            if not adapter or not adapter.is_available:
                continue

            try:
                provider_results = await adapter.enrich_contact(pending_inputs)
                for result in provider_results:
                    if result.status in {EnrichmentStatus.ENRICHED, EnrichmentStatus.CACHE_HIT}:
                        results[result.row_index] = result
                        pending_indices.discard(result.row_index)
                    elif result.status == EnrichmentStatus.NOT_FOUND:
                        if result.row_index not in results:
                            results[result.row_index] = result
                    elif result.status == EnrichmentStatus.ERROR:
                        if self._result_is_auth_failure(result.error_message):
                            raise RuntimeError(
                                f"{provider_name} authentication failed: {result.error_message or 'unauthorized'}"
                            )
                        if self._result_is_schema_failure(result.error_message):
                            raise ValueError(
                                f"{provider_name} request invalid: {result.error_message or 'schema validation failed'}"
                            )
                    else:
                        results[result.row_index] = result
                        pending_indices.discard(result.row_index)
            except Exception as e:
                if self._is_auth_exception(e) or self._is_schema_exception(e):
                    raise
                if self._is_transient_exception(e):
                    logger.warning(
                        "Transient enrichment error in provider %s (falling through): %s",
                        provider_name,
                        e,
                    )
                else:
                    logger.error(f"Error in provider {provider_name}: {e}")

        for inp in inputs:
            if inp.row_index not in results:
                results[inp.row_index] = ContactEnrichmentResult(
                    row_index=inp.row_index,
                    status=EnrichmentStatus.NOT_FOUND,
                    email=str(inp.email).strip() if inp.email else None,
                    linkedin_url=inp.linkedin_url,
                )

        return [results[inp.row_index] for inp in inputs]

    async def __aenter__(self) -> "CascadingEnrichmentProvider":
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        await self.close()
