"""Smoke tests for Eye package."""

import os
import sys

# Add repo root so `import eye` resolves when running tests directly
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

import numpy as np


# ---------------------------------------------------------------------------
# 1) Public exports
# ---------------------------------------------------------------------------

def test_public_exports_smoke():
    import eye

    must_have = [
        "Detections",
        "auto_convert",
        "VideoInfo",
        "VideoWriter",
        "ByteTrack",
        "Tracker",
        "TrackerType",
        "SmoothingConfig",
        "SmoothingPreset",
        "ExponentialSmoothing",
        "KalmanSmoothing",
        "BoxAnnotator",
        "GradientBoxAnnotator",
        "NeonTraceAnnotator",
        "ShadowBoxAnnotator",
        "FPSAnnotator",
        "InfoAnnotator",
        "PolygonZone",
        # New converter aliases
        "from_yolo",
        "from_roboflow",
        "from_detectron2",
        "from_pytorch",
        "from_tensorflow",
        "from_mmdetection",
        "from_opencv",
        "BoTSORTTracker",
    ]

    missing = [name for name in must_have if not hasattr(eye, name)]
    assert not missing, f"Missing exports: {missing}"


# ---------------------------------------------------------------------------
# 2) Detections core
# ---------------------------------------------------------------------------

def test_detections_construct_and_slice():
    import eye

    xyxy = np.array([[10, 10, 20, 20], [30, 30, 60, 60]], dtype=np.float32)
    conf = np.array([0.9, 0.8], dtype=np.float32)
    class_id = np.array([1, 2], dtype=np.int64)

    det = eye.Detections(xyxy=xyxy, confidence=conf, class_id=class_id)
    assert len(det) == 2

    det1 = det[:1]
    assert len(det1) == 1
    assert det1.xyxy.shape == (1, 4)


def test_detections_empty():
    """Detections.empty() returns a valid zero-length instance."""
    from eye.detection.core import Detections

    det = Detections.empty()
    assert len(det) == 0
    assert det.xyxy.shape == (0, 4)


# ---------------------------------------------------------------------------
# 3) Annotators
# ---------------------------------------------------------------------------

def test_annotators_smoke_on_empty_detections():
    import eye

    image = np.zeros((240, 320, 3), dtype=np.uint8)
    det_empty = eye.Detections(xyxy=np.zeros((0, 4), dtype=np.float32))

    box = eye.BoxAnnotator()
    out = box.annotate(image.copy(), det_empty)
    assert out.shape == image.shape

    grad = eye.GradientBoxAnnotator()
    out2 = grad.annotate(image.copy(), det_empty)
    assert out2.shape == image.shape


# ---------------------------------------------------------------------------
# 4) Smoothing
# ---------------------------------------------------------------------------

def test_smoothing_smoke():
    import eye

    smoother = eye.ExponentialSmoothing(alpha=0.5)
    box = np.array([10.0, 20.0, 30.0, 40.0], dtype=np.float32)

    out1 = smoother.update(7, box)
    out2 = smoother.update(7, box + 1.0)

    assert out1.shape == (4,)
    assert out2.shape == (4,)


# ---------------------------------------------------------------------------
# 5) auto_convert — None / empty / ndarray inputs
# ---------------------------------------------------------------------------

def test_auto_convert_none_returns_empty():
    """auto_convert(None) must return an empty Detections."""
    from eye.detection.auto_convert import auto_convert

    det = auto_convert(None)
    assert len(det) == 0


def test_auto_convert_empty_array_returns_empty():
    """auto_convert with a 0-row (N,6) array returns empty Detections."""
    from eye.detection.auto_convert import auto_convert

    arr = np.zeros((0, 6), dtype=np.float32)
    det = auto_convert(arr)
    assert len(det) == 0


def test_auto_convert_raw_6col_array():
    """auto_convert with (N,6) array: x1,y1,x2,y2,conf,cls."""
    from eye.detection.auto_convert import auto_convert

    arr = np.array([[10, 20, 50, 80, 0.9, 0],
                    [15, 25, 55, 85, 0.7, 1]], dtype=np.float32)
    det = auto_convert(arr)
    assert len(det) == 2
    np.testing.assert_allclose(det.confidence, [0.9, 0.7], atol=1e-5)


def test_auto_convert_raw_4col_array():
    """auto_convert with (N,4) array: just boxes."""
    from eye.detection.auto_convert import auto_convert

    arr = np.array([[10, 20, 50, 80]], dtype=np.float32)
    det = auto_convert(arr)
    assert len(det) == 1
    assert det.confidence is None or len(det.confidence) == 1


def test_auto_convert_raw_5col_array():
    """auto_convert with (N,5) array: OpenCV DNN format [cls,conf,x,y,w,h]
    or boxes+conf — should not crash."""
    from eye.detection.auto_convert import auto_convert

    arr = np.array([[10, 20, 50, 80, 0.9]], dtype=np.float32)
    det = auto_convert(arr)
    assert len(det) >= 0  # must not throw


# ---------------------------------------------------------------------------
# 6) auto_convert — mock Ultralytics (YOLO)
# ---------------------------------------------------------------------------

def test_auto_convert_mock_ultralytics():
    """auto_convert auto-detects a mock Ultralytics result."""
    from eye.detection.auto_convert import auto_convert

    class T:
        """Minimal tensor-like wrapper with .cpu().numpy() chain."""
        def __init__(self, data):
            self._arr = np.asarray(data, dtype=np.float32)

        def cpu(self):
            return self

        def numpy(self):
            return self._arr

        def int(self):
            return T(self._arr.astype(int))

        def __len__(self):
            return len(self._arr)

    class MockBoxes:
        def __init__(self):
            self.xyxy = T([[10, 20, 30, 40]])
            self.conf = T([0.95])
            self.cls = T([0])
            self.id = None
            self.data = {}

    class MockResult:
        def __init__(self):
            self.boxes = MockBoxes()
            self.orig_shape = (480, 640)
            self.masks = None
            self.obb = None
            self.names = {0: "person"}

        def __len__(self):
            return 1

    det = auto_convert(MockResult())
    assert len(det) >= 1


# ---------------------------------------------------------------------------
# 7) auto_convert — mock Detectron2
# ---------------------------------------------------------------------------

def test_auto_convert_mock_detectron2():
    """auto_convert auto-detects a mock Detectron2 Instances result."""
    from eye.detection.auto_convert import auto_convert

    class FakeTensor:
        """Mimics a detectron2 Boxes or Tensor."""
        def __init__(self, data):
            self._data = np.asarray(data, dtype=np.float32)

        @property
        def tensor(self):
            return self

        def cpu(self):
            return self

        def numpy(self):
            return self._data

    class MockInstances:
        def __init__(self):
            self._fields = {
                "pred_boxes": FakeTensor([[100, 100, 200, 200]]),
                "scores": FakeTensor([0.88]),
                "pred_classes": FakeTensor([2]),
            }

        def has(self, key):
            return key in self._fields

        def get(self, key):
            return self._fields[key]

        def __getattr__(self, key):
            if key.startswith("_"):
                raise AttributeError(key)
            if key not in self._fields:
                raise AttributeError(key)
            return self._fields[key]

    det = auto_convert(MockInstances())
    assert len(det) >= 1


# ---------------------------------------------------------------------------
# 8) auto_convert — mock Roboflow Inference
# ---------------------------------------------------------------------------

def test_auto_convert_mock_roboflow():
    """auto_convert auto-detects a mock Roboflow InferencePipeline result."""
    from eye.detection.auto_convert import auto_convert

    rf_dict = {
        "predictions": [
            {"x": 150, "y": 150, "width": 100, "height": 100,
             "confidence": 0.92, "class": "car", "class_id": 0},
        ],
        "image": {"width": 640, "height": 480},
    }
    det = auto_convert(rf_dict)
    assert len(det) >= 1


# ---------------------------------------------------------------------------
# 9) from_* explicit aliases
# ---------------------------------------------------------------------------

def test_from_yolo_alias():
    """from_yolo must be importable and callable."""
    from eye.detection.auto_convert import from_yolo

    det = from_yolo(None)
    assert len(det) == 0


def test_from_roboflow_alias_none():
    """from_roboflow(None) returns empty Detections."""
    from eye.detection.auto_convert import from_roboflow

    det = from_roboflow(None)
    assert len(det) == 0


def test_from_detectron2_alias_none():
    """from_detectron2(None) returns empty Detections."""
    from eye.detection.auto_convert import from_detectron2

    det = from_detectron2(None)
    assert len(det) == 0


# ---------------------------------------------------------------------------
# 10) quick.py detect() defaults to auto
# ---------------------------------------------------------------------------

def test_quick_detect_auto():
    """eye.quick.detect() defaults to model_type='auto'."""
    from eye.quick import detect

    det = detect(None)  # auto_convert(None) → empty
    assert len(det) == 0


# ---------------------------------------------------------------------------
# 11) filters module loads without errors
# ---------------------------------------------------------------------------

def test_filters_import():
    """Importing filters must not raise."""
    from eye.filters import FilterPipeline, ConfidenceFilter

    fp = FilterPipeline(filters=[ConfidenceFilter(min_confidence=0.5)])
    assert fp is not None


# ---------------------------------------------------------------------------
# 12) web module loads without errors
# ---------------------------------------------------------------------------

def test_web_import():
    """Importing WebAPI must not raise."""
    from eye.web import WebAPI

    assert WebAPI is not None


# ---------------------------------------------------------------------------
# 13) ByteTrack tracker multi-frame update cycle
# ---------------------------------------------------------------------------

def test_bytetrack_tracker_update_cycle():
    """ByteTrack tracker should assign IDs across frames."""
    from eye.core.trackers.bytetrack_tracker import ByteTrackTracker

    tracker = ByteTrackTracker(max_age=5, min_hits=1, iou_threshold=0.3)

    # Simulate 3 frames with a box moving slightly
    for frame in range(3):
        dets = np.array([[10.0 + frame, 10.0 + frame, 50.0 + frame, 50.0 + frame, 0.9]])
        result = tracker.update(dets)
        assert isinstance(result, np.ndarray)

    # After 3 frames, should have at least one confirmed track
    assert result.shape[0] >= 1
    assert result.shape[1] == 5  # [x1, y1, x2, y2, track_id]


# ---------------------------------------------------------------------------
# 14) BoT-SORT tracker update cycle
# ---------------------------------------------------------------------------

def test_botsort_tracker_update_cycle():
    """BoT-SORT tracker should assign IDs across frames."""
    from eye.core.trackers.botsort_tracker import BoTSORTTracker

    tracker = BoTSORTTracker(max_age=5, min_hits=1, iou_threshold=0.3)

    for frame in range(3):
        dets = np.array([[20.0 + frame, 20.0 + frame, 60.0 + frame, 60.0 + frame, 0.85]])
        result = tracker.update(dets)
        assert isinstance(result, np.ndarray)

    assert result.shape[0] >= 1
    assert result.shape[1] == 5


# ---------------------------------------------------------------------------
# 15) BoT-SORT tracker with appearance features
# ---------------------------------------------------------------------------

def test_botsort_with_features():
    """BoT-SORT should accept optional appearance features."""
    from eye.core.trackers.botsort_tracker import BoTSORTTracker

    tracker = BoTSORTTracker(max_age=5, min_hits=1)
    features = np.random.randn(1, 128).astype(np.float32)
    dets = np.array([[10.0, 10.0, 50.0, 50.0, 0.9]])

    # Should not crash with features
    result = tracker.update(dets, features=features)
    assert isinstance(result, np.ndarray)


# ---------------------------------------------------------------------------
# 16) Tracker wrapper with TrackerType.BYTETRACK
# ---------------------------------------------------------------------------

def test_tracker_wrapper_bytetrack():
    """Tracker wrapper should work with ByteTrack backend."""
    import eye

    tracker = eye.Tracker(
        tracker_type=eye.TrackerType.BYTETRACK,
        inflation_factor=1.5,
    )

    for frame in range(3):
        det = eye.Detections(
            xyxy=np.array([[10.0 + frame, 10.0 + frame, 50.0 + frame, 50.0 + frame]]),
            confidence=np.array([0.9]),
        )
        result = tracker.update(det)
        assert isinstance(result, eye.Detections)

    # After several frames the track should be confirmed
    assert len(result) >= 0  # may be 0 or 1 depending on min_hits


# ---------------------------------------------------------------------------
# 17) Tracker wrapper with TrackerType.BOTSORT
# ---------------------------------------------------------------------------

def test_tracker_wrapper_botsort():
    """Tracker wrapper should work with BoT-SORT backend."""
    import eye

    tracker = eye.Tracker(tracker_type=eye.TrackerType.BOTSORT)

    for frame in range(3):
        det = eye.Detections(
            xyxy=np.array([[10 + frame, 10 + frame, 50 + frame, 50 + frame]], dtype=np.float32),
            confidence=np.array([0.9]),
        )
        result = tracker.update(det)
        assert isinstance(result, eye.Detections)


# ---------------------------------------------------------------------------
# 18) Tracker reset clears state
# ---------------------------------------------------------------------------

def test_tracker_reset():
    """Tracker.reset() should clear internal state."""
    import eye

    tracker = eye.Tracker(tracker_type=eye.TrackerType.BYTETRACK)
    det = eye.Detections(
        xyxy=np.array([[10.0, 10.0, 50.0, 50.0]]),
        confidence=np.array([0.9]),
    )
    tracker.update(det)
    tracker.reset()

    # After reset internal trackers list should be empty
    assert len(tracker._tracker.trackers) == 0
    assert tracker._tracker.frame_count == 0


# ---------------------------------------------------------------------------
# 19) Tracker with smoothing enabled
# ---------------------------------------------------------------------------

def test_tracker_with_smoothing():
    """Tracker should work with enable_smoothing=True."""
    import eye

    tracker = eye.Tracker(
        tracker_type=eye.TrackerType.BYTETRACK,
        enable_smoothing=True,
        smoothing_alpha=0.3,
    )
    assert tracker._smoother is not None

    for frame in range(5):
        det = eye.Detections(
            xyxy=np.array([[10 + frame, 10 + frame, 50 + frame, 50 + frame]], dtype=np.float32),
            confidence=np.array([0.9]),
        )
        result = tracker.update(det)
        assert isinstance(result, eye.Detections)


# ---------------------------------------------------------------------------
# 20) ExponentialSmoothing jitter reduction
# ---------------------------------------------------------------------------

def test_exponential_smoothing():
    """ExponentialSmoothing should reduce jitter in tracked boxes."""
    from eye.detection.tools.smoothing import ExponentialSmoothing

    smoother = ExponentialSmoothing(alpha=0.3)

    # Send several boxes with tracker_id=1
    boxes = []
    for i in range(10):
        # Add noise to simulate jitter
        jitter = np.random.randn() * 2
        box = np.array([100 + jitter, 100 + jitter, 200 + jitter, 200 + jitter])
        smoothed = smoother.update(tracker_id=1, box=box)
        boxes.append(smoothed)

    # Smoothed boxes should have less variance than raw jitter
    smoothed_arr = np.array(boxes)
    variance = np.var(smoothed_arr, axis=0).mean()
    # With alpha=0.3 and gaussian jitter stdev=2, smoothed variance should be reduced
    assert variance < 10.0, f"Smoothing not reducing jitter: variance={variance}"


# ---------------------------------------------------------------------------
# 21) BoTSORTTracker export accessible via eye namespace
# ---------------------------------------------------------------------------

def test_botsort_export():
    """eye.BoTSORTTracker should be importable."""
    import eye
    assert hasattr(eye, "BoTSORTTracker")
    assert eye.BoTSORTTracker is not None


# ---------------------------------------------------------------------------
# 22) ByteTrack _convert_x_to_bbox handles negative state
# ---------------------------------------------------------------------------

def test_bytetrack_kalman_safety():
    """ByteTrack KalmanBoxTracker must not produce NaN from bad state."""
    from eye.core.trackers.bytetrack_tracker import KalmanBoxTracker

    trk = KalmanBoxTracker(np.array([10, 10, 50, 50, 0.9]))
    # Force negative state values (simulates Kalman divergence)
    trk.kf_x[2] = -100.0  # negative area
    trk.kf_x[3] = -0.5    # negative ratio
    bbox = trk._convert_x_to_bbox(trk.kf_x)
    # Must not contain NaN
    assert not np.any(np.isnan(bbox)), f"NaN in bbox: {bbox}"


# ---------------------------------------------------------------------------
# 30) Filter pipeline uses Detections correctly
# ---------------------------------------------------------------------------

def test_filter_pipeline():
    """Verify ConfidenceFilter, AreaFilter, AspectRatioFilter, ClassFilter work."""
    from eye.detection.core import Detections
    from eye.filters import (
        ConfidenceFilter, AreaFilter, AspectRatioFilter,
        ClassFilter, FilterPipeline,
    )

    dets = Detections(
        xyxy=np.array([
            [0, 0, 100, 200],   # area=20000, ratio=0.5
            [0, 0, 200, 100],   # area=20000, ratio=2.0
            [0, 0, 10, 10],     # area=100,   ratio=1.0
        ], dtype=float),
        confidence=np.array([0.9, 0.3, 0.8]),
        class_id=np.array([0, 1, 0]),
    )

    # Confidence filter
    cf = ConfidenceFilter(min_confidence=0.5)
    result = cf(dets)
    assert len(result) == 2  # filters out 0.3

    # Area filter
    af = AreaFilter(min_area=500)
    result = af(dets)
    assert len(result) == 2  # filters out 100-area box

    # Aspect ratio filter
    arf = AspectRatioFilter(min_ratio=0.8, max_ratio=1.5)
    result = arf(dets)
    assert len(result) == 1  # only ratio=1.0 passes

    # Class filter
    clf = ClassFilter(allowed_classes=[0])
    result = clf(dets)
    assert len(result) == 2  # filters out class 1

    # Pipeline composition
    pipeline = FilterPipeline([cf, af])
    result = pipeline(dets)
    assert len(result) == 1  # only high-conf + large-area
    stats = pipeline.get_stats()
    assert stats['total_processed'] == 3


# ---------------------------------------------------------------------------
# 31) quick.track() caches trackers across calls
# ---------------------------------------------------------------------------

def test_quick_track_caching():
    """Verify track() reuses the same tracker across calls."""
    from eye.quick import _tracker_cache, _get_cached_tracker

    _tracker_cache.clear()

    t1 = _get_cached_tracker("traffic")
    t2 = _get_cached_tracker("traffic")
    assert t1 is t2, "Tracker should be cached and reused"

    t3 = _get_cached_tracker("pedestrians")
    assert t3 is not t1, "Different use cases should get different trackers"

    _tracker_cache.clear()


# ---------------------------------------------------------------------------
# 32) smooth_detections preserves mask field
# ---------------------------------------------------------------------------

def test_smooth_detections_preserves_mask():
    """Verify smooth_detections keeps mask in output."""
    from eye.detection.core import Detections
    from eye.detection.tools.smoothing import ExponentialSmoothing, smooth_detections

    masks = np.zeros((2, 100, 100), dtype=bool)
    masks[0, 10:20, 10:20] = True
    masks[1, 30:40, 30:40] = True

    dets = Detections(
        xyxy=np.array([[10, 10, 50, 50], [60, 60, 100, 100]], dtype=float),
        confidence=np.array([0.9, 0.8]),
        class_id=np.array([0, 1]),
        tracker_id=np.array([1, 2]),
        mask=masks,
    )

    smoother = ExponentialSmoothing(alpha=0.5)
    result = smooth_detections(dets, smoother)
    assert result.mask is not None, "mask should be preserved"
    assert result.mask.shape == (2, 100, 100)


# ---------------------------------------------------------------------------
# 33) MaskAnnotator uses detections.mask (not data['masks'])
# ---------------------------------------------------------------------------

def test_mask_annotator_uses_mask_attribute():
    """MaskAnnotator should read detections.mask, not data['masks']."""
    from eye.detection.core import Detections
    from eye.annotators.modern import MaskAnnotator

    img = np.zeros((100, 100, 3), dtype=np.uint8)

    # No mask -> return unchanged
    dets_no_mask = Detections(
        xyxy=np.array([[10, 10, 50, 50]], dtype=float),
        confidence=np.array([0.9]),
    )
    ann = MaskAnnotator()
    result = ann.annotate(img, dets_no_mask)
    assert np.array_equal(result, img), "No mask -> return image unchanged"

    # With mask -> should annotate (not crash)
    mask = np.zeros((1, 100, 100), dtype=bool)
    mask[0, 20:40, 20:40] = True
    dets_with_mask = Detections(
        xyxy=np.array([[20, 20, 40, 40]], dtype=float),
        confidence=np.array([0.9]),
        class_id=np.array([0]),
        mask=mask,
    )
    result = ann.annotate(img, dets_with_mask)
    assert not np.array_equal(result, img), "With mask -> image should be annotated"


# ---------------------------------------------------------------------------
# 34) No TODO/FIXME/HACK markers in source
# ---------------------------------------------------------------------------

def test_no_forbidden_markers():
    """Verify no TODO/FIXME/HACK markers exist in source."""
    import re
    import pathlib

    pattern = re.compile(r'\b(TODO|FIXME|HACK)\b', re.IGNORECASE)
    root = pathlib.Path(__file__).parent.parent / "eye"
    violations = []

    for py_file in root.rglob("*.py"):
        if "__pycache__" in str(py_file):
            continue
        if py_file.name == "__init___supervision_original.py":
            continue
        content = py_file.read_text(encoding="utf-8", errors="ignore")
        for i, line in enumerate(content.splitlines(), 1):
            if pattern.search(line):
                violations.append(f"{py_file.relative_to(root)}:{i}: {line.strip()}")

    assert not violations, f"Forbidden markers found:\n" + "\n".join(violations)


# ---------------------------------------------------------------------------
# 35) smoothing.py imports without filterpy
# ---------------------------------------------------------------------------

def test_exponential_smoothing_no_filterpy():
    """ExponentialSmoothing must be importable even without filterpy."""
    from eye.detection.tools.smoothing import ExponentialSmoothing
    es = ExponentialSmoothing(alpha=0.4)
    box = np.array([10.0, 20.0, 30.0, 40.0])
    result = es.update(tracker_id=99, box=box)
    assert result.shape == (4,)


# ---------------------------------------------------------------------------
# Run with pytest or directly
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    passed = failed = 0
    for t in tests:
        try:
            t()
            print(f"  PASS  {t.__name__}")
            passed += 1
        except Exception as exc:
            print(f"  FAIL  {t.__name__}: {exc}")
            failed += 1
    print(f"\n{passed} passed, {failed} failed out of {passed + failed}")
    raise SystemExit(1 if failed else 0)