"""
Tests for rpicv2.Camera

All hardware calls (modprobe, libcamera, ffmpeg, cv2.VideoCapture) are mocked
so these run on any machine — no Raspberry Pi required.
"""
import unittest
from unittest.mock import patch, MagicMock, PropertyMock, call
import numpy as np
import cv2

# Patch cv2.VideoCapture before importing Camera so the module-level import
# doesn't touch real hardware.
with patch("cv2.VideoCapture"):
    from rpicv2.camera import Camera


def _make_mock_cap(frame=None, opened=True, read_ok=True):
    """Return a mock that behaves like cv2.VideoCapture."""
    if frame is None:
        frame = np.zeros((720, 1280, 3), dtype=np.uint8)
    cap = MagicMock()
    cap.isOpened.return_value = opened
    cap.read.return_value = (read_ok, frame)
    cap.grab.return_value = read_ok
    cap.retrieve.return_value = (read_ok, frame)
    cap.get.return_value = 0.0
    cap.set.return_value = True
    return cap


def _make_camera(width=1280, height=720, fps=30, device=0, frame=None, opened=True):
    """Construct a Camera with all hardware mocked out."""
    mock_cap = _make_mock_cap(frame=frame, opened=opened)
    with (
        patch("os.system"),
        patch("subprocess.Popen"),
        patch("time.sleep"),
        patch("cv2.VideoCapture", return_value=mock_cap),
    ):
        cam = Camera(width=width, height=height, fps=fps, device=device)
    # Keep a reference so tests can inspect it
    cam._mock_cap = mock_cap
    return cam


# ---------------------------------------------------------------------------
# Initialisation
# ---------------------------------------------------------------------------

class TestCameraInit(unittest.TestCase):

    def test_default_params(self):
        cam = _make_camera()
        self.assertEqual(cam.width, 1280)
        self.assertEqual(cam.height, 720)
        self.assertEqual(cam.fps, 30)
        self.assertEqual(cam.device, 0)

    def test_custom_params(self):
        cam = _make_camera(width=1920, height=1080, fps=60, device=1)
        self.assertEqual(cam.width, 1920)
        self.assertEqual(cam.height, 1080)
        self.assertEqual(cam.fps, 60)
        self.assertEqual(cam.device, 1)

    def test_modprobe_called(self):
        with (
            patch("os.system") as mock_sys,
            patch("subprocess.Popen"),
            patch("time.sleep"),
            patch("cv2.VideoCapture", return_value=_make_mock_cap()),
        ):
            Camera(device=2)
        cmd = mock_sys.call_args[0][0]
        self.assertIn("v4l2loopback", cmd)
        self.assertIn("video_nr=2", cmd)

    def test_pipeline_command_contains_resolution(self):
        with (
            patch("os.system"),
            patch("subprocess.Popen") as mock_popen,
            patch("time.sleep"),
            patch("cv2.VideoCapture", return_value=_make_mock_cap()),
        ):
            Camera(width=640, height=480, fps=15)
        cmd = mock_popen.call_args[0][0]
        self.assertIn("640", cmd)
        self.assertIn("480", cmd)
        self.assertIn("15", cmd)
        self.assertIn("libcamera-vid", cmd)
        self.assertIn("ffmpeg", cmd)

    def test_raises_if_camera_wont_open(self):
        mock_cap = _make_mock_cap(opened=False)
        with (
            patch("os.system"),
            patch("subprocess.Popen"),
            patch("time.sleep"),
            patch("cv2.VideoCapture", return_value=mock_cap),
        ):
            with self.assertRaises(RuntimeError):
                Camera()

    def test_sleep_called_after_pipeline(self):
        with (
            patch("os.system"),
            patch("subprocess.Popen"),
            patch("time.sleep") as mock_sleep,
            patch("cv2.VideoCapture", return_value=_make_mock_cap()),
        ):
            Camera()
        mock_sleep.assert_called_once_with(2)


# ---------------------------------------------------------------------------
# Read interface
# ---------------------------------------------------------------------------

class TestRead(unittest.TestCase):

    def setUp(self):
        self.frame = np.zeros((720, 1280, 3), dtype=np.uint8)
        self.frame[:] = (10, 20, 30)  # non-black so we can verify content
        self.cam = _make_camera(frame=self.frame)

    def tearDown(self):
        with patch("cv2.destroyAllWindows"):
            self.cam.release()

    def test_read_returns_ndarray(self):
        frame = self.cam.read()
        self.assertIsInstance(frame, np.ndarray)

    def test_read_correct_shape(self):
        frame = self.cam.read()
        self.assertEqual(frame.shape, (720, 1280, 3))

    def test_read_gray_shape(self):
        frame = self.cam.read_gray()
        self.assertEqual(frame.ndim, 2)
        self.assertEqual(frame.shape, (720, 1280))

    def test_read_rgb_shape(self):
        frame = self.cam.read_rgb()
        self.assertEqual(frame.shape, (720, 1280, 3))

    def test_read_rgb_channel_order(self):
        # BGR frame with B=10,G=20,R=30 → RGB should be R=30,G=20,B=10
        frame_bgr = self.cam.read()
        frame_rgb = self.cam.read_rgb()
        # Red channel in RGB == Blue channel in BGR at same pixel
        np.testing.assert_array_equal(frame_rgb[:, :, 0], frame_bgr[:, :, 2])

    def test_read_resized(self):
        frame = self.cam.read_resized(320, 240)
        self.assertEqual(frame.shape, (240, 320, 3))

    def test_read_flipped_shape_unchanged(self):
        frame = self.cam.read_flipped()
        self.assertEqual(frame.shape, (720, 1280, 3))

    def test_read_raises_on_failure(self):
        self.cam._mock_cap.read.return_value = (False, None)
        with self.assertRaises(RuntimeError):
            self.cam.read()


# ---------------------------------------------------------------------------
# cv2.VideoCapture-compatible interface
# ---------------------------------------------------------------------------

class TestVideoCaptureCompat(unittest.TestCase):

    def setUp(self):
        self.cam = _make_camera()

    def tearDown(self):
        with patch("cv2.destroyAllWindows"):
            self.cam.release()

    def test_isOpened_true(self):
        self.assertTrue(self.cam.isOpened())

    def test_isOpened_false_after_release(self):
        self.cam._mock_cap.isOpened.return_value = False
        self.cam.cap.release()
        self.assertFalse(self.cam.isOpened())

    def test_grab(self):
        result = self.cam.grab()
        self.assertTrue(result)
        self.cam._mock_cap.grab.assert_called_once()

    def test_retrieve(self):
        ok, frame = self.cam.retrieve()
        self.assertTrue(ok)
        self.assertIsInstance(frame, np.ndarray)

    def test_get_proxies_to_cap(self):
        self.cam._mock_cap.get.return_value = 30.0
        val = self.cam.get(cv2.CAP_PROP_FPS)
        self.cam._mock_cap.get.assert_called_once_with(cv2.CAP_PROP_FPS)
        self.assertEqual(val, 30.0)

    def test_set_proxies_to_cap(self):
        self.cam.set(cv2.CAP_PROP_BRIGHTNESS, 0.5)
        self.cam._mock_cap.set.assert_called_once_with(cv2.CAP_PROP_BRIGHTNESS, 0.5)

    def test_drop_in_replacement_pattern(self):
        """Camera can be used anywhere cv2.VideoCapture is expected."""
        cam = self.cam
        self.assertTrue(cam.isOpened())
        ret, frame = cam.cap.read()
        self.assertTrue(ret)
        self.assertIsInstance(frame, np.ndarray)


# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------

class TestDisplayHelpers(unittest.TestCase):

    def setUp(self):
        self.cam = _make_camera()

    def tearDown(self):
        with patch("cv2.destroyAllWindows"):
            self.cam.release()

    def test_show_calls_imshow(self):
        with patch("cv2.imshow") as mock_imshow:
            frame = self.cam.show("TestWindow")
        mock_imshow.assert_called_once()
        args = mock_imshow.call_args[0]
        self.assertEqual(args[0], "TestWindow")
        self.assertIsInstance(args[1], np.ndarray)

    def test_show_returns_frame(self):
        with patch("cv2.imshow"):
            frame = self.cam.show()
        self.assertIsInstance(frame, np.ndarray)

    def test_snapshot_writes_file(self):
        with patch("cv2.imwrite") as mock_write:
            self.cam.snapshot("/tmp/test.jpg")
        mock_write.assert_called_once()
        path_arg = mock_write.call_args[0][0]
        self.assertEqual(path_arg, "/tmp/test.jpg")

    def test_snapshot_returns_frame(self):
        with patch("cv2.imwrite"):
            frame = self.cam.snapshot("/tmp/test.jpg")
        self.assertIsInstance(frame, np.ndarray)


# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------

class TestProperties(unittest.TestCase):

    def setUp(self):
        self.cam = _make_camera(width=1920, height=1080)

    def tearDown(self):
        with patch("cv2.destroyAllWindows"):
            self.cam.release()

    def test_resolution(self):
        self.assertEqual(self.cam.resolution, (1920, 1080))

    def test_shape(self):
        self.assertEqual(self.cam.shape, (1080, 1920, 3))

    def test_repr(self):
        r = repr(self.cam)
        self.assertIn("Camera", r)
        self.assertIn("1920", r)
        self.assertIn("1080", r)


# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------

class TestLifecycle(unittest.TestCase):

    def test_release_stops_process(self):
        cam = _make_camera()
        mock_process = cam.process
        with patch("cv2.destroyAllWindows"):
            cam.release()
        mock_process.terminate.assert_called_once()

    def test_release_releases_cap(self):
        cam = _make_camera()
        mock_cap = cam._mock_cap
        with patch("cv2.destroyAllWindows"):
            cam.release()
        mock_cap.release.assert_called_once()

    def test_context_manager_releases_on_exit(self):
        mock_cap = _make_mock_cap()
        with (
            patch("os.system"),
            patch("subprocess.Popen") as mock_popen,
            patch("time.sleep"),
            patch("cv2.VideoCapture", return_value=mock_cap),
            patch("cv2.destroyAllWindows"),
        ):
            with Camera() as cam:
                mock_process = cam.process
            mock_cap.release.assert_called_once()
            mock_process.terminate.assert_called_once()

    def test_context_manager_releases_on_exception(self):
        mock_cap = _make_mock_cap()
        with (
            patch("os.system"),
            patch("subprocess.Popen"),
            patch("time.sleep"),
            patch("cv2.VideoCapture", return_value=mock_cap),
            patch("cv2.destroyAllWindows"),
        ):
            try:
                with Camera() as cam:
                    raise ValueError("test error")
            except ValueError:
                pass
        mock_cap.release.assert_called_once()

    def test_release_is_idempotent(self):
        """Calling release() twice should not crash."""
        cam = _make_camera()
        with patch("cv2.destroyAllWindows"):
            cam.release()
            cam.release()


if __name__ == "__main__":
    unittest.main()
