from __future__ import annotations

from typing import Any

import pytest

import icsd.adapters.numpy_invariants as numpy_invariants
from icsd.adapters.numpy_invariants import NumpyBatchFieldInvariant
from icsd.core.batch import validate_states
from icsd.core.invariants import Invariant, InvariantSet


class _AsarrayFailureRuntime:
    def asarray(self, _: Any) -> Any:
        raise TypeError("cannot convert")


def test_missing_numpy_returns_optional_dependency_failure(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(numpy_invariants, "_load_numpy_runtime", lambda: None)
    invariant = NumpyBatchFieldInvariant(field="values")

    outcome = invariant({"values": [1, 2, 3]})

    assert isinstance(outcome, tuple)
    assert len(outcome) == 1
    failure = outcome[0]
    assert failure.code == "optional_dependency_missing"
    assert failure.details == {
        "dependency": "numpy",
        "extra": "speed-numpy",
        "install": "dpa-icsd[speed-numpy]",
        "field": "values",
        "reason": "optional_dependency_missing",
    }


def test_numpy_asarray_failure_returns_deterministic_failure(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(
        numpy_invariants,
        "_load_numpy_runtime",
        lambda: _AsarrayFailureRuntime(),
    )
    invariant = NumpyBatchFieldInvariant(field="values")

    outcome = invariant({"values": object()})

    assert isinstance(outcome, tuple)
    assert len(outcome) == 1
    failure = outcome[0]
    assert failure.code == "numpy_batch_invalid"
    assert failure.details == {
        "field": "values",
        "reason": "numpy_asarray_failed",
        "error_type": "TypeError",
    }


def test_constructor_rejects_invalid_expected_ndim_bool() -> None:
    with pytest.raises(TypeError, match="expected_ndim must be an integer"):
        NumpyBatchFieldInvariant(field="values", expected_ndim=True)


def test_non_mapping_failure_reports_stable_state_type_name() -> None:
    invariant = NumpyBatchFieldInvariant(field="values")

    outcome = invariant([1, 2, 3])  # type: ignore[arg-type]

    assert isinstance(outcome, tuple)
    assert len(outcome) == 1
    assert outcome[0].details == {
        "field": "values",
        "reason": "state_not_mapping",
        "state_type": "list",
    }


def test_batch_integration_preserves_order_and_is_deterministic_when_numpy_missing(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(numpy_invariants, "_load_numpy_runtime", lambda: None)

    invariant_set = InvariantSet(
        [Invariant(name="numpy_values", check=NumpyBatchFieldInvariant(field="values"))]
    )
    states = [
        {"values": [1, 2, 3]},
        {"values": [5, 8]},
        {"other": [1, 2, 3]},
    ]

    first_report = validate_states(invariant_set, states)
    second_report = validate_states(invariant_set, states)

    assert [result.index for result in first_report.results] == [0, 1, 2]
    assert [result.valid for result in first_report.results] == [False, False, False]
    reasons = [result.failures[0].details["reason"] for result in first_report.results]
    assert reasons == [
        "optional_dependency_missing",
        "optional_dependency_missing",
        "missing_field",
    ]
    assert first_report.as_dict() == second_report.as_dict()


def test_numpy_invariant_valid_numeric_array_passes() -> None:
    np = pytest.importorskip("numpy")
    invariant = NumpyBatchFieldInvariant(field="values")

    outcome = invariant({"values": np.asarray([1, 2, 3], dtype=np.int64)})

    assert outcome is True


def test_numpy_invariant_rejects_bool_dtype_with_stable_accepted_kinds() -> None:
    np = pytest.importorskip("numpy")
    invariant = NumpyBatchFieldInvariant(field="values")

    outcome = invariant({"values": np.asarray([True, False], dtype=np.bool_)})

    assert isinstance(outcome, tuple)
    failure = outcome[0]
    assert failure.details["reason"] == "non_numeric_dtype"
    assert failure.details["accepted_kinds"] == ["i", "u", "f"]


def test_numpy_invariant_ndim_mismatch_reports_expected_and_actual() -> None:
    np = pytest.importorskip("numpy")
    invariant = NumpyBatchFieldInvariant(field="values", expected_ndim=1)

    outcome = invariant({"values": np.asarray([[1.0, 2.0]])})

    assert isinstance(outcome, tuple)
    failure = outcome[0]
    assert failure.details["reason"] == "ndim_mismatch"
    assert failure.details["expected_ndim"] == 1
    assert failure.details["actual_ndim"] == 2


def test_numpy_invariant_allow_empty_controls_empty_array_behavior() -> None:
    np = pytest.importorskip("numpy")
    allow_invariant = NumpyBatchFieldInvariant(
        field="values",
        allow_empty=True,
        min_value=0,
        max_value=10,
    )
    reject_invariant = NumpyBatchFieldInvariant(field="values", allow_empty=False)

    allowed = allow_invariant({"values": np.asarray([], dtype=np.float64)})
    rejected = reject_invariant({"values": np.asarray([], dtype=np.float64)})

    assert allowed is True
    assert isinstance(rejected, tuple)
    assert rejected[0].details["reason"] == "empty_array_not_allowed"


def test_numpy_invariant_non_finite_check_runs_before_bounds() -> None:
    np = pytest.importorskip("numpy")
    invariant = NumpyBatchFieldInvariant(
        field="values",
        expected_ndim=1,
        min_value=0,
        require_finite=True,
    )

    outcome = invariant({"values": np.asarray([float("nan"), -1.0])})

    assert isinstance(outcome, tuple)
    assert len(outcome) == 1
    assert outcome[0].details["reason"] == "non_finite_values"


def test_numpy_invariant_first_failure_wins_in_declared_order() -> None:
    np = pytest.importorskip("numpy")
    invariant = NumpyBatchFieldInvariant(field="values", expected_ndim=1, require_finite=True)

    outcome = invariant({"values": np.asarray([[float("nan")]])})

    assert isinstance(outcome, tuple)
    assert len(outcome) == 1
    assert outcome[0].details["reason"] == "ndim_mismatch"


def test_numpy_invariant_bounds_report_python_scalars() -> None:
    np = pytest.importorskip("numpy")
    min_invariant = NumpyBatchFieldInvariant(field="values", min_value=0)
    max_invariant = NumpyBatchFieldInvariant(field="values", max_value=3)

    min_outcome = min_invariant({"values": np.asarray([-2, 1], dtype=np.int64)})
    max_outcome = max_invariant({"values": np.asarray([1.0, 9.0], dtype=np.float64)})

    assert isinstance(min_outcome, tuple)
    assert isinstance(max_outcome, tuple)

    min_failure = min_outcome[0]
    max_failure = max_outcome[0]
    assert min_failure.details["reason"] == "min_value_violation"
    assert max_failure.details["reason"] == "max_value_violation"
    assert isinstance(min_failure.details["observed_min"], int)
    assert isinstance(max_failure.details["observed_max"], float)
