"""
Local deduplication fallback (non-canonical) with simple clustering.

This is intended as a --local fallback when EngineClient is unavailable.
It uses a lightweight similarity approach over specified key columns and
produces a MergePlan-like JSON structure.
"""

from __future__ import annotations

import csv
import json
import re
import asyncio
import logging
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from difflib import SequenceMatcher

logger = logging.getLogger(__name__)

DEDUPE_BLOCKING_MIN_ROWS = 500


@dataclass
class Cluster:
    members: List[Dict[str, Any]]
    golden_index: int
    merge_confidence: float


def _normalize(text: str) -> str:
    return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()


def _similarity(a: str, b: str) -> float:
    return SequenceMatcher(None, a, b).ratio() * 100


def _load_csv(csv_path: Path) -> Tuple[List[Dict[str, str]], List[str]]:
    with csv_path.open(newline="", encoding="utf-8-sig") as f:
        reader = list(csv.DictReader(f))
    if not reader:
        return [], []
    return reader, reader[0].keys()


def _choose_golden(members: List[Dict[str, Any]]) -> int:
    # Pick the row with fewest empty fields; fallback to first.
    best_idx = 0
    best_score = -1
    for idx, row in enumerate(members):
        score = len([v for v in row.values() if v not in (None, "", " ")])
        if score > best_score:
            best_score = score
            best_idx = idx
    return best_idx


def _select_blocking_column(
    key_columns: List[str], row: Dict[str, Any]
) -> Optional[str]:
    if not key_columns or not row:
        return None
    available = [col for col in key_columns if col in row]
    if not available:
        return None
    preferred_tokens = ("domain", "website", "email")
    for token in preferred_tokens:
        for col in available:
            if token in col.lower():
                return col
    return available[0]


def _cluster_rows(
    rows: List[Dict[str, str]],
    key_columns: List[str],
    threshold: int,
) -> List[Cluster]:
    clusters: List[Cluster] = []
    used = [False] * len(rows)

    norm_keys = []
    for row in rows:
        parts = []
        for col in key_columns:
            parts.append(_normalize(str(row.get(col, "") or "")))
        norm_keys.append(" ".join(parts).strip())

    for i, norm_i in enumerate(norm_keys):
        if used[i]:
            continue
        group = [rows[i]]
        used[i] = True
        for j in range(i + 1, len(rows)):
            if used[j]:
                continue
            sim = _similarity(norm_i, norm_keys[j])
            if sim >= threshold:
                used[j] = True
                group.append(rows[j])
        golden_idx = _choose_golden(group)
        clusters.append(Cluster(members=group, golden_index=golden_idx, merge_confidence=1.0))

    return clusters


def _extract_pairs(df: Any, id_field: str) -> List[Tuple[str, str]]:
    """
    Extract duplicate pairs from an engine output DataFrame.
    """
    try:
        import pandas as pd  # type: ignore
    except Exception as exc:  # pragma: no cover - optional dependency
        raise RuntimeError("pandas is required for engine dedup.") from exc

    pairs: List[Tuple[str, str]] = []
    columns = list(getattr(df, "columns", []))
    id_like = [col for col in columns if "id" in str(col).lower()]

    def _append_pairs(left: str, right: str) -> None:
        for _, row in df.iterrows():
            a = row.get(left)
            b = row.get(right)
            if pd.isna(a) or pd.isna(b):
                continue
            a_val = str(a).strip()
            b_val = str(b).strip()
            if not a_val or not b_val or a_val == b_val:
                continue
            pairs.append((a_val, b_val))

    if id_field in columns and len(id_like) >= 2:
        for col in id_like:
            if col == id_field:
                continue
            _append_pairs(id_field, col)
        if pairs:
            return pairs

    if len(id_like) >= 2:
        _append_pairs(id_like[0], id_like[1])

    return pairs


def _clusters_from_pairs(
    rows: List[Dict[str, Any]],
    pairs: List[Tuple[str, str]],
    id_field: str,
) -> List[Cluster]:
    # Map row ids to row data (ids are assigned as string indices)
    id_to_row = {str(idx): row for idx, row in enumerate(rows)}
    parents: Dict[str, str] = {row_id: row_id for row_id in id_to_row}

    def _find(x: str) -> str:
        while parents[x] != x:
            parents[x] = parents[parents[x]]
            x = parents[x]
        return x

    def _union(a: str, b: str) -> None:
        root_a = _find(a)
        root_b = _find(b)
        if root_a != root_b:
            parents[root_b] = root_a

    for left, right in pairs:
        if left in parents and right in parents:
            _union(left, right)

    # Ensure singletons are present
    groups: Dict[str, List[str]] = defaultdict(list)
    for row_id in parents:
        groups[_find(row_id)].append(row_id)

    clusters: List[Cluster] = []
    for member_ids in groups.values():
        members = [id_to_row.get(mid, {}) for mid in member_ids]
        golden_idx = _choose_golden(members)
        clusters.append(Cluster(members=members, golden_index=golden_idx, merge_confidence=1.0))

    return clusters


def _engine_cluster(
    rows: List[Dict[str, Any]],
    key_columns: List[str],
    threshold: int,
) -> List[Cluster]:
    try:
        from fmatch.saas.services.match_gateway import MatchGateway  # type: ignore
    except Exception as exc:  # pragma: no cover - optional dependency
        raise RuntimeError("MatchGateway is not available") from exc

    try:
        import pandas as pd  # type: ignore
    except Exception as exc:
        raise RuntimeError("pandas is required for engine dedup.") from exc

    id_field = "__source_id"
    df = pd.DataFrame(rows)
    if id_field not in df.columns:
        df[id_field] = [str(idx) for idx in range(len(df))]

    mappings = [
        {"source": col, "reference": col, "preferred_algo": "WRatio"}
        for col in key_columns
        if rows and col in rows[0]
    ]

    async def _run() -> Any:
        gateway = await MatchGateway.create()
        return await gateway.dedup(df, threshold=threshold, mappings=mappings)

    try:
        result = asyncio.run(_run())
    except Exception as exc:  # pragma: no cover - engine runtime surface
        raise RuntimeError(f"Gateway dedup failed: {exc}") from exc

    df = getattr(result, "matches", None)
    if df is None:
        raise RuntimeError("Engine dedup did not return matches DataFrame.")

    pairs = _extract_pairs(df, id_field)
    return _clusters_from_pairs(rows, pairs, id_field)


def build_merge_plan(clusters: List[Cluster], threshold: int, engine_mode: bool) -> Dict[str, Any]:
    plan_clusters: List[Dict[str, Any]] = []
    for idx, cluster in enumerate(clusters, start=1):
        golden = cluster.members[cluster.golden_index]
        members = cluster.members
        plan_clusters.append(
            {
                "cluster_id": f"c{idx:04d}",
                "members": members,
                "golden_record": golden,
                "merge_confidence": cluster.merge_confidence,
            }
        )
    return {
        "$schema": "MergePlan/v1",
        "stats": {
            "clusters_found": len(plan_clusters),
            "duplicates_identified": sum(len(c.members) - 1 for c in clusters),
            "threshold": threshold,
        },
        "engine_mode": engine_mode,
        "local_fallback_warning": None if engine_mode else "Local dedup used; results may differ from EngineClient.",
        "clusters": plan_clusters,
    }


def dedup_local(
    csv_path: Path,
    key_columns: List[str],
    threshold: int,
    output_path: Path,
    overwrite: bool = False,
) -> Dict[str, Any]:
    rows, headers = _load_csv(csv_path)
    if not rows:
        raise ValueError("Input CSV is empty.")
    for col in key_columns:
        if col not in headers:
            raise ValueError(f"Key column '{col}' not found in CSV.")

    if output_path.exists() and not overwrite:
        raise FileExistsError(f"Output {output_path} exists. Use --overwrite.")

    clusters = _cluster_rows(rows, key_columns, threshold)
    plan = build_merge_plan(clusters, threshold=threshold, engine_mode=False)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")
    return plan


def dedup_engine(
    csv_path: Path,
    key_columns: List[str],
    threshold: int,
    output_path: Path,
    overwrite: bool = False,
) -> Dict[str, Any]:
    rows, headers = _load_csv(csv_path)
    if not rows:
        raise ValueError("Input CSV is empty.")
    for col in key_columns:
        if col not in headers:
            raise ValueError(f"Key column '{col}' not found in CSV.")

    if output_path.exists() and not overwrite:
        raise FileExistsError(f"Output {output_path} exists. Use --overwrite.")

    clusters = _engine_cluster(rows, key_columns, threshold)
    plan = build_merge_plan(clusters, threshold=threshold, engine_mode=True)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")
    return plan


def apply_merge_plan(plan_path: Path, source_csv: Path, output_path: Path, overwrite: bool = False) -> int:
    if output_path.exists() and not overwrite:
        raise FileExistsError(f"Output {output_path} exists. Use --overwrite.")

    with plan_path.open(encoding="utf-8") as f:
        plan = json.load(f)
    clusters = plan.get("clusters", [])

    rows, headers = _load_csv(source_csv)
    if not rows:
        raise ValueError("Source CSV is empty.")

    # Build a set of member dicts to skip (all but golden)
    to_skip = []
    for cluster in clusters:
        members = cluster.get("members", [])
        golden = cluster.get("golden_record")
        for member in members:
            if member != golden:
                to_skip.append(member)

    deduped_rows: List[Dict[str, Any]] = []
    for row in rows:
        if row in to_skip:
            continue
        deduped_rows.append(row)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=headers)
        writer.writeheader()
        writer.writerows(deduped_rows)

    return len(deduped_rows)


def dedup_enterprise(
    source_specs: List[Tuple[str, Path]],
    *,
    key_columns: List[str] | None,
    threshold: int | None,
    output_path: Path,
    review_output_path: Path | None = None,
    overwrite: bool = False,
    local: bool = False,
    survivor_policy: str = "crm_person",
    blocking_keys: List[str] | None = None,
    profile: str = "default",
    block_size_cap: int = 5000,
    limit_per_source: int | None = None,
) -> Dict[str, Any]:
    from g_gremlin.dedup_enterprise import build_enterprise_dedupe_plan

    return build_enterprise_dedupe_plan(
        source_specs,
        key_columns=key_columns,
        threshold=threshold,
        output_path=output_path,
        review_output_path=review_output_path,
        overwrite=overwrite,
        local=local,
        survivor_policy=survivor_policy,
        blocking_keys=blocking_keys,
        profile=profile,
        block_size_cap=block_size_cap,
        limit_per_source=limit_per_source,
    )
