"""Specialized detectors for B2B data fields."""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import pandas as pd
import numpy as np

try:
    from fmatch.saas import settings as saas_settings
except ImportError:  # pragma: no cover - optional in non-SaaS contexts
    saas_settings = None  # type: ignore[assignment]

from fmatch.utils_normalize import (
    COMPANY_STOPWORDS,
    load_corp_markers,
    normalize_company_name,
    to_domain,
)
from .b2b_ontology import B2B_ONTOLOGY, FINGERPRINTS, FIELD_COMBINATIONS

_SAMPLE_SIZE = 800
_RANDOM_STATE = 13

log = logging.getLogger(__name__)

_B2C_DOMAIN_PATHS = [
    Path(__file__).resolve().parents[2]
    / "core"
    / "resources"
    / "b2c_domains_v20250719.txt",
    Path(__file__).resolve().parents[2] / "resources" / "b2c_domains_v20250719.txt",
]


def _load_b2c_domains() -> set[str]:
    domains: set[str] = set()
    for path in _B2C_DOMAIN_PATHS:
        if path.exists():
            with path.open("r", encoding="utf-8") as handle:
                for line in handle:
                    domain = line.strip().lower()
                    if domain:
                        domains.add(domain)
            break
    return domains


_B2C_DOMAINS = _load_b2c_domains()

CRITICAL = {
    "contacts": {"required": ["email"], "expected": ["account_name", "domain"]},
    "accounts": {"required": ["account_name"], "expected": ["domain"]},
}

_CORP_VALIDATION_PATTERN = re.compile(load_corp_markers(), re.IGNORECASE)


def profile_dataset(df: pd.DataFrame) -> dict:
    cols = list(df.columns)
    if not cols:
        return {
            "has_email": False,
            "any_corp": False,
            "avg_nulls": 1.0,
            "mostly_numeric": True,
            "likely_entity_type": "accounts",
        }

    def col_has(pat, column_name):
        series = df[column_name].astype(str)
        return series.str.contains(pat, na=False).mean()

    email_rate = max((col_has("@", c) for c in cols), default=0.0)
    corp_rate = max((col_has(_CORP_VALIDATION_PATTERN, c) for c in cols), default=0.0)
    null_avg = float(np.mean([df[c].isna().mean() for c in cols])) if cols else 1.0
    mostly_numeric = (
        sum(pd.api.types.is_numeric_dtype(df[c]) for c in cols) / max(1, len(cols))
        > 0.5
    )
    likely = "contacts" if email_rate >= 0.25 else "accounts"
    return {
        "has_email": email_rate >= 0.25,
        "any_corp": corp_rate >= 0.10,
        "avg_nulls": null_avg,
        "mostly_numeric": mostly_numeric,
        "likely_entity_type": likely,
    }


_NEG_HEADER_PATTERN = re.compile(
    B2B_ONTOLOGY["account_name"]["negative_header_markers"], re.IGNORECASE
)
_EMAIL_PATTERN = re.compile(B2B_ONTOLOGY["email"]["pattern"])
_DOMAIN_URL_PATTERN = re.compile(B2B_ONTOLOGY["domain"]["url_pattern"])
_DOMAIN_TLD_PATTERN = re.compile(B2B_ONTOLOGY["domain"]["tld_pattern"], re.IGNORECASE)
_CORP_PATTERN = re.compile(B2B_ONTOLOGY["account_name"]["hard_markers"], re.IGNORECASE)

_FIELD_TYPE_ALIAS = {
    "account_name": "name",
    "domain": "domain",
    "email": "email",
}

_TRANSFORM_DEFAULTS = {
    "account_name": ["normalize_company_name"],
    "domain": ["to_domain", "lower"],
    "email": ["lower", "strip"],
}

_PREFERRED_ALGO = {
    "account_name": "TokenSetRatio",
    "domain": "Exact",
    "email": "Exact",
}

_WEIGHTS = {
    "account_name": 0.95,
    "domain": 1.0,
    "email": 0.90,
}


def _email_domains(series: pd.Series) -> pd.Series:
    if series.empty:
        return series.iloc[0:0]
    domains = series.str.extract(r"@([^>\s]+)", expand=False)
    return domains.map(
        lambda value: to_domain(value) if isinstance(value, str) and value else None
    )


def _normalized_domains(series: pd.Series) -> pd.Series:
    if series.empty:
        return series.iloc[0:0]
    return series.map(
        lambda value: to_domain(value) if isinstance(value, str) and value else None
    )


def _b2c_ratio_from_emails(series: pd.Series) -> float:
    domains = _email_domains(series).dropna()
    if domains.empty:
        return 0.0
    hits = sum(1 for value in domains if value in _B2C_DOMAINS)
    return hits / len(domains)


def _b2c_ratio_from_domains(series: pd.Series) -> float:
    domains = _normalized_domains(series).dropna()
    if domains.empty:
        return 0.0
    hits = sum(1 for value in domains if value in _B2C_DOMAINS)
    return hits / len(domains)


def _business_email_score(domain: str) -> float:
    """Score email domain for business likelihood (0=B2C, 1=business)"""
    if not domain:
        return 0.0
    d = str(domain).lower()
    if d in _B2C_DOMAINS:
        return 0.0
    if d.endswith((".edu", ".gov", ".mil")):
        return 0.7
    return 1.0


def _detect_company_size(series: pd.Series) -> str:
    """Detect likely company size from company names"""
    s = series.astype(str).str.lower()
    enterprise_rate = s.str.contains(
        r"\b(?:enterprise|fortune|global|international|worldwide)\b", na=False
    ).mean()
    startup_rate = s.str.contains(
        r"\b(?:startup|seed|series [a-c]|ventures?)\b", na=False
    ).mean()

    if enterprise_rate > 0.30:
        return "enterprise"
    elif startup_rate > 0.20:
        return "startup"
    else:
        return "mid_market"


def _root_company_tokens(series: pd.Series) -> pd.Series:
    normalized = series.map(normalize_company_name)
    normalized = normalized.fillna("")
    return normalized.str.replace(r"[^a-z0-9]", "", regex=True)


def _root_domain_tokens(series: pd.Series) -> pd.Series:
    normalized = _normalized_domains(series)
    normalized = normalized.fillna("")
    return normalized.str.replace(r"[^a-z0-9]", "", regex=True)


def top_email_domains(df: pd.DataFrame, email_col: str, k: int = 5) -> Dict[str, int]:
    column = df.get(email_col)
    if column is None:
        return {}
    domains = column.astype(str).str.extract(r"@([^>\s/]+)", expand=False)
    domains = domains.map(
        lambda value: to_domain(value) if isinstance(value, str) and value else None
    )
    counts = domains.dropna().value_counts().head(max(1, k))
    return counts.to_dict()


@dataclass
class Detection:
    """Result of field detection with explainability."""

    field_type: str
    column: Optional[str]
    confidence: float
    explanation: Dict[str, float]
    top_candidates: List[Tuple[str, float]] = field(default_factory=list)
    derived_from: Optional[str] = None


class B2BFieldDetector:
    """Cascade detector for B2B fields with cross-validation."""

    def __init__(self, df: pd.DataFrame, sample_size: int = _SAMPLE_SIZE):
        self.df = df
        self.profile = profile_dataset(self.df)
        self.sample_size = min(sample_size, len(df)) if len(df) else 0
        self._samples: Dict[str, pd.Series] = {}
        self._string_samples: Dict[str, pd.Series] = {}
        self.detections: Dict[str, Detection] = {}
        self.disqualifications = self._compute_disqualifications()

    def get_sample(self, column: str, as_str: bool = False) -> pd.Series:
        sample = self._get_sample(column, as_str=as_str)
        return sample.copy()

    def _get_sample(self, column: str, as_str: bool = False) -> pd.Series:
        if column not in self._samples:
            series = self.df[column]
            if self.sample_size and len(series) > self.sample_size:
                sample = series.sample(self.sample_size, random_state=_RANDOM_STATE)
            else:
                sample = series.copy()
            self._samples[column] = sample
        sample = self._samples[column]
        if as_str:
            key = f"{column}__str"
            if key not in self._string_samples:
                mask = sample.notna()
                str_series = sample.astype(str)
                str_series = str_series.where(mask, "")
                str_series = str_series.str.strip()
                self._string_samples[key] = str_series
            return self._string_samples[key]
        return sample

    def _is_numeric(self, column: str) -> bool:
        try:
            return pd.api.types.is_numeric_dtype(self.df[column])
        except Exception:
            return False

    def email_b2c_ratio(self, column: Optional[str]) -> float:
        if not column:
            return 0.0
        series = self._get_sample(column, as_str=True)
        return _b2c_ratio_from_emails(series)

    def domain_b2c_ratio(self, column: Optional[str]) -> float:
        if not column:
            return 0.0
        series = self._get_sample(column, as_str=True)
        return _b2c_ratio_from_domains(series)

    def detect_all(self) -> Dict[str, Detection]:
        from fmatch.saas.settings import B2B_DETECTION
        from .b2b_ontology import INDUSTRY_PATTERNS

        self.detections = {}
        self.detections["email"] = self._detect_email()
        self.detections["domain"] = self._detect_domain()
        self.detections["account_name"] = self._detect_account_name()
        self.detections["contact_first"] = self._detect_first_name()
        self.detections["contact_last"] = self._detect_last_name()
        self.detections["contact_full"] = self._detect_full_name()

        # LinkedIn detection if enabled
        if B2B_DETECTION.get("enable_linkedin_detector", True):
            self.detections["linkedin"] = self.detect_linkedin()

        self._cross_validate()
        self._detect_combinations()

        # Compute industry hints if enabled
        self.industry_hints = {}
        if (
            B2B_DETECTION.get("enable_industry_hints", True)
            and self.detections.get("account_name")
            and self.detections["account_name"].column
        ):
            col = self.detections["account_name"].column
            name_series = self.df[col].astype(str).str.lower()
            for ind, tokens in INDUSTRY_PATTERNS.items():
                pattern = "|".join(tokens)
                hit = name_series.str.contains(pattern, na=False).sum()
                if hit > 0:
                    self.industry_hints[ind] = int(hit)

        # Detect company size if enabled
        self.company_size = None
        if (
            B2B_DETECTION.get("enable_company_size_signal", True)
            and self.detections.get("account_name")
            and self.detections["account_name"].column
        ):
            col = self.detections["account_name"].column
            self.company_size = _detect_company_size(self.df[col])

        # Expose in detections preview
        self.detections_preview = {
            "industry_hints": self.industry_hints,
            "company_size": self.company_size,
        }

        return self.detections

    def _compute_disqualifications(self) -> Dict[str, float]:
        penalties: Dict[str, float] = {}
        for col in self.df.columns:
            penalty = 0.0
            sample = self._get_sample(col)
            sample_len = len(sample)
            if sample_len == 0:
                penalties[col] = 0.0
                continue
            if _NEG_HEADER_PATTERN.search(col):
                penalty += 0.20
            unique_ratio = sample.dropna().nunique() / sample_len if sample_len else 0.0
            if unique_ratio < 0.05:
                penalty += 0.30
            if self._is_numeric(col):
                penalty += 0.50
            else:
                str_sample = self._get_sample(col, as_str=True)
                digits_only = str_sample.str.match(r"^\d+$", na=False)
                if not digits_only.empty and float(digits_only.mean()) > 0.8:
                    penalty += 0.40
            null_rate = 1.0 - float(sample.notna().mean())
            if null_rate > 0.9:
                penalty += 0.30
            penalties[col] = min(penalty, 0.8)
        return penalties

    def _detect_email(self) -> Detection:
        # Hard-claim slam dunk for obvious email columns
        for col in self.df.columns:
            if self._is_numeric(col):
                continue
            str_sample = self._get_sample(col, as_str=True)
            if str_sample.empty:
                continue
            if "email" in col.lower():
                at_rate = float(str_sample.str.contains("@", na=False).mean())
                if at_rate >= 0.90:
                    explanation = {
                        "header": 1.0,
                        "@_rate": round(at_rate, 3),
                        "lock": 1.0,
                    }
                    return Detection(
                        field_type="email",
                        column=col,
                        confidence=1.0,
                        explanation=explanation,
                        top_candidates=[(col, 1.0)],
                    )

        candidates: List[Tuple[str, float, Dict[str, float]]] = []
        for col in self.df.columns:
            scores: Dict[str, float] = {}
            penalty = self.disqualifications.get(col, 0.0)
            if self._is_numeric(col):
                scores["numeric_dtype"] = -0.40
                total = sum(scores.values()) - penalty
                confidence = max(0.0, total)
                explanation = dict(scores)
                if penalty:
                    explanation["penalty"] = -penalty
                candidates.append((col, confidence, explanation))
                continue
            str_sample = self._get_sample(col, as_str=True)
            sample = self._get_sample(col)
            if str_sample.empty:
                explanation = {"empty": 0.0}
                if penalty:
                    explanation["penalty"] = -penalty
                candidates.append((col, 0.0, explanation))
                continue
            col_lower = col.lower()
            header_score = 0.0
            for alias in B2B_ONTOLOGY["email"]["aliases"]:
                if alias in col_lower:
                    header_score = max(header_score, 0.30)
            scores["header"] = header_score
            valid_emails = str_sample.str.match(_EMAIL_PATTERN, na=False)
            scores["pattern"] = (
                float(valid_emails.mean()) * 0.50 if not valid_emails.empty else 0.0
            )
            has_at = str_sample.str.contains("@", na=False)
            scores["at_symbol"] = (
                float(has_at.mean()) * 0.15 if not has_at.empty else 0.0
            )
            unique_ratio = (
                sample.dropna().nunique() / len(sample) if len(sample) else 0.0
            )
            if unique_ratio >= B2B_ONTOLOGY["email"]["constraints"]["min_unique_ratio"]:
                scores["uniqueness"] = 0.05
            b2c_ratio = _b2c_ratio_from_emails(str_sample)
            if b2c_ratio > 0.6:
                scores["b2c_penalty"] = -0.20

            # Add business email scoring if enabled
            from fmatch.saas.settings import B2B_DETECTION

            if B2B_DETECTION.get("enable_business_email_score", True):
                # Compute average business score across sample
                domains = str_sample.str.extract(r"@([^>\s/]+)", expand=False)
                if not domains.empty:
                    biz_scores = domains.dropna().map(_business_email_score)
                    if not biz_scores.empty:
                        avg_biz = float(biz_scores.mean())
                        scores["business_email_score"] = (
                            0.10 * avg_biz
                        )  # Small but meaningful lift

            total = sum(scores.values()) - penalty
            confidence = max(0.0, min(1.0, total))
            explanation = dict(scores)
            if penalty:
                explanation["penalty"] = -penalty
            if b2c_ratio:
                explanation["b2c_ratio"] = b2c_ratio
            candidates.append((col, confidence, explanation))
        candidates.sort(key=lambda item: item[1], reverse=True)
        best_col, best_score, best_expl = (
            candidates[0] if candidates else (None, 0.0, {})
        )
        column = best_col if best_score > 0.3 else None
        top_candidates = [(c, round(s, 3)) for c, s, _ in candidates[:3]]
        return Detection(
            field_type="email",
            column=column,
            confidence=float(best_score),
            explanation={k: float(v) for k, v in best_expl.items()},
            top_candidates=top_candidates,
        )

    def _detect_domain(self) -> Detection:
        candidates: List[Tuple[str, float, Dict[str, float]]] = []
        for col in self.df.columns:
            scores: Dict[str, float] = {}
            penalty = self.disqualifications.get(col, 0.0)
            if self._is_numeric(col):
                scores["numeric_dtype"] = -0.40
                total = sum(scores.values()) - penalty
                confidence = max(0.0, total)
                explanation = dict(scores)
                if penalty:
                    explanation["penalty"] = -penalty
                candidates.append((col, confidence, explanation))
                continue
            str_sample = self._get_sample(col, as_str=True)
            if str_sample.empty:
                explanation = {"empty": 0.0}
                if penalty:
                    explanation["penalty"] = -penalty
                candidates.append((col, 0.0, explanation))
                continue
            col_lower = col.lower()
            header_score = 0.0
            for alias in B2B_ONTOLOGY["domain"]["aliases"]:
                if alias in col_lower:
                    header_score = max(header_score, 0.25)
            scores["header"] = header_score
            url_match = str_sample.str.match(_DOMAIN_URL_PATTERN, na=False)
            scores["url_pattern"] = (
                float(url_match.mean()) * 0.40 if not url_match.empty else 0.0
            )
            tld_match = str_sample.str.contains(_DOMAIN_TLD_PATTERN, na=False)
            scores["tld_pattern"] = (
                float(tld_match.mean()) * 0.30 if not tld_match.empty else 0.0
            )
            no_at = 1.0 - float(str_sample.str.contains("@", na=False).mean())
            scores["no_email"] = max(0.0, no_at) * 0.05
            b2c_ratio = _b2c_ratio_from_domains(str_sample)
            if b2c_ratio > 0.6:
                scores["b2c_penalty"] = -0.20
            total = sum(scores.values()) - penalty
            confidence = max(0.0, min(1.0, total))
            explanation = dict(scores)
            if penalty:
                explanation["penalty"] = -penalty
            if b2c_ratio:
                explanation["b2c_ratio"] = b2c_ratio
            candidates.append((col, confidence, explanation))
        candidates.sort(key=lambda item: item[1], reverse=True)
        best_col, best_score, best_expl = (
            candidates[0] if candidates else (None, 0.0, {})
        )
        if best_score < 0.5:
            email_det = self.detections.get("email")
            if email_det and email_det.column:
                email_series = self._get_sample(email_det.column, as_str=True)
                derived_domains = _email_domains(email_series).dropna()
                if (
                    not derived_domains.empty
                    and len(derived_domains) / max(1, len(email_series)) > 0.5
                ):
                    explanation = {"derived_from_email": 0.72}
                    derived_ratio = _b2c_ratio_from_domains(derived_domains.fillna(""))
                    if derived_ratio:
                        explanation["b2c_ratio"] = derived_ratio
                    return Detection(
                        field_type="domain",
                        column=None,
                        confidence=0.72,
                        explanation=explanation,
                        top_candidates=[],
                        derived_from=email_det.column,
                    )
        column = best_col if best_score > 0.3 else None
        top_candidates = [(c, round(s, 3)) for c, s, _ in candidates[:3]]
        return Detection(
            field_type="domain",
            column=column,
            confidence=float(best_score),
            explanation={k: float(v) for k, v in best_expl.items()},
            top_candidates=top_candidates,
        )

    def _detect_account_name(self) -> Detection:
        # Hard-claim branch for company/account headers that scream account name
        keywords = ("company", "account", "organization", "business")
        for col in self.df.columns:
            if self._is_numeric(col):
                continue
            str_sample = self._get_sample(col, as_str=True)
            if str_sample.empty:
                continue
            if any(key in col.lower() for key in keywords):
                corp_rate = float(
                    str_sample.str.contains(_CORP_PATTERN, na=False).mean()
                )
                at_rate = float(str_sample.str.contains("@", na=False).mean())
                if corp_rate >= 0.30 and at_rate <= 0.10:
                    explanation = {
                        "header": 0.35,
                        "corp_markers": round(min(0.60, corp_rate), 3),
                        "lock": 1.0,
                    }
                    return Detection(
                        field_type="account_name",
                        column=col,
                        confidence=0.95,
                        explanation=explanation,
                        top_candidates=[(col, 0.95)],
                    )

        candidates: List[Tuple[str, float, Dict[str, float]]] = []
        for col in self.df.columns:
            scores: Dict[str, float] = {}
            penalty = self.disqualifications.get(col, 0.0)
            if self._is_numeric(col):
                scores["numeric_dtype"] = -0.40
                total = sum(scores.values()) - penalty
                confidence = max(0.0, total)
                explanation = dict(scores)
                if penalty:
                    explanation["penalty"] = -penalty
                candidates.append((col, confidence, explanation))
                continue
            str_sample = self._get_sample(col, as_str=True)
            sample = self._get_sample(col)
            if str_sample.empty:
                explanation = {"empty": 0.0}
                if penalty:
                    explanation["penalty"] = -penalty
                candidates.append((col, 0.0, explanation))
                continue
            col_lower = col.lower()
            header_score = 0.0
            for alias in B2B_ONTOLOGY["account_name"]["aliases"]:
                if alias in col_lower:
                    header_score = max(header_score, 0.25)
            scores["header"] = header_score
            corp_ratio = str_sample.str.contains(_CORP_PATTERN, na=False)
            scores["corp_markers"] = (
                float(corp_ratio.mean()) * 0.40 if not corp_ratio.empty else 0.0
            )
            title_case = str_sample.str.match(r"^[A-Z][a-z]", na=False)
            scores["capitalization"] = (
                float(title_case.mean()) * 0.10 if not title_case.empty else 0.0
            )
            avg_len = float(str_sample.str.len().mean()) if len(str_sample) else 0.0
            if (
                B2B_ONTOLOGY["account_name"]["constraints"]["min_avg_len"]
                <= avg_len
                <= 50
            ):
                scores["length"] = 0.10
            unique_ratio = (
                sample.dropna().nunique() / len(sample) if len(sample) else 0.0
            )
            if (
                unique_ratio
                and B2B_ONTOLOGY["account_name"]["constraints"]["min_unique_ratio"]
                <= unique_ratio
                <= B2B_ONTOLOGY["account_name"]["constraints"]["max_unique_ratio"]
            ):
                scores["diversity"] = 0.15
            email_noise = str_sample.str.contains("@", na=False)
            if not email_noise.empty and float(email_noise.mean()) > 0.1:
                scores["email_penalty"] = -0.30
            if avg_len < 4:
                scores["short_penalty"] = -0.20
            total = sum(scores.values()) - penalty
            confidence = max(0.0, min(1.0, total))
            explanation = dict(scores)
            if penalty:
                explanation["penalty"] = -penalty
            candidates.append((col, confidence, explanation))
        candidates.sort(key=lambda item: item[1], reverse=True)
        best_col, best_score, best_expl = (
            candidates[0] if candidates else (None, 0.0, {})
        )
        column = best_col if best_score > 0.3 else None
        top_candidates = [(c, round(s, 3)) for c, s, _ in candidates[:3]]
        return Detection(
            field_type="account_name",
            column=column,
            confidence=float(best_score),
            explanation={k: float(v) for k, v in best_expl.items()},
            top_candidates=top_candidates,
        )

    def _detect_first_name(self) -> Detection:
        candidates: List[Tuple[str, float, Dict[str, float]]] = []
        for col in self.df.columns:
            if self._is_numeric(col):
                continue
            str_sample = self._get_sample(col, as_str=True)
            if str_sample.empty:
                continue
            scores: Dict[str, float] = {}
            penalty = self.disqualifications.get(col, 0.0)
            col_lower = col.lower()
            header_score = 0.0
            for alias in B2B_ONTOLOGY["contact_first"]["aliases"]:
                if alias in col_lower:
                    header_score = max(header_score, 0.40)
            scores["header"] = header_score
            name_pattern = str_sample.str.match(
                r"^[A-Z][a-z]+(?:[-'\s][A-Z]?[a-z]+)*$", na=False
            )
            scores["name_pattern"] = (
                float(name_pattern.mean()) * 0.35 if not name_pattern.empty else 0.0
            )
            avg_len = float(str_sample.str.len().mean()) if len(str_sample) else 0.0
            if 2 <= avg_len <= 12:
                scores["length"] = 0.15
            unique_ratio = (
                str_sample.nunique() / len(str_sample) if len(str_sample) else 0.0
            )
            if 0.05 <= unique_ratio <= 0.5:
                scores["diversity"] = 0.10
            total = sum(scores.values()) - penalty
            confidence = max(0.0, min(1.0, total))
            explanation = dict(scores)
            if penalty:
                explanation["penalty"] = -penalty
            candidates.append((col, confidence, explanation))
        candidates.sort(key=lambda item: item[1], reverse=True)
        best_col, best_score, best_expl = (
            candidates[0] if candidates else (None, 0.0, {})
        )
        column = best_col if best_score > 0.3 else None
        top_candidates = [(c, round(s, 3)) for c, s, _ in candidates[:3]]
        return Detection(
            field_type="contact_first",
            column=column,
            confidence=float(best_score),
            explanation={k: float(v) for k, v in best_expl.items()},
            top_candidates=top_candidates,
        )

    def _detect_last_name(self) -> Detection:
        candidates: List[Tuple[str, float, Dict[str, float]]] = []
        for col in self.df.columns:
            if self._is_numeric(col):
                continue
            str_sample = self._get_sample(col, as_str=True)
            if str_sample.empty:
                continue
            scores: Dict[str, float] = {}
            penalty = self.disqualifications.get(col, 0.0)
            col_lower = col.lower()
            header_score = 0.0
            for alias in B2B_ONTOLOGY["contact_last"]["aliases"]:
                if alias in col_lower:
                    header_score = max(header_score, 0.40)
            scores["header"] = header_score
            name_pattern = str_sample.str.match(
                r"^[A-Z](?:[a-z]+|'[A-Z][a-z]+)(?:[-\s]?[A-Z]?[a-z]+)*$",
                na=False,
            )
            scores["name_pattern"] = (
                float(name_pattern.mean()) * 0.35 if not name_pattern.empty else 0.0
            )
            avg_len = float(str_sample.str.len().mean()) if len(str_sample) else 0.0
            if 2 <= avg_len <= 20:
                scores["length"] = 0.15
            unique_ratio = (
                str_sample.nunique() / len(str_sample) if len(str_sample) else 0.0
            )
            if 0.2 <= unique_ratio <= 0.8:
                scores["diversity"] = 0.10
            total = sum(scores.values()) - penalty
            confidence = max(0.0, min(1.0, total))
            explanation = dict(scores)
            if penalty:
                explanation["penalty"] = -penalty
            candidates.append((col, confidence, explanation))
        candidates.sort(key=lambda item: item[1], reverse=True)
        best_col, best_score, best_expl = (
            candidates[0] if candidates else (None, 0.0, {})
        )
        column = best_col if best_score > 0.3 else None
        top_candidates = [(c, round(s, 3)) for c, s, _ in candidates[:3]]
        return Detection(
            field_type="contact_last",
            column=column,
            confidence=float(best_score),
            explanation={k: float(v) for k, v in best_expl.items()},
            top_candidates=top_candidates,
        )

    def _detect_full_name(self) -> Detection:
        candidates: List[Tuple[str, float, Dict[str, float]]] = []
        for col in self.df.columns:
            if self._is_numeric(col):
                continue
            str_sample = self._get_sample(col, as_str=True)
            if str_sample.empty:
                continue
            scores: Dict[str, float] = {}
            penalty = self.disqualifications.get(col, 0.0)
            col_lower = col.lower()
            header_score = 0.0
            for alias in B2B_ONTOLOGY["contact_full"]["aliases"]:
                if alias in col_lower:
                    header_score = max(header_score, 0.35)
            scores["header"] = header_score
            has_space = str_sample.str.contains(" ", na=False)
            scores["has_space"] = (
                float(has_space.mean()) * 0.25 if not has_space.empty else 0.0
            )
            multi_cap = str_sample.str.match(r"^[A-Z][a-z]+\s+[A-Z][a-z]", na=False)
            scores["multi_word"] = (
                float(multi_cap.mean()) * 0.25 if not multi_cap.empty else 0.0
            )
            avg_len = float(str_sample.str.len().mean()) if len(str_sample) else 0.0
            if 5 <= avg_len <= 40:
                scores["length"] = 0.15
            total = sum(scores.values()) - penalty
            confidence = max(0.0, min(1.0, total))
            explanation = dict(scores)
            if penalty:
                explanation["penalty"] = -penalty
            candidates.append((col, confidence, explanation))
        candidates.sort(key=lambda item: item[1], reverse=True)
        best_col, best_score, best_expl = (
            candidates[0] if candidates else (None, 0.0, {})
        )
        column = best_col if best_score > 0.3 else None
        top_candidates = [(c, round(s, 3)) for c, s, _ in candidates[:3]]
        return Detection(
            field_type="contact_full",
            column=column,
            confidence=float(best_score),
            explanation={k: float(v) for k, v in best_expl.items()},
            top_candidates=top_candidates,
        )

    def detect_linkedin(self) -> Detection:
        """Detect LinkedIn URL columns"""
        patt = re.compile(B2B_ONTOLOGY["linkedin"]["pattern"], re.IGNORECASE)
        candidates = []

        for col in self.df.columns:
            if self._is_numeric(col):
                continue

            s = self.df[col].astype(str)
            col_lower = col.lower()

            # Check header match
            header_match = any(
                alias in col_lower for alias in B2B_ONTOLOGY["linkedin"]["aliases"]
            )
            header_score = 0.4 if header_match else 0.0

            # Check content match
            content_rate = float(s.str.contains(patt, na=False).mean())
            content_score = 0.6 * content_rate

            confidence = header_score + content_score

            if confidence > 0:
                candidates.append((col, confidence))

        # Sort by confidence
        candidates.sort(key=lambda x: -x[1])

        if candidates and candidates[0][1] >= 0.4:
            best_col, best_conf = candidates[0]
            top_cands = [(c, round(conf, 3)) for c, conf in candidates[:3]]

            return Detection(
                field_type="linkedin",
                column=best_col,
                confidence=best_conf,
                explanation={"header_or_pattern": best_conf},
                top_candidates=top_cands,
            )

        return Detection(
            field_type="linkedin",
            column=None,
            confidence=0.0,
            explanation={},
            top_candidates=[],
        )

    def _cross_validate(self) -> None:
        domain_det = self.detections.get("domain")
        company_det = self.detections.get("account_name")
        if domain_det and domain_det.column and company_det and company_det.column:
            pair_df = self.df[[domain_det.column, company_det.column]].dropna(how="all")
            if not pair_df.empty:
                if self.sample_size and len(pair_df) > self.sample_size:
                    pair_df = pair_df.sample(
                        self.sample_size, random_state=_RANDOM_STATE
                    )
                domain_roots = _root_domain_tokens(
                    pair_df[domain_det.column].astype(str)
                )
                company_roots = _root_company_tokens(pair_df[company_det.column])
                mask = (domain_roots != "") & (company_roots != "")
                if mask.any():
                    agreement = float(
                        (
                            domain_roots[mask].str[:10] == company_roots[mask].str[:10]
                        ).mean()
                    )
                    boost = 0.0
                    if agreement >= 0.50:
                        boost = 0.10
                    elif agreement >= 0.30:
                        boost = 0.05
                    if boost:
                        domain_det.confidence = min(1.0, domain_det.confidence + boost)
                        domain_det.explanation["cross_validation"] = round(boost, 3)
                        company_det.confidence = min(
                            1.0, company_det.confidence + boost
                        )
                        company_det.explanation["cross_validation"] = round(boost, 3)
        email_det = self.detections.get("email")
        domain_det = self.detections.get("domain")
        if email_det and email_det.column and domain_det and domain_det.column:
            pair_df = self.df[[email_det.column, domain_det.column]].dropna(how="all")
            if not pair_df.empty:
                if self.sample_size and len(pair_df) > self.sample_size:
                    pair_df = pair_df.sample(
                        self.sample_size, random_state=_RANDOM_STATE
                    )
                email_domains = _email_domains(
                    pair_df[email_det.column].astype(str)
                ).dropna()
                domain_values = _normalized_domains(
                    pair_df[domain_det.column].astype(str)
                ).dropna()
                aligned = email_domains.index.intersection(domain_values.index)
                if len(aligned) > 0:
                    agreement = float(
                        (
                            email_domains.loc[aligned] == domain_values.loc[aligned]
                        ).mean()
                    )
                    if agreement > 0.4:
                        boost = min(0.05, agreement * 0.10)
                        domain_det.confidence = min(1.0, domain_det.confidence + boost)
                        domain_det.explanation["email_domain_validation"] = boost

    def _detect_combinations(self) -> None:
        combo = FIELD_COMBINATIONS.get("full_name_split")
        first_det = self.detections.get("contact_first")
        last_det = self.detections.get("contact_last")
        full_det = self.detections.get("contact_full")
        if (
            combo
            and first_det
            and first_det.column
            and last_det
            and last_det.column
            and (not full_det or not full_det.column)
        ):
            derived_from = f"{first_det.column}+{last_det.column}"
            self.detections["contact_full"] = Detection(
                field_type="contact_full",
                column=None,
                confidence=0.90,
                explanation={"derived_from": 0.90},
                top_candidates=[],
                derived_from=derived_from,
            )


def fingerprint_match(headers: List[str]) -> Optional[Dict[str, object]]:
    headers_lower = {h.lower().strip() for h in headers}
    best: Optional[Dict[str, object]] = None
    for name, config in FINGERPRINTS.items():
        required = {h.lower().strip() for h in config.get("headers", [])}
        if not required:
            continue
        overlap = len(headers_lower & required) / len(required)
        if overlap <= 0.0:
            continue
        candidate = {"name": name, "config": config, "overlap": overlap}
        if not best or overlap > best["overlap"]:
            best = candidate
    return best


def _first_case_insensitive(df: pd.DataFrame, header: str) -> Optional[str]:
    header_norm = header.lower().strip()
    for col in df.columns:
        if col.lower().strip() == header_norm:
            return col
    return None


def _build_template_mappings(
    source_print: Optional[Dict[str, object]],
    ref_print: Optional[Dict[str, object]],
    source_df: pd.DataFrame,
    reference_df: Optional[pd.DataFrame],
) -> List[Dict]:
    if reference_df is None or not source_print or not ref_print:
        return []
    if source_print.get("name") != ref_print.get("name"):
        return []

    def _logical_headers(fp: Dict[str, object]) -> Dict[str, str]:
        config = fp.get("config") if isinstance(fp, dict) else None
        if not isinstance(config, dict):
            return {}
        return {
            logical: header for header, logical in config.get("mappings", {}).items()
        }

    source_logical = _logical_headers(source_print)
    ref_logical = _logical_headers(ref_print)
    shared_fields = set(source_logical) & set(ref_logical)
    mappings: List[Dict[str, object]] = []
    for logical_field in ("domain", "account_name", "email"):
        if logical_field not in shared_fields:
            continue
        source_header = source_logical[logical_field]
        ref_header = ref_logical[logical_field]
        source_col = _first_case_insensitive(
            source_df, source_header
        ) or _first_case_insensitive(source_df, logical_field.replace("_", " "))
        ref_col = _first_case_insensitive(
            reference_df, ref_header
        ) or _first_case_insensitive(reference_df, logical_field.replace("_", " "))
        if not source_col or not ref_col:
            continue
        mapping: Dict[str, object] = {
            "source": source_col,
            "reference": ref_col,
            "type": _FIELD_TYPE_ALIAS.get(logical_field, logical_field),
            "preferred_algo": _PREFERRED_ALGO.get(logical_field, "Exact"),
            "transforms": list(_TRANSFORM_DEFAULTS.get(logical_field, [])),
            "weight": _WEIGHTS.get(logical_field, 1.0),
            "confidence": 0.95,
        }
        if logical_field in ("domain", "email"):
            mapping["preprocessing"] = "raw"
        mappings.append(mapping)
    return mappings


def _classify_dataset(
    detector: Optional[B2BFieldDetector], fields: Dict[str, Detection]
) -> str:
    if not detector:
        return "accounts"
    profile = getattr(detector, "profile", None)
    if isinstance(profile, dict):
        likely = profile.get("likely_entity_type")
        if isinstance(likely, str):
            return likely
    email_det = fields.get("email")
    if email_det and email_det.column:
        series = detector.get_sample(email_det.column, as_str=True)
        if not series.empty:
            at_ratio = float(series.str.contains("@", na=False).mean())
            if at_ratio >= 0.5:
                return "contacts"
    return "accounts"


def _anchors_ok(
    fields: Dict[str, Detection],
    detector: B2BFieldDetector,
    dataset_type: str,
    min_conf: float,
    require_two: bool,
) -> bool:
    account_det = fields.get("account_name")
    domain_det = fields.get("domain")
    email_det = fields.get("email")

    account_valid = bool(
        account_det and account_det.column and account_det.confidence >= min_conf
    )
    domain_ratio = (
        detector.domain_b2c_ratio(domain_det.column)
        if domain_det and domain_det.column
        else 0.0
    )
    domain_valid = bool(
        domain_det
        and domain_det.column
        and domain_det.confidence >= min_conf
        and domain_ratio <= 0.6
    )
    email_ratio = (
        detector.email_b2c_ratio(email_det.column)
        if email_det and email_det.column
        else 0.0
    )
    email_valid = bool(
        email_det
        and email_det.column
        and email_det.confidence >= min_conf
        and (email_ratio <= 0.6 or dataset_type == "contacts")
    )

    if dataset_type == "contacts":
        if not email_valid:
            return False
        if require_two:
            return domain_valid or account_valid
        return True

    anchors_hit = [flag for flag in (account_valid, domain_valid, email_valid) if flag]
    if require_two:
        return len(anchors_hit) >= 2
    return any(anchors_hit)


def enforce_critical(
    detections: Dict[str, Detection], entity: str
) -> Optional[Dict[str, object]]:
    rules = CRITICAL.get(entity, {})
    required = rules.get("required", [])
    missing: List[str] = []
    for field in required:
        det = detections.get(field)
        if not det or not det.column or det.confidence < 0.85:
            missing.append(field)
    if missing:
        fields = ", ".join(missing)
        return {
            "requires_confirmation": True,
            "confirmationMessage": f"Missing critical field(s) for {entity}: {fields}. Please confirm.",
        }
    return None


def validate_mappings(
    df: pd.DataFrame, detections: Dict[str, Detection], entity: str
) -> List[str]:
    issues: List[str] = []
    account_det = detections.get("account_name")
    if account_det and account_det.column:
        series = df[account_det.column].astype(str)
        if series.str.contains("@", na=False).mean() > 0.50:
            issues.append("Company field contains mostly emails")
        lowered = series.str.lower().str.strip()
        if not lowered.empty:
            stopword_ratio = lowered.isin(COMPANY_STOPWORDS).mean()
            if stopword_ratio > 0.40:
                issues.append("Company field contains generic placeholders")
    email_det = detections.get("email")
    if email_det and email_det.column:
        series = df[email_det.column].astype(str)
        corp_ratio = series.str.contains(_CORP_VALIDATION_PATTERN, na=False).mean()
        if corp_ratio > 0.30:
            issues.append("Email field contains company names")
    first_det = detections.get("contact_first")
    last_det = detections.get("contact_last")
    if first_det and first_det.column and last_det and last_det.column:
        first_len = df[first_det.column].astype(str).str.len().mean()
        last_len = df[last_det.column].astype(str).str.len().mean()
        if first_len and last_len and first_len > last_len * 1.5:
            issues.append("First and last names may be swapped")
    return issues


def _anchor_summary(
    fields: Dict[str, Detection], min_conf: float
) -> List[Tuple[str, float]]:
    summary: List[Tuple[str, float]] = []
    for key in ("account_name", "domain", "email"):
        det = fields.get(key)
        if det and det.column and det.confidence >= min_conf:
            summary.append((key, round(det.confidence, 3)))
    return summary


def _detection_to_dict(det: Detection) -> Dict:
    return {
        "column": det.column,
        "confidence": round(det.confidence, 3),
        "explanation": {k: round(float(v), 3) for k, v in det.explanation.items()},
        "top_candidates": [
            (col, round(float(score), 3)) for col, score in det.top_candidates
        ],
        "derived_from": det.derived_from,
    }


def _detector_snapshot(fields: Dict[str, Detection]) -> Dict[str, Dict[str, object]]:
    summary: Dict[str, Dict[str, object]] = {}
    for name, det in fields.items():
        summary[name] = {
            "column": det.column,
            "confidence": round(det.confidence, 3),
            "locked": bool(det.explanation.get("lock")),
            "explanation": {k: float(v) for k, v in det.explanation.items()},
        }
    return summary


def _get_tenant_config(account_id: Optional[str]) -> Dict[str, float | str]:
    base: Dict[str, float | str] = {
        "enabled": True,
        "min_anchor_confidence": 0.80,
        "require_two_anchors": True,
        "fingerprint_auto_threshold": 0.90,
        "fingerprint_confirm_threshold": 0.70,
        "strict_mode": "aggressive",
    }
    cfg = getattr(saas_settings, "B2B_DETECTION", None)
    if isinstance(cfg, dict):
        base["enabled"] = bool(cfg.get("enabled", base["enabled"]))
        base["min_anchor_confidence"] = float(
            cfg.get("min_anchor_confidence", base["min_anchor_confidence"])
        )
        base["require_two_anchors"] = bool(
            cfg.get("require_two_anchors", base["require_two_anchors"])
        )
        base["fingerprint_auto_threshold"] = float(
            cfg.get("fingerprint_auto_threshold", base["fingerprint_auto_threshold"])
        )
        base["fingerprint_confirm_threshold"] = float(
            cfg.get(
                "fingerprint_confirm_threshold", base["fingerprint_confirm_threshold"]
            )
        )
        base["strict_mode"] = cfg.get("strict_mode", base["strict_mode"])
    return base


def auto_map_b2b(
    source_df: pd.DataFrame,
    reference_df: Optional[pd.DataFrame],
    account_id: Optional[str] = None,
) -> Dict[str, object]:
    log.info(
        "b2b: start detectors (src=%s cols=%d, ref=%s cols=%d)",
        source_df is not None,
        source_df.shape[1] if hasattr(source_df, "shape") else 0,
        reference_df is not None,
        reference_df.shape[1] if reference_df is not None else 0,
    )
    tenant_cfg = _get_tenant_config(account_id)
    auto_threshold = float(tenant_cfg.get("fingerprint_auto_threshold", 0.90))
    confirm_threshold = float(tenant_cfg.get("fingerprint_confirm_threshold", 0.70))
    min_anchor_conf = float(tenant_cfg.get("min_anchor_confidence", 0.80))
    require_two = bool(tenant_cfg.get("require_two_anchors", True))

    source_fp = fingerprint_match(list(source_df.columns))
    ref_fp = (
        fingerprint_match(list(reference_df.columns))
        if reference_df is not None
        else None
    )

    source_detector = B2BFieldDetector(source_df)
    source_fields = source_detector.detect_all()
    ref_detector: Optional[B2BFieldDetector] = None
    ref_fields: Dict[str, Detection] = {}
    if reference_df is not None:
        ref_detector = B2BFieldDetector(reference_df)
        ref_fields = ref_detector.detect_all()

    source_type = _classify_dataset(source_detector, source_fields)
    ref_type = (
        _classify_dataset(ref_detector, ref_fields) if ref_detector else source_type
    )

    anchor_summary = {
        "source": _anchor_summary(source_fields, min_anchor_conf),
        "reference": _anchor_summary(ref_fields, min_anchor_conf) if ref_fields else [],
    }

    detections_payload: Dict[str, object] = {
        "source": {k: _detection_to_dict(v) for k, v in source_fields.items()},
        "reference": {k: _detection_to_dict(v) for k, v in ref_fields.items()}
        if ref_fields
        else None,
        "profile": {
            "source": source_detector.profile,
            "reference": ref_detector.profile if ref_detector else None,
        },
    }

    preview: Dict[str, object] = {}
    source_email = source_fields.get("email")
    if source_email and source_email.column:
        preview_domains = top_email_domains(source_df, source_email.column)
        if preview_domains:
            preview["domains"] = preview_domains
    if preview:
        detections_payload["preview"] = preview

    gating_messages: List[str] = []
    source_gate = enforce_critical(source_fields, source_type)
    if source_gate:
        msg = source_gate.get("confirmationMessage")
        if msg:
            gating_messages.append(str(msg))
    ref_gate = enforce_critical(ref_fields, ref_type) if ref_fields else None
    if ref_gate:
        msg = ref_gate.get("confirmationMessage")
        if msg:
            gating_messages.append(str(msg))

    mappings: List[Dict[str, object]] = []
    method = "detection"
    requires_confirmation = False
    confirmation_msg: Optional[str] = None
    mapping_warnings: List[str] = []

    if (
        reference_df is not None
        and source_fp
        and ref_fp
        and source_fp.get("name") == ref_fp.get("name")
    ):
        source_overlap = float(source_fp.get("overlap", 0.0))
        reference_overlap = float(ref_fp.get("overlap", 0.0))
        min_overlap = min(source_overlap, reference_overlap)
        if min_overlap >= confirm_threshold:
            method = "fingerprint"
            mappings = _build_template_mappings(
                source_fp, ref_fp, source_df, reference_df
            )
            if mappings:
                requires_confirmation = min_overlap < auto_threshold
                if requires_confirmation:
                    confirmation_msg = f"Fingerprint '{source_fp.get('name')}' matched {min_overlap:.0%} of expected headers. Please confirm anchors."
            else:
                method = "detection"

    if not mappings and reference_df is not None:
        method = "detection"
        for logical_field in ("domain", "account_name", "email"):
            src_det = source_fields.get(logical_field)
            ref_det = ref_fields.get(logical_field)
            if not src_det or not ref_det or not src_det.column or not ref_det.column:
                continue
            mapping: Dict[str, object] = {
                "source": src_det.column,
                "reference": ref_det.column,
                "type": _FIELD_TYPE_ALIAS.get(logical_field, logical_field),
                "preferred_algo": _PREFERRED_ALGO.get(logical_field, "Exact"),
                "transforms": list(_TRANSFORM_DEFAULTS.get(logical_field, [])),
                "weight": _WEIGHTS.get(logical_field, 1.0),
                "confidence": round(min(src_det.confidence, ref_det.confidence), 3),
            }
            if logical_field in ("domain", "email"):
                mapping["preprocessing"] = "raw"
            mappings.append(mapping)

    if gating_messages:
        confirmation_msg = "; ".join(gating_messages)
        requires_confirmation = True
        mappings = []
        method = "detection"
        if "Provisional - confirm anchors" not in mapping_warnings:
            mapping_warnings.append("Provisional - confirm anchors")

    source_ok = _anchors_ok(
        source_fields, source_detector, source_type, min_anchor_conf, require_two
    )
    ref_ok = True
    if ref_detector:
        ref_ok = _anchors_ok(
            ref_fields, ref_detector, ref_type, min_anchor_conf, require_two
        )

    if not (source_ok and ref_ok):
        requires_confirmation = True
        if not confirmation_msg:
            if source_type == "contacts":
                confirmation_msg = "Please confirm Email and either Company or Domain."
            else:
                confirmation_msg = (
                    "Please confirm Company and Domain (or one anchor plus a second)."
                )
        if "Provisional - confirm anchors" not in mapping_warnings:
            mapping_warnings.append("Provisional - confirm anchors")

    if mappings:
        source_issues = validate_mappings(source_df, source_fields, source_type)
        for issue in source_issues:
            if issue not in mapping_warnings:
                mapping_warnings.append(issue)
        if reference_df is not None:
            ref_issues = validate_mappings(reference_df, ref_fields, ref_type)
            for issue in ref_issues:
                if issue not in mapping_warnings:
                    mapping_warnings.append(issue)

    # Use decimal float for consistency (0.75 or 0.80)
    threshold_suggestion = 0.75 if requires_confirmation else 0.80

    # Add enhanced telemetry with industry hints and company size
    telemetry = {
        "dataset_types": {
            "source": source_type,
            "reference": ref_type if ref_detector else None,
        },
        "anchors_detected": anchor_summary,
        "anchors_confirmed": not requires_confirmation,
        "detectors": {
            "source": _detector_snapshot(source_fields),
            "reference": _detector_snapshot(ref_fields) if ref_fields else None,
        },
        "requires_confirmation": requires_confirmation,
        "mapping_warnings": mapping_warnings,
        "profile": {
            "source": getattr(source_detector, "profile", {}),
            "reference": getattr(ref_detector, "profile", {}) if ref_detector else None,
        },
        "industry_hints": getattr(source_detector, "industry_hints", {}),
        "company_size": getattr(source_detector, "company_size", None),
    }

    # Add detections_preview for UI display
    detections_preview = getattr(source_detector, "detections_preview", {})

    return {
        "success": True,
        "method": method,
        "requires_confirmation": requires_confirmation,
        "confirmationMessage": confirmation_msg,
        "anchor_summary": anchor_summary,
        "detections": detections_payload,
        "mappings": mappings,
        "mapping_warnings": mapping_warnings,
        "threshold_suggestion": threshold_suggestion,
        "dataset_types": {
            "source": source_type,
            "reference": ref_type if ref_detector else None,
        },
        "detections_preview": detections_preview,
        "telemetry": telemetry,
    }
