"""Scraper service for BYO web scraping providers."""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from uuid import UUID

import json
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from .. import settings
from ..api.progress import get_redis
from ..models import ScraperJob, ScraperJobStatus
from .scraper_blob_storage import ScraperBlobStorage
from .scraper_provider import (
    SCRAPER_PROVIDER_REGISTRY,
    ensure_default_scraper_providers_registered,
    get_provider_credential_keys,
    provider_display_name,
)
from .secret_storage import get_saas_secret_backend
from .sheets_writer import SheetsWriter

logger = logging.getLogger(__name__)


@dataclass
class ValidationResult:
    rows_with_valid_inputs: int
    rows_skipped: int
    estimated_credits: int
    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,
            "byo_provider_connected": self.byo_provider_connected,
            "errors": self.errors,
        }


@dataclass
class TestResult:
    results: List[Dict[str, Any]]
    rows_scraped: int
    rows_skipped: int
    rows_errored: int

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


@dataclass
class JobCreationResult:
    job_id: UUID
    status: str
    estimated_credits: int
    existing: bool = False

    def to_dict(self) -> Dict[str, Any]:
        return {
            "job_id": str(self.job_id),
            "status": self.status,
            "estimated_credits": self.estimated_credits,
            "existing": self.existing,
        }


@dataclass
class JobStatus:
    job_id: UUID
    status: str
    progress: int
    rows_processed: int
    rows_total: int
    rows_scraped: int
    rows_skipped: int
    rows_errored: int
    error_message: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        data = {
            "job_id": str(self.job_id),
            "status": self.status,
            "progress": self.progress,
            "rows_processed": self.rows_processed,
            "rows_total": self.rows_total,
            "rows_scraped": self.rows_scraped,
            "rows_skipped": self.rows_skipped,
            "rows_errored": self.rows_errored,
        }
        if self.error_message:
            data["error_message"] = self.error_message
        return data


class ScraperService:
    def __init__(self, db: AsyncSession, workspace_id: str):
        self.db = db
        self.workspace_id = workspace_id
        self.blob_storage = ScraperBlobStorage()
        ensure_default_scraper_providers_registered()

    async def list_tools(self) -> List[Dict[str, Any]]:
        return [tool.to_api_dict() for tool in SCRAPER_PROVIDER_REGISTRY.list_all_tools()]

    async def list_actors(self) -> List[Dict[str, Any]]:
        return await self.list_tools()

    async def _get_provider_credentials(self, provider: str) -> Dict[str, str]:
        backend = get_saas_secret_backend()
        credentials: Dict[str, str] = {}
        for key_name in get_provider_credential_keys(provider):
            secret = await backend.get_secret(
                db=self.db,
                integration=provider,
                key=key_name,
                workspace_id=self.workspace_id,
            )
            if secret is not None:
                credentials[key_name] = secret.value
        return credentials

    def get_tool(self, tool_id: str):
        return SCRAPER_PROVIDER_REGISTRY.get_provider_for_tool(tool_id).get_tool(tool_id)

    async def validate_inputs(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        actor_type: str,
    ) -> ValidationResult:
        tool = self.get_tool(actor_type)
        provider_name = tool.provider
        provider_credentials = await self._get_provider_credentials(provider_name)
        if not provider_credentials:
            return ValidationResult(
                rows_with_valid_inputs=0,
                rows_skipped=len(rows),
                estimated_credits=0,
                byo_provider_connected=False,
                errors=[f"{provider_display_name(provider_name)} not connected"],
            )
        provider = SCRAPER_PROVIDER_REGISTRY.get_provider(provider_name, **provider_credentials)
        counts = provider.validate_rows(rows, input_mapping, actor_type)
        return ValidationResult(
            rows_with_valid_inputs=counts["rows_with_valid_inputs"],
            rows_skipped=counts["rows_skipped"],
            estimated_credits=counts["estimated_credits"],
            byo_provider_connected=True,
        )

    async def run_test(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        output_fields: List[str],
        actor_type: str,
        max_rows: int = 5,
    ) -> TestResult:
        tool = self.get_tool(actor_type)
        provider_name = tool.provider
        provider_credentials = await self._get_provider_credentials(provider_name)
        if not provider_credentials:
            raise ValueError(f"{provider_display_name(provider_name)} not connected")
        provider = SCRAPER_PROVIDER_REGISTRY.get_provider(provider_name, **provider_credentials)
        output_fields = self._resolve_output_fields(tool, output_fields)
        results = provider.run_test(
            rows=rows,
            input_mapping=input_mapping,
            output_fields=output_fields,
            tool_id=actor_type,
            max_rows=max_rows,
        )
        rows_scraped = sum(1 for r in results if r.get("status") == "scraped")
        rows_skipped = sum(1 for r in results if r.get("status") == "skipped")
        rows_errored = sum(1 for r in results if r.get("status") == "error")
        return TestResult(
            results=results,
            rows_scraped=rows_scraped,
            rows_skipped=rows_skipped,
            rows_errored=rows_errored,
        )

    @staticmethod
    def _resolve_output_fields(tool: Any, output_fields: List[str]) -> List[str]:
        if output_fields:
            valid = {field.key for field in tool.output_schema}
            invalid = [field for field in output_fields if field not in valid]
            if invalid:
                raise ValueError(f"Invalid output fields: {', '.join(invalid)}")
            return output_fields
        return [field.key for field in tool.output_schema if field.default]

    async def create_job(
        self,
        rows: List[Dict[str, Any]],
        actor_type: str,
        input_mapping: Dict[str, str],
        output_fields: List[str],
        run_id: str,
        created_by: Optional[str] = None,
        notify_email: Optional[str] = None,
        sheet_id: Optional[str] = None,
        sheet_name: Optional[str] = None,
        output_labels: Optional[Dict[str, str]] = None,
        output_column_prefix: Optional[str] = None,
        background_delivery: bool = False,
    ) -> JobCreationResult:
        if len(rows) > settings.SCRAPER_MAX_ROWS:
            raise ValueError(
                f"Row limit exceeded: max {settings.SCRAPER_MAX_ROWS} rows per request, "
                f"received {len(rows)}"
            )

        existing = await self.db.execute(
            select(ScraperJob).where(
                ScraperJob.workspace_id == self.workspace_id,
                ScraperJob.run_id == run_id,
            )
        )
        existing_job = existing.scalar_one_or_none()
        if existing_job:
            if background_delivery and sheet_id and sheet_name:
                try:
                    await self._persist_job_meta(
                        existing_job.id,
                        sheet_id=sheet_id,
                        sheet_name=sheet_name,
                        output_labels=output_labels,
                        output_column_prefix=output_column_prefix,
                    )
                except Exception as exc:
                    logger.warning(
                        "[SCRAPER] Failed to persist meta for existing job %s: %s",
                        existing_job.id,
                        exc,
                    )
            return JobCreationResult(
                job_id=existing_job.id,
                status=existing_job.status,
                estimated_credits=existing_job.rows_total,
                existing=True,
            )

        tool = self.get_tool(actor_type)
        provider_name = tool.provider
        provider_credentials = await self._get_provider_credentials(provider_name)
        if not provider_credentials:
            raise ValueError(f"{provider_display_name(provider_name)} not connected")
        output_fields = self._resolve_output_fields(tool, output_fields)
        expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.SCRAPER_DATA_TTL_SECONDS)

        job = ScraperJob(
            workspace_id=self.workspace_id,
            run_id=run_id,
            status=ScraperJobStatus.QUEUED.value,
            provider=provider_name,
            actor_type=tool.id,
            actor_id=tool.provider_tool_id,
            input_mapping=input_mapping,
            output_fields=output_fields,
            rows_total=len(rows),
            rows_processed=0,
            rows_scraped=0,
            rows_skipped=0,
            rows_errored=0,
            cursor=0,
            notify_email=notify_email,
            sheet_id=sheet_id,
            sheet_name=sheet_name,
            expires_at=expires_at,
        )
        self.db.add(job)
        await self.db.flush()

        try:
            inputs_uri = await self.blob_storage.store_inputs(self.workspace_id, job.id, rows)
            results_uri = await self.blob_storage.store_results(self.workspace_id, job.id, [])
            job.inputs_uri = inputs_uri
            job.results_uri = results_uri
            await self.db.commit()
        except Exception:
            await self.db.rollback()
            try:
                await self.blob_storage.delete_blobs(self.workspace_id, job.id)
            except Exception as cleanup_exc:
                logger.warning(
                    "[SCRAPER] Failed to clean up blobs for aborted job %s: %s",
                    job.id,
                    cleanup_exc,
                )
            raise

        await self.db.refresh(job)

        if background_delivery and sheet_id and sheet_name:
            try:
                await self._persist_job_meta(
                    job.id,
                    sheet_id=sheet_id,
                    sheet_name=sheet_name,
                    output_labels=output_labels,
                    output_column_prefix=output_column_prefix,
                )
            except Exception as exc:
                logger.warning(
                    "[SCRAPER] Failed to persist meta for job %s: %s",
                    job.id,
                    exc,
                )

        await self._enqueue_background_job(job.id)

        return JobCreationResult(
            job_id=job.id,
            status=job.status,
            estimated_credits=len(rows),
            existing=False,
        )

    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_scraper_job",
                str(job_id),
                job_timeout=settings.JOB_TIMEOUT_DEFAULT,
            )

        await run_sync(_do_enqueue)

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

    async def _store_scheduled_results(
        self,
        job: ScraperJob,
        results: List[Dict[str, Any]],
    ) -> None:
        if not results:
            return
        redis_conn = await get_redis()
        if not redis_conn:
            return
        meta_key = f"byo_scraper_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: List[Dict[str, Any]] = []
        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 = {
            "actor_type": job.actor_type,
            "input_mapping": job.input_mapping or {},
            "output_fields": job.output_fields or [],
            "output_labels": meta.get("output_labels") or {},
            "output_column_prefix": meta.get("output_column_prefix") or "",
            "sheet_name": sheet_name,
        }

        writer = SheetsWriter(sheet_id=sheet_id, account_id=str(self.workspace_id), api_key="")
        await writer.store_scraper_results(
            sheet_name=sheet_name,
            results=cleaned,
            config=config,
            metadata={"job_id": str(job.id)},
        )

    async def get_job_status(self, job_id: UUID) -> Optional[JobStatus]:
        result = await self.db.execute(
            select(ScraperJob).where(
                ScraperJob.id == job_id,
                ScraperJob.workspace_id == self.workspace_id,
            )
        )
        job = result.scalar_one_or_none()
        if not job:
            return None

        progress = 0
        if job.rows_total > 0:
            progress = int((job.rows_processed / job.rows_total) * 100)

        return JobStatus(
            job_id=job.id,
            status=job.status,
            progress=progress,
            rows_processed=job.rows_processed,
            rows_total=job.rows_total,
            rows_scraped=job.rows_scraped,
            rows_skipped=job.rows_skipped,
            rows_errored=job.rows_errored,
            error_message=job.error_message,
        )

    async def get_results_chunk(self, job_id: UUID, cursor: int, limit: int) -> Dict[str, Any]:
        return await self.blob_storage.get_results_chunk(self.workspace_id, job_id, cursor, limit)

    async def cancel_job(self, job_id: UUID) -> bool:
        job = await self.db.get(ScraperJob, job_id)
        if not job or str(job.workspace_id) != str(self.workspace_id):
            return False
        if job.status in (ScraperJobStatus.COMPLETED.value, ScraperJobStatus.FAILED.value):
            return False
        job.status = ScraperJobStatus.CANCELLED.value
        job.completed_at = datetime.now(timezone.utc)
        await self.db.commit()
        try:
            await self.blob_storage.delete_blobs(self.workspace_id, job_id)
        except Exception as exc:
            logger.warning("[SCRAPER] Failed to delete blobs for %s: %s", job_id, exc)
        return True
