import json
import math
from typing import Any, Dict, List, Optional, Sequence, Tuple

import numpy as np
import pandas as pd


_SOURCE_ID_HINTS: Sequence[str] = (
    "source_id",
    "src_id",
    "source",
    "left_id",
    "left_record_id",
    "source_record_id",
    "source_row_id",
    "source_idx",
    "source_index",
    "input_id",
    "input_record_id",
    "input_row_id",
    "row_id",
    "row_index",
    "record_id",
    "s_id",
    "s_record_id",
    "s_list_record_id",
    "s_row_id",
)

_REFERENCE_ID_HINTS: Sequence[str] = (
    "reference_id",
    "ref_id",
    "reference",
    "right_id",
    "match_id",
    "entity_id",
    "target_id",
    "r_id",
    "r_record_id",
)

_SCORE_HINTS: Sequence[str] = (
    "score",
    "confidence_score",
    "match_score",
    "confidence",
    "overall_score",
)

_DOMAIN_HINTS: Sequence[str] = (
    "is_domain_exact",
    "domain_exact",
    "domain_match_exact",
    "domain_exact_match",
    "website_exact_match",
    "domain_is_exact",
    "site_exact_match",
)

_NAME_SCORE_HINTS: Sequence[str] = (
    "name_score",
    "contact_name_score",
    "name_similarity",
    "normalized_name_score",
)

_DISTANCE_HINTS: Sequence[str] = (
    "distance",
    "edit_distance",
    "levenshtein",
    "geo_distance",
)


def _uniq_preserve(items: Sequence[Optional[str]]) -> List[str]:
    seen = set()
    ordered: List[str] = []
    for item in items:
        if not item:
            continue
        if item in seen:
            continue
        ordered.append(item)
        seen.add(item)
    return ordered


def _maybe_config_value(
    configuration: Optional[Dict[str, Any]], *keys: str
) -> Optional[str]:
    if not configuration:
        return None
    for key in keys:
        value = configuration.get(key)
        if isinstance(value, str) and value:
            return value
    return None


def _guess_id_column(
    frame: pd.DataFrame,
    configuration: Optional[Dict[str, Any]],
    hints: Sequence[str],
    additional_prefix: str,
) -> Optional[str]:
    candidates: List[str] = []

    config_candidate = _maybe_config_value(
        configuration,
        f"{additional_prefix}_id_column",
        f"{additional_prefix}_id",
        f"{additional_prefix}Id",
    )
    if config_candidate:
        candidates.append(config_candidate)

    candidates.extend(hints)

    for column in frame.columns:
        col_lower = column.lower()
        if col_lower in hints:
            candidates.append(column)
        if additional_prefix and col_lower.startswith(additional_prefix + "_"):
            if "id" in col_lower or "record" in col_lower or col_lower.endswith("idx"):
                candidates.append(column)
        if additional_prefix and col_lower.endswith(additional_prefix + "_id"):
            candidates.append(column)

    candidates = _uniq_preserve(candidates)
    for candidate in candidates:
        if candidate in frame.columns:
            return candidate
    return None


def _guess_source_column(
    frame: pd.DataFrame, configuration: Optional[Dict[str, Any]]
) -> Optional[str]:
    return _guess_id_column(frame, configuration, _SOURCE_ID_HINTS, "s")


def _guess_reference_column(
    frame: pd.DataFrame, configuration: Optional[Dict[str, Any]]
) -> Optional[str]:
    return _guess_id_column(frame, configuration, _REFERENCE_ID_HINTS, "r")


def _guess_score_column(frame: pd.DataFrame) -> Optional[str]:
    for hint in _SCORE_HINTS:
        if hint in frame.columns:
            return hint
    # fallback: any numeric column with "score" in name
    for column in frame.columns:
        col_lower = column.lower()
        if "score" in col_lower or "confidence" in col_lower:
            series = frame[column]
            if pd.api.types.is_numeric_dtype(series):
                return column
    return None


def _ensure_domain_flag(frame: pd.DataFrame) -> Tuple[Optional[str], List[str]]:
    created: List[str] = []
    for hint in _DOMAIN_HINTS:
        if hint in frame.columns:
            return hint, created

    if "field_score_details" not in frame.columns:
        return None, created

    col_name = "_domain_exact_flag"

    def _compute(value: Any) -> bool:
        if value is None:
            return False
        if isinstance(value, (list, tuple)):
            iterable = value
        else:
            try:
                iterable = json.loads(value)
                if not isinstance(iterable, list):
                    iterable = []
            except Exception:
                return False
        for entry in iterable:
            if not isinstance(entry, dict):
                continue
            field_name = str(entry.get("source_field") or "").lower()
            if any(token in field_name for token in ("domain", "website", "url")):
                try:
                    score = float(entry.get("score", 0))
                except Exception:
                    score = 0.0
                if score >= 100.0:
                    return True
        return False

    frame[col_name] = frame["field_score_details"].apply(_compute)
    created.append(col_name)
    return col_name, created


def _find_name_score_columns(frame: pd.DataFrame) -> List[str]:
    cols: List[str] = []
    for column in frame.columns:
        col_lower = column.lower()
        if any(hint in col_lower for hint in _NAME_SCORE_HINTS):
            cols.append(column)
    return cols


def _find_distance_columns(frame: pd.DataFrame) -> List[str]:
    cols: List[str] = []
    for column in frame.columns:
        col_lower = column.lower()
        if any(hint in col_lower for hint in _DISTANCE_HINTS):
            if pd.api.types.is_numeric_dtype(frame[column]):
                cols.append(column)
    return cols


def _fanout_stats(group_sizes: pd.Series) -> Dict[str, Any]:
    if group_sizes.empty:
        return {
            "unique_sources_w_candidates": 0,
            "sources_with_ties": 0,
            "max_candidates_per_source": 0,
            "avg_candidates_per_source": 0.0,
        }
    return {
        "unique_sources_w_candidates": int(group_sizes.shape[0]),
        "sources_with_ties": int((group_sizes > 1).sum()),
        "max_candidates_per_source": int(group_sizes.max()),
        "avg_candidates_per_source": float(group_sizes.mean()),
    }


def apply_best_match_per_source(
    frame: pd.DataFrame,
    configuration: Optional[Dict[str, Any]] = None,
    *,
    max_per_source: Optional[int] = None,
    logger: Any = None,
) -> Tuple[pd.DataFrame, Dict[str, Any]]:
    """Reduce candidate matches to top-N per source with stable tie-breakers.

    Parameters
    ----------
    frame:
        DataFrame containing candidate matches.
    configuration:
        Optional job configuration dict (used for ID hints).
    max_per_source:
        Maximum matches to keep per source. When `None` a value of 1 is assumed.
    logger:
        Optional logger for diagnostic output.
    """

    if frame is None or frame.empty:
        return frame, {}

    if max_per_source is None:
        max_per_source = 1
    else:
        try:
            max_per_source = int(max_per_source)
        except Exception:
            max_per_source = 1
        if max_per_source <= 0:
            max_per_source = 1

    work_df = frame.copy()

    source_col = _guess_source_column(work_df, configuration)
    if not source_col:
        if logger:
            logger.warning(
                "best-match reduction skipped: source identifier column not found"
            )
        return frame, {}

    score_col = _guess_score_column(work_df)
    if not score_col:
        if logger:
            logger.warning("best-match reduction skipped: score column not found")
        return frame, {}

    # Normalise score column to numeric for reliable sorting
    with np.errstate(invalid="ignore"):
        work_df[score_col] = pd.to_numeric(work_df[score_col], errors="coerce")

    temp_columns: List[str] = []
    domain_flag_col, created_cols = _ensure_domain_flag(work_df)
    temp_columns.extend(created_cols)

    name_score_cols = _find_name_score_columns(work_df)
    distance_cols = _find_distance_columns(work_df)

    sort_columns: List[str] = []
    ascending: List[bool] = []

    if domain_flag_col:
        sort_columns.append(domain_flag_col)
        ascending.append(False)

    sort_columns.append(score_col)
    ascending.append(False)

    for col in name_score_cols:
        sort_columns.append(col)
        ascending.append(False)

    if "r_tier" in work_df.columns:
        sort_columns.append("r_tier")
        ascending.append(True)

    for col in distance_cols:
        sort_columns.append(col)
        ascending.append(True)

    # Ensure deterministic ordering even without explicit tie-breakers
    sort_kind = "stable"
    try:
        work_df = work_df.sort_values(
            by=sort_columns, ascending=ascending, kind=sort_kind
        )
    except Exception:
        # If sorting fails (e.g., non-comparable data), fall back to default ordering
        work_df = work_df.sort_values(by=sort_columns, ascending=ascending)

    group_sizes = work_df.groupby(source_col, dropna=False).size()
    stats = {
        "candidates_total": int(len(work_df)),
    }
    stats.update(_fanout_stats(group_sizes))
    stats["max_per_source"] = int(max_per_source)

    if max_per_source == 1:
        reduced = work_df.drop_duplicates(subset=[source_col], keep="first")
    else:
        reduced = work_df.groupby(source_col, dropna=False).head(max_per_source)

    reduced = reduced.copy()
    stats["kept_rows"] = int(len(reduced))
    stats["best_match_applied"] = True
    stats["reduction_ratio"] = (
        float(len(reduced) / len(work_df)) if len(work_df) else 0.0
    )

    if logger:
        kept = stats["kept_rows"]
        total = stats["candidates_total"]
        ratio = total / kept if kept else math.inf
        logger.info(
            "best-match reduction: candidates=%d -> kept=%d (%.2fx)", total, kept, ratio
        )
        logger.info(
            "best-match tie stats: sources=%d with_ties=%d max_fanout=%d",
            stats.get("unique_sources_w_candidates", 0),
            stats.get("sources_with_ties", 0),
            stats.get("max_candidates_per_source", 0),
        )

    # Drop helper columns we created
    for column in temp_columns:
        if column in reduced.columns:
            reduced.drop(columns=[column], inplace=True)

    return reduced, stats
