"""
Reason Code Generation for Match Results
=========================================
Generates human-readable reason chips and evidence JSON for match results.
"""

from typing import Dict, List, Tuple, Optional, Any
import pandas as pd

# Safe fuzzy matching import with fallback
try:
    from rapidfuzz import fuzz as _rf_fuzz

    _token_set = lambda a, b: _rf_fuzz.token_set_ratio(a, b)
except Exception:
    try:
        from fuzzywuzzy import fuzz as _fz_fuzz

        _token_set = lambda a, b: _fz_fuzz.token_set_ratio(a, b)
    except Exception:
        _token_set = None  # Force fallback to estimation

# Chip priority order for deterministic output
CHIP_PRIORITY = [
    "DOM",
    "FAM",
    "NAME90",
    "NAME80",
    "NAME70",
    "NAME*",
    "WEB",
    "GEO",
    "IND",
    "SIZE",
    "B2C?",
]
MAX_CHIPS = 5


def generate_reason_codes(
    source_row: pd.Series,
    ref_row: pd.Series,
    score: float,
    mappings: List[Dict[str, Any]],
    b2c_flag: bool = False,
) -> Tuple[List[str], Dict[str, Any]]:
    """
    Generate reason chips and evidence JSON for a match.

    Returns:
        (chips, evidence_dict) where chips is a list of reason codes
        and evidence_dict is the structured evidence
    """
    chips = []
    evidence = {"anchors": [], "score": round(score, 1)}

    # Check domain match
    domain_matched = False
    family_matched = False

    # Find domain mapping
    domain_map = None
    for m in mappings:
        if (
            m.get("preferred_algo") == "Domain"
            or m.get("similarity_measure") == "Domain"
        ):
            domain_map = m
            break

    if domain_map:
        src_col = domain_map.get("source")
        ref_col = domain_map.get("ref") or domain_map.get("reference")

        if src_col and ref_col:
            src_val = str(source_row.get(src_col, "")).lower().strip()
            ref_val = str(ref_row.get(ref_col, "")).lower().strip()

            if src_val and ref_val:
                if src_val == ref_val:
                    chips.append("DOM")
                    evidence["anchors"].append("domain")
                    evidence["domain"] = {"exact": True}
                    domain_matched = True
                elif _is_domain_family(src_val, ref_val):
                    chips.append("FAM")
                    evidence["anchors"].append("family")
                    evidence["family"] = {"hit": True}
                    family_matched = True

    # Check company/name match
    # Only compute NAME chips if no domain match (or if we can actually calculate it)
    if not (domain_matched or family_matched):
        name_map = None
        for m in mappings:
            algo = m.get("preferred_algo") or m.get("similarity_measure", "")
            if "token" in algo.lower() and m.get("weight", 0) >= 0.2:
                name_map = m
                break

        if name_map:
            src_col = name_map.get("source")
            ref_col = name_map.get("ref") or name_map.get("reference")

            if src_col and ref_col:
                src_val = str(source_row.get(src_col, "")).strip()
                ref_val = str(ref_row.get(ref_col, "")).strip()

                if src_val and ref_val:
                    # Compute TokenSet ratio if available
                    if _token_set:
                        try:
                            name_score = _token_set(src_val, ref_val)
                            if name_score >= 90:
                                chips.append("NAME90")
                                evidence["anchors"].append("name")
                                evidence["name"] = {
                                    "algo": "TokenSet",
                                    "score": name_score,
                                }
                            elif name_score >= 80:
                                chips.append("NAME80")
                                evidence["anchors"].append("name")
                                evidence["name"] = {
                                    "algo": "TokenSet",
                                    "score": name_score,
                                }
                            elif name_score >= 70:
                                chips.append("NAME70")
                                evidence["anchors"].append("name")
                                evidence["name"] = {
                                    "algo": "TokenSet",
                                    "score": name_score,
                                }
                        except:
                            # Error in computation, use fallback
                            if score >= 80:
                                chips.append("NAME80")
                                evidence["anchors"].append("name")
                                evidence["name"] = {"algo": "TokenSet", "score": 80}
                    else:
                        # No fuzzy lib available, estimate from overall score
                        if score >= 80:
                            chips.append("NAME80")
                            evidence["anchors"].append("name")
                            evidence["name"] = {"algo": "TokenSet", "score": 80}

    # Check website match
    website_matched = False
    for col in source_row.index:
        if "website" in col.lower() or "url" in col.lower():
            src_web = source_row[col]
            # Find corresponding ref column
            for ref_col in ref_row.index:
                if "website" in ref_col.lower() or "url" in ref_col.lower():
                    ref_web = ref_row[ref_col]
                    if src_web and ref_web:
                        src_host = _extract_host(str(src_web))
                        ref_host = _extract_host(str(ref_web))
                        if src_host and ref_host and src_host == ref_host:
                            chips.append("WEB")
                            evidence["anchors"].append("website")
                            evidence["website"] = {"match": True}
                            website_matched = True
                            break
            if website_matched:
                break

    # Check geo match
    geo_matched = False
    city_match = False
    state_match = False

    for col in source_row.index:
        col_lower = col.lower()
        if "city" in col_lower:
            src_city = source_row[col]
            for ref_col in ref_row.index:
                if "city" in ref_col.lower():
                    ref_city = ref_row[ref_col]
                    if (
                        src_city
                        and ref_city
                        and str(src_city).lower() == str(ref_city).lower()
                    ):
                        city_match = True
                        break
        elif "state" in col_lower or "province" in col_lower:
            src_state = source_row[col]
            for ref_col in ref_row.index:
                ref_col_lower = ref_col.lower()
                if "state" in ref_col_lower or "province" in ref_col_lower:
                    ref_state = ref_row[ref_col]
                    if (
                        src_state
                        and ref_state
                        and str(src_state).lower() == str(ref_state).lower()
                    ):
                        state_match = True
                        break

    if city_match or state_match:
        chips.append("GEO")
        evidence["anchors"].append("geo")
        evidence["geo"] = {"city": city_match, "state": state_match}
        geo_matched = True

    # Add B2C flag if present
    if b2c_flag:
        chips.append("B2C?")
        evidence["b2c"] = True

    # Limit anchors to top 5
    evidence["anchors"] = evidence["anchors"][:5]

    # Deterministic chip ordering and deduplication
    chips = list(set(chips))  # Deduplicate
    chips = sorted(
        chips, key=lambda c: CHIP_PRIORITY.index(c) if c in CHIP_PRIORITY else 999
    )
    chips = chips[:MAX_CHIPS]  # Cap at 5 chips

    return chips, evidence


def _extract_host(url: str) -> Optional[str]:
    """Extract and normalize host from URL."""
    if not url:
        return None
    import re

    url = url.lower().strip()
    url = re.sub(r"^https?://", "", url)
    url = re.sub(r"^www\.", "", url)
    url = url.split("/")[0]
    url = re.sub(r":\d+$", "", url)
    url = url.rstrip(".")
    return url if url and "." in url else None


def _is_domain_family(
    domain1: str, domain2: str, family_domains: List[str] = None
) -> bool:
    """Check if two domains are in the same family."""
    # If family_domains provided, check if ref domain is in expanded family
    if family_domains:
        return domain2 in family_domains and domain1 != domain2

    # Fallback: Simple heuristic - check if they share the same base domain
    if not domain1 or not domain2 or domain1 == domain2:
        return False

    # Extract base domains (last two parts)
    parts1 = domain1.split(".")
    parts2 = domain2.split(".")

    if len(parts1) >= 2 and len(parts2) >= 2:
        base1 = ".".join(parts1[-2:])
        base2 = ".".join(parts2[-2:])
        return base1 == base2 and domain1 != domain2

    return False


def update_match_status_with_reasons(
    df: pd.DataFrame, threshold: float = 75, require_domain_for_match: bool = True
) -> pd.DataFrame:
    """
    Update match_status based on score and reason codes.

    Account Match requires threshold AND (DOM or FAM) if require_domain_for_match=True.
    This must be called LAST to ensure nothing overrides the domain requirement.
    """
    if "reasons" not in df.columns:
        return df

    def derive_status(score: float, reason_str: str, explanation: str = "") -> str:
        """Derive status from score and reason chips."""
        chips = reason_str.split(",") if reason_str else []
        has_domain = "DOM" in chips or "FAM" in chips

        # Check for row-level fallback (no domain candidates found)
        is_fallback = "[NO_DOM_CANDIDATES]" in explanation if explanation else False

        # Account Match: High score WITH domain backing AND not a fallback
        if score >= threshold and has_domain and not is_fallback:
            return "Account Match"

        # Fallback rows are ALWAYS capped at Possible Match
        if is_fallback:
            # Add NAME* chip if high scoring name match
            if score >= threshold - 10:
                return "Possible Match"
            return "No Match"

        # Possible Match: Medium score OR high score without domain
        if score >= threshold - 10:
            return "Possible Match"

        # Special case: Very high name match without domain
        if score >= threshold and "NAME90" in chips and not has_domain:
            return "Possible Match"

        return "No Match"

    # Vectorized application for performance
    df["match_status"] = df.apply(
        lambda row: derive_status(
            row.get("match_score", row.get("score", 0)),
            row.get("reasons", ""),
            row.get("explanation", ""),
        ),
        axis=1,
    )

    return df


def add_reason_codes_to_results(
    result_df: pd.DataFrame,
    source_df: pd.DataFrame,
    reference_df: pd.DataFrame,
    mappings: List[Dict[str, Any]],
    source_id_col: str = None,
    ref_id_col: str = None,
) -> pd.DataFrame:
    """
    Add reason codes to result dataframe using keyed merge.

    This is the main entry point for the worker to call.
    """
    if result_df.empty:
        result_df["reasons"] = ""
        return result_df

    # Build evidence dataframe with proper keys
    evidence_rows = []

    for idx, row in result_df.iterrows():
        # Get source and ref IDs for alignment
        src_id = row.get(source_id_col) if source_id_col else None
        ref_id = row.get(ref_id_col) if ref_id_col else None

        # Find matching rows
        if src_id is not None and source_id_col in source_df.columns:
            src_rows = source_df[source_df[source_id_col] == src_id]
            src_row = src_rows.iloc[0] if not src_rows.empty else pd.Series()
        else:
            src_row = pd.Series()

        if ref_id is not None and ref_id_col in reference_df.columns:
            ref_rows = reference_df[reference_df[ref_id_col] == ref_id]
            ref_row = ref_rows.iloc[0] if not ref_rows.empty else pd.Series()
        else:
            ref_row = pd.Series()

        # Check B2C flag
        b2c_flag = row.get("b2c_flag", False)

        # Generate codes
        score = row.get("match_score", row.get("score", 0))
        chips, evidence = generate_reason_codes(
            src_row, ref_row, score, mappings, b2c_flag
        )

        # Check if this is a fallback row (no domain candidates)
        explanation = row.get("explanation", "")
        if "[NO_DOM_CANDIDATES]" in explanation:
            # Add NAME* chip to indicate name-only fallback match
            if (
                "NAME90" not in chips
                and "NAME80" not in chips
                and "NAME70" not in chips
            ):
                if score >= 70:  # High name score
                    chips.append("NAME*")

        # Create evidence row with key
        ev_row = {
            "_merge_key": src_id
            if src_id is not None
            else idx,  # Use source_id or index
            "reasons": ",".join(chips),
        }
        evidence_rows.append(ev_row)

    # Create evidence dataframe
    evidence_df = pd.DataFrame(evidence_rows)

    # Add merge key to result_df
    if source_id_col and source_id_col in result_df.columns:
        result_df["_merge_key"] = result_df[source_id_col]
    else:
        result_df["_merge_key"] = result_df.index

    # Merge evidence onto results
    result_df = result_df.merge(
        evidence_df, on="_merge_key", how="left", suffixes=("_old", "")
    )

    # Drop old columns and merge key
    result_df = result_df.drop(columns=["_merge_key", "reasons_old"], errors="ignore")

    # Fill any missing values
    result_df["reasons"] = result_df["reasons"].fillna("")

    # Update match_status based on reasons (this must be last)
    result_df = update_match_status_with_reasons(result_df)

    return result_df
