from __future__ import annotations

import asyncio
import json
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

import pandas as pd
from fmatch.utils_normalize import strip_formerly_known_as

from ..services.engine_client import EngineClient
from ..services.domain_utils import (
    extract_domains_vectorized,
    extract_domain_from_url_robust,
)
from ..api.intelligence_client import IntelligenceClient
from ..repositories.job_repo import JobRepository
from ..db import get_session
from ..services.cache import HybridCache, make_cache_key
from ..settings import (
    API_SYNC_MAX_RECORDS,
    API_SYNC_MAX_BYTES,
    API_CACHE_TTL_SEC,
)
from ..storage import get_storage_backend
from ..repos.api_repo import ApiRepo


PROFILE_CONFIGS = {
    "leads_to_accounts": {
        "goal": "balanced",  # Intelligence still owns thresholds
        "preset_mappings": [
            {
                "source": "Company",
                "reference": "Name",
                "algorithm": "WRatio",
                "weight": 1.0,
            },
            {
                "source": "Email",
                "reference": "Website",
                "algorithm": "Domain",
                "weight": 1.0,
            },
        ],
    },
}


def _strip_fka_fields(record: Dict[str, Any]) -> Dict[str, Any]:
    """Return a copy with `(FKA …)` style annotations removed from string values."""
    if not record:
        return record
    for key, value in list(record.items()):
        if isinstance(value, str):
            record[key] = strip_formerly_known_as(value)
    return record


def _fingerprint(obj: dict | list | str | None) -> str:
    """Deterministic SHA-256 on normalized JSON; stable across key order."""
    import hashlib

    def _norm(x):
        if x is None:
            return None
        if isinstance(x, dict):
            return {k: _norm(x[k]) for k in sorted(x)}
        if isinstance(x, list):
            return [_norm(i) for i in x]
        if isinstance(x, str):
            return x.strip()
        return x

    data = json.dumps(_norm(obj), separators=(",", ":"), sort_keys=True)
    return hashlib.sha256(data.encode()).hexdigest()


@dataclass
class _ApiCtx:
    api_key_prefix: str
    account_id: str
    tier: str
    qps_limit: int


class PublicMatchService:
    def __init__(self, api_ctx: _ApiCtx):
        self.api_ctx = api_ctx
        self.engine = EngineClient()
        self.intel = IntelligenceClient()
        self.cache = HybridCache(namespace="papi", lru_size=512, ttl=API_CACHE_TTL_SEC)

    def _payload_size(self, body: Dict[str, Any]) -> int:
        try:
            return len(json.dumps(body).encode())
        except Exception:
            return 0

    def _is_sync(self, body: Dict[str, Any]) -> bool:
        # Rule: one source + <=100 candidates OR payload <= 256KB
        cands = body.get("candidates") or []
        # If URLs or storage keys are present, force async
        if (
            body.get("source_url")
            or body.get("reference_url")
            or body.get("source_storage_key")
            or body.get("reference_storage_key")
        ):
            return False
        if (
            body.get("source")
            and isinstance(cands, list)
            and len(cands) <= API_SYNC_MAX_RECORDS
        ):
            return True
        return self._payload_size(body) <= API_SYNC_MAX_BYTES

    async def _policy_for(self, profile: str) -> Dict[str, Any]:
        # Map profile to segment (same string)
        return await self.intel.get_policy(
            account_id=self.api_ctx.account_id, segment=profile
        )

    def _extract_top_reasons(self, row: Dict[str, Any]) -> List[Dict[str, Any]]:
        fsd = (
            row.get("field_score_details")
            or row.get("feature_scores")
            or row.get("field_scores")
            or {}
        )
        try:
            if isinstance(fsd, str):
                fsd = json.loads(fsd)
        except Exception:
            fsd = {}
        # Convert dict into list of {field, score}
        out = []
        if isinstance(fsd, dict):
            for k, v in fsd.items():
                try:
                    out.append({"field": k, "score": float(v)})
                except Exception:
                    continue
        elif isinstance(fsd, list):
            out = fsd[:]
        # Top few
        return sorted(out, key=lambda x: x.get("score", 0.0), reverse=True)[:5]

    def _recommendation(self, score: float, policy: Dict[str, Any]) -> str:
        auto_link = float(policy.get("auto_link", 0.80))
        suggest = float(policy.get("suggest", 0.70))
        if score >= auto_link:
            return "merge"
        if score >= suggest:
            return "review"
        return "no_match"

    async def _sync_match(self, body: Dict[str, Any], profile: str) -> Dict[str, Any]:
        # Cache key includes fingerprints of payload, not raw objects
        policy = await self._policy_for(profile)
        cache_key = make_cache_key(
            "v1:match",
            {
                "acct": self.api_ctx.account_id,
                "profile": profile,
                "policy": {
                    "auto_link": policy.get("auto_link"),
                    "suggest": policy.get("suggest"),
                    "version": policy.get("version", 0),
                },
                "src_hash": _fingerprint(body.get("source")),
                "cand_hash": _fingerprint(body.get("candidates")),
            },
        )
        cached = await self.cache.get(cache_key)
        if cached is not None:
            return cached

        # Build DataFrames
        src = body.get("source") or {}
        cands = body.get("candidates") or []
        df_src = pd.DataFrame([src])
        if not cands:
            # If no candidates provided, we treat as self-match? return no_match
            return {
                "best_match": {},
                "confidence": 0.0,
                "explanations": [],
                "all_scores": [],
                "recommendation": "no_match",
                "decision_id": f"dec_{uuid.uuid4().hex[:16]}",
            }
        df_ref = pd.DataFrame(cands)

        # --- Fix C: Light preprocessing for noisy sheets ---
        def _company_clean(s: pd.Series) -> pd.Series:
            if s is None or s.empty:
                return s
            # Lowercase, strip punctuation and common corp suffixes
            import re

            pat = re.compile(
                r"\b(incorporated|inc|llc|ltd|corp|corporation|co|company|plc|gmbh|sa|s\.a\.|bv|srl|pty)\b\.?",
                re.I,
            )
            return (
                s.astype(str)
                .str.lower()
                .str.replace(r"[^a-z0-9&\s]", "", regex=True)
                .str.replace(pat, "", regex=True)
                .str.replace(r"\s+", " ", regex=True)
                .str.strip()
            )

        def _ensure_domain(df: pd.DataFrame) -> pd.DataFrame:
            if df is None or df.empty:
                return df
            cols = {c.lower(): c for c in df.columns}
            # Try Domain column
            dom_col = cols.get("domain")
            if not dom_col:
                # Prefer website-like columns
                web = (
                    cols.get("website")
                    or cols.get("dhc provider website")
                    or cols.get("domain_append")
                )
                if web:
                    df["Domain"] = df[web].apply(extract_domain_from_url_robust)
                else:
                    # Fallback to email to derive domain
                    email_col = cols.get("email") or cols.get("work_email")
                    if email_col:
                        df["Domain"] = extract_domains_vectorized(
                            df[email_col], source_type="email"
                        )
            else:
                # Normalize existing Domain column via robust extractor to get registrable root
                df["Domain"] = df[dom_col].apply(extract_domain_from_url_robust)
            return df

        # Prepare frames
        df_src = _ensure_domain(df_src)
        df_ref = _ensure_domain(df_ref)
        # Add cleaned names (company/name) for gentle improvements
        if "Company" in df_src.columns:
            df_src["Company_clean"] = _company_clean(df_src["Company"])
        if "Name" in df_ref.columns:
            df_ref["Name_clean"] = _company_clean(df_ref["Name"])

        # Ask Intelligence to auto-configure with profile goal (if preset)
        cfg_goal = (PROFILE_CONFIGS.get(profile, {}) or {}).get("goal", profile)
        cfg = await self.intel.auto_configure(df_src, df_ref, goal=cfg_goal)
        preset = (PROFILE_CONFIGS.get(profile, {}) or {}).get("preset_mappings", [])
        auto_maps = cfg.get("field_mappings") or cfg.get("mappings") or []

        # Merge preset mappings (first) with auto maps, de-duping by (source, reference, algorithm)
        def _key(m):
            return (
                (m.get("source") or m.get("source_field") or "").lower(),
                (
                    m.get("reference") or m.get("ref") or m.get("reference_field") or ""
                ).lower(),
                (m.get("algorithm") or "").lower(),
            )

        seen = set()
        merged = []
        for m in preset + auto_maps:
            k = _key(m)
            if k in seen:
                continue
            seen.add(k)
            merged.append(m)
        mappings = merged
        # Add supplemental mappings for normalization/geo tie-breaks if available
        try:
            # Domain-to-Domain with Domain algorithm if both have Domain
            if "Domain" in df_src.columns and "Domain" in df_ref.columns:
                if not any(
                    (m.get("source", ""), m.get("reference", ""))
                    == ("Domain", "Domain")
                    for m in mappings
                ):
                    mappings.append(
                        {
                            "source": "Domain",
                            "reference": "Domain",
                            "algorithm": "Domain",
                            "weight": 1.0,
                        }
                    )
            # Company clean names
            if "Company_clean" in df_src.columns and "Name_clean" in df_ref.columns:
                mappings.append(
                    {
                        "source": "Company_clean",
                        "reference": "Name_clean",
                        "algorithm": "WRatio",
                        "weight": 0.2,
                    }
                )
            # Optional geo tie-breaks
            for geo_col in ("City", "State"):
                if geo_col in df_src.columns and geo_col in df_ref.columns:
                    mappings.append(
                        {
                            "source": geo_col,
                            "reference": geo_col,
                            "algorithm": "Exact",
                            "weight": 0.05,
                        }
                    )
        except Exception:
            pass

        # Fix B: lower threshold cap to ~0.65 and return top-K (post-process)
        suggest = float(policy.get("suggest", 0.70))
        threshold = min(suggest, 0.65)

        # Fix A/D: Enable blocking when policy or config signals it; prefer Domain blocking
        policy_block = bool((policy.get("blocking") or {}).get("enabled", False))
        cfg_block = bool(((cfg or {}).get("blocking") or {}).get("enabled", False))
        apply_blocking = policy_block or cfg_block
        blocking_strategy = None
        if apply_blocking and (
            "Domain" in df_src.columns and "Domain" in df_ref.columns
        ):
            blocking_strategy = {
                "source_column": "Domain",
                "reference_column": "Domain",
                "key_length": 3,
            }

        # Run engine
        res = await self.engine.match_records(
            df_src,
            df_ref,
            mappings=mappings,
            threshold=threshold,
            blocking_strategy=blocking_strategy,
        )

        if res.matches is None or len(res.matches) == 0:
            out = {
                "best_match": {},
                "confidence": 0.0,
                "explanations": [],
                "all_scores": [],
                "recommendation": "no_match",
                "decision_id": f"dec_{uuid.uuid4().hex[:16]}",
            }
            await self.cache.set(cache_key, out)
            return out

        # Convert top row
        ranked = res.matches.sort_values(by=["score"], ascending=False)
        top = ranked.iloc[0]
        score = float(top.get("score") or top.get("match_score") or 0.0)
        row_dict = top.to_dict()
        # Keep original record
        best_match = {k: row_dict.get(k) for k in df_ref.columns if k in row_dict}
        best_match = _strip_fka_fields(best_match)
        reasons = self._extract_top_reasons(row_dict)
        all_scores = [
            float(s)
            for s in (res.matches.get("score") or res.matches.get("match_score") or [])
        ]

        # Prepare top-K candidates (up to 3)
        top_k = []
        try:
            for _, rr in ranked.head(3).iterrows():
                rdict = rr.to_dict()
                match_dict = {k: rdict.get(k) for k in df_ref.columns if k in rdict}
                match_dict = _strip_fka_fields(match_dict)
                top_k.append(
                    {
                        "match": match_dict,
                        "score": float(
                            rdict.get("score") or rdict.get("match_score") or 0.0
                        ),
                    }
                )
        except Exception:
            top_k = []

        out = {
            "best_match": best_match,
            "confidence": score,
            "explanations": reasons,
            "all_scores": all_scores,
            "recommendation": self._recommendation(score, policy),
            "decision_id": f"dec_{uuid.uuid4().hex[:16]}",
            "top_candidates": top_k,
        }

        await self.cache.set(cache_key, out)
        return out

    async def _async_match(
        self, body: Dict[str, Any], profile: str, idem_key: Optional[str]
    ) -> Dict[str, Any]:
        # Persist artifacts (JSON payload or URLs provided)
        storage = get_storage_backend()
        artifact_key = f"api_jobs/{self.api_ctx.account_id}/{uuid.uuid4().hex}.json"
        # Serialize raw body for worker consumption
        payload = {
            "profile": profile,
            "body": body,
            "account_id": self.api_ctx.account_id,
        }

        class _MemFile:
            def __init__(self, data: bytes):
                self._data = data

            async def read(self) -> bytes:
                return self._data

        await storage.save(
            file=_MemFile(json.dumps(payload).encode()), key=artifact_key
        )

        # Create job (include resolved goal for worker continuity)
        job_id = f"api_{uuid.uuid4().hex[:22]}"

        async for session in get_session():
            repo = JobRepository(session)
            await repo.create_job(
                job_id=job_id,
                project_id=self.api_ctx.account_id,
                account_id=self.api_ctx.account_id,
                status="queued",
                mode="match",
                config={
                    "artifact_key": artifact_key,
                    "profile": profile,
                    "goal": (PROFILE_CONFIGS.get(profile, {}) or {}).get(
                        "goal", profile
                    ),
                    "idem_key": idem_key,
                    "api_key_prefix": self.api_ctx.api_key_prefix,
                },
            )

        # Kick worker (fire-and-forget)
        try:
            from ..workers.api_match_worker import ApiMatchWorker
        except Exception:
            # Worker not available - still accept, caller will poll and see no progress
            pass
        else:

            async def _run():
                async for session in get_session():
                    w = ApiMatchWorker(session)
                    await w.run_job(
                        job_id=job_id,
                        account_id=self.api_ctx.account_id,
                        profile=profile,
                        options=body.get("options") or {},
                    )

            asyncio.create_task(_run())

        return {
            "status": "processing",
            "job_id": job_id,
            "poll_url": f"/api/v1/jobs/{job_id}",
        }

    async def route_match(
        self, body_model: Any, idem_key: Optional[str]
    ) -> Dict[str, Any]:
        # Model -> dict for size detection/caching
        body = (
            json.loads(body_model.model_dump_json())
            if hasattr(body_model, "model_dump_json")
            else (
                body_model.dict() if hasattr(body_model, "dict") else dict(body_model)
            )
        )
        profile = (body.get("profile") or "balanced").strip()

        # Decide path
        if self._is_sync(body):
            result = await self._sync_match(body, profile)
            # Meter usage
            async for session in get_session():
                repo = ApiRepo(session)
                try:
                    size = self._payload_size(body)
                    cands = body.get("candidates") or []
                    await repo.bump_usage(
                        self.api_ctx.api_key_prefix,
                        calls=1,
                        matches=len(cands) or 1,
                        bytes_count=size,
                    )
                except Exception:
                    pass
            return result
        else:
            return await self._async_match(body, profile, idem_key)
