from __future__ import annotations

import asyncio
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple

import pandas as pd

logger = logging.getLogger(__name__)


class MatchMode(Enum):
    """Match operation modes."""

    MATCH = "match"  # Source -> Reference lookup
    DEDUP = "dedupe"  # Self-dedup within single dataset
    MERGE = "merge"  # Find duplicates for merge


@dataclass
class MatchConfig:
    """Resolved match configuration."""

    mappings: List[Dict[str, Any]]
    threshold: float = 0.8
    blocking_strategy: Optional[Dict[str, Any]] = None
    processing_mode: str = "speed"  # "speed" or "scale"
    source_id_column: str = "__source_id"
    reference_id_column: str = "__reference_id"

    # AI metadata
    ai_generated: bool = False
    confidence: float = 0.0
    warnings: List[str] = field(default_factory=list)


@dataclass
class MatchResult:
    """Normalized match result."""

    matches: pd.DataFrame
    stats: Dict[str, Any]
    config_used: MatchConfig
    elapsed_ms: int = 0


class MatchGateway:
    """
    The ONLY way to match records.
    All features must use this gateway - no exceptions.
    """

    _instance: Optional["MatchGateway"] = None
    _lock = asyncio.Lock()

    # B2B Column Detection Patterns
    DOMAIN_TOKENS = ("domain", "website", "web", "url")
    EMAIL_TOKENS = ("email", "e-mail", "email_address", "work_email", "business_email")
    COMPANY_TOKENS = ("company", "company_name", "account", "account_name", "organization", "org", "name")

    # Thresholds
    LOCAL_MAX_ROWS = 500
    SCALE_MODE_THRESHOLD = 5000

    @classmethod
    async def create(cls, base_url: Optional[str] = None) -> "MatchGateway":
        """Factory method for getting the gateway instance."""
        async with cls._lock:
            if cls._instance is None:
                cls._instance = cls(base_url)
            elif base_url and not cls._instance._base_url:
                cls._instance._base_url = base_url
            return cls._instance

    def __init__(self, base_url: Optional[str] = None):
        self._base_url = base_url
        self._engine = None
        self._intel = None
        self._last_used = datetime.now(timezone.utc)

        # Metrics
        self._match_count = 0
        self._total_rows_processed = 0
        self._ai_config_hits = 0
        self._ai_config_misses = 0

    # ========== Public API ==========

    async def match(
        self,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        *,
        source_col: Optional[str] = None,
        target_col: Optional[str] = None,
        threshold: float = 0.8,
        mappings: Optional[List[Dict[str, Any]]] = None,
    ) -> MatchResult:
        """
        Match source records against reference records.

        This is the PRIMARY entry point for all matching operations.
        Handles:
        - AI-powered config generation (with heuristic fallback)
        - B2B-aware blocking strategy resolution
        - Engine orchestration (with reference indexing for large datasets)
        - Score normalization (0-1 scale)
        - Result enrichment
        """
        start = datetime.now(timezone.utc)
        self._last_used = start
        self._match_count += 1
        self._total_rows_processed += len(source_df)

        # Resolve configuration
        config = await self._resolve_config(
            source_df,
            reference_df,
            source_col=source_col,
            target_col=target_col,
            threshold=threshold,
            mappings=mappings,
            mode=MatchMode.MATCH,
        )

        # Ensure Engine is available
        engine = await self._get_engine()

        # Add ID columns
        source_df = self._add_id_column(source_df, config.source_id_column)
        reference_df = self._add_id_column(reference_df, config.reference_id_column)

        # Select processing strategy based on dataset size
        if len(reference_df) > self.SCALE_MODE_THRESHOLD:
            try:
                result = await self._match_with_index(
                    engine, source_df, reference_df, config
                )
            except Exception as exc:
                logger.warning("Indexed match failed, falling back to direct: %s", exc)
                result = await self._match_direct(
                    engine, source_df, reference_df, config
                )
        else:
            result = await self._match_direct(engine, source_df, reference_df, config)

        # Normalize result
        normalized = self._normalize_result(result, config)

        elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
        normalized.elapsed_ms = elapsed

        logger.info(
            "Match complete: %d source -> %d reference = %d matches in %dms (blocking=%s)",
            len(source_df),
            len(reference_df),
            len(normalized.matches),
            elapsed,
            config.blocking_strategy.get("source_column")
            if config.blocking_strategy
            else "none",
        )

        return normalized

    async def dedup(
        self,
        df: pd.DataFrame,
        *,
        key_col: Optional[str] = None,
        threshold: float = 0.85,
        mappings: Optional[List[Dict[str, Any]]] = None,
    ) -> MatchResult:
        """
        Deduplicate records within a single dataset.
        """
        start = datetime.now(timezone.utc)
        self._last_used = start
        self._match_count += 1
        self._total_rows_processed += len(df)

        config = await self._resolve_config(
            df,
            df,
            source_col=key_col,
            target_col=key_col,
            threshold=threshold,
            mappings=mappings,
            mode=MatchMode.DEDUP,
        )

        engine = await self._get_engine()
        df = self._add_id_column(df, config.source_id_column)

        discovery_policy: Dict[str, Any] = {
            "field_mappings": config.mappings,
            "threshold": config.threshold,
        }
        if config.blocking_strategy:
            discovery_policy["blocking"] = {
                "apply_blocking": True,
                "keys": [
                    {
                        "column": config.blocking_strategy.get("source_column"),
                        "key_length": config.blocking_strategy.get("key_length", 3),
                    }
                ],
            }

        records = df.to_dict(orient="records")
        raw_result = await engine.dedupe(
            records=records,
            discovery_policy=discovery_policy,
            id_field=config.source_id_column,
        )

        normalized = self._normalize_result(raw_result, config)
        elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
        normalized.elapsed_ms = elapsed

        logger.info(
            "Dedupe complete: %d rows = %d matches in %dms (blocking=%s)",
            len(df),
            len(normalized.matches),
            elapsed,
            config.blocking_strategy.get("source_column")
            if config.blocking_strategy
            else "none",
        )

        return normalized

    # ========== Configuration Resolution ==========

    async def _resolve_config(
        self,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        *,
        source_col: Optional[str] = None,
        target_col: Optional[str] = None,
        threshold: float = 0.8,
        mappings: Optional[List[Dict[str, Any]]] = None,
        mode: MatchMode = MatchMode.MATCH,
    ) -> MatchConfig:
        """
        Resolve full match configuration.
        Priority:
        1. Explicit mappings (if provided)
        2. AI-generated config (with blocking hints)
        3. Heuristic auto-configure (fallback)
        """
        normalized_threshold = self._normalize_threshold(threshold, default=0.8)

        # If mappings provided, just resolve blocking
        if mappings:
            blocking = self._resolve_blocking(
                {}, source_df, reference_df, source_col, target_col, mappings
            )
            return MatchConfig(
                mappings=mappings,
                threshold=normalized_threshold,
                blocking_strategy=blocking,
                processing_mode=self._select_processing_mode(source_df, reference_df),
            )

        # Try AI config first
        ai_config = await self._get_ai_config(source_df, reference_df, mode=mode)
        if ai_config and (ai_config.get("mappings") or ai_config.get("field_mappings")):
            self._ai_config_hits += 1
            mappings = ai_config.get("mappings") or ai_config.get("field_mappings") or []
            option_hints = ai_config.get("option_hints", {}) or {}

            blocking = self._resolve_blocking(
                option_hints, source_df, reference_df, source_col, target_col, mappings
            )

            return MatchConfig(
                mappings=mappings,
                threshold=self._normalize_threshold(
                    option_hints.get("threshold", normalized_threshold),
                    default=normalized_threshold,
                ),
                blocking_strategy=blocking,
                processing_mode=self._select_processing_mode(source_df, reference_df),
                ai_generated=True,
                confidence=float(ai_config.get("overall_confidence", 0.0) or 0.0),
                warnings=ai_config.get("warnings", []) or [],
            )

        # Fallback to heuristic
        self._ai_config_misses += 1
        logger.info("AI config unavailable, using heuristic auto-configure")

        intel = await self._get_intel()
        if intel:
            try:
                goal = "dedupe" if mode == MatchMode.DEDUP else "match"
                heuristic = await intel.auto_configure(
                    source_df, reference_df, goal=goal
                )
                if heuristic and (heuristic.get("mappings") or heuristic.get("field_mappings")):
                    mappings = heuristic.get("mappings") or heuristic.get("field_mappings") or []
                    blocking = self._resolve_blocking(
                        heuristic, source_df, reference_df, source_col, target_col, mappings
                    )
                    return MatchConfig(
                        mappings=mappings,
                        threshold=self._normalize_threshold(
                            heuristic.get("threshold", normalized_threshold),
                            default=normalized_threshold,
                        ),
                        blocking_strategy=blocking,
                        processing_mode=self._select_processing_mode(
                            source_df, reference_df
                        ),
                    )
            except Exception as exc:
                logger.warning("Heuristic auto-configure failed: %s", exc)

        # Last resort: use CLI columns as single mapping
        if source_col and target_col:
            mappings = [
                {"source": source_col, "reference": target_col, "weight": 1.0}
            ]
            blocking = {
                "source_column": source_col,
                "reference_column": target_col,
                "key_length": 3,
            }
            return MatchConfig(
                mappings=mappings,
                threshold=normalized_threshold,
                blocking_strategy=blocking,
                processing_mode=self._select_processing_mode(source_df, reference_df),
            )

        raise ValueError(
            "Cannot resolve match config: no mappings, AI, or CLI columns available"
        )

    def _resolve_blocking(
        self,
        hints: Dict[str, Any],
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        source_col: Optional[str],
        target_col: Optional[str],
        mappings: List[Dict[str, Any]],
    ) -> Optional[Dict[str, Any]]:
        """
        Resolve blocking strategy with B2B-aware hierarchy:
        1. Explicit blocking config
        2. Domain blocking (best for B2B)
        3. Email domain blocking
        4. CLI columns (user explicitly specified what to match)
        5. Company column detection
        6. First mapping fallback
        7. Forced fallback for large datasets
        """
        src_cols = [str(c) for c in source_df.columns if not str(c).startswith("__")]
        ref_cols = [str(c) for c in reference_df.columns if not str(c).startswith("__")]

        explicit = self._parse_blocking_hint(hints, src_cols, ref_cols, mappings)
        if explicit:
            logger.info(
                "Blocking: explicit %s -> %s",
                explicit.get("source_column"),
                explicit.get("reference_column"),
            )
            return explicit

        # Explicit blocking from hints (block type)
        block_type = (hints.get("block") or "").lower()
        if block_type in {"domain", "state"}:
            tokens = self.DOMAIN_TOKENS if block_type == "domain" else ("state",)
            src_block = self._find_column_by_tokens(src_cols, tokens)
            ref_block = self._find_column_by_tokens(ref_cols, tokens)
            if src_block and ref_block:
                logger.info("Blocking: %s on %s -> %s", block_type, src_block, ref_block)
                return {
                    "source_column": src_block,
                    "reference_column": ref_block,
                    "key_length": 3,
                }

        # Domain blocking (best for B2B)
        src_domain = self._find_column_by_tokens(src_cols, self.DOMAIN_TOKENS)
        ref_domain = self._find_column_by_tokens(ref_cols, self.DOMAIN_TOKENS)
        if src_domain and ref_domain:
            logger.info("Blocking: domain on %s -> %s", src_domain, ref_domain)
            return {
                "source_column": src_domain,
                "reference_column": ref_domain,
                "key_length": 3,
            }

        # Email domain blocking
        src_email = self._find_column_by_tokens(src_cols, self.EMAIL_TOKENS)
        ref_email = self._find_column_by_tokens(ref_cols, self.EMAIL_TOKENS)
        if src_email and ref_email:
            logger.info("Blocking: email-domain on %s -> %s", src_email, ref_email)
            return {
                "source_column": src_email,
                "reference_column": ref_email,
                "key_length": 3,
            }

        # CLI columns (user explicitly specified)
        if source_col and target_col:
            logger.info("Blocking: CLI columns %s -> %s", source_col, target_col)
            return {
                "source_column": source_col,
                "reference_column": target_col,
                "key_length": 3,
            }

        # AI anchor
        anchor = hints.get("anchor")
        if anchor:
            src_match = self._match_column_name(anchor, src_cols)
            ref_match = self._match_column_name(anchor, ref_cols) or self._find_ref_for_source(anchor, mappings)
            if src_match and ref_match:
                logger.info("Blocking: anchor %s -> %s", src_match, ref_match)
                return {
                    "source_column": src_match,
                    "reference_column": ref_match,
                    "key_length": 3,
                }

        # Company column detection
        src_company = self._find_column_by_tokens(src_cols, self.COMPANY_TOKENS)
        ref_company = self._find_column_by_tokens(ref_cols, self.COMPANY_TOKENS)
        if src_company and ref_company:
            logger.info("Blocking: company on %s -> %s", src_company, ref_company)
            return {
                "source_column": src_company,
                "reference_column": ref_company,
                "key_length": 3,
            }

        # First mapping
        if mappings:
            first = mappings[0]
            src = first.get("source")
            ref = first.get("reference") or first.get("ref")
            if src and ref:
                logger.info("Blocking: first mapping %s -> %s", src, ref)
                return {"source_column": src, "reference_column": ref, "key_length": 3}

        # Forced fallback for large datasets
        row_count = max(len(source_df), len(reference_df))
        if row_count > self.LOCAL_MAX_ROWS and src_cols and ref_cols:
            src_fallback = src_cols[0]
            ref_fallback = ref_cols[0]
            logger.warning(
                "Blocking: forced fallback %s -> %s (large dataset)",
                src_fallback,
                ref_fallback,
            )
            return {
                "source_column": src_fallback,
                "reference_column": ref_fallback,
                "key_length": 3,
            }

        logger.warning("Blocking: none configured")
        return None

    # ========== Engine Operations ==========

    async def _match_direct(
        self,
        engine,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        config: MatchConfig,
    ):
        """Direct matching for smaller datasets."""
        return await engine.match_records(
            source_df,
            reference_df,
            mappings=config.mappings,
            threshold=config.threshold,
            source_id_column=config.source_id_column,
            reference_id_column=config.reference_id_column,
            blocking_strategy=config.blocking_strategy,
        )

    async def _match_with_index(
        self,
        engine,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        config: MatchConfig,
    ):
        """Use reference indexing for large reference datasets."""
        index_id = await engine.begin_reference_index(
            reference_df,
            {
                "mappings": config.mappings,
                "threshold": config.threshold,
                "blocking": config.blocking_strategy,
                "reference_id_column": config.reference_id_column,
            },
        )

        try:
            return await engine.match_with_index(
                source_df,
                index_id,
                {
                    "mappings": config.mappings,
                    "threshold": config.threshold,
                    "source_id_column": config.source_id_column,
                    "reference_id_column": config.reference_id_column,
                    "blocking": config.blocking_strategy,
                },
            )
        finally:
            await self._release_index(engine, index_id)

    async def _release_index(self, engine, index_id: str) -> None:
        if hasattr(engine, "end_reference_index"):
            await engine.end_reference_index(index_id)
            return
        if hasattr(engine, "release_index"):
            await engine.release_index(index_id)

    # ========== Result Normalization ==========

    def _normalize_result(self, raw_result, config: MatchConfig) -> MatchResult:
        """Normalize Engine result to standard format."""
        matches_df = raw_result.matches if hasattr(raw_result, "matches") else pd.DataFrame()

        if matches_df is None or len(matches_df) == 0:
            return MatchResult(matches=pd.DataFrame(), stats={"total": 0}, config_used=config)

        df = matches_df.copy()

        # Normalize score column - Engine returns confidence_score
        if "confidence_score" in df.columns:
            df["score"] = df["confidence_score"]
        elif "match_score" in df.columns:
            df["score"] = df["match_score"]

        # Normalize to 0-1 scale
        if "score" in df.columns and df["score"].max() > 1.0:
            df["score"] = df["score"] / 100.0

        # Normalize ID columns
        if config.source_id_column in df.columns:
            df["source_id"] = df[config.source_id_column]
        if config.reference_id_column in df.columns:
            df["reference_id"] = df[config.reference_id_column]

        stats = {
            "total": len(df),
            "above_threshold": len(df[df.get("score", 0) >= config.threshold])
            if "score" in df.columns
            else 0,
        }

        return MatchResult(matches=df, stats=stats, config_used=config)

    # ========== Helpers ==========

    def _find_column_by_tokens(self, columns: List[str], tokens: Tuple[str, ...]) -> Optional[str]:
        for col in columns:
            lower = col.lower()
            if any(token in lower for token in tokens):
                return col
        return None

    def _match_column_name(self, name: str, columns: List[str]) -> Optional[str]:
        lower = name.lower()
        for col in columns:
            if col.lower() == lower:
                return col
        for col in columns:
            if lower in col.lower():
                return col
        return None

    def _find_ref_for_source(self, source: str, mappings: List[Dict[str, Any]]) -> Optional[str]:
        for m in mappings:
            if str(m.get("source", "")).lower() == source.lower():
                return m.get("reference") or m.get("ref")
        return None

    def _find_source_for_ref(self, reference: str, mappings: List[Dict[str, Any]]) -> Optional[str]:
        for m in mappings:
            if str(m.get("reference", "")).lower() == reference.lower():
                return m.get("source")
        return None

    def _add_id_column(self, df: pd.DataFrame, col_name: str) -> pd.DataFrame:
        if col_name not in df.columns:
            df = df.copy()
            df[col_name] = range(len(df))
        return df

    def _select_processing_mode(self, source_df: pd.DataFrame, reference_df: pd.DataFrame) -> str:
        total = len(source_df) + len(reference_df)
        return "scale" if total > self.SCALE_MODE_THRESHOLD else "speed"

    def _normalize_threshold(self, value: Any, *, default: float = 0.8) -> float:
        try:
            threshold = float(value)
        except (TypeError, ValueError):
            threshold = default
        if threshold > 1.0:
            threshold = threshold / 100.0
        return max(0.0, min(1.0, threshold))

    def _parse_blocking_hint(
        self,
        hints: Dict[str, Any],
        src_cols: List[str],
        ref_cols: List[str],
        mappings: List[Dict[str, Any]],
    ) -> Optional[Dict[str, Any]]:
        blocking = hints.get("blocking_strategy") or hints.get("blocking")
        if not isinstance(blocking, dict):
            return None

        apply = blocking.get("apply_blocking")
        if apply is False or blocking.get("enabled") is False:
            return None

        src_col = None
        ref_col = None
        key_len = blocking.get("key_length", 3)

        keys = blocking.get("keys")
        if isinstance(keys, list) and keys:
            first = keys[0]
            if isinstance(first, dict):
                src_col = first.get("source_column") or first.get("column")
                ref_col = first.get("reference_column") or first.get("column")
                key_len = first.get("key_length", key_len)
        else:
            src_col = blocking.get("source_column") or blocking.get("column")
            ref_col = blocking.get("reference_column") or blocking.get("column")
            key_len = blocking.get("key_length", key_len)

        if src_col and not ref_col:
            ref_col = self._find_ref_for_source(src_col, mappings) or src_col
        if ref_col and not src_col:
            src_col = self._find_source_for_ref(ref_col, mappings) or ref_col

        if src_col and src_col not in src_cols:
            mapped_src = self._find_source_for_ref(ref_col or "", mappings)
            if mapped_src:
                src_col = mapped_src
        if ref_col and ref_col not in ref_cols:
            mapped_ref = self._find_ref_for_source(src_col or "", mappings)
            if mapped_ref:
                ref_col = mapped_ref

        if not src_col or not ref_col:
            return None

        if src_col not in src_cols or ref_col not in ref_cols:
            return None

        return {
            "source_column": src_col,
            "reference_column": ref_col,
            "key_length": int(key_len or 3),
        }

    async def _get_engine(self):
        if self._engine is None:
            from fmatch.saas.services.engine_client import EngineClient

            self._engine = EngineClient()
        return self._engine

    async def _get_intel(self):
        if self._intel is None:
            try:
                from fmatch.saas.api.intelligence_client import IntelligenceClient

                self._intel = IntelligenceClient(base_url=self._base_url)
            except ImportError:
                pass
        return self._intel

    def _default_intel_headers(self) -> Dict[str, str]:
        headers: Dict[str, str] = {}
        if os.getenv("FM_INTEL_AUTHORIZATION"):
            headers["Authorization"] = os.getenv("FM_INTEL_AUTHORIZATION")
        if os.getenv("FM_INTEL_API_KEY"):
            headers["X-API-Key"] = os.getenv("FM_INTEL_API_KEY")
        if os.getenv("FM_INTEL_ACCOUNT_ID"):
            headers["X-Account-Id"] = os.getenv("FM_INTEL_ACCOUNT_ID")
        return headers

    async def _get_ai_config(
        self,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        *,
        mode: MatchMode = MatchMode.MATCH,
    ) -> Optional[Dict[str, Any]]:
        """Call AI match-config endpoint."""
        try:
            import httpx
        except Exception:
            return None

        base_url = self._base_url
        if not base_url:
            intel = await self._get_intel()
            base_url = getattr(intel, "base_url", "") if intel else ""
        if not base_url:
            base_url = os.getenv("INTELLIGENCE_BASE_URL") or os.getenv("PUBLIC_BASE_URL") or ""
        if not base_url:
            return None

        payload = {
            "source_schema": self._df_to_schema(source_df),
            "reference_schema": self._df_to_schema(reference_df),
            "mode": mode.value,
        }

        headers = {}
        intel = await self._get_intel()
        if intel and getattr(intel, "headers", None):
            headers.update(intel.headers)
        else:
            headers.update(self._default_intel_headers())

        try:
            async with httpx.AsyncClient(timeout=30.0) as client:
                resp = await client.post(
                    f"{base_url.rstrip('/')}/api/v2/ai/match-config",
                    json=payload,
                    headers=headers,
                )
                if resp.status_code == 200:
                    return resp.json()
        except Exception as exc:
            logger.debug("AI config fetch failed: %s", exc)

        return None

    def _df_to_schema(self, df: pd.DataFrame) -> Dict[str, Any]:
        columns = []
        for col in df.columns:
            if str(col).startswith("__"):
                continue
            series = df[col]
            try:
                samples = series.dropna().head(5).tolist()
                fill_rate = float(series.notna().mean()) if len(df) else 0.0
                data_type = str(series.dtype)
            except Exception:
                samples = []
                fill_rate = None
                data_type = None
            columns.append(
                {
                    "name": str(col),
                    "samples": [str(s) for s in samples],
                    "fill_rate": fill_rate,
                    "data_type": data_type,
                }
            )
        return {"columns": columns, "row_count": len(df)}

    # ========== Metrics ==========

    def get_metrics(self) -> Dict[str, Any]:
        return {
            "match_count": self._match_count,
            "total_rows_processed": self._total_rows_processed,
            "ai_config_hit_rate": self._ai_config_hits
            / max(self._ai_config_hits + self._ai_config_misses, 1),
            "last_used": self._last_used.isoformat(),
        }


async def get_match_gateway() -> MatchGateway:
    """FastAPI dependency for MatchGateway."""
    return await MatchGateway.create()


class GatewayUnavailableResult:
    """Returned when gateway cannot score."""

    def __init__(self, source_df: pd.DataFrame, reason: str):
        self.available = False
        self.reason = reason
        self.matches = pd.DataFrame()
        self.unresolved = source_df.copy()
        self.unresolved["_fm_status"] = "needs_review"
        self.unresolved["_fm_reason"] = reason
