# SPDX-License-Identifier: Apache-2.0
"""Tests for TokenHasher."""

# Third Party
import numpy as np
import pytest

# First Party
from lmcache.v1.multiprocess.token_hasher import (
    TokenHasher,
    chunk_hash_windows_numba,
    rolling_hash_windows_numba,
    unique_hits_direct_id_numba,
    update_table_id_numba,
)


@pytest.fixture
def hasher() -> TokenHasher:
    """TokenHasher with small chunk_size for testing."""
    return TokenHasher(chunk_size=4, hash_algorithm="blake3")


class TestTokenHasher:
    def test_init_blake3(self) -> None:
        hasher = TokenHasher(chunk_size=256, hash_algorithm="blake3")
        assert hasher.chunk_size == 256
        assert hasher.none_hash is not None

    def test_hash_tokens_returns_bytes(self, hasher: TokenHasher) -> None:
        h = hasher.hash_tokens([1, 2, 3, 4])
        assert isinstance(h, bytes)

    def test_hash_tokens_deterministic(self, hasher: TokenHasher) -> None:
        h1 = hasher.hash_tokens([1, 2, 3, 4])
        h2 = hasher.hash_tokens([1, 2, 3, 4])
        assert h1 == h2

    def test_hash_tokens_different_input(self, hasher: TokenHasher) -> None:
        h1 = hasher.hash_tokens([1, 2, 3, 4])
        h2 = hasher.hash_tokens([5, 6, 7, 8])
        assert h1 != h2

    def test_hash_tokens_with_prefix(self, hasher: TokenHasher) -> None:
        """Rolling hash: same tokens with different prefix produces different hash."""
        h_no_prefix = hasher.hash_tokens([1, 2, 3, 4])
        h_with_prefix = hasher.hash_tokens([1, 2, 3, 4], prefix_hash=h_no_prefix)
        assert h_no_prefix != h_with_prefix

    def test_compute_chunk_hashes_exact_chunks(self, hasher: TokenHasher) -> None:
        """8 tokens with chunk_size=4 produces 2 hashes."""
        tokens = list(range(8))
        hashes = hasher.compute_chunk_hashes(tokens)
        assert len(hashes) == 2

    def test_compute_chunk_hashes_partial_chunk_discarded(
        self, hasher: TokenHasher
    ) -> None:
        """10 tokens with chunk_size=4 produces 2 hashes (last 2 tokens discarded)."""
        tokens = list(range(10))
        hashes = hasher.compute_chunk_hashes(tokens)
        assert len(hashes) == 2

    def test_compute_chunk_hashes_too_few_tokens(self, hasher: TokenHasher) -> None:
        """3 tokens with chunk_size=4 produces 0 hashes."""
        hashes = hasher.compute_chunk_hashes([1, 2, 3])
        assert len(hashes) == 0

    def test_compute_chunk_hashes_empty(self, hasher: TokenHasher) -> None:
        hashes = hasher.compute_chunk_hashes([])
        assert len(hashes) == 0

    def test_compute_chunk_hashes_rolling(self, hasher: TokenHasher) -> None:
        """Second chunk hash depends on the first (rolling property)."""
        tokens = list(range(8))
        hashes = hasher.compute_chunk_hashes(tokens)
        # Hash of chunk [4,5,6,7] alone (no prefix) should differ
        standalone = hasher.hash_tokens([4, 5, 6, 7])
        assert hashes[1] != standalone

    def test_compute_chunk_hashes_matches_manual(self, hasher: TokenHasher) -> None:
        """compute_chunk_hashes should match manual rolling hash_tokens calls."""
        tokens = list(range(12))  # 3 chunks
        auto_hashes = hasher.compute_chunk_hashes(tokens)

        h0 = hasher.hash_tokens(tokens[0:4])
        h1 = hasher.hash_tokens(tokens[4:8], prefix_hash=h0)
        h2 = hasher.hash_tokens(tokens[8:12], prefix_hash=h1)
        assert auto_hashes == [h0, h1, h2]

    def test_hash_to_bytes_from_bytes(self) -> None:
        val = b"\x01\x02\x03"
        assert TokenHasher.hash_to_bytes(val) is val

    def test_hash_to_bytes_from_int(self) -> None:
        val = 42
        result = TokenHasher.hash_to_bytes(val)
        assert isinstance(result, bytes)
        assert len(result) == 8


_BASE = np.uint64(31)


class TestRollingHashWindowsNumba:
    def test_output_length(self) -> None:
        arr = np.arange(10, dtype=np.uint64)
        out = rolling_hash_windows_numba(arr, 4, _BASE)
        assert len(out) == 7  # 10 - 4 + 1

    def test_single_window(self) -> None:
        arr = np.array([1, 2, 3], dtype=np.uint64)
        out = rolling_hash_windows_numba(arr, 3, _BASE)
        assert len(out) == 1

    def test_k_equals_1(self) -> None:
        """With window size 1 each output equals the corresponding input element."""
        arr = np.array([7, 13, 42], dtype=np.uint64)
        out = rolling_hash_windows_numba(arr, 1, _BASE)
        np.testing.assert_array_equal(out, arr)

    def test_deterministic(self) -> None:
        arr = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
        out1 = rolling_hash_windows_numba(arr, 3, _BASE)
        out2 = rolling_hash_windows_numba(arr, 3, _BASE)
        np.testing.assert_array_equal(out1, out2)

    def test_different_inputs_different_outputs(self) -> None:
        arr1 = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
        arr2 = np.array([5, 4, 3, 2, 1], dtype=np.uint64)
        out1 = rolling_hash_windows_numba(arr1, 3, _BASE)
        out2 = rolling_hash_windows_numba(arr2, 3, _BASE)
        assert not np.array_equal(out1, out2)

    def test_different_base_different_output(self) -> None:
        arr = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
        out1 = rolling_hash_windows_numba(arr, 3, np.uint64(31))
        out2 = rolling_hash_windows_numba(arr, 3, np.uint64(37))
        assert not np.array_equal(out1, out2)

    def test_manual_values(self) -> None:
        """Verify polynomial hash values against manual computation.

        For arr=[1,2,3,4,5], k=3, base=31:
          power = 31^2 = 961
          window [1,2,3]: h = ((0*31+1)*31+2)*31+3 = 1026
          window [2,3,4]: h = 1026 - 1*961 = 65; 65*31+4 = 2019
          window [3,4,5]: h = 2019 - 2*961 = 97; 97*31+5 = 3012
        """
        arr = np.array([1, 2, 3, 4, 5], dtype=np.uint64)
        out = rolling_hash_windows_numba(arr, 3, _BASE)
        expected = np.array([1026, 2019, 3012], dtype=np.uint64)
        np.testing.assert_array_equal(out, expected)

    def test_output_dtype(self) -> None:
        arr = np.array([1, 2, 3], dtype=np.uint64)
        out = rolling_hash_windows_numba(arr, 2, _BASE)
        assert out.dtype == np.uint64


class TestChunkHashWindowsNumba:
    def test_output_length(self) -> None:
        arr = np.arange(12, dtype=np.uint64)
        out = chunk_hash_windows_numba(arr, 4, _BASE)
        assert len(out) == 3  # 12 // 4

    def test_partial_chunk_ignored(self) -> None:
        """Trailing tokens that don't fill a full window are dropped."""
        arr = np.arange(10, dtype=np.uint64)
        out = chunk_hash_windows_numba(arr, 4, _BASE)
        assert len(out) == 2  # 10 // 4

    def test_no_full_windows(self) -> None:
        arr = np.arange(3, dtype=np.uint64)
        out = chunk_hash_windows_numba(arr, 4, _BASE)
        assert len(out) == 0

    def test_deterministic(self) -> None:
        arr = np.arange(8, dtype=np.uint64)
        out1 = chunk_hash_windows_numba(arr, 4, _BASE)
        out2 = chunk_hash_windows_numba(arr, 4, _BASE)
        np.testing.assert_array_equal(out1, out2)

    def test_different_inputs_different_outputs(self) -> None:
        arr1 = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint64)
        arr2 = np.array([6, 5, 4, 3, 2, 1], dtype=np.uint64)
        out1 = chunk_hash_windows_numba(arr1, 3, _BASE)
        out2 = chunk_hash_windows_numba(arr2, 3, _BASE)
        assert not np.array_equal(out1, out2)

    def test_first_window_matches_rolling(self) -> None:
        """First chunk hash must equal the first rolling-hash window."""
        arr = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint64)
        chunk_out = chunk_hash_windows_numba(arr, 3, _BASE)
        rolling_out = rolling_hash_windows_numba(arr, 3, _BASE)
        assert chunk_out[0] == rolling_out[0]

    def test_manual_values(self) -> None:
        """Verify chunk hashes against manual computation.

        For arr=[1,2,3,4,5,6], k=3, base=31:
          window 0 [1,2,3]: ((0*31+1)*31+2)*31+3 = 1026
          window 1 [4,5,6]: ((0*31+4)*31+5)*31+6 = 4005
        """
        arr = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint64)
        out = chunk_hash_windows_numba(arr, 3, _BASE)
        expected = np.array([1026, 4005], dtype=np.uint64)
        np.testing.assert_array_equal(out, expected)

    def test_output_dtype(self) -> None:
        arr = np.array([1, 2, 3, 4], dtype=np.uint64)
        out = chunk_hash_windows_numba(arr, 2, _BASE)
        assert out.dtype == np.uint64


class TestUpdateTableIdNumba:
    def _empty_table(self, size: int = 8) -> np.ndarray:
        return np.full(size, -1, dtype=np.int64)

    def test_basic_update(self) -> None:
        table = self._empty_table()
        hashes = np.array([0], dtype=np.uint64)  # 0 & 7 = 0
        vals = np.array([99], dtype=np.int64)
        update_table_id_numba(hashes, table, vals)
        assert table[0] == 99

    def test_index_from_hash_lower_bits(self) -> None:
        """idx = hash & (table_size - 1)."""
        table = self._empty_table(8)  # mask = 7
        hashes = np.array([9], dtype=np.uint64)  # 9 & 7 = 1
        vals = np.array([42], dtype=np.int64)
        update_table_id_numba(hashes, table, vals)
        assert table[1] == 42
        assert all(table[i] == -1 for i in range(8) if i != 1)

    def test_multiple_updates(self) -> None:
        table = self._empty_table()
        hashes = np.array([0, 1, 2], dtype=np.uint64)
        vals = np.array([10, 20, 30], dtype=np.int64)
        update_table_id_numba(hashes, table, vals)
        assert table[0] == 10
        assert table[1] == 20
        assert table[2] == 30

    def test_collision_last_write_wins(self) -> None:
        """When two hashes map to the same slot, the last value is stored."""
        table = self._empty_table()
        # hash 0 → idx 0, hash 8 → idx 0 (8 & 7 = 0)
        hashes = np.array([0, 8], dtype=np.uint64)
        vals = np.array([10, 20], dtype=np.int64)
        update_table_id_numba(hashes, table, vals)
        assert table[0] == 20

    def test_modifies_in_place(self) -> None:
        table = self._empty_table()
        ptr = table.ctypes.data
        hashes = np.array([3], dtype=np.uint64)
        vals = np.array([7], dtype=np.int64)
        update_table_id_numba(hashes, table, vals)
        assert table.ctypes.data == ptr  # same buffer
        assert table[3] == 7

    def test_empty_hashes_no_change(self) -> None:
        table = self._empty_table()
        hashes = np.empty(0, dtype=np.uint64)
        vals = np.empty(0, dtype=np.int64)
        update_table_id_numba(hashes, table, vals)
        assert all(v == -1 for v in table)


class TestUniqueHitsDirectIdNumba:
    def _table(self, size: int = 8) -> np.ndarray:
        return np.full(size, -1, dtype=np.int64)

    def test_no_hits_all_empty(self) -> None:
        table = self._table()
        hashes = np.array([0, 1, 2], dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 4)
        assert len(out) == 0

    def test_single_hit(self) -> None:
        table = self._table()
        table[1] = 5
        hashes = np.array([1], dtype=np.uint64)  # 1 & 7 = 1 → ID 5
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 10)
        assert len(out) == 1
        assert out[0] == 5

    def test_deduplication(self) -> None:
        """Same ID reached via two different hashes is returned only once."""
        table = self._table()
        table[1] = 3  # index 1 → ID 3
        # hash 1 and hash 9 both map to index 1 (& 7 == 1)
        hashes = np.array([1, 9], dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 5)
        assert len(out) == 1
        assert out[0] == 3

    def test_multiple_unique_ids(self) -> None:
        table = self._table()
        table[0] = 0
        table[1] = 1
        table[2] = 2
        hashes = np.array([0, 1, 2], dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 5)
        assert len(out) == 3
        assert set(out) == {0, 1, 2}

    def test_mixed_hits_and_misses(self) -> None:
        table = self._table()
        table[2] = 10
        # indices 0 (miss), 2 (hit → 10), 5 (miss)
        hashes = np.array([0, 2, 5], dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 15)
        assert len(out) == 1
        assert out[0] == 10

    def test_order_of_first_encounter_preserved(self) -> None:
        """IDs appear in the order they are first seen in the hash stream."""
        table = self._table()
        table[0] = 2
        table[1] = 0
        table[2] = 1
        hashes = np.array([0, 1, 2], dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 3)
        np.testing.assert_array_equal(out, [2, 0, 1])

    def test_empty_hashes(self) -> None:
        table = self._table()
        hashes = np.empty(0, dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 4)
        assert len(out) == 0

    def test_output_dtype(self) -> None:
        table = self._table()
        table[0] = 0
        hashes = np.array([0], dtype=np.uint64)
        out = unique_hits_direct_id_numba(hashes, table, np.uint64(7), 1)
        assert out.dtype == np.int64
