"""
Simple Fuzzy Matcher Implementation
Provides basic fuzzy matching functionality for the SaaS application
"""

import pandas as pd
from typing import Optional
from rapidfuzz import fuzz
import logging
import re
import os
from dataclasses import dataclass
from typing import Callable

# Import globalization utilities
from ..preprocessing.rules.text_normalizers import is_cjk

log = logging.getLogger(__name__)


# Punctuation removal pattern for CJK text hardening
# Keep ASCII letters/digits + CJK/Cyrillic; drop underscores and symbols
_PUNCT_PATTERN = re.compile(
    r"[^\dA-Za-z\u3400-\u9FFF\uF900-\uFAFF\u3040-\u30FF\uAC00-\uD7AF\u0400-\u04FF]",
    re.UNICODE,
)


def _grams(s: str, n: int = 2) -> set[str]:
    """Generate character n-grams from string."""
    return {s[i : i + n] for i in range(max(0, len(s) - n + 1))} if s else set()


def cjk_dice(a: str, b: str, n: int = 2) -> float:
    """
    Character bigram Dice coefficient for CJK text matching.

    Works well for Asian languages (Chinese, Japanese, Korean) where
    character-level similarity is meaningful without tokenization.

    Args:
        a, b: Strings to compare
        n: N-gram size (default 2 for bigrams)

    Returns:
        Similarity score 0-100 (scaled for compatibility with rapidfuzz)
    """
    if not a or not b:
        return 0.0

    # Strip punctuation and whitespace for cleaner CJK comparison
    a = _PUNCT_PATTERN.sub("", str(a))
    b = _PUNCT_PATTERN.sub("", str(b))

    A, B = _grams(a, n), _grams(b, n)
    if not A or not B:
        return 0.0

    intersection = len(A & B)
    dice_coeff = (2 * intersection) / (len(A) + len(B))

    # Scale to 0-100 for consistency with rapidfuzz scores
    return dice_coeff * 100.0


def smart_algorithm_selector(
    text1: str, text2: str, default_algo: str = "WRatio"
) -> str:
    """
    Intelligently select algorithm based on text characteristics.

    Args:
        text1, text2: Text to analyze
        default_algo: Algorithm to use if no special case detected

    Returns:
        Algorithm name to use
    """
    # If either text contains CJK characters, use CJK_DICE2
    if is_cjk(text1) or is_cjk(text2):
        return "CJK_DICE2"

    # For short product codes or IDs, exact matching works better
    if (
        len(text1) < 8
        and len(text2) < 8
        and any(c.isdigit() for c in text1)
        and any(c.isdigit() for c in text2)
    ):
        return "QRatio"  # Better for mixed alpha-numeric

    return default_algo


@dataclass(frozen=True)
class _MappingCfg:
    source_col: str
    reference_col: str
    weight: float
    algo_name: str
    algo_func: Callable[[str, str], float]


class FuzzyMatcher:
    """
    Basic fuzzy matching engine for matching and deduplication
    """

    def __init__(self, mode: str = "match"):
        """
        Initialize the matcher

        Args:
            mode: Either "match" or "dedupe"
        """
        self.mode = mode
        self.threshold = 85
        self.mappings = []
        self.algorithms = {
            "WRatio": fuzz.WRatio,
            "Ratio": fuzz.ratio,
            "PartialRatio": fuzz.partial_ratio,
            "TokenSetRatio": fuzz.token_set_ratio,
            "TokenSortRatio": fuzz.token_sort_ratio,
            "QRatio": fuzz.QRatio,
            "Exact": lambda s1, s2: 100 if str(s1).lower() == str(s2).lower() else 0,
            "CJK_DICE2": cjk_dice,  # New algorithm for Asian text
            "Auto": lambda s1, s2: (
                cjk_dice(s1, s2)
                if (is_cjk(s1) or is_cjk(s2))
                else (
                    fuzz.QRatio(s1, s2)
                    if (
                        min(len(s1 or ""), len(s2 or "")) < 8
                        and any(c.isdigit() for c in (s1 or ""))
                        and any(c.isdigit() for c in (s2 or ""))
                    )
                    else fuzz.WRatio(s1, s2)
                )
            ),  # Smart selection: CJK->DICE, short alphanumeric->QRatio, else WRatio
        }

    def add_mapping(
        self,
        source_col: str,
        reference_col: str,
        weight: float = 1.0,
        algorithm: str = "WRatio",
    ):
        """Add a column mapping for matching"""
        self.mappings.append(
            {
                "source": source_col,
                "reference": reference_col,
                "weight": weight,
                "algorithm": algorithm,
            }
        )

    def set_threshold(self, threshold: int):
        """Set the matching threshold (0-100)"""
        self.threshold = threshold

    def calculate_similarity(self, row1: pd.Series, row2: pd.Series) -> float:
        """
        Calculate similarity score between two rows based on mappings
        """
        if not self.mappings:
            return 0.0

        total_score = 0.0
        total_weight = 0.0

        for mapping in self.mappings:
            source_col = mapping["source"]
            ref_col = mapping["reference"]
            weight = mapping["weight"]
            algo_name = mapping.get("algorithm", "WRatio")

            # Get the algorithm function
            algo_func = self.algorithms.get(algo_name, fuzz.WRatio)

            try:
                # Get values
                val1 = str(row1.get(source_col, ""))
                val2 = str(row2.get(ref_col, ""))

                # Skip if either value is empty
                if not val1 or not val2:
                    continue

                # Calculate similarity
                score = algo_func(val1, val2)

                # Add weighted score
                total_score += score * weight
                total_weight += weight

            except Exception as e:
                log.debug(f"Error comparing {source_col} to {ref_col}: {e}")
                continue

        # Calculate weighted average
        if total_weight > 0:
            return total_score / total_weight
        return 0.0

    def match(
        self,
        source_df: pd.DataFrame,
        ref_df: pd.DataFrame,
        source_id: Optional[str] = None,
        reference_id: Optional[str] = None,
        blocking_col: Optional[str] = None,
    ) -> pd.DataFrame:
        """
        Perform matching between source and reference dataframes

        Returns:
            DataFrame with matches
        """
        log.info(
            f"Starting match with {len(source_df)} source and {len(ref_df)} reference records"
        )
        log.info(f"Threshold: {self.threshold}, Mappings: {len(self.mappings)}")

        if source_df is None or ref_df is None or source_df.empty or ref_df.empty:
            return pd.DataFrame()

        # If no mappings specified, try to auto-detect based on column names
        if not self.mappings:
            log.warning("No mappings specified, using column name matching")
            common_cols = set(source_df.columns) & set(ref_df.columns)
            for col in common_cols:
                if not col.endswith("_id") and not col.lower() == "id":
                    self.add_mapping(col, col, 1.0)
            log.info(f"Auto-created {len(self.mappings)} mappings")

        # If mappings are compatible with RapidFuzz cdist, use a vectorized implementation.
        # This preserves the current scoring semantics (weighted average over non-empty fields)
        # but avoids the Python-level double loop.
        mapping_cfgs: list[_MappingCfg] = []
        for mapping in self.mappings:
            source_col = mapping.get("source")
            reference_col = mapping.get("reference")
            if not source_col or not reference_col:
                continue
            try:
                weight = float(mapping.get("weight", 1.0))
            except Exception:
                weight = 1.0
            algo_name = mapping.get("algorithm", "WRatio")
            algo_func = self.algorithms.get(algo_name, fuzz.WRatio)
            mapping_cfgs.append(
                _MappingCfg(
                    source_col=source_col,
                    reference_col=reference_col,
                    weight=weight,
                    algo_name=str(algo_name),
                    algo_func=algo_func,
                )
            )

        supported_scorers = {
            fuzz.WRatio,
            fuzz.ratio,
            fuzz.partial_ratio,
            fuzz.token_set_ratio,
            fuzz.token_sort_ratio,
            fuzz.QRatio,
        }
        can_vectorize = True
        for cfg in mapping_cfgs:
            if cfg.algo_name == "Exact":
                continue
            if cfg.algo_func not in supported_scorers:
                can_vectorize = False
                break

        if can_vectorize and mapping_cfgs:
            try:
                import numpy as np
                from rapidfuzz import process as rf_process

                matches: list[dict] = []
                n_src = len(source_df)
                n_ref = len(ref_df)
                total_comparisons = n_src * n_ref

                # Default to 1 to avoid thread oversubscription in web workers.
                # Can be overridden at deploy-time.
                rf_workers = 1
                env_workers = os.environ.get("FM_SHEETS_RF_WORKERS")
                if env_workers:
                    try:
                        rf_workers = max(1, int(env_workers))
                    except Exception:
                        rf_workers = 1

                # Bound peak memory: process source in chunks so score matrices stay manageable.
                # Target: ~5M elements per chunk (float32 matrix ~20MB).
                target_elems = 5_000_000
                chunk_size = max(1, min(n_src, target_elems // max(1, n_ref)))

                # Pre-extract string arrays for all referenced columns (or empty strings if missing).
                needed_src_cols = {c.source_col for c in mapping_cfgs}
                needed_ref_cols = {c.reference_col for c in mapping_cfgs}

                src_arrays: dict[str, "np.ndarray"] = {}
                ref_arrays: dict[str, "np.ndarray"] = {}
                for col in needed_src_cols:
                    if col in source_df.columns:
                        src_arrays[col] = source_df[col].astype(str).to_numpy()
                    else:
                        src_arrays[col] = np.full(n_src, "", dtype=object)
                for col in needed_ref_cols:
                    if col in ref_df.columns:
                        ref_arrays[col] = ref_df[col].astype(str).to_numpy()
                    else:
                        ref_arrays[col] = np.full(n_ref, "", dtype=object)

                # Precompute non-empty masks for reference columns.
                ref_nonempty: dict[str, "np.ndarray"] = {
                    col: (arr != "") for col, arr in ref_arrays.items()
                }

                # Exact semantics are case-insensitive (matches calculate_similarity's lambda).
                exact_ref_lower: dict[str, "np.ndarray"] = {}
                for cfg in mapping_cfgs:
                    if cfg.algo_name != "Exact":
                        continue
                    r_col = cfg.reference_col
                    if r_col in exact_ref_lower:
                        continue
                    exact_ref_lower[r_col] = np.char.lower(ref_arrays[r_col].astype(str))

                # Precompute IDs/index labels to preserve existing output semantics.
                src_index_labels = source_df.index.to_numpy()
                ref_index_labels = ref_df.index.to_numpy()
                src_id_arr = (
                    source_df[source_id].to_numpy()
                    if (source_id and source_id in source_df.columns)
                    else None
                )
                ref_id_arr = (
                    ref_df[reference_id].to_numpy()
                    if (reference_id and reference_id in ref_df.columns)
                    else None
                )

                for start in range(0, n_src, chunk_size):
                    end = min(n_src, start + chunk_size)
                    rows = end - start

                    total_score = np.zeros((rows, n_ref), dtype=np.float32)
                    total_weight = np.zeros((rows, n_ref), dtype=np.float32)

                    for cfg in mapping_cfgs:
                        s_vals = src_arrays[cfg.source_col][start:end]
                        r_vals = ref_arrays[cfg.reference_col]

                        # Skip work quickly if either side is entirely empty for this mapping.
                        s_nonempty = (s_vals != "")
                        r_nonempty = ref_nonempty[cfg.reference_col]
                        if not s_nonempty.any() or not r_nonempty.any():
                            continue

                        if cfg.algo_name == "Exact":
                            s_vals_lower = np.char.lower(s_vals.astype(str))
                            r_vals_lower = exact_ref_lower.get(cfg.reference_col)
                            if r_vals_lower is None:
                                r_vals_lower = np.char.lower(r_vals.astype(str))
                                exact_ref_lower[cfg.reference_col] = r_vals_lower
                            scores = (s_vals_lower[:, None] == r_vals_lower[None, :]).astype(
                                np.float32
                            ) * 100.0
                        else:
                            scores = rf_process.cdist(
                                s_vals,
                                r_vals,
                                scorer=cfg.algo_func,
                                score_cutoff=0,
                                workers=rf_workers,
                            )

                        # Apply the same "skip empty values" rule as calculate_similarity().
                        valid = s_nonempty[:, None] & r_nonempty[None, :]
                        if valid.dtype != bool:
                            valid = valid.astype(bool)

                        scores = (scores * valid).astype(np.float32, copy=False)
                        total_score += scores * cfg.weight
                        total_weight += valid.astype(np.float32) * cfg.weight

                    # Weighted average over fields that were actually scored.
                    with np.errstate(divide="ignore", invalid="ignore"):
                        avg_scores = np.divide(
                            total_score,
                            total_weight,
                            out=np.zeros_like(total_score),
                            where=(total_weight > 0),
                        )

                    hit_rows, hit_cols = np.where(avg_scores >= float(self.threshold))
                    if hit_rows.size == 0:
                        continue

                    for r_off, c_idx in zip(hit_rows.tolist(), hit_cols.tolist()):
                        s_pos = start + r_off
                        src_index_label = src_index_labels[s_pos]
                        ref_index_label = ref_index_labels[c_idx]

                        score = float(avg_scores[r_off, c_idx])
                        s_id_val = (
                            src_id_arr[s_pos] if src_id_arr is not None else src_index_label
                        )
                        r_id_val = (
                            ref_id_arr[c_idx] if ref_id_arr is not None else ref_index_label
                        )

                        s_row = source_df.iloc[s_pos]
                        r_row = ref_df.iloc[c_idx]

                        match_record = {
                            "source_index": src_index_label,
                            "reference_index": ref_index_label,
                            "source_id": s_id_val,
                            "reference_id": r_id_val,
                            "score": score / 100.0,  # Normalize to 0-1
                            "match_type": "exact" if score >= 95 else "fuzzy",
                        }

                        for mapping in self.mappings:
                            src_col = mapping.get("source")
                            ref_col = mapping.get("reference")
                            if src_col and src_col in source_df.columns:
                                match_record[f"source_{src_col}"] = str(s_row[src_col])
                            if ref_col and ref_col in ref_df.columns:
                                match_record[f"ref_{ref_col}"] = str(r_row[ref_col])

                        matches.append(match_record)

                log.info(
                    f"Matching complete: {total_comparisons} comparisons, {len(matches)} matches found"
                )

                return pd.DataFrame(matches) if matches else pd.DataFrame()
            except Exception as e:
                log.warning(f"Vectorized match failed; falling back to nested loop: {e}")

        # Fallback: simple nested loop (original implementation)
        matches: list[dict] = []
        total_comparisons = 0

        for i, source_row in source_df.iterrows():
            for j, ref_row in ref_df.iterrows():
                total_comparisons += 1

                # Calculate similarity
                score = self.calculate_similarity(source_row, ref_row)

                # Check if above threshold
                if score >= self.threshold:
                    match_record = {
                        "source_index": i,
                        "reference_index": j,
                        "source_id": source_row.get(source_id, i) if source_id else i,
                        "reference_id": ref_row.get(reference_id, j)
                        if reference_id
                        else j,
                        "score": score / 100.0,  # Normalize to 0-1
                        "match_type": "exact" if score >= 95 else "fuzzy",
                    }

                    # Add mapped column values for display
                    for mapping in self.mappings:
                        src_col = mapping.get("source")
                        ref_col = mapping.get("reference")
                        if src_col and src_col in source_row.index:
                            match_record[f"source_{src_col}"] = str(source_row[src_col])
                        if ref_col and ref_col in ref_row.index:
                            match_record[f"ref_{ref_col}"] = str(ref_row[ref_col])

                    matches.append(match_record)

                # Log progress every 1000 comparisons
                if total_comparisons % 1000 == 0:
                    log.debug(
                        f"Processed {total_comparisons} comparisons, found {len(matches)} matches"
                    )

        log.info(
            f"Matching complete: {total_comparisons} comparisons, {len(matches)} matches found"
        )

        # Convert to DataFrame
        if matches:
            return pd.DataFrame(matches)
        else:
            return pd.DataFrame()

    def dedupe(self, df: pd.DataFrame, id_column: Optional[str] = None) -> pd.DataFrame:
        """
        Perform deduplication within a single dataframe

        Returns:
            DataFrame with duplicate pairs
        """
        log.info(f"Starting deduplication with {len(df)} records")

        duplicates = []

        # If no mappings, use all non-ID columns
        if not self.mappings:
            for col in df.columns:
                if not col.endswith("_id") and col.lower() != "id":
                    self.add_mapping(col, col, 1.0)

        # Compare each record with all others
        processed_pairs = set()

        for i in range(len(df)):
            for j in range(
                i + 1, len(df)
            ):  # Only compare with records after to avoid duplicates
                pair_key = tuple(sorted([i, j]))
                if pair_key in processed_pairs:
                    continue
                processed_pairs.add(pair_key)

                # Calculate similarity
                score = self.calculate_similarity(df.iloc[i], df.iloc[j])

                # Check if above threshold
                if score >= self.threshold:
                    dupe_record = {
                        "record_1": df.iloc[i].get(id_column, i) if id_column else i,
                        "record_2": df.iloc[j].get(id_column, j) if id_column else j,
                        "cluster_id": f"C{len(duplicates):04d}",
                        "score": score / 100.0,
                        "is_duplicate": True,
                    }

                    # Add column values
                    for mapping in self.mappings:
                        col = mapping["source"]
                        if col in df.columns:
                            dupe_record[f"record_1_{col}"] = str(df.iloc[i][col])
                            dupe_record[f"record_2_{col}"] = str(df.iloc[j][col])

                    duplicates.append(dupe_record)

        log.info(f"Deduplication complete: Found {len(duplicates)} duplicate pairs")

        if duplicates:
            return pd.DataFrame(duplicates)
        else:
            return pd.DataFrame()
