"""
Enrichment Service for BYO Provider Locker.

Orchestrates enrichment job creation, validation, and status tracking.
Handles:
- Input validation (check for valid domains)
- Credit estimation
- Job creation with idempotency (run_id)
- Job status polling
- Provider availability checking
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import os
import secrets
import hashlib
from typing import Any, Dict, List, Optional
from uuid import UUID
from urllib.parse import urlencode

import logging
from sqlalchemy import select

logger = logging.getLogger(__name__)
from sqlalchemy.ext.asyncio import AsyncSession

from ..models import (
    BYOEnrichmentJob,
    EnrichmentJobStatus,
    EnrichmentJobType,
)
from .enrichment_adapters import (
    CascadingEnrichmentProvider,
    CompanyEnrichmentInput,
    ContactEnrichmentInput,
    EnrichmentStatus,
    FullEnrichAdapter,
    get_provider_capabilities,
    get_unsupported_output_fields,
)
from fmatch.core.fullenrich import JOB_FAILURE_STATUSES, JOB_PENDING_STATUSES, map_output_fields_to_enrich_fields
from fmatch.core.fullenrich import constants as fe_constants
from fmatch.core.fullenrich import validation as fe_validation
from .secret_cache import SecretCacheContext
from .email_notifications import send_enrichment_complete_notification
from .secret_storage import get_saas_secret_backend, BYO_SUPPORTED_INTEGRATIONS
from .sheets_writer import SheetsWriter
from .. import settings
from ..api.progress import get_redis

ROW_CONTRACT_STATUS_VALUES = {"resolved", "partial", "unresolved"}
ROW_CONTRACT_REASON_CODES = {
    "provider_lacks_field",
    "provider_no_match",
    "provider_timeout",
    "provider_rate_limited",
    "provider_auth_error",
    "provider_server_error",
    "filtered_by_minimum_provider",
    "input_missing_required",
    "no_connected_provider",
}
RUN_POLICY_BEST_EFFORT = "best_effort"
RUN_POLICY_MINIMUM_PROVIDER = "minimum_provider"
BILLABLE_CONTACT_FIELDS = (
    "work_email",
    "work_emails",
    "phone",
    "phones",
    "linkedin_url",
    "title",
)


def _build_credit_estimate_payload(
    estimated_credits: int,
    estimated_valid_rows: Optional[int] = None,
) -> Dict[str, Any]:
    credits = max(0, int(estimated_credits or 0))
    valid_rows = credits if estimated_valid_rows is None else max(0, int(estimated_valid_rows or 0))
    return {
        "estimated_credits": credits,
        "estimated_valid_rows": valid_rows,
        "credit_per_valid_row": 1,
        "formula": "valid_rows * 1",
    }


@dataclass
class ValidationResult:
    """Result of input validation."""
    rows_with_valid_inputs: int
    rows_skipped: int
    estimated_credits: int
    providers_available: List[Dict[str, Any]]
    byo_provider_connected: bool
    estimated_valid_rows: Optional[int] = None
    credit_estimate: Dict[str, Any] = field(default_factory=dict)
    provider_capabilities: List[Dict[str, Any]] = field(default_factory=list)
    run_policy: str = RUN_POLICY_BEST_EFFORT
    minimum_provider_eligible: bool = True
    errors: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        estimated_valid_rows = (
            int(self.estimated_valid_rows)
            if self.estimated_valid_rows is not None
            else int(self.rows_with_valid_inputs)
        )
        credit_estimate = dict(self.credit_estimate or {})
        if not credit_estimate:
            credit_estimate = _build_credit_estimate_payload(
                estimated_credits=self.estimated_credits,
                estimated_valid_rows=estimated_valid_rows,
            )
        return {
            "rows_with_valid_inputs": self.rows_with_valid_inputs,
            "rows_skipped": self.rows_skipped,
            "estimated_credits": self.estimated_credits,
            "estimated_valid_rows": estimated_valid_rows,
            "credit_estimate": credit_estimate,
            "providers_available": self.providers_available,
            "byo_provider_connected": self.byo_provider_connected,
            "provider_capabilities": self.provider_capabilities,
            "run_policy": self.run_policy,
            "minimum_provider_eligible": self.minimum_provider_eligible,
            "errors": self.errors,
        }


@dataclass
class TestResult:
    """Result of live test enrichment."""
    results: List[Dict[str, Any]]
    credits_consumed: int
    rows_enriched: int
    rows_skipped: int
    rows_with_data: int = 0

    def to_dict(self) -> Dict[str, Any]:
        return {
            "results": self.results,
            "credits_consumed": self.credits_consumed,
            "rows_enriched": self.rows_enriched,
            "rows_billable": self.rows_enriched,
            "rows_with_data": self.rows_with_data,
            "rows_skipped": self.rows_skipped,
        }


@dataclass
class JobCreationResult:
    """Result of job creation."""
    job_id: UUID
    run_id: str
    status: str
    estimated_credits: int
    estimated_valid_rows: Optional[int] = None
    credit_estimate: Dict[str, Any] = field(default_factory=dict)
    existing: bool = False

    def to_dict(self) -> Dict[str, Any]:
        estimated_valid_rows = (
            int(self.estimated_valid_rows)
            if self.estimated_valid_rows is not None
            else int(self.estimated_credits)
        )
        credit_estimate = dict(self.credit_estimate or {})
        if not credit_estimate:
            credit_estimate = _build_credit_estimate_payload(
                estimated_credits=self.estimated_credits,
                estimated_valid_rows=estimated_valid_rows,
            )
        return {
            "job_id": str(self.job_id),
            "run_id": self.run_id,
            "status": self.status,
            "estimated_credits": self.estimated_credits,
            "estimated_valid_rows": estimated_valid_rows,
            "credit_estimate": credit_estimate,
            "existing": self.existing,
        }


@dataclass
class JobStatus:
    """Job status response."""
    job_id: UUID
    status: str
    progress: int
    rows_processed: int
    rows_total: int
    rows_enriched: int = 0
    rows_with_data: int = 0
    rows_skipped: int = 0
    rows_errored: int = 0
    credits_consumed: int = 0
    estimated_credits: Optional[int] = None
    estimated_valid_rows: Optional[int] = None
    credit_variance: Optional[int] = None
    credit_estimate: Optional[Dict[str, Any]] = None
    summary: Optional[Dict[str, Any]] = None
    error_message: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        result = {
            "job_id": str(self.job_id),
            "status": self.status,
            "progress": self.progress,
            "rows_processed": self.rows_processed,
            "rows_total": self.rows_total,
            "rows_enriched": self.rows_enriched,
            "rows_billable": self.rows_enriched,
            "rows_with_data": self.rows_with_data,
            "rows_skipped": self.rows_skipped,
            "rows_errored": self.rows_errored,
            "credits_consumed": self.credits_consumed,
        }
        if self.estimated_credits is not None:
            result["estimated_credits"] = self.estimated_credits
        if self.estimated_valid_rows is not None:
            result["estimated_valid_rows"] = self.estimated_valid_rows
        if self.credit_variance is not None:
            result["credit_variance"] = self.credit_variance
        if self.credit_estimate:
            result["credit_estimate"] = self.credit_estimate
        if self.summary:
            result["summary"] = self.summary
        if self.error_message:
            result["error_message"] = self.error_message
        return result


class EnrichmentService:
    """
    Service for orchestrating enrichment jobs.

    Handles validation, job creation, and status tracking.
    """

    MAX_ROWS_PER_REQUEST = settings.BYO_ENRICHMENT_MAX_ROWS

    def __init__(self, db: AsyncSession, workspace_id: str):
        """
        Initialize enrichment service.

        Args:
            db: Async database session
            workspace_id: Workspace ID for this service instance
        """
        self.db = db
        self.workspace_id = workspace_id

    @staticmethod
    def _get_row_index(row: Dict[str, Any], fallback: int) -> int:
        value = row.get("__fm_row_index__")
        if value is None:
            return fallback
        try:
            return int(value)
        except (TypeError, ValueError):
            return fallback

    @staticmethod
    def _contract_list(raw: Any) -> List[str]:
        if raw is None:
            return []
        if isinstance(raw, str):
            return [part.strip() for part in raw.split(",") if part and part.strip()]
        if isinstance(raw, (list, tuple, set)):
            items: List[str] = []
            for item in raw:
                if item is None:
                    continue
                text = str(item).strip()
                if text:
                    items.append(text)
            return items
        return []

    @classmethod
    def _contract_note_list(cls, raw: Any) -> List[str]:
        if raw is None:
            return []
        if isinstance(raw, str):
            parts = [part.strip() for part in raw.split(";") if part and part.strip()]
        elif isinstance(raw, (list, tuple, set)):
            parts = [str(part).strip() for part in raw if part is not None and str(part).strip()]
        else:
            parts = []
        return [code for code in parts if code in ROW_CONTRACT_REASON_CODES]

    @staticmethod
    def _is_missing_contract_value(value: Any) -> bool:
        if value is None:
            return True
        if isinstance(value, str):
            return value.strip() == ""
        if isinstance(value, (list, tuple, set)):
            if not value:
                return True
            return all(EnrichmentService._is_missing_contract_value(item) for item in value)
        if isinstance(value, dict):
            return len(value) == 0
        return False

    @staticmethod
    def _first_non_empty(values: Any) -> Any:
        if not isinstance(values, list):
            return None
        for item in values:
            if not EnrichmentService._is_missing_contract_value(item):
                return item
        return None

    @staticmethod
    def _source_value(source_input: Any, *keys: str) -> Any:
        if source_input is None:
            return None
        if isinstance(source_input, dict):
            for key in keys:
                if key in source_input and source_input.get(key) is not None:
                    return source_input.get(key)
            return None
        for key in keys:
            if hasattr(source_input, key):
                value = getattr(source_input, key)
                if value is not None:
                    return value
        return None

    @staticmethod
    def _clean_text(value: Any) -> Optional[str]:
        if value is None:
            return None
        text = str(value).strip()
        return text or None

    @classmethod
    def _normalize_compare_text(cls, value: Any) -> Optional[str]:
        text = cls._clean_text(value)
        if text is None:
            return None
        return " ".join(text.lower().split())

    @classmethod
    def _normalize_compare_email(cls, value: Any) -> Optional[str]:
        text = cls._clean_text(value)
        return text.lower() if text else None

    @classmethod
    def _normalize_compare_domain(cls, value: Any) -> Optional[str]:
        text = cls._clean_text(value)
        if text is None:
            return None
        if "@" in text:
            return fe_validation.extract_domain_from_email(text)
        normalized = text.lower()
        if "://" in normalized:
            normalized = normalized.split("://", 1)[1]
        normalized = normalized.split("/", 1)[0].strip()
        if normalized.startswith("www."):
            normalized = normalized[4:]
        return normalized or None

    @classmethod
    def _normalize_compare_url(cls, value: Any) -> Optional[str]:
        text = cls._clean_text(value)
        if text is None:
            return None
        return text.rstrip("/").lower()

    @classmethod
    def _string_list(cls, raw: Any) -> List[str]:
        if isinstance(raw, list):
            values = raw
        elif raw is None:
            values = []
        else:
            values = [raw]
        cleaned: List[str] = []
        for value in values:
            text = cls._clean_text(value)
            if text:
                cleaned.append(text)
        return cleaned

    @classmethod
    def _contact_source_context(cls, source_input: Any) -> Dict[str, Optional[str]]:
        input_email = cls._clean_text(cls._source_value(source_input, "input_email", "email"))
        explicit_domain = cls._clean_text(
            cls._source_value(source_input, "input_company_domain", "company_domain", "domain")
        )
        inferred_domain = fe_validation.extract_domain_from_email(input_email) if input_email else None
        return {
            "input_first_name": cls._clean_text(
                cls._source_value(source_input, "input_first_name", "first_name", "firstname")
            ),
            "input_last_name": cls._clean_text(
                cls._source_value(source_input, "input_last_name", "last_name", "lastname")
            ),
            "input_email": input_email,
            "input_linkedin_url": cls._clean_text(
                cls._source_value(source_input, "input_linkedin_url", "linkedin_url")
            ),
            "input_company_name": cls._clean_text(
                cls._source_value(source_input, "input_company_name", "company_name")
            ),
            "input_company_domain": explicit_domain or inferred_domain,
        }

    @classmethod
    def _sanitize_contact_result_row(
        cls,
        result_row: Dict[str, Any],
        source_input: Any,
    ) -> None:
        if not isinstance(result_row, dict):
            return

        source_context = cls._contact_source_context(source_input)
        for key, value in source_context.items():
            if value is not None:
                result_row[key] = value

        source_email = cls._normalize_compare_email(source_context.get("input_email"))
        primary_work_email = cls._clean_text(result_row.get("work_email") or result_row.get("email"))
        work_email_values = cls._string_list(result_row.get("work_emails"))
        if not work_email_values:
            work_email_values = cls._string_list(result_row.get("emails"))
        if source_email:
            work_email_values = [
                email
                for email in work_email_values
                if cls._normalize_compare_email(email) != source_email
            ]
            if cls._normalize_compare_email(primary_work_email) == source_email:
                primary_work_email = None
        if not primary_work_email and work_email_values:
            primary_work_email = work_email_values[0]
        result_row["work_email"] = primary_work_email
        result_row["email"] = primary_work_email
        result_row["work_emails"] = work_email_values
        result_row["emails"] = work_email_values

        field_normalizers = {
            "first_name": ("input_first_name", cls._normalize_compare_text),
            "last_name": ("input_last_name", cls._normalize_compare_text),
            "linkedin_url": ("input_linkedin_url", cls._normalize_compare_url),
            "company_name": ("input_company_name", cls._normalize_compare_text),
            "company_domain": ("input_company_domain", cls._normalize_compare_domain),
        }
        for field_name, (source_key, normalizer) in field_normalizers.items():
            source_value = source_context.get(source_key)
            if source_value is None:
                continue
            result_value = result_row.get(field_name)
            if normalizer(result_value) and normalizer(result_value) == normalizer(source_value):
                result_row[field_name] = None

    @classmethod
    def _result_value_for_field(
        cls,
        result_row: Dict[str, Any],
        field: str,
        enrichment_type: str,
    ) -> Any:
        if enrichment_type == EnrichmentJobType.CONTACT.value:
            if field == "work_email":
                primary_work_email = (
                    result_row.get("work_email")
                    or cls._first_non_empty(result_row.get("work_emails"))
                )
                if not cls._is_missing_contract_value(primary_work_email):
                    return primary_work_email
                fallback_email = (
                    result_row.get("email")
                    or cls._first_non_empty(result_row.get("emails"))
                )
                # Redundant by design: sanitized rows should already have these
                # echoes stripped, but keep the guard for legacy payloads.
                if cls._normalize_compare_email(fallback_email) == cls._normalize_compare_email(
                    result_row.get("input_email")
                ):
                    return None
                return fallback_email
            if field == "work_emails":
                if isinstance(result_row.get("work_emails"), list):
                    return result_row.get("work_emails")
                primary_email = result_row.get("work_email")
                if not cls._is_missing_contract_value(primary_email):
                    return [primary_email]
                fallback_emails = result_row.get("emails") if isinstance(result_row.get("emails"), list) else []
                input_email = cls._normalize_compare_email(result_row.get("input_email"))
                if input_email:
                    fallback_emails = [
                        email
                        for email in fallback_emails
                        if cls._normalize_compare_email(email) != input_email
                    ]
                if fallback_emails:
                    return fallback_emails
                fallback_primary = result_row.get("email")
                if cls._normalize_compare_email(fallback_primary) == input_email:
                    return []
                return [fallback_primary] if not cls._is_missing_contract_value(fallback_primary) else []
            if field == "personal_email":
                return result_row.get("personal_email") or cls._first_non_empty(result_row.get("personal_emails"))
            if field == "personal_emails":
                if isinstance(result_row.get("personal_emails"), list):
                    return result_row.get("personal_emails")
                personal_email = result_row.get("personal_email")
                return [personal_email] if not cls._is_missing_contract_value(personal_email) else []
            if field == "phone":
                return result_row.get("phone") or cls._first_non_empty(result_row.get("phones"))
            if field == "phones":
                if isinstance(result_row.get("phones"), list):
                    return result_row.get("phones")
                primary_phone = result_row.get("phone")
                return [primary_phone] if not cls._is_missing_contract_value(primary_phone) else []
            return result_row.get(field)
        if field == "ultimate_parent":
            return result_row.get("ultimate_parent_name") or result_row.get("ultimate_parent_domain")
        if field == "stock_ticker":
            return result_row.get("ticker")
        if field == "employee_count":
            return result_row.get("employee_count") or result_row.get("employee_range")
        return result_row.get(field)

    @classmethod
    def _infer_contract_notes(
        cls,
        raw_status: str,
        error_message: str,
        resolved_provider: str,
        missing_fields: List[str],
    ) -> List[str]:
        notes: List[str] = []
        message = (error_message or "").lower()
        status = (raw_status or "").lower()

        if status == EnrichmentStatus.SKIPPED.value:
            notes.append("input_missing_required")
        elif status == EnrichmentStatus.NOT_FOUND.value:
            notes.append("provider_no_match")
        elif status == EnrichmentStatus.ERROR.value:
            if "429" in message or "rate limit" in message:
                notes.append("provider_rate_limited")
            elif "timeout" in message or "timed out" in message or "504" in message or "408" in message:
                notes.append("provider_timeout")
            elif (
                "401" in message
                or "403" in message
                or "auth" in message
                or "token" in message
                or "unauthoriz" in message
            ):
                notes.append("provider_auth_error")
            else:
                notes.append("provider_server_error")

        if resolved_provider == "none" and not notes:
            notes.append("provider_no_match")

        if missing_fields and resolved_provider != "none":
            notes.append("provider_lacks_field")

        deduped: List[str] = []
        for code in notes:
            if code in ROW_CONTRACT_REASON_CODES and code not in deduped:
                deduped.append(code)
        return deduped

    @classmethod
    def _annotate_row_contract(
        cls,
        result_row: Dict[str, Any],
        output_fields: List[str],
        enrichment_type: str,
    ) -> None:
        # Source of truth: docs/GOOGLE_SHEETS_ENRICHMENT_ROW_CONTRACT.md.
        # Keep provider-native status in `status`; normalized contract status is
        # written to `contract_status` to avoid breaking existing API/UI callers.
        if not isinstance(result_row, dict):
            return

        selected_fields: List[str] = []
        seen_fields = set()
        for field in output_fields or []:
            field_name = str(field or "").strip()
            if not field_name or field_name in seen_fields:
                continue
            seen_fields.add(field_name)
            selected_fields.append(field_name)

        raw_status = str(result_row.get("status") or "").strip().lower()
        is_unresolved_status = raw_status in {
            EnrichmentStatus.NOT_FOUND.value,
            EnrichmentStatus.SKIPPED.value,
            EnrichmentStatus.ERROR.value,
        }

        existing_missing = cls._contract_list(result_row.get("missing_fields"))
        if selected_fields:
            if existing_missing:
                missing_set = set(existing_missing)
                missing_fields = [field for field in selected_fields if field in missing_set]
            else:
                missing_fields = [
                    field
                    for field in selected_fields
                    if cls._is_missing_contract_value(
                        cls._result_value_for_field(result_row, field, enrichment_type)
                    )
                ]
            if is_unresolved_status:
                missing_fields = selected_fields[:]
        else:
            missing_fields = existing_missing

        requested_count = len(selected_fields)
        missing_count = len(missing_fields)
        try:
            coverage_pct = int(round(float(result_row.get("coverage_pct"))))
        except (TypeError, ValueError):
            if requested_count == 0:
                coverage_pct = 100
            else:
                coverage_pct = int(round((requested_count - missing_count) * 100 / requested_count))
        coverage_pct = max(0, min(100, coverage_pct))

        provider_candidate = str(result_row.get("provider") or "").strip().lower()
        if requested_count > 0:
            has_selected_data = (missing_count < requested_count) and not is_unresolved_status
        else:
            has_selected_data = raw_status in {
                EnrichmentStatus.ENRICHED.value,
                EnrichmentStatus.CACHE_HIT.value,
            }
            if not has_selected_data and provider_candidate and not is_unresolved_status:
                has_selected_data = str(result_row.get("status") or "").lower() not in {
                    EnrichmentStatus.NOT_FOUND.value,
                    EnrichmentStatus.SKIPPED.value,
                    EnrichmentStatus.ERROR.value,
                }

        explicit_provider = str(result_row.get("resolved_provider") or "").strip().lower()
        if explicit_provider:
            resolved_provider = explicit_provider
        elif provider_candidate and has_selected_data:
            resolved_provider = provider_candidate
        else:
            resolved_provider = "none"

        contract_status = str(
            result_row.get("contract_status")
            or result_row.get("status_contract")
            or result_row.get("cascade_status")
            or ""
        ).strip().lower()
        if contract_status not in ROW_CONTRACT_STATUS_VALUES:
            if resolved_provider == "none":
                contract_status = "unresolved"
            elif missing_fields:
                contract_status = "partial"
            else:
                contract_status = "resolved"

        notes = cls._contract_note_list(result_row.get("notes"))
        inferred_notes = cls._infer_contract_notes(
            raw_status=str(result_row.get("status") or ""),
            error_message=str(result_row.get("error_message") or ""),
            resolved_provider=resolved_provider,
            missing_fields=missing_fields,
        )
        for code in inferred_notes:
            if code not in notes:
                notes.append(code)

        result_row["resolved_provider"] = resolved_provider
        result_row["contract_status"] = contract_status
        result_row["coverage_pct"] = coverage_pct
        result_row["missing_fields"] = ",".join(missing_fields)
        result_row["notes"] = ";".join(notes)

    @classmethod
    def _annotate_row_contracts(
        cls,
        rows: List[Dict[str, Any]],
        output_fields: List[str],
        enrichment_type: str,
    ) -> None:
        for row in rows or []:
            cls._annotate_row_contract(row, output_fields, enrichment_type)

    @classmethod
    def _has_row_data_for_fields(
        cls,
        result_row: Dict[str, Any],
        fields: List[str],
        enrichment_type: str,
    ) -> bool:
        for field in fields:
            if not cls._is_missing_contract_value(
                cls._result_value_for_field(result_row, field, enrichment_type)
            ):
                return True
        return False

    @classmethod
    def _selected_billable_fields(
        cls,
        output_fields: List[str],
        enrichment_type: str,
    ) -> List[str]:
        selected_fields = cls._dedupe_output_fields(output_fields)
        if enrichment_type != EnrichmentJobType.CONTACT.value:
            return selected_fields
        return [field for field in selected_fields if field in BILLABLE_CONTACT_FIELDS]

    @classmethod
    def _has_billable_row_data(
        cls,
        result_row: Dict[str, Any],
        output_fields: List[str],
        enrichment_type: str,
    ) -> bool:
        billable_fields = cls._selected_billable_fields(output_fields, enrichment_type)
        if not billable_fields:
            return False
        return cls._has_row_data_for_fields(result_row, billable_fields, enrichment_type)

    @classmethod
    def _has_selected_row_data(
        cls,
        result_row: Dict[str, Any],
        output_fields: List[str],
        enrichment_type: str,
    ) -> bool:
        selected_fields = cls._dedupe_output_fields(output_fields)
        if not selected_fields:
            return str(result_row.get("status") or "").strip().lower() in {
                EnrichmentStatus.ENRICHED.value,
                EnrichmentStatus.CACHE_HIT.value,
            }
        return cls._has_row_data_for_fields(result_row, selected_fields, enrichment_type)

    @classmethod
    def _effective_result_status(
        cls,
        result_row: Dict[str, Any],
        output_fields: List[str],
        enrichment_type: str,
    ) -> str:
        raw_status = str(result_row.get("status") or "").strip().lower()
        if raw_status not in {
            EnrichmentStatus.ENRICHED.value,
            EnrichmentStatus.CACHE_HIT.value,
        }:
            return raw_status

        selected_fields = cls._dedupe_output_fields(output_fields)
        if not selected_fields:
            return raw_status

        contract_status = str(result_row.get("contract_status") or "").strip().lower()
        coverage_pct = cls._to_non_negative_int(result_row.get("coverage_pct")) or 0
        if contract_status == "unresolved" or coverage_pct <= 0:
            return EnrichmentStatus.NOT_FOUND.value
        if enrichment_type == EnrichmentJobType.CONTACT.value and not cls._has_billable_row_data(
            result_row,
            output_fields,
            enrichment_type,
        ):
            return EnrichmentStatus.NOT_FOUND.value
        return raw_status

    @classmethod
    def _finalize_result_rows(
        cls,
        rows: List[Dict[str, Any]],
        output_fields: List[str],
        enrichment_type: str,
    ) -> None:
        cls._annotate_row_contracts(rows, output_fields, enrichment_type)
        for row in rows or []:
            raw_status = str(row.get("status") or "").strip().lower()
            effective_status = cls._effective_result_status(row, output_fields, enrichment_type)
            if raw_status and effective_status and raw_status != effective_status:
                row.setdefault("provider_status", raw_status)
                row["status"] = effective_status

    @classmethod
    def _summarize_result_rows(
        cls,
        rows: List[Dict[str, Any]],
        output_fields: Optional[List[str]] = None,
        enrichment_type: str = EnrichmentJobType.COMPANY.value,
    ) -> Dict[str, Any]:
        credits_consumed = 0
        rows_enriched = 0
        rows_with_data = 0
        rows_skipped = 0
        rows_errored = 0
        provider_breakdown: Dict[str, int] = {}
        errors: List[Dict[str, Any]] = []

        for row in rows or []:
            status = str(row.get("status") or "").strip().lower()
            contract_status = str(row.get("contract_status") or "").strip().lower()
            if contract_status in {"resolved", "partial"}:
                rows_with_data += 1
            if status == EnrichmentStatus.ENRICHED.value:
                credits_consumed += 1
                rows_enriched += 1
                provider = str(row.get("provider") or "unknown").strip() or "unknown"
                provider_breakdown[provider] = provider_breakdown.get(provider, 0) + 1
            elif status == EnrichmentStatus.SKIPPED.value:
                rows_skipped += 1
            elif status == EnrichmentStatus.ERROR.value:
                rows_errored += 1
                error_message = str(row.get("error_message") or "").strip()
                if error_message and len(errors) < 10:
                    errors.append(
                        {
                            "row_index": row.get("row_index"),
                            "error": error_message,
                        }
                    )

        return {
            "credits_consumed": credits_consumed,
            "rows_enriched": rows_enriched,
            "rows_billable": rows_enriched,
            "rows_with_data": rows_with_data,
            "rows_skipped": rows_skipped,
            "rows_errored": rows_errored,
            "provider_breakdown": provider_breakdown,
            "errors": errors,
        }

    @staticmethod
    def _to_non_negative_int(value: Any) -> Optional[int]:
        if value is None:
            return None
        if isinstance(value, bool):
            return int(value)
        try:
            return max(0, int(value))
        except (TypeError, ValueError):
            return None

    @classmethod
    def _apply_credit_reconciliation_summary(
        cls,
        summary: Optional[Dict[str, Any]],
        *,
        estimated_credits: Optional[int] = None,
        estimated_valid_rows: Optional[int] = None,
        credits_consumed: Optional[int] = None,
    ) -> Dict[str, Any]:
        payload = dict(summary or {})

        nested_estimate = payload.get("credit_estimate")
        nested_estimated_credits = None
        nested_estimated_valid_rows = None
        if isinstance(nested_estimate, dict):
            nested_estimated_credits = nested_estimate.get("estimated_credits")
            nested_estimated_valid_rows = nested_estimate.get("estimated_valid_rows")

        resolved_estimated_credits = cls._to_non_negative_int(estimated_credits)
        if resolved_estimated_credits is None:
            resolved_estimated_credits = cls._to_non_negative_int(payload.get("estimated_credits"))
        if resolved_estimated_credits is None:
            resolved_estimated_credits = cls._to_non_negative_int(nested_estimated_credits)
        if resolved_estimated_credits is None:
            resolved_estimated_credits = 0

        resolved_estimated_valid_rows = cls._to_non_negative_int(estimated_valid_rows)
        if resolved_estimated_valid_rows is None:
            resolved_estimated_valid_rows = cls._to_non_negative_int(payload.get("estimated_valid_rows"))
        if resolved_estimated_valid_rows is None:
            resolved_estimated_valid_rows = cls._to_non_negative_int(nested_estimated_valid_rows)
        if resolved_estimated_valid_rows is None:
            resolved_estimated_valid_rows = resolved_estimated_credits

        payload["estimated_credits"] = resolved_estimated_credits
        payload["estimated_valid_rows"] = resolved_estimated_valid_rows
        payload["credit_estimate"] = _build_credit_estimate_payload(
            estimated_credits=resolved_estimated_credits,
            estimated_valid_rows=resolved_estimated_valid_rows,
        )

        resolved_actual_credits = cls._to_non_negative_int(credits_consumed)
        if resolved_actual_credits is None:
            resolved_actual_credits = cls._to_non_negative_int(payload.get("actual_credits"))
        if resolved_actual_credits is not None:
            payload["actual_credits"] = resolved_actual_credits
            payload["credit_variance"] = resolved_actual_credits - resolved_estimated_credits
        else:
            payload.pop("actual_credits", None)
            payload.pop("credit_variance", None)

        return payload

    @staticmethod
    def normalize_run_policy(run_policy: Optional[str]) -> str:
        value = str(run_policy or "").strip().lower()
        if value == RUN_POLICY_MINIMUM_PROVIDER:
            return RUN_POLICY_MINIMUM_PROVIDER
        return RUN_POLICY_BEST_EFFORT

    @staticmethod
    def _dedupe_output_fields(output_fields: Optional[List[str]]) -> List[str]:
        selected_fields: List[str] = []
        seen_fields = set()
        for field in output_fields or []:
            key = str(field or "").strip()
            if not key or key in seen_fields:
                continue
            seen_fields.add(key)
            selected_fields.append(key)
        return selected_fields

    @staticmethod
    def _providers_for_enrichment_type(enrichment_type: str) -> List[str]:
        if str(enrichment_type or "").strip().lower() == "contact":
            return ["fullenrich", "apollo", "pdl", "amplemarket"]
        return ["foundrygraph", "apollo", "fullenrich", "pdl"]

    @staticmethod
    def _is_provider_connected_status(status_value: str) -> bool:
        status = str(status_value or "").strip().lower()
        return status in {"connected", "available"}

    @classmethod
    def build_provider_capabilities_summary(
        cls,
        provider_statuses: Optional[List[Dict[str, Any]]],
        enrichment_type: str,
        output_fields: Optional[List[str]],
    ) -> List[Dict[str, Any]]:
        entity_type = "contact" if str(enrichment_type or "").strip().lower() == "contact" else "company"
        status_map: Dict[str, Dict[str, Any]] = {}
        for item in provider_statuses or []:
            if not isinstance(item, dict):
                continue
            provider = str(item.get("provider") or "").strip().lower()
            if not provider:
                continue
            status_map[provider] = item

        selected_fields = cls._dedupe_output_fields(output_fields)

        providers = cls._providers_for_enrichment_type(entity_type)
        summaries: List[Dict[str, Any]] = []
        for provider in providers:
            capability = get_provider_capabilities(provider)
            supported_fields = capability.get("supported_fields", {}).get(entity_type, []) or []
            unsupported_fields = get_unsupported_output_fields(
                provider=provider,
                enrichment_type=entity_type,
                selected_fields=selected_fields,
            )
            unsupported_set = set(unsupported_fields)
            supported_selected = [field for field in selected_fields if field not in unsupported_set]
            requested_count = len(selected_fields)
            if requested_count == 0:
                coverage_pct = 100
            else:
                coverage_pct = int(round(len(supported_selected) * 100 / requested_count))

            status_row = status_map.get(provider) or {"provider": provider}
            status_value = str(status_row.get("status") or "").strip().lower()
            if not status_value:
                status_value = "available" if provider == "foundrygraph" else "not_connected"
            connected = cls._is_provider_connected_status(status_value)
            supports_entity = entity_type in (capability.get("entity_types") or [])
            supports_all_selected = len(unsupported_fields) == 0

            summaries.append(
                {
                    "provider": provider,
                    "label": capability.get("label") or provider,
                    "status": status_value,
                    "connected": connected,
                    "entity_types": capability.get("entity_types") or [],
                    "supported_fields": supported_fields,
                    "selected_supported_fields": supported_selected,
                    "unsupported_fields": unsupported_fields,
                    "supports_entity_type": supports_entity,
                    "supports_all_selected_fields": supports_all_selected,
                    "eligible_for_minimum_provider": bool(
                        connected and supports_entity and supports_all_selected
                    ),
                    "coverage_pct": coverage_pct,
                    "quality_tier": capability.get("quality_tier"),
                    "billing": capability.get("billing"),
                    "estimated_provider_cost_per_row": capability.get("estimated_provider_cost_per_row"),
                }
            )
        return summaries

    @classmethod
    def _select_provider_capability(
        cls,
        provider_capabilities: Optional[List[Dict[str, Any]]],
        provider: str,
    ) -> Optional[Dict[str, Any]]:
        provider_name = str(provider or "").strip().lower()
        if not provider_name:
            return None
        for item in provider_capabilities or []:
            if not isinstance(item, dict):
                continue
            if str(item.get("provider") or "").strip().lower() == provider_name:
                return item
        return None

    @classmethod
    def _minimum_provider_error_message(
        cls,
        provider_capability: Optional[Dict[str, Any]],
        provider: str,
        enrichment_type: str,
    ) -> str:
        provider_name = str(provider or "").strip().lower() or "provider"
        entity_type = "contact" if str(enrichment_type or "").strip().lower() == "contact" else "company"
        if not provider_capability:
            return f"{provider_name} is not available for {entity_type} enrichment under minimum_provider policy."

        label = str(provider_capability.get("label") or provider_name)
        if not provider_capability.get("connected"):
            return f"{label} is not connected. Connect it or switch to best-effort mode."
        if not provider_capability.get("supports_entity_type"):
            return f"{label} does not support {entity_type} enrichment."

        unsupported = [
            str(field).strip()
            for field in (provider_capability.get("unsupported_fields") or [])
            if str(field).strip()
        ]
        if unsupported:
            return (
                f"{label} cannot return all selected fields under minimum_provider policy. "
                f"Unsupported fields: {', '.join(unsupported)}."
            )
        return f"{label} is not eligible for minimum_provider policy."

    async def _get_or_create_fullenrich_webhook_secret(self) -> str:
        backend = get_saas_secret_backend()
        secret_value = await backend.get_secret(
            self.db,
            integration="fullenrich",
            key="webhook_secret",
            workspace_id=self.workspace_id,
        )
        if secret_value:
            return secret_value.value

        value = secrets.token_urlsafe(32)
        await backend.set_secret(
            db=self.db,
            integration="fullenrich",
            key="webhook_secret",
            value=value,
            workspace_id=self.workspace_id,
            created_by="system",
        )
        await self.db.commit()
        return value

    @staticmethod
    def _hash_webhook_secret(secret: str) -> str:
        return hashlib.sha256(secret.encode()).hexdigest()

    def _build_fullenrich_webhook_url(self, secret: str, job_id: Optional[str] = None) -> str:
        base = (getattr(settings, "PUBLIC_BASE_URL", None) or settings.APP_URL or "").rstrip("/")
        if not base:
            raise RuntimeError("PUBLIC_BASE_URL is not configured for FullEnrich webhooks")
        query = {"workspace_id": self.workspace_id, "secret": secret}
        if job_id:
            query["job_id"] = job_id
        query = urlencode(query)
        return f"{base}/api/v2/webhooks/fullenrich?{query}"

    async def _persist_job_rows(self, job_id: UUID, rows: List[Dict[str, Any]]) -> None:
        if not rows:
            return
        redis_conn = await get_redis()
        key = f"byo_enrichment_rows:{job_id}"
        payload = json.dumps(rows, default=str)
        await redis_conn.setex(
            key,
            settings.BYO_ENRICHMENT_ROW_TTL_SECONDS,
            payload,
        )

    async def _persist_job_meta(
        self,
        job_id: UUID,
        sheet_id: Optional[str],
        sheet_name: Optional[str],
        force_reenrich: Optional[bool],
    ) -> None:
        if not sheet_id or not sheet_name:
            return
        redis_conn = await get_redis()
        key = f"byo_enrichment_meta:{job_id}"
        payload = json.dumps(
            {
                "sheet_id": sheet_id,
                "sheet_name": sheet_name,
                "force_reenrich": bool(force_reenrich),
            }
        )
        await redis_conn.setex(
            key,
            settings.BYO_ENRICHMENT_ROW_TTL_SECONDS,
            payload,
        )

    async def _enqueue_background_job(self, job_id: UUID) -> None:
        from anyio.to_thread import run_sync
        from rq import Queue
        from ..worker import conn as _rq_conn

        def _do_enqueue() -> None:
            Queue(settings.RQ_QUEUE_NAME, connection=_rq_conn).enqueue(
                "fmatch.saas.worker.run_enrichment_job",
                str(job_id),
                job_timeout=settings.JOB_TIMEOUT_DEFAULT,
            )

        await run_sync(_do_enqueue)

    async def _store_scheduled_results(
        self,
        job: BYOEnrichmentJob,
        results: List[Dict[str, Any]],
    ) -> None:
        if not results:
            return
        redis_conn = await get_redis()
        meta_key = f"byo_enrichment_meta:{job.id}"
        meta_raw = await redis_conn.get(meta_key)
        if not meta_raw:
            return
        try:
            meta_payload = meta_raw.decode() if isinstance(meta_raw, (bytes, bytearray)) else meta_raw
            meta = json.loads(meta_payload)
        except Exception:
            return

        sheet_id = meta.get("sheet_id")
        sheet_name = meta.get("sheet_name")
        if not sheet_id or not sheet_name:
            return

        cleaned = []
        for result in results:
            if not isinstance(result, dict):
                continue
            sanitized = dict(result)
            sanitized.pop("raw", None)
            sanitized.pop("custom", None)
            cleaned.append(sanitized)

        config = {
            "output_fields": job.output_fields or [],
            "enrichment_type": job.enrichment_type,
            "force_reenrich": bool(meta.get("force_reenrich")),
            "sheet_name": sheet_name,
        }
        writer = SheetsWriter(sheet_id=sheet_id, account_id=str(self.workspace_id), api_key="")
        await writer.store_enrichment_results(
            sheet_name=sheet_name,
            results=cleaned,
            config=config,
            metadata={"job_id": str(job.id)},
        )

    async def get_provider_status(self) -> List[Dict[str, Any]]:
        """
        Get status of available enrichment providers.

        Returns:
            List of provider status dicts
        """
        backend = get_saas_secret_backend()
        return await backend.get_provider_status(self.db, self.workspace_id)

    async def validate_inputs(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        output_fields: List[str],
        enrichment_type: str = "company",
        provider: str = "foundrygraph",
        run_policy: Optional[str] = None,
    ) -> ValidationResult:
        """
        Validate enrichment inputs and estimate credits (free operation).

        Args:
            rows: Input rows to validate
            input_mapping: Column mapping (e.g., {"domain": "A"})
            output_fields: Desired output fields

        Returns:
            ValidationResult with estimates and provider status
        """
        enrichment_type = (enrichment_type or "company").lower()
        provider_name = str(provider or "foundrygraph").strip().lower() or "foundrygraph"
        run_policy_value = self.normalize_run_policy(run_policy)
        valid_count = 0
        skipped_count = 0

        if enrichment_type == "contact":
            first_col = input_mapping.get("firstname", "firstname")
            last_col = input_mapping.get("lastname", "lastname")
            domain_col = input_mapping.get("domain", "domain")
            company_col = input_mapping.get("company_name", "company_name")
            email_col = input_mapping.get("email", "email")
            linkedin_col = input_mapping.get("linkedin_url", "linkedin_url")
            for i, row in enumerate(rows):
                input_row = ContactEnrichmentInput(
                    first_name=row.get(first_col),
                    last_name=row.get(last_col),
                    domain=row.get(domain_col),
                    company_name=row.get(company_col),
                    email=row.get(email_col),
                    linkedin_url=row.get(linkedin_col),
                    row_index=self._get_row_index(row, i),
                    custom=row,
                )
                if input_row.is_valid:
                    valid_count += 1
                else:
                    skipped_count += 1
        else:
            domain_col = input_mapping.get("domain", "domain")
            for row in rows:
                value = row.get(domain_col)
                domain = str(value).strip() if value is not None else ""
                if domain:
                    valid_count += 1
                else:
                    skipped_count += 1

        # Get provider status
        providers = await self.get_provider_status()
        provider_capabilities = self.build_provider_capabilities_summary(
            provider_statuses=providers,
            enrichment_type=enrichment_type,
            output_fields=output_fields,
        )
        selected_provider_capability = self._select_provider_capability(
            provider_capabilities=provider_capabilities,
            provider=provider_name,
        )

        if enrichment_type == "contact":
            byo_connected = any(
                self._is_provider_connected_status(p.get("status"))
                and p.get("provider") in {"fullenrich", "apollo", "pdl", "amplemarket"}
                for p in providers
            )
        else:
            byo_connected = any(
                self._is_provider_connected_status(p.get("status"))
                and p.get("provider") in BYO_SUPPORTED_INTEGRATIONS
                for p in providers
            )

        # Estimate credits (1 credit per valid row)
        estimated_credits = valid_count
        minimum_provider_eligible = bool(
            selected_provider_capability and selected_provider_capability.get("eligible_for_minimum_provider")
        )
        errors: List[str] = []
        if run_policy_value == RUN_POLICY_MINIMUM_PROVIDER and not minimum_provider_eligible:
            errors.append(
                self._minimum_provider_error_message(
                    provider_capability=selected_provider_capability,
                    provider=provider_name,
                    enrichment_type=enrichment_type,
                )
            )

        return ValidationResult(
            rows_with_valid_inputs=valid_count,
            rows_skipped=skipped_count,
            estimated_credits=estimated_credits,
            estimated_valid_rows=valid_count,
            credit_estimate=_build_credit_estimate_payload(
                estimated_credits=estimated_credits,
                estimated_valid_rows=valid_count,
            ),
            providers_available=providers,
            byo_provider_connected=byo_connected,
            provider_capabilities=provider_capabilities,
            run_policy=run_policy_value,
            minimum_provider_eligible=minimum_provider_eligible,
            errors=errors,
        )

    async def run_test(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        output_fields: List[str],
        provider: str,
        enrichment_type: str = "company",
        max_rows: int = 10,
        run_policy: Optional[str] = None,
    ) -> TestResult:
        """
        Run a live test enrichment on a sample of rows.

        This consumes credits!

        Args:
            rows: Input rows (first N will be used)
            input_mapping: Column mapping
            output_fields: Desired output fields
            provider: Enrichment provider name
            max_rows: Maximum rows to test (default 10)

        Returns:
            TestResult with enriched data
        """
        enrichment_type = (enrichment_type or "company").lower()
        # Limit rows
        test_rows = rows[:max_rows]

        provider_name = (provider or "foundrygraph").lower()
        run_policy_value = self.normalize_run_policy(run_policy)
        if run_policy_value == RUN_POLICY_MINIMUM_PROVIDER:
            unsupported_fields = get_unsupported_output_fields(
                provider=provider_name,
                enrichment_type=enrichment_type,
                selected_fields=output_fields,
            )
            if unsupported_fields:
                raise ValueError(
                    f"{provider_name} cannot return all selected fields under minimum_provider policy: "
                    f"{', '.join(unsupported_fields)}"
                )
        provider_set = {provider_name} if provider_name in {"fullenrich", "pdl", "amplemarket"} else set()
        async with SecretCacheContext(
            self.db, self.workspace_id, provider_set
        ) as cache:
            async with CascadingEnrichmentProvider(
                providers=[provider_name],
                secret_cache=cache,
                db=self.db,
                account_id=self.workspace_id,
            ) as provider:
                if enrichment_type == "contact":
                    first_col = input_mapping.get("firstname", "firstname")
                    last_col = input_mapping.get("lastname", "lastname")
                    domain_col = input_mapping.get("domain", "domain")
                    company_col = input_mapping.get("company_name", "company_name")
                    email_col = input_mapping.get("email", "email")
                    linkedin_col = input_mapping.get("linkedin_url", "linkedin_url")
                    inputs = []
                    for i, row in enumerate(test_rows):
                        inputs.append(ContactEnrichmentInput(
                            first_name=row.get(first_col),
                            last_name=row.get(last_col),
                            domain=row.get(domain_col),
                            company_name=row.get(company_col),
                            email=row.get(email_col),
                            linkedin_url=row.get(linkedin_col),
                            row_index=self._get_row_index(row, i),
                            custom=row,
                        ))
                    results = await provider.enrich_contact(inputs)
                else:
                    domain_col = input_mapping.get("domain", "domain")
                    name_col = input_mapping.get("company_name", "company_name")
                    inputs = []
                    for i, row in enumerate(test_rows):
                        inputs.append(CompanyEnrichmentInput(
                            domain=row.get(domain_col),
                            company_name=row.get(name_col),
                            row_index=self._get_row_index(row, i),
                            custom=row,
                        ))
                    results = await provider.enrich_company(inputs)

        input_by_index = {inp.row_index: inp for inp in inputs}
        result_dicts = []
        for result in results:
            result_dict = result.to_dict()
            if enrichment_type == EnrichmentJobType.CONTACT.value:
                source_input = input_by_index.get(result.row_index)
                if source_input:
                    self._sanitize_contact_result_row(result_dict, source_input)
            if enrichment_type != "contact":
                source_input = input_by_index.get(result.row_index)
                if source_input:
                    result_dict["domain"] = source_input.domain
            result_dicts.append(result_dict)

        self._finalize_result_rows(result_dicts, output_fields, enrichment_type)
        result_summary = self._summarize_result_rows(result_dicts, output_fields, enrichment_type)
        return TestResult(
            results=result_dicts,
            credits_consumed=result_summary["credits_consumed"],
            rows_enriched=result_summary["rows_enriched"],
            rows_skipped=result_summary["rows_skipped"],
            rows_with_data=result_summary["rows_with_data"],
        )

    async def create_job(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        output_fields: List[str],
        provider: str,
        enrichment_type: str,
        run_id: str,
        run_policy: Optional[str] = None,
        created_by: Optional[str] = None,
        output_range: Optional[str] = None,
        notify_email: Optional[str] = None,
        spreadsheet_url: Optional[str] = None,
        sheet_id: Optional[str] = None,
        sheet_name: Optional[str] = None,
        force_reenrich: Optional[bool] = None,
    ) -> JobCreationResult:
        """
        Create an enrichment job.

        If a job with the same run_id exists, returns the existing job (idempotency).

        Args:
            rows: Input rows (max 1000)
            input_mapping: Column mapping
            output_fields: Desired output fields
            provider: Enrichment provider name
            enrichment_type: "company" or "contact"
            run_id: Idempotency key
            run_policy: Run policy (`best_effort` or `minimum_provider`)
            created_by: User email who created the job
            output_range: Google Sheets range for results
            notify_email: Email address for job completion notification
            spreadsheet_url: URL back to the spreadsheet with results
            sheet_id: Spreadsheet ID for scheduled writeback
            sheet_name: Sheet tab name for scheduled writeback
            force_reenrich: Whether to overwrite existing output values

        Returns:
            JobCreationResult with job ID and status

        Raises:
            ValueError: If row limit exceeded
        """
        # Check row limit
        if len(rows) > self.MAX_ROWS_PER_REQUEST:
            raise ValueError(
                f"Row limit exceeded: max {self.MAX_ROWS_PER_REQUEST} rows per request, "
                f"received {len(rows)}"
            )

        # Check for existing job with same run_id (idempotency)
        existing = await self.db.execute(
            select(BYOEnrichmentJob).where(
                BYOEnrichmentJob.workspace_id == self.workspace_id,
                BYOEnrichmentJob.run_id == run_id,
            )
        )
        existing_job = existing.scalar_one_or_none()

        if existing_job:
            logger.info(
                f"[ENRICHMENT] Returning existing job for run_id={run_id}: "
                f"job_id={existing_job.id}, status={existing_job.status}"
            )
            existing_summary_obj = getattr(existing_job, "summary", None)
            existing_summary_raw = existing_summary_obj if isinstance(existing_summary_obj, dict) else {}
            existing_estimated_credits = existing_summary_raw.get(
                "estimated_credits",
                getattr(existing_job, "row_count", 0),
            )
            existing_estimated_valid_rows = existing_summary_raw.get(
                "estimated_valid_rows",
                existing_estimated_credits,
            )
            existing_summary = self._apply_credit_reconciliation_summary(
                getattr(existing_job, "summary", None),
                estimated_credits=existing_estimated_credits,
                estimated_valid_rows=existing_estimated_valid_rows,
            )
            estimated_credits = self._to_non_negative_int(existing_summary.get("estimated_credits")) or 0
            estimated_valid_rows = (
                self._to_non_negative_int(existing_summary.get("estimated_valid_rows")) or estimated_credits
            )
            if sheet_id and sheet_name:
                try:
                    await self._persist_job_meta(existing_job.id, sheet_id, sheet_name, force_reenrich)
                except Exception as exc:
                    logger.warning(
                        "[ENRICHMENT] Failed to persist meta for existing job %s: %s",
                        existing_job.id,
                        exc,
                    )
            if existing_job.status == EnrichmentJobStatus.QUEUED.value:
                try:
                    await self._persist_job_rows(existing_job.id, rows)
                    await self._enqueue_background_job(existing_job.id)
                except Exception as exc:
                    logger.error(
                        "[ENRICHMENT] Failed to enqueue existing job %s: %s",
                        existing_job.id,
                        exc,
                    )
            return JobCreationResult(
                job_id=existing_job.id,
                run_id=run_id,
                status=existing_job.status,  # Already a string
                estimated_credits=estimated_credits,
                estimated_valid_rows=estimated_valid_rows,
                credit_estimate=dict(existing_summary.get("credit_estimate") or {}),
                existing=True,
            )

        enrichment_type_value = (enrichment_type or "company").lower()
        run_policy_value = self.normalize_run_policy(run_policy)
        provider_name = (provider or "foundrygraph").lower()
        provider_list = [provider_name]
        # Validate inputs
        validation = await self.validate_inputs(
            rows,
            input_mapping,
            output_fields,
            enrichment_type=enrichment_type_value,
            provider=provider_name,
            run_policy=run_policy_value,
        )
        if validation.errors:
            raise ValueError(validation.errors[0])
        initial_summary = self._apply_credit_reconciliation_summary(
            summary=None,
            estimated_credits=validation.estimated_credits,
            estimated_valid_rows=validation.rows_with_valid_inputs,
            credits_consumed=0,
        )

        # Map enrichment type
        job_type = (
            EnrichmentJobType.CONTACT
            if enrichment_type_value == "contact"
            else EnrichmentJobType.COMPANY
        )

        # Create job
        job = BYOEnrichmentJob(
            workspace_id=self.workspace_id,
            run_id=run_id,
            status=EnrichmentJobStatus.QUEUED.value,
            enrichment_type=job_type.value,
            row_count=len(rows),
            cursor=0,
            rows_processed=0,
            rows_enriched=0,
            rows_skipped=0,
            rows_errored=0,
            credits_consumed=0,
            chunk_size=settings.BYO_ENRICHMENT_CHUNK_SIZE,
            max_rows=self.MAX_ROWS_PER_REQUEST,
            providers=provider_list,
            input_mapping=input_mapping,
            output_fields=output_fields,
            output_range=output_range,
            created_by=created_by,
            notify_email=notify_email,
            spreadsheet_url=spreadsheet_url,
            summary=initial_summary,
        )
        self.db.add(job)
        await self.db.commit()
        await self.db.refresh(job)

        logger.info(
            f"[ENRICHMENT] Created job {job.id} for {len(rows)} rows, "
            f"estimated_credits={validation.estimated_credits}"
        )

        # MVP: Process inline for small jobs
        inline_threshold = getattr(settings, "BYO_ENRICHMENT_INLINE_THRESHOLD", 100)
        force_inline = os.getenv("FM_PROCESS_IN_APP") == "1"
        if force_inline:
            logger.info("[ENRICHMENT] FM_PROCESS_IN_APP=1 forcing inline processing for job %s", job.id)
        if sheet_id and sheet_name:
            try:
                await self._persist_job_meta(job.id, sheet_id, sheet_name, force_reenrich)
            except Exception as exc:
                logger.warning(
                    "[ENRICHMENT] Failed to persist job meta %s: %s",
                    job.id,
                    exc,
                )
        if job_type == EnrichmentJobType.CONTACT and provider_name == "fullenrich":
            try:
                await self._submit_contact_job(job, rows, input_mapping, run_id)
                await self.db.refresh(job)
            except Exception as e:
                logger.error(f"[ENRICHMENT] Contact submission failed for job {job.id}: {e}")
                await self.db.refresh(job)
        elif len(rows) <= inline_threshold or force_inline:
            try:
                await self._process_job_inline(
                    job,
                    rows,
                    input_mapping,
                    output_fields,
                    provider_list,
                )
                # Refresh to get updated status
                await self.db.refresh(job)
            except Exception as e:
                logger.error(f"[ENRICHMENT] Inline processing failed for job {job.id}: {e}")
                # Job remains in queued state - client can retry
        else:
            try:
                await self._persist_job_rows(job.id, rows)
                await self._enqueue_background_job(job.id)
            except Exception as exc:
                logger.error(
                    "[ENRICHMENT] Failed to enqueue background job %s: %s",
                    job.id,
                    exc,
                )
                job.status = EnrichmentJobStatus.FAILED.value
                job.error_message = f"Failed to enqueue job: {exc}"
                job.completed_at = datetime.now(timezone.utc)
                await self.db.commit()

        return JobCreationResult(
            job_id=job.id,
            run_id=run_id,
            status=job.status,  # Already a string
            estimated_credits=validation.estimated_credits,
            estimated_valid_rows=validation.rows_with_valid_inputs,
            credit_estimate=dict(initial_summary.get("credit_estimate") or {}),
            existing=False,
        )

    async def _submit_contact_job(
        self,
        job: BYOEnrichmentJob,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        run_id: str,
    ) -> None:
        first_col = input_mapping.get("firstname", "firstname")
        last_col = input_mapping.get("lastname", "lastname")
        domain_col = input_mapping.get("domain", "domain")
        company_col = input_mapping.get("company_name", "company_name")
        email_col = input_mapping.get("email", "email")
        linkedin_col = input_mapping.get("linkedin_url", "linkedin_url")
        inputs: List[ContactEnrichmentInput] = []
        skipped_results: List[Dict[str, Any]] = []

        async with SecretCacheContext(self.db, self.workspace_id, {"fullenrich"}) as cache:
            adapter = FullEnrichAdapter(secret_cache=cache)
            if not adapter.is_available:
                job.status = EnrichmentJobStatus.FAILED.value
                job.error_message = "FullEnrich API key not configured"
                job.completed_at = datetime.now(timezone.utc)
                job.rows_processed = 0
                job.rows_skipped = 0
                job.rows_errored = len(rows)
                await self.db.commit()
                return

            for i, row in enumerate(rows):
                input_row = ContactEnrichmentInput(
                    first_name=row.get(first_col),
                    last_name=row.get(last_col),
                    domain=row.get(domain_col),
                    company_name=row.get(company_col),
                    email=row.get(email_col),
                    linkedin_url=row.get(linkedin_col),
                    row_index=self._get_row_index(row, i),
                    custom=row,
                )
                if not input_row.is_valid:
                    skipped_results.append({
                        "row_index": input_row.row_index,
                        "status": EnrichmentStatus.SKIPPED.value,
                        "provider": "fullenrich",
                        "first_name": input_row.first_name,
                        "last_name": input_row.last_name,
                        "firstname": input_row.firstname,
                        "lastname": input_row.lastname,
                        "input_email": str(input_row.email).strip() if input_row.email else None,
                        "linkedin_url": input_row.linkedin_url,
                        "error_message": "Invalid input: LinkedIn URL or name + company required",
                    })
                    continue

                name = adapter._derive_contact_name(input_row)
                if not name and not input_row.linkedin_url:
                    skipped_results.append({
                        "row_index": input_row.row_index,
                        "status": EnrichmentStatus.SKIPPED.value,
                        "provider": "fullenrich",
                        "first_name": input_row.first_name,
                        "last_name": input_row.last_name,
                        "firstname": input_row.firstname,
                        "lastname": input_row.lastname,
                        "input_email": str(input_row.email).strip() if input_row.email else None,
                        "linkedin_url": input_row.linkedin_url,
                        "error_message": "Unable to derive contact name from input",
                    })
                    continue

                inputs.append(input_row)

            self._annotate_row_contracts(skipped_results, job.output_fields or [], job.enrichment_type)
            if not inputs:
                summary = dict(job.summary or {})
                summary.update({
                    "provider_breakdown": {},
                    "top_errors": [],
                    "results": skipped_results,
                })
                summary = self._apply_credit_reconciliation_summary(
                    summary=summary,
                    credits_consumed=0,
                )
                job.status = EnrichmentJobStatus.COMPLETED.value
                job.completed_at = datetime.now(timezone.utc)
                job.rows_processed = len(rows)
                job.rows_skipped = len(skipped_results)
                job.rows_enriched = 0
                job.rows_errored = 0
                job.credits_consumed = 0
                job.summary = summary
                await self.db.commit()
                return

            webhook_secret = secrets.token_urlsafe(32)
            webhook_secret_hash = self._hash_webhook_secret(webhook_secret)
            seed_summary = dict(job.summary or {})
            seed_summary.update(
                {
                    "fullenrich_webhook_secret_hash": webhook_secret_hash,
                    "fullenrich_api_version": adapter.api_version.value,
                }
            )
            job.summary = seed_summary
            await self.db.commit()
            try:
                webhook_url = self._build_fullenrich_webhook_url(webhook_secret, job_id=str(job.id))
                # Map UI output fields to FullEnrich API enrich_fields
                enrich_fields = None
                if job.output_fields:
                    enrich_fields = map_output_fields_to_enrich_fields(job.output_fields)
                    logger.info(
                        f"[ENRICHMENT] Mapped output_fields {job.output_fields} to enrich_fields {enrich_fields}"
                    )
                enrichment_id = await adapter.submit_contact_enrichment(
                    inputs,
                    webhook_url=webhook_url,
                    job_id=str(job.id),
                    run_id=run_id,
                    workspace_id=self.workspace_id,
                    enrich_fields=enrich_fields,
                )
            except Exception as exc:
                summary = dict(job.summary or {})
                summary.update({
                    "provider_breakdown": {},
                    "top_errors": [{"error": str(exc)}],
                    "results": skipped_results,
                })
                summary = self._apply_credit_reconciliation_summary(
                    summary=summary,
                    credits_consumed=0,
                )
                job.status = EnrichmentJobStatus.FAILED.value
                job.error_message = str(exc)
                job.completed_at = datetime.now(timezone.utc)
                job.rows_processed = len(skipped_results)
                job.rows_skipped = len(skipped_results)
                job.rows_errored = len(inputs)
                job.summary = summary
                await self.db.commit()
                return

        submitted_rows = [
            {
                "row_index": inp.row_index,
                "first_name": inp.first_name,
                "last_name": inp.last_name,
                "firstname": inp.firstname,
                "lastname": inp.lastname,
                "domain": inp.domain,
                "company_name": inp.company_name,
                "input_email": str(inp.email).strip() if inp.email else None,
                "linkedin_url": inp.linkedin_url,
            }
            for inp in inputs
        ]

        summary = dict(job.summary or {})
        summary.update({
            "provider_breakdown": {},
            "top_errors": [],
            "results": skipped_results,
            "submitted_row_indices": [row["row_index"] for row in submitted_rows],
            "submitted_rows": submitted_rows,
            "fullenrich_enrichment_id": enrichment_id,
            "fullenrich_status": "submitted",
            "fullenrich_webhook_secret_hash": webhook_secret_hash,
            "fullenrich_api_version": adapter.api_version.value,
        })
        summary = self._apply_credit_reconciliation_summary(
            summary=summary,
            credits_consumed=job.credits_consumed,
        )

        job.status = EnrichmentJobStatus.RUNNING.value
        job.started_at = datetime.now(timezone.utc)
        job.rows_processed = len(skipped_results)
        job.rows_skipped = len(skipped_results)
        job.summary = summary
        await self.db.commit()

    async def get_job_status(self, job_id: UUID) -> Optional[JobStatus]:
        """
        Get the status of an enrichment job.

        Args:
            job_id: Job ID to check

        Returns:
            JobStatus if found, None otherwise
        """
        result = await self.db.execute(
            select(BYOEnrichmentJob).where(
                BYOEnrichmentJob.id == job_id,
                BYOEnrichmentJob.workspace_id == self.workspace_id,
            )
        )
        job = result.scalar_one_or_none()

        if job is None:
            return None

        refreshed = await self._refresh_contact_job_from_provider(job)
        if refreshed:
            await self.db.refresh(job)

        # Calculate progress
        progress = 0
        if job.row_count > 0:
            progress = int((job.rows_processed / job.row_count) * 100)
        summary_raw = job.summary if isinstance(job.summary, dict) else {}
        estimated_seed = summary_raw.get("estimated_credits", job.row_count)
        estimated_valid_seed = summary_raw.get("estimated_valid_rows", estimated_seed)
        summary = self._apply_credit_reconciliation_summary(
            summary=job.summary,
            estimated_credits=estimated_seed,
            estimated_valid_rows=estimated_valid_seed,
            credits_consumed=job.credits_consumed,
        )
        estimated_credits = self._to_non_negative_int(summary.get("estimated_credits"))
        estimated_valid_rows = self._to_non_negative_int(summary.get("estimated_valid_rows"))
        credit_variance = summary.get("credit_variance")
        if credit_variance is not None:
            try:
                credit_variance = int(credit_variance)
            except (TypeError, ValueError):
                credit_variance = None

        rows_with_data = self._to_non_negative_int(summary.get("rows_with_data"))
        if rows_with_data is None:
            rows_with_data = job.rows_enriched

        return JobStatus(
            job_id=job.id,
            status=job.status,  # Already a string
            progress=progress,
            rows_processed=job.rows_processed,
            rows_total=job.row_count,
            rows_enriched=job.rows_enriched,
            rows_with_data=rows_with_data,
            rows_skipped=job.rows_skipped,
            rows_errored=job.rows_errored,
            credits_consumed=job.credits_consumed,
            estimated_credits=estimated_credits,
            estimated_valid_rows=estimated_valid_rows,
            credit_variance=credit_variance,
            credit_estimate=dict(summary.get("credit_estimate") or {}),
            summary=summary,
            error_message=job.error_message,
        )

    async def _refresh_contact_job_from_provider(self, job: BYOEnrichmentJob) -> bool:
        if job.enrichment_type != EnrichmentJobType.CONTACT.value:
            return False
        providers = job.providers or []
        provider_name = providers[0] if providers else "foundrygraph"
        if provider_name != "fullenrich":
            return False

        summary = dict(job.summary or {})
        status_hint = str(summary.get("fullenrich_status") or "").upper()
        if job.status not in {EnrichmentJobStatus.QUEUED.value, EnrichmentJobStatus.RUNNING.value}:
            if status_hint not in JOB_PENDING_STATUSES:
                return False
        enrichment_id = summary.get("fullenrich_enrichment_id")
        if not enrichment_id:
            return False

        now = datetime.now(timezone.utc)
        last_poll_raw = summary.get("fullenrich_last_poll_at")
        if last_poll_raw:
            try:
                last_poll = datetime.fromisoformat(str(last_poll_raw).replace("Z", "+00:00"))
            except Exception:
                last_poll = None
            if last_poll and (now - last_poll).total_seconds() < 30:
                return False

        api_version = summary.get("fullenrich_api_version")
        if not api_version:
            logger.warning("[ENRICHMENT] Job %s missing fullenrich_api_version, defaulting to v1.", job.id)
            api_version = fe_constants.FullEnrichAPIVersion.V1.value

        async with SecretCacheContext(self.db, self.workspace_id, {"fullenrich"}) as cache:
            adapter = FullEnrichAdapter(secret_cache=cache, api_version=api_version)
            if not adapter.is_available:
                return False
            try:
                payload = await adapter.fetch_contact_enrichment(str(enrichment_id))
            except Exception as exc:
                logger.warning(
                    "[ENRICHMENT] FullEnrich status fetch failed for job %s: %s",
                    job.id,
                    exc,
                )
                summary["fullenrich_last_poll_at"] = now.isoformat()
                job.summary = summary
                await self.db.commit()
                return True

        status_raw = str(payload.get("status") or "").upper()
        summary["fullenrich_status"] = status_raw or summary.get("fullenrich_status")
        summary["fullenrich_enrichment_id"] = (
            payload.get("enrichment_id") or payload.get("id") or summary.get("fullenrich_enrichment_id")
        )
        summary["fullenrich_last_poll_at"] = now.isoformat()

        if status_raw in JOB_PENDING_STATUSES or not status_raw:
            job.summary = summary
            await self.db.commit()
            return True

        if api_version == fe_constants.FullEnrichAPIVersion.V2.value:
            raw_contacts = payload.get("data") or []
        else:
            raw_contacts = payload.get("datas") or []
        results_by_index: Dict[int, Dict[str, Any]] = {}
        for entry in raw_contacts:
            custom = entry.get("custom") or {}
            row_index = custom.get("row_index")
            try:
                row_index = int(row_index)
            except (TypeError, ValueError):
                row_index = None
            if row_index is None:
                continue
            fallback = ContactEnrichmentInput(
                first_name=custom.get("first_name") or custom.get("firstname"),
                last_name=custom.get("last_name") or custom.get("lastname"),
                domain=custom.get("domain") or custom.get("company_domain"),
                company_name=custom.get("company_name") or custom.get("company"),
                email=custom.get("email"),
                linkedin_url=custom.get("linkedin_url"),
                row_index=row_index,
                custom=custom,
            )
            parsed = adapter._parse_contact_payload(entry, fallback, api_version=api_version)
            results_by_index[row_index] = parsed.to_dict()

        submitted_rows = summary.get("submitted_rows") or []
        skipped_results = [
            r for r in (summary.get("results") or []) if r.get("status") == EnrichmentStatus.SKIPPED.value
        ]
        for row in submitted_rows:
            idx = row.get("row_index")
            try:
                idx = int(idx) if idx is not None else None
            except (TypeError, ValueError):
                idx = None
            if idx is None or idx in results_by_index:
                continue
            results_by_index[idx] = {
                "row_index": idx,
                "status": EnrichmentStatus.NOT_FOUND.value,
                "provider": "fullenrich",
                "first_name": row.get("first_name"),
                "last_name": row.get("last_name"),
                "firstname": row.get("firstname") or row.get("first_name"),
                "lastname": row.get("lastname") or row.get("last_name"),
                "domain": row.get("domain"),
                "company_name": row.get("company_name"),
                "input_email": row.get("input_email") or row.get("email"),
                "linkedin_url": row.get("linkedin_url"),
            }

        results = skipped_results + list(results_by_index.values())
        if job.enrichment_type == EnrichmentJobType.CONTACT.value:
            submitted_by_index: Dict[int, Dict[str, Any]] = {}
            for row in submitted_rows:
                idx = row.get("row_index")
                try:
                    idx = int(idx) if idx is not None else None
                except (TypeError, ValueError):
                    idx = None
                if idx is None:
                    continue
                submitted_by_index[idx] = row
            for row in results:
                idx = row.get("row_index")
                try:
                    idx = int(idx) if idx is not None else None
                except (TypeError, ValueError):
                    idx = None
                if idx is None:
                    continue
                source_row = submitted_by_index.get(idx)
                if source_row:
                    self._sanitize_contact_result_row(row, source_row)
        self._finalize_result_rows(results, job.output_fields or [], job.enrichment_type)
        result_summary = self._summarize_result_rows(results, job.output_fields or [], job.enrichment_type)
        rows_enriched = result_summary["rows_enriched"]
        rows_with_data = result_summary["rows_with_data"]
        rows_errored = result_summary["rows_errored"]
        rows_skipped = result_summary["rows_skipped"]
        rows_processed = len(results)
        credits_consumed = result_summary["credits_consumed"]
        errors = result_summary["errors"][:5]

        summary.update(
            {
                "provider_breakdown": {"fullenrich": rows_enriched},
                "top_errors": errors,
                "results": results,
                "rows_with_data": rows_with_data,
                "rows_billable": rows_enriched,
            }
        )
        summary = self._apply_credit_reconciliation_summary(
            summary=summary,
            credits_consumed=credits_consumed,
        )

        if status_raw in JOB_FAILURE_STATUSES:
            job.status = EnrichmentJobStatus.FAILED.value
            job.error_message = payload.get("error") or f"FullEnrich status: {status_raw}"
            job.completed_at = now
        else:
            job.status = EnrichmentJobStatus.COMPLETED.value
            job.completed_at = now

        job.rows_processed = rows_processed
        job.rows_enriched = rows_enriched
        job.rows_skipped = rows_skipped
        job.rows_errored = rows_errored
        job.credits_consumed = credits_consumed
        job.summary = summary
        await self.db.commit()

        if job.status == EnrichmentJobStatus.COMPLETED.value:
            try:
                await self._store_scheduled_results(job, results)
            except Exception as exc:
                logger.warning(
                    "[ENRICHMENT] Failed to store scheduled results for job %s: %s",
                    job.id,
                    exc,
                )

        if job.status == EnrichmentJobStatus.COMPLETED.value and job.notify_email:
            if not summary.get("notification_sent"):
                try:
                    await send_enrichment_complete_notification(
                        user_email=job.notify_email,
                        job_name=f"Contact Enrichment ({job.row_count} contacts)",
                        job_id=str(job.id),
                        total_contacts=job.row_count,
                        enriched_count=rows_enriched,
                        not_found_count=max(
                            0,
                            rows_processed - rows_enriched - rows_skipped - rows_errored,
                        ),
                        error_count=rows_errored,
                        spreadsheet_url=job.spreadsheet_url,
                    )
                    summary["notification_sent"] = True
                    job.summary = summary
                    await self.db.commit()
                except Exception as exc:
                    logger.warning(
                        "[ENRICHMENT] Failed to send completion notification for job %s: %s",
                        job.id,
                        exc,
                    )
                    summary["notification_error"] = str(exc)
                    summary["notification_attempted_at"] = now.isoformat()
                    job.summary = summary
                    await self.db.commit()
        return True

    async def list_jobs(
        self,
        limit: int = 20,
        status_filter: Optional[str] = None,
    ) -> List[JobStatus]:
        """
        List enrichment jobs for the workspace.

        Args:
            limit: Maximum number of jobs to return
            status_filter: Optional status filter (queued, running, completed, failed)

        Returns:
            List of JobStatus objects
        """
        query = select(BYOEnrichmentJob).where(
            BYOEnrichmentJob.workspace_id == self.workspace_id
        )

        if status_filter:
            try:
                status_enum = EnrichmentJobStatus(status_filter)
                query = query.where(BYOEnrichmentJob.status == status_enum)
            except ValueError:
                pass

        query = query.order_by(BYOEnrichmentJob.created_at.desc()).limit(limit)

        result = await self.db.execute(query)
        jobs = result.scalars().all()

        statuses = []
        for job in jobs:
            progress = 0
            if job.row_count > 0:
                progress = int((job.rows_processed / job.row_count) * 100)
            summary_raw = job.summary if isinstance(job.summary, dict) else {}
            estimated_seed = summary_raw.get("estimated_credits", job.row_count)
            estimated_valid_seed = summary_raw.get("estimated_valid_rows", estimated_seed)
            summary = self._apply_credit_reconciliation_summary(
                summary=job.summary,
                estimated_credits=estimated_seed,
                estimated_valid_rows=estimated_valid_seed,
                credits_consumed=job.credits_consumed,
            )
            estimated_credits = self._to_non_negative_int(summary.get("estimated_credits"))
            estimated_valid_rows = self._to_non_negative_int(summary.get("estimated_valid_rows"))
            credit_variance = summary.get("credit_variance")
            if credit_variance is not None:
                try:
                    credit_variance = int(credit_variance)
                except (TypeError, ValueError):
                    credit_variance = None

            rows_with_data = self._to_non_negative_int(summary.get("rows_with_data"))
            if rows_with_data is None:
                rows_with_data = job.rows_enriched

            statuses.append(JobStatus(
                job_id=job.id,
                status=job.status,  # Already a string
                progress=progress,
                rows_processed=job.rows_processed,
                rows_total=job.row_count,
                rows_enriched=job.rows_enriched,
                rows_with_data=rows_with_data,
                rows_skipped=job.rows_skipped,
                rows_errored=job.rows_errored,
                credits_consumed=job.credits_consumed,
                estimated_credits=estimated_credits,
                estimated_valid_rows=estimated_valid_rows,
                credit_variance=credit_variance,
                credit_estimate=dict(summary.get("credit_estimate") or {}),
                summary=summary,
                error_message=job.error_message,
            ))

        return statuses

    async def _process_job_inline(
        self,
        job: BYOEnrichmentJob,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        output_fields: List[str],
        providers: List[str],
    ) -> None:
        """
        Process enrichment job inline (synchronously).

        MVP implementation - processes all rows immediately instead of
        queuing for background processing. For production scale, use
        RQ background processing instead.

        Args:
            job: The job record to update
            rows: Input rows to enrich
            input_mapping: Column mapping
            output_fields: Desired output fields
            providers: Provider priority list
        """
        from datetime import datetime, timezone

        logger.info(f"[ENRICHMENT] Starting inline processing for job {job.id}")

        # Mark job as running
        job.status = EnrichmentJobStatus.RUNNING.value
        job.started_at = datetime.now(timezone.utc)
        await self.db.commit()

        try:
            providers = providers[:1] if providers else ["foundrygraph"]
            # Setup providers with secret cache
            provider_set = {p for p in providers if p in {"fullenrich", "pdl", "amplemarket"}}
            async with SecretCacheContext(
                self.db, self.workspace_id, provider_set
            ) as cache:
                async with CascadingEnrichmentProvider(
                    providers=providers,
                    secret_cache=cache,
                    db=self.db,
                    account_id=self.workspace_id,
                ) as provider:
                    if job.enrichment_type == EnrichmentJobType.CONTACT.value:
                        first_col = input_mapping.get("firstname", "firstname")
                        last_col = input_mapping.get("lastname", "lastname")
                        domain_col = input_mapping.get("domain", "domain")
                        company_col = input_mapping.get("company_name", "company_name")
                        email_col = input_mapping.get("email", "email")
                        linkedin_col = input_mapping.get("linkedin_url", "linkedin_url")
                        inputs = []
                        for i, row in enumerate(rows):
                            inputs.append(ContactEnrichmentInput(
                                first_name=row.get(first_col),
                                last_name=row.get(last_col),
                                domain=row.get(domain_col),
                                company_name=row.get(company_col),
                                email=row.get(email_col),
                                linkedin_url=row.get(linkedin_col),
                                row_index=self._get_row_index(row, i),
                                custom=row,
                            ))
                        results = await provider.enrich_contact(inputs)
                    else:
                        domain_col = input_mapping.get("domain", "domain")
                        name_col = input_mapping.get("company_name", "company_name")
                        inputs = []
                        for i, row in enumerate(rows):
                            inputs.append(CompanyEnrichmentInput(
                                domain=row.get(domain_col),
                                company_name=row.get(name_col),
                                row_index=self._get_row_index(row, i),
                                custom=row,
                            ))
                        results = await provider.enrich_company(inputs)

            result_data = []
            input_by_index = {inp.row_index: inp for inp in inputs}

            for result in results:
                result_dict = result.to_dict()
                if job.enrichment_type == EnrichmentJobType.CONTACT.value:
                    source_input = input_by_index.get(result.row_index)
                    if source_input:
                        self._sanitize_contact_result_row(result_dict, source_input)
                result_data.append(result_dict)
            self._finalize_result_rows(result_data, output_fields, job.enrichment_type)
            result_summary = self._summarize_result_rows(result_data, output_fields, job.enrichment_type)
            credits_consumed = result_summary["credits_consumed"]
            rows_enriched = result_summary["rows_enriched"]
            rows_with_data = result_summary["rows_with_data"]
            rows_skipped = result_summary["rows_skipped"]
            rows_errored = result_summary["rows_errored"]
            provider_breakdown = result_summary["provider_breakdown"]
            errors = result_summary["errors"][:5]
            # Build summary
            summary = dict(job.summary or {})
            summary.update({
                "provider_breakdown": provider_breakdown,
                "top_errors": errors[:5],
                "rows_with_data": rows_with_data,
                "rows_billable": rows_enriched,
                "results": result_data,  # Store results in summary for client to read
            })
            summary = self._apply_credit_reconciliation_summary(
                summary=summary,
                credits_consumed=credits_consumed,
            )

            # Update job as completed
            job.status = EnrichmentJobStatus.COMPLETED.value
            job.completed_at = datetime.now(timezone.utc)
            job.rows_processed = len(rows)
            job.rows_enriched = rows_enriched
            job.rows_skipped = rows_skipped
            job.rows_errored = rows_errored
            job.credits_consumed = credits_consumed
            job.summary = summary
            await self.db.commit()

            logger.info(
                f"[ENRICHMENT] Completed job {job.id}: "
                f"enriched={rows_enriched}, skipped={rows_skipped}, errored={rows_errored}"
            )

            if job.notify_email:
                summary = dict(job.summary or {})
                if not summary.get("notification_sent"):
                    try:
                        record_label = "contacts" if job.enrichment_type == "contact" else "companies"
                        await send_enrichment_complete_notification(
                            user_email=job.notify_email,
                            job_name=f"Enrichment ({job.row_count} {record_label})",
                            job_id=str(job.id),
                            total_contacts=job.row_count,
                            enriched_count=job.rows_enriched,
                            not_found_count=max(
                                0,
                                job.rows_processed - job.rows_enriched - job.rows_skipped - job.rows_errored,
                            ),
                            error_count=job.rows_errored,
                            spreadsheet_url=job.spreadsheet_url,
                            record_label=record_label,
                        )
                        summary["notification_sent"] = True
                        job.summary = summary
                        await self.db.commit()
                    except Exception as notify_exc:
                        logger.warning(
                            "[ENRICHMENT] Failed to send notification for job %s: %s",
                            job.id,
                            notify_exc,
                        )

        except Exception as e:
            logger.error(f"[ENRICHMENT] Job {job.id} failed: {e}", exc_info=True)
            job.status = EnrichmentJobStatus.FAILED.value
            job.completed_at = datetime.now(timezone.utc)
            job.error_message = str(e)
            job.summary = self._apply_credit_reconciliation_summary(
                summary=job.summary,
                credits_consumed=job.credits_consumed,
            )
            await self.db.commit()
            raise
