"""Tests for windowed GeoTIFF reads in InferenceSlicer."""

import threading
import time
from collections.abc import Callable

import numpy as np
import pytest

from supervision.detection.core import Detections
from supervision.detection.tools.inference_slicer import InferenceSlicer


class _FakeCRS:
    """Minimal rasterio-style CRS stub exposing only `is_projected`."""

    def __init__(self, is_projected: bool):
        self.is_projected = is_projected

    def __repr__(self) -> str:
        kind = "projected" if self.is_projected else "geographic"
        return f"_FakeCRS({kind})"


class _FakeRasterDataset:
    """Lightweight rasterio-style dataset supporting windowed reads.

    Mimics the duck-typed interface that ``InferenceSlicer`` relies on without
    requiring ``rasterio`` to be installed.
    """

    def __init__(self, image_hwc: np.ndarray, crs: object | None = None):
        self._image = image_hwc  # numpy (H, W, C)
        self.height, self.width = image_hwc.shape[:2]
        self.crs = crs  # None or object with .is_projected

    def read(self, window: tuple[tuple[int, int], tuple[int, int]]) -> np.ndarray:
        (row_start, row_stop), (col_start, col_stop) = window
        crop = self._image[row_start:row_stop, col_start:col_stop, :]
        return np.transpose(crop, (2, 0, 1))  # (C, H, W) like rasterio


class _ConcurrencyCheckDataset:
    """Dataset that tracks peak concurrent reads to verify read serialization."""

    def __init__(self, image_hwc: np.ndarray):
        self._image = image_hwc
        self.height, self.width = image_hwc.shape[:2]
        self.crs = None
        self._lock = threading.Lock()
        self._active = 0
        self.peak_concurrent = 0

    def read(self, window: tuple[tuple[int, int], tuple[int, int]]) -> np.ndarray:
        with self._lock:
            self._active += 1
            self.peak_concurrent = max(self.peak_concurrent, self._active)
        time.sleep(0.002)  # amplify race window so concurrent reads are detectable
        (row_start, row_stop), (col_start, col_stop) = window
        crop = self._image[row_start:row_stop, col_start:col_stop, :]
        result = np.transpose(crop, (2, 0, 1))
        with self._lock:
            self._active -= 1
        return result


def _sortable(detections: Detections) -> np.ndarray:
    """Sort detection boxes so two runs can be compared order-independently."""
    return np.array(sorted(detections.xyxy.tolist()), dtype=float)


@pytest.fixture
def make_raster_dataset() -> Callable[..., _FakeRasterDataset]:
    """Factory: create a _FakeRasterDataset from a numpy image array."""

    def factory(image_hwc: np.ndarray, crs: object | None = None) -> _FakeRasterDataset:
        return _FakeRasterDataset(image_hwc, crs=crs)

    return factory


@pytest.fixture
def fixed_detection_callback() -> Callable[[np.ndarray], Detections]:
    """Return a constant single-box detection for every tile."""

    def callback(_: np.ndarray) -> Detections:
        return Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    return callback


@pytest.fixture
def make_recording_callback() -> Callable[[list[np.ndarray]], Callable]:
    """Factory: given a sink list, return a callback that records each tile."""

    def factory(sink: list[np.ndarray]) -> Callable[[np.ndarray], Detections]:
        def callback(tile: np.ndarray) -> Detections:
            sink.append(tile.copy())
            return Detections.empty()

        return callback

    return factory


class TestInferenceSlicerGeoTIFF:
    def test_windowed_raster_matches_in_memory_array(
        self,
        make_raster_dataset: Callable,
        fixed_detection_callback: Callable,
    ) -> None:
        """Raster and array paths produce identical merged detections."""
        # Arrange
        rng = np.random.default_rng(42)
        image = rng.integers(0, 255, size=(256, 256, 3), dtype=np.uint8)
        dataset = make_raster_dataset(image, crs=_FakeCRS(is_projected=True))
        slicer = InferenceSlicer(
            callback=fixed_detection_callback,
            slice_wh=128,
            overlap_wh=0,
        )

        # Act
        detections_array = slicer(image)
        detections_raster = slicer(dataset)

        # Assert
        assert np.array_equal(_sortable(detections_array), _sortable(detections_raster))

    @pytest.mark.parametrize(
        ("seed", "image_shape", "slice_wh", "overlap_wh"),
        [
            pytest.param(7, (128, 192, 3), 64, 0, id="no-overlap"),
            pytest.param(99, (200, 220, 3), 96, 32, id="with-overlap"),
        ],
    )
    def test_raster_tiles_match_array_tiles(
        self,
        make_raster_dataset: Callable,
        make_recording_callback: Callable,
        seed: int,
        image_shape: tuple[int, int, int],
        slice_wh: int,
        overlap_wh: int,
    ) -> None:
        """Windowed raster read returns identical pixel tiles as array path."""
        # Arrange
        rng = np.random.default_rng(seed)
        image = rng.integers(0, 255, size=image_shape, dtype=np.uint8)
        dataset = make_raster_dataset(image)

        array_tiles: list[np.ndarray] = []
        raster_tiles: list[np.ndarray] = []

        slicer_array = InferenceSlicer(
            callback=make_recording_callback(array_tiles),
            slice_wh=slice_wh,
            overlap_wh=overlap_wh,
        )
        slicer_raster = InferenceSlicer(
            callback=make_recording_callback(raster_tiles),
            slice_wh=slice_wh,
            overlap_wh=overlap_wh,
        )

        # Act
        slicer_array(image)
        slicer_raster(dataset)

        # Assert
        assert len(array_tiles) == len(raster_tiles) > 0
        for array_tile, raster_tile in zip(array_tiles, raster_tiles):
            assert np.array_equal(array_tile, raster_tile)

    def test_windowed_raster_preserves_band_dtype(
        self, make_raster_dataset: Callable
    ) -> None:
        """Tiles read from a dataset keep the source dtype (e.g. uint16)."""
        # Arrange
        rng = np.random.default_rng(5)
        image = rng.integers(0, 4000, size=(128, 128, 3), dtype=np.uint16)
        dataset = make_raster_dataset(image)

        seen: list[np.ndarray] = []

        def callback(tile: np.ndarray) -> Detections:
            seen.append(tile)
            return Detections.empty()

        slicer = InferenceSlicer(callback=callback, slice_wh=64, overlap_wh=0)

        # Act
        slicer(dataset)

        # Assert
        assert seen
        assert all(tile.dtype == np.uint16 for tile in seen)

    @pytest.mark.parametrize(
        "crs",
        [
            pytest.param(None, id="no-crs"),
            pytest.param(_FakeCRS(is_projected=True), id="projected-crs"),
        ],
    )
    def test_crs_allows_slicing(
        self,
        make_raster_dataset: Callable,
        fixed_detection_callback: Callable,
        crs: object | None,
    ) -> None:
        """None CRS and projected CRS both allow slicing without error."""
        image = np.zeros((128, 128, 3), dtype=np.uint8)
        dataset = make_raster_dataset(image, crs=crs)
        slicer = InferenceSlicer(
            callback=fixed_detection_callback,
            slice_wh=64,
            overlap_wh=0,
        )

        detections = slicer(dataset)

        assert len(detections) == 4

    def test_geographic_crs_raises(
        self,
        make_raster_dataset: Callable,
        fixed_detection_callback: Callable,
    ) -> None:
        """Dataset with a geographic (non-projected) CRS raises ValueError."""
        image = np.zeros((128, 128, 3), dtype=np.uint8)
        dataset = make_raster_dataset(image, crs=_FakeCRS(is_projected=False))
        slicer = InferenceSlicer(
            callback=fixed_detection_callback,
            slice_wh=64,
            overlap_wh=0,
        )

        with pytest.raises(ValueError, match="projected coordinate reference"):
            slicer(dataset)

    def test_single_band_raster_produces_hwc1_tiles(
        self, make_raster_dataset: Callable
    ) -> None:
        """Single-band raster tiles arrive at the callback as (H, W, 1) arrays."""
        image = np.zeros((128, 128, 1), dtype=np.uint8)
        dataset = make_raster_dataset(image)

        seen: list[np.ndarray] = []

        def callback(tile: np.ndarray) -> Detections:
            seen.append(tile)
            return Detections.empty()

        slicer = InferenceSlicer(callback=callback, slice_wh=64, overlap_wh=0)

        slicer(dataset)

        assert seen
        assert all(tile.ndim == 3 and tile.shape[2] == 1 for tile in seen)

    def test_raster_smaller_than_slice_produces_single_tile(
        self, make_raster_dataset: Callable
    ) -> None:
        """Raster smaller than slice_wh is processed as exactly one tile."""
        image = np.zeros((48, 64, 3), dtype=np.uint8)
        dataset = make_raster_dataset(image)

        tile_count: list[int] = [0]

        def callback(tile: np.ndarray) -> Detections:
            tile_count[0] += 1
            return Detections.empty()

        slicer = InferenceSlicer(callback=callback, slice_wh=128, overlap_wh=0)

        slicer(dataset)

        assert tile_count[0] == 1

    def test_compact_masks_with_windowed_raster(
        self, make_raster_dataset: Callable
    ) -> None:
        """compact_masks=True correctly moves and compresses masks from raster tiles."""
        rng = np.random.default_rng(17)
        image = rng.integers(0, 255, size=(128, 128, 3), dtype=np.uint8)
        dataset = make_raster_dataset(image)

        def masked_callback(tile: np.ndarray) -> Detections:
            h, w = tile.shape[:2]
            mask = np.zeros((1, h, w), dtype=bool)
            mask[0, : h // 2, : w // 2] = True
            return Detections(
                xyxy=np.array([[0, 0, w // 2, h // 2]], dtype=float),
                confidence=np.array([0.9]),
                class_id=np.array([0]),
                mask=mask,
            )

        slicer = InferenceSlicer(
            callback=masked_callback,
            slice_wh=64,
            overlap_wh=0,
            compact_masks=True,
        )

        detections = slicer(dataset)

        assert len(detections) > 0

    def test_thread_workers_with_raster_serializes_reads(
        self, fixed_detection_callback: Callable
    ) -> None:
        """Raster reads are serialized even when thread_workers > 1."""
        rng = np.random.default_rng(3)
        image = rng.integers(0, 255, size=(256, 256, 3), dtype=np.uint8)
        dataset = _ConcurrencyCheckDataset(image)

        slicer = InferenceSlicer(
            callback=fixed_detection_callback,
            slice_wh=64,
            overlap_wh=0,
            thread_workers=4,
        )

        slicer(dataset)

        assert dataset.peak_concurrent == 1

    def test_real_rasterio_memoryfile_integration(
        self, fixed_detection_callback: Callable
    ) -> None:
        """Real rasterio MemoryFile produces same detections as the array path."""
        pytest.importorskip("rasterio")
        from rasterio.io import MemoryFile

        # Arrange
        rng = np.random.default_rng(123)
        image = rng.integers(0, 255, size=(128, 128, 3), dtype=np.uint8)
        bands = np.transpose(image, (2, 0, 1))  # (C, H, W)

        slicer = InferenceSlicer(
            callback=fixed_detection_callback,
            slice_wh=64,
            overlap_wh=0,
        )
        detections_array = slicer(image)

        profile = {
            "driver": "GTiff",
            "height": image.shape[0],
            "width": image.shape[1],
            "count": image.shape[2],
            "dtype": image.dtype,
        }

        # Act
        with MemoryFile() as memfile:
            with memfile.open(**profile) as dst:
                dst.write(bands)
            with memfile.open() as dataset:
                detections_raster = slicer(dataset)

        # Assert
        assert np.array_equal(_sortable(detections_array), _sortable(detections_raster))
