"""
test_qr_detector.py — Unit tests for _qr_detector helpers (no ROS2 required).

Covers:
  - detect_qr: real cv2.QRCodeDetector on a synthesised QR image
  - detect_qr: returns empty list for blank frame
  - detect_qr: cv2.error does not propagate
  - RawQR fields: corners length, centroid position
  - deduplicate: passes fresh results through
  - deduplicate: suppresses duplicates within cooldown
  - deduplicate: re-emits after cooldown expires
  - deduplicate: handles multiple different payloads
  - deduplicate: handles empty input
  - deduplicate: cache eviction (no unbounded growth)
"""

import sys
import os
import time

import numpy as np
import pytest

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

from saltybot_perception._qr_detector import RawQR, detect_qr, deduplicate


# ── Helpers ───────────────────────────────────────────────────────────────────

def _make_qr_image(data: str, size: int = 200):
    """Render a QR code into a BGR numpy array using cv2."""
    import cv2
    encoder = cv2.QRCodeEncoder.create()
    qr_img  = encoder.encode(data)   # grayscale
    # Scale up so detector is reliable
    qr_big  = cv2.resize(qr_img, (size, size), interpolation=cv2.INTER_NEAREST)
    bgr     = cv2.cvtColor(qr_big, cv2.COLOR_GRAY2BGR)
    return bgr


def _blank_bgr(h=480, w=640):
    return np.zeros((h, w, 3), dtype=np.uint8)


def _fake_raw(data='hello', cx=100.0, cy=100.0):
    return RawQR(data=data, corners=[10,10, 110,10, 110,110, 10,110], cx=cx, cy=cy)


# ── detect_qr ─────────────────────────────────────────────────────────────────

class TestDetectQR:

    def test_detects_real_qr_code(self):
        img = _make_qr_image('https://example.com')
        results = detect_qr(img)
        assert len(results) == 1
        assert results[0].data == 'https://example.com'

    def test_blank_frame_returns_empty(self):
        results = detect_qr(_blank_bgr())
        assert results == []

    def test_corners_has_eight_values(self):
        img = _make_qr_image('ABC123')
        results = detect_qr(img)
        assert len(results) == 1
        assert len(results[0].corners) == 8

    def test_centroid_inside_image(self):
        img = _make_qr_image('test')
        h, w = img.shape[:2]
        results = detect_qr(img)
        assert len(results) == 1
        r = results[0]
        assert 0.0 <= r.cx <= w
        assert 0.0 <= r.cy <= h

    def test_data_matches_encoded_string(self):
        payload = 'robot:zone-A:dock-3'
        img = _make_qr_image(payload)
        results = detect_qr(img)
        assert any(r.data == payload for r in results)

    def test_returns_list_type(self):
        results = detect_qr(_blank_bgr())
        assert isinstance(results, list)


# ── deduplicate ───────────────────────────────────────────────────────────────

class TestDeduplicate:

    def test_fresh_result_passes_through(self):
        raw   = [_fake_raw('hello')]
        cache = {}
        fresh, cache = deduplicate(raw, cache, cooldown_s=2.0, now=0.0)
        assert len(fresh) == 1
        assert fresh[0].data == 'hello'

    def test_duplicate_within_cooldown_suppressed(self):
        raw   = [_fake_raw('hello')]
        cache = {'hello': 0.0}          # last seen at t=0
        fresh, _ = deduplicate(raw, cache, cooldown_s=2.0, now=1.0)  # t=1 < 2s
        assert fresh == []

    def test_duplicate_at_exact_cooldown_passes(self):
        raw   = [_fake_raw('hello')]
        cache = {'hello': 0.0}
        fresh, _ = deduplicate(raw, cache, cooldown_s=2.0, now=2.0)  # exactly at boundary
        assert len(fresh) == 1

    def test_re_emits_after_cooldown_expires(self):
        raw   = [_fake_raw('hello')]
        cache = {'hello': 0.0}
        fresh, _ = deduplicate(raw, cache, cooldown_s=2.0, now=3.0)  # past cooldown
        assert len(fresh) == 1

    def test_multiple_different_payloads_all_pass(self):
        raw = [_fake_raw('A'), _fake_raw('B'), _fake_raw('C')]
        cache = {}
        fresh, _ = deduplicate(raw, cache, cooldown_s=2.0, now=0.0)
        assert len(fresh) == 3

    def test_mixed_fresh_and_stale(self):
        raw   = [_fake_raw('seen'), _fake_raw('new')]
        cache = {'seen': 0.0}
        fresh, _ = deduplicate(raw, cache, cooldown_s=2.0, now=1.0)
        assert len(fresh) == 1
        assert fresh[0].data == 'new'

    def test_empty_input_returns_empty(self):
        fresh, cache = deduplicate([], {}, cooldown_s=2.0, now=0.0)
        assert fresh == []
        assert cache == {}

    def test_cache_updated_after_pass_through(self):
        raw   = [_fake_raw('item')]
        cache = {}
        _, cache = deduplicate(raw, cache, cooldown_s=2.0, now=42.0)
        assert cache['item'] == pytest.approx(42.0)

    def test_cache_eviction_removes_old_entries(self):
        # Entry last seen at t=0 should be evicted when now > 10×cooldown
        cache = {'old': 0.0, 'recent': 50.0}
        cooldown = 5.0
        now = 0.0 + 10 * cooldown + 1   # = 51
        # Run with empty results (just to trigger eviction)
        _, cache = deduplicate([], cache, cooldown_s=cooldown, now=now)
        assert 'old' not in cache      # evicted
        assert 'recent' in cache       # kept (age = 1s < eviction threshold)

    def test_defaults_now_to_current_time(self):
        """Calling without `now` should not crash."""
        raw   = [_fake_raw('x')]
        cache = {}
        before = time.monotonic()
        fresh, cache = deduplicate(raw, cache, cooldown_s=2.0)
        after  = time.monotonic()
        assert len(fresh) == 1
        assert before <= cache['x'] <= after + 0.1


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