"""Legacy-state migration helpers with explicit quarantine reporting."""

from __future__ import annotations

from collections.abc import Callable, Iterable, Mapping
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any

from .errors import InvariantFailure
from .invariants import InvariantSet

__all__ = [
    "LegacyStateTransformer",
    "LegacyStateReasonResolver",
    "MigrationReport",
    "QuarantineRecord",
    "migrate_legacy_state",
    "migrate_legacy_states",
]

LegacyStateTransformer = Callable[[dict[str, Any]], Mapping[str, Any] | None]
LegacyStateReasonResolver = Callable[[tuple[InvariantFailure, ...]], str]

DEFAULT_QUARANTINE_REASON = "legacy_state_invariant_violation"
NON_MAPPING_STATE_REASON = "legacy_state_not_mapping"
TRANSFORM_RESULT_REASON = "legacy_state_transform_not_mapping"


@dataclass(frozen=True, slots=True)
class QuarantineRecord:
    """A legacy state that could not be migrated into an invariant-safe representation."""

    index: int
    reason: str
    original_state: Any
    candidate_state: dict[str, Any] | None = None
    failures: tuple[InvariantFailure, ...] = ()
    details: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.index < 0:
            msg = "index must be greater than or equal to zero."
            raise ValueError(msg)
        if not isinstance(self.reason, str) or not self.reason.strip():
            msg = "reason must be a non-empty string."
            raise ValueError(msg)
        object.__setattr__(self, "reason", self.reason.strip())
        if self.candidate_state is not None and not isinstance(self.candidate_state, Mapping):
            msg = "candidate_state must be a mapping when provided."
            raise TypeError(msg)
        if self.candidate_state is not None:
            object.__setattr__(self, "candidate_state", deepcopy(dict(self.candidate_state)))
        if any(not isinstance(item, InvariantFailure) for item in self.failures):
            msg = "failures must contain only InvariantFailure values."
            raise TypeError(msg)
        if not isinstance(self.details, Mapping):
            msg = "details must be a mapping."
            raise TypeError(msg)
        object.__setattr__(self, "details", dict(self.details))

    def as_dict(self) -> dict[str, Any]:
        """Return a deterministic serialisable payload."""
        payload: dict[str, Any] = {
            "index": self.index,
            "reason": self.reason,
            "original_state": deepcopy(self.original_state),
            "failure_count": len(self.failures),
        }
        if self.candidate_state is not None:
            payload["candidate_state"] = deepcopy(self.candidate_state)
        if self.failures:
            payload["failures"] = [item.as_dict() for item in self.failures]
        if self.details:
            payload["details"] = dict(self.details)
        return payload


@dataclass(frozen=True, slots=True)
class MigrationReport:
    """Deterministic result for a legacy-state migration batch."""

    migrated_states: tuple[dict[str, Any], ...]
    quarantined: tuple[QuarantineRecord, ...]

    def __post_init__(self) -> None:
        normalised_states: list[dict[str, Any]] = []
        for state in self.migrated_states:
            if not isinstance(state, Mapping):
                msg = "migrated_states must contain only mapping values."
                raise TypeError(msg)
            normalised_states.append(deepcopy(dict(state)))
        object.__setattr__(self, "migrated_states", tuple(normalised_states))
        if any(not isinstance(item, QuarantineRecord) for item in self.quarantined):
            msg = "quarantined must contain only QuarantineRecord values."
            raise TypeError(msg)

    @property
    def migrated_count(self) -> int:
        """Return count of successfully migrated states."""
        return len(self.migrated_states)

    @property
    def quarantined_count(self) -> int:
        """Return count of quarantined states."""
        return len(self.quarantined)

    @property
    def total_count(self) -> int:
        """Return total number of processed states."""
        return self.migrated_count + self.quarantined_count


def migrate_legacy_state(
    state: Any,
    *,
    invariants: InvariantSet,
    transform: LegacyStateTransformer | None = None,
    index: int = 0,
    quarantine_reason: str = DEFAULT_QUARANTINE_REASON,
    reason_resolver: LegacyStateReasonResolver | None = None,
) -> tuple[dict[str, Any] | None, QuarantineRecord | None]:
    """Migrate a single legacy state into an invariant-safe state or quarantine record."""
    if not isinstance(invariants, InvariantSet):
        msg = "invariants must be an InvariantSet instance."
        raise TypeError(msg)
    if transform is not None and not callable(transform):
        msg = "transform must be callable when provided."
        raise TypeError(msg)
    if reason_resolver is not None and not callable(reason_resolver):
        msg = "reason_resolver must be callable when provided."
        raise TypeError(msg)
    resolved_reason = _normalise_reason(quarantine_reason)
    if not isinstance(state, Mapping):
        return None, QuarantineRecord(
            index=index,
            reason=NON_MAPPING_STATE_REASON,
            original_state=deepcopy(state),
            details={"error": "legacy state must be a mapping."},
        )
    original_state = deepcopy(dict(state))
    candidate_state = deepcopy(original_state)
    if transform is not None:
        transformed = transform(candidate_state)
        if transformed is None:
            transformed_state = candidate_state
        elif isinstance(transformed, Mapping):
            transformed_state = deepcopy(dict(transformed))
        else:
            return None, QuarantineRecord(
                index=index,
                reason=TRANSFORM_RESULT_REASON,
                original_state=original_state,
                details={
                    "error": "transform must return a mapping or None.",
                    "returned_type": type(transformed).__name__,
                },
            )
    else:
        transformed_state = candidate_state

    migrated_state, failures = invariants.try_construct_state(
        transformed_state,
        field_name="legacy state",
    )
    if failures:
        reason = resolved_reason
        if reason_resolver is not None:
            reason = _normalise_reason(reason_resolver(failures))
        return None, QuarantineRecord(
            index=index,
            reason=reason,
            original_state=original_state,
            candidate_state=transformed_state,
            failures=failures,
        )
    return migrated_state, None


def migrate_legacy_states(
    states: Iterable[Any],
    *,
    invariants: InvariantSet,
    transform: LegacyStateTransformer | None = None,
    quarantine_reason: str = DEFAULT_QUARANTINE_REASON,
    reason_resolver: LegacyStateReasonResolver | None = None,
) -> MigrationReport:
    """Migrate an iterable of legacy states and quarantine those that remain invalid."""
    if isinstance(states, (str, bytes, bytearray)):
        msg = "states must be an iterable of legacy state values."
        raise TypeError(msg)
    migrated: list[dict[str, Any]] = []
    quarantined: list[QuarantineRecord] = []
    for index, state in enumerate(states):
        migrated_state, quarantine = migrate_legacy_state(
            state,
            invariants=invariants,
            transform=transform,
            index=index,
            quarantine_reason=quarantine_reason,
            reason_resolver=reason_resolver,
        )
        if migrated_state is not None:
            migrated.append(migrated_state)
            continue
        if quarantine is not None:
            quarantined.append(quarantine)
    return MigrationReport(
        migrated_states=tuple(migrated),
        quarantined=tuple(quarantined),
    )


def _normalise_reason(value: str) -> str:
    if not isinstance(value, str) or not value.strip():
        msg = "quarantine reason must be a non-empty string."
        raise ValueError(msg)
    return value.strip()
