"""Tests for SharedMemoryFrameManager cache invalidation.

Covers the case where a SHM segment is unlinked and recreated at a
different size across a camera add/remove cycle while a long-lived
in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment.
"""

import unittest
from types import SimpleNamespace
from unittest.mock import patch

import numpy as np

from frigate.util.image import SharedMemoryFrameManager


def _fake_shm(size: int) -> SimpleNamespace:
    """A minimal stand-in for UntrackedSharedMemory with .size and .buf."""
    return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None)


class TestSharedMemoryFrameManagerGet(unittest.TestCase):
    def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None:
        """A cached ref to an older smaller segment must be dropped and the
        current (correctly sized) segment reopened. Without this, np.ndarray
        would raise "buffer is too small for requested array" when the
        in-memory cache pointed at an old SHM after a same-name resize."""
        manager = SharedMemoryFrameManager()

        small = _fake_shm(size=100)
        current = _fake_shm(size=2_500)
        manager.shm_store["cam_frame0"] = small

        with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
            arr = manager.get("cam_frame0", (50, 50))

        self.assertIsNotNone(arr)
        self.assertEqual(arr.shape, (50, 50))
        self.assertIs(manager.shm_store["cam_frame0"], current)

    def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None:
        """Symmetric to the smaller-cache case: when detect resolution drops,
        the SHM is unlinked and recreated at a smaller size. A cached ref to
        the old, larger segment still satisfies any size check but points at
        an orphaned inode whose stale bytes get reinterpreted at the new
        shape — producing miscolored, distorted YUV frames downstream. Drop
        the cache so we reopen by name and bind to the current segment."""
        manager = SharedMemoryFrameManager()

        old_large = _fake_shm(size=10_000)
        current = _fake_shm(size=2_500)
        manager.shm_store["cam_frame0"] = old_large

        with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
            arr = manager.get("cam_frame0", (50, 50))

        self.assertIsNotNone(arr)
        self.assertEqual(arr.shape, (50, 50))
        self.assertIs(manager.shm_store["cam_frame0"], current)

    def test_get_keeps_cached_segment_when_size_matches(self) -> None:
        """Don't pay the reopen cost when the cached ref is the right size."""
        manager = SharedMemoryFrameManager()

        cached = _fake_shm(size=2_500)
        manager.shm_store["cam_frame0"] = cached

        with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
            arr = manager.get("cam_frame0", (50, 50))
            untracked_shm_cls.assert_not_called()

        self.assertIsNotNone(arr)
        self.assertIs(manager.shm_store["cam_frame0"], cached)

    def test_get_opens_fresh_when_no_cache_entry(self) -> None:
        manager = SharedMemoryFrameManager()
        fresh = _fake_shm(size=2_500)

        with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh):
            arr = manager.get("cam_frame0", (50, 50))

        self.assertIsNotNone(arr)
        self.assertIs(manager.shm_store["cam_frame0"], fresh)

    def test_get_returns_none_when_segment_missing(self) -> None:
        manager = SharedMemoryFrameManager()

        with patch(
            "frigate.util.image.UntrackedSharedMemory",
            side_effect=FileNotFoundError,
        ):
            arr = manager.get("cam_frame0", (50, 50))

        self.assertIsNone(arr)

    def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None:
        """Race during a same-name SHM recreate: cache is stale, we reopen
        by name, but the maintainer hasn't allocated the new segment yet —
        the reopened ref is also too small. Skip the frame (return None)
        rather than crash on np.ndarray."""
        manager = SharedMemoryFrameManager()

        small_cached = _fake_shm(size=100)
        still_small_after_reopen = _fake_shm(size=100)
        manager.shm_store["cam_frame0"] = small_cached

        with patch(
            "frigate.util.image.UntrackedSharedMemory",
            return_value=still_small_after_reopen,
        ):
            arr = manager.get("cam_frame0", (50, 50))

        self.assertIsNone(arr)
        # Don't cache the too-small reopened ref — next call will re-open
        # once the maintainer has finished recreating the segment.
        self.assertNotIn("cam_frame0", manager.shm_store)

    def test_get_handles_n_dimensional_shape(self) -> None:
        """np.prod must be used (not raw multiplication) for tuple shapes."""
        manager = SharedMemoryFrameManager()
        # YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400
        big_enough = _fake_shm(size=3_110_400)
        manager.shm_store["cam_frame0"] = big_enough

        with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
            arr = manager.get("cam_frame0", (1620, 1920))
            untracked_shm_cls.assert_not_called()

        self.assertIsNotNone(arr)
        self.assertEqual(arr.shape, (1620, 1920))


class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
    """End-to-end-style: simulates the full unlink-and-recreate cycle."""

    def test_segment_grows_then_get_succeeds(self) -> None:
        manager = SharedMemoryFrameManager()

        # Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200
        small = _fake_shm(size=115_200)
        manager.shm_store["cam_frame0"] = small
        arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf)
        self.assertEqual(arr_small.shape, (360, 320))

        # Phase 2: restart at 1920x1080 — new SHM segment, larger size.
        large = _fake_shm(size=3_110_400)
        with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
            arr_large = manager.get("cam_frame0", (1620, 1920))

        self.assertIsNotNone(arr_large)
        self.assertEqual(arr_large.shape, (1620, 1920))


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