"""Pure deterministic normalizers with no locale or network dependency."""

from __future__ import annotations

import hashlib
import ipaddress
import json
import re
import unicodedata
from collections.abc import Iterable
from urllib.parse import urlsplit

from ..domain.enums import OsmObjectType
from ..domain.models import RegionScopedIdentity
from ..errors import ContractViolation
from ..pbf.models import ParsedOsmObject
from ..pbf.tags import canonicalize_osm_tags
from .models import NormalizationContext, NormalizedBusiness, RawBusinessFields

_MAX_RAW_TEXT_LENGTH = 4096
_MAX_NORMALIZED_TEXT_LENGTH = 1024
_MAX_DOMAIN_LENGTH = 253
_CONTROL_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
_WHITESPACE_PATTERN = re.compile(r"\s+")
_PHONE_EXTENSION_PATTERN = re.compile(r"(?:ext\.?|extension|x|#)\s*\d+\s*$", re.IGNORECASE)


def _clean_text(value: object) -> str | None:
    if value is None:
        return None
    if not isinstance(value, str):
        raise ContractViolation("normalization input must be a string or null")
    if len(value) > _MAX_RAW_TEXT_LENGTH:
        raise ContractViolation("normalization input exceeds the bounded length")
    normalized = unicodedata.normalize("NFKC", value)
    normalized = _CONTROL_PATTERN.sub(" ", normalized)
    normalized = _WHITESPACE_PATTERN.sub(" ", normalized).strip()
    if len(normalized) > _MAX_RAW_TEXT_LENGTH:
        raise ContractViolation("normalized input exceeds the bounded length")
    return normalized or None


def _word_normalize(value: object) -> str | None:
    cleaned = _clean_text(value)
    if cleaned is None:
        return None
    cleaned = cleaned.casefold().replace("&", " and ")
    output: list[str] = []
    previous_space = False
    for character in unicodedata.normalize("NFKC", cleaned):
        category = unicodedata.category(character)
        if character.isalnum():
            output.append(character)
            previous_space = False
        elif category.startswith(("P", "S", "Z")) or character.isspace():
            if output and not previous_space:
                output.append(" ")
                previous_space = True
        if len(output) > _MAX_NORMALIZED_TEXT_LENGTH:
            raise ContractViolation("normalized text exceeds the bounded length")
    return "".join(output).strip() or None


def normalize_name(value: object) -> str | None:
    return _word_normalize(value)


def normalize_address(value: object) -> str | None:
    return _word_normalize(value)


def normalize_country_code(value: object) -> str | None:
    cleaned = _clean_text(value)
    if cleaned is None:
        return None
    country = cleaned.upper()
    if not re.fullmatch(r"[A-Z]{2}", country):
        return None
    return country


def normalize_domain(value: object) -> str | None:
    cleaned = _clean_text(value)
    if cleaned is None:
        return None
    candidate = cleaned if "://" in cleaned else f"https://{cleaned}"
    try:
        parsed = urlsplit(candidate)
        if parsed.scheme.casefold() not in {"http", "https"}:
            return None
        if parsed.username is not None or parsed.password is not None:
            return None
        hostname = parsed.hostname
    except ValueError:
        return None
    if hostname is None:
        return None
    host = hostname.rstrip(".").casefold()
    if host.startswith("www."):
        host = host[4:]
    if not host or host == "localhost":
        return None
    try:
        canonical = host.encode("idna").decode("ascii")
    except UnicodeError:
        return None
    if len(canonical) > _MAX_DOMAIN_LENGTH:
        return None
    try:
        ipaddress.ip_address(canonical)
    except ValueError:
        labels = canonical.split(".")
        if (
            len(labels) < 2
            or any(not label or len(label) > 63 for label in labels)
            or any(not re.fullmatch(r"[a-z0-9-]+", label) for label in labels)
            or any(label.startswith("-") or label.endswith("-") for label in labels)
        ):
            return None
    else:
        return None
    return canonical


def normalize_phone(value: object, context: NormalizationContext) -> str | None:
    if not isinstance(context, NormalizationContext):
        raise ContractViolation("context must be NormalizationContext")
    cleaned = _clean_text(value)
    if cleaned is None:
        return None
    without_extension = _PHONE_EXTENSION_PATTERN.sub("", cleaned)
    if any(character.isalpha() for character in without_extension):
        return None
    international = without_extension.lstrip().startswith("+")
    digits = "".join(character for character in without_extension if character.isdigit())
    if digits.startswith("00"):
        digits = digits[2:]
        international = True
    if not international and context.calling_code is not None:
        if digits.startswith(context.calling_code):
            international = True
        else:
            trunk = context.national_trunk_prefix
            if digits.startswith(trunk):
                digits = digits[len(trunk) :]
            digits = f"{context.calling_code}{digits}"
            international = True
    if not 7 <= len(digits) <= 15:
        return None
    return f"+{digits}" if international else digits


def _first_tag(tags: dict[str, str], keys: Iterable[str]) -> str | None:
    for key in keys:
        value = tags.get(key)
        if value is not None and value.strip():
            return value
    return None


def _address_from_tags(tags: dict[str, str]) -> str | None:
    street_line = " ".join(
        part
        for part in (
            tags.get("addr:housenumber", "").strip(),
            tags.get("addr:street", "").strip(),
        )
        if part
    )
    parts = [
        street_line,
        tags.get("addr:suburb", "").strip(),
        tags.get("addr:district", "").strip(),
    ]
    combined = ", ".join(part for part in parts if part)
    return combined or None


def _tags_hash(tags: tuple[tuple[str, str], ...]) -> str:
    canonical = json.dumps(tags, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(canonical).hexdigest()


def raw_business_fields_from_parsed_object(
    *,
    region_id: str,
    parsed_object: ParsedOsmObject,
    context: NormalizationContext,
) -> RawBusinessFields:
    if not isinstance(parsed_object, ParsedOsmObject):
        raise ContractViolation("parsed_object must be ParsedOsmObject")
    if not isinstance(context, NormalizationContext):
        raise ContractViolation("context must be NormalizationContext")
    tags = canonicalize_osm_tags(parsed_object.tags)
    country = _first_tag(tags, ("addr:country", "is_in:country_code")) or context.country_code
    return RawBusinessFields(
        identity=RegionScopedIdentity(
            region_id=region_id,
            osm_type=parsed_object.identity.osm_type,
            osm_id=parsed_object.identity.osm_id,
        ),
        name=_first_tag(tags, ("name", "brand", "operator")),
        website=_first_tag(tags, ("contact:website", "website", "url")),
        phone=_first_tag(tags, ("contact:phone", "phone", "contact:mobile")),
        address=_address_from_tags(tags),
        city=_first_tag(tags, ("addr:city", "addr:town", "addr:village")),
        state=tags.get("addr:state"),
        postcode=tags.get("addr:postcode"),
        country=country,
        longitude_e7=(
            parsed_object.longitude_e7
            if parsed_object.identity.osm_type is OsmObjectType.NODE
            else None
        ),
        latitude_e7=(
            parsed_object.latitude_e7
            if parsed_object.identity.osm_type is OsmObjectType.NODE
            else None
        ),
        source_tags_hash=_tags_hash(parsed_object.tags),
    )


def normalize_business(
    raw: RawBusinessFields,
    context: NormalizationContext,
) -> NormalizedBusiness:
    if not isinstance(raw, RawBusinessFields):
        raise ContractViolation("raw must be RawBusinessFields")
    if not isinstance(context, NormalizationContext):
        raise ContractViolation("context must be NormalizationContext")

    name = normalize_name(raw.name)
    domain = normalize_domain(raw.website)
    phone = normalize_phone(raw.phone, context)
    address = normalize_address(raw.address)
    city = normalize_address(raw.city)
    state = normalize_address(raw.state)
    postcode = normalize_address(raw.postcode)
    country = normalize_country_code(raw.country) or context.country_code
    canonical = json.dumps(
        {
            "address": address,
            "city": city,
            "country": country,
            "domain": domain,
            "name": name,
            "phone": phone,
            "postcode": postcode,
            "state": state,
        },
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    return NormalizedBusiness(
        identity=raw.identity,
        name=name,
        domain=domain,
        phone=phone,
        address=address,
        city=city,
        state=state,
        postcode=postcode,
        country=country,
        longitude_e7=raw.longitude_e7,
        latitude_e7=raw.latitude_e7,
        source_tags_hash=raw.source_tags_hash,
        identity_hash=hashlib.sha256(canonical).hexdigest(),
    )
