# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for the ONNX → TFLite export pipeline.

Tests cover:
* ``export_tflite()`` — the main conversion function (mocked ``onnx2tf``)
* ``_check_onnx2tf_available()`` — import-based availability check
* ``_numpy_allow_pickle()`` — NumPy monkey-patch context manager
* ``_patch_validation_download()`` — validation download redirect
* ``_get_onnx_input_info()`` — ONNX model input metadata reader
* ``_prepare_calibration_data()`` — calibration data preparation
* ``format="tflite"`` parameter wiring through ``RFDETR.export()``
"""

from __future__ import annotations

import sys
import types
from pathlib import Path
from typing import Any, Generator
from unittest import mock

import numpy as np
import pytest

from rfdetr.export._tflite import _IS_ONNX2TF_AVAILABLE
from rfdetr.export._tflite.converter import (
    _DEFAULT_CALIB_SAMPLES,
    _DEFAULT_DIR_CALIB_SAMPLES,
    _IMAGE_EXTENSIONS,
    _NUMPY_LOAD_PATCH_LOCK,
    _VALID_QUANTIZATIONS,
    _check_onnx2tf_available,
    _get_onnx_input_info,
    _load_calibration_images,
    _numpy_allow_pickle,
    _patch_validation_download,
    _prepare_calibration_data,
    export_tflite,
)

onnx2tf_available = pytest.mark.skipif(not _IS_ONNX2TF_AVAILABLE, reason="onnx2tf not installed")

# ---------------------------------------------------------------------------
# Helpers — fake onnx2tf module injected into sys.modules
# ---------------------------------------------------------------------------


class _FakeOnnx2tfModule:
    """Namespace that mimics ``onnx2tf`` for testing."""

    def __init__(self) -> None:
        self.convert = mock.MagicMock()


_ONNX2TF_KEYS = ("onnx2tf", "onnx2tf.onnx2tf", "onnx2tf.utils", "onnx2tf.utils.common_functions")


def _install_fake_onnx2tf() -> tuple[_FakeOnnx2tfModule, mock.MagicMock, dict[str, object]]:
    """Insert a fake ``onnx2tf`` package into ``sys.modules``.

    Saves any pre-existing real modules under the same keys so they can be restored by ``_remove_fake_onnx2tf``
    (Copilot: do not silently clobber real modules that a prior test may have imported).

    Returns:
        Tuple of (fake_module, convert_mock, saved_originals).

    Examples:
        >>> fake, convert_mock, saved = _install_fake_onnx2tf()
        >>> import sys
        >>> "onnx2tf" in sys.modules
        True
        >>> _remove_fake_onnx2tf(saved)
    """
    # Snapshot originals before overwriting (None means the key was absent).
    saved: dict[str, object] = {k: sys.modules.get(k) for k in _ONNX2TF_KEYS}

    fake = _FakeOnnx2tfModule()
    pkg = types.ModuleType("onnx2tf")
    pkg.convert = fake.convert  # type: ignore[attr-defined]
    pkg.__version__ = "2.4.0"  # type: ignore[attr-defined]

    # onnx2tf.onnx2tf — force-imported by export_tflite() before patching
    inner_mod = types.ModuleType("onnx2tf.onnx2tf")
    inner_mod.download_test_image_data = mock.MagicMock(  # type: ignore[attr-defined]
        return_value=np.zeros((20, 128, 128, 3), dtype=np.float32),
    )

    # onnx2tf.utils and onnx2tf.utils.common_functions
    utils_mod = types.ModuleType("onnx2tf.utils")
    cf_mod = types.ModuleType("onnx2tf.utils.common_functions")
    cf_mod.download_test_image_data = mock.MagicMock(  # type: ignore[attr-defined]
        return_value=np.zeros((20, 128, 128, 3), dtype=np.float32),
    )

    # Wire up module hierarchy
    pkg.onnx2tf = inner_mod  # type: ignore[attr-defined]
    pkg.utils = utils_mod  # type: ignore[attr-defined]
    utils_mod.common_functions = cf_mod  # type: ignore[attr-defined]

    sys.modules["onnx2tf"] = pkg
    sys.modules["onnx2tf.onnx2tf"] = inner_mod
    sys.modules["onnx2tf.utils"] = utils_mod
    sys.modules["onnx2tf.utils.common_functions"] = cf_mod
    return fake, fake.convert, saved


def _remove_fake_onnx2tf(saved: dict[str, object] | None = None) -> None:
    """Remove fake ``onnx2tf`` entries from ``sys.modules`` and restore originals.

    Args:
        saved: Snapshot returned by ``_install_fake_onnx2tf``.  If a key was
            present before installation its original value is restored; if it was absent it is deleted.  When *saved* is
            ``None`` all ``onnx2tf*`` keys are simply deleted (legacy behaviour).

    Examples:
        >>> _, _, saved = _install_fake_onnx2tf()
        >>> import sys
        >>> "onnx2tf" in sys.modules
        True
        >>> _remove_fake_onnx2tf(saved)
        >>> "onnx2tf" in sys.modules
        False
    """
    if saved is not None:
        for key in _ONNX2TF_KEYS:
            original = saved.get(key)
            if original is None:
                sys.modules.pop(key, None)
            else:
                sys.modules[key] = original  # type: ignore[assignment]
    else:
        for key in list(sys.modules):
            if key == "onnx2tf" or key.startswith("onnx2tf."):
                del sys.modules[key]


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def fake_onnx2tf():
    """Provide a fake ``onnx2tf`` that records *convert()* calls.

    Also patches ``_replace_gridsample_for_tflite`` to return the input path unchanged so tests that supply stub ONNX
    bytes do not depend on ``onnx.load`` tolerating those bytes.
    """
    fake, convert_mock, saved = _install_fake_onnx2tf()
    with mock.patch(
        "rfdetr.export._tflite.converter._replace_gridsample_for_tflite",
        side_effect=lambda path, _dir: path,
    ):
        yield fake, convert_mock
    _remove_fake_onnx2tf(saved)


@pytest.fixture()
def onnx_model(tmp_path: Path) -> Path:
    """Create a dummy ``.onnx`` file."""
    p = tmp_path / "model.onnx"
    p.write_bytes(b"\x08\x06")  # minimal bytes
    return p


@pytest.fixture()
def mock_prepare_calib(tmp_path: Path) -> Generator:
    """Mock ``_prepare_calibration_data`` so dummy ONNX files work.

    ``export_tflite`` calls ``_prepare_calibration_data`` which calls ``_get_onnx_input_info`` (requiring a real ONNX
    file).  Since the ``onnx_model`` fixture writes only stub bytes, this mock prevents the ONNX parse from being
    attempted.
    """
    dummy_npy = tmp_path / "_rfdetr_calib_data.npy"
    np.save(str(dummy_npy), np.zeros((1, 64, 64, 3), dtype=np.float32))
    with mock.patch(
        "rfdetr.export._tflite.converter._prepare_calibration_data",
        return_value=dummy_npy,
    ) as m:
        yield m


@pytest.fixture()
def tflite_output(tmp_path: Path, onnx_model: Path) -> Path:
    """Create expected TFLite output file so export_tflite finds it."""
    out = tmp_path / "output"
    out.mkdir()
    (out / f"{onnx_model.stem}_float32.tflite").write_bytes(b"tflite")
    return out


# ---------------------------------------------------------------------------
# TestExportTfliteConverter
# ---------------------------------------------------------------------------


@onnx2tf_available
class TestExportTfliteConverter:
    """Tests for ``export_tflite()``."""

    def test_missing_onnx_raises_file_not_found(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
        with pytest.raises(FileNotFoundError, match="ONNX model not found"):
            export_tflite(tmp_path / "nope.onnx", tmp_path / "out")

    def test_invalid_quantization_raises_value_error(self, onnx_model: Path, tmp_path: Path, fake_onnx2tf: Any) -> None:
        with pytest.raises(ValueError, match="Unsupported quantization"):
            export_tflite(onnx_model, tmp_path / "out", quantization="q4")

    @pytest.mark.parametrize(
        "static_mode",
        [
            pytest.param("int8_static", id="int8_static"),
            pytest.param("full_int8", id="full_int8"),
            pytest.param("integer_quant", id="integer_quant"),
        ],
    )
    def test_static_int8_raises(self, onnx_model: Path, tmp_path: Path, fake_onnx2tf: Any, static_mode: str) -> None:
        """A static / full-integer INT8 request must raise a ValueError.

        Static INT8 is intentionally unsupported; only dynamic-range 'int8' is offered.
        """
        with pytest.raises(ValueError, match="[Ss]tatic / full-integer INT8 is not supported"):
            export_tflite(onnx_model, tmp_path / "out", quantization=static_mode)

    def test_default_quantization_calls_convert(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        _, convert_mock = fake_onnx2tf
        result = export_tflite(onnx_model, tflite_output)

        convert_mock.assert_called_once()
        kwargs = convert_mock.call_args.kwargs
        assert kwargs["input_onnx_file_path"] == str(onnx_model)
        assert kwargs["output_folder_path"] == str(tflite_output)
        assert kwargs["output_signaturedefs"] is True
        assert kwargs["non_verbose"] is True
        assert "output_integer_quantized_tflite" not in kwargs
        assert result == tflite_output / "model_float32.tflite"

    def test_custom_input_not_passed_to_convert(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """custom_input_op_name_np_data_path must NOT be passed to convert().

        The onnx2tf custom_input code path triggers a tf.tile rank mismatch with DINOv2-style backbones when N > 1.  We
        rely on patching download_test_image_data() instead.
        """
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output)

        kwargs = convert_mock.call_args.kwargs
        assert "custom_input_op_name_np_data_path" not in kwargs

    def test_output_signaturedefs_always_enabled(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """output_signaturedefs must always be True.

        Segmentation models produce ONNX node names with leading "/" characters that violate the TF saved_model naming
        pattern. Enabling signature defs bypasses this restriction.
        """
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output)

        kwargs = convert_mock.call_args.kwargs
        assert kwargs["output_signaturedefs"] is True

    def test_tflite_backend_forced_to_tf_converter(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """tflite_backend must always be 'tf_converter' to avoid the TFLite TopK_V2 kernel check.

        onnx2tf 2.x defaults to flatbuffer_direct, which trips a "k > internal dimension" error at AllocateTensors()
        time on RF-DETR's encoder TopK node.  tf_converter is forced unconditionally.
        """
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output)

        assert convert_mock.call_args.kwargs["tflite_backend"] == "tf_converter"

    def test_replace_to_pseudo_operators_contains_erf_and_gelu(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """replace_to_pseudo_operators must include Erf and GeLU.

        Without this, AllocateTensors() fails with "FlexErf failed to prepare" because the TFLite runtime lacks native
        Erf / GeLU kernels.
        """
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output)

        pseudo_ops = convert_mock.call_args.kwargs.get("replace_to_pseudo_operators", [])
        assert "Erf" in pseudo_ops
        assert "GeLU" in pseudo_ops

    def test_fp32_quantization_no_int8_flag(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output, quantization="fp32")
        assert "output_integer_quantized_tflite" not in convert_mock.call_args.kwargs

    def test_fp16_quantization_no_int8_flag(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output, quantization="fp16")
        assert "output_integer_quantized_tflite" not in convert_mock.call_args.kwargs

    def test_int8_quantization_produces_dynamic_range(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Int8 export derives a dynamic-range model and avoids onnx2tf's -oiqt path.

        onnx2tf's ``output_integer_quantized_tflite`` (-oiqt) only yields static quantization, which RF-DETR's
        transformer activations do not survive. The converter instead builds dynamic-range INT8 from the SavedModel via
        ``_quantize_dynamic_range``, so the onnx2tf call must NOT carry the ``output_integer_quantized_tflite`` flag.
        """
        _, convert_mock = fake_onnx2tf
        dyn_path = tflite_output / "model_dynamic_range_quant.tflite"
        with mock.patch(
            "rfdetr.export._tflite.converter._quantize_dynamic_range",
            return_value=dyn_path,
        ) as quant_mock:
            result = export_tflite(onnx_model, tflite_output, quantization="int8")
        assert "output_integer_quantized_tflite" not in convert_mock.call_args.kwargs
        quant_mock.assert_called_once()
        assert result == dyn_path

    def test_verbosity_forwarded(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        _, convert_mock = fake_onnx2tf
        export_tflite(onnx_model, tflite_output, verbosity="debug")
        assert convert_mock.call_args.kwargs["verbosity"] == "debug"

    def test_convert_failure_raises_runtime_error(
        self,
        onnx_model: Path,
        tmp_path: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        _, convert_mock = fake_onnx2tf
        convert_mock.side_effect = RuntimeError("boom")
        with pytest.raises(RuntimeError, match="onnx2tf conversion failed"):
            export_tflite(onnx_model, tmp_path / "out")

    def test_fallback_when_primary_tflite_missing(
        self,
        onnx_model: Path,
        tmp_path: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Fallback returns a stem-scoped file when the primary *_fp32.tflite is absent."""
        out = tmp_path / "out"
        out.mkdir()
        # Scoped fallback: must match {stem}_*.tflite (stem == "model" here). Written pre-renamed
        # ("_float16") to mirror onnx2tf's real output naming -- _rename_precision_outputs renames it
        # to "_fp16" before the fallback glob runs.
        (out / "model_float16.tflite").write_bytes(b"fb")
        result = export_tflite(onnx_model, out)
        assert result.name == "model_fp16.tflite"

    def test_fallback_does_not_return_unrelated_tflite(
        self,
        onnx_model: Path,
        tmp_path: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Stale artifacts from a different export are never returned as fallback."""
        out = tmp_path / "out"
        out.mkdir()
        # Unrelated file — does NOT match model_*.tflite.
        (out / "other_model.tflite").write_bytes(b"stale")
        with pytest.raises(RuntimeError, match="no .tflite file matching"):
            export_tflite(onnx_model, out)

    def test_no_tflite_output_raises_runtime_error(
        self,
        onnx_model: Path,
        tmp_path: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Empty output directory raises RuntimeError after conversion."""
        out = tmp_path / "empty_out"
        out.mkdir()
        with pytest.raises(RuntimeError, match="no .tflite file matching"):
            export_tflite(onnx_model, out)

    def test_returns_path_object(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        result = export_tflite(onnx_model, tflite_output)
        assert isinstance(result, Path)

    def test_calibration_data_forwarded_to_prepare(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Verify that calibration_data is passed to _prepare_calibration_data."""
        calib_path = "/some/calib.npy"
        export_tflite(onnx_model, tflite_output, calibration_data=calib_path)
        call_args = mock_prepare_calib.call_args
        assert call_args[0][1] == calib_path  # second positional arg

    def test_max_images_forwarded_to_prepare(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Verify that max_images is passed to _prepare_calibration_data."""
        export_tflite(onnx_model, tflite_output, max_images=42)
        call_kwargs = mock_prepare_calib.call_args
        assert call_kwargs.kwargs.get("max_images") == 42

    def test_max_images_default_is_100(
        self,
        onnx_model: Path,
        tflite_output: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """Verify that max_images defaults to 100 when not specified."""
        export_tflite(onnx_model, tflite_output)
        call_kwargs = mock_prepare_calib.call_args
        assert call_kwargs.kwargs.get("max_images") == 100

    def test_valid_quantizations_set(self) -> None:
        assert _VALID_QUANTIZATIONS == {None, "fp32", "fp16", "int8"}

    def test_output_name_end_to_end_filename_includes_gs_patched(
        self,
        tmp_path: Path,
        fake_onnx2tf: Any,
        mock_prepare_calib: Any,
    ) -> None:
        """A real RF-DETR export always contains GridSample nodes (module docstring), so
        ``_replace_gridsample_for_tflite`` always renames the ONNX stem with a ``_gs_patched`` infix before ``onnx2tf``
        ever runs. ``output_name="my-model"`` (already baked into the ONNX filename by ``export_onnx``, per
        ``_convert_onnx_export``'s docstring) must therefore produce ``my-model_gs_patched_fp32.tflite`` — not ``my-
        model_fp32.tflite`` — confirming the actually-produced filename rather than the docstring's simplified "inherits
        its stem" claim.

        The ``fake_onnx2tf`` fixture stubs ``_replace_gridsample_for_tflite`` as a pure passthrough (no rename), so this
        test overrides that stub to reflect the real rename step.
        """
        onnx_path = tmp_path / "my-model.onnx"
        onnx_path.write_bytes(b"\x08\x06")
        out_dir = tmp_path / "out"
        out_dir.mkdir()
        (out_dir / "my-model_gs_patched_float32.tflite").write_bytes(b"tflite")

        with mock.patch(
            "rfdetr.export._tflite.converter._replace_gridsample_for_tflite",
            side_effect=lambda path, output_dir: output_dir / f"{path.stem}_gs_patched.onnx",
        ):
            result = export_tflite(onnx_path, out_dir)

        assert result.name == "my-model_gs_patched_fp32.tflite"


# ---------------------------------------------------------------------------
# TestExportFormatParameter
# ---------------------------------------------------------------------------


class TestExportFormatParameter:
    """Tests for ``format="tflite"`` wiring through ``RFDETR.export()``."""

    @pytest.fixture(autouse=True)
    def _patch_export_deps(self, tmp_path: Path) -> None:
        """Mock the heavy export dependencies so ``RFDETR.export()`` is fast."""
        self._tmp_path = tmp_path
        onnx_out = tmp_path / "inference_model.onnx"
        onnx_out.write_bytes(b"onnx")

        import contextlib

        self._mock_stack = contextlib.ExitStack()

        # Mock export_onnx to return a fake ONNX file path
        self._mock_export_onnx = self._mock_stack.enter_context(
            mock.patch("rfdetr.export.main.export_onnx", return_value=str(onnx_out))
        )
        # Mock make_infer_image to return a small tensor
        import torch

        self._mock_stack.enter_context(
            mock.patch(
                "rfdetr.export.main.make_infer_image",
                return_value=torch.zeros(1, 3, 560, 560),
            )
        )
        # Mock export_tflite
        self._mock_export_tflite = self._mock_stack.enter_context(
            mock.patch(
                "rfdetr.export._tflite.converter.export_tflite",
                return_value=tmp_path / "inference_model_float32.tflite",
            )
        )
        yield
        self._mock_stack.close()

    @staticmethod
    def _make_rfdetr() -> Any:
        """Create a minimal RFDETR instance with mocked internals."""
        from rfdetr.detr import RFDETR

        obj = RFDETR.__new__(RFDETR)
        obj.model = mock.MagicMock()
        obj.model.resolution = 560
        obj.model.device = "cpu"
        obj.model.model.to.return_value = obj.model.model
        obj.model_config = mock.MagicMock()
        obj.model_config.segmentation_head = False
        obj.model_config.patch_size = 14
        obj.model_config.num_windows = 1
        return obj

    def test_tflite_format_calls_export_tflite(self) -> None:
        obj = self._make_rfdetr()
        obj.export(format="tflite", output_dir=str(self._tmp_path / "out"))
        self._mock_export_tflite.assert_called_once()

    def test_onnx_format_does_not_call_export_tflite(self) -> None:
        obj = self._make_rfdetr()
        obj.export(format="onnx", output_dir=str(self._tmp_path / "out"))
        self._mock_export_tflite.assert_not_called()

    def test_quantization_forwarded(self) -> None:
        obj = self._make_rfdetr()
        obj.export(
            format="tflite",
            output_dir=str(self._tmp_path / "out"),
            quantization="int8",
        )
        call_kwargs = self._mock_export_tflite.call_args
        assert call_kwargs[1].get("quantization") == "int8" or call_kwargs.kwargs.get("quantization") == "int8"

    @pytest.mark.parametrize(
        "quant",
        [
            pytest.param(None, id="none"),
            pytest.param("fp32", id="fp32"),
            pytest.param("fp16", id="fp16"),
            pytest.param("int8", id="int8"),
        ],
    )
    def test_all_quantization_modes_accepted(self, quant: str | None) -> None:
        obj = self._make_rfdetr()
        obj.export(
            format="tflite",
            output_dir=str(self._tmp_path / "out"),
            quantization=quant,
        )
        self._mock_export_tflite.assert_called_once()

    def test_unsupported_format_raises(self) -> None:
        obj = self._make_rfdetr()
        with pytest.raises(ValueError, match="[Uu]nsupported.*format"):
            obj.export(format="banana", output_dir=str(self._tmp_path / "out"))

    def test_calibration_data_forwarded(self) -> None:
        """Verify calibration_data kwarg reaches export_tflite."""
        obj = self._make_rfdetr()
        calib = "/my/calib.npy"
        obj.export(
            format="tflite",
            output_dir=str(self._tmp_path / "out"),
            calibration_data=calib,
        )
        call_kwargs = self._mock_export_tflite.call_args
        assert call_kwargs[1].get("calibration_data") == calib or call_kwargs.kwargs.get("calibration_data") == calib

    def test_max_images_forwarded(self) -> None:
        """Verify max_images kwarg reaches export_tflite."""
        obj = self._make_rfdetr()
        obj.export(
            format="tflite",
            output_dir=str(self._tmp_path / "out"),
            max_images=50,
        )
        call_kwargs = self._mock_export_tflite.call_args
        assert call_kwargs[1].get("max_images") == 50 or call_kwargs.kwargs.get("max_images") == 50

    def test_max_images_default_is_100(self) -> None:
        """Verify max_images defaults to 100 when not specified."""
        obj = self._make_rfdetr()
        obj.export(
            format="tflite",
            output_dir=str(self._tmp_path / "out"),
        )
        call_kwargs = self._mock_export_tflite.call_args
        assert call_kwargs[1].get("max_images") == 100 or call_kwargs.kwargs.get("max_images") == 100


# ---------------------------------------------------------------------------
# TestNumpyAllowPickle
# ---------------------------------------------------------------------------


class TestNumpyAllowPickle:
    """Tests for ``_numpy_allow_pickle()`` context manager."""

    def test_patches_np_load(self) -> None:
        original = np.load
        with _numpy_allow_pickle():
            assert np.load is not original
        assert np.load is original

    def test_sets_allow_pickle_default(self) -> None:
        calls: list[dict[str, Any]] = []
        original = np.load

        def _spy(*args: Any, **kwargs: Any) -> Any:
            calls.append(kwargs.copy())
            raise ValueError("stop")

        np.load = _spy  # type: ignore[assignment]
        try:
            with _numpy_allow_pickle():
                with pytest.raises(ValueError, match="stop"):
                    np.load("dummy.npy")
            assert calls[0].get("allow_pickle") is True
        finally:
            np.load = original  # type: ignore[assignment]

    def test_restores_on_exception(self) -> None:
        """Patch is restored even when an exception propagates out of the context."""
        original = np.load
        try:
            with _numpy_allow_pickle():
                raise RuntimeError("boom")
        except RuntimeError:
            pass
        assert np.load is original

    def test_concurrent_access_lock_not_reentrant(self) -> None:
        """Lock prevents a second context from patching np.load while the first is still active."""
        import threading

        entered = threading.Event()
        released = threading.Event()

        def _hold_context() -> None:
            with _numpy_allow_pickle():
                entered.set()
                released.wait(timeout=2.0)

        holder = threading.Thread(target=_hold_context)
        holder.start()
        entered.wait(timeout=2.0)

        acquired = _NUMPY_LOAD_PATCH_LOCK.acquire(blocking=False)
        released.set()
        holder.join(timeout=2.0)

        assert not acquired, "Lock must remain held while first context is active"


# ---------------------------------------------------------------------------
# TestPatchValidationDownload
# ---------------------------------------------------------------------------


class TestPatchValidationDownload:
    """Tests for ``_patch_validation_download()`` context manager."""

    def test_patches_download_in_common_functions(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
        """The patch replaces download_test_image_data in common_functions."""
        data = np.random.rand(5, 128, 128, 3).astype(np.float32)
        npy_path = tmp_path / "calib.npy"
        np.save(str(npy_path), data)

        cf_mod = sys.modules["onnx2tf.utils.common_functions"]
        original_fn = cf_mod.download_test_image_data

        with _patch_validation_download(str(npy_path)):
            assert cf_mod.download_test_image_data is not original_fn
            result = cf_mod.download_test_image_data()
            np.testing.assert_array_equal(result, data)

        # Restored after exit
        assert cf_mod.download_test_image_data is original_fn

    def test_patches_download_in_onnx2tf_module(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
        """The patch also covers onnx2tf.onnx2tf if it has the function."""
        data = np.random.rand(3, 64, 64, 3).astype(np.float32)
        npy_path = tmp_path / "calib.npy"
        np.save(str(npy_path), data)

        # Create onnx2tf.onnx2tf submodule with download_test_image_data
        onnx2tf_inner = types.ModuleType("onnx2tf.onnx2tf")
        original_fn = mock.MagicMock()
        onnx2tf_inner.download_test_image_data = original_fn  # type: ignore[attr-defined]
        sys.modules["onnx2tf.onnx2tf"] = onnx2tf_inner

        try:
            with _patch_validation_download(str(npy_path)):
                assert onnx2tf_inner.download_test_image_data is not original_fn
                result = onnx2tf_inner.download_test_image_data()
                np.testing.assert_array_equal(result, data)

            assert onnx2tf_inner.download_test_image_data is original_fn
        finally:
            sys.modules.pop("onnx2tf.onnx2tf", None)

    def test_restores_on_exception(self, tmp_path: Path, fake_onnx2tf: Any) -> None:
        """Functions are restored even when an exception occurs."""
        npy_path = tmp_path / "calib.npy"
        np.save(str(npy_path), np.zeros((1, 8, 8, 3), dtype=np.float32))

        cf_mod = sys.modules["onnx2tf.utils.common_functions"]
        original_fn = cf_mod.download_test_image_data

        try:
            with _patch_validation_download(str(npy_path)):
                raise RuntimeError("boom")
        except RuntimeError:
            pass

        assert cf_mod.download_test_image_data is original_fn

    def test_skips_missing_modules(self, tmp_path: Path) -> None:
        """Does not raise when onnx2tf modules are not in sys.modules."""
        npy_path = tmp_path / "calib.npy"
        np.save(str(npy_path), np.zeros((1, 8, 8, 3), dtype=np.float32))

        # Ensure onnx2tf is NOT in sys.modules
        keys = [k for k in sys.modules if k == "onnx2tf" or k.startswith("onnx2tf.")]
        saved = {k: sys.modules.pop(k) for k in keys}

        try:
            with _patch_validation_download(str(npy_path)):
                pass  # should not raise
        finally:
            sys.modules.update(saved)


class TestGetOnnxInputInfo:
    """Tests for ``_get_onnx_input_info()``."""

    def test_reads_input_name_and_shape(self, tmp_path: Path) -> None:
        """Build a minimal ONNX model and verify we read back its metadata."""
        onnx = pytest.importorskip("onnx", reason="onnx not installed")
        TensorProto, helper = onnx.TensorProto, onnx.helper  # noqa: N806

        inp = helper.make_tensor_value_info("images", TensorProto.FLOAT, [1, 3, 560, 560])
        out = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 100, 4])
        node = helper.make_node("Identity", inputs=["images"], outputs=["output"])
        graph = helper.make_graph([node], "test", [inp], [out])
        model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
        onnx_path = tmp_path / "test_model.onnx"
        onnx.save(model, str(onnx_path))

        name, dims = _get_onnx_input_info(onnx_path)
        assert name == "images"
        assert dims == [1, 3, 560, 560]

    def test_different_input_shape(self, tmp_path: Path) -> None:
        """Verify non-square resolution reads correctly."""
        onnx = pytest.importorskip("onnx", reason="onnx not installed")
        TensorProto, helper = onnx.TensorProto, onnx.helper  # noqa: N806

        inp = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 3, 448, 640])
        out = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 10, 4])
        node = helper.make_node("Identity", inputs=["input"], outputs=["output"])
        graph = helper.make_graph([node], "test", [inp], [out])
        model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
        onnx_path = tmp_path / "test_model.onnx"
        onnx.save(model, str(onnx_path))

        name, dims = _get_onnx_input_info(onnx_path)
        assert name == "input"
        assert dims == [1, 3, 448, 640]


# ---------------------------------------------------------------------------
# TestPrepareCalibrationData
# ---------------------------------------------------------------------------


class TestPrepareCalibrationData:
    """Tests for ``_prepare_calibration_data()``."""

    @pytest.fixture()
    def _mock_onnx_info(self) -> Generator:
        """Mock ``_get_onnx_input_info`` to return a known shape."""
        with mock.patch(
            "rfdetr.export._tflite.converter._get_onnx_input_info",
            return_value=("input", [1, 3, 256, 256]),
        ):
            yield

    def test_none_generates_random_data(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")

        npy_path = _prepare_calibration_data(onnx_path, None, tmp_path, "fp32")

        assert isinstance(npy_path, Path)
        assert npy_path.is_file()

        data = np.load(str(npy_path))
        assert data.shape == (_DEFAULT_CALIB_SAMPLES, 256, 256, 3)
        assert data.dtype == np.float32

    def test_none_int8_emits_warning(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")

        with mock.patch("rfdetr.export._tflite.converter.logger") as mock_logger:
            _prepare_calibration_data(onnx_path, None, tmp_path, "int8")
            mock_logger.warning.assert_called_once()
            assert "INT8" in mock_logger.warning.call_args[0][0]

    def test_ndarray_saves_to_npy(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")
        calib = np.random.rand(10, 256, 256, 3).astype(np.float32)

        npy_path = _prepare_calibration_data(onnx_path, calib, tmp_path, "fp32")

        loaded = np.load(str(npy_path))
        np.testing.assert_array_equal(loaded, calib)

    def test_path_string_used_directly(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")
        npy_file = tmp_path / "my_calib.npy"
        np.save(str(npy_file), np.zeros((5, 256, 256, 3), dtype=np.float32))

        npy_path = _prepare_calibration_data(onnx_path, str(npy_file), tmp_path, "fp32")

        assert npy_path == npy_file

    def test_directory_loads_images(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        """A directory path triggers image loading and .npy creation."""
        from PIL import Image

        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")
        img_dir = tmp_path / "images"
        img_dir.mkdir()
        for i in range(5):
            img = Image.new("RGB", (100, 80), color=(i * 50, 0, 0))
            img.save(img_dir / f"img_{i:03d}.jpg")

        npy_path = _prepare_calibration_data(onnx_path, str(img_dir), tmp_path, "int8")

        assert npy_path.is_file()
        data = np.load(str(npy_path))
        # _mock_onnx_info returns [1, 3, 256, 256] → H=256, W=256
        assert data.shape == (5, 256, 256, 3)
        assert data.dtype == np.float32

    def test_directory_respects_max_images(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        """Verify that max_images limits the number of images loaded from a directory."""
        from PIL import Image

        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")
        img_dir = tmp_path / "images"
        img_dir.mkdir()
        for i in range(10):
            img = Image.new("RGB", (100, 80), color=(i * 25, 0, 0))
            img.save(img_dir / f"img_{i:03d}.jpg")

        npy_path = _prepare_calibration_data(onnx_path, str(img_dir), tmp_path, "int8", max_images=3)

        assert npy_path.is_file()
        data = np.load(str(npy_path))
        assert data.shape[0] == 3  # only 3 of 10 images loaded

    def test_missing_path_raises_file_not_found(self, tmp_path: Path, _mock_onnx_info: None) -> None:
        onnx_path = tmp_path / "model.onnx"
        onnx_path.write_bytes(b"\x00")

        with pytest.raises(FileNotFoundError, match="Calibration data path not found"):
            _prepare_calibration_data(onnx_path, "/nonexistent/calib.npy", tmp_path, "fp32")


# ---------------------------------------------------------------------------
# TestLoadCalibrationImages
# ---------------------------------------------------------------------------


class TestLoadCalibrationImages:
    """Tests for ``_load_calibration_images()``."""

    @staticmethod
    def _make_images(directory: Path, count: int = 5, size: tuple[int, int] = (100, 80)) -> list[Path]:
        """Create *count* small JPEG images in *directory*."""
        from PIL import Image

        paths: list[Path] = []
        for i in range(count):
            p = directory / f"img_{i:04d}.jpg"
            Image.new("RGB", size, color=(i * 40 % 256, 0, 0)).save(p)
            paths.append(p)
        return paths

    def test_loads_images_with_correct_shape(self, tmp_path: Path) -> None:
        self._make_images(tmp_path, count=3)
        result = _load_calibration_images(tmp_path, height=128, width=256)

        assert isinstance(result, np.ndarray)
        assert result.shape == (3, 128, 256, 3)
        assert result.dtype == np.float32
        assert result.min() >= 0.0
        assert result.max() <= 1.0

    def test_resize_matches_predict_convention(self, tmp_path: Path) -> None:
        """The calibration resize must follow predict()'s convention: bilinear, half-pixel, antialias-free.

        Guards the #1206 alignment: PIL's default resize (BICUBIC + adaptive antialias when downscaling) produces a
        pixel distribution the model never sees at inference and would skew the INT8 ranges.
        """
        import torchvision.transforms.functional as F  # noqa: N812
        from PIL import Image

        rng = np.random.default_rng(11)
        base = rng.integers(0, 256, size=(40, 50, 3), dtype=np.uint8)
        nearest = getattr(Image, "Resampling", Image).NEAREST
        pil = Image.fromarray(base, mode="RGB").resize((400, 320), nearest)
        pil.save(tmp_path / "img.png")

        result = _load_calibration_images(tmp_path, height=128, width=160)

        ref = F.resize(F.to_tensor(pil), [128, 160], antialias=False).numpy().transpose(1, 2, 0)
        max_diff = float(np.abs(result[0] - ref).max())
        assert max_diff < 1e-3, (
            f"Calibration resize diverged from predict()'s antialias-free convention: "
            f"max|diff|={max_diff:.4f}. _load_calibration_images must resize with "
            f"_bilinear_resize_half_pixel, not PIL."
        )

        # PIL's default resample (the old behaviour) must stay far from the reference,
        # proving this assertion actually discriminates on the resize convention.
        pil_default = np.asarray(pil.resize((160, 128)), dtype=np.float32) / np.float32(255.0)
        assert float(np.abs(pil_default - ref).max()) > 0.05, (
            "PIL's default resize unexpectedly matches the reference; this test no longer discriminates."
        )

    def test_respects_max_images(self, tmp_path: Path) -> None:
        self._make_images(tmp_path, count=10)
        result = _load_calibration_images(tmp_path, height=64, width=64, max_images=4)
        assert result.shape[0] == 4

    def test_empty_directory_raises(self, tmp_path: Path) -> None:
        empty = tmp_path / "empty"
        empty.mkdir()
        with pytest.raises(FileNotFoundError, match="No supported image files"):
            _load_calibration_images(empty, height=64, width=64)

    def test_nonexistent_directory_raises(self, tmp_path: Path) -> None:
        with pytest.raises(FileNotFoundError, match="Calibration image directory not found"):
            _load_calibration_images(tmp_path / "does_not_exist", height=64, width=64)

    def test_unsupported_extensions_ignored(self, tmp_path: Path) -> None:
        """Only image extensions are loaded; .txt files are skipped."""
        self._make_images(tmp_path, count=2)
        (tmp_path / "readme.txt").write_text("hello")
        (tmp_path / "data.csv").write_text("a,b")

        result = _load_calibration_images(tmp_path, height=32, width=32)
        assert result.shape[0] == 2

    def test_skips_unreadable_files(self, tmp_path: Path) -> None:
        """Corrupt images are skipped without raising."""
        self._make_images(tmp_path, count=3)
        # Write a corrupt file with a supported extension
        (tmp_path / "corrupt.jpg").write_bytes(b"not-a-jpeg")

        result = _load_calibration_images(tmp_path, height=32, width=32)
        assert result.shape[0] == 3  # only the 3 valid images

    def test_all_unreadable_raises(self, tmp_path: Path) -> None:
        """If all files are unreadable, raises FileNotFoundError."""
        (tmp_path / "bad1.jpg").write_bytes(b"garbage")
        (tmp_path / "bad2.png").write_bytes(b"garbage")

        with pytest.raises(FileNotFoundError, match="No readable images"):
            _load_calibration_images(tmp_path, height=32, width=32)

    def test_png_and_jpeg_both_loaded(self, tmp_path: Path) -> None:
        """Both .jpg and .png formats are loaded."""
        from PIL import Image

        Image.new("RGB", (50, 50), "red").save(tmp_path / "a.jpg")
        Image.new("RGB", (50, 50), "blue").save(tmp_path / "b.png")

        result = _load_calibration_images(tmp_path, height=32, width=32)
        assert result.shape[0] == 2

    def test_constants_are_reasonable(self) -> None:
        """Sanity-check the module-level constants."""
        assert _DEFAULT_DIR_CALIB_SAMPLES > 0
        assert ".jpg" in _IMAGE_EXTENSIONS
        assert ".png" in _IMAGE_EXTENSIONS


# ---------------------------------------------------------------------------
# TestCheckOnnx2tfAvailable
# ---------------------------------------------------------------------------


@onnx2tf_available
class TestCheckOnnx2tfAvailable:
    """Tests for ``_check_onnx2tf_available()``."""

    def test_available_when_importable(self, fake_onnx2tf: Any) -> None:
        _check_onnx2tf_available()  # should not raise

    def test_raises_when_not_importable(self) -> None:
        """ImportError is raised with install hint when onnx2tf is absent."""
        _remove_fake_onnx2tf()
        with mock.patch.dict(sys.modules, {"onnx2tf": None}):
            with pytest.raises(ImportError, match="onnx2tf is not installed"):
                _check_onnx2tf_available()


# ---------------------------------------------------------------------------
# TestGridSampleOnnxRewrite
# ---------------------------------------------------------------------------

onnx_gs_available = pytest.mark.skipif(
    not all(
        __import__("importlib").util.find_spec(p) is not None for p in ("onnx", "onnx_graphsurgeon", "onnxruntime")
    ),
    reason="onnx, onnx_graphsurgeon, and onnxruntime required",
)


def _build_gridsample_onnx(
    path: Path,
    *,
    n: int = 1,
    c: int = 4,
    h: int = 8,
    w: int = 8,
    h_out: int = 4,
    w_out: int = 4,
) -> None:
    """Write a minimal ONNX model with one GridSample node to *path*.

    Examples:
        Requires ``onnx`` and ``onnx_graphsurgeon`` which are optional — skipped in the doctest runner.

        >>> callable(_build_gridsample_onnx)  # doctest: +SKIP
        True
    """
    import onnx
    from onnx import TensorProto, helper

    im = helper.make_tensor_value_info("im", TensorProto.FLOAT, [n, c, h, w])
    grid = helper.make_tensor_value_info("grid", TensorProto.FLOAT, [n, h_out, w_out, 2])
    out = helper.make_tensor_value_info("out", TensorProto.FLOAT, [n, c, h_out, w_out])
    node = helper.make_node(
        "GridSample",
        inputs=["im", "grid"],
        outputs=["out"],
        mode="bilinear",
        padding_mode="zeros",
        align_corners=0,
    )
    graph = helper.make_graph([node], "gs_test", [im, grid], [out])
    model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 16)])
    onnx.save(model, str(path))


@pytest.fixture()
def gridsample_onnx(tmp_path: Path) -> Path:
    """Build a tiny ONNX file with a single GridSample node."""
    p = tmp_path / "gs_model.onnx"
    _build_gridsample_onnx(p)
    return p


class TestGridSampleOnnxRewrite:
    """Tests for the ONNX-level GridSample → Gather(axis=0) rewrite."""

    def test_module_import_does_not_raise(self) -> None:
        """Importing the converter module must succeed regardless of onnx2tf version."""
        import rfdetr.export._tflite.converter  # noqa: F401

    @onnx_gs_available
    def test_no_gridsample_nodes_after_rewrite(self, gridsample_onnx: Path, tmp_path: Path) -> None:
        """_replace_gridsample_for_tflite removes all GridSample nodes from the graph."""
        import onnx
        import onnx_graphsurgeon as gs

        from rfdetr.export._tflite.converter import _replace_gridsample_for_tflite

        patched_path = _replace_gridsample_for_tflite(gridsample_onnx, tmp_path)

        model = onnx.load(str(patched_path))
        graph = gs.import_onnx(model)
        remaining = [n for n in graph.nodes if n.op == "GridSample"]
        assert remaining == [], f"Expected no GridSample nodes; found {len(remaining)}"

    @onnx_gs_available
    def test_gather_nodes_present_after_rewrite(self, gridsample_onnx: Path, tmp_path: Path) -> None:
        """Rewritten graph contains Gather nodes (the TFLite-safe replacement ops)."""
        import onnx
        import onnx_graphsurgeon as gs

        from rfdetr.export._tflite.converter import _replace_gridsample_for_tflite

        patched_path = _replace_gridsample_for_tflite(gridsample_onnx, tmp_path)

        model = onnx.load(str(patched_path))
        graph = gs.import_onnx(model)
        gather_nodes = [n for n in graph.nodes if n.op == "Gather"]
        assert len(gather_nodes) >= 4, f"Expected ≥4 Gather nodes (one per bilinear corner); found {len(gather_nodes)}"

    @onnx_gs_available
    def test_numerical_equivalence_vs_pytorch(self, gridsample_onnx: Path, tmp_path: Path) -> None:
        """Rewritten ONNX output matches torch.nn.functional.grid_sample within 1e-5."""
        import onnxruntime as ort
        import torch
        import torch.nn.functional as F  # noqa: N812

        from rfdetr.export._tflite.converter import _replace_gridsample_for_tflite

        rng = np.random.default_rng(0)
        im_np = rng.standard_normal((1, 4, 8, 8)).astype(np.float32)
        grid_np = rng.uniform(-1, 1, (1, 4, 4, 2)).astype(np.float32)

        ref = F.grid_sample(
            torch.from_numpy(im_np),
            torch.from_numpy(grid_np),
            mode="bilinear",
            padding_mode="zeros",
            align_corners=False,
        ).numpy()

        patched_path = _replace_gridsample_for_tflite(gridsample_onnx, tmp_path)
        sess = ort.InferenceSession(str(patched_path), providers=["CPUExecutionProvider"])
        (result,) = sess.run(None, {"im": im_np, "grid": grid_np})

        np.testing.assert_allclose(
            result, ref, atol=1e-5, rtol=0, err_msg="Gather(axis=0) rewrite output diverges from F.grid_sample"
        )
