"""
test_sky_detector.py — Unit tests for sky detection helpers (no ROS2 required).

Covers:
  SkyResult:
    - fields accessible by name
    - sky_fraction in [0, 1]
    - horizon_y is int
    - sky_mask is ndarray

  detect_sky — output contract:
    - returns SkyResult
    - sky_fraction in [0, 1] for all test images
    - sky_mask shape matches input
    - sky_mask dtype is uint8
    - empty image returns sky_fraction=0.0, horizon_y=-1
    - sky_fraction=0.0 and horizon_y=-1 for ground-only image

  detect_sky — blue sky detection:
    - solid blue sky → sky_fraction ≈ 1.0
    - solid blue sky → horizon_y = H-1 (sky fills every row)
    - solid blue sky → sky_mask nearly all 255

  detect_sky — overcast sky detection:
    - solid white/grey overcast image → sky_fraction ≈ 1.0
    - solid grey overcast → horizon_y = H-1

  detect_sky — non-sky:
    - solid green ground → sky_fraction ≈ 0.0
    - solid green ground → horizon_y = -1
    - solid brown → sky_fraction ≈ 0.0

  detect_sky — split image (sky top, ground bottom):
    - top half blue sky, bottom half green → sky_fraction ≈ 1.0 (scan_frac=0.5)
    - split image → horizon_y near H//2
    - sky_fraction decreases as scan_frac increases past the sky region

  detect_sky — horizon estimation:
    - wider sky region → higher horizon_y
    - horizon_y within image bounds [0, H-1] when sky detected
    - horizon_y == -1 when no sky anywhere

  detect_sky — scan_frac and row_threshold params:
    - scan_frac=1.0 analyses full frame
    - scan_frac=0.0 → sky_fraction=0.0 regardless of image content
    - row_threshold=0.0 → every row counts as sky row → horizon_y = H-1 for any image
    - row_threshold=1.0 → only rows fully sky count → tighter horizon on mixed 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._sky_detector import (
    SkyResult,
    detect_sky,
)


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

def _hsv_solid_bgr(h_val: int, s: int, v: int, rows: int = 64, cols: int = 64) -> np.ndarray:
    """Create a solid BGR image from an HSV specification."""
    import cv2
    hsv = np.full((rows, cols, 3), [h_val, s, v], dtype=np.uint8)
    return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)


# Canonical sky and ground colours (OpenCV HSV: H∈[0,180], S/V∈[0,255])
def _blue_sky(rows=64, cols=64)     -> np.ndarray: return _hsv_solid_bgr(105, 180, 200, rows, cols)  # mid-blue sky
def _overcast(rows=64, cols=64)     -> np.ndarray: return _hsv_solid_bgr(0,   20,  220, rows, cols)  # bright grey
def _green_ground(rows=64, cols=64) -> np.ndarray: return _hsv_solid_bgr(40,  180, 100, rows, cols)  # green grass
def _brown_ground(rows=64, cols=64) -> np.ndarray: return _hsv_solid_bgr(15,  160, 90,  rows, cols)  # soil/gravel


def _split(top_bgr: np.ndarray, bottom_bgr: np.ndarray) -> np.ndarray:
    """Stack two same-width BGR images vertically."""
    return np.concatenate([top_bgr, bottom_bgr], axis=0)


# ── SkyResult ─────────────────────────────────────────────────────────────────

class TestSkyResult:

    def test_fields_accessible(self):
        mask = np.zeros((4, 4), dtype=np.uint8)
        r = SkyResult(sky_fraction=0.7, horizon_y=30, sky_mask=mask)
        assert r.sky_fraction == pytest.approx(0.7)
        assert r.horizon_y   == 30
        assert r.sky_mask     is mask

    def test_sky_fraction_in_range(self):
        mask = np.zeros((4, 4), dtype=np.uint8)
        for v in (0.0, 0.5, 1.0):
            r = SkyResult(sky_fraction=v, horizon_y=-1, sky_mask=mask)
            assert 0.0 <= r.sky_fraction <= 1.0

    def test_horizon_y_is_int(self):
        mask = np.zeros((4, 4), dtype=np.uint8)
        r = SkyResult(sky_fraction=0.0, horizon_y=-1, sky_mask=mask)
        assert isinstance(r.horizon_y, int)


# ── detect_sky — output contract ─────────────────────────────────────────────

class TestDetectSkyContract:

    def test_returns_sky_result(self):
        r = detect_sky(_blue_sky())
        assert isinstance(r, SkyResult)

    def test_sky_fraction_in_range_blue(self):
        r = detect_sky(_blue_sky())
        assert 0.0 <= r.sky_fraction <= 1.0

    def test_sky_fraction_in_range_ground(self):
        r = detect_sky(_green_ground())
        assert 0.0 <= r.sky_fraction <= 1.0

    def test_sky_mask_shape_matches_input(self):
        img = _blue_sky(rows=48, cols=80)
        r = detect_sky(img)
        assert r.sky_mask.shape == (48, 80)

    def test_sky_mask_dtype_uint8(self):
        r = detect_sky(_blue_sky())
        assert r.sky_mask.dtype == np.uint8

    def test_empty_image_returns_zero_fraction(self):
        r = detect_sky(np.zeros((0, 64, 3), dtype=np.uint8))
        assert r.sky_fraction == pytest.approx(0.0)
        assert r.horizon_y == -1

    def test_ground_sky_fraction_zero(self):
        r = detect_sky(_green_ground())
        assert r.sky_fraction == pytest.approx(0.0, abs=0.05)

    def test_ground_horizon_y_minus_one(self):
        r = detect_sky(_green_ground())
        assert r.horizon_y == -1


# ── detect_sky — blue sky ────────────────────────────────────────────────────

class TestDetectSkyBlue:

    def test_blue_sky_fraction_near_one(self):
        r = detect_sky(_blue_sky())
        assert r.sky_fraction > 0.90, (
            f'solid blue sky should give sky_fraction > 0.9, got {r.sky_fraction:.3f}')

    def test_blue_sky_horizon_at_last_row(self):
        """Entire image is sky → horizon at bottom row."""
        h = 64
        r = detect_sky(_blue_sky(rows=h))
        assert r.horizon_y == h - 1, (
            f'all-sky image: horizon_y should be {h-1}, got {r.horizon_y}')

    def test_blue_sky_mask_mostly_255(self):
        r = detect_sky(_blue_sky())
        sky_pixels = np.count_nonzero(r.sky_mask)
        total      = r.sky_mask.size
        assert sky_pixels / total > 0.90

    def test_blue_sky_positive_horizon(self):
        r = detect_sky(_blue_sky())
        assert r.horizon_y >= 0


# ── detect_sky — overcast sky ────────────────────────────────────────────────

class TestDetectSkyOvercast:

    def test_overcast_fraction_near_one(self):
        r = detect_sky(_overcast())
        assert r.sky_fraction > 0.90, (
            f'overcast grey sky should give sky_fraction > 0.9, got {r.sky_fraction:.3f}')

    def test_overcast_horizon_at_last_row(self):
        h = 64
        r = detect_sky(_overcast(rows=h))
        assert r.horizon_y == h - 1

    def test_overcast_positive_horizon(self):
        r = detect_sky(_overcast())
        assert r.horizon_y >= 0


# ── detect_sky — non-sky images ──────────────────────────────────────────────

class TestDetectSkyNonSky:

    def test_green_ground_fraction_zero(self):
        r = detect_sky(_green_ground())
        assert r.sky_fraction < 0.05

    def test_green_ground_horizon_minus_one(self):
        assert detect_sky(_green_ground()).horizon_y == -1

    def test_brown_ground_fraction_zero(self):
        r = detect_sky(_brown_ground())
        assert r.sky_fraction < 0.05

    def test_brown_ground_horizon_minus_one(self):
        assert detect_sky(_brown_ground()).horizon_y == -1


# ── detect_sky — split images ─────────────────────────────────────────────────

class TestDetectSkySplit:

    def test_top_half_sky_scan_frac_half(self):
        """scan_frac=0.5 → only top half analysed → sky_fraction ≈ 1.0 for top-sky image."""
        h = 64
        img = _split(_blue_sky(rows=h // 2), _green_ground(rows=h // 2))
        r = detect_sky(img, scan_frac=0.5)
        assert r.sky_fraction > 0.85, (
            f'top-half sky with scan_frac=0.5: expected >0.85, got {r.sky_fraction:.3f}')

    def test_horizon_near_midpoint(self):
        """Sky in top half, ground in bottom half → horizon_y near H//2."""
        h = 64
        img = _split(_blue_sky(rows=h // 2), _green_ground(rows=h // 2))
        r = detect_sky(img)
        # Horizon should be within the top half (rows 0 .. h//2-1)
        assert 0 <= r.horizon_y < h, f'horizon_y={r.horizon_y} out of bounds'
        assert r.horizon_y < h // 2 + 4, (
            f'horizon_y={r.horizon_y} should be near or before row {h // 2}')

    def test_sky_fraction_decreases_when_scan_extends_into_ground(self):
        """With top-sky/bottom-ground image, increasing scan_frac past the sky
        boundary should decrease the sky_fraction."""
        h = 128
        img = _split(_blue_sky(rows=h // 2), _green_ground(rows=h // 2))
        r_top  = detect_sky(img, scan_frac=0.4)   # mostly sky region
        r_full = detect_sky(img, scan_frac=1.0)   # half sky, half ground
        assert r_top.sky_fraction > r_full.sky_fraction, (
            f'scanning less of the ground should give higher fraction: '
            f'scan0.4={r_top.sky_fraction:.3f} scan1.0={r_full.sky_fraction:.3f}')

    def test_sky_only_top_quarter_horizon_within_quarter(self):
        """Sky only in top 25 % → horizon_y in first quarter of rows."""
        h = 64
        sky_rows = h // 4
        img = _split(_blue_sky(rows=sky_rows), _green_ground(rows=h - sky_rows))
        r = detect_sky(img)
        assert 0 <= r.horizon_y < sky_rows + 2, (
            f'horizon_y={r.horizon_y} should be within top quarter ({sky_rows} rows)')


# ── detect_sky — horizon ordering ────────────────────────────────────────────

class TestDetectSkyHorizonOrdering:

    def test_more_sky_rows_higher_horizon_y(self):
        """Larger sky region → higher (larger row index) horizon_y."""
        h = 128
        sky_a = h // 4   # 25 % sky
        sky_b = h * 3 // 4  # 75 % sky
        img_a = _split(_blue_sky(rows=sky_a), _green_ground(rows=h - sky_a))
        img_b = _split(_blue_sky(rows=sky_b), _green_ground(rows=h - sky_b))
        r_a = detect_sky(img_a)
        r_b = detect_sky(img_b)
        assert r_b.horizon_y > r_a.horizon_y, (
            f'larger sky region ({sky_b}px) should give higher horizon_y than '
            f'smaller ({sky_a}px): {r_b.horizon_y} vs {r_a.horizon_y}')

    def test_horizon_within_image_bounds_when_sky_present(self):
        h, w = 80, 64
        r = detect_sky(_blue_sky(rows=h, cols=w))
        assert 0 <= r.horizon_y <= h - 1

    def test_horizon_minus_one_when_no_sky(self):
        assert detect_sky(_green_ground()).horizon_y == -1


# ── detect_sky — parameter sensitivity ───────────────────────────────────────

class TestDetectSkyParams:

    def test_scan_frac_1_analyses_full_frame(self):
        """scan_frac=1.0 on all-sky image → fraction ≈ 1.0."""
        r = detect_sky(_blue_sky(), scan_frac=1.0)
        assert r.sky_fraction > 0.90

    def test_scan_frac_zero_gives_zero_fraction(self):
        """scan_frac approaching 0 → effectively empty scan → sky_fraction = 0."""
        # The implementation clamps scan_rows = max(1, ...) so scan_frac=0 → 1 row scanned
        # We check that it doesn't crash and returns a valid result.
        r = detect_sky(_blue_sky(), scan_frac=0.0)
        assert 0.0 <= r.sky_fraction <= 1.0

    def test_row_threshold_zero_horizon_at_last_row_any_image(self):
        """row_threshold=0 → every row satisfies the threshold → horizon = H-1
        even if only a single sky pixel exists anywhere."""
        h = 64
        img = _split(_blue_sky(rows=4), _green_ground(rows=h - 4))
        r = detect_sky(img, row_threshold=0.0)
        # At least the sky rows (top 4) will have sky pixels, so horizon ≥ 3
        assert r.horizon_y >= 3

    def test_row_threshold_high_tightens_horizon(self):
        """High row_threshold only accepts near-fully-sky rows → lower horizon_y."""
        h = 64
        # Left 60% of each row: sky; right 40%: ground.
        # Low threshold (0.3) → both halves count as sky rows → horizon = H-1
        # High threshold (0.7) → only sky-dominant rows count → horizon = H-1 still
        #   (since 60% > 0.7 is False, so high threshold gives lower horizon)
        w = 100
        img = np.concatenate([
            _blue_sky(rows=h, cols=60),
            _green_ground(rows=h, cols=40),
        ], axis=1)
        r_low  = detect_sky(img, row_threshold=0.30)
        r_high = detect_sky(img, row_threshold=0.70)
        # With 60% sky per row: low threshold (0.3) passes; high (0.7) fails
        assert r_low.horizon_y  >= 0, 'low threshold should find sky rows'
        assert r_high.horizon_y == -1, (
            f'60% sky/row fails 0.7 threshold; horizon_y should be -1, got {r_high.horizon_y}')


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