"""Immutable contracts for bounded PBF acquisition and parsing."""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Mapping
from urllib.parse import urlsplit

from ..domain.enums import OsmObjectType
from ..domain.models import OsmIdentity
from ..errors import ContractViolation, PbfDownloadRejected

_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"})
_MAX_TEST_DOWNLOAD_BYTES = 64 * 1024 * 1024
_MAX_PARSE_FILE_BYTES = 4 * 1024 * 1024 * 1024
_MAX_PARSE_OBJECTS = 100_000_000
_MAX_PARSE_TAGS = 500_000_000
_MAX_TAGS_PER_OBJECT = 1_024
_MAX_COLLECTION_ITEMS = 1_000_000
_MAX_BLOB_BYTES = 32 * 1024 * 1024
_MAX_TOTAL_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
_MAX_BLOB_COUNT = 500_000
_MAX_BLOB_HEADER_BYTES = 64 * 1024


def _nonblank(value: object, field_name: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ContractViolation(f"{field_name} must be a nonblank string")
    return value.strip()


def _positive_int(value: object, field_name: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
        raise ContractViolation(f"{field_name} must be a positive integer")
    return value


def _aware_utc(value: datetime, field_name: str) -> datetime:
    if not isinstance(value, datetime) or value.tzinfo is None:
        raise ContractViolation(f"{field_name} must be timezone-aware")
    return value.astimezone(timezone.utc)


@dataclass(frozen=True, slots=True)
class PbfParseLimits:
    """Hard-bounded limits for one parser invocation."""

    max_file_size_bytes: int = 8 * 1024 * 1024
    max_objects: int = 100_000
    max_total_tags: int = 500_000
    max_tags_per_object: int = 256
    max_way_nodes: int = 100_000
    max_relation_members: int = 100_000
    max_blob_size_bytes: int = 32 * 1024 * 1024
    max_uncompressed_blob_bytes: int = 32 * 1024 * 1024
    max_total_uncompressed_bytes: int = 64 * 1024 * 1024
    max_blob_count: int = 1024
    max_blob_header_bytes: int = 64 * 1024

    def __post_init__(self) -> None:
        values = {
            "max_file_size_bytes": self.max_file_size_bytes,
            "max_objects": self.max_objects,
            "max_total_tags": self.max_total_tags,
            "max_tags_per_object": self.max_tags_per_object,
            "max_way_nodes": self.max_way_nodes,
            "max_relation_members": self.max_relation_members,
            "max_blob_size_bytes": self.max_blob_size_bytes,
            "max_uncompressed_blob_bytes": self.max_uncompressed_blob_bytes,
            "max_total_uncompressed_bytes": self.max_total_uncompressed_bytes,
            "max_blob_count": self.max_blob_count,
            "max_blob_header_bytes": self.max_blob_header_bytes,
        }
        for name, value in values.items():
            object.__setattr__(self, name, _positive_int(value, name))
        if self.max_file_size_bytes > _MAX_PARSE_FILE_BYTES:
            raise ContractViolation("max_file_size_bytes exceeds the production hard limit")
        if self.max_objects > _MAX_PARSE_OBJECTS:
            raise ContractViolation("max_objects exceeds the Phase 2A.3 hard limit")
        if self.max_total_tags > _MAX_PARSE_TAGS:
            raise ContractViolation("max_total_tags exceeds the Phase 2A.3 hard limit")
        if self.max_tags_per_object > _MAX_TAGS_PER_OBJECT:
            raise ContractViolation("max_tags_per_object exceeds the Phase 2A.3 hard limit")
        if self.max_way_nodes > _MAX_COLLECTION_ITEMS:
            raise ContractViolation("max_way_nodes exceeds the Phase 2A.3 hard limit")
        if self.max_relation_members > _MAX_COLLECTION_ITEMS:
            raise ContractViolation("max_relation_members exceeds the Phase 2A.3 hard limit")
        if self.max_blob_size_bytes > _MAX_BLOB_BYTES:
            raise ContractViolation("max_blob_size_bytes exceeds the PBF envelope hard limit")
        if self.max_uncompressed_blob_bytes > _MAX_BLOB_BYTES:
            raise ContractViolation(
                "max_uncompressed_blob_bytes exceeds the PBF envelope hard limit"
            )
        if self.max_total_uncompressed_bytes > _MAX_TOTAL_UNCOMPRESSED_BYTES:
            raise ContractViolation(
                "max_total_uncompressed_bytes exceeds the PBF envelope hard limit"
            )
        if self.max_blob_count > _MAX_BLOB_COUNT:
            raise ContractViolation("max_blob_count exceeds the PBF envelope hard limit")
        if self.max_blob_header_bytes > _MAX_BLOB_HEADER_BYTES:
            raise ContractViolation("max_blob_header_bytes exceeds the PBF envelope hard limit")
        if self.max_uncompressed_blob_bytes > self.max_total_uncompressed_bytes:
            raise ContractViolation(
                "max_uncompressed_blob_bytes cannot exceed max_total_uncompressed_bytes"
            )
        if self.max_tags_per_object > self.max_total_tags:
            raise ContractViolation("max_tags_per_object cannot exceed max_total_tags")


@dataclass(frozen=True, slots=True)
class RelationMember:
    """Detached relation-member evidence."""

    member_type: OsmObjectType
    ref: int
    role: str = ""

    def __post_init__(self) -> None:
        if not isinstance(self.member_type, OsmObjectType):
            try:
                object.__setattr__(self, "member_type", OsmObjectType(self.member_type))
            except (TypeError, ValueError) as exc:
                raise ContractViolation("member_type is not supported") from exc
        object.__setattr__(self, "ref", _positive_int(self.ref, "ref"))
        if not isinstance(self.role, str):
            raise ContractViolation("role must be a string")


@dataclass(frozen=True, slots=True)
class ParsedOsmObject:
    """A detached OSM object safe to retain after a pyosmium callback."""

    identity: OsmIdentity
    version: int
    tags: tuple[tuple[str, str], ...] = ()
    longitude_e7: int | None = None
    latitude_e7: int | None = None
    node_refs: tuple[int, ...] = ()
    members: tuple[RelationMember, ...] = ()

    def __post_init__(self) -> None:
        if not isinstance(self.identity, OsmIdentity):
            raise ContractViolation("identity must be an OsmIdentity")
        object.__setattr__(self, "version", _positive_int(self.version, "version"))

        normalized_tags: list[tuple[str, str]] = []
        for pair in self.tags:
            if not isinstance(pair, tuple) or len(pair) != 2:
                raise ContractViolation("tags must contain key/value tuples")
            key, value = pair
            if not isinstance(value, str):
                raise ContractViolation("tag value must be a string")
            normalized_tags.append((_nonblank(key, "tag key"), value))
        object.__setattr__(self, "tags", tuple(sorted(normalized_tags)))

        normalized_refs = tuple(_positive_int(ref, "node_ref") for ref in self.node_refs)
        object.__setattr__(self, "node_refs", normalized_refs)
        normalized_members = tuple(self.members)
        if any(not isinstance(member, RelationMember) for member in normalized_members):
            raise ContractViolation("members must contain RelationMember values")
        object.__setattr__(self, "members", normalized_members)

        for coordinate_name in ("longitude_e7", "latitude_e7"):
            coordinate = getattr(self, coordinate_name)
            if coordinate is not None and (
                isinstance(coordinate, bool) or not isinstance(coordinate, int)
            ):
                raise ContractViolation(f"{coordinate_name} must be an integer or null")

        if self.identity.osm_type is OsmObjectType.NODE:
            if self.longitude_e7 is None or self.latitude_e7 is None:
                raise ContractViolation("node coordinates are required")
            if not -1_800_000_000 <= self.longitude_e7 <= 1_800_000_000:
                raise ContractViolation("longitude_e7 is outside WGS84 bounds")
            if not -900_000_000 <= self.latitude_e7 <= 900_000_000:
                raise ContractViolation("latitude_e7 is outside WGS84 bounds")
            if self.node_refs or self.members:
                raise ContractViolation("nodes cannot contain references or members")
        elif self.identity.osm_type is OsmObjectType.WAY:
            if self.longitude_e7 is not None or self.latitude_e7 is not None or self.members:
                raise ContractViolation("ways cannot contain point coordinates or relation members")
        elif self.identity.osm_type is OsmObjectType.RELATION:
            if self.longitude_e7 is not None or self.latitude_e7 is not None or self.node_refs:
                raise ContractViolation("relations cannot contain point coordinates or node refs")


@dataclass(frozen=True, slots=True)
class PbfObjectCounts:
    """Counts of supported source object types."""

    nodes: int = 0
    ways: int = 0
    relations: int = 0

    def __post_init__(self) -> None:
        for name in ("nodes", "ways", "relations"):
            value = getattr(self, name)
            if isinstance(value, bool) or not isinstance(value, int) or value < 0:
                raise ContractViolation(f"{name} must be a nonnegative integer")

    @property
    def total(self) -> int:
        return self.nodes + self.ways + self.relations

    def as_mapping(self) -> Mapping[str, int]:
        return MappingProxyType({"node": self.nodes, "way": self.ways, "relation": self.relations})


@dataclass(frozen=True, slots=True)
class PbfParseSummary:
    """Deterministic metadata for a completed bounded parse."""

    content_sha256: str
    size_bytes: int
    counts: PbfObjectCounts
    total_tags: int
    parser_started_at: datetime
    parser_completed_at: datetime

    def __post_init__(self) -> None:
        if not isinstance(self.content_sha256, str) or not _SHA256_PATTERN.fullmatch(
            self.content_sha256
        ):
            raise ContractViolation("content_sha256 must be lowercase SHA-256")
        object.__setattr__(self, "size_bytes", _positive_int(self.size_bytes, "size_bytes"))
        if not isinstance(self.counts, PbfObjectCounts):
            raise ContractViolation("counts must be PbfObjectCounts")
        if isinstance(self.total_tags, bool) or not isinstance(self.total_tags, int):
            raise ContractViolation("total_tags must be a nonnegative integer")
        if self.total_tags < 0:
            raise ContractViolation("total_tags must be a nonnegative integer")
        started = _aware_utc(self.parser_started_at, "parser_started_at")
        completed = _aware_utc(self.parser_completed_at, "parser_completed_at")
        if completed < started:
            raise ContractViolation("parser_completed_at cannot precede parser_started_at")
        object.__setattr__(self, "parser_started_at", started)
        object.__setattr__(self, "parser_completed_at", completed)


@dataclass(frozen=True, slots=True)
class ApprovedTestPbfDownload:
    """Explicit fail-closed settings for one loopback-only test download."""

    region_id: str
    url: str = field(repr=False)
    expected_sha256: str
    expected_size_bytes: int
    max_size_bytes: int = 8 * 1024 * 1024
    timeout_seconds: float = 5.0

    def __post_init__(self) -> None:
        object.__setattr__(self, "region_id", _nonblank(self.region_id, "region_id"))
        if not isinstance(self.url, str):
            raise PbfDownloadRejected("approved test PBF URL must be a string")
        parsed = urlsplit(self.url)
        if parsed.scheme != "http":
            raise PbfDownloadRejected("approved test PBF URL must use loopback HTTP")
        if parsed.hostname not in _LOOPBACK_HOSTS:
            raise PbfDownloadRejected(
                "approved test PBF URL host is not a literal loopback address"
            )
        try:
            port = parsed.port
        except ValueError:
            raise PbfDownloadRejected("approved test PBF URL port is invalid") from None
        if port is None:
            raise PbfDownloadRejected("approved test PBF URL must include an explicit port")
        if parsed.username is not None or parsed.password is not None:
            raise PbfDownloadRejected("approved test PBF URL cannot contain credentials")
        if parsed.query or parsed.fragment:
            raise PbfDownloadRejected("approved test PBF URL cannot contain query or fragment")
        if not parsed.path.endswith(".osm.pbf"):
            raise PbfDownloadRejected("approved test PBF URL must end with .osm.pbf")
        if not _SHA256_PATTERN.fullmatch(self.expected_sha256):
            raise PbfDownloadRejected("expected SHA-256 is invalid")
        object.__setattr__(
            self,
            "expected_size_bytes",
            _positive_int(self.expected_size_bytes, "expected_size_bytes"),
        )
        object.__setattr__(
            self, "max_size_bytes", _positive_int(self.max_size_bytes, "max_size_bytes")
        )
        if self.max_size_bytes > _MAX_TEST_DOWNLOAD_BYTES:
            raise PbfDownloadRejected("max_size_bytes exceeds the Phase 2A.3 hard limit")
        if self.expected_size_bytes > self.max_size_bytes:
            raise PbfDownloadRejected("expected size exceeds max_size_bytes")
        if (
            isinstance(self.timeout_seconds, bool)
            or not isinstance(self.timeout_seconds, (int, float))
            or self.timeout_seconds <= 0
            or self.timeout_seconds > 30
        ):
            raise PbfDownloadRejected("timeout_seconds must be within (0, 30]")


@dataclass(frozen=True, slots=True)
class VerifiedPbfInput:
    """Explicit local-file approval evidence required by the parser."""

    local_path: Path
    content_sha256: str
    size_bytes: int
    provenance: str

    def __post_init__(self) -> None:
        if not isinstance(self.local_path, Path):
            raise ContractViolation("local_path must be a pathlib.Path")
        if not _SHA256_PATTERN.fullmatch(self.content_sha256):
            raise ContractViolation("content_sha256 must be lowercase SHA-256")
        object.__setattr__(self, "size_bytes", _positive_int(self.size_bytes, "size_bytes"))
        object.__setattr__(self, "provenance", _nonblank(self.provenance, "provenance"))


@dataclass(frozen=True, slots=True)
class ValidatedPbfDownload:
    """Validated content-addressed PBF cache entry."""

    region_id: str
    source_url: str = field(repr=False)
    local_path: Path
    content_sha256: str
    size_bytes: int
    downloaded_at: datetime
    validated_at: datetime
    etag: str | None = None
    last_modified: str | None = None
    cache_hit: bool = False

    def __post_init__(self) -> None:
        object.__setattr__(self, "region_id", _nonblank(self.region_id, "region_id"))
        object.__setattr__(self, "source_url", _nonblank(self.source_url, "source_url"))
        if not isinstance(self.local_path, Path):
            raise ContractViolation("local_path must be a pathlib.Path")
        if not _SHA256_PATTERN.fullmatch(self.content_sha256):
            raise ContractViolation("content_sha256 must be lowercase SHA-256")
        object.__setattr__(self, "size_bytes", _positive_int(self.size_bytes, "size_bytes"))
        if self.local_path.name != f"{self.content_sha256}.osm.pbf":
            raise ContractViolation("local_path must use the content-addressed PBF filename")
        if not isinstance(self.cache_hit, bool):
            raise ContractViolation("cache_hit must be a boolean")
        downloaded = _aware_utc(self.downloaded_at, "downloaded_at")
        validated = _aware_utc(self.validated_at, "validated_at")
        if validated < downloaded:
            raise ContractViolation("validated_at cannot precede downloaded_at")
        object.__setattr__(self, "downloaded_at", downloaded)
        object.__setattr__(self, "validated_at", validated)

    def as_verified_input(self) -> VerifiedPbfInput:
        """Return immutable parser approval evidence for this validated download."""

        return VerifiedPbfInput(
            local_path=self.local_path,
            content_sha256=self.content_sha256,
            size_bytes=self.size_bytes,
            provenance=f"validated test download for {self.region_id}",
        )


@dataclass(frozen=True, slots=True)
class ParsedExtractMetadata:
    """Parser-complete metadata that is not yet a reconciliation-complete snapshot."""

    region_id: str
    source_url: str = field(repr=False)
    content_sha256: str
    size_bytes: int
    downloaded_at: datetime
    validated_at: datetime
    parser_started_at: datetime
    parser_completed_at: datetime
    counts: PbfObjectCounts
    total_tags: int
    etag: str | None = None
    last_modified: str | None = None

    @classmethod
    def from_results(
        cls, download: ValidatedPbfDownload, summary: PbfParseSummary
    ) -> "ParsedExtractMetadata":
        if download.content_sha256 != summary.content_sha256:
            raise ContractViolation("download and parse SHA-256 values differ")
        if download.size_bytes != summary.size_bytes:
            raise ContractViolation("download and parse sizes differ")
        return cls(
            region_id=download.region_id,
            source_url=download.source_url,
            content_sha256=download.content_sha256,
            size_bytes=download.size_bytes,
            downloaded_at=download.downloaded_at,
            validated_at=download.validated_at,
            parser_started_at=summary.parser_started_at,
            parser_completed_at=summary.parser_completed_at,
            counts=summary.counts,
            total_tags=summary.total_tags,
            etag=download.etag,
            last_modified=download.last_modified,
        )

    def __post_init__(self) -> None:
        object.__setattr__(self, "region_id", _nonblank(self.region_id, "region_id"))
        object.__setattr__(self, "source_url", _nonblank(self.source_url, "source_url"))
        if not _SHA256_PATTERN.fullmatch(self.content_sha256):
            raise ContractViolation("content_sha256 must be lowercase SHA-256")
        object.__setattr__(self, "size_bytes", _positive_int(self.size_bytes, "size_bytes"))
        downloaded = _aware_utc(self.downloaded_at, "downloaded_at")
        validated = _aware_utc(self.validated_at, "validated_at")
        started = _aware_utc(self.parser_started_at, "parser_started_at")
        completed = _aware_utc(self.parser_completed_at, "parser_completed_at")
        if not downloaded <= validated <= started <= completed:
            raise ContractViolation("extract lifecycle timestamps are not ordered")
        if not isinstance(self.counts, PbfObjectCounts):
            raise ContractViolation("counts must be PbfObjectCounts")
        if isinstance(self.total_tags, bool) or not isinstance(self.total_tags, int):
            raise ContractViolation("total_tags must be nonnegative")
        if self.total_tags < 0:
            raise ContractViolation("total_tags must be nonnegative")
        object.__setattr__(self, "downloaded_at", downloaded)
        object.__setattr__(self, "validated_at", validated)
        object.__setattr__(self, "parser_started_at", started)
        object.__setattr__(self, "parser_completed_at", completed)
