"""Historical deal similarity scoring for quote readiness."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Iterable

from .forensics import NormalizedDealRecord


_DIMENSION_WEIGHTS = {
    "motion": 0.24,
    "segment": 0.12,
    "product_family": 0.14,
    "size_band": 0.08,
    "discount_pct": 0.12,
    "term_months": 0.10,
    "payment_terms": 0.08,
    "template_key": 0.06,
    "approval_role": 0.06,
}
_TOTAL_WEIGHT = float(sum(_DIMENSION_WEIGHTS.values()))


@dataclass(frozen=True)
class SimilarityInput:
    motion_id: str
    segment: str | None = None
    product_family: str | None = None
    size_band: str | None = None
    discount_pct: float | None = None
    term_months: int | None = None
    payment_terms: str | None = None
    template_key: str | None = None
    recommended_approval_roles: tuple[str, ...] = ()


@dataclass(frozen=True)
class RankedDealSimilarity:
    record_id: str
    similarity_score: float
    matched_dimensions: tuple[str, ...]
    mismatched_dimensions: tuple[str, ...]
    coverage_ratio: float
    reasoning: str


def rank_similar_deals(
    current: SimilarityInput,
    candidates: Iterable[NormalizedDealRecord],
    *,
    limit: int = 5,
) -> list[RankedDealSimilarity]:
    scored: list[RankedDealSimilarity] = []
    seen: set[str] = set()
    for candidate in candidates:
        record_id = str(candidate.record_id or "").strip()
        if not record_id or record_id in seen:
            continue
        seen.add(record_id)
        scored.append(_score_candidate(current, candidate))
    scored.sort(key=lambda item: (-item.similarity_score, item.record_id))
    return scored[: max(1, int(limit))]


def _score_candidate(
    current: SimilarityInput,
    candidate: NormalizedDealRecord,
) -> RankedDealSimilarity:
    raw_score = 0.0
    available_weight = 0.0
    matched: list[str] = []
    mismatched: list[str] = []

    raw, available = _score_text_dimension(
        label="motion",
        current_value=current.motion_id,
        candidate_value=candidate.motion_id,
        weight=_DIMENSION_WEIGHTS["motion"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_text_dimension(
        label="segment",
        current_value=current.segment,
        candidate_value=candidate.segment,
        weight=_DIMENSION_WEIGHTS["segment"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_text_dimension(
        label="product_family",
        current_value=current.product_family,
        candidate_value=candidate.product_family,
        weight=_DIMENSION_WEIGHTS["product_family"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_text_dimension(
        label="size_band",
        current_value=current.size_band,
        candidate_value=candidate.size_band,
        weight=_DIMENSION_WEIGHTS["size_band"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_numeric_proximity(
        label="discount_pct",
        current_value=current.discount_pct,
        candidate_value=candidate.discount_pct,
        weight=_DIMENSION_WEIGHTS["discount_pct"],
        matched=matched,
        mismatched=mismatched,
        tolerance=20.0,
        unit="pts",
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_numeric_proximity(
        label="term_months",
        current_value=float(current.term_months) if current.term_months is not None else None,
        candidate_value=float(candidate.term_months) if candidate.term_months is not None else None,
        weight=_DIMENSION_WEIGHTS["term_months"],
        matched=matched,
        mismatched=mismatched,
        tolerance=24.0,
        unit="months",
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_text_dimension(
        label="payment_terms",
        current_value=current.payment_terms,
        candidate_value=candidate.payment_terms,
        weight=_DIMENSION_WEIGHTS["payment_terms"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_text_dimension(
        label="template_key",
        current_value=current.template_key,
        candidate_value=candidate.template_key,
        weight=_DIMENSION_WEIGHTS["template_key"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    raw, available = _score_role_dimension(
        expected_roles=current.recommended_approval_roles,
        candidate_role=candidate.approver_role,
        weight=_DIMENSION_WEIGHTS["approval_role"],
        matched=matched,
        mismatched=mismatched,
    )
    raw_score += raw
    available_weight += available

    coverage_ratio = round(available_weight / _TOTAL_WEIGHT, 4) if _TOTAL_WEIGHT else 0.0
    score = raw_score * (0.65 + (0.35 * coverage_ratio))
    reasoning = _reasoning_text(matched, mismatched)
    return RankedDealSimilarity(
        record_id=str(candidate.record_id),
        similarity_score=max(0.0, min(round(score, 4), 1.0)),
        matched_dimensions=tuple(matched),
        mismatched_dimensions=tuple(mismatched),
        coverage_ratio=coverage_ratio,
        reasoning=reasoning,
    )


def _score_text_dimension(
    *,
    label: str,
    current_value: str | None,
    candidate_value: str | None,
    weight: float,
    matched: list[str],
    mismatched: list[str],
) -> tuple[float, float]:
    current_text = _clean_text(current_value)
    candidate_text = _clean_text(candidate_value)
    if not current_text and not candidate_text:
        return weight * 0.35, 0.0
    if not current_text:
        return weight * 0.35, 0.0
    if not candidate_text:
        mismatched.append(f"{label} missing on historical deal")
        return weight * 0.2, 0.0
    if current_text.lower() == candidate_text.lower():
        matched.append(f"{label}: {candidate_text}")
        return weight, weight
    mismatched.append(f"{label}: expected {current_text}, saw {candidate_text}")
    return 0.0, weight


def _score_numeric_proximity(
    *,
    label: str,
    current_value: float | None,
    candidate_value: float | None,
    weight: float,
    matched: list[str],
    mismatched: list[str],
    tolerance: float,
    unit: str,
) -> tuple[float, float]:
    if current_value is None and candidate_value is None:
        return weight * 0.35, 0.0
    if current_value is None:
        return weight * 0.35, 0.0
    if candidate_value is None:
        mismatched.append(f"{label} missing on historical deal")
        return weight * 0.2, 0.0

    delta = abs(float(current_value) - float(candidate_value))
    closeness = max(0.0, 1.0 - min(delta, tolerance) / tolerance)
    if closeness >= 0.75:
        matched.append(f"{label}: within {delta:.1f} {unit}")
    elif closeness <= 0.45:
        mismatched.append(f"{label}: off by {delta:.1f} {unit}")
    return weight * closeness, weight


def _score_role_dimension(
    *,
    expected_roles: tuple[str, ...],
    candidate_role: str | None,
    weight: float,
    matched: list[str],
    mismatched: list[str],
) -> tuple[float, float]:
    normalized_expected = tuple(_clean_text(role) for role in expected_roles if _clean_text(role))
    candidate_text = _clean_text(candidate_role)
    if not normalized_expected and not candidate_text:
        return weight * 0.35, 0.0
    if not normalized_expected:
        return weight * 0.35, 0.0
    if not candidate_text:
        mismatched.append("approval_role missing on historical deal")
        return weight * 0.2, 0.0
    if any(candidate_text.lower() == role.lower() for role in normalized_expected):
        matched.append(f"approval_role: {candidate_text}")
        return weight, weight
    mismatched.append(f"approval_role: expected one of {', '.join(normalized_expected)}, saw {candidate_text}")
    return 0.0, weight


def _reasoning_text(matched: list[str], mismatched: list[str]) -> str:
    matched_text = ", ".join(matched[:3]) if matched else "limited direct overlap"
    mismatch_text = mismatched[0] if mismatched else "no material mismatches"
    return f"Matched on {matched_text}; {mismatch_text}."


def _clean_text(value: str | None) -> str | None:
    text = str(value or "").strip()
    return text or None


__all__ = [
    "RankedDealSimilarity",
    "SimilarityInput",
    "rank_similar_deals",
]
