"""Unit tests for PerceptualHashStore and perceptual-hash helpers in vm_inspection.

Tests: _compute_perceptual_hash, _hamming_distance, PerceptualHashStore.
How: Construct minimal PNG bytes via Pillow; call helpers directly.
Why: Dedup correctness is safety-critical — if the store wrongly suppresses a
     changed frame the pilot loses observability.
"""

from __future__ import annotations

import io
from typing import TYPE_CHECKING

import pytest
from PIL import Image as PilImage

from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, _compute_perceptual_hash, _hamming_distance

if TYPE_CHECKING:
    from pytest_mock import MockerFixture


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_png(r: int, g: int, b: int, width: int = 64, height: int = 48) -> bytes:
    """Return a solid-colour PNG as bytes.

    Args:
        r: Red channel value (0-255).
        g: Green channel value (0-255).
        b: Blue channel value (0-255).
        width: Image width in pixels.
        height: Image height in pixels.

    Returns:
        Raw PNG bytes of a solid-colour image.
    """
    img = PilImage.new("RGB", (width, height), color=(r, g, b))
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


# ---------------------------------------------------------------------------
# _compute_perceptual_hash
# ---------------------------------------------------------------------------


class TestComputePerceptualHash:
    """Tests for the _compute_perceptual_hash helper.

    Verifies output size, determinism, and content sensitivity.
    These properties are required for the PerceptualHashStore to function
    correctly as a dedup layer.
    """

    def test_compute_perceptual_hash_returns_64_bytes(self) -> None:
        """Hash of any valid PNG is exactly 64 bytes (8x8 greyscale thumbnail).

        Tests: _compute_perceptual_hash output size contract.
        How: Create a 64x48 solid PNG, call the helper, assert len == 64.
        Why: _hamming_distance and PerceptualHashStore both assume 64-byte
             sequences; a size mismatch would silently corrupt comparisons.
        """
        # Arrange
        png = _make_png(128, 128, 128)

        # Act
        result = _compute_perceptual_hash(png)

        # Assert
        assert len(result) == 64

    def test_compute_perceptual_hash_identical_images_match(self) -> None:
        """Two calls with the same PNG produce identical hash bytes.

        Tests: Hash determinism.
        How: Call _compute_perceptual_hash twice on the same bytes and compare.
        Why: Non-deterministic hashes would cause spurious dedup misses,
             delivering unchanged screenshots as JPEG tokens to the pilot.
        """
        # Arrange
        png = _make_png(200, 100, 50)

        # Act / Assert
        assert _compute_perceptual_hash(png) == _compute_perceptual_hash(png)

    def test_compute_perceptual_hash_different_images_differ(self) -> None:
        """A fully black image and a fully white image produce different hashes.

        Tests: Hash sensitivity to visually distinct content.
        How: Compare hashes of (0,0,0) and (255,255,255) solid PNGs.
        Why: Changed screens must produce distinct hashes so the pilot receives
             the updated screenshot rather than a cached dedup response.
        """
        # Arrange
        black = _make_png(0, 0, 0)
        white = _make_png(255, 255, 255)

        # Act
        hash_black = _compute_perceptual_hash(black)
        hash_white = _compute_perceptual_hash(white)

        # Assert
        assert hash_black != hash_white

    def test_compute_perceptual_hash_returns_bytes(self) -> None:
        """Return type is bytes, not str or bytearray.

        Tests: _compute_perceptual_hash return type.
        How: Assert isinstance(result, bytes) on a typical PNG.
        Why: The hash store and _hamming_distance expect raw bytes; a wrong
             type would raise a TypeError at runtime under xdist.
        """
        # Arrange
        png = _make_png(10, 20, 30)

        # Act
        result = _compute_perceptual_hash(png)

        # Assert
        assert isinstance(result, bytes)


# ---------------------------------------------------------------------------
# _hamming_distance
# ---------------------------------------------------------------------------


class TestHammingDistance:
    """Tests for the _hamming_distance byte-comparison helper.

    Covers the zero, maximum, single-delta, and symmetry cases that the
    dedup threshold logic depends on.
    """

    def test_hamming_distance_identical_bytes_is_zero(self) -> None:
        """Distance between identical byte sequences is 0.

        Tests: Zero-distance baseline case.
        How: Compare b'\\x00' * 64 against itself.
        Why: Unchanged screens must yield distance 0 so the threshold check
             passes and the pilot receives a ScreenshotUnchangedResult.
        """
        # Arrange
        data = b"\x00" * 64

        # Act / Assert
        assert _hamming_distance(data, data) == 0

    def test_hamming_distance_completely_different_is_64(self) -> None:
        """Distance between all-zero and all-0xFF sequences is 64.

        Tests: Maximum-distance case.
        How: Compare bytes(64) against b'\\xff' * 64.
        Why: Confirms the counter accumulates once per differing position across
             the full hash width.
        """
        # Arrange
        zeros = b"\x00" * 64
        ones = b"\xff" * 64

        # Act / Assert
        assert _hamming_distance(zeros, ones) == 64

    def test_hamming_distance_single_byte_differs(self) -> None:
        """Distance is 1 when exactly one byte out of 64 differs.

        Tests: Per-position increment.
        How: Construct two 64-byte sequences identical except at index 32.
        Why: Ensures each differing position contributes exactly 1 to the total.
        """
        # Arrange
        a = b"\x00" * 64
        b_bytes = b"\x00" * 32 + b"\x01" + b"\x00" * 31

        # Act / Assert
        assert _hamming_distance(a, b_bytes) == 1

    def test_hamming_distance_is_symmetric(self) -> None:
        """hamming_distance(a, b) equals hamming_distance(b, a).

        Tests: Symmetry property.
        How: Compare two distinct 64-byte sequences in both orders.
        Why: The dedup check compares (stored, current) but the distance must
             be order-independent; asymmetry would produce inconsistent results.
        """
        # Arrange
        a = b"\xaa" * 64
        b_bytes = b"\x55" * 64

        # Act / Assert
        assert _hamming_distance(a, b_bytes) == _hamming_distance(b_bytes, a)


# ---------------------------------------------------------------------------
# PerceptualHashStore
# ---------------------------------------------------------------------------


class TestPerceptualHashStore:
    """Tests for the PerceptualHashStore class.

    Covers construction, store() contract, check_unchanged() lifecycle,
    per-VM isolation, baseline replacement, threshold customisation, and the
    mandatory use of time.monotonic() for elapsed tracking.
    """

    def test_store_returns_hash_bytes(self) -> None:
        """store() returns the 64-byte perceptual hash computed from the PNG.

        Tests: store() return value contract.
        How: Call store() with a solid PNG and assert type and length.
        Why: Callers may use the returned hash for audit logging; the contract
             must be stable and typed.
        """
        # Arrange
        store = PerceptualHashStore()
        png = _make_png(128, 0, 255)

        # Act
        result = store.store("vm1", png)

        # Assert
        assert isinstance(result, bytes)
        assert len(result) == 64

    def test_check_unchanged_first_call_returns_false(self) -> None:
        """check_unchanged() on an empty store returns (False, 0.0).

        Tests: First-screenshot behaviour with no stored baseline.
        How: Call check_unchanged() before any store() call; assert both fields.
        Why: The very first screenshot must always be forwarded to the pilot —
             there is no baseline to compare against, so dedup must not suppress it.
        """
        # Arrange
        store = PerceptualHashStore()
        png = _make_png(10, 20, 30)

        # Act
        unchanged, elapsed = store.check_unchanged("vm1", png)

        # Assert
        assert unchanged is False
        assert elapsed == pytest.approx(0.0)

    def test_check_unchanged_same_image_returns_true(self) -> None:
        """After storing a frame, the identical frame is detected as unchanged.

        Tests: Core dedup hit path.
        How: store() a PNG, then call check_unchanged() with the same bytes.
        Why: Repeated identical screenshots must be suppressed to eliminate
             image tokens from the pilot's context window.
        """
        # Arrange
        store = PerceptualHashStore()
        png = _make_png(100, 100, 100)
        store.store("vm1", png)

        # Act
        unchanged, _ = store.check_unchanged("vm1", png)

        # Assert
        assert unchanged is True

    def test_check_unchanged_different_image_returns_false(self) -> None:
        """After storing, a completely different image is detected as changed.

        Tests: Dedup miss for visually distinct content.
        How: Store black PNG, then check_unchanged() with white PNG.
        Why: Changed screens must pass through to the pilot; dedup must not
             suppress content that differs visually beyond the threshold.
        """
        # Arrange
        store = PerceptualHashStore()
        png_a = _make_png(0, 0, 0)
        png_b = _make_png(255, 255, 255)
        store.store("vm1", png_a)

        # Act
        unchanged, _ = store.check_unchanged("vm1", png_b)

        # Assert
        assert unchanged is False

    def test_check_unchanged_elapsed_is_non_negative_after_store(self) -> None:
        """Elapsed time returned by check_unchanged() is >= 0.0 after a store.

        Tests: elapsed_seconds basic sanity.
        How: store() then check_unchanged(); assert elapsed >= 0.0.
        Why: Elapsed is used in ScreenshotUnchangedResult.last_capture_seconds_ago;
             a negative value would be nonsensical to the pilot.
        """
        # Arrange
        store = PerceptualHashStore()
        png = _make_png(50, 50, 50)
        store.store("vm1", png)

        # Act
        _, elapsed = store.check_unchanged("vm1", png)

        # Assert
        assert elapsed >= 0.0

    def test_check_unchanged_uses_monotonic_time(self, mocker: MockerFixture) -> None:
        """Elapsed seconds is computed from time.monotonic(), not datetime.now().

        Tests: Correct time source for elapsed tracking.
        How: Patch time.monotonic with controlled return values; verify the delta
             matches the mocked sequence (1030.5 - 1000.0 = 30.5 s).
        Why: Explicit plan constraint — wall-clock time is forbidden for elapsed
             calculation (NTP jumps, DST, and clock skew corrupt timing).
        """
        # Arrange
        store = PerceptualHashStore()
        png = _make_png(80, 80, 80)

        monotonic_seq = iter([1_000.0, 1_030.5])
        mocker.patch("mcp_vm_blackbox.vm_inspection.time.monotonic", side_effect=lambda: next(monotonic_seq))
        store.store("vm1", png)  # consumes first value → stored_time = 1000.0

        # Act
        unchanged, elapsed = store.check_unchanged("vm1", png)  # consumes second -> elapsed = 1030.5 - 1000.0

        # Assert
        assert unchanged is True
        assert abs(elapsed - 30.5) < 0.01

    def test_check_unchanged_independent_per_vm(self) -> None:
        """Hash store tracks each VM name independently with no cross-contamination.

        Tests: Per-VM isolation in _hashes.
        How: Store black for vm1, white for vm2; verify both self-checks pass
             and both cross-checks fail.
        Why: Dedup for one VM must not corrupt the baseline of another VM running
             in the same MCP server process.
        """
        # Arrange
        store = PerceptualHashStore()
        png_black = _make_png(0, 0, 0)
        png_white = _make_png(255, 255, 255)
        store.store("vm1", png_black)
        store.store("vm2", png_white)

        # Act
        unchanged_vm1, _ = store.check_unchanged("vm1", png_black)
        changed_vm1, _ = store.check_unchanged("vm1", png_white)
        unchanged_vm2, _ = store.check_unchanged("vm2", png_white)
        changed_vm2, _ = store.check_unchanged("vm2", png_black)

        # Assert
        assert unchanged_vm1 is True
        assert changed_vm1 is False
        assert unchanged_vm2 is True
        assert changed_vm2 is False

    def test_store_updates_hash_on_second_call(self) -> None:
        """Storing a second frame overwrites the baseline for that VM.

        Tests: Baseline replacement semantics.
        How: store() black, store() white, then check_unchanged() with both.
        Why: The most-recent captured frame is always the dedup reference — old
             baselines must not persist after a new screenshot is taken.
        """
        # Arrange
        store = PerceptualHashStore()
        png_a = _make_png(0, 0, 0)
        png_b = _make_png(255, 255, 255)
        store.store("vm1", png_a)
        store.store("vm1", png_b)  # overwrites the black baseline

        # Act
        unchanged, _ = store.check_unchanged("vm1", png_b)  # matches new baseline
        changed, _ = store.check_unchanged("vm1", png_a)  # stale baseline evicted

        # Assert
        assert unchanged is True
        assert changed is False
