"""Bounded deterministic duplicate-candidate generation."""

from __future__ import annotations

import hashlib
from collections import defaultdict
from collections.abc import Iterable
from itertools import combinations

from ..errors import ContractViolation
from ..normalization.models import NormalizedBusiness
from .models import (
    DuplicateCandidate,
    DuplicateCandidateLimits,
    DuplicateCandidateReport,
    DuplicateCandidateStrength,
)


def _source_key(record: NormalizedBusiness) -> tuple[str, str, int]:
    identity = record.identity
    return (identity.region_id, identity.osm_type.value, identity.osm_id)


def _phone_bucket(record: NormalizedBusiness) -> str | None:
    if record.phone is None:
        return None
    if record.phone.startswith("+"):
        return f"phone:{record.phone}"
    return f"phone:{record.country or 'unknown'}:{record.phone}"


def _bucket_keys(record: NormalizedBusiness) -> tuple[tuple[str, str], ...]:
    keys: list[tuple[str, str]] = []
    if record.domain is not None:
        keys.append((f"domain:{record.domain}", "domain_exact"))
    phone = _phone_bucket(record)
    if phone is not None:
        keys.append((phone, "phone_exact"))
    if record.name is not None and record.address is not None:
        keys.append(
            (
                f"name_address:{record.name}|{record.address}",
                "name_address_exact",
            )
        )
    if record.name is not None and record.city is not None and record.postcode is not None:
        keys.append(
            (
                f"name_city_postcode:{record.name}|{record.city}|{record.postcode}",
                "name_city_postcode_exact",
            )
        )
    return tuple(keys)


def _conflicting_identity(left: NormalizedBusiness, right: NormalizedBusiness) -> bool:
    name_conflict = left.name is not None and right.name is not None and left.name != right.name
    address_conflict = (
        left.address is not None and right.address is not None and left.address != right.address
    )
    return name_conflict and address_conflict


def _classify(
    left: NormalizedBusiness,
    right: NormalizedBusiness,
    reasons: set[str],
) -> tuple[DuplicateCandidateStrength, int]:
    external_exact = {"domain_exact", "phone_exact"} & reasons
    if external_exact and _conflicting_identity(left, right):
        return DuplicateCandidateStrength.COLLISION, 30
    if {"domain_exact", "phone_exact"}.issubset(reasons):
        return DuplicateCandidateStrength.EXACT_EVIDENCE, 100
    if "name_address_exact" in reasons:
        return DuplicateCandidateStrength.EXACT_EVIDENCE, 98
    if external_exact and left.name is not None and left.name == right.name:
        return DuplicateCandidateStrength.HIGH_CONFIDENCE, 95
    if external_exact:
        return DuplicateCandidateStrength.HIGH_CONFIDENCE, 88
    return DuplicateCandidateStrength.NEEDS_REVIEW, 70


class DuplicateCandidateGenerator:
    """Generate review-only candidates without resolving or merging records."""

    def __init__(self, limits: DuplicateCandidateLimits | None = None) -> None:
        self._limits = DuplicateCandidateLimits() if limits is None else limits
        if not isinstance(self._limits, DuplicateCandidateLimits):
            raise ContractViolation("limits must be DuplicateCandidateLimits")

    def generate(self, records: Iterable[NormalizedBusiness]) -> DuplicateCandidateReport:
        values: list[NormalizedBusiness] = []
        seen_identities: set[tuple[str, str, int]] = set()
        scan_region: str | None = None
        for record in records:
            if not isinstance(record, NormalizedBusiness):
                raise ContractViolation("records must contain NormalizedBusiness values")
            if len(values) >= self._limits.max_records:
                raise ContractViolation("duplicate scan exceeds max_records")
            region_id = record.identity.region_id
            if scan_region is None:
                scan_region = region_id
            elif region_id != scan_region:
                raise ContractViolation("duplicate scan must contain exactly one source region")
            source_key = _source_key(record)
            if source_key in seen_identities:
                raise ContractViolation("duplicate scan contains a repeated source identity")
            seen_identities.add(source_key)
            values.append(record)

        buckets: dict[str, list[tuple[int, str]]] = defaultdict(list)
        for index, record in enumerate(values):
            for bucket_key, reason in _bucket_keys(record):
                buckets[bucket_key].append((index, reason))

        pair_reasons: dict[tuple[int, int], set[str]] = defaultdict(set)
        skipped: list[str] = []
        for bucket_key in sorted(buckets):
            members = buckets[bucket_key]
            if len(members) > self._limits.max_bucket_size:
                skipped.append(hashlib.sha256(bucket_key.encode("utf-8")).hexdigest())
                continue
            for (left_index, left_reason), (right_index, right_reason) in combinations(members, 2):
                pair = (min(left_index, right_index), max(left_index, right_index))
                pair_reasons[pair].update((left_reason, right_reason))
                if len(pair_reasons) > self._limits.max_candidates:
                    raise ContractViolation("duplicate scan exceeds max_candidates")

        candidates: list[DuplicateCandidate] = []
        for left_index, right_index in sorted(pair_reasons):
            left = values[left_index]
            right = values[right_index]
            reasons = pair_reasons[(left_index, right_index)]
            strength, score = _classify(left, right, reasons)
            candidates.append(
                DuplicateCandidate(
                    left=left.identity,
                    right=right.identity,
                    strength=strength,
                    score=score,
                    reason_codes=tuple(reasons),
                    evidence=tuple(f"matched:{reason}" for reason in reasons),
                )
            )

        return DuplicateCandidateReport(
            records_examined=len(values),
            bucket_count=len(buckets),
            candidates=tuple(candidates),
            skipped_oversized_bucket_hashes=tuple(skipped),
        )
