"""Bulk SFDC Account matcher using DuckDB cache + hybrid scoring."""

from __future__ import annotations

import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
from uuid import uuid4

import pandas as pd

from ..db import AsyncSessionLocal
from ..models import Job, JobStatus
from ..operators.match_salesforce_account import (
    ScoringBackend,
    _compute_match_score,
    _normalize_company_name,
    _normalize_domain,
)
from ..services.engine_client import EngineClient
from ..services.salesforce_gateway import SalesforceGateway
from .sfdc_account_cache import SfdcAccountCache

logger = logging.getLogger(__name__)

STATE_KEY = "sfdc_bulk:job:{job_id}:state"
PROGRESS_KEY = "sfdc_bulk:job:{job_id}:progress"
RESULT_KEY = "sfdc_bulk:job:{job_id}:result"
IDEMPOTENCY_KEY = "sfdc_bulk:{account_id}:{key}"

RESULT_TTL = 86400  # 24h
AMBIGUITY_DELTA = 5
ENGINE_BATCH_SIZE = 200


@dataclass
class BulkMatchStats:
    cache_queries: int = 0
    cache_hits: int = 0
    matched: int = 0
    ambiguous: int = 0
    unmatched: int = 0

    @property
    def cache_hit_rate(self) -> float:
        if not self.cache_queries:
            return 0.0
        return round(self.cache_hits / self.cache_queries, 4)


class SfdcBulkMatcher:
    """CPU-bound matcher orchestrating cache queries + scoring."""

    def __init__(
        self,
        cache: SfdcAccountCache,
        scoring_backend: ScoringBackend = ScoringBackend.LOCAL,
    ) -> None:
        self.cache = cache
        self.scoring_backend = scoring_backend

    async def match_batch(
        self,
        rows: List[Dict[str, Any]],
        threshold: int = 80,
        progress_callback: Optional[Callable[[int, str], Any]] = None,
        scoring_backend: Optional[ScoringBackend] = None,
    ) -> Tuple[List[Dict[str, Any]], BulkMatchStats]:
        scoring_backend = scoring_backend or self.scoring_backend
        stats = BulkMatchStats()
        results: List[Dict[str, Any]] = []

        total = len(rows) or 1
        for idx, row in enumerate(rows):
            stats.cache_queries += 1
            match_result, cache_hit = await self._match_single(
                row, threshold, scoring_backend
            )
            if cache_hit:
                stats.cache_hits += 1

            if match_result.get("status") == "matched":
                stats.matched += 1
            elif match_result.get("status") == "ambiguous":
                stats.ambiguous += 1
            else:
                stats.unmatched += 1

            results.append(match_result)

            if progress_callback and (idx + 1) % 100 == 0:
                pct = int(((idx + 1) / total) * 100)
                await self._emit_progress(progress_callback, pct, "matching")

        await self._emit_progress(progress_callback, 100, "matching")
        return results, stats

    async def _match_single(
        self,
        row: Dict[str, Any],
        threshold: int,
        scoring_backend: ScoringBackend,
    ) -> Tuple[Dict[str, Any], bool]:
        cache_hit = False
        company_name = row.get("company") or row.get("company_name") or ""
        domain = row.get("domain") or row.get("website")
        normalized_name = _normalize_company_name(company_name)
        normalized_domain = _normalize_domain(domain) if domain else ""
        row_id = str(row.get("row_id") or row.get("id") or row.get("index") or "")

        result: Dict[str, Any] = {
            "row_id": row_id,
            "company_name": company_name,
            "domain": domain,
            "account_id": None,
            "account_name": None,
            "match_score": 0,
            "match_method": None,
            "status": "unmatched",
            "candidates": [],
        }

        if normalized_domain:
            domain_match = await asyncio.to_thread(
                self.cache.query_by_domain, normalized_domain
            )
            if domain_match:
                cache_hit = True
                result.update(
                    {
                        "account_id": domain_match.get("account_id"),
                        "account_name": domain_match.get("account_name"),
                        "match_score": 100,
                        "match_method": "domain",
                        "status": "matched",
                    }
                )
                return result, cache_hit

        if not normalized_name:
            result["status"] = "unmatched"
            result["reason"] = "No searchable name or domain"
            return result, cache_hit

        candidates = await asyncio.to_thread(
            self.cache.query_by_name_prefix, normalized_name
        )
        if candidates:
            cache_hit = True
        if not candidates:
            result["reason"] = "No Salesforce candidates"
            return result, cache_hit

        scored = await score_candidates(
            company_name, normalized_domain, candidates, scoring_backend, row_id
        )
        if not scored:
            result["reason"] = "No scored candidates"
            return result, cache_hit

        scored.sort(key=lambda x: x.get("score", 0), reverse=True)
        best = scored[0]
        second_best = scored[1] if len(scored) > 1 else None

        if best.get("score", 0) >= threshold:
            if second_best and abs(best["score"] - second_best.get("score", 0)) <= AMBIGUITY_DELTA:
                result.update(
                    {
                        "status": "ambiguous",
                        "match_score": best.get("score", 0),
                        "candidates": scored[:5],
                        "match_method": "name_fuzzy",
                    }
                )
            else:
                result.update(
                    {
                        "status": "matched",
                        "account_id": best.get("account_id") or best.get("Id"),
                        "account_name": best.get("account_name") or best.get("Name"),
                        "match_score": best.get("score", 0),
                        "match_method": "name_exact"
                        if best.get("score", 0) == 100
                        else "name_fuzzy",
                    }
                )
        else:
            result.update(
                {
                    "status": "unmatched",
                    "match_score": best.get("score", 0),
                    "reason": f"Best score {best.get('score', 0)} below threshold {threshold}",
                }
            )
        return result, cache_hit

    async def _emit_progress(
        self,
        cb: Optional[Callable[[int, str], Any]],
        pct: int,
        phase: str,
    ) -> None:
        if not cb:
            return
        try:
            res = cb(pct, phase)
            if asyncio.iscoroutine(res):
                await res
        except Exception:
            logger.debug("Bulk matcher progress callback failed", exc_info=True)


async def score_candidates(
    input_name: str,
    input_domain: Optional[str],
    candidates: List[Dict[str, Any]],
    scoring_backend: ScoringBackend,
    account_id: str,
) -> List[Dict[str, Any]]:
    """Hybrid scorer: Engine primary (if available) with rapidfuzz fallback."""
    if scoring_backend == ScoringBackend.ENGINE:
        try:
            if len(candidates) > ENGINE_BATCH_SIZE:
                candidates = candidates[:ENGINE_BATCH_SIZE]
            names = []
            ids = []
            for cand in candidates:
                names.append(cand.get("account_name") or cand.get("Name") or "")
                ids.append(cand.get("account_id") or cand.get("Id"))
            engine = EngineClient()
            source_df = pd.DataFrame([{"row_id": "row", "name": input_name}])
            ref_df = pd.DataFrame(
                {"Id": ids, "Name": names, "account_id": ids, "account_name": names}
            )
            mappings = [{"source": "name", "reference": "Name", "algorithm": "TokenSortRatio", "weight": 1.0}]
            engine_res = await engine.match_records(
                source_df,
                ref_df,
                mappings=mappings,
                threshold=0.0,
                source_id_column="row_id",
                reference_id_column="Id",
                blocking_strategy={"apply_blocking": False},
            )
            scored = []
            for match in engine_res.matches or []:
                scored.append(
                    {
                        "Id": match.get("reference_id") or match.get("target_id"),
                        "account_id": match.get("reference_id") or match.get("target_id"),
                        "account_name": match.get("reference_name") or match.get("target_name"),
                        "score": int((match.get("score") or 0) * 100),
                    }
                )
            if scored:
                return scored
        except Exception as exc:  # noqa: BLE001
            logger.debug("Engine scoring failed, falling back to rapidfuzz: %s", exc)

    scored_local: List[Dict[str, Any]] = []
    normalized_input = _normalize_company_name(input_name)
    for cand in candidates:
        name = cand.get("account_name") or cand.get("Name") or ""
        score = _compute_match_score(input_name, name, normalized_input)
        scored_local.append(
            {
                **cand,
                "account_id": cand.get("account_id") or cand.get("Id"),
                "account_name": name,
                "score": score,
            }
        )
    scored_local.sort(key=lambda x: x.get("score", 0), reverse=True)
    return scored_local


class SfdcBulkMatchWorker:
    """End-to-end bulk matcher with cache prefetch + Redis job tracking."""

    def __init__(self, redis=None, db_factory=AsyncSessionLocal) -> None:
        self.redis = redis
        self.db_factory = db_factory

    async def start_job(
        self,
        *,
        account_id: str,
        integration_id: str,
        gateway: SalesforceGateway,
        rows: List[Dict[str, Any]],
        threshold: int = 80,
        scoring_backend: ScoringBackend = ScoringBackend.LOCAL,
        idempotency_key: Optional[str] = None,
        job_record_id: Optional[str] = None,
    ) -> Tuple[str, bool]:
        """Start bulk match job. Returns (job_id, started_new_job)."""
        job_id = job_record_id or str(uuid4())
        if self.redis and idempotency_key:
            idem_cache_key = IDEMPOTENCY_KEY.format(
                account_id=account_id, key=idempotency_key
            )
            acquired = await self.redis.set(
                idem_cache_key,
                job_id,
                nx=True,
                ex=RESULT_TTL,
            )
            if not acquired:
                existing = await self.redis.get(idem_cache_key)
                if existing:
                    job_id_existing = (
                        existing.decode("utf-8")
                        if hasattr(existing, "decode")
                        else str(existing)
                    )
                    return job_id_existing, False
                acquired = await self.redis.set(
                    idem_cache_key,
                    job_id,
                    nx=True,
                    ex=RESULT_TTL,
                )
                if not acquired:
                    existing_retry = await self.redis.get(idem_cache_key)
                    if existing_retry:
                        job_id_existing = (
                            existing_retry.decode("utf-8")
                            if hasattr(existing_retry, "decode")
                            else str(existing_retry)
                        )
                        return job_id_existing, False
                    return job_id, False

        if self.redis:
            await self.redis.set(STATE_KEY.format(job_id=job_id), "queued", ex=RESULT_TTL)
            await self._set_progress(job_id, 0, "queued")

        asyncio.create_task(
            self._run_job(
                job_id=job_id,
                account_id=account_id,
                integration_id=integration_id,
                gateway=gateway,
                rows=rows,
                threshold=threshold,
                scoring_backend=scoring_backend,
                job_record_id=job_record_id,
            )
        )
        return job_id, True

    async def _run_job(
        self,
        job_id: str,
        account_id: str,
        integration_id: str,
        gateway: SalesforceGateway,
        rows: List[Dict[str, Any]],
        threshold: int,
        scoring_backend: ScoringBackend,
        job_record_id: Optional[str],
    ) -> None:
        start = time.perf_counter()
        await self._set_state(job_id, "processing")
        await self._update_job_record(job_record_id, JobStatus.PROCESSING, None, None)
        cache = SfdcAccountCache(
            account_id=account_id, integration_id=integration_id, redis=self.redis
        )

        async def _cache_progress(pct: int, phase: str) -> None:
            await self._set_progress(job_id, int(pct * 0.5), phase or "prefetching")

        try:
            await cache.get_or_prefetch(
                gateway, force_refresh=False, progress_callback=_cache_progress
            )
        except Exception as exc:  # noqa: BLE001
            await self._fail(job_id, f"prefetch_failed: {exc}", job_record_id)
            return

        await self._set_progress(job_id, 55, "matching")
        matcher = SfdcBulkMatcher(cache=cache, scoring_backend=scoring_backend)

        async def _match_progress(pct: int, phase: str) -> None:
            scaled = 55 + int(pct * 0.4 / 100 * 100)
            await self._set_progress(job_id, min(scaled, 95), phase or "matching")

        try:
            match_results, stats = await matcher.match_batch(
                rows, threshold=threshold, progress_callback=_match_progress
            )
        except Exception as exc:  # noqa: BLE001
            await self._fail(job_id, f"match_failed: {exc}", job_record_id)
            return

        elapsed_ms = int((time.perf_counter() - start) * 1000)
        payload = {
            "job_id": job_id,
            "total_rows": len(rows),
            "matched_rows": stats.matched,
            "unmatched_rows": stats.unmatched,
            "ambiguous_rows": stats.ambiguous,
            "cache_hit_rate": stats.cache_hit_rate,
            "processing_time_ms": elapsed_ms,
            "results": match_results,
        }

        await self._set_result(job_id, payload)
        await self._set_progress(job_id, 100, "done")
        await self._set_state(job_id, "completed")
        await self._update_job_record(job_record_id, JobStatus.COMPLETED, payload, None)

    # ─────────────── Redis helpers ───────────────
    async def _set_state(self, job_id: str, state: str) -> None:
        if not self.redis:
            return
        await self.redis.set(STATE_KEY.format(job_id=job_id), state, ex=RESULT_TTL)

    async def _set_progress(self, job_id: str, pct: int, phase: str) -> None:
        if not self.redis:
            return
        payload = json.dumps({"percent": pct, "phase": phase})
        await self.redis.set(PROGRESS_KEY.format(job_id=job_id), payload, ex=RESULT_TTL)

    async def _set_result(self, job_id: str, payload: Dict[str, Any]) -> None:
        if not self.redis:
            return
        await self.redis.set(
            RESULT_KEY.format(job_id=job_id),
            json.dumps(payload),
            ex=RESULT_TTL,
        )

    async def _fail(
        self, job_id: str, message: str, job_record_id: Optional[str] = None
    ) -> None:
        await self._set_state(job_id, "failed")
        await self._set_result(job_id, {"error": message})
        await self._set_progress(job_id, 100, "failed")
        await self._update_job_record(job_record_id, JobStatus.FAILED, None, message)

    async def _update_job_record(
        self,
        job_id: Optional[str],
        status: JobStatus,
        results: Optional[Dict[str, Any]],
        error: Optional[str],
    ) -> None:
        if not job_id or not self.db_factory:
            return
        try:
            async with self.db_factory() as session:  # type: AsyncSession
                rec = await session.get(Job, job_id)
                if not rec:
                    return
                rec.status = status
                if results:
                    rec.results = results
                if error:
                    rec.error = error
                await session.commit()
        except Exception:
            logger.debug("Failed to update Job %s", job_id, exc_info=True)
