from __future__ import annotations

import json
import logging
import math
import textwrap
from typing import Any, Dict, List, Literal, Optional

from fastapi import APIRouter
from pydantic import BaseModel, Field

from ...providers import get_llm_provider

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v2/ai", tags=["ai"])


class ColumnSchema(BaseModel):
    name: str
    samples: List[Any] = Field(default_factory=list)
    data_type: Optional[str] = None
    fill_rate: Optional[float] = Field(default=None, ge=0.0, le=1.0)
    fill_rate_estimated: Optional[bool] = None
    distinct_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0)
    unique_estimate: Optional[int] = None
    sample_non_empty: Optional[int] = None
    sample_size: Optional[int] = None
    tags: List[str] = Field(default_factory=list)


class DatasetSchema(BaseModel):
    columns: List[ColumnSchema] = Field(default_factory=list)
    row_count: Optional[int] = None
    sheet: Optional[str] = None


class MatchConfigRequest(BaseModel):
    source_schema: DatasetSchema
    reference_schema: Optional[DatasetSchema] = None
    mode: Literal["match", "dedupe"] = "match"


class ColumnMapping(BaseModel):
    source: str
    reference: Optional[str] = None
    type: Literal["exact", "fuzzy"] = "exact"
    threshold: Optional[int] = Field(default=None, ge=0, le=100)
    confidence: float = Field(ge=0.0, le=1.0)
    reason: Optional[str] = None
    weight: float = Field(default=0.85, ge=0.0, le=1.0)


class MatchConfigResponse(BaseModel):
    mappings: List[ColumnMapping]
    overall_confidence: float = Field(ge=0.0, le=1.0)
    reasoning: str
    warnings: List[str] = Field(default_factory=list)
    option_hints: Optional["OptionHints"] = None


class OptionHints(BaseModel):
    block: Optional[Literal["domain", "state", "none"]] = None
    anchor: Optional[str] = None
    source_id: Optional[str] = None
    reference_id: Optional[str] = None
    best_only: Optional[bool] = None
    threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0)
    normalize_source: Optional[bool] = None


RESPONSE_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "mappings": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "source": {"type": "string"},
                    "reference": {"type": "string"},
                    "type": {"type": "string", "enum": ["exact", "fuzzy"]},
                    "threshold": {"type": "integer", "minimum": 0, "maximum": 100},
                    "confidence": {"type": "number", "minimum": 0, "maximum": 1},
                    "reason": {"type": "string"},
                },
                "required": ["source", "type", "confidence"],
            },
        },
        "reasoning": {"type": "string"},
        "warnings": {"type": "array", "items": {"type": "string"}},
        "overall_confidence": {"type": "number", "minimum": 0, "maximum": 1},
        "option_hints": {
            "type": "object",
            "properties": {
                "block": {"type": "string", "enum": ["domain", "state", "none"]},
                "anchor": {"type": "string"},
                "source_id": {"type": "string"},
                "reference_id": {"type": "string"},
                "best_only": {"type": "boolean"},
                "threshold": {"type": "number", "minimum": 0, "maximum": 1},
                "normalize_source": {"type": "boolean"},
            },
        },
    },
    "required": ["mappings", "reasoning"],
}


@router.get("/match-config/health")
async def health_check():
    """Health check endpoint"""
    import os
    provider = get_llm_provider()
    return {
        "status": "healthy",
        "llm_provider": os.getenv("LLM_PROVIDER"),
        "model": os.getenv("VERTEX_MODEL") or os.getenv("OLLAMA_MODEL"),
        "provider_available": provider is not None,
        "provider_type": type(provider).__name__ if provider else None,
    }

@router.get("/debug-env")
async def debug_env():
    """Debug endpoint to check environment variables"""
    import os
    return {
        "LLM_PROVIDER": os.getenv("LLM_PROVIDER"),
        "VERTEX_PROJECT": os.getenv("VERTEX_PROJECT"),
        "VERTEX_LOCATION": os.getenv("VERTEX_LOCATION"),
        "VERTEX_MODEL": os.getenv("VERTEX_MODEL"),
        "provider_initialized": get_llm_provider() is not None,
        "provider_type": type(get_llm_provider()).__name__ if get_llm_provider() else None,
    }

@router.post("/match-config", response_model=MatchConfigResponse)
async def generate_ai_match_config(
    body: MatchConfigRequest,
) -> MatchConfigResponse:
    """Generate AI-powered column mappings for Sheets match wizard."""

    provider = get_llm_provider()
    src_type = _infer_dataset_type(body.source_schema.columns)
    ref_type = (
        _infer_dataset_type(body.reference_schema.columns)
        if body.reference_schema
        else src_type
    )
    relationship_mode = _determine_relationship_mode(src_type, ref_type, body.mode)
    prompt = _build_prompt(body)
    logger.info(
        "ai-match-config: mode=%s src_cols=%d ref_cols=%d",
        body.mode,
        len(body.source_schema.columns),
        len(body.reference_schema.columns) if body.reference_schema else 0,
    )

    value: Dict[str, Any] = {}
    fallback_reason: Optional[str] = None

    if provider is None:
        fallback_reason = (
            "AI service is not configured for this workspace; generated heuristic mappings."
        )
        logger.warning("AI match config falling back: provider not configured")
    else:
        try:
            llm_result = provider.generate_json(
                prompt, schema=RESPONSE_SCHEMA
            )
            logger.info("LLM returned result: %s", llm_result)
            value = llm_result.get("value") if isinstance(llm_result, dict) else {}
            value = _coerce_llm_value(value)
        except Exception as exc:  # pragma: no cover - upstream failures
            logger.exception("AI match config LLM call failed: %s", exc)
            fallback_reason = (
                "AI service is temporarily unavailable; generated heuristic mappings."
            )
            value = {}

    if not isinstance(value, dict):
        value = {}
    logger.info("Extracted value from LLM result: %s", value)

    raw_mappings = value.get("mappings") or []
    logger.info("Raw mappings from AI response: %s", raw_mappings)
    normalized_mappings = _normalize_mappings(raw_mappings, body, relationship_mode)
    logger.info("Normalized mappings: %s", normalized_mappings)

    if not normalized_mappings:
        normalized_mappings = _fallback_mappings(body)
        if normalized_mappings and fallback_reason is None:
            fallback_reason = (
                "AI response lacked confident mappings; generated heuristic mappings."
            )

    warnings = _normalize_warnings(value.get("warnings"))
    if fallback_reason:
        warnings.append(fallback_reason)

    reasoning = (
        value.get("reasoning")
        or fallback_reason
        or "AI-generated column configuration"
    )
    overall_confidence = value.get("overall_confidence") or _average_confidence(
        normalized_mappings
    )

    llm_option_hints = _normalize_option_hints(value.get("option_hints"), body)
    fallback_hints = _suggest_option_hints(body, normalized_mappings)
    combined_hints = _merge_option_hints(llm_option_hints, fallback_hints)

    logger.info(
        "Option hints (LLM=%s fallback=%s combined=%s)",
        llm_option_hints,
        fallback_hints,
        combined_hints,
    )
    return MatchConfigResponse(
        mappings=[ColumnMapping(**mapping) for mapping in normalized_mappings],
        reasoning=reasoning,
        overall_confidence=float(overall_confidence or 0.0),
        warnings=[str(w) for w in warnings if w],
        option_hints=combined_hints,
    )


def _coerce_llm_value(payload: Any) -> Dict[str, Any]:
    """Normalize LLM payloads into a dict, handling nested value wrappers/strings."""

    current = payload
    for _ in range(5):
        if isinstance(current, dict):
            # Some providers wrap again under a "value" key.
            if "mappings" in current or "reasoning" in current or "warnings" in current:
                return current
            if "value" in current:
                logger.debug("LLM payload contains nested 'value'; unwrapping.")
                current = current["value"]
                continue
            return current

        if isinstance(current, str):
            stripped = current.strip()
            if not stripped:
                return {}
            try:
                logger.debug(
                    "Attempting to json.loads LLM string payload (length=%d)",
                    len(stripped),
                )
                current = json.loads(stripped)
                continue
            except json.JSONDecodeError as exc:
                logger.warning("LLM JSON payload decode failed: %s", exc)
                return {}

        # Unsupported type – bail.
        break

    return current if isinstance(current, dict) else {}


def _build_prompt(body: MatchConfigRequest) -> str:
    src_block = _format_schema_section("SOURCE DATASET", body.source_schema)
    ref_block = (
        _format_schema_section("REFERENCE DATASET", body.reference_schema)
        if body.reference_schema
        else "REFERENCE DATASET:\n  - (dedupe mode: reference columns are the same as source columns)\n"
    )
    src_type = _infer_dataset_type(body.source_schema.columns)
    ref_type = (
        _infer_dataset_type(body.reference_schema.columns)
        if body.reference_schema
        else src_type
    )
    relationship_mode = _determine_relationship_mode(src_type, ref_type, body.mode)
    relationship_mode_label = relationship_mode.replace("_", " ").title()
    relationship_rules = _relationship_guidance(relationship_mode)
    source_id_hint = _guess_identifier_column(body.source_schema.columns, src_type)
    reference_id_hint = (
        _guess_identifier_column(body.reference_schema.columns, ref_type)
        if body.reference_schema
        else None
    )
    identifier_guidance = _identifier_guidance(
        source_id_hint,
        reference_id_hint,
        body.mode,
        src_type,
        ref_type,
        relationship_mode,
    )
    mode_text = (
        "two different datasets that must be matched"
        if body.mode == "match"
        else "a single dataset that should be deduplicated"
    )

    return textwrap.dedent(
        f"""
        You are a senior data matching expert. Analyze the schemas below and recommend the optimal
        column mappings for {mode_text}. Focus on identifying the strongest anchors such as Email,
        Domain/Website, Company/Account, and Contact Name.

        Rules:
        1. ONLY return columns that clearly correspond between datasets.
        2. ALWAYS use type="exact" for emails, domains, ids, and deterministic identifiers.
        3. Use type="fuzzy" for company names, person names, addresses, or titles. Include a threshold between 75-90.
        4. Confidence MUST be a decimal between 0 and 1 (e.g., 0.92 for 92%).
        5. ALWAYS explain your reasoning and list any warnings if the data looks incomplete.
        6. ALWAYS prefer identifiers (email, external_id, Salesforce IDs, domains) before fuzzy text fields.
        7. ONLY use column names that explicitly appear in the schema sections below. NEVER invent or assume missing
           columns. If a source column lacks a counterpart in the reference data, skip it and explain why in warnings.
        {relationship_rules}
        {identifier_guidance}
        Detected relationship mode: {relationship_mode_label}

        {src_block}
        {ref_block}

        Return JSON with the following shape:
        {{
          "mappings": [{{"source": "...", "reference": "...", "type": "exact|fuzzy", "threshold": 85, "confidence": 0.9, "reason": "..."}}],
          "reasoning": "overall explanation",
          "warnings": ["optional warnings"],
          "overall_confidence": 0.0-1.0,
          "option_hints": {{
            "source_id": "best source identifier column",
            "reference_id": "best reference identifier column",
            "block": "domain|state|none",
            "threshold": 0.0-1.0,
            "best_only": true/false
          }}
        }}
        """
    ).strip()


def _format_schema_section(label: str, schema: Optional[DatasetSchema]) -> str:
    if not schema or not schema.columns:
        return f"{label}:\n  - (no columns provided)\n"

    lines = [f"{label}:"]
    for column in schema.columns[:20]:
        samples = ", ".join(_format_sample(v) for v in column.samples[:3])
        dtype = column.data_type or "string"
        meta_bits = []
        if column.fill_rate is not None:
            fill_pct = int(round(column.fill_rate * 100))
            suffix = " (est)" if column.fill_rate_estimated else ""
            meta_bits.append(f"fill={fill_pct}%{suffix}")
        if column.distinct_ratio is not None:
            meta_bits.append(f"distinct={int(round(column.distinct_ratio * 100))}%")
        if column.unique_estimate is not None:
            meta_bits.append(f"unique≈{column.unique_estimate:,}")
        if column.tags:
            meta_bits.append("tags:" + ",".join(column.tags[:3]))
        meta = f" ({'; '.join(meta_bits)})" if meta_bits else ""
        lines.append(f"  - {column.name} [{dtype}]{meta} -> {samples}")
    return "\n".join(lines)


def _format_sample(value: Any) -> str:
    text = str(value) if value is not None else ""
    if len(text) > 40:
        text = text[:37] + "..."
    return text


def _normalize_mappings(
    raw_mappings: Any,
    body: MatchConfigRequest,
    relationship_mode: str,
) -> List[Dict[str, Any]]:
    normalized: List[Dict[str, Any]] = []
    if not isinstance(raw_mappings, list):
        return normalized

    source_names = {
        (column.name or "").strip().lower(): column.name or ""
        for column in (body.source_schema.columns or [])
    }
    reference_names = (
        {
            (column.name or "").strip().lower(): column.name or ""
            for column in (body.reference_schema.columns or [])
        }
        if body.reference_schema
        else {}
    )

    src_type = _infer_dataset_type(body.source_schema.columns)
    ref_type = (
        _infer_dataset_type(body.reference_schema.columns)
        if body.reference_schema
        else src_type
    )
    source_columns = _build_column_lookup(body.source_schema.columns)
    reference_columns = (
        _build_column_lookup(body.reference_schema.columns)
        if body.reference_schema
        else {}
    )

    for item in raw_mappings:
        if not isinstance(item, dict):
            continue
        source = (item.get("source") or item.get("left") or "").strip()
        reference = (item.get("reference") or item.get("right") or "").strip()
        if not source:
            continue
        source_key = source.lower()
        if source_key not in source_names:
            continue
        source = source_names[source_key]

        match_type = (item.get("type") or "exact").lower()
        if match_type not in {"exact", "fuzzy"}:
            match_type = "exact"
        confidence = item.get("confidence")
        try:
            confidence = float(confidence)
        except (TypeError, ValueError):
            confidence = None
        if confidence is None or math.isnan(confidence):
            confidence = 0.8
        confidence = max(0.0, min(1.0, confidence))

        threshold = item.get("threshold")
        try:
            if threshold is not None:
                threshold = int(threshold)
        except (TypeError, ValueError):
            threshold = None

        ref_value = reference or (None if body.mode == "dedupe" else "")
        src_column_meta = source_columns.get(source_key)
        src_role = _infer_column_role(src_column_meta)

        if body.mode == "match":
            if not reference:
                continue
            ref_key = reference.lower()
            if ref_key not in reference_names:
                continue
            ref_value = reference_names[ref_key]
            ref_column_meta = reference_columns.get(ref_key)
            ref_role = _infer_column_role(ref_column_meta)

            if not _roles_compatible(src_role, ref_role, relationship_mode):
                continue

        weight = _calculate_weight(
            src_column_meta,
            ref_column_meta if body.mode == "match" else src_column_meta,
            match_type,
            relationship_mode,
        )

        normalized.append(
            {
                "source": source,
                "reference": ref_value,
                "type": match_type,
                "threshold": threshold,
                "confidence": confidence,
                "reason": item.get("reason"),
                "weight": weight,
            }
        )

    # Summary logging for observability
    skipped_count = len(raw_mappings) - len(normalized)
    if skipped_count > 0:
        logger.info(
            "Filtered %d invalid mapping(s) from LLM response (schema mismatch or incompatible roles)",
            skipped_count,
        )

    return normalized


def _normalize_warnings(raw: Any) -> List[str]:
    if raw is None:
        return []
    if isinstance(raw, list):
        return [str(item) for item in raw if item]
    return [str(raw)]


def _average_confidence(mappings: List[Dict[str, Any]]) -> float:
    if not mappings:
        return 0.0
    total = sum(mapping.get("confidence", 0.0) for mapping in mappings)
    return max(0.0, min(1.0, total / len(mappings)))


KEYWORD_GROUPS = {
    "email": ("email", "e-mail"),
    "domain": ("domain", "website", "url"),
    "company": ("company", "account", "organization", "org"),
    "name": ("contact name", "full name", "name"),
    "title": ("title", "role"),
    "phone": ("phone", "mobile", "telephone"),
    "id": ("id", "identifier", "uuid"),
}

ROLE_EMAIL_HINTS = {"email", "e-mail"}
ROLE_DOMAIN_HINTS = {"domain", "website", "url"}
ROLE_COMPANY_HINTS = {"company", "account", "organization", "org", "business"}
ROLE_PERSON_HINTS = {"first", "last", "name", "contact", "person"}
ROLE_TITLE_HINTS = {"title", "role"}
ROLE_IDENTIFIER_HINTS = {"id", "guid", "uuid"}


def _determine_relationship_mode(
    src_type: str, ref_type: str, mode: Literal["match", "dedupe"]
) -> str:
    if mode == "dedupe":
        if src_type == "contact":
            return "contact_dedupe"
        if src_type == "entity":
            return "account_dedupe"
        return "dedupe"
    if src_type == "contact" and ref_type == "entity":
        return "contact_to_account"
    if src_type == "contact" and ref_type == "contact":
        return "contact_to_contact"
    if src_type == "entity" and ref_type == "entity":
        return "account_to_account"
    if src_type == "entity" and ref_type == "contact":
        return "account_to_contact"
    return "unknown"


def _fallback_mappings(body: MatchConfigRequest) -> List[Dict[str, Any]]:
    """Deterministic fallback when the LLM does not return mappings."""
    src_cols = body.source_schema.columns
    ref_cols = body.reference_schema.columns if body.reference_schema else []
    mappings: List[Dict[str, Any]] = []

    def _find(columns: List[ColumnSchema], group: str) -> Optional[str]:
        if not columns:
            return None
        keywords = KEYWORD_GROUPS.get(group, (group,))
        for keyword in keywords:
            for column in columns:
                if keyword in (column.name or "").lower():
                    return column.name
        return None

    anchors = [
        ("email", "exact", 95, 0.95),
        ("domain", "exact", 90, 0.9),
        ("company", "fuzzy", 85, 0.85),
        ("name", "fuzzy", 80, 0.8),
    ]

    for keyword, match_type, threshold, confidence in anchors:
        left = _find(src_cols, keyword)
        right = _find(ref_cols, keyword) if body.reference_schema else left
        if left and (right or body.mode == "dedupe"):
            mappings.append(
                {
                    "source": left,
                    "reference": right if body.mode == "match" else None,
                    "type": match_type,
                    "threshold": threshold,
                    "confidence": confidence,
                    "reason": f"Fallback {keyword} mapping",
                }
            )

    return mappings


def _build_column_lookup(columns: Optional[List[ColumnSchema]]) -> Dict[str, ColumnSchema]:
    lookup: Dict[str, ColumnSchema] = {}
    if not columns:
        return lookup
    for column in columns:
        if not column or not column.name:
            continue
        lookup[column.name.strip().lower()] = column
    return lookup


def _infer_column_role(column: Optional[ColumnSchema]) -> str:
    if column is None:
        return "unknown"
    name = (column.name or "").lower()
    dtype = (column.data_type or "").lower()
    tags = {tag.lower() for tag in (column.tags or [])}

    if any(hint in name for hint in ROLE_EMAIL_HINTS) or dtype == "email":
        return "email"
    if any(hint in name for hint in ROLE_DOMAIN_HINTS) or dtype in {"domain", "url"}:
        return "domain"
    if any(hint in name for hint in ROLE_COMPANY_HINTS):
        return "company"
    if any(hint in name for hint in ROLE_PERSON_HINTS):
        return "person"
    if any(hint in name for hint in ROLE_TITLE_HINTS):
        return "title"
    if (
        any(hint in name for hint in ROLE_IDENTIFIER_HINTS)
        or dtype == "id"
        or "id" in tags
    ):
        return "identifier"
    if "state" in name or "province" in name or "country" in name:
        return "region"
    return "unknown"


def _roles_compatible(
    source_role: str,
    reference_role: str,
    relationship_mode: str,
) -> bool:
    if reference_role == "unknown" or source_role == "unknown":
        return True

    if relationship_mode == "contact_to_account":
        return source_role in {"domain", "company", "identifier"} and reference_role in {
            "domain",
            "company",
            "identifier",
        }

    if relationship_mode == "contact_to_contact":
        allowed = {"email", "person", "title", "identifier", "phone", "domain", "company"}
        return source_role in allowed and reference_role in allowed

    if relationship_mode == "account_to_account":
        return source_role in {"domain", "company", "identifier", "region"} and reference_role in {
            "domain",
            "company",
            "identifier",
            "region",
        }

    if relationship_mode == "account_to_contact":
        if source_role in {"domain", "company"} and reference_role in {"domain", "company"}:
            return True
        if source_role == "identifier" and reference_role == "identifier":
            return True
        return False

    # dedupe or unknown relationship: require like-for-like roles
    if source_role == "email":
        return reference_role == "email"
    if source_role in {"person", "title"}:
        return reference_role in {"person", "title"}
    if source_role == "identifier":
        return reference_role == "identifier"
    if source_role == "domain":
        return reference_role == "domain"
    if source_role == "company":
        return reference_role == "company"
    if source_role == "region":
        return reference_role == "region"
    return True


def _calculate_weight(
    src_column: Optional[ColumnSchema],
    ref_column: Optional[ColumnSchema],
    match_type: str,
    relationship_mode: str,
) -> float:
    base = 0.85 if match_type == "exact" else 0.7

    def _extract_metric(column: Optional[ColumnSchema], attr: str) -> Optional[float]:
        if column is None:
            return None
        value = getattr(column, attr, None)
        if value is None or isinstance(value, bool):
            return None
        try:
            return max(0.0, min(1.0, float(value)))
        except (TypeError, ValueError):
            return None

    def _avg_or_default(values: List[Optional[float]], default: float) -> float:
        filtered = [value for value in values if value is not None]
        if not filtered:
            return default
        return sum(filtered) / len(filtered)

    fill_rate = _avg_or_default(
        [_extract_metric(src_column, "fill_rate"), _extract_metric(ref_column, "fill_rate")],
        0.8,
    )
    distinct_ratio = _avg_or_default(
        [
            _extract_metric(src_column, "distinct_ratio"),
            _extract_metric(ref_column, "distinct_ratio"),
        ],
        0.5,
    )

    quality = max(0.3, min(1.0, (fill_rate * 0.6) + (distinct_ratio * 0.4)))

    boost = 0.0
    src_role = _infer_column_role(src_column)
    if relationship_mode == "contact_to_account":
        if src_role == "domain":
            boost = 0.10
        elif src_role == "company":
            boost = 0.05
        elif src_role == "identifier":
            boost = 0.08
    elif relationship_mode == "contact_to_contact":
        if src_role == "email":
            boost = 0.08
        elif src_role == "identifier":
            boost = 0.06
    elif relationship_mode == "account_to_account":
        if src_role == "domain":
            boost = 0.08
        elif src_role == "identifier":
            boost = 0.06
    elif relationship_mode == "contact_dedupe":
        if src_role == "email":
            boost = 0.12
        elif src_role == "person":
            boost = 0.06
        elif src_role == "identifier":
            boost = 0.08
    elif relationship_mode == "account_dedupe":
        if src_role == "domain":
            boost = 0.12
        elif src_role == "company":
            boost = 0.08
        elif src_role == "identifier":
            boost = 0.06

    return max(0.1, min(1.0, (base * quality) + boost))


def _suggest_option_hints(
    body: MatchConfigRequest, mappings: List[Dict[str, Any]]
) -> Optional[OptionHints]:
    hints: Dict[str, Any] = {}
    src_type = _infer_dataset_type(body.source_schema.columns)
    ref_type = (
        _infer_dataset_type(body.reference_schema.columns)
        if body.reference_schema
        else src_type
    )
    relationship_mode = _determine_relationship_mode(src_type, ref_type, body.mode)
    source_id = _guess_id_column(body.source_schema.columns)
    if source_id:
        hints["source_id"] = source_id
    if body.reference_schema:
        reference_id = _guess_id_column(body.reference_schema.columns)
        if reference_id:
            hints["reference_id"] = reference_id

    block = _suggest_blocking(body)
    if block:
        hints["block"] = block

    anchor = _suggest_anchor(body)
    if anchor:
        hints["anchor"] = anchor

    threshold_hint = _suggest_threshold(mappings)
    if threshold_hint is not None:
        hints["threshold"] = threshold_hint

    best_only_hint = _suggest_best_only(body)
    if best_only_hint is not None:
        hints["best_only"] = best_only_hint

    if _should_enable_source_normalization(body, relationship_mode):
        hints["normalize_source"] = True

    if not hints:
        return None
    return OptionHints(**hints)


def _should_enable_source_normalization(
    body: MatchConfigRequest, relationship_mode: str
) -> bool:
    if relationship_mode != "contact_to_account":
        return False
    source_columns = body.source_schema.columns or []
    reference_columns = body.reference_schema.columns if body.reference_schema else []
    if not source_columns or not reference_columns:
        return False

    def _has_domain_reference(columns: List[ColumnSchema]) -> bool:
        for column in columns:
            if _infer_column_role(column) == "domain":
                return True
        return False

    def _needs_normalization(columns: List[ColumnSchema]) -> bool:
        for column in columns:
            role = _infer_column_role(column)
            if role in {"domain", "email"}:
                fill_rate = column.fill_rate or 0.0
                if fill_rate < 0.99:
                    return True
        return False

    return _has_domain_reference(reference_columns) and _needs_normalization(source_columns)


def _guess_id_column(columns: List[ColumnSchema]) -> Optional[str]:
    if not columns:
        return None
    candidates = []
    preferred_keywords = ("account", "lead", "contact")
    for column in columns:
        name = (column.name or "").strip()
        lower = name.lower()
        if not name:
            continue
        if any(keyword in lower for keyword in preferred_keywords) and "id" in lower:
            candidates.append((0, name))
            continue
        if "id" in lower or "uuid" in lower or "guid" in lower:
            candidates.append((1, name))
            continue
        if column.data_type and column.data_type.lower() in {"id", "identifier"}:
            candidates.append((2, name))
            continue
        sample_hits = sum(
            1 for sample in (column.samples or []) if _looks_like_identifier(sample)
        )
        if sample_hits >= 2:
            candidates.append((3, name))
    if not candidates:
        return None
    candidates.sort(key=lambda entry: entry[0])
    return candidates[0][1]


def _looks_like_identifier(sample: Any) -> bool:
    text = str(sample or "").strip()
    if not text:
        return False
    if len(text) in (15, 18) and text.isalnum():
        return True
    if text.startswith("00") and text.isalnum() and len(text) >= 12:
        return True
    return False


def _normalize_option_hints(
    option_hints: Any, body: MatchConfigRequest
) -> Optional[OptionHints]:
    if not isinstance(option_hints, dict):
        return None
    hints: Dict[str, Any] = {}
    block = option_hints.get("block")
    if block in {"domain", "state", "none"}:
        hints["block"] = block
    for key in ("anchor", "source_id", "reference_id"):
        value = option_hints.get(key)
        if isinstance(value, str) and value.strip():
            hints[key] = value.strip()
    threshold = option_hints.get("threshold")
    if threshold is not None:
        try:
            value = float(threshold)
            if value > 1:
                value = value / 100.0
            hints["threshold"] = max(0.0, min(1.0, value))
        except (TypeError, ValueError):
            pass
    best_only = option_hints.get("best_only")
    if isinstance(best_only, bool):
        hints["best_only"] = best_only
    normalize_source = option_hints.get("normalize_source")
    if isinstance(normalize_source, bool):
        hints["normalize_source"] = normalize_source
    if not hints:
        return None
    return OptionHints(**hints)


def _merge_option_hints(
    llm_hints: Optional[OptionHints], fallback_hints: Optional[OptionHints]
) -> Optional[OptionHints]:
    if not llm_hints and not fallback_hints:
        return None
    merged = OptionHints()
    for hints in (llm_hints, fallback_hints):
        if not hints:
            continue
        for field, value in hints.dict(exclude_none=True).items():
            setattr(merged, field, value)
    # Collapse to None if all fields empty
    if not merged.dict(exclude_none=True):
        return None
    return merged


def _suggest_blocking(body: MatchConfigRequest) -> Optional[Literal["domain", "state"]]:
    src_cols = body.source_schema.columns
    ref_cols = body.reference_schema.columns if body.reference_schema else []
    if _has_semantic_column(src_cols, {"domain", "website", "url"}) and (
        body.mode == "dedupe" or _has_semantic_column(ref_cols, {"domain", "website", "url"})
    ):
        return "domain"
    if _has_semantic_column(src_cols, {"state", "region", "province"}) and (
        body.mode == "dedupe" or _has_semantic_column(ref_cols, {"state", "region", "province"})
    ):
        return "state"
    return None


def _suggest_anchor(body: MatchConfigRequest) -> Optional[str]:
    ref_cols = body.reference_schema.columns if body.reference_schema else []
    if _has_semantic_column(body.source_schema.columns, {"state", "region"}) and (
        body.mode == "dedupe" or _has_semantic_column(ref_cols, {"state", "region"})
    ):
        return "State"
    if _has_semantic_column(body.source_schema.columns, {"country"}) and (
        body.mode == "dedupe" or _has_semantic_column(ref_cols, {"country"})
    ):
        return "Country"
    return None


def _has_semantic_column(columns: List[ColumnSchema], keywords: set[str]) -> bool:
    if not columns:
        return False
    lowered = {kw.lower() for kw in keywords}
    for column in columns:
        name = (column.name or "").lower()
        dtype = (column.data_type or "").lower()
        if any(keyword in name for keyword in lowered):
            return True
        if dtype and any(keyword in dtype for keyword in lowered):
            return True
    return False


def _suggest_threshold(mappings: List[Dict[str, Any]]) -> Optional[float]:
    thresholds = []
    for mapping in mappings:
        threshold = mapping.get("threshold")
        if isinstance(threshold, int):
            thresholds.append(threshold / 100)
    if not thresholds:
        return None
    return max(0.6, min(0.95, max(thresholds)))


def _suggest_best_only(body: MatchConfigRequest) -> Optional[bool]:
    src_rows = body.source_schema.row_count or 0
    ref_rows = body.reference_schema.row_count if body.reference_schema else None
    if src_rows == 0 and not ref_rows:
        return None
    comparisons = src_rows * (ref_rows or max(src_rows, 1))
    if comparisons >= 5000:
        return True
    if comparisons <= 500:
        return False
    return None


PERSON_HINTS = {
    "first",
    "last",
    "name",
    "email",
    "phone",
    "mobile",
    "title",
    "contact",
}
ENTITY_HINTS = {
    "company",
    "account",
    "organization",
    "org",
    "domain",
    "website",
    "site",
}
SALESFORCE_ENTITY_IDS = {"account id", "18-digit account id"}
SALESFORCE_CONTACT_IDS = {"lead id", "contact id"}
IDENTIFIER_PRIORITIES = {
    "contact": [
        "lead id",
        "contact id",
        "zoominfo contact id",
        "person id",
        "email",
    ],
    "entity": [
        "18-digit account id",
        "account id",
        "company id",
        "external id",
        "domain",
        "website",
    ],
    "unknown": ["id"],
}


def _infer_dataset_type(columns: List[ColumnSchema]) -> str:
    """Detect whether a dataset looks like contacts or entities."""

    person_score = 0
    entity_score = 0

    for column in columns or []:
        name = (column.name or "").lower()
        if not name:
            continue
        if any(keyword in name for keyword in PERSON_HINTS):
            person_score += 1
        if any(keyword in name for keyword in ENTITY_HINTS):
            entity_score += 1

        # Weight Salesforce-style IDs more heavily.
        if any(keyword in name for keyword in SALESFORCE_ENTITY_IDS):
            entity_score += 3
        if any(keyword in name for keyword in SALESFORCE_CONTACT_IDS):
            person_score += 3

        if "id" in name:
            if "account" in name or "company" in name:
                entity_score += 2
            if "lead" in name or "contact" in name:
                person_score += 2

    if person_score > entity_score:
        return "contact"
    if entity_score > person_score:
        return "entity"
    return "unknown"


def _relationship_guidance(relationship_mode: str) -> str:
    """Create explicit prompt instructions for dataset relationship."""

    if relationship_mode == "contact_dedupe":
        return textwrap.dedent(
            """
            CONTACT DEDUPLICATION MODE:
            - Prioritize Email as the most deterministic identifier.
            - Then align person names (First + Last), Phone, and Mobile fields to catch near-duplicates.
            - Company/Domain values can support matches but are secondary.
            - ALWAYS map like-for-like columns (Email→Email, First Name→First Name) and use fuzzy matching for names.
            """
        ).strip()

    if relationship_mode == "account_dedupe":
        return textwrap.dedent(
            """
            ACCOUNT DEDUPLICATION MODE:
            - Prioritize Domain/Website first, then Company/Account names (use fuzzy matching for Inc/LLC variations).
            - Address attributes (Street, City, State, Country) are strong secondary anchors.
            - Ignore contact-level attributes (individual names, personal emails) because multiple contacts can belong to one account.
            - ALWAYS map like-for-like company fields (Domain→Domain, Company Name→Company Name).
            """
        ).strip()

    if relationship_mode == "dedupe":
        return (
            "ALWAYS treat both sides as the same dataset (dedupe mode). NEVER map a column "
            "to a different semantic type; only pair identical attributes so duplicates collapse correctly."
        )

    if relationship_mode == "contact_to_account":
        return textwrap.dedent(
            """
            IMPORTANT RELATIONSHIP RULES (Contact → Account):
            - Contacts must be linked to accounts via COMPANY-LEVEL anchors (Domain, Website, Company/Account name, Account IDs).
            - NEVER map personal attributes (Email, First Name, Last Name, Title) directly to account-level columns when the reference dataset lacks person fields.
            - If the reference dataset does not expose contact-level identifiers, EXCLUDE those mappings and explain the omission in warnings.
            - Prefer Domain/Website anchors first, then Company/Account name, then matching IDs when both datasets contain them.
            """
        ).strip()

    if relationship_mode == "contact_to_contact":
        return (
            "Both datasets appear to be contacts. ALWAYS align Email→Email, person names, phone numbers, and contact IDs. "
            "NEVER map contact fields to company/account-only attributes."
        )

    if relationship_mode == "account_to_account":
        return (
            "Both datasets appear to be company/entity records. ALWAYS align Domains/Websites and Company/Account names. "
            "ONLY map identifiers that exist on both sides."
        )

    if relationship_mode == "account_to_contact":
        return (
            "The source dataset looks like companies while the reference contains contacts. "
            "SKIP mappings that would pair company fields to person-level columns; instead, explain in warnings that no valid mapping exists."
        )

    return (
        "When unsure of the relationship, ALWAYS prefer matching like-for-like fields (emails to emails, domains to domains, "
        "company names to company names) and NEVER map personal attributes to company attributes."
    )


def _guess_identifier_column(
    columns: Optional[List[ColumnSchema]], dataset_type: str
) -> Optional[str]:
    if not columns:
        return None
    priorities = IDENTIFIER_PRIORITIES.get(dataset_type, IDENTIFIER_PRIORITIES["unknown"])
    normalized = [(col.name or "").strip() for col in columns]

    for keyword in priorities:
        for original, norm in zip(columns, normalized):
            if keyword in norm.lower():
                return original.name

    for original, norm in zip(columns, normalized):
        if "id" in norm.lower():
            return original.name
    return None


def _identifier_guidance(
    source_id: Optional[str],
    reference_id: Optional[str],
    mode: Literal["match", "dedupe"],
    src_type: str,
    ref_type: str,
    relationship_mode: str,
) -> str:
    lines: List[str] = []
    if mode == "match":
        if relationship_mode == "contact_to_account":
            if source_id:
                lines.append(
                    f"- '{source_id}' is the strongest CONTACT identifier. Use it for source ID hints, but do NOT map it directly "
                    "to account identifiers. Instead, rely on Domain/Website/Company columns to bridge contacts to accounts."
                )
            if reference_id:
                lines.append(
                    f"- '{reference_id}' is the strongest ACCOUNT identifier. Only map to it when the source provides an account-level "
                    "column (e.g., Company Name, Account ID). Contact IDs must not be paired directly with account IDs."
                )
        elif relationship_mode == "account_to_contact":
            lines.append(
                "- The datasets are incompatible (Account → Contact). Skip ID mappings and explain why in warnings."
            )
        else:
            if source_id and reference_id:
                lines.append(
                    f"- Treat '{source_id}' as the primary source identifier and '{reference_id}' as the reference identifier. "
                    "ALWAYS prefer an exact mapping between them when both exist."
                )
            elif source_id:
                lines.append(
                    f"- Use '{source_id}' as the strongest source identifier. If the reference dataset lacks an equivalent column, "
                    "explain that in warnings."
                )
            elif reference_id:
                lines.append(
                    f"- '{reference_id}' is the strongest reference identifier. Recommend mapping to it whenever a compatible source column exists."
                )
    else:  # dedupe
        if source_id:
            lines.append(
                f"- '{source_id}' appears to be the canonical identifier. Prefer exact mappings on this column to collapse duplicates safely."
            )

    if not lines:
        return ""
    return "Identifier Guidance:\n" + "\n".join(lines) + "\n"
