"""Estimate duplicate likelihood scores for rows using fuzzy matching."""

from __future__ import annotations

import time
from enum import Enum
from functools import lru_cache
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple

from rapidfuzz import fuzz

# Configuration constants
MAX_CANDIDATES = 2000  # Maximum candidates to process per row
CHUNK_SIZE = 2000  # Process in chunks for large datasets
CACHE_SIZE = 10000  # LRU cache size for field extraction


# Blocking modes for different performance/accuracy trade-offs
class BlockingMode(Enum):
    SPEED = "speed"  # Fast but less accurate (blocking on email domain)
    BALANCED = "balanced"  # Good balance (blocking on first letter of company/name)
    SCALE = "scale"  # Most accurate but slower (no blocking)


_DEFAULT_WEIGHTS = {
    "email": 0.55,
    "name": 0.30,
    "company": 0.15,
}

FEATURE_FLAG = False  # Controlled from Apps Script "Labs" toggle


def _normalize_headers(headers: Sequence[Any], length: int) -> List[str]:
    names: List[str] = []
    for idx in range(length):
        raw = "" if headers is None or idx >= len(headers) else headers[idx]
        label = str(raw or "").strip() or f"Column {idx + 1}"
        names.append(label)
    return names


@lru_cache(maxsize=CACHE_SIZE)
def _extract_fields_cached(row_hash: str, headers_hash: str) -> Dict[str, str]:
    """Cached field extraction to avoid redundant processing."""
    # This will be called by the non-cached version
    return {}


def _extract_fields(row: Sequence[Any], headers: Sequence[str]) -> Dict[str, str]:
    mapping = {header.lower(): idx for idx, header in enumerate(headers)}

    def get(header_keys: Iterable[str]) -> str:
        for key in header_keys:
            idx = mapping.get(key)
            if idx is not None and idx < len(row):
                value = row[idx]
                if value is None:
                    continue
                text = str(value).strip()
                if text:
                    return text
        return ""

    fields = {
        "email": get(["email", "work email", "primary email", "email address"]),
        "name": get(["name", "full name", "contact name", "first name", "last name"]),
        "company": get(["company", "account", "account name", "organization"]),
        "phone": get(["phone", "mobile", "telephone", "phone number"]),
        "domain": "",
    }

    # Extract domain from email for blocking
    if fields["email"] and "@" in fields["email"]:
        fields["domain"] = fields["email"].split("@")[-1].lower()

    return fields


def _score_pair(
    left: Dict[str, str], right: Dict[str, str], weights: Dict[str, float]
) -> Tuple[float, str]:
    """Score similarity between two rows and return what matched."""
    # Exact email match wins immediately
    if left["email"] and left["email"].lower() == right["email"].lower():
        return 1.0, "email_exact"

    score = 0.0
    total_weight = 0.0
    matched_on = []

    # Email similarity
    if left["email"] and right["email"]:
        email_score = fuzz.ratio(left["email"].lower(), right["email"].lower()) / 100.0
        if email_score > 0.9:
            matched_on.append("email")
        score += weights["email"] * email_score
        total_weight += weights["email"]

    # Domain + name match (strong signal)
    if (
        left["domain"]
        and right["domain"]
        and left["domain"] == right["domain"]
        and left["name"]
        and right["name"]
    ):
        name_score = fuzz.token_sort_ratio(left["name"], right["name"]) / 100.0
        if name_score > 0.85:
            matched_on.append("domain+name")
            score += 0.2  # Bonus for domain+name match

    # Name similarity
    if left["name"] and right["name"]:
        name_score = fuzz.token_sort_ratio(left["name"], right["name"]) / 100.0
        if name_score > 0.9:
            matched_on.append("name")
        score += weights["name"] * name_score
        total_weight += weights["name"]

    # Company similarity
    if left["company"] and right["company"]:
        company_score = fuzz.token_sort_ratio(left["company"], right["company"]) / 100.0
        if company_score > 0.9:
            matched_on.append("company")
        score += weights["company"] * company_score
        total_weight += weights["company"]

    # Phone match (additional signal)
    if left.get("phone") and right.get("phone") and left["phone"] == right["phone"]:
        matched_on.append("phone")
        score += 0.1  # Bonus for phone match

    if total_weight == 0:
        return 0.0, "no_match"

    final_score = min(1.0, score / total_weight)
    match_type = "+".join(matched_on[:2]) if matched_on else "fuzzy"

    return final_score, match_type


def _apply_blocking(
    candidates: List[Dict[str, Any]],
    target_fields: Dict[str, str],
    mode: BlockingMode,
    headers: Sequence[str],
) -> List[Dict[str, Any]]:
    """Apply blocking to reduce candidates based on mode."""
    if mode == BlockingMode.SCALE:
        # No blocking - process all candidates
        return candidates

    blocked = []

    for candidate in candidates:
        cand_fields = _extract_fields(candidate.get("row", []), headers)

        if mode == BlockingMode.SPEED:
            # Block on email domain or company first letter
            if target_fields["domain"] and cand_fields["domain"]:
                if target_fields["domain"] == cand_fields["domain"]:
                    blocked.append(candidate)
            elif target_fields["company"] and cand_fields["company"]:
                if (
                    target_fields["company"][0].lower()
                    == cand_fields["company"][0].lower()
                ):
                    blocked.append(candidate)

        elif mode == BlockingMode.BALANCED:
            # Block on first letter of name or company
            should_include = False

            if target_fields["name"] and cand_fields["name"]:
                if target_fields["name"][0].lower() == cand_fields["name"][0].lower():
                    should_include = True

            if target_fields["company"] and cand_fields["company"]:
                if (
                    target_fields["company"][0].lower()
                    == cand_fields["company"][0].lower()
                ):
                    should_include = True

            if target_fields["domain"] and cand_fields["domain"]:
                if target_fields["domain"] == cand_fields["domain"]:
                    should_include = True

            if should_include:
                blocked.append(candidate)

    # If blocking eliminated everything, fall back to top N candidates
    if not blocked and candidates:
        return candidates[: min(100, len(candidates))]

    return blocked


def _best_duplicate_match(
    row: Sequence[Any],
    headers: Sequence[str],
    candidates: Iterable[Dict[str, Any]],
    self_index: Optional[int],
    weights: Optional[Dict[str, float]] = None,
    mode: BlockingMode = BlockingMode.BALANCED,
    threshold: float = 0.0,
) -> Tuple[float, Optional[int], str]:
    """Find best duplicate match with blocking and early exit."""
    weights = weights or _DEFAULT_WEIGHTS
    row_fields = _extract_fields(row, headers)

    # Early exit if no identifying information
    if not (row_fields["email"] or row_fields["name"] or row_fields["company"]):
        return 0.0, None, "no_data"

    # Convert candidates to list and apply MAX_CANDIDATES limit
    candidate_list = list(candidates)
    if len(candidate_list) > MAX_CANDIDATES:
        candidate_list = candidate_list[:MAX_CANDIDATES]

    # Apply blocking to reduce search space
    blocked_candidates = _apply_blocking(candidate_list, row_fields, mode, headers)

    best_score = 0.0
    best_index: Optional[int] = None
    best_match_type = "no_match"
    ties = []  # Track ties for deterministic resolution

    for candidate in blocked_candidates:
        cand_index = candidate.get("row_index")
        if self_index is not None and cand_index == self_index:
            continue

        cand_row = candidate.get("row") or []
        cand_fields = _extract_fields(cand_row, headers)
        score, match_type = _score_pair(row_fields, cand_fields, weights)

        # Early exit on perfect match
        if score >= 0.99:
            return score, cand_index, match_type

        # Early exit if score below threshold
        if threshold > 0 and score < threshold:
            continue

        if score > best_score:
            best_score = score
            best_index = cand_index
            best_match_type = match_type
            ties = [(score, cand_index, match_type)]
        elif score == best_score and cand_index is not None:
            ties.append((score, cand_index, match_type))

    # Deterministic tie-breaking: choose lowest index
    if len(ties) > 1:
        ties.sort(key=lambda x: (x[1] or float("inf")))
        best_score, best_index, best_match_type = ties[0]

    return best_score, best_index, best_match_type


def detect_duplicate_likelihood(payload: Dict[str, Any]) -> Dict[str, Any]:
    """Detect duplicate likelihood with improved performance and features."""
    if payload.get("is_header"):
        return {
            "value": [0.0, "header_row"],
            "best_row_index": None,
            "matched_on": "header_row",
            "meta": {
                "mode": BlockingMode.BALANCED.value,
                "threshold": 0.0,
                "candidates_evaluated": 0,
                "elapsed_ms": 0,
                "is_complete": True,
                "best_row_index": None,
                "matched_on": "header_row",
            },
        }

    # Check feature flag
    if not FEATURE_FLAG and not payload.get("feature_enabled"):
        return {
            "value": [0.0, "disabled"],
            "best_row_index": None,
            "matched_on": "disabled",
            "meta": {
                "mode": BlockingMode.BALANCED.value,
                "threshold": 0.0,
                "candidates_evaluated": 0,
                "elapsed_ms": 0,
                "is_complete": True,
                "best_row_index": None,
                "matched_on": "disabled",
            },
        }

    row = payload.get("row") or []
    headers = _normalize_headers(payload.get("headers") or [], len(row))
    candidates = payload.get("candidates") or []
    row_index = payload.get("row_index")

    # Get configuration from payload
    mode_str = payload.get("mode", "balanced")
    mode = (
        BlockingMode(mode_str)
        if mode_str in [m.value for m in BlockingMode]
        else BlockingMode.BALANCED
    )
    threshold = float(payload.get("threshold", 0.0))

    # Handle chunked processing state
    chunk_state = payload.get("chunk_state", {})
    if chunk_state:
        # Restore previous best from chunk state
        prev_best_score = chunk_state.get("best_score", 0.0)
        prev_best_index = chunk_state.get("best_index")
        prev_matched_on = chunk_state.get("matched_on", "no_match")
    else:
        prev_best_score = 0.0
        prev_best_index = None
        prev_matched_on = "no_match"

    start = time.perf_counter()

    # Process in chunks if needed
    if len(candidates) > CHUNK_SIZE:
        # Process current chunk
        chunk_start = chunk_state.get("processed", 0)
        chunk_end = min(chunk_start + CHUNK_SIZE, len(candidates))
        chunk_candidates = candidates[chunk_start:chunk_end]

        score, match_index, matched_on = _best_duplicate_match(
            row, headers, chunk_candidates, row_index, mode=mode, threshold=threshold
        )

        # Compare with previous best
        if score > prev_best_score:
            final_score = score
            final_index = match_index
            final_matched = matched_on
        else:
            final_score = prev_best_score
            final_index = prev_best_index
            final_matched = prev_matched_on

        # Update chunk state for next iteration
        new_chunk_state = {
            "processed": chunk_end,
            "total": len(candidates),
            "best_score": final_score,
            "best_index": final_index,
            "matched_on": final_matched,
            "chunks_done": chunk_end // CHUNK_SIZE,
            "total_chunks": (len(candidates) + CHUNK_SIZE - 1) // CHUNK_SIZE,
        }

        is_complete = chunk_end >= len(candidates)
    else:
        # Process all at once
        score, match_index, matched_on = _best_duplicate_match(
            row, headers, candidates, row_index, mode=mode, threshold=threshold
        )
        final_score = score
        final_index = match_index
        final_matched = matched_on
        new_chunk_state = None
        is_complete = True

    elapsed = time.perf_counter() - start

    scored = round(final_score, 3)
    matched_label = final_matched or "no_match"

    result: Dict[str, Any] = {
        "value": [scored, matched_label],
        "score": scored,
        "best_row_index": final_index,
        "matched_on": matched_label,
    }

    # Add chunk progress if processing in chunks
    if new_chunk_state and not is_complete:
        result["chunk_state"] = new_chunk_state
        result["progress"] = {
            "processed": new_chunk_state["processed"],
            "total": new_chunk_state["total"],
            "percent": round(
                100 * new_chunk_state["processed"] / new_chunk_state["total"], 1
            ),
            "eta_ms": int(
                elapsed
                * (new_chunk_state["total"] - new_chunk_state["processed"])
                / CHUNK_SIZE
            ),
        }

    # Add metadata
    result.setdefault("meta", {})
    result["meta"].update(
        {
            "mode": mode.value,
            "threshold": threshold,
            "candidates_evaluated": len(candidates)
            if is_complete
            else chunk_end - chunk_start,
            "elapsed_ms": int(elapsed * 1000),
            "is_complete": is_complete,
            "best_row_index": final_index,
            "matched_on": matched_label,
        }
    )

    return result


def benchmark_duplicate_scoring(
    rows: Sequence[Sequence[Any]], headers: Optional[Sequence[str]] = None
) -> Dict[str, Any]:
    """Small helper to benchmark duplicate scoring across a dataset."""
    headers = _normalize_headers(headers or [], len(rows[0]) if rows else 0)
    start = time.perf_counter()
    for idx, row in enumerate(rows):
        _best_duplicate_match(
            row,
            headers,
            (
                {"row": candidate, "row_index": c_idx}
                for c_idx, candidate in enumerate(rows)
            ),
            idx,
        )
    elapsed = time.perf_counter() - start
    return {"rows": len(rows), "elapsed_ms": int(elapsed * 1000)}
