"""
test_blur_detector.py — Unit tests for blur detection helpers (no ROS2 required).

Covers:
  BlurResult:
    - fields accessible by name
    - is_blurred True when variance < threshold
    - is_blurred False when variance >= threshold

  laplacian_variance:
    - sharp synthetic image has higher variance than blurred version
    - solid (zero-texture) image returns near-zero variance
    - greyscale input accepted without error
    - ROI crop returns variance of cropped region only
    - empty ROI returns 0.0
    - returns float
    - variance is non-negative

  detect_blur — output contract:
    - returns BlurResult
    - threshold stored in result matches input
    - variance matches laplacian_variance independently computed
    - is_blurred consistent with variance vs threshold

  detect_blur — sharp vs blurred:
    - sharp checkerboard → not blurred at default threshold
    - heavily Gaussian-blurred image → blurred at default threshold
    - sharp image variance > blurred image variance
    - very low threshold → sharp image reported blurred
    - very high threshold → blurred image still reported blurred

  detect_blur — ROI:
    - sharp ROI on blurred image → ROI variant has higher variance
    - blurred ROI on sharp image → ROI variant has lower variance
    - roi_frac=0 equivalent to no ROI
"""

import sys
import os

import numpy as np
import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from saltybot_bringup._blur_detector import (
    BlurResult,
    laplacian_variance,
    detect_blur,
)


# ── Image factories ───────────────────────────────────────────────────────────

def _checkerboard(h=64, w=64, tile=8) -> np.ndarray:
    """High-frequency checkerboard — very sharp, high Laplacian variance."""
    img = np.zeros((h, w), dtype=np.uint8)
    for r in range(h):
        for c in range(w):
            if ((r // tile) + (c // tile)) % 2 == 0:
                img[r, c] = 255
    bgr = np.stack([img, img, img], axis=-1)
    return bgr


def _blurred(bgr, ksize=21) -> np.ndarray:
    """Gaussian-blur a BGR image."""
    import cv2
    k = ksize if ksize % 2 == 1 else ksize + 1
    return cv2.GaussianBlur(bgr, (k, k), 0)


def _solid(val=128, h=64, w=64) -> np.ndarray:
    """Solid grey BGR image — zero texture."""
    return np.full((h, w, 3), val, dtype=np.uint8)


def _gradient(h=64, w=64) -> np.ndarray:
    """Horizontal linear gradient — mild texture."""
    row = np.linspace(0, 255, w, dtype=np.uint8)
    img = np.tile(row, (h, 1))
    return np.stack([img, img, img], axis=-1)


# ── BlurResult ────────────────────────────────────────────────────────────────

class TestBlurResult:

    def test_fields_accessible(self):
        r = BlurResult(variance=50.0, is_blurred=True, threshold=100.0)
        assert r.variance == pytest.approx(50.0)
        assert r.is_blurred is True
        assert r.threshold == pytest.approx(100.0)

    def test_is_blurred_true_when_variance_below_threshold(self):
        r = BlurResult(variance=30.0, is_blurred=30.0 < 100.0, threshold=100.0)
        assert r.is_blurred is True

    def test_is_blurred_false_when_variance_above_threshold(self):
        r = BlurResult(variance=200.0, is_blurred=200.0 < 100.0, threshold=100.0)
        assert r.is_blurred is False


# ── laplacian_variance ────────────────────────────────────────────────────────

class TestLaplacianVariance:

    def test_returns_float(self):
        v = laplacian_variance(_checkerboard())
        assert isinstance(v, float)

    def test_variance_non_negative(self):
        for img in [_checkerboard(), _solid(), _blurred(_checkerboard())]:
            assert laplacian_variance(img) >= 0.0

    def test_sharp_higher_than_blurred(self):
        sharp = _checkerboard()
        blurred = _blurred(sharp, ksize=21)
        assert laplacian_variance(sharp) > laplacian_variance(blurred)

    def test_solid_image_near_zero(self):
        v = laplacian_variance(_solid(128))
        assert v < 1.0, f'solid image variance should be near 0, got {v}'

    def test_greyscale_input_accepted(self):
        grey = np.zeros((32, 32), dtype=np.uint8)
        grey[::4, ::4] = 255
        v = laplacian_variance(grey)
        assert isinstance(v, float)
        assert v >= 0.0

    def test_roi_returns_crop_variance(self):
        """Left half: solid black. Right half: checkerboard.
        ROI over right half should have high variance."""
        h, w = 64, 64
        img = np.zeros((h, w, 3), dtype=np.uint8)
        cb = _checkerboard(h, w // 2, tile=4)
        img[:, w // 2:] = cb
        var_full = laplacian_variance(img)
        var_roi  = laplacian_variance(img, roi=(0, h, w // 2, w))
        assert var_roi > var_full, (
            f'ROI over checkerboard side should have higher variance: '
            f'roi={var_roi:.1f} full={var_full:.1f}'
        )

    def test_empty_roi_returns_zero(self):
        img = _checkerboard()
        v = laplacian_variance(img, roi=(10, 10, 5, 5))  # zero-size crop
        assert v == pytest.approx(0.0)

    def test_checkerboard_has_high_variance(self):
        v = laplacian_variance(_checkerboard(64, 64, tile=4))
        assert v > 500.0, f'checkerboard should have high variance, got {v:.1f}'


# ── detect_blur — output contract ─────────────────────────────────────────────

class TestDetectBlurContract:

    def test_returns_blur_result(self):
        r = detect_blur(_checkerboard())
        assert isinstance(r, BlurResult)

    def test_threshold_stored_in_result(self):
        r = detect_blur(_checkerboard(), threshold=77.5)
        assert r.threshold == pytest.approx(77.5)

    def test_variance_matches_independent_computation(self):
        img = _checkerboard()
        r = detect_blur(img, threshold=100.0)
        expected = laplacian_variance(img)
        assert r.variance == pytest.approx(expected, rel=1e-5)

    def test_is_blurred_consistent_with_variance(self):
        img = _checkerboard()
        r = detect_blur(img, threshold=100.0)
        assert r.is_blurred == (r.variance < r.threshold)


# ── detect_blur — sharp vs blurred ────────────────────────────────────────────

class TestDetectBlurSharpVsBlurred:

    def test_checkerboard_not_blurred_at_default_threshold(self):
        r = detect_blur(_checkerboard(), threshold=100.0)
        assert not r.is_blurred, (
            f'sharp checkerboard should not be blurred, variance={r.variance:.1f}')

    def test_heavily_blurred_image_is_blurred(self):
        blurred = _blurred(_checkerboard(), ksize=31)
        r = detect_blur(blurred, threshold=100.0)
        assert r.is_blurred, (
            f'heavily blurred image should be flagged, variance={r.variance:.1f}')

    def test_sharp_variance_greater_than_blurred(self):
        sharp   = _checkerboard()
        blurred = _blurred(sharp, ksize=21)
        r_sharp   = detect_blur(sharp)
        r_blurred = detect_blur(blurred)
        assert r_sharp.variance > r_blurred.variance

    def test_very_high_threshold_flags_sharp_as_blurred(self):
        """If threshold is set above any realistic variance, even sharp images are flagged."""
        r = detect_blur(_checkerboard(), threshold=1e9)
        assert r.is_blurred

    def test_very_high_threshold_flags_blurred_as_blurred(self):
        blurred = _blurred(_checkerboard(), ksize=31)
        r = detect_blur(blurred, threshold=1e9)
        assert r.is_blurred

    def test_solid_image_is_blurred_at_default_threshold(self):
        r = detect_blur(_solid(128), threshold=100.0)
        assert r.is_blurred, (
            f'featureless solid should be flagged, variance={r.variance:.1f}')

    def test_gradient_variance_positive(self):
        """A linear gradient has some edges → variance > 0."""
        r = detect_blur(_gradient())
        assert r.variance > 0.0


# ── detect_blur — ROI ────────────────────────────────────────────────────────

class TestDetectBlurROI:

    def test_sharp_roi_on_blurred_image_has_higher_variance(self):
        """Create an image where one half is sharp and the other blurred."""
        h, w = 64, 128
        sharp_half   = _checkerboard(h, w // 2, tile=4)
        blurred_half = _blurred(sharp_half, ksize=21)
        img = np.concatenate([sharp_half, blurred_half], axis=1)

        var_sharp_roi   = laplacian_variance(img, roi=(0, h, 0, w // 2))
        var_blurred_roi = laplacian_variance(img, roi=(0, h, w // 2, w))
        assert var_sharp_roi > var_blurred_roi, (
            f'sharp ROI ({var_sharp_roi:.1f}) should exceed blurred ROI ({var_blurred_roi:.1f})'
        )

    def test_detect_blur_roi_param_passed_through(self):
        """detect_blur with roi gives same variance as laplacian_variance with same roi."""
        img = _checkerboard()
        roi = (0, 32, 0, 32)
        r   = detect_blur(img, threshold=100.0, roi=roi)
        expected = laplacian_variance(img, roi=roi)
        assert r.variance == pytest.approx(expected, rel=1e-5)

    def test_no_roi_uses_full_frame(self):
        img = _checkerboard()
        r_no_roi   = detect_blur(img, threshold=100.0)
        r_full_roi = detect_blur(img, threshold=100.0,
                                 roi=(0, img.shape[0], 0, img.shape[1]))
        assert r_no_roi.variance == pytest.approx(r_full_roi.variance, rel=1e-5)


if __name__ == '__main__':
    pytest.main([__file__, '-v'])
