from __future__ import annotations

import math
from typing import Any

import pytest

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

DEFAULT_INVARIANTS = {"numpy_batch_field", "numba_batch_field"}
DEFAULT_CODES = {"numpy_batch_invalid", "numba_batch_invalid"}


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


def _normalise_value(value: Any) -> Any:
    if isinstance(value, float) and math.isnan(value):
        return "NaN"
    if isinstance(value, list):
        return [_normalise_value(item) for item in value]
    if isinstance(value, dict):
        normalised: dict[str, Any] = {}
        for key, item in value.items():
            normalised[key] = _normalise_value(item)
        if normalised.get("invariant") in DEFAULT_INVARIANTS:
            normalised["invariant"] = "batch_field"
        if normalised.get("code") in DEFAULT_CODES:
            normalised["code"] = "batch_invalid"
        return normalised
    return value


def _normalise_outcome(
    outcome: bool | tuple[Any, ...],
) -> bool | list[dict[str, Any]]:
    if outcome is True:
        return True
    return [_normalise_value(failure.as_dict()) for failure in outcome]


def _assert_outcome_parity(
    expected: bool | tuple[Any, ...],
    actual: bool | tuple[Any, ...],
) -> None:
    assert _normalise_outcome(actual) == _normalise_outcome(expected)
    if expected is True:
        assert actual is True
        return
    assert isinstance(expected, tuple)
    assert isinstance(actual, tuple)
    assert len(actual) == len(expected)
    for expected_failure, actual_failure in zip(expected, actual, strict=True):
        assert actual_failure.message == expected_failure.message
        assert _normalise_value(actual_failure.details) == _normalise_value(
            expected_failure.details
        )


@pytest.mark.parametrize(
    ("helper_class", "expected_invariant", "expected_code"),
    [
        (NumpyBatchFieldInvariant, "numpy_batch_field", "numpy_batch_invalid"),
        (NumbaBatchFieldInvariant, "numba_batch_field", "numba_batch_invalid"),
    ],
)
def test_optional_helpers_keep_distinct_default_failure_metadata(
    helper_class: type[NumpyBatchFieldInvariant] | type[NumbaBatchFieldInvariant],
    expected_invariant: str,
    expected_code: str,
) -> None:
    outcome = helper_class(field="values")({"other": [1, 2, 3]})

    assert isinstance(outcome, tuple)
    assert len(outcome) == 1
    failure = outcome[0]
    assert failure.invariant == expected_invariant
    assert failure.code == expected_code


@pytest.mark.parametrize(
    ("config", "state"),
    [
        ({"field": "values"}, [1, 2, 3]),
        ({"field": "values"}, {"other": [1, 2, 3]}),
    ],
    ids=["state_not_mapping", "missing_field"],
)
def test_optional_helpers_match_structural_failure_payloads(
    config: dict[str, Any],
    state: Any,
) -> None:
    expected = NumpyBatchFieldInvariant(**config)(state)  # type: ignore[arg-type]
    actual = NumbaBatchFieldInvariant(**config)(state)  # type: ignore[arg-type]

    _assert_outcome_parity(expected, actual)


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

    state = {"values": [1, 2, 3]}
    expected = NumpyBatchFieldInvariant(field="values")(state)
    actual = NumbaBatchFieldInvariant(field="values")(state)

    _assert_outcome_parity(expected, actual)


def test_optional_helpers_match_numpy_asarray_failure_payload(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    runtime = _AsarrayFailureRuntime()
    monkeypatch.setattr(numpy_invariants, "_load_numpy_runtime", lambda: runtime)
    monkeypatch.setattr(numba_invariants, "_load_numpy_runtime", lambda: runtime)

    state = {"values": object()}
    expected = NumpyBatchFieldInvariant(field="values")(state)
    actual = NumbaBatchFieldInvariant(field="values")(state)

    _assert_outcome_parity(expected, actual)


@pytest.mark.parametrize(
    ("config", "state_factory", "expected_reason"),
    [
        (
            {"field": "values", "expected_ndim": 1, "min_value": 0, "max_value": 10},
            lambda np: {"values": np.asarray([1.0, 2.0, 3.0], dtype=np.float64)},
            None,
        ),
        (
            {"field": "values"},
            lambda np: {"values": np.asarray([True, False], dtype=np.bool_)},
            "non_numeric_dtype",
        ),
        (
            {"field": "values", "expected_ndim": 1},
            lambda np: {"values": np.asarray([[1.0, 2.0]], dtype=np.float64)},
            "ndim_mismatch",
        ),
        (
            {"field": "values", "allow_empty": False},
            lambda np: {"values": np.asarray([], dtype=np.float64)},
            "empty_array_not_allowed",
        ),
        (
            {"field": "values", "require_finite": True, "min_value": 0},
            lambda np: {"values": np.asarray([float("nan"), 1.0], dtype=np.float64)},
            "non_finite_values",
        ),
        (
            {"field": "values", "min_value": 0},
            lambda np: {"values": np.asarray([-3.0, 5.0], dtype=np.float64)},
            "min_value_violation",
        ),
        (
            {"field": "values", "max_value": 2},
            lambda np: {"values": np.asarray([1.0, 4.0], dtype=np.float64)},
            "max_value_violation",
        ),
        (
            {"field": "values", "require_finite": False, "min_value": 0},
            lambda np: {"values": np.asarray([float("nan"), -1.0], dtype=np.float64)},
            "min_value_violation",
        ),
    ],
    ids=[
        "valid_pass",
        "non_numeric_dtype",
        "ndim_mismatch",
        "empty_disallowed",
        "non_finite_values",
        "min_value_violation",
        "max_value_violation",
        "nan_bounds_reference_path",
    ],
)
def test_optional_helpers_match_numpy_overlap_scenarios(
    config: dict[str, Any],
    state_factory: Any,
    expected_reason: str | None,
) -> None:
    np = pytest.importorskip("numpy")

    state = state_factory(np)
    expected = NumpyBatchFieldInvariant(**config)(state)
    actual = NumbaBatchFieldInvariant(**config)(state)

    _assert_outcome_parity(expected, actual)
    if expected_reason is None:
        assert expected is True
        assert actual is True
    else:
        assert isinstance(expected, tuple)
        assert isinstance(actual, tuple)
        assert expected[0].details["reason"] == expected_reason
        assert actual[0].details["reason"] == expected_reason


def test_optional_helpers_preserve_first_failure_order_for_overlap_checks() -> None:
    np = pytest.importorskip("numpy")
    config = {
        "field": "values",
        "expected_ndim": 1,
        "min_value": 0,
        "require_finite": True,
    }
    state = {"values": np.asarray([[float("nan"), -1.0]], dtype=np.float64)}

    expected = NumpyBatchFieldInvariant(**config)(state)
    actual = NumbaBatchFieldInvariant(**config)(state)

    _assert_outcome_parity(expected, actual)
    assert isinstance(expected, tuple)
    assert isinstance(actual, tuple)
    assert expected[0].details["reason"] == "ndim_mismatch"
    assert actual[0].details["reason"] == "ndim_mismatch"


def test_optional_helper_batch_reports_match_after_metadata_normalisation() -> None:
    np = pytest.importorskip("numpy")
    config = {
        "field": "values",
        "expected_ndim": 1,
        "min_value": 0,
        "require_finite": True,
    }
    states = [
        {"values": np.asarray([1.0, 2.0], dtype=np.float64)},
        {"values": np.asarray([float("nan")], dtype=np.float64)},
        {"values": np.asarray([-3.0], dtype=np.float64)},
        {"other": [1, 2, 3]},
    ]

    numpy_set = InvariantSet(
        [Invariant(name="values_check", check=NumpyBatchFieldInvariant(**config))]
    )
    numba_set = InvariantSet(
        [Invariant(name="values_check", check=NumbaBatchFieldInvariant(**config))]
    )

    numpy_report = validate_states(numpy_set, states)
    numba_report = validate_states(numba_set, states)

    assert [result.index for result in numba_report.results] == [0, 1, 2, 3]
    assert _normalise_value(numba_report.as_dict()) == _normalise_value(numpy_report.as_dict())
