"""Tests for EncryptedSerializer with msgpack allowlist functionality.

These tests mirror the msgpack allowlist tests in test_jsonplus.py but run them
through the EncryptedSerializer to ensure the allowlist behavior is preserved
when encryption is enabled.
"""

from __future__ import annotations

import logging
import pathlib
import re
import uuid
from collections import deque
from datetime import date, datetime, time, timezone
from decimal import Decimal
from ipaddress import IPv4Address
from typing import Literal, cast

import ormsgpack
import pytest
from pydantic import BaseModel

from langgraph.checkpoint.base import BaseCheckpointSaver, _with_msgpack_allowlist
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde.base import CipherProtocol
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.serde.jsonplus import (
    EXT_METHOD_SINGLE_ARG,
    JsonPlusSerializer,
    _msgpack_enc,
    _warned_blocked_types,
    _warned_unregistered_types,
)


class InnerPydantic(BaseModel):
    hello: str


class MyPydantic(BaseModel):
    foo: str
    bar: int
    inner: InnerPydantic


class AnotherPydantic(BaseModel):
    foo: str


class _PassthroughCipher(CipherProtocol):
    def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
        return "passthrough", plaintext

    def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
        assert ciphername == "passthrough"
        return ciphertext


def _make_encrypted_serde(
    allowed_msgpack_modules: (
        _lg_msgpack.AllowedMsgpackModules | Literal[True] | None | object
    ) = _lg_msgpack._SENTINEL,
) -> EncryptedSerializer:
    """Create an EncryptedSerializer with AES encryption for testing."""
    inner = JsonPlusSerializer(
        allowed_msgpack_modules=cast(
            _lg_msgpack.AllowedMsgpackModules | Literal[True] | None,
            allowed_msgpack_modules,
        )
    )
    return EncryptedSerializer.from_pycryptodome_aes(
        serde=inner, key=b"1234567890123456"
    )


def test_msgpack_method_pathlib_blocked_encrypted_strict(
    tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
) -> None:
    target = tmp_path / "secret.txt"
    target.write_text("secret")
    payload = ormsgpack.packb(
        ormsgpack.Ext(
            EXT_METHOD_SINGLE_ARG,
            _msgpack_enc(("pathlib", "Path", target, "read_text")),
        ),
        option=ormsgpack.OPT_NON_STR_KEYS,
    )
    serde = EncryptedSerializer(
        _PassthroughCipher(),
        JsonPlusSerializer(allowed_msgpack_modules=None),
    )

    caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
    caplog.clear()
    result = serde.loads_typed(("msgpack+passthrough", payload))

    assert result == target
    assert "blocked deserialization of method call pathlib.path.read_text" in (
        caplog.text.lower()
    )


class TestEncryptedSerializerMsgpackAllowlist:
    """Test msgpack allowlist behavior through EncryptedSerializer."""

    @pytest.fixture(autouse=True)
    def _reset_warned_types(self) -> None:
        # Warning dedup state is process-global; reset per-test so each case
        # sees a fresh slate and assertions about warning emission are stable.
        _warned_unregistered_types.clear()
        _warned_blocked_types.clear()

    def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test safe types deserialize without warnings through encryption."""
        serde = _make_encrypted_serde()

        safe_objects = [
            datetime.now(),
            date.today(),
            time(12, 30),
            timezone.utc,
            uuid.uuid4(),
            Decimal("123.45"),
            {1, 2, 3},
            frozenset([1, 2, 3]),
            deque([1, 2, 3]),
            IPv4Address("192.168.1.1"),
            pathlib.Path("/tmp/test"),
        ]

        for obj in safe_objects:
            caplog.clear()
            dumped = serde.dumps_typed(obj)
            # Verify encryption is happening
            assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
            result = serde.loads_typed(dumped)
            assert "unregistered type" not in caplog.text.lower(), (
                f"Unexpected warning for {type(obj)}"
            )
            assert result is not None

    def test_pydantic_warns_by_default(self, caplog: pytest.LogCaptureFixture) -> None:
        """Pydantic models not in allowlist should log warning but still deserialize."""
        current = _lg_msgpack.STRICT_MSGPACK_ENABLED
        _lg_msgpack.STRICT_MSGPACK_ENABLED = False
        serde = _make_encrypted_serde()

        obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))

        caplog.clear()
        dumped = serde.dumps_typed(obj)
        assert "+aes" in dumped[0]
        result = serde.loads_typed(dumped)

        assert "unregistered type" in caplog.text.lower()
        assert "allowed_msgpack_modules" in caplog.text
        assert result == obj
        _lg_msgpack.STRICT_MSGPACK_ENABLED = current

    def test_strict_mode_blocks_unregistered(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Strict mode should block unregistered types through encryption."""
        serde = _make_encrypted_serde(allowed_msgpack_modules=None)

        obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))

        caplog.clear()
        dumped = serde.dumps_typed(obj)
        assert "+aes" in dumped[0]
        result = serde.loads_typed(dumped)

        assert "blocked" in caplog.text.lower()
        expected = obj.model_dump()
        assert result == expected

    def test_allowlist_silences_warning(self, caplog: pytest.LogCaptureFixture) -> None:
        """Types in allowed_msgpack_modules should deserialize without warnings."""
        serde = _make_encrypted_serde(
            allowed_msgpack_modules=[
                ("tests.test_encrypted", "MyPydantic"),
                ("tests.test_encrypted", "InnerPydantic"),
            ]
        )

        obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))

        caplog.clear()
        dumped = serde.dumps_typed(obj)
        assert "+aes" in dumped[0]
        result = serde.loads_typed(dumped)

        assert "unregistered type" not in caplog.text.lower()
        assert "blocked" not in caplog.text.lower()
        assert result == obj

    def test_allowlist_blocks_non_listed(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Allowlists should block unregistered types even through encryption."""
        serde = _make_encrypted_serde(
            allowed_msgpack_modules=[("tests.test_encrypted", "MyPydantic")]
        )

        obj = AnotherPydantic(foo="nope")

        caplog.clear()
        dumped = serde.dumps_typed(obj)
        assert "+aes" in dumped[0]
        result = serde.loads_typed(dumped)

        assert "blocked" in caplog.text.lower()
        expected = obj.model_dump()
        assert result == expected

    def test_safe_types_value_equality(self, caplog: pytest.LogCaptureFixture) -> None:
        """Verify safe types are correctly restored with proper values through encryption."""
        serde = _make_encrypted_serde(allowed_msgpack_modules=None)

        test_cases = [
            datetime(2024, 1, 15, 12, 30, 45, 123456),
            date(2024, 6, 15),
            time(14, 30, 0),
            uuid.UUID("12345678-1234-5678-1234-567812345678"),
            Decimal("123.456789"),
            {1, 2, 3, 4, 5},
            frozenset(["a", "b", "c"]),
            deque([1, 2, 3]),
            IPv4Address("10.0.0.1"),
            pathlib.Path("/some/test/path"),
            re.compile(r"\d+", re.MULTILINE),
        ]

        for obj in test_cases:
            caplog.clear()
            dumped = serde.dumps_typed(obj)
            assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
            result = serde.loads_typed(dumped)

            assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
            if isinstance(obj, re.Pattern):
                assert result.pattern == obj.pattern
                assert result.flags == obj.flags
            else:
                assert result == obj, (
                    f"Value mismatch for {type(obj)}: {result} != {obj}"
                )

    def test_regex_safe_type(self, caplog: pytest.LogCaptureFixture) -> None:
        """re.compile patterns should deserialize without warnings as a safe type."""
        serde = _make_encrypted_serde(allowed_msgpack_modules=None)
        pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)

        caplog.clear()
        dumped = serde.dumps_typed(pattern)
        assert "+aes" in dumped[0]
        result = serde.loads_typed(dumped)

        assert "blocked" not in caplog.text.lower()
        assert "unregistered" not in caplog.text.lower()
        assert result.pattern == pattern.pattern
        assert result.flags == pattern.flags


class TestWithMsgpackAllowlistEncrypted:
    """Test _with_msgpack_allowlist function with EncryptedSerializer."""

    def test_propagates_allowlist_to_inner_serde(self) -> None:
        """_with_msgpack_allowlist should propagate allowlist to inner JsonPlusSerializer."""
        inner = JsonPlusSerializer(allowed_msgpack_modules=None)
        encrypted = EncryptedSerializer.from_pycryptodome_aes(
            serde=inner, key=b"1234567890123456"
        )

        extra = [("my.module", "MyClass")]
        result = _with_msgpack_allowlist(encrypted, extra)

        # Should return a new EncryptedSerializer
        assert isinstance(result, EncryptedSerializer)
        assert result is not encrypted
        # Inner serde should have the allowlist
        assert isinstance(result.serde, JsonPlusSerializer)
        assert isinstance(result.serde._allowed_msgpack_modules, set)
        assert ("my.module", "MyClass") in result.serde._allowed_msgpack_modules

    def test_preserves_cipher(self) -> None:
        """_with_msgpack_allowlist should preserve the cipher from the original."""
        inner = JsonPlusSerializer(allowed_msgpack_modules=None)
        encrypted = EncryptedSerializer.from_pycryptodome_aes(
            serde=inner, key=b"1234567890123456"
        )

        result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])

        assert isinstance(result, EncryptedSerializer)
        # Should use the same cipher
        assert result.cipher is encrypted.cipher

    def test_returns_same_if_not_jsonplus_inner(self) -> None:
        """_with_msgpack_allowlist should return same serde if inner is not JsonPlusSerializer."""

        class DummyInnerSerde:
            def dumps_typed(self, obj: object) -> tuple[str, bytes]:
                return ("dummy", b"")

            def loads_typed(self, data: tuple[str, bytes]) -> None:
                return None

        from langgraph.checkpoint.serde.base import CipherProtocol

        class DummyCipher(CipherProtocol):
            def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
                return "dummy", plaintext

            def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
                return ciphertext

        encrypted = EncryptedSerializer(DummyCipher(), DummyInnerSerde())
        result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])

        assert result is encrypted

    def test_warns_if_allowlist_unsupported(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        class DummySerde:
            def dumps_typed(self, obj: object) -> tuple[str, bytes]:
                return ("dummy", b"")

            def loads_typed(self, data: tuple[str, bytes]) -> object:
                return data

        serde = DummySerde()
        caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.base")
        caplog.clear()

        result = _with_msgpack_allowlist(serde, [("my.module", "MyClass")])

        assert result is serde
        assert "does not support msgpack allowlist" in caplog.text.lower()

    def test_noop_allowlist_returns_same_encrypted_instance(self) -> None:
        inner = JsonPlusSerializer(allowed_msgpack_modules=None)
        encrypted = EncryptedSerializer.from_pycryptodome_aes(
            serde=inner, key=b"1234567890123456"
        )

        result = _with_msgpack_allowlist(encrypted, ())

        assert result is encrypted

    def test_functional_roundtrip_with_allowlist(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """End-to-end test: allowlist applied via _with_msgpack_allowlist works."""
        inner = JsonPlusSerializer(allowed_msgpack_modules=None)
        encrypted = EncryptedSerializer.from_pycryptodome_aes(
            serde=inner, key=b"1234567890123456"
        )

        # Apply allowlist for MyPydantic
        updated = _with_msgpack_allowlist(
            encrypted,
            [
                ("tests.test_encrypted", "MyPydantic"),
                ("tests.test_encrypted", "InnerPydantic"),
            ],
        )

        obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))

        caplog.clear()
        dumped = updated.dumps_typed(obj)
        assert "+aes" in dumped[0]
        result = updated.loads_typed(dumped)

        # Should deserialize without blocking
        assert "blocked" not in caplog.text.lower()
        assert result == obj

    def test_original_still_blocks_after_with_allowlist(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Original serde should still block after _with_msgpack_allowlist creates a new one."""
        inner = JsonPlusSerializer(allowed_msgpack_modules=None)
        encrypted = EncryptedSerializer.from_pycryptodome_aes(
            serde=inner, key=b"1234567890123456"
        )

        # Apply allowlist - this should create a NEW serde
        _with_msgpack_allowlist(
            encrypted,
            [("tests.test_encrypted", "MyPydantic")],
        )

        # Original should still block
        obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))

        caplog.clear()
        dumped = encrypted.dumps_typed(obj)
        result = encrypted.loads_typed(dumped)

        assert "blocked" in caplog.text.lower()
        assert result == obj.model_dump()


class TestEncryptedSerializerUnencryptedFallback:
    """Test that EncryptedSerializer handles unencrypted data correctly."""

    def test_loads_unencrypted_data(self) -> None:
        """EncryptedSerializer should handle unencrypted data for backwards compat."""
        plain = JsonPlusSerializer(allowed_msgpack_modules=None)
        encrypted = _make_encrypted_serde(allowed_msgpack_modules=None)

        obj = {"key": "value", "number": 42}

        # Serialize with plain serde
        dumped = plain.dumps_typed(obj)
        assert "+aes" not in dumped[0]

        # Should still deserialize with encrypted serde
        result = encrypted.loads_typed(dumped)
        assert result == obj


def test_with_allowlist_uses_copy_protocol() -> None:
    class CopyAwareSaver(BaseCheckpointSaver[str]):
        def __init__(self) -> None:
            super().__init__(serde=JsonPlusSerializer(allowed_msgpack_modules=None))
            self.copy_was_used = False

        def __copy__(self) -> object:
            clone = object.__new__(self.__class__)
            clone.__dict__ = self.__dict__.copy()
            clone.copy_was_used = True
            return clone

    saver = CopyAwareSaver()

    updated = saver.with_allowlist([("tests.test_encrypted", "MyPydantic")])

    assert isinstance(updated, CopyAwareSaver)
    assert updated is not saver
    assert updated.copy_was_used is True
    assert saver.copy_was_used is False
