# algorithm_selector.py
"""Per-column algorithm selection for optimal matching."""

from typing import Dict, Tuple, Optional, List, Callable, Any
from dataclasses import dataclass, field
from functools import lru_cache
import pandas as pd
import logging
import re
from rapidfuzz import fuzz, distance
from fmatch.utils_normalize import apply_transforms

log = logging.getLogger(__name__)


@dataclass
class EnhancedMapping:
    """Enhanced mapping with per-column algorithm selection."""

    source: str
    ref: str
    weight: float = 1.0
    preferred_algo: Optional[str] = None  # Algorithm override
    algo_confidence: Optional[float] = None  # Confidence in this choice
    raw_score: Optional[float] = None
    component_scores: Optional[Dict] = None
    auto_mapped: bool = False
    transforms: List[str] = field(default_factory=list)
    preprocessing: Optional[str] = None

    def to_dict(self):
        """Convert to dictionary for serialization."""
        return {
            "source": self.source,
            "ref": self.ref,
            "weight": self.weight,
            "preferred_algo": self.preferred_algo,
            "algo_confidence": self.algo_confidence,
            "raw_score": self.raw_score,
            "component_scores": self.component_scores,
            "auto_mapped": self.auto_mapped,
            "transforms": list(self.transforms) if self.transforms else [],
            "preprocessing": self.preprocessing,
        }

    @classmethod
    def from_dict(cls, data: Dict):
        """Create from dictionary."""
        return cls(**data)


class PerColumnAlgorithmSelector:
    """Selects optimal algorithm for each column based on data characteristics."""

    # Algorithm capabilities matrix
    ALGO_STRENGTHS = {
        "Exact": {
            "good_for": ["email", "id", "code", "unique_identifier"],
            "handles_typos": False,
            "handles_reordering": False,
            "handles_abbreviations": False,
            "min_confidence": 0.90,
        },
        "JaroWinkler": {
            "good_for": ["name_person", "short_text", "phone"],
            "handles_typos": True,
            "handles_reordering": False,
            "handles_abbreviations": False,
            "min_confidence": 0.80,
        },
        "TokenSetRatio": {
            "good_for": ["name_company", "address", "organization"],
            "handles_typos": True,
            "handles_reordering": True,
            "handles_abbreviations": True,
            "min_confidence": 0.85,
        },
        "WRatio": {
            "good_for": ["general", "mixed", "unknown"],
            "handles_typos": True,
            "handles_reordering": True,
            "handles_abbreviations": False,
            "min_confidence": 0.70,
        },
        "PartialRatio": {
            "good_for": ["substring", "product_desc", "notes"],
            "handles_typos": True,
            "handles_reordering": False,
            "handles_abbreviations": False,
            "min_confidence": 0.75,
        },
    }

    def __init__(self, engine_stats_func=None):
        """Initialize with optional engine stats function."""
        self.engine_stats_func = engine_stats_func

    def select_algorithm(
        self,
        src_series: pd.Series,
        ref_series: pd.Series,
        semantic_type: Optional[str] = None,
        column_stats: Optional[Dict] = None,
    ) -> Tuple[str, float]:
        """
        Select best algorithm for a column pair.

        Returns:
            Tuple of (algorithm_name, confidence_score)
        """
        # Use existing stats if provided, otherwise calculate
        if column_stats is None:
            column_stats = self._calculate_column_stats(src_series, ref_series)

        # Ensure required keys exist with safe defaults
        column_stats = self._ensure_stats_keys(column_stats)

        # Start with scores for each algorithm
        algo_scores = {}

        # 1. Exact match for high-cardinality unique fields
        if column_stats["distinct_ratio"] > 0.95:
            if semantic_type in ["email", "id", "unique_identifier"]:
                algo_scores["Exact"] = 0.95
            elif column_stats["avg_len"] > 20:  # Long unique strings
                algo_scores["Exact"] = 0.85

        # 2. JaroWinkler for short strings with few tokens
        if column_stats["avg_len"] < 15 and column_stats["avg_tokens"] < 2:
            algo_scores["JaroWinkler"] = 0.85
            # Boost for person names
            if semantic_type == "name_person":
                algo_scores["JaroWinkler"] = 0.90
            # Good for phone numbers
            elif semantic_type == "phone":
                algo_scores["JaroWinkler"] = 0.88

        # 3. TokenSetRatio for multi-word fields that might be reordered
        if column_stats["avg_tokens"] > 2:
            algo_scores["TokenSetRatio"] = 0.80
            # Boost for company names and addresses
            if semantic_type in ["name_company", "organization"]:
                algo_scores["TokenSetRatio"] = 0.95
            elif semantic_type == "address":
                algo_scores["TokenSetRatio"] = 0.90

        # 4. PartialRatio for potential substring matches
        if column_stats["length_variance"] > 0.5:  # High variance suggests substrings
            algo_scores["PartialRatio"] = 0.75
            # Boost for descriptions and notes
            if semantic_type in ["product_desc", "notes", "description"]:
                algo_scores["PartialRatio"] = 0.85

        # 5. Default to WRatio as general-purpose
        algo_scores["WRatio"] = 0.70

        # Adjust scores based on data characteristics
        algo_scores = self._adjust_scores_by_data(algo_scores, column_stats)

        # Select highest scoring algorithm
        best_algo = max(algo_scores.items(), key=lambda x: x[1])

        log.debug(
            f"Algorithm selection for {semantic_type}: {best_algo[0]} "
            f"(confidence: {best_algo[1]:.2f})"
        )

        return best_algo[0], best_algo[1]

    def _calculate_column_stats(
        self, src_series: pd.Series, ref_series: pd.Series
    ) -> Dict:
        """Calculate statistics for algorithm selection."""
        # Use engine stats if available
        if self.engine_stats_func:
            try:
                src_stats = self.engine_stats_func(src_series)
                ref_stats = (
                    self.engine_stats_func(ref_series) if ref_series is not None else {}
                )

                # Combine and enhance stats
                if ref_series is not None:
                    combined = pd.concat([src_series.dropna(), ref_series.dropna()])
                else:
                    combined = src_series.dropna()

                # Map engine stats keys to expected keys
                avg_len = src_stats.get(
                    "avg_length",
                    src_stats.get("avg_len", src_stats.get("median_char_length", 10.0)),
                )
                avg_tokens = src_stats.get(
                    "avg_tokens", src_stats.get("median_token_count", 1.0)
                )

                return {
                    "distinct_ratio": src_stats.get("distinct_ratio", 0.0),
                    "avg_len": avg_len,
                    "avg_tokens": avg_tokens,
                    "length_variance": self._calculate_length_variance(combined),
                    "semantic_type": src_stats.get("inferred_type", "unknown"),
                    "has_special_chars": src_stats.get("has_special_chars", False),
                    "numeric_ratio": src_stats.get("numeric_ratio", 0.0),
                }
            except Exception as e:
                log.warning(f"Failed to use engine stats: {e}")

        # Fallback to simple calculation
        if ref_series is not None:
            combined = pd.concat([src_series.dropna(), ref_series.dropna()])
        else:
            combined = src_series.dropna()

        if combined.empty:
            return {
                "distinct_ratio": 0.0,
                "avg_len": 0.0,
                "avg_tokens": 0.0,
                "length_variance": 0.0,
                "semantic_type": "unknown",
                "has_special_chars": False,
                "numeric_ratio": 0.0,
            }

        # Convert to string for analysis
        str_series = combined.astype(str)

        # Calculate metrics
        lengths = str_series.str.len()
        tokens = str_series.str.split()

        return {
            "distinct_ratio": combined.nunique() / len(combined),
            "avg_len": lengths.mean(),
            "avg_tokens": tokens.str.len().mean(),
            "length_variance": lengths.std() / lengths.mean()
            if lengths.mean() > 0
            else 0,
            "semantic_type": "unknown",
            "has_special_chars": str_series.str.contains(r"[^a-zA-Z0-9\s]").any(),
            "numeric_ratio": str_series.str.count(r"\d").sum()
            / str_series.str.len().sum(),
        }

    def _calculate_length_variance(self, series: pd.Series) -> float:
        """Calculate normalized length variance."""
        if series.empty:
            return 0.0

        lengths = series.astype(str).str.len()
        if lengths.mean() == 0:
            return 0.0

        return lengths.std() / lengths.mean()

    def _ensure_stats_keys(self, stats: Dict) -> Dict:
        """Ensure all required keys exist in stats dictionary with safe defaults."""
        defaults = {
            "distinct_ratio": 0.0,
            "avg_len": 10.0,  # Default average length
            "avg_tokens": 1.0,  # Default token count
            "length_variance": 0.0,
            "semantic_type": "unknown",
            "has_special_chars": False,
            "numeric_ratio": 0.0,
        }

        # Create new dict with defaults and update with actual values
        result = defaults.copy()
        result.update(stats)
        return result

    def _adjust_scores_by_data(
        self, scores: Dict[str, float], stats: Dict
    ) -> Dict[str, float]:
        """Adjust algorithm scores based on data characteristics."""
        # Penalize exact matching for fields with typos likely
        if stats.get("has_special_chars") and "Exact" in scores:
            scores["Exact"] *= 0.8

        # Boost TokenSetRatio for high token count
        if stats.get("avg_tokens", 0) > 4 and "TokenSetRatio" in scores:
            scores["TokenSetRatio"] *= 1.1

        # Penalize JaroWinkler for long strings
        if stats.get("avg_len", 0) > 30 and "JaroWinkler" in scores:
            scores["JaroWinkler"] *= 0.7

        return scores


class MultiAlgorithmScorer:
    """Scorer that supports per-column algorithm selection and ensemble scoring."""

    # CORRECTED Algorithm function mapping
    ALGO_FUNCTIONS: Dict[str, Callable] = {
        "Exact": lambda s1, s2, **kwargs: 1.0 if s1 == s2 else 0.0,
        "JaroWinkler": lambda s1, s2: float(distance.JaroWinkler.similarity(s1, s2)),
        "Jaro": lambda s1, s2: float(distance.Jaro.similarity(s1, s2)),
        "TokenSetRatio": lambda s1, s2: fuzz.token_set_ratio(s1, s2) / 100.0,
        "TokenSortRatio": lambda s1, s2: fuzz.token_sort_ratio(s1, s2) / 100.0,
        "WRatio": lambda s1, s2: fuzz.WRatio(s1, s2) / 100.0,
        "PartialRatio": lambda s1, s2: fuzz.partial_ratio(s1, s2) / 100.0,
        "Ratio": lambda s1, s2: fuzz.ratio(s1, s2) / 100.0,
    }

    # Role accounts that should bypass early accept - use regex for exact matching
    ROLE_RE = re.compile(
        r"^(info|sales|support|contact|admin|billing|help|hello|no-?reply|"
        r"marketing|team|accounts|service|customer)$",
        re.IGNORECASE,
    )

    @staticmethod
    def _is_domain_mapping(mapping: EnhancedMapping) -> bool:
        """Lightweight check to see if a mapping targets domain-like data."""
        if not mapping:
            return False
        transforms = getattr(mapping, "transforms", []) or []
        if any(t == "to_domain" for t in transforms):
            return True
        source_name = (mapping.source or "").lower()
        ref_name = (mapping.ref or mapping.source or "").lower()
        domain_tokens = ("domain", "website", "url", "hostname", "host")
        return any(token in source_name for token in domain_tokens) or any(
            token in ref_name for token in domain_tokens
        )

    def __init__(
        self,
        mappings: List[EnhancedMapping],
        cache_size: int = 100_000,
        *,
        coverage_gate: float = 0.60,
    ):
        self.mappings = mappings
        self.coverage_gate = float(coverage_gate)
        self._ensure_domain_algorithms()
        self._normalize_mapping_algorithms()
        self._validate_algorithms()
        # Performance optimization with caching
        self._cached_score = lru_cache(maxsize=cache_size)(self._compute_similarity)
        self._empty_pair_cache = {}
        self._apply_default_ensembles()

    @staticmethod
    def _extract_email_local(email: str) -> str:
        """Extract local part of email, removing plus addressing and Gmail dots."""
        if not email or "@" not in email:
            return ""
        local, domain = email.split("@", 1)

        # Remove plus addressing
        local = local.split("+", 1)[0]

        # For Gmail/Googlemail, remove dots from local part
        if domain.lower() in ("gmail.com", "googlemail.com"):
            local = local.replace(".", "")

        return local

    @staticmethod
    def _is_role_account(email: str) -> bool:
        """Check if email is a role account that should bypass early accept."""
        if not email or "@" not in email:
            return False
        local = email.split("@", 1)[0]
        return bool(MultiAlgorithmScorer.ROLE_RE.fullmatch(local))

    @staticmethod
    def _normalize_domain(domain: str) -> str:
        """Normalize domain by stripping www. and converting to lowercase."""
        domain = (domain or "").strip().lower()
        if domain.startswith("www."):
            domain = domain[4:]
        return domain

    @staticmethod
    def _domain_similarity(left: str, right: str) -> float:
        """Domain-aware similarity with normalization and soft fallback."""
        dom_l = MultiAlgorithmScorer._normalize_domain(left)
        dom_r = MultiAlgorithmScorer._normalize_domain(right)

        if not dom_l or not dom_r:
            return 0.0
        if dom_l == dom_r:
            return 1.0

        # Compare registered domain (last two labels) to treat subdomains as near matches
        def _registrable(val: str) -> str:
            parts = val.split(".")
            return ".".join(parts[-2:]) if len(parts) >= 2 else val

        if _registrable(dom_l) == _registrable(dom_r):
            return 0.95

        return fuzz.ratio(dom_l, dom_r) / 100.0

    @classmethod
    def _ensure_domain_algorithms(cls):
        """Add domain algorithms once to avoid runtime KeyErrors."""
        for key in ("Domain", "ExactDomain", "domain", "exactdomain"):
            if key not in cls.ALGO_FUNCTIONS:
                cls.ALGO_FUNCTIONS[key] = cls._domain_similarity

    @classmethod
    def _canonicalize_algo_name(cls, algo_name: Optional[str]) -> Optional[str]:
        """Normalize algorithm names to the canonical keys we support."""
        if not algo_name:
            return None

        raw = str(algo_name).strip()
        if not raw:
            return None

        normalized = re.sub(r"[^a-zA-Z]", "", raw).lower()
        alias_map = {
            "domain": "Domain",
            "exactdomain": "ExactDomain",
            "jw": "JaroWinkler",
            "wratio": "WRatio",
            "tokenset": "TokenSetRatio",
            "tokensetratio": "TokenSetRatio",
            "tokensortratio": "TokenSortRatio",
        }

        if normalized in alias_map:
            return alias_map[normalized]

        for key in cls.ALGO_FUNCTIONS:
            key_normalized = re.sub(r"[^a-zA-Z]", "", key).lower()
            if normalized == key_normalized or raw == key:
                return key

        return None

    def _normalize_mapping_algorithms(self):
        """Canonicalize algorithm names on all mappings."""
        for mapping in self.mappings:
            if not mapping:
                continue

            if mapping.preferred_algo:
                canonical = self._canonicalize_algo_name(mapping.preferred_algo)
                if canonical:
                    mapping.preferred_algo = canonical

            if hasattr(mapping, "algorithms") and mapping.algorithms:
                for algo_spec in mapping.algorithms:
                    canonical = self._canonicalize_algo_name(algo_spec.get("name"))
                    if canonical:
                        algo_spec["name"] = canonical

    @staticmethod
    def _should_early_accept(l: dict, r: dict) -> bool:
        """Check if records should be early accepted based on domain and email matching."""
        dom_l = MultiAlgorithmScorer._normalize_domain(l.get("domain") or "")
        dom_r = MultiAlgorithmScorer._normalize_domain(r.get("domain") or "")
        if dom_l and dom_r and dom_l == dom_r:
            email_l = l.get("email") or ""
            email_r = r.get("email") or ""

            # Skip early accept for role accounts
            if MultiAlgorithmScorer._is_role_account(
                email_l
            ) or MultiAlgorithmScorer._is_role_account(email_r):
                return False

            em_l = MultiAlgorithmScorer._extract_email_local(email_l)
            em_r = MultiAlgorithmScorer._extract_email_local(email_r)
            if em_l and em_r and em_l == em_r:
                return True
        return False

    def _apply_default_ensembles(self):
        """Apply default ensemble configurations based on field names."""
        for mapping in self.mappings:
            # Respect explicit per-field overrides
            if mapping.preferred_algo:
                continue
            # Skip if already has ensemble algorithms
            if hasattr(mapping, "algorithms") and mapping.algorithms:
                continue

            # Check if field names suggest specific ensemble
            field_name = ((mapping.source or "") + "|" + (mapping.ref or "")).lower()

            # Domain/website fields
            if any(term in field_name for term in ["domain", "website", "url", "web"]):
                mapping.algorithms = [
                    {"name": "TokenSetRatio", "weight": 0.5},
                    {"name": "WRatio", "weight": 0.3},
                    {"name": "JaroWinkler", "weight": 0.2},
                ]
            # Name/company fields
            elif any(
                term in field_name for term in ["name", "company", "account", "org"]
            ):
                mapping.algorithms = [
                    {"name": "WRatio", "weight": 0.5},
                    {"name": "TokenSetRatio", "weight": 0.3},
                    {"name": "PartialRatio", "weight": 0.2},
                ]
            # Address fields
            elif any(term in field_name for term in ["address", "street", "location"]):
                mapping.algorithms = [
                    {"name": "TokenSetRatio", "weight": 0.4},
                    {"name": "WRatio", "weight": 0.4},
                    {"name": "PartialRatio", "weight": 0.2},
                ]
            # Email fields
            elif any(term in field_name for term in ["email", "mail"]):
                mapping.algorithms = [
                    {"name": "Exact", "weight": 0.5},
                    {"name": "JaroWinkler", "weight": 0.3},
                    {"name": "WRatio", "weight": 0.2},
                ]
            # ID/code fields
            elif any(term in field_name for term in ["id", "code", "number"]):
                mapping.algorithms = [
                    {"name": "Exact", "weight": 0.7},
                    {"name": "JaroWinkler", "weight": 0.3},
                ]
            # Default ensemble for general text fields
            elif not mapping.preferred_algo or mapping.preferred_algo == "WRatio":
                mapping.algorithms = [
                    {"name": "WRatio", "weight": 0.6},
                    {"name": "TokenSetRatio", "weight": 0.4},
                ]

    def _validate_algorithms(self):
        """Ensure all selected algorithms are available."""
        for mapping in self.mappings:
            # Normalize preferred algorithm casing/aliases
            if mapping.preferred_algo:
                canonical = self._canonicalize_algo_name(mapping.preferred_algo)
                if canonical:
                    mapping.preferred_algo = canonical

            # Validate single algorithm
            if (
                mapping.preferred_algo
                and mapping.preferred_algo not in self.ALGO_FUNCTIONS
            ):
                raise ValueError(f"Unknown algorithm: {mapping.preferred_algo}")

            # Validate ensemble algorithms
            if hasattr(mapping, "algorithms") and mapping.algorithms:
                for algo_spec in mapping.algorithms:
                    algo_name = algo_spec.get("name", "WRatio")
                    canonical = self._canonicalize_algo_name(algo_name)
                    if canonical:
                        algo_name = canonical
                        algo_spec["name"] = canonical
                    if algo_name not in self.ALGO_FUNCTIONS:
                        raise ValueError(f"Unknown algorithm in ensemble: {algo_name}")

    def _compute_similarity(self, algo_name: str, s1: str, s2: str) -> float:
        """Cached similarity computation."""
        return self.ALGO_FUNCTIONS[algo_name](s1, s2)

    def score_pair(self, src_record: Dict, ref_record: Dict) -> Dict:
        """
        Score a single pair using per-column algorithms with ensemble support.

        Returns dict with overall score and field-level details.
        """
        # Check for early accept condition
        if self._should_early_accept(src_record, ref_record):
            return {
                "confidence_score": 100.0,
                "field_score_details": [
                    {
                        "source_field": "domain+email",
                        "ref_field": "domain+email",
                        "algorithm": "EarlyAccept",
                        "algo_confidence": 1.0,
                        "score": 100.0,
                        "weight": 1.0,
                        # Remove PII - don't include actual email/domain values
                        "src_value": "[REDACTED]",
                        "ref_value": "[REDACTED]",
                    }
                ],
                "algorithms_used": ["EarlyAccept"],
                "effective_weight": 1.0,
                "num_fields_compared": 1,
                "scores": {"score": 100.0},
            }

        field_scores = []
        total_weight = 0.0
        weighted_sum = 0.0
        effective_weight = 0.0
        domain_exact_match = False

        for mapping in self.mappings:
            src_val = str(src_record.get(mapping.source, "")).strip()
            ref_val = str(ref_record.get(mapping.ref, "")).strip()

            if mapping.transforms:
                src_val = apply_transforms(src_val, mapping.transforms)
                ref_val = apply_transforms(ref_val, mapping.transforms)

            # cdist semantics: only count weights where BOTH sides have values.
            # Empty pairs contribute 0 weight and 0 score; overall score is later
            # coverage-penalized based on weight coverage.
            total_weight += mapping.weight
            if not src_val or not ref_val:
                continue

            # Check if mapping has ensemble algorithms
            if hasattr(mapping, "algorithms") and mapping.algorithms:
                # Ensemble scoring with multiple algorithms
                component_scores = {}
                total_algo_weight = 0.0
                weighted_algo_sum = 0.0

                for algo_spec in mapping.algorithms:
                    algo_name = algo_spec.get("name", "WRatio")
                    algo_weight = float(algo_spec.get("weight", 1.0))

                    algo_score = self._cached_score(algo_name, src_val, ref_val)
                    component_scores[algo_name] = algo_score

                    weighted_algo_sum += algo_score * algo_weight
                    total_algo_weight += algo_weight

                # Calculate blended score
                score = (
                    weighted_algo_sum / total_algo_weight if total_algo_weight > 0 else 0.0
                )
                algo_used = f"Ensemble({','.join(component_scores.keys())})"
            else:
                # Single algorithm (backward compatibility)
                algo_name = mapping.preferred_algo or "WRatio"
                score = self._cached_score(algo_name, src_val, ref_val)
                algo_used = algo_name
                component_scores = {algo_name: score}

            if (
                self._is_domain_mapping(mapping)
                and mapping.preferred_algo
                in {"Exact", "Domain", "ExactDomain", "domain", "exactdomain"}
                and score >= 0.999
            ):
                domain_exact_match = True

            score_percent = score * 100.0
            component_scores_percent = (
                {algo: val * 100.0 for algo, val in component_scores.items()}
                if component_scores
                else {}
            )

            # Track for transparency
            field_score_detail = {
                "source_field": mapping.source,
                "ref_field": mapping.ref,
                "algorithm": algo_used,
                "algo_confidence": mapping.algo_confidence or 0.0,
                "score": score_percent,
                "weight": mapping.weight,
                "src_value": src_val[:50] + "..." if len(src_val) > 50 else src_val,
                "ref_value": ref_val[:50] + "..." if len(ref_val) > 50 else ref_val,
            }

            # Add component scores for ensemble
            if component_scores_percent:
                field_score_detail["component_scores"] = component_scores_percent

            field_scores.append(field_score_detail)

            weighted_sum += score * mapping.weight
            effective_weight += mapping.weight

        # Calculate final score
        final_score_norm = (
            weighted_sum / effective_weight if effective_weight > 0 else 0.0
        )

        # Apply the same coverage penalty logic as the cdist path.
        if total_weight > 0 and effective_weight < total_weight:
            coverage = effective_weight / total_weight
            if coverage < self.coverage_gate:
                final_score_norm *= coverage

        if domain_exact_match:
            final_score_norm = 1.0

        final_score_pct = final_score_norm * 100.0

        return {
            "confidence_score": final_score_pct,
            "field_score_details": field_scores,
            "algorithms_used": list(
                set(
                    fs["algorithm"]
                    for fs in field_scores
                    if not fs["algorithm"].startswith("Empty")
                )
            ),
            "effective_weight": effective_weight,
            "num_fields_compared": len(field_scores),
            "scores": self._extract_component_scores(field_scores, final_score_pct),
        }

    def _extract_component_scores(
        self, field_scores: List[Dict], final_score_pct: float
    ) -> Dict:
        """Extract component scores for transparency in results."""
        # Aggregate all component scores across fields
        all_component_scores: Dict[str, List[float]] = {}
        for fs in field_scores:
            component_map = fs.get("component_scores") or {}
            for algo, score in component_map.items():
                all_component_scores.setdefault(algo, []).append(score)

        # Average the scores per algorithm
        avg_scores: Dict[str, float] = {}
        for algo, scores in all_component_scores.items():
            avg_scores[algo] = sum(scores) / len(scores) if scores else 0.0

        # Add the final blended score
        avg_scores["score"] = final_score_pct

        return avg_scores

    def get_algorithm_usage_stats(self) -> Dict[str, int]:
        """Get statistics on algorithm usage."""
        algo_counts = {}
        for mapping in self.mappings:
            algo = mapping.preferred_algo or "WRatio"
            algo_counts[algo] = algo_counts.get(algo, 0) + 1
        return algo_counts

    def clear_cache(self):
        """Clear the similarity score cache."""
        self._cached_score.cache_clear()
        self._empty_pair_cache.clear()


# Enhanced configuration for engine integration
@dataclass
class EnhancedMatchConfig:
    """Extended config with per-column algorithms."""

    src_path: Any
    ref_path: Any
    maps: List[Dict]
    threshold: float = 80.0
    column_algorithms: Optional[Dict[str, str]] = None
    enable_multi_algo: bool = False
    algorithm: str = "WRatio"  # Fallback default
    # Add other existing config fields as needed


@dataclass
class EnhancedDedupeConfig:
    """Extended dedupe config with per-column algorithms."""

    src_path: Any
    maps: List[Dict]
    threshold: float = 80.0
    column_algorithms: Optional[Dict[str, str]] = None
    enable_multi_algo: bool = False
    algorithm: str = "WRatio"  # Fallback default
    # Add other existing config fields as needed
