from __future__ import annotations

import asyncio
import inspect
from contextlib import suppress
from typing import Any, Callable, Dict, List, Optional, Set

import pandas as pd

from ..intelligence.similarity import fuzz
from ..services.domain_utils import extract_registrable_domains, normalize_domain_series
from ..services.match_gateway import MatchGateway, MatchResult


class IDHunterGatewayAdapter:
    """Adapts ID Hunter to MatchGateway."""

    DOMAIN_NORM_COL = "__domain_norm"
    DOMAIN_REG_COL = "__domain_reg"
    NAME_GATE_THRESHOLD = 0.85
    PASS2_HEARTBEAT_INTERVAL_S = 5.0
    PASS2_HEARTBEAT_MAX_PROGRESS = 88

    def __init__(self, gateway: MatchGateway):
        self.gateway = gateway

    @classmethod
    async def create(cls) -> "IDHunterGatewayAdapter":
        gateway = await MatchGateway.create()
        return cls(gateway)

    async def match_accounts(
        self,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        threshold: float = 0.85,
        use_domain: bool = True,
        use_fuzzy: bool = True,
        progress_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
    ) -> MatchResult:
        source_df, reference_df = self._ensure_id_columns(source_df, reference_df)
        source_df = self._add_domain_columns(source_df)
        reference_df = self._add_domain_columns(reference_df)

        mappings = self._build_mappings(
            source_df, reference_df, use_domain, use_fuzzy=use_fuzzy
        )
        if not mappings:
            raise ValueError("No valid mappings available for ID Hunter matching.")

        await self._emit_progress(
            progress_hook,
            stage="pass1_start",
            progress=55,
            message="Running Account Hunter pass 1/2 (exact domain + company name).",
            source_rows=len(source_df),
            reference_rows=len(reference_df),
        )

        primary_result = await self.gateway.match(
            source_df,
            reference_df,
            threshold=threshold,
            mappings=mappings,
            options={"use_row_candidates": False},
        )
        matched_source_ids = self._extract_matched_source_ids(
            primary_result.matches,
            threshold=threshold,
        )
        total_source_rows = len(source_df)
        pass1_match_count = (
            int(len(primary_result.matches)) if primary_result.matches is not None else 0
        )
        unresolved_count = max(0, total_source_rows - len(matched_source_ids))

        await self._emit_progress(
            progress_hook,
            stage="pass1_done",
            progress=70,
            message=(
                "Account Hunter pass 1/2 complete: "
                f"{len(matched_source_ids)} matched, {unresolved_count} unresolved."
            ),
            pass1_matches=pass1_match_count,
            pass1_matched_rows=len(matched_source_ids),
            pass1_unmatched_rows=unresolved_count,
            source_rows=total_source_rows,
        )

        if not use_domain:
            await self._emit_progress(
                progress_hook,
                stage="pass2_skipped",
                progress=86,
                message="Skipping Account Hunter pass 2/2 (domain fallback disabled).",
                reason="domain_matching_disabled",
            )
            return primary_result

        if matched_source_ids:
            source_ids = source_df["__source_id"].astype(str)
            unresolved_source_df = source_df.loc[~source_ids.isin(matched_source_ids)].copy()
        else:
            unresolved_source_df = source_df

        if unresolved_source_df.empty:
            await self._emit_progress(
                progress_hook,
                stage="pass2_skipped",
                progress=86,
                message="Skipping Account Hunter pass 2/2 (all rows matched in pass 1).",
                reason="all_rows_matched_in_pass1",
            )
            return primary_result

        await self._emit_progress(
            progress_hook,
            stage="pass2_start",
            progress=76,
            message=(
                "Running Account Hunter pass 2/2 "
                f"(registrable-domain fallback) on {len(unresolved_source_df)} rows."
            ),
            pass2_scope_rows=len(unresolved_source_df),
            source_rows=total_source_rows,
        )

        fallback_result = await self._match_on_registrable_domain(
            unresolved_source_df,
            reference_df,
            threshold=threshold,
            use_fuzzy=use_fuzzy,
            progress_hook=progress_hook,
        )
        if fallback_result is None or fallback_result.matches.empty:
            return primary_result

        combined = pd.concat(
            [primary_result.matches, fallback_result.matches], ignore_index=True
        )
        stats = self._build_stats(combined, threshold)
        return MatchResult(
            matches=combined,
            stats=stats,
            config_used=primary_result.config_used,
            elapsed_ms=primary_result.elapsed_ms,
        )

    def _ensure_id_columns(
        self, source_df: pd.DataFrame, reference_df: pd.DataFrame
    ) -> tuple[pd.DataFrame, pd.DataFrame]:
        source_df = source_df.copy()
        reference_df = reference_df.copy()

        if "__source_id" not in source_df.columns:
            if "row_id" in source_df.columns:
                source_df["__source_id"] = source_df["row_id"]
            else:
                source_df["__source_id"] = range(len(source_df))

        if "__reference_id" not in reference_df.columns:
            if "sf_id" in reference_df.columns:
                reference_df["__reference_id"] = reference_df["sf_id"]
            else:
                reference_df["__reference_id"] = range(len(reference_df))

        return source_df, reference_df

    def _add_domain_columns(self, df: pd.DataFrame) -> pd.DataFrame:
        df = df.copy()
        if "domain" in df.columns:
            domain_norm = normalize_domain_series(df["domain"])
        else:
            domain_norm = pd.Series([None] * len(df))
        df[self.DOMAIN_NORM_COL] = domain_norm
        df[self.DOMAIN_REG_COL] = extract_registrable_domains(domain_norm)
        return df

    def _build_stats(self, matches: pd.DataFrame, threshold: float) -> Dict[str, Any]:
        if matches is None or matches.empty:
            return {"total": 0}
        above = 0
        if "score" in matches.columns:
            above = int((matches["score"] >= threshold).sum())
        return {"total": len(matches), "above_threshold": above}

    def _build_mappings(
        self,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        use_domain: bool,
        use_fuzzy: bool = True,
    ) -> List[Dict[str, Any]]:
        mappings: List[Dict[str, Any]] = []
        if (
            use_domain
            and self.DOMAIN_NORM_COL in source_df.columns
            and self.DOMAIN_NORM_COL in reference_df.columns
        ):
            mappings.append(
                {
                    "source": self.DOMAIN_NORM_COL,
                    "reference": self.DOMAIN_NORM_COL,
                    "weight": 0.6,
                    "algorithm": "Exact",
                }
            )
        if (
            use_fuzzy
            and "company_name" in source_df.columns
            and "company_name" in reference_df.columns
        ):
            mappings.append(
                {
                    "source": "company_name",
                    "reference": "company_name",
                    "weight": 0.4,
                    "algorithm": "WRatio",
                }
            )
        return mappings

    async def _match_on_registrable_domain(
        self,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
        threshold: float,
        use_fuzzy: bool = True,
        progress_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
    ) -> Optional[MatchResult]:
        if (
            self.DOMAIN_REG_COL not in source_df.columns
            or self.DOMAIN_REG_COL not in reference_df.columns
        ):
            await self._emit_progress(
                progress_hook,
                stage="pass2_skipped",
                progress=86,
                message="Skipping Account Hunter pass 2/2 (registrable domain columns missing).",
                reason="registrable_domain_columns_missing",
            )
            return None

        src_subset = source_df[source_df[self.DOMAIN_REG_COL].notna()].copy()
        ref_subset = reference_df[reference_df[self.DOMAIN_REG_COL].notna()].copy()
        if src_subset.empty or ref_subset.empty:
            await self._emit_progress(
                progress_hook,
                stage="pass2_skipped",
                progress=86,
                message="Skipping Account Hunter pass 2/2 (no registrable-domain candidates).",
                reason="registrable_domain_scope_empty",
                pass2_source_rows=len(src_subset),
                pass2_reference_rows=len(ref_subset),
            )
            return None

        mappings = [
            {
                "source": self.DOMAIN_REG_COL,
                "reference": self.DOMAIN_REG_COL,
                "weight": 1.0 if not use_fuzzy else 0.6,
                "algorithm": "Exact",
            }
        ]
        if use_fuzzy:
            mappings.append(
                {
                    "source": "company_name",
                    "reference": "company_name",
                    "weight": 0.4,
                    "algorithm": "WRatio",
                }
            )

        heartbeat_task: Optional[asyncio.Task[Any]] = None
        try:
            heartbeat_task = asyncio.create_task(
                self._pass2_heartbeat(progress_hook, unresolved_rows=len(src_subset))
            )
            result = await self.gateway.match(
                src_subset,
                ref_subset,
                threshold=threshold,
                mappings=mappings,
                options={"use_row_candidates": False},
            )
        finally:
            if heartbeat_task is not None:
                heartbeat_task.cancel()
                with suppress(asyncio.CancelledError):
                    await heartbeat_task

        filtered = self._filter_reg_domain_matches(
            result.matches, source_df, reference_df
        )
        if filtered is None or filtered.empty:
            await self._emit_progress(
                progress_hook,
                stage="pass2_done",
                progress=86,
                message=(
                    "Account Hunter pass 2/2 complete: no additional matches from "
                    "registrable-domain fallback."
                ),
                pass2_matches=0,
            )
            return None
        await self._emit_progress(
            progress_hook,
            stage="pass2_done",
            progress=86,
            message=(
                "Account Hunter pass 2/2 complete: "
                f"{len(filtered)} additional fallback matches."
            ),
            pass2_matches=len(filtered),
        )
        return MatchResult(
            matches=filtered,
            stats=self._build_stats(filtered, threshold),
            config_used=result.config_used,
            elapsed_ms=result.elapsed_ms,
        )

    async def _emit_progress(
        self,
        progress_hook: Optional[Callable[[Dict[str, Any]], Any]],
        **payload: Any,
    ) -> None:
        if progress_hook is None:
            return
        try:
            maybe_awaitable = progress_hook(payload)
            if inspect.isawaitable(maybe_awaitable):
                await maybe_awaitable
        except Exception:
            return

    async def _pass2_heartbeat(
        self,
        progress_hook: Optional[Callable[[Dict[str, Any]], Any]],
        *,
        unresolved_rows: int,
    ) -> None:
        if progress_hook is None:
            return
        progress = 77
        while True:
            await asyncio.sleep(self.PASS2_HEARTBEAT_INTERVAL_S)
            await self._emit_progress(
                progress_hook,
                stage="pass2_heartbeat",
                progress=progress,
                message=(
                    "Account Hunter pass 2/2 is still running "
                    f"({unresolved_rows} unresolved rows)."
                ),
                pass2_scope_rows=unresolved_rows,
            )
            progress = min(progress + 1, self.PASS2_HEARTBEAT_MAX_PROGRESS)

    def _extract_matched_source_ids(
        self,
        matches: Optional[pd.DataFrame],
        *,
        threshold: float,
    ) -> Set[str]:
        if matches is None or matches.empty:
            return set()

        df = matches
        if "score" in df.columns:
            score_series = pd.to_numeric(df["score"], errors="coerce")
            df = df.loc[score_series >= float(threshold)]

        for col in ("source_id", "__source_id", "row_id"):
            if col in df.columns:
                return set(df[col].dropna().astype(str))
        return set()

    def _filter_reg_domain_matches(
        self,
        matches: pd.DataFrame,
        source_df: pd.DataFrame,
        reference_df: pd.DataFrame,
    ) -> pd.DataFrame:
        if matches is None or matches.empty:
            return pd.DataFrame()
        if "source_id" not in matches.columns or "reference_id" not in matches.columns:
            return pd.DataFrame()

        src_index = source_df["__source_id"].astype(str)
        ref_index = reference_df["__reference_id"].astype(str)

        src_domain = pd.Series(source_df[self.DOMAIN_NORM_COL].values, index=src_index)
        ref_domain = pd.Series(
            reference_df[self.DOMAIN_NORM_COL].values, index=ref_index
        )
        src_reg = pd.Series(source_df[self.DOMAIN_REG_COL].values, index=src_index)
        ref_reg = pd.Series(reference_df[self.DOMAIN_REG_COL].values, index=ref_index)
        src_name_col = (
            source_df["company_name"]
            if "company_name" in source_df.columns
            else pd.Series([None] * len(source_df))
        )
        ref_name_col = (
            reference_df["company_name"]
            if "company_name" in reference_df.columns
            else pd.Series([None] * len(reference_df))
        )
        src_name = pd.Series(src_name_col.values, index=src_index)
        ref_name = pd.Series(ref_name_col.values, index=ref_index)

        df = matches.copy()
        df["__src_id"] = df["source_id"].astype(str)
        df["__ref_id"] = df["reference_id"].astype(str)

        df["__src_domain"] = df["__src_id"].map(src_domain)
        df["__ref_domain"] = df["__ref_id"].map(ref_domain)
        df["__src_reg"] = df["__src_id"].map(src_reg)
        df["__ref_reg"] = df["__ref_id"].map(ref_reg)

        reg_match = df["__src_reg"].notna() & (df["__src_reg"] == df["__ref_reg"])
        host_match = df["__src_domain"].notna() & (df["__src_domain"] == df["__ref_domain"])
        candidate = df[reg_match & ~host_match].copy()
        if candidate.empty:
            return pd.DataFrame()

        candidate["__src_name"] = candidate["__src_id"].map(src_name).fillna("")
        candidate["__ref_name"] = candidate["__ref_id"].map(ref_name).fillna("")

        def _name_score(row) -> float:
            try:
                src = str(row["__src_name"] or "").strip()
                ref = str(row["__ref_name"] or "").strip()
                if not src or not ref:
                    return 0.0
                return float(fuzz.WRatio(src, ref)) / 100.0
            except Exception:
                return 0.0

        candidate["__name_score"] = candidate.apply(_name_score, axis=1)
        candidate = candidate[candidate["__name_score"] >= self.NAME_GATE_THRESHOLD]
        if candidate.empty:
            return pd.DataFrame()

        drop_cols = [
            "__src_id",
            "__ref_id",
            "__src_domain",
            "__ref_domain",
            "__src_reg",
            "__ref_reg",
            "__src_name",
            "__ref_name",
            "__name_score",
        ]
        return candidate.drop(columns=[c for c in drop_cols if c in candidate.columns])
