from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from typing import Any

import pytest

import icsd.adapters._cython_feasibility as cython_feasibility

ROOT = Path(__file__).resolve().parents[1]
BENCHMARK_SCRIPT = ROOT / "benchmarks" / "benchmark_cython_speedups.py"
PROFILE_SCRIPT = ROOT / "benchmarks" / "profile_cython_targets.py"


def _raise_module_not_found(*_: Any, **__: Any) -> Any:
    raise ModuleNotFoundError("missing speedup module")


def _run_json_script(path: Path, args: list[str]) -> dict[str, Any]:
    result = subprocess.run(
        [sys.executable, str(path), *args],
        cwd=ROOT,
        capture_output=True,
        text=True,
        check=False,
    )
    assert result.returncode == 0, result.stderr
    payload_text = result.stdout.strip()
    payload = json.loads(payload_text)
    assert payload_text == json.dumps(payload, sort_keys=True)
    return payload


def test_load_cython_speedups_returns_none_when_module_missing(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(cython_feasibility.importlib, "import_module", _raise_module_not_found)

    loaded = cython_feasibility._load_cython_speedups(build=False)

    assert loaded is None


def test_load_cython_speedups_build_exception_returns_none(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class _FakePyxImport:
        @staticmethod
        def install(**_: Any) -> None:
            raise RuntimeError("compile failed")

    monkeypatch.setattr(cython_feasibility.importlib, "import_module", _raise_module_not_found)
    monkeypatch.setitem(sys.modules, "pyximport", _FakePyxImport)

    loaded = cython_feasibility._load_cython_speedups(build=True)

    assert loaded is None


def test_run_parity_case_default_never_requests_build(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    np = pytest.importorskip("numpy")
    build_flags: list[bool] = []

    def _fake_loader(*, build: bool) -> Any | None:
        build_flags.append(build)
        return None

    monkeypatch.setattr(cython_feasibility, "_load_cython_speedups", _fake_loader)

    outcome = cython_feasibility.run_parity_case([1.0, 2.0], np_runtime=np)

    assert build_flags == [False]
    assert outcome["status"] == "speedup_unavailable"
    assert outcome["speedup_available"] is False


def test_normalise_float64_1d_contiguous_contract() -> None:
    np = pytest.importorskip("numpy")
    original = np.asfortranarray(np.arange(6, dtype=np.int64).reshape(2, 3))

    normalised = cython_feasibility._normalise_float64_1d_contiguous(
        original,
        np_runtime=np,
    )

    assert normalised.dtype == np.float64
    assert normalised.ndim == 1
    assert bool(normalised.flags["C_CONTIGUOUS"]) is True


def test_reference_kernel_contracts_and_empty_min_max_guard() -> None:
    np = pytest.importorskip("numpy")

    assert cython_feasibility.reference_all_finite([1.0, 2.0], np_runtime=np) is True
    assert cython_feasibility.reference_all_finite([1.0, float("nan")], np_runtime=np) is False
    assert cython_feasibility.reference_min_max([1.0, 2.0], np_runtime=np) == (1.0, 2.0)
    with pytest.raises(ValueError, match="must not be empty"):
        cython_feasibility.reference_min_max([], np_runtime=np)


def test_run_parity_case_reports_match_with_fake_speedup() -> None:
    np = pytest.importorskip("numpy")

    class _FakeSpeedups:
        @staticmethod
        def all_finite_double(values: Any) -> bool:
            return bool(np.isfinite(values).all())

        @staticmethod
        def min_max_double(values: Any) -> tuple[float, float]:
            if int(values.size) == 0:
                msg = "values must not be empty."
                raise ValueError(msg)
            return float(values.min()), float(values.max())

    outcome = cython_feasibility.run_parity_case(
        [1.0, 2.0, 3.0],
        np_runtime=np,
        speedups=_FakeSpeedups(),
    )

    assert outcome["status"] == "ok"
    assert outcome["speedup_available"] is True
    assert outcome["matched"] is True
    assert outcome["reference"] == outcome["speedup"]


def test_run_parity_case_reports_deterministic_mismatch() -> None:
    np = pytest.importorskip("numpy")

    class _MismatchSpeedups:
        @staticmethod
        def all_finite_double(values: Any) -> bool:
            return bool(np.isfinite(values).all())

        @staticmethod
        def min_max_double(values: Any) -> tuple[float, float]:
            if int(values.size) == 0:
                msg = "values must not be empty."
                raise ValueError(msg)
            return float(values.min()), float(values.max() + 1.0)

    outcome = cython_feasibility.run_parity_case(
        [1.0, 2.0, 3.0],
        np_runtime=np,
        speedups=_MismatchSpeedups(),
    )

    assert outcome["status"] == "mismatch"
    assert outcome["speedup_available"] is True
    assert outcome["matched"] is False


def test_benchmark_script_json_contract() -> None:
    payload = _run_json_script(
        BENCHMARK_SCRIPT,
        ["--format", "json", "--iterations", "2", "--seed", "79079"],
    )

    assert payload["build_speedup"] is False
    assert payload["iterations"] == 2
    assert payload["seed"] == 79079
    assert isinstance(payload["cases"], list)
    if payload["status"] != "numpy_unavailable":
        assert payload["cases"], "expected deterministic benchmark cases"
        for row in payload["cases"]:
            assert set(row) == {
                "error_type",
                "matched",
                "name",
                "reference_seconds",
                "size",
                "speedup_seconds",
                "status",
            }


def test_profile_script_json_contract_and_sorting_rules() -> None:
    payload = _run_json_script(
        PROFILE_SCRIPT,
        ["--format", "json", "--repeats", "2", "--top-n", "5", "--seed", "79079"],
    )

    assert payload["repeats"] == 2
    assert payload["top_n"] == 5
    assert payload["seed"] == 79079
    if payload["status"] != "numpy_unavailable":
        for workload in payload["workloads"]:
            rows = workload["rows"]
            assert rows == sorted(
                rows,
                key=lambda row: (-row["cumulative_time"], row["function"]),
            )
