"""Invariant primitives for validating and enforcing state conditions."""

from __future__ import annotations

from collections.abc import Callable, Iterable, Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

from .errors import (
    InvariantFailure,
    InvariantViolationError,
    PolicyProfileNotFoundError,
    PolicyVersionMismatchError,
)

__all__ = ["Invariant", "InvariantSet", "InvariantProfile", "InvariantProfileRegistry"]

InvariantCheck = Callable[
    [Mapping[str, Any]],
    bool | InvariantFailure | Sequence[InvariantFailure] | None,
]


@dataclass(frozen=True, slots=True)
class Invariant:
    """A named validation rule that can emit structured failure records."""

    name: str
    check: InvariantCheck
    failure_code: str = "invariant_failed"

    def validate(self, state: Mapping[str, Any]) -> tuple[InvariantFailure, ...]:
        """Evaluate this invariant against state and return any failures."""
        result = self.check(state)
        if result is None or result is True:
            return ()
        if result is False:
            return (self._default_failure(),)
        if isinstance(result, InvariantFailure):
            return (self._normalise_failure(result),)
        if isinstance(result, Sequence) and not isinstance(result, (str, bytes, bytearray)):
            failures: list[InvariantFailure] = []
            for item in result:
                if not isinstance(item, InvariantFailure):
                    msg = (
                        "Invariant checks that return sequences must contain "
                        "InvariantFailure values."
                    )
                    raise TypeError(msg)
                failures.append(self._normalise_failure(item))
            return tuple(failures)
        msg = (
            "Invariant checks must return bool, None, InvariantFailure, or a "
            "sequence of InvariantFailure values."
        )
        raise TypeError(msg)

    def _default_failure(self) -> InvariantFailure:
        return InvariantFailure(
            invariant=self.name,
            code=self.failure_code,
            message=f"Invariant '{self.name}' failed.",
        )

    def _normalise_failure(self, failure: InvariantFailure) -> InvariantFailure:
        if failure.invariant:
            return failure
        return InvariantFailure(
            invariant=self.name,
            code=failure.code,
            message=failure.message,
            details=failure.details,
        )


@dataclass(frozen=True, slots=True)
class InvariantSet:
    """A consistent runner over a group of invariants."""

    invariants: tuple[Invariant, ...]

    def __init__(self, invariants: Iterable[Invariant]) -> None:
        values = tuple(invariants)
        for value in values:
            if not isinstance(value, Invariant):
                msg = "InvariantSet expects only Invariant instances."
                raise TypeError(msg)
        object.__setattr__(self, "invariants", values)

    def validate(self, state: Mapping[str, Any]) -> tuple[InvariantFailure, ...]:
        """Run all invariants and return failures in declaration order."""
        failures: list[InvariantFailure] = []
        for invariant in self.invariants:
            failures.extend(invariant.validate(state))
        return tuple(failures)

    def is_valid(self, state: Mapping[str, Any]) -> bool:
        """Return True when no invariant reports failures."""
        return not self.validate(state)

    def assert_valid(self, state: Mapping[str, Any]) -> None:
        """Raise InvariantViolationError when one or more failures exist."""
        failures = self.validate(state)
        if failures:
            raise InvariantViolationError(failures)

    def construct_state(
        self,
        state: Mapping[str, Any],
        *,
        field_name: str = "state",
    ) -> dict[str, Any]:
        """Return a copied state object only when it satisfies this invariant set."""
        if not isinstance(state, Mapping):
            msg = f"{field_name} must be a mapping."
            raise TypeError(msg)
        candidate = deepcopy(dict(state))
        self.assert_valid(candidate)
        return candidate

    def try_construct_state(
        self,
        state: Mapping[str, Any],
        *,
        field_name: str = "state",
    ) -> tuple[dict[str, Any] | None, tuple[InvariantFailure, ...]]:
        """Return copied state and failures without raising for invariant violations."""
        if not isinstance(state, Mapping):
            msg = f"{field_name} must be a mapping."
            raise TypeError(msg)
        candidate = deepcopy(dict(state))
        failures = self.validate(candidate)
        if failures:
            return None, failures
        return candidate, ()

    def construct_state_from_proposal(
        self,
        *,
        current_state: Mapping[str, Any],
        proposal_changes: Mapping[str, Any],
        field_name: str = "authority proposal",
    ) -> dict[str, Any]:
        """Merge proposal changes into current state and return validated output."""
        if not isinstance(current_state, Mapping):
            msg = "current_state must be a mapping."
            raise TypeError(msg)
        if not isinstance(proposal_changes, Mapping):
            msg = f"{field_name} must be a mapping."
            raise TypeError(msg)
        merged_state = deepcopy(dict(current_state))
        merged_state.update(deepcopy(dict(proposal_changes)))
        self.assert_valid(merged_state)
        return merged_state


@dataclass(frozen=True, slots=True)
class InvariantProfile:
    """Versioned policy profile that binds an invariant set to authority metadata."""

    name: str
    policy: str
    policy_version: str
    invariants: InvariantSet
    active_from: str | None
    active_until: str | None

    def __init__(
        self,
        *,
        name: str,
        policy: str,
        policy_version: str,
        invariants: InvariantSet | Iterable[Invariant],
        active_from: datetime | str | None = None,
        active_until: datetime | str | None = None,
    ) -> None:
        object.__setattr__(self, "name", _require_non_empty_string("name", name))
        object.__setattr__(self, "policy", _require_non_empty_string("policy", policy))
        object.__setattr__(
            self,
            "policy_version",
            _require_non_empty_string("policy_version", policy_version),
        )
        if isinstance(invariants, InvariantSet):
            invariant_set = invariants
        else:
            invariant_set = InvariantSet(invariants)
        object.__setattr__(self, "invariants", invariant_set)
        normalised_active_from = _normalise_optional_timestamp(
            active_from,
            field_name="active_from",
        )
        normalised_active_until = _normalise_optional_timestamp(
            active_until,
            field_name="active_until",
        )
        if normalised_active_from is not None and normalised_active_until is not None:
            if _parse_timestamp(normalised_active_from) >= _parse_timestamp(
                normalised_active_until
            ):
                msg = "active_from must be earlier than active_until."
                raise ValueError(msg)
        object.__setattr__(self, "active_from", normalised_active_from)
        object.__setattr__(self, "active_until", normalised_active_until)

    @property
    def authority(self) -> str:
        """Return canonical authority reference for this profile."""
        return f"{self.policy}@{self.policy_version}"

    def is_active_at(self, *, active_at: datetime | str) -> bool:
        """Return True when profile is active for the supplied UTC timestamp."""
        moment = _normalise_timestamp(active_at, field_name="active_at")
        return _profile_is_active(self, active_at=moment)


class InvariantProfileRegistry:
    """Resolve named invariant policy profiles with optional version checks."""

    __slots__ = ("_profiles",)

    def __init__(self, profiles: Iterable[InvariantProfile]) -> None:
        mapped: dict[str, list[InvariantProfile]] = {}
        for profile in profiles:
            if not isinstance(profile, InvariantProfile):
                msg = "InvariantProfileRegistry expects only InvariantProfile instances."
                raise TypeError(msg)
            existing = mapped.setdefault(profile.name, [])
            if (
                existing
                and _is_non_temporal_profile(profile)
                and all(_is_non_temporal_profile(item) for item in existing)
            ):
                msg = f"Duplicate invariant profile name: {profile.name!r}."
                raise ValueError(msg)
            existing.append(profile)
        if not mapped:
            msg = "InvariantProfileRegistry requires at least one profile."
            raise ValueError(msg)
        ordered_profiles: dict[str, tuple[InvariantProfile, ...]] = {}
        for profile_name, values in mapped.items():
            ordered = tuple(sorted(values, key=_profile_sort_key))
            _validate_no_temporal_overlap(profile_name=profile_name, profiles=ordered)
            ordered_profiles[profile_name] = ordered
        self._profiles = ordered_profiles

    def resolve(
        self,
        *,
        profile: str,
        policy_version: str | None = None,
        active_at: datetime | str | None = None,
    ) -> InvariantProfile:
        """Return a profile by name and optionally enforce policy version equality."""
        profile_name = _require_non_empty_string("profile", profile)
        try:
            available_profiles = self._profiles[profile_name]
        except KeyError as exc:
            raise PolicyProfileNotFoundError(profile_name) from exc
        if policy_version is not None:
            requested_version = _require_non_empty_string("policy_version", policy_version)
            candidates = tuple(
                item for item in available_profiles if item.policy_version == requested_version
            )
            if not candidates:
                expected_versions = sorted({item.policy_version for item in available_profiles})
                raise PolicyVersionMismatchError(
                    profile_name=profile_name,
                    expected_version=" | ".join(expected_versions),
                    requested_version=requested_version,
                )
        else:
            candidates = available_profiles
        if active_at is None:
            if len(candidates) != 1:
                msg = (
                    "active_at must be provided when resolving profile "
                    f"{profile_name!r} with multiple temporal variants."
                )
                raise ValueError(msg)
            selected = candidates[0]
            if not _is_non_temporal_profile(selected):
                msg = (
                    f"active_at must be provided when resolving temporal profile {profile_name!r}."
                )
                raise ValueError(msg)
            return selected
        active_moment = _normalise_timestamp(active_at, field_name="active_at")
        active_candidates = tuple(
            item for item in candidates if _profile_is_active(item, active_at=active_moment)
        )
        if not active_candidates:
            msg = f"No active profile variant for {profile_name!r} at {active_moment!r}."
            raise ValueError(msg)
        if len(active_candidates) > 1:
            msg = f"Multiple profile variants are active for {profile_name!r} at {active_moment!r}."
            raise ValueError(msg)
        selected = active_candidates[0]
        if policy_version is not None:
            requested_version = _require_non_empty_string("policy_version", policy_version)
            if selected.policy_version != requested_version:
                msg = (
                    "Resolved profile version mismatch for "
                    f"{profile_name!r}: expected {requested_version!r}, got "
                    f"{selected.policy_version!r}."
                )
                raise ValueError(msg)
        return selected

    @property
    def profile_names(self) -> tuple[str, ...]:
        """Return profile names in deterministic insertion order."""
        return tuple(self._profiles)


def _require_non_empty_string(field_name: str, value: str) -> str:
    if not isinstance(value, str) or not value.strip():
        msg = f"{field_name} must be a non-empty string."
        raise ValueError(msg)
    return value.strip()


def _normalise_optional_timestamp(
    value: datetime | str | None,
    *,
    field_name: str,
) -> str | None:
    if value is None:
        return None
    return _normalise_timestamp(value, field_name=field_name)


def _normalise_timestamp(
    value: datetime | str,
    *,
    field_name: str,
) -> str:
    if isinstance(value, datetime):
        timestamp = value
    elif isinstance(value, str):
        return _parse_timestamp(value).isoformat().replace("+00:00", "Z")
    else:
        msg = f"{field_name} must be a datetime, ISO-8601 UTC string, or None."
        raise ValueError(msg)
    if timestamp.tzinfo is None:
        timestamp = timestamp.replace(tzinfo=timezone.utc)
    else:
        timestamp = timestamp.astimezone(timezone.utc)
    return timestamp.isoformat().replace("+00:00", "Z")


def _parse_timestamp(raw_value: str) -> datetime:
    value = raw_value.strip()
    if value.endswith("Z"):
        value = f"{value[:-1]}+00:00"
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError as exc:
        msg = f"timestamp must be a valid ISO-8601 UTC string; got {raw_value!r}."
        raise ValueError(msg) from exc
    if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(None):
        msg = f"timestamp must include a UTC offset; got {raw_value!r}."
        raise ValueError(msg)
    return parsed.astimezone(timezone.utc)


def _is_non_temporal_profile(profile: InvariantProfile) -> bool:
    return profile.active_from is None and profile.active_until is None


def _profile_sort_key(profile: InvariantProfile) -> tuple[datetime, datetime, str]:
    start = (
        datetime.min.replace(tzinfo=timezone.utc)
        if profile.active_from is None
        else _parse_timestamp(profile.active_from)
    )
    end = (
        datetime.max.replace(tzinfo=timezone.utc)
        if profile.active_until is None
        else _parse_timestamp(profile.active_until)
    )
    return (start, end, profile.policy_version)


def _validate_no_temporal_overlap(
    *,
    profile_name: str,
    profiles: Sequence[InvariantProfile],
) -> None:
    previous: InvariantProfile | None = None
    previous_end: datetime | None = None
    for current in profiles:
        current_start = (
            None if current.active_from is None else _parse_timestamp(current.active_from)
        )
        if previous is None:
            previous = current
            previous_end = (
                None if current.active_until is None else _parse_timestamp(current.active_until)
            )
            continue
        if previous_end is None:
            msg = (
                "Temporal activation windows overlap for profile "
                f"{profile_name!r}; unbounded active_until overlaps subsequent entries."
            )
            raise ValueError(msg)
        if current_start is None or current_start < previous_end:
            msg = f"Temporal activation windows overlap for profile {profile_name!r}."
            raise ValueError(msg)
        previous = current
        previous_end = (
            None if current.active_until is None else _parse_timestamp(current.active_until)
        )


def _profile_is_active(profile: InvariantProfile, *, active_at: str) -> bool:
    moment = _parse_timestamp(active_at)
    if profile.active_from is not None and moment < _parse_timestamp(profile.active_from):
        return False
    if profile.active_until is not None and moment >= _parse_timestamp(profile.active_until):
        return False
    return True
