"""Tests for filter_verify module — Cuckoo filter offline verification.

Tests parse_header, parse_salts_header, StaleFilterError, verify_offline,
and internal helper functions.
"""

from __future__ import annotations

import hashlib
import struct
import time

import pytest
from unittest.mock import patch

from optropic.filter_verify import (
    parse_header,
    parse_salts_header,
    verify_offline,
    StaleFilterError,
    _sha256,
    _uint32_be,
    _uint16_be,
    _fingerprint,
    _h1,
    _alt_index,
    _filter_lookup,
    _verify_signature,
    HEADER_SIZE,
    SIGNATURE_SIZE,
    SLOTS_PER_BUCKET,
)


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


def build_header(
    version: int = 1,
    issued_at: int | None = None,
    item_count: int = 0,
    key_id: int = 1,
    capacity: int = 1024,
) -> bytes:
    """Build a 19-byte filter header."""
    if issued_at is None:
        issued_at = int(time.time())
    return struct.pack(">BqIHI", version, issued_at, item_count, key_id, capacity)


def build_filter_binary(
    version: int = 1,
    issued_at: int | None = None,
    item_count: int = 0,
    key_id: int = 1,
    capacity: int = 64,
) -> bytes:
    """Build a complete filter binary: header + zeroed bucket data + 64-byte signature."""
    header = build_header(version, issued_at, item_count, key_id, capacity)
    bucket_data = b"\x00" * (capacity * SLOTS_PER_BUCKET * 2)
    signature = b"\x00" * SIGNATURE_SIZE
    return header + bucket_data + signature


# ---------------------------------------------------------------------------
# parse_header
# ---------------------------------------------------------------------------


class TestParseHeader:
    def test_valid_header(self):
        now = int(time.time())
        buf = build_header(version=2, issued_at=now, item_count=42, key_id=7, capacity=2048)
        h = parse_header(buf)

        assert h["version"] == 2
        assert h["issued_at"] == now
        assert h["item_count"] == 42
        assert h["key_id"] == 7
        assert h["capacity"] == 2048

    def test_buffer_too_small(self):
        with pytest.raises(ValueError, match="too small"):
            parse_header(b"\x00" * 10)

    def test_larger_buffer_ok(self):
        buf = build_header(version=1) + b"\x00" * 100
        h = parse_header(buf)
        assert h["version"] == 1

    def test_specific_timestamp(self):
        ts = 1700000000
        buf = build_header(issued_at=ts)
        h = parse_header(buf)
        assert h["issued_at"] == ts


# ---------------------------------------------------------------------------
# parse_salts_header
# ---------------------------------------------------------------------------


class TestParseSaltsHeader:
    def test_single_pair(self):
        salts = parse_salts_header("tenant-1:aabbccdd")
        assert len(salts) == 1
        assert salts["tenant-1"] == bytes.fromhex("aabbccdd")

    def test_multiple_pairs(self):
        salts = parse_salts_header("t1:aabb,t2:ccdd")
        assert len(salts) == 2
        assert salts["t1"] == bytes.fromhex("aabb")
        assert salts["t2"] == bytes.fromhex("ccdd")

    def test_empty_string(self):
        salts = parse_salts_header("")
        assert len(salts) == 0

    def test_skip_malformed(self):
        salts = parse_salts_header("valid:aabb,nocolon,also-valid:ccdd")
        assert len(salts) == 2
        assert "nocolon" not in salts

    def test_strips_whitespace(self):
        salts = parse_salts_header(" t1 : aabb , t2 : ccdd ")
        assert len(salts) == 2
        assert "t1" in salts
        assert "t2" in salts


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


class TestInternalHelpers:
    def test_sha256(self):
        result = _sha256(b"hello")
        expected = hashlib.sha256(b"hello").digest()
        assert result == expected

    def test_uint32_be(self):
        data = struct.pack(">I", 0xDEADBEEF)
        assert _uint32_be(data, 0) == 0xDEADBEEF

    def test_uint16_be(self):
        data = struct.pack(">H", 0xCAFE)
        assert _uint16_be(data, 0) == 0xCAFE

    def test_fingerprint_is_nonzero(self):
        fp = _fingerprint("test-item")
        assert isinstance(fp, int)
        assert fp > 0
        assert fp <= 0xFFFF

    def test_fingerprint_deterministic(self):
        assert _fingerprint("abc") == _fingerprint("abc")

    def test_h1_within_capacity(self):
        capacity = 256
        idx = _h1("test-item", capacity)
        assert 0 <= idx < capacity

    def test_alt_index_within_capacity(self):
        capacity = 256
        idx = _h1("test-item", capacity)
        fp = _fingerprint("test-item")
        alt = _alt_index(idx, fp, capacity)
        # alt_index can exceed capacity before modulo — just check it's an int
        assert isinstance(alt, int)

    def test_filter_lookup_empty_filter(self):
        """Empty filter (all zeroes) should never match."""
        capacity = 64
        filter_data = b"\x00" * (capacity * SLOTS_PER_BUCKET * 2)
        assert _filter_lookup(filter_data, capacity, "anything") is False


# ---------------------------------------------------------------------------
# StaleFilterError
# ---------------------------------------------------------------------------


class TestStaleFilterError:
    def test_attributes(self):
        err = StaleFilterError(300000, 259200)
        assert err.code == "FILTER_STALE_CRITICAL"
        assert err.age_seconds == 300000
        assert err.trust_window_seconds == 259200
        assert "300000" in str(err)
        assert "259200" in str(err)


# ---------------------------------------------------------------------------
# verify_offline
# ---------------------------------------------------------------------------


class TestVerifyOffline:
    def test_clear_for_empty_filter(self):
        now = int(time.time())
        filter_bytes = build_filter_binary(issued_at=now, capacity=64)
        salts = {"tenant-1": bytes.fromhex("aabb")}

        result = verify_offline("asset-123", filter_bytes, salts)

        assert result.revocation_status == "clear"
        assert result.signature_valid is True
        assert result.verification_mode == "offline"
        assert result.freshness == "current"
        assert result.filter_age_seconds is not None
        assert result.filter_age_seconds < 5

    def test_stale_filter_permissive(self):
        old_ts = int(time.time()) - 300000  # ~3.5 days
        filter_bytes = build_filter_binary(issued_at=old_ts, capacity=64)
        salts = {"t1": b"\x01"}

        result = verify_offline(
            "asset-456",
            filter_bytes,
            salts,
            filter_policy="permissive",
            trust_window_seconds=259200,
        )

        assert result.freshness == "stale"
        assert result.revocation_status == "clear"

    def test_stale_filter_strict_raises(self):
        old_ts = int(time.time()) - 300000
        filter_bytes = build_filter_binary(issued_at=old_ts, capacity=64)
        salts = {"t1": b"\x01"}

        with pytest.raises(StaleFilterError):
            verify_offline(
                "asset-789",
                filter_bytes,
                salts,
                filter_policy="strict",
                trust_window_seconds=259200,
            )

    def test_clear_with_empty_salts(self):
        now = int(time.time())
        filter_bytes = build_filter_binary(issued_at=now, capacity=64)

        result = verify_offline("asset-999", filter_bytes, {})
        assert result.revocation_status == "clear"

    def test_verified_at_is_iso_string(self):
        now = int(time.time())
        filter_bytes = build_filter_binary(issued_at=now, capacity=64)

        result = verify_offline("asset-iso", filter_bytes, {})
        # Should contain 'T' (ISO 8601)
        assert "T" in result.verified_at

    def test_signature_invalid_returns_unknown(self):
        """When public_key is given and signature is bad, expect 'unknown'."""
        now = int(time.time())
        filter_bytes = build_filter_binary(issued_at=now, capacity=64)
        # Dummy public key — _verify_signature will fail
        fake_pk = b"\x00" * 32

        with patch("optropic.filter_verify._verify_signature", return_value=False):
            result = verify_offline(
                "asset-sig",
                filter_bytes,
                {"t1": b"\xaa"},
                public_key=fake_pk,
            )

        assert result.signature_valid is False
        assert result.revocation_status == "unknown"


# ---------------------------------------------------------------------------
# _verify_signature
# ---------------------------------------------------------------------------


class TestVerifySignature:
    def test_too_small_buffer_returns_false(self):
        assert _verify_signature(b"\x00" * 10, b"\x00" * 32) is False

    def test_without_pynacl_returns_true(self):
        """When PyNaCl is not installed, signature check is skipped (returns True)."""
        buf = b"\x00" * (HEADER_SIZE + SIGNATURE_SIZE + 10)
        # This test relies on nacl not being installed, or the ImportError path
        # Either way, should not crash
        result = _verify_signature(buf, b"\x00" * 32)
        assert isinstance(result, bool)
