"""
V1 Match API to MatchGateway Adapter
====================================
Bridges the v1 public match API to MatchGateway while preserving
v1 response shape and decision semantics.

Key design decisions:
1. Uses deterministic mappings (no AI config) to avoid per-row overhead
2. Batch operations use single gateway call, results grouped by source_id
3. V1-compat decision shim: only "review" or "no_match" (no "merge")
4. LLM rerank intentionally disabled in gateway path

See: dynamic-purring-sutherland.md for full migration plan.
"""

from __future__ import annotations

import logging
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

import pandas as pd

from ..services.match_gateway import MatchGateway, MatchResult
from ..settings import V1_MATCH_GATEWAY_THRESHOLD

logger = logging.getLogger(__name__)


# ============================================================
# Canonical Column Resolution
# ============================================================

COMPANY_ALIASES = (
    "Company",
    "Name",
    "Account Name",
    "company",
    "name",
    "account_name",
    "Account",
    "account",
    "Company Name",
    "company_name",
)

DOMAIN_ALIASES = (
    "Domain",
    "Website",
    "domain",
    "website",
    "EmailDomain",
    "email_domain",
    "Web",
    "web",
    "URL",
    "url",
)

EMAIL_ALIASES = (
    "Email",
    "email",
    "E-mail",
    "e-mail",
    "EmailAddress",
    "email_address",
    "Work Email",
    "work_email",
)


def _resolve_column(df: pd.DataFrame, aliases: tuple) -> Optional[str]:
    """Find first matching column from aliases."""
    for alias in aliases:
        if alias in df.columns:
            return alias
    return None


# Deterministic mappings - no AI config overhead
# NOTE: Column names use __ prefix to avoid gateway blocking detection
# (columns starting with __ are excluded from blocking, per match_gateway.py line 365)
V1_DETERMINISTIC_MAPPINGS = [
    # Domain matching - JaroWinkler handles typos well
    {"source": "__v1_domain", "reference": "__v1_domain", "weight": 0.7, "algorithm": "JaroWinkler"},
    # Company matching - WRatio handles variations well
    {"source": "__v1_company", "reference": "__v1_company", "weight": 0.4, "algorithm": "WRatio"},
]

# B2C domains to filter when b2c_filter is enabled
B2C_DOMAINS = frozenset({
    "gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "live.com",
    "aol.com", "icloud.com", "me.com", "protonmail.com", "proton.me",
    "zoho.com", "gmx.com", "mail.com", "mail.ru", "qq.com", "163.com",
    "fastmail.com", "msn.com", "comcast.net", "verizon.net", "att.net",
    "sbcglobal.net", "ymail.com",
})


# ============================================================
# Normalization Helpers (match v1 inline logic)
# ============================================================

_ROOT_DOMAIN_RE = re.compile(
    r"^(?:https?://)?(?:www\.)?([^/:]+).*$", re.IGNORECASE
)
# Suffix regex: includes "corporation", uses negative lookbehind to preserve "& Co" / "and Co"
_COMPANY_SUFFIXES = re.compile(
    r"\b(inc\.?|llc\.?|ltd\.?|limited|corp\.?|corporation|(?<!and )co\.?|gmbh|s\.?a\.?|srl|plc)\.?$",
    re.IGNORECASE,
)


def _extract_root_domain(val: Any) -> str:
    """
    Extract root domain (eTLD+1) from URL, email, or domain string.

    Matches v1 inline logic:
    - api.acme.com -> acme.com
    - www.foo.co.uk -> co.uk (simplified - takes last 2 parts)
    """
    s = str(val or "").strip().lower()
    if not s:
        return ""
    # Handle email addresses
    if "@" in s:
        s = s.split("@")[-1]
    # Remove protocol
    if s.startswith("http://") or s.startswith("https://"):
        s = s.split("://", 1)[1]
    # Remove www. prefix
    if s.startswith("www."):
        s = s[4:]
    # Remove path
    s = s.split("/", 1)[0]
    # Remove port if present
    s = s.split(":")[0]
    # Take last 2 parts for eTLD+1 (matches v1 inline logic)
    parts = s.split(".")
    return ".".join(parts[-2:]) if len(parts) >= 2 else s


def _normalize_company_name(val: Any) -> str:
    """Normalize company name for matching."""
    s = str(val or "").strip()
    if not s:
        return ""
    # Lowercase
    s = s.lower()
    # Replace & with "and" BEFORE suffix stripping (preserves "& Co" -> "and co")
    s = s.replace("&", "and").replace(",", " ")
    # Normalize whitespace
    s = " ".join(s.split())
    # Remove common suffixes (negative lookbehind preserves "and co")
    s = _COMPANY_SUFFIXES.sub("", s).strip()
    return " ".join(s.split())


def _add_canonical_columns(df: pd.DataFrame) -> pd.DataFrame:
    """
    Pre-derive canonical columns (__v1_domain, __v1_company) from aliased inputs.
    This allows deterministic mappings to work regardless of source column names.

    NOTE: Column names use __ prefix to avoid gateway blocking detection.
    Columns starting with __ are excluded from blocking (per match_gateway.py line 365).
    This ensures rows with empty domains are not filtered out - they just score 0 on
    the domain component but can still match on company name.
    """
    df = df.copy()

    # Derive __v1_domain from Domain/Website/EmailDomain/Email
    domain_col = _resolve_column(df, DOMAIN_ALIASES)
    email_col = _resolve_column(df, EMAIL_ALIASES)
    if domain_col:
        df["__v1_domain"] = df[domain_col].apply(_extract_root_domain)
    elif email_col:
        df["__v1_domain"] = df[email_col].apply(_extract_root_domain)
    else:
        df["__v1_domain"] = ""

    # Derive __v1_company from Company/Name/Account Name
    company_col = _resolve_column(df, COMPANY_ALIASES)
    if company_col:
        df["__v1_company"] = df[company_col].apply(_normalize_company_name)
    else:
        df["__v1_company"] = ""

    return df


# ============================================================
# Response Dataclass
# ============================================================


@dataclass
class V1MatchResponse:
    """V1-compatible match response structure."""

    best_match: Optional[Dict[str, Any]]
    confidence: float
    explanations: List[Dict[str, Any]]
    recommendation: str  # "review" | "no_match" (NO "merge" in v1 inline path)
    applied_options: Dict[str, Any]
    matches: List[Dict[str, Any]] = field(default_factory=list)
    ambiguous: bool = False
    margin: Optional[float] = None
    llm: Dict[str, Any] = field(default_factory=lambda: {
        "used": False, "choice_id": None, "reason": None, "overrode": False
    })
    # Internal tracking
    gateway_score: Optional[float] = None
    gateway_latency_ms: Optional[int] = None


# ============================================================
# ID/Name Helpers (match v1 logic)
# ============================================================


def _pick_id_and_type(record: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
    """Extract ID and type from candidate record (matches v1 _pick_id_and_type)."""
    cols = list(record.keys())
    L = [str(c or "").lower() for c in cols]

    for c in ("id", "accountid", "contactid", "leadid"):
        if c in L:
            idx = L.index(c)
            col_name = cols[idx]
            t = (
                "Account" if c == "accountid"
                else "Contact" if c == "contactid"
                else "Lead" if c == "leadid"
                else None
            )
            return str(record.get(col_name) or ""), t

    for c in ("account id", "contact id", "lead id"):
        if c in L:
            idx = L.index(c)
            col_name = cols[idx]
            t = (
                "Account" if "account" in c
                else "Contact" if "contact" in c
                else "Lead" if "lead" in c
                else None
            )
            return str(record.get(col_name) or ""), t

    # Fallback: use first id-like column
    for col in cols:
        if "id" in col.lower():
            return str(record.get(col) or ""), None

    return None, None


def _pick_display_name(record: Dict[str, Any]) -> Optional[str]:
    """Extract display name from candidate record (matches v1 _pick_display_name)."""
    cols = list(record.keys())
    L = [str(c or "").lower() for c in cols]

    for c in ("name", "account name", "company"):
        if c in L:
            idx = L.index(c)
            col_name = cols[idx]
            return str(record.get(col_name) or "")

    return str(record.get(cols[0]) or "") if cols else None


# ============================================================
# V1 Gateway Adapter
# ============================================================


class V1GatewayAdapter:
    """
    Adapts v1 match requests to MatchGateway and back.

    Responsibilities:
    1. Pre-derive canonical columns (_domain, _company) from aliased inputs
    2. Convert v1 dict/list format to DataFrames with canonical columns
    3. Call MatchGateway.match() with deterministic mappings (no AI config)
    4. Group results by source_id and extract top-K per source
    5. Convert MatchResult to v1 response shape using existing helpers
    6. Apply v1-compat decision shim (threshold logic, no "merge")
    """

    def __init__(self, gateway: MatchGateway):
        self.gateway = gateway
        self.logger = logging.getLogger(__name__)

    @classmethod
    async def create(cls) -> "V1GatewayAdapter":
        """Factory method with gateway initialization."""
        gateway = await MatchGateway.create()
        return cls(gateway)

    # -------------------- Filtering Helpers --------------------

    def _filter_b2c_candidates(
        self, candidates: List[Dict[str, Any]]
    ) -> Tuple[List[Dict[str, Any]], List[int]]:
        """
        Filter out candidates with B2C domains when b2c_filter is enabled.

        Returns:
            Tuple of (filtered_candidates, original_indices) for mapping back
        """
        filtered = []
        indices = []
        for i, cand in enumerate(candidates):
            domain = _extract_root_domain(
                cand.get("Domain") or cand.get("Website") or
                cand.get("EmailDomain") or cand.get("Email") or ""
            )
            if domain not in B2C_DOMAINS:
                filtered.append(cand)
                indices.append(i)
        return filtered, indices

    def _map_indices_to_original(
        self, filtered_indices: List[int], original_indices: List[int]
    ) -> List[int]:
        """Map filtered result indices back to original candidate indices."""
        return [original_indices[i] for i in filtered_indices if i < len(original_indices)]

    # -------------------- Single Match --------------------

    async def match(
        self,
        source: Dict[str, Any],
        candidates: List[Dict[str, Any]],
        *,
        threshold: Optional[float] = None,
        top_k: int = 3,
        options: Optional[Dict[str, Any]] = None,
    ) -> V1MatchResponse:
        """
        Execute match through gateway with v1-compat response.

        Args:
            source: Source record (single dict)
            candidates: Candidate records (list of dicts)
            threshold: Match threshold (default: V1_MATCH_GATEWAY_THRESHOLD, typically 0.8)
            top_k: Max candidates to return
            options: v1 match options (blocking, b2c_filter, etc.)

        Returns:
            V1-compatible response with gateway score available
        """
        start_ms = int(time.time() * 1000)
        opts = options or {}
        threshold = threshold if threshold is not None else V1_MATCH_GATEWAY_THRESHOLD

        if not candidates:
            return self._empty_response(opts, threshold)

        # Apply b2c_filter if enabled - filter out consumer email domains
        working_candidates = candidates
        candidate_index_map: Optional[List[int]] = None
        if opts.get("b2c_filter"):
            working_candidates, candidate_index_map = self._filter_b2c_candidates(candidates)
            if not working_candidates:
                return self._empty_response(opts, threshold)

        # Log warning for use_domain_family - not yet implemented in gateway path
        if opts.get("use_domain_family"):
            self.logger.debug(
                "use_domain_family requested but not implemented in gateway path; "
                "domain matching will use exact domain comparison"
            )

        # Convert to DataFrames with canonical columns
        source_df = self._to_dataframe_with_canonical([source])
        reference_df = self._to_dataframe_with_canonical(working_candidates)

        try:
            # Call gateway with deterministic mappings
            result = await self.gateway.match(
                source_df,
                reference_df,
                threshold=threshold,
                mappings=V1_DETERMINISTIC_MAPPINGS,
            )

            # Build v1 response (pass original candidates for response, but use index map)
            response = self._build_v1_response_single(
                source=source,
                candidates=candidates,  # Original candidates for response
                filtered_candidates=working_candidates,  # Filtered for matching
                candidate_index_map=candidate_index_map,
                result=result,
                threshold=threshold,
                top_k=top_k,
                options=opts,
            )

            response.gateway_latency_ms = int(time.time() * 1000) - start_ms
            return response

        except Exception as e:
            self.logger.warning("Gateway match failed: %s", e, exc_info=True)
            raise

    # -------------------- Batch Match --------------------

    async def match_batch(
        self,
        sources: List[Dict[str, Any]],
        candidates: List[Dict[str, Any]],
        *,
        threshold: Optional[float] = None,
        top_k: int = 3,
        options: Optional[Dict[str, Any]] = None,
    ) -> List[V1MatchResponse]:
        """
        Process batch in ONE gateway call, then split results by source_id.

        This avoids per-row AI config and maximizes gateway efficiency.

        Args:
            sources: List of source records
            candidates: List of candidate records (shared across all sources)
            threshold: Match threshold (default: V1_MATCH_GATEWAY_THRESHOLD, typically 0.8)
            top_k: Max candidates per source
            options: v1 match options

        Returns:
            List of V1-compatible responses, one per source
        """
        start_ms = int(time.time() * 1000)
        opts = options or {}
        threshold = threshold if threshold is not None else V1_MATCH_GATEWAY_THRESHOLD

        if not sources:
            return []

        if not candidates:
            return [self._empty_response(opts, threshold) for _ in sources]

        # Apply b2c_filter if enabled - filter out consumer email domains
        working_candidates = candidates
        candidate_index_map: Optional[List[int]] = None
        if opts.get("b2c_filter"):
            working_candidates, candidate_index_map = self._filter_b2c_candidates(candidates)
            if not working_candidates:
                return [self._empty_response(opts, threshold) for _ in sources]

        # Log warning for use_domain_family - not yet implemented in gateway path
        if opts.get("use_domain_family"):
            self.logger.debug(
                "use_domain_family requested but not implemented in gateway path; "
                "domain matching will use exact domain comparison"
            )

        # Convert to DataFrames with canonical columns
        source_df = self._to_dataframe_with_canonical(sources)
        reference_df = self._to_dataframe_with_canonical(working_candidates)

        try:
            # Single gateway call for entire batch
            result = await self.gateway.match(
                source_df,
                reference_df,
                threshold=threshold,
                mappings=V1_DETERMINISTIC_MAPPINGS,
            )

            # Group top-K by source_id
            grouped = self._group_top_k_by_source(result.matches, top_k)

            # Map indices back to original candidates if filtering was applied
            if candidate_index_map is not None:
                for source_id, matches in grouped.items():
                    for match in matches:
                        ref_idx = match.get("ref_idx", 0)
                        if 0 <= ref_idx < len(candidate_index_map):
                            match["ref_idx"] = candidate_index_map[ref_idx]

            # Build v1 response for each source
            elapsed_ms = int(time.time() * 1000) - start_ms
            responses = []
            for i, source in enumerate(sources):
                top_matches = grouped.get(i, [])
                response = self._build_v1_response_from_grouped(
                    source=source,
                    candidates=candidates,  # Use original candidates for lookup
                    top_matches=top_matches,
                    threshold=threshold,
                    options=opts,
                )
                response.gateway_latency_ms = elapsed_ms
                responses.append(response)

            return responses

        except Exception as e:
            self.logger.warning("Gateway batch match failed: %s", e, exc_info=True)
            raise

    # -------------------- DataFrame Conversion --------------------

    def _to_dataframe_with_canonical(
        self, records: List[Dict[str, Any]]
    ) -> pd.DataFrame:
        """Convert list of dicts to DataFrame with canonical columns."""
        df = pd.DataFrame(records)
        return _add_canonical_columns(df)

    # -------------------- Result Grouping --------------------

    def _group_top_k_by_source(
        self, matches_df: pd.DataFrame, k: int
    ) -> Dict[int, List[Dict[str, Any]]]:
        """
        Group gateway matches by source_id and return top-K per source.

        Returns dict mapping source_idx -> list of match dicts with score and ref_idx.
        """
        if matches_df is None or len(matches_df) == 0:
            return {}

        grouped: Dict[int, List[Dict[str, Any]]] = {}

        # Determine column names (gateway may use __source_id or source_id)
        src_col = "__source_id" if "__source_id" in matches_df.columns else "source_id"
        ref_col = "__reference_id" if "__reference_id" in matches_df.columns else "reference_id"
        score_col = "score" if "score" in matches_df.columns else "confidence_score"

        for source_id, group in matches_df.groupby(src_col):
            # Sort by score descending and take top-K
            sorted_group = group.nlargest(k, score_col)
            matches = []
            for _, row in sorted_group.iterrows():
                matches.append({
                    "score": float(row.get(score_col, 0)),
                    "ref_idx": int(row.get(ref_col, 0)),
                    "row": row.to_dict(),
                })
            grouped[int(source_id)] = matches

        return grouped

    # -------------------- Response Building --------------------

    def _build_v1_response_single(
        self,
        source: Dict[str, Any],
        candidates: List[Dict[str, Any]],
        result: MatchResult,
        threshold: float,
        top_k: int,
        options: Dict[str, Any],
        filtered_candidates: Optional[List[Dict[str, Any]]] = None,
        candidate_index_map: Optional[List[int]] = None,
    ) -> V1MatchResponse:
        """Build v1 response from gateway result for single-source match."""
        grouped = self._group_top_k_by_source(result.matches, top_k)
        top_matches = grouped.get(0, [])

        # Map indices back to original candidates if filtering was applied
        if candidate_index_map is not None:
            for match in top_matches:
                ref_idx = match.get("ref_idx", 0)
                if 0 <= ref_idx < len(candidate_index_map):
                    match["ref_idx"] = candidate_index_map[ref_idx]

        return self._build_v1_response_from_grouped(
            source=source,
            candidates=candidates,  # Use original candidates for lookup
            top_matches=top_matches,
            threshold=threshold,
            options=options,
        )

    def _build_v1_response_from_grouped(
        self,
        source: Dict[str, Any],
        candidates: List[Dict[str, Any]],
        top_matches: List[Dict[str, Any]],
        threshold: float,
        options: Dict[str, Any],
    ) -> V1MatchResponse:
        """Build v1 response from grouped top-K matches."""
        if not top_matches:
            return self._empty_response(options, threshold)

        # Best match
        best_match_data = top_matches[0]
        best_score = best_match_data["score"]
        ref_idx = best_match_data["ref_idx"]

        # Get candidate record
        try:
            best_candidate = candidates[ref_idx] if 0 <= ref_idx < len(candidates) else None
        except (IndexError, TypeError):
            best_candidate = None

        # Calculate margin and ambiguous flag
        margin: Optional[float] = None
        if len(top_matches) >= 2:
            margin = top_matches[0]["score"] - top_matches[1]["score"]
        ambiguous = (margin is not None and margin < 0.03 and best_score >= 0.55)

        # Build explanations (simplified - gateway doesn't provide field-level details in same format)
        explanations = self._build_v1_explanations(source, best_candidate, best_score)

        # Build matches list in v1 format
        matches_list = []
        for m in top_matches:
            try:
                cand = candidates[m["ref_idx"]] if 0 <= m["ref_idx"] < len(candidates) else {}
            except (IndexError, TypeError):
                cand = {}
            matches_list.append({
                "candidate": cand,
                "score": m["score"],
                "explanations": self._build_v1_explanations(source, cand, m["score"]),
            })

        # V1-compat decision shim (no "merge")
        recommendation = self._v1_recommendation(best_score, threshold)

        return V1MatchResponse(
            best_match=best_candidate,
            confidence=best_score,
            explanations=explanations,
            recommendation=recommendation,
            applied_options=self._build_applied_options(options, threshold),
            matches=matches_list,
            ambiguous=ambiguous,
            margin=margin,
            llm={"used": False, "choice_id": None, "reason": None, "overrode": False},
            gateway_score=best_score,
        )

    def _build_v1_explanations(
        self,
        source: Dict[str, Any],
        candidate: Optional[Dict[str, Any]],
        score: float,
    ) -> List[Dict[str, Any]]:
        """Build v1-style explanations based on canonical matching."""
        explanations = []

        if not candidate:
            return explanations

        # Extract domains
        s_domain = _extract_root_domain(
            source.get("Domain") or source.get("Website") or
            source.get("EmailDomain") or source.get("Email") or ""
        )
        c_domain = _extract_root_domain(
            candidate.get("Domain") or candidate.get("Website") or
            candidate.get("EmailDomain") or candidate.get("Email") or ""
        )

        # Extract company names
        s_company = _normalize_company_name(
            source.get("Company") or source.get("Name") or
            source.get("Account Name") or ""
        )
        c_company = _normalize_company_name(
            candidate.get("Company") or candidate.get("Name") or
            candidate.get("Account Name") or ""
        )

        # Build explanations based on what matched
        if s_domain and c_domain and s_domain == c_domain:
            explanations.append({
                "field": "domain",
                "signal": "exact_match",
                "score": 1.0,
                "display": "Domain: exact",
            })
        elif s_domain and c_domain:
            # Partial domain match
            explanations.append({
                "field": "domain",
                "signal": "partial_match",
                "score": 0.7,
                "display": "Domain: partial",
            })

        if s_company and c_company and s_company == c_company:
            explanations.append({
                "field": "company",
                "signal": "clean_exact",
                "score": 1.0,
                "display": "Company: exact",
            })
        elif s_company and c_company:
            explanations.append({
                "field": "company",
                "signal": "partial_match",
                "score": 0.5,
                "display": "Company: similar",
            })

        if not s_domain and not s_company:
            explanations.append({
                "field": "record",
                "signal": "no_fields",
                "score": 0.0,
                "display": "No domain/company in source",
            })

        return explanations

    def _v1_recommendation(self, score: float, threshold: float) -> str:
        """
        V1-compatible decision shim.

        v1 inline path only returns "review" or "no_match" - NO "merge".
        """
        return "review" if score >= threshold else "no_match"

    def _build_applied_options(
        self,
        options: Dict[str, Any],
        threshold: float,
    ) -> Dict[str, Any]:
        """Build v1-style applied_options response."""
        blocking = options.get("blocking", {})
        return {
            "threshold": threshold,
            "blocking": {
                "enabled": blocking.get("enabled", False),
                "column": blocking.get("column"),
            },
            "b2c_filter": options.get("b2c_filter", False),
            "use_domain_family": options.get("use_domain_family", False),
            "threshold_defaulted": threshold == V1_MATCH_GATEWAY_THRESHOLD,
            "gateway_routed": True,  # Flag to identify gateway path
        }

    def _empty_response(
        self,
        options: Dict[str, Any],
        threshold: float,
    ) -> V1MatchResponse:
        """Return empty/no-match response."""
        return V1MatchResponse(
            best_match=None,
            confidence=0.0,
            explanations=[],
            recommendation="no_match",
            applied_options=self._build_applied_options(options, threshold),
            matches=[],
            ambiguous=False,
            margin=None,
            llm={"used": False, "choice_id": None, "reason": None, "overrode": False},
            gateway_score=0.0,
        )
