"""Optional NumPy-backed invariant helper for deterministic batch field checks.

This helper follows a strict first-failure-wins contract: each call returns either
True or exactly one InvariantFailure. Missing NumPy is handled at call-time,
not construction-time. Empty arrays are valid when allow_empty=True; bounds are
skipped because extrema are undefined for empty arrays.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from icsd.core.errors import InvariantFailure

__all__ = ["NumpyBatchFieldInvariant"]

ACCEPTED_DTYPE_KINDS = ["i", "u", "f"]


@dataclass(frozen=True, slots=True)
class NumpyBatchFieldInvariant:
    """Declarative NumPy field invariant with deterministic failure envelopes."""

    field: str
    name: str = "numpy_batch_field"
    failure_code: str = "numpy_batch_invalid"
    expected_ndim: int | None = None
    min_value: int | float | None = None
    max_value: int | float | None = None
    require_finite: bool = True
    allow_empty: bool = True

    def __post_init__(self) -> None:
        if not isinstance(self.field, str) or not self.field.strip():
            msg = "field must be a non-empty string."
            raise TypeError(msg)
        if self.expected_ndim is not None:
            if isinstance(self.expected_ndim, bool) or not isinstance(self.expected_ndim, int):
                msg = "expected_ndim must be an integer when provided."
                raise TypeError(msg)
            if self.expected_ndim < 0:
                msg = "expected_ndim must be greater than or equal to zero."
                raise ValueError(msg)
        if self.min_value is not None and not _is_number_scalar(self.min_value):
            msg = "min_value must be an int or float when provided."
            raise TypeError(msg)
        if self.max_value is not None and not _is_number_scalar(self.max_value):
            msg = "max_value must be an int or float when provided."
            raise TypeError(msg)
        if self.min_value is not None and self.max_value is not None:
            if self.min_value > self.max_value:
                msg = "min_value must be less than or equal to max_value."
                raise ValueError(msg)
        if not isinstance(self.require_finite, bool):
            msg = "require_finite must be a boolean."
            raise TypeError(msg)
        if not isinstance(self.allow_empty, bool):
            msg = "allow_empty must be a boolean."
            raise TypeError(msg)

    def __call__(self, state: Mapping[str, Any]) -> bool | tuple[InvariantFailure, ...]:
        if not isinstance(state, Mapping):
            return (
                self._failure(
                    message="State must be a mapping.",
                    details={
                        "field": self.field,
                        "reason": "state_not_mapping",
                        "state_type": type(state).__name__,
                    },
                ),
            )

        if self.field not in state:
            return (
                self._failure(
                    message=f"State is missing required field {self.field!r}.",
                    details={
                        "field": self.field,
                        "reason": "missing_field",
                    },
                ),
            )

        numpy_runtime = _load_numpy_runtime()
        if numpy_runtime is None:
            return (
                InvariantFailure(
                    invariant=self.name,
                    code="optional_dependency_missing",
                    message="Optional dependency 'numpy' is required for this invariant.",
                    details={
                        "dependency": "numpy",
                        "extra": "speed-numpy",
                        "install": "dpa-icsd[speed-numpy]",
                        "field": self.field,
                        "reason": "optional_dependency_missing",
                    },
                ),
            )

        try:
            array = numpy_runtime.asarray(state[self.field])
        except Exception as exc:  # pragma: no cover - defensive path
            return (
                self._failure(
                    message=f"Unable to convert field {self.field!r} to a NumPy array.",
                    details={
                        "field": self.field,
                        "reason": "numpy_asarray_failed",
                        "error_type": type(exc).__name__,
                    },
                ),
            )

        dtype_kind = str(array.dtype.kind)
        if dtype_kind not in ACCEPTED_DTYPE_KINDS:
            return (
                self._failure(
                    message=f"Field {self.field!r} must contain numeric array values.",
                    details={
                        "field": self.field,
                        "reason": "non_numeric_dtype",
                        "dtype": str(array.dtype),
                        "dtype_kind": dtype_kind,
                        "accepted_kinds": list(ACCEPTED_DTYPE_KINDS),
                    },
                ),
            )

        if self.expected_ndim is not None and int(array.ndim) != self.expected_ndim:
            return (
                self._failure(
                    message=(
                        f"Field {self.field!r} must be {self.expected_ndim}-dimensional; "
                        f"got {int(array.ndim)}."
                    ),
                    details={
                        "field": self.field,
                        "reason": "ndim_mismatch",
                        "expected_ndim": self.expected_ndim,
                        "actual_ndim": int(array.ndim),
                    },
                ),
            )

        is_empty = int(array.size) == 0
        if is_empty and not self.allow_empty:
            return (
                self._failure(
                    message=f"Field {self.field!r} must not be empty.",
                    details={
                        "field": self.field,
                        "reason": "empty_array_not_allowed",
                    },
                ),
            )

        if self.require_finite and not bool(numpy_runtime.isfinite(array).all()):
            return (
                self._failure(
                    message=f"Field {self.field!r} must contain only finite values.",
                    details={
                        "field": self.field,
                        "reason": "non_finite_values",
                    },
                ),
            )

        if not is_empty and self.min_value is not None and bool((array < self.min_value).any()):
            observed_min = _to_py_scalar(array.min(), np=numpy_runtime)
            return (
                self._failure(
                    message=(
                        f"Field {self.field!r} must be greater than or equal to {self.min_value}."
                    ),
                    details={
                        "field": self.field,
                        "reason": "min_value_violation",
                        "min_value": self.min_value,
                        "observed_min": observed_min,
                    },
                ),
            )

        if not is_empty and self.max_value is not None and bool((array > self.max_value).any()):
            observed_max = _to_py_scalar(array.max(), np=numpy_runtime)
            return (
                self._failure(
                    message=(
                        f"Field {self.field!r} must be less than or equal to {self.max_value}."
                    ),
                    details={
                        "field": self.field,
                        "reason": "max_value_violation",
                        "max_value": self.max_value,
                        "observed_max": observed_max,
                    },
                ),
            )

        return True

    def _failure(self, *, message: str, details: dict[str, Any]) -> InvariantFailure:
        return InvariantFailure(
            invariant=self.name,
            code=self.failure_code,
            message=message,
            details=details,
        )


def _is_number_scalar(value: Any) -> bool:
    return not isinstance(value, bool) and isinstance(value, (int, float))


def _load_numpy_runtime() -> Any | None:
    try:
        import numpy as np
    except ModuleNotFoundError:
        return None
    return np


def _to_py_scalar(value: Any, *, np: Any) -> int | float:
    raw_value = value.item() if hasattr(value, "item") else value
    if isinstance(raw_value, np.integer) or (
        isinstance(raw_value, int) and not isinstance(raw_value, bool)
    ):
        return int(raw_value)
    if isinstance(raw_value, np.floating) or isinstance(raw_value, float):
        return float(raw_value)
    if isinstance(raw_value, bool):
        return int(raw_value)
    msg = "Expected a numeric scalar value."
    raise TypeError(msg)
