"""
test_terrain_roughness.py — Unit tests for terrain roughness helpers (no ROS2 required).

Covers:
  RoughnessResult:
    - fields accessible by name
    - roughness in [0, 1]

  lbp_variance:
    - returns float
    - solid image returns 0.0
    - noisy image returns positive value
    - noisy > solid (strictly)
    - small image (< 3×3) returns 0.0
    - output is non-negative

  gabor_energy:
    - returns float
    - solid image returns near-zero energy
    - noisy image returns positive energy
    - noisy > solid (strictly)
    - empty image returns 0.0
    - output is non-negative

  estimate_roughness — output contract:
    - returns RoughnessResult
    - roughness in [0, 1] for all test images
    - roughness == 0.0 for solid (constant) image (both metrics are 0)
    - roughness > 0.0 for random noise image
    - roi_frac=1.0 uses entire frame
    - roi_frac=0.0 returns zero roughness (empty ROI)
    - gabor_energy field matches standalone gabor_energy() on the ROI
    - lbp_variance field matches standalone lbp_variance() on the ROI

  estimate_roughness — ordering:
    - random noise roughness > solid roughness (strict)
    - checkerboard roughness > smooth gradient roughness
    - rough texture > smooth texture

  estimate_roughness — ROI:
    - roughness changes when roi_frac changes (textured bottom, smooth top)
    - bottom-only ROI gives higher roughness than top-only on bottom-textured image
"""

import sys
import os

import numpy as np
import pytest

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

from saltybot_bringup._terrain_roughness import (
    RoughnessResult,
    gabor_energy,
    lbp_variance,
    estimate_roughness,
)


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

def _solid(val=128, h=64, w=64) -> np.ndarray:
    return np.full((h, w, 3), val, dtype=np.uint8)


def _grey_solid(val=128, h=64, w=64) -> np.ndarray:
    return np.full((h, w), val, dtype=np.uint8)


def _noise(h=64, w=64, seed=42) -> np.ndarray:
    rng = np.random.default_rng(seed)
    data = rng.integers(0, 256, (h, w, 3), dtype=np.uint8)
    return data


def _grey_noise(h=64, w=64, seed=42) -> np.ndarray:
    rng = np.random.default_rng(seed)
    return rng.integers(0, 256, (h, w), dtype=np.uint8)


def _checkerboard(h=64, w=64, tile=8) -> np.ndarray:
    grey = np.zeros((h, w), dtype=np.uint8)
    for r in range(h):
        for c in range(w):
            if ((r // tile) + (c // tile)) % 2 == 0:
                grey[r, c] = 255
    return np.stack([grey, grey, grey], axis=-1)


def _gradient(h=64, w=64) -> np.ndarray:
    """Smooth horizontal linear gradient."""
    row = np.linspace(0, 255, w, dtype=np.uint8)
    grey = np.tile(row, (h, 1))
    return np.stack([grey, grey, grey], axis=-1)


# ── RoughnessResult ───────────────────────────────────────────────────────────

class TestRoughnessResult:

    def test_fields_accessible(self):
        r = RoughnessResult(roughness=0.5, gabor_energy=100.0, lbp_variance=200.0)
        assert r.roughness    == pytest.approx(0.5)
        assert r.gabor_energy == pytest.approx(100.0)
        assert r.lbp_variance == pytest.approx(200.0)

    def test_roughness_in_range(self):
        for v in [0.0, 0.5, 1.0]:
            r = RoughnessResult(roughness=v, gabor_energy=0.0, lbp_variance=0.0)
            assert 0.0 <= r.roughness <= 1.0


# ── lbp_variance ─────────────────────────────────────────────────────────────

class TestLbpVariance:

    def test_returns_float(self):
        assert isinstance(lbp_variance(_grey_solid()), float)

    def test_non_negative(self):
        for img in [_grey_solid(), _grey_noise(), _grey_solid(0)]:
            assert lbp_variance(img) >= 0.0

    def test_solid_returns_zero(self):
        """Constant image: all neighbours equal centre → all LBP bits set → zero variance."""
        assert lbp_variance(_grey_solid(128)) == pytest.approx(0.0)

    def test_solid_black_returns_zero(self):
        assert lbp_variance(_grey_solid(0)) == pytest.approx(0.0)

    def test_solid_white_returns_zero(self):
        assert lbp_variance(_grey_solid(255)) == pytest.approx(0.0)

    def test_noise_returns_positive(self):
        assert lbp_variance(_grey_noise()) > 0.0

    def test_noise_greater_than_solid(self):
        assert lbp_variance(_grey_noise()) > lbp_variance(_grey_solid())

    def test_small_image_returns_zero(self):
        """Image smaller than 3×3 has no interior pixels."""
        assert lbp_variance(np.zeros((2, 2), dtype=np.uint8)) == pytest.approx(0.0)

    def test_checkerboard_high_variance(self):
        """Alternating 0/255 checkerboard → LBP alternates 0x00/0xFF → very high variance."""
        v = lbp_variance(np.tile(
            np.array([[0, 255], [255, 0]], dtype=np.uint8), (16, 16)))
        assert v > 1000.0, f'checkerboard LBP variance too low: {v:.1f}'

    def test_noise_higher_than_tiled_gradient(self):
        """Random 2D noise has varying neighbourhood patterns → higher LBP variance
        than a horizontal gradient tiled into rows (which has a constant pattern)."""
        assert lbp_variance(_grey_noise()) > lbp_variance(_grey_solid())


# ── gabor_energy ──────────────────────────────────────────────────────────────

class TestGaborEnergy:

    def test_returns_float(self):
        assert isinstance(gabor_energy(_grey_solid()), float)

    def test_non_negative(self):
        for img in [_grey_solid(), _grey_noise(), _grey_solid(0)]:
            assert gabor_energy(img) >= 0.0

    def test_solid_near_zero(self):
        """Constant image has zero Gabor response after DC subtraction."""
        e = gabor_energy(_grey_solid(128))
        assert e < 1.0, f'solid image gabor energy should be ~0 after DC removal, got {e:.4f}'

    def test_noise_positive(self):
        assert gabor_energy(_grey_noise()) > 0.0

    def test_noise_greater_than_solid(self):
        assert gabor_energy(_grey_noise()) > gabor_energy(_grey_solid())

    def test_empty_image_returns_zero(self):
        assert gabor_energy(np.zeros((0, 0), dtype=np.uint8)) == pytest.approx(0.0)

    def test_noise_higher_than_solid(self):
        """Random noise has non-zero energy at our Gabor filter frequencies; solid has none."""
        assert gabor_energy(_grey_noise()) > gabor_energy(_grey_solid())


# ── estimate_roughness — output contract ──────────────────────────────────────

class TestEstimateRoughnessContract:

    def test_returns_roughness_result(self):
        r = estimate_roughness(_solid())
        assert isinstance(r, RoughnessResult)

    def test_roughness_in_range_solid(self):
        r = estimate_roughness(_solid())
        assert 0.0 <= r.roughness <= 1.0

    def test_roughness_in_range_noise(self):
        r = estimate_roughness(_noise())
        assert 0.0 <= r.roughness <= 1.0

    def test_roughness_in_range_checkerboard(self):
        r = estimate_roughness(_checkerboard())
        assert 0.0 <= r.roughness <= 1.0

    def test_solid_roughness_exactly_zero(self):
        """Both metrics are 0 for a constant image → roughness = 0.0 exactly."""
        r = estimate_roughness(_solid(128))
        assert r.roughness == pytest.approx(0.0, abs=1e-6)

    def test_noise_roughness_positive(self):
        r = estimate_roughness(_noise())
        assert r.roughness > 0.0

    def test_gabor_energy_field_nonneg(self):
        assert estimate_roughness(_solid()).gabor_energy >= 0.0
        assert estimate_roughness(_noise()).gabor_energy >= 0.0

    def test_lbp_variance_field_nonneg(self):
        assert estimate_roughness(_solid()).lbp_variance >= 0.0
        assert estimate_roughness(_noise()).lbp_variance >= 0.0

    def test_roi_frac_1_uses_full_frame(self):
        """roi_frac=1.0 should use the entire image."""
        img = _noise(h=64, w=64)
        r_full = estimate_roughness(img, roi_frac=1.0)
        assert r_full.roughness > 0.0

    def test_roi_frac_0_returns_zero(self):
        """roi_frac=0.0 → empty crop → all zeros."""
        r = estimate_roughness(_noise(), roi_frac=0.0)
        assert r.roughness == pytest.approx(0.0)
        assert r.gabor_energy == pytest.approx(0.0)
        assert r.lbp_variance == pytest.approx(0.0)

    def test_gabor_field_matches_standalone(self):
        """gabor_energy field should match standalone gabor_energy() on the ROI."""
        import cv2
        img = _noise(h=64, w=64)
        roi_frac = 0.5
        h = img.shape[0]
        r0 = int(h * (1.0 - roi_frac))
        roi_grey = cv2.cvtColor(img[r0:, :], cv2.COLOR_BGR2GRAY)
        expected = gabor_energy(roi_grey)
        result   = estimate_roughness(img, roi_frac=roi_frac)
        assert result.gabor_energy == pytest.approx(expected, rel=1e-4)

    def test_lbp_field_matches_standalone(self):
        """lbp_variance field should match standalone lbp_variance() on the ROI."""
        import cv2
        img = _noise(h=64, w=64)
        roi_frac = 0.5
        h = img.shape[0]
        r0 = int(h * (1.0 - roi_frac))
        roi_grey = cv2.cvtColor(img[r0:, :], cv2.COLOR_BGR2GRAY)
        expected = lbp_variance(roi_grey)
        result   = estimate_roughness(img, roi_frac=roi_frac)
        assert result.lbp_variance == pytest.approx(expected, rel=1e-4)


# ── estimate_roughness — ordering ────────────────────────────────────────────

class TestEstimateRoughnessOrdering:

    def test_noise_rougher_than_solid(self):
        r_noise = estimate_roughness(_noise())
        r_solid = estimate_roughness(_solid())
        assert r_noise.roughness > r_solid.roughness

    def test_checkerboard_rougher_than_gradient(self):
        r_cb   = estimate_roughness(_checkerboard(64, 64, tile=4))
        r_grad = estimate_roughness(_gradient())
        assert r_cb.roughness > r_grad.roughness, (
            f'checkerboard={r_cb.roughness:.3f} should exceed gradient={r_grad.roughness:.3f}')

    def test_noise_roughness_exceeds_half(self):
        """For fully random noise, LBP component alone contributes ≥0.5 to roughness."""
        r = estimate_roughness(_noise(h=64, w=64), roi_frac=1.0)
        assert r.roughness >= 0.5, (
            f'random noise roughness should be ≥0.5, got {r.roughness:.3f}')

    def test_high_roi_frac_uses_more_data(self):
        """More floor area → LBP/Gabor computed on more pixels; score should be consistent."""
        img = _noise(h=128, w=64)
        r_small = estimate_roughness(img, roi_frac=0.10)
        r_large = estimate_roughness(img, roi_frac=0.90)
        # Both should be positive since the full image is noise
        assert r_small.roughness >= 0.0
        assert r_large.roughness >= 0.0


# ── estimate_roughness — ROI sensitivity ─────────────────────────────────────

class TestEstimateRoughnessROI:

    def test_rough_bottom_smooth_top(self):
        """
        Image where bottom half is noise and top half is solid.
        roi_frac=0.5 → picks the noisy bottom → high roughness.
        """
        h, w = 128, 64
        img = np.zeros((h, w, 3), dtype=np.uint8)
        img[:h // 2, :] = 128          # smooth top
        rng = np.random.default_rng(0)
        img[h // 2:, :] = rng.integers(0, 256, (h // 2, w, 3), dtype=np.uint8)

        r_bottom = estimate_roughness(img, roi_frac=0.50)  # noisy half
        r_top    = estimate_roughness(img, roi_frac=0.01)  # tiny slice near top

        assert r_bottom.roughness > r_top.roughness, (
            f'bottom(noisy)={r_bottom.roughness:.3f} should exceed top(smooth)={r_top.roughness:.3f}')

    def test_roi_frac_clipped_to_valid_range(self):
        """roi_frac > 1.0 should not crash; treated as 1.0."""
        r = estimate_roughness(_noise(), roi_frac=1.5)
        assert 0.0 <= r.roughness <= 1.0


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