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


@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
    errors: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "rows_with_valid_inputs": self.rows_with_valid_inputs,
            "rows_skipped": self.rows_skipped,
            "estimated_credits": self.estimated_credits,
            "providers_available": self.providers_available,
            "byo_provider_connected": self.byo_provider_connected,
            "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

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


@dataclass
class JobCreationResult:
    """Result of job creation."""
    job_id: UUID
    run_id: str
    status: str
    estimated_credits: int
    existing: bool = False

    def to_dict(self) -> Dict[str, Any]:
        return {
            "job_id": str(self.job_id),
            "run_id": self.run_id,
            "status": self.status,
            "estimated_credits": self.estimated_credits,
            "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_skipped: int = 0
    rows_errored: int = 0
    credits_consumed: int = 0
    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_skipped": self.rows_skipped,
            "rows_errored": self.rows_errored,
            "credits_consumed": self.credits_consumed,
        }
        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

    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",
    ) -> 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()
        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()
        if enrichment_type == "contact":
            byo_connected = any(
                p["status"] == "connected" and p["provider"] in {"fullenrich", "apollo", "pdl", "amplemarket"}
                for p in providers
            )
        else:
            byo_connected = any(
                p["status"] == "connected" and p["provider"] in BYO_SUPPORTED_INTEGRATIONS
                for p in providers
            )

        # Estimate credits (1 credit per valid row)
        estimated_credits = valid_count

        return ValidationResult(
            rows_with_valid_inputs=valid_count,
            rows_skipped=skipped_count,
            estimated_credits=estimated_credits,
            providers_available=providers,
            byo_provider_connected=byo_connected,
        )

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

        # Count results
        credits_consumed = 0
        rows_enriched = 0
        rows_skipped = 0

        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 == "contact":
                source_input = input_by_index.get(result.row_index)
                if source_input:
                    if not result_dict.get("first_name"):
                        result_dict["first_name"] = source_input.first_name
                    if not result_dict.get("last_name"):
                        result_dict["last_name"] = source_input.last_name
                    if not result_dict.get("firstname"):
                        result_dict["firstname"] = source_input.firstname
                    if not result_dict.get("lastname"):
                        result_dict["lastname"] = source_input.lastname
                    if not result_dict.get("domain"):
                        result_dict["domain"] = source_input.domain
                    if not result_dict.get("company_name"):
                        result_dict["company_name"] = source_input.company_name
                    if not result_dict.get("email"):
                        result_dict["email"] = source_input.email
                    if not result_dict.get("linkedin_url"):
                        result_dict["linkedin_url"] = source_input.linkedin_url
            else:
                source_input = input_by_index.get(result.row_index)
                if source_input:
                    result_dict["domain"] = source_input.domain
            result_dicts.append(result_dict)

            if result.status == EnrichmentStatus.ENRICHED:
                credits_consumed += 1
                rows_enriched += 1
            elif result.status == EnrichmentStatus.SKIPPED:
                rows_skipped += 1

        return TestResult(
            results=result_dicts,
            credits_consumed=credits_consumed,
            rows_enriched=rows_enriched,
            rows_skipped=rows_skipped,
        )

    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,
        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
            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}"
            )
            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=existing_job.row_count,
                existing=True,
            )

        enrichment_type_value = (enrichment_type or "company").lower()
        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,
        )

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

            if not inputs:
                summary = {
                    "provider_breakdown": {},
                    "top_errors": [],
                    "results": skipped_results,
                }
                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 = {
                    "provider_breakdown": {},
                    "top_errors": [{"error": str(exc)}],
                    "results": skipped_results,
                }
                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,
                "email": str(inp.email).strip() if inp.email else None,
                "linkedin_url": inp.linkedin_url,
            }
            for inp in inputs
        ]

        summary = {
            "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,
        }

        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)

        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_skipped=job.rows_skipped,
            rows_errored=job.rows_errored,
            credits_consumed=job.credits_consumed,
            summary=job.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"),
                "email": row.get("email"),
                "linkedin_url": row.get("linkedin_url"),
            }

        results = skipped_results + list(results_by_index.values())
        rows_enriched = sum(
            1 for r in results_by_index.values() if r.get("status") == EnrichmentStatus.ENRICHED.value
        )
        rows_errored = sum(
            1 for r in results_by_index.values() if r.get("status") == EnrichmentStatus.ERROR.value
        )
        rows_skipped = len(skipped_results)
        rows_processed = rows_skipped + len(results_by_index)
        credits_consumed = rows_enriched

        errors = []
        for r in results_by_index.values():
            if r.get("status") == EnrichmentStatus.ERROR.value and r.get("error_message"):
                errors.append({"row_index": r.get("row_index"), "error": r.get("error_message")})
            if len(errors) >= 5:
                break

        summary.update(
            {
                "provider_breakdown": {"fullenrich": rows_enriched},
                "top_errors": errors,
                "results": results,
            }
        )

        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=rows_processed - rows_enriched - 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)

            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_skipped=job.rows_skipped,
                rows_errored=job.rows_errored,
                credits_consumed=job.credits_consumed,
                summary=job.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)

            # Process results
            credits_consumed = 0
            rows_enriched = 0
            rows_skipped = 0
            rows_errored = 0
            result_data = []
            provider_breakdown = {}
            errors = []
            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:
                        if not result_dict.get("first_name"):
                            result_dict["first_name"] = source_input.first_name
                        if not result_dict.get("last_name"):
                            result_dict["last_name"] = source_input.last_name
                        if not result_dict.get("firstname"):
                            result_dict["firstname"] = source_input.firstname
                        if not result_dict.get("lastname"):
                            result_dict["lastname"] = source_input.lastname
                        if not result_dict.get("domain"):
                            result_dict["domain"] = source_input.domain
                        if not result_dict.get("company_name"):
                            result_dict["company_name"] = source_input.company_name
                        if not result_dict.get("email"):
                            result_dict["email"] = source_input.email
                        if not result_dict.get("linkedin_url"):
                            result_dict["linkedin_url"] = source_input.linkedin_url
                result_data.append(result_dict)

                if result.status == EnrichmentStatus.ENRICHED:
                    credits_consumed += 1
                    rows_enriched += 1
                    # Track provider usage
                    prov = result.provider or "unknown"
                    provider_breakdown[prov] = provider_breakdown.get(prov, 0) + 1
                elif result.status == EnrichmentStatus.SKIPPED:
                    rows_skipped += 1
                elif result.status == EnrichmentStatus.ERROR:
                    rows_errored += 1
                    if result.error_message and len(errors) < 10:
                        errors.append({
                            "row_index": result.row_index,
                            "error": result.error_message,
                        })

            # Build summary
            summary = {
                "provider_breakdown": provider_breakdown,
                "top_errors": errors[:5],
                "results": result_data,  # Store results in summary for client to read
            }

            # 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_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)
            await self.db.commit()
            raise
