import dataclasses
import json
import logging
import pathlib
import re
import sys
import uuid
from collections import deque
from datetime import date, datetime, time, timezone
from decimal import Decimal
from enum import Enum
from ipaddress import IPv4Address
from zoneinfo import ZoneInfo

import dataclasses_json
import numpy as np
import ormsgpack
import pandas as pd
import pytest
from langchain_core.documents.base import Document
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, SecretStr
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1

from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules
from langgraph.checkpoint.serde.event_hooks import (
    SerdeEvent,
    register_serde_event_listener,
)
from langgraph.checkpoint.serde.jsonplus import (
    EXT_METHOD_SINGLE_ARG,
    InvalidModuleError,
    JsonPlusSerializer,
    _msgpack_enc,
    _msgpack_ext_hook_to_json,
    _warned_blocked_types,
    _warned_unregistered_types,
)
from langgraph.store.base import Item


class InnerPydantic(BaseModel):
    hello: str


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


class AnotherPydantic(BaseModel):
    foo: str


class InnerPydanticV1(BaseModelV1):
    hello: str


class MyPydanticV1(BaseModelV1):
    foo: str
    bar: int
    inner: InnerPydanticV1


@dataclasses.dataclass
class InnerDataclass:
    hello: str


@dataclasses.dataclass
class MyDataclass:
    foo: str
    bar: int
    inner: InnerDataclass

    def something(self) -> None:
        pass


@dataclasses.dataclass(slots=True)
class MyDataclassWSlots:
    foo: str
    bar: int
    inner: InnerDataclass

    def something(self) -> None:
        pass


class MyEnum(Enum):
    FOO = "foo"
    BAR = "bar"


@dataclasses_json.dataclass_json
@dataclasses.dataclass
class Person:
    name: str


def test_serde_jsonplus() -> None:
    uid = uuid.UUID(int=1)
    deque_instance = deque([1, 2, 3])
    tzn = ZoneInfo("America/New_York")
    ip4 = IPv4Address("192.168.0.1")
    current_date = date(2024, 4, 19)
    current_time = time(23, 4, 57, 51022, timezone.max)
    current_timestamp = datetime(2024, 4, 19, 23, 4, 57, 51022, timezone.max)

    to_serialize = {
        "path": pathlib.Path("foo", "bar"),
        "re": re.compile(r"foo", re.DOTALL),
        "decimal": Decimal("1.10101"),
        "set": {1, 2, frozenset({1, 2})},
        "frozen_set": frozenset({1, 2, 3}),
        "ip4": ip4,
        "deque": deque_instance,
        "tzn": tzn,
        "date": current_date,
        "time": current_time,
        "uid": uid,
        "timestamp": current_timestamp,
        "my_rich_dict": {(1, 2, 3): 45},
        "my_slotted_class": MyDataclassWSlots("bar", 2, InnerDataclass("hello")),
        "my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")),
        "my_enum": MyEnum.FOO,
        "my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")),
        "my_secret_str": SecretStr("meow"),
        "person": Person(name="foo"),
        "a_bool": True,
        "a_none": None,
        "a_str": "foo",
        "a_str_nuc": "foo\u0000",
        "a_str_uc": "foo ⛰️",
        "a_str_ucuc": "foo \u26f0\ufe0f\u0000",
        "a_str_ucucuc": "foo \\u26f0\\ufe0f",
        "an_int": 1,
        "a_float": 1.1,
        "a_bytes": b"my bytes",
        "a_bytearray": bytearray([42]),
        "my_item": Item(
            value={},
            key="my-key",
            namespace=("a", "name", " "),
            created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
            updated_at=datetime(2024, 9, 24, 17, 29, 11, 128397),
        ),
    }

    if sys.version_info < (3, 14):
        to_serialize["my_pydantic_v1"] = MyPydanticV1(
            foo="foo", bar=1, inner=InnerPydanticV1(hello="hello")
        )
        to_serialize["my_secret_str_v1"] = SecretStrV1("meow")

    allowed_msgpack_modules: AllowedMsgpackModules = [
        InnerDataclass,
        MyDataclass,
        MyDataclassWSlots,
        MyEnum,
        InnerPydantic,
        MyPydantic,
        # Testing that it supports both.
        (Person.__module__, Person.__name__),
        (SecretStr.__module__, SecretStr.__name__),
    ]
    if sys.version_info < (3, 14):
        allowed_msgpack_modules.extend(  # type: ignore
            [
                (InnerPydanticV1.__module__, InnerPydanticV1.__name__),
                (MyPydanticV1.__module__, MyPydanticV1.__name__),
                (SecretStrV1.__module__, SecretStrV1.__name__),
            ]
        )

    serde = JsonPlusSerializer(allowed_msgpack_modules=allowed_msgpack_modules)

    dumped = serde.dumps_typed(to_serialize)

    assert dumped[0] == "msgpack"
    assert serde.loads_typed(dumped) == to_serialize

    for value in to_serialize.values():
        assert serde.loads_typed(serde.dumps_typed(value)) == value

    surrogates = [
        "Hello??",
        "Python??",
        "Surrogate??",
        "Example??",
        "String??",
        "With??",
        "Surrogates??",
        "Embedded??",
        "In??",
        "The??",
        "Text??",
        "收花🙄·到",
    ]
    serde = JsonPlusSerializer(pickle_fallback=False)

    assert serde.loads_typed(serde.dumps_typed(surrogates)) == surrogates


def test_serde_jsonplus_json_mode() -> None:
    uid = uuid.UUID(int=1)
    deque_instance = deque([1, 2, 3])
    tzn = ZoneInfo("America/New_York")
    ip4 = IPv4Address("192.168.0.1")
    current_date = date(2024, 4, 19)
    current_time = time(23, 4, 57, 51022, timezone.max)
    current_timestamp = datetime(2024, 4, 19, 23, 4, 57, 51022, timezone.max)

    to_serialize = {
        "path": pathlib.Path("foo", "bar"),
        "re": re.compile(r"foo", re.DOTALL),
        "decimal": Decimal("1.10101"),
        "set": {1, 2, frozenset({1, 2})},
        "frozen_set": frozenset({1, 2, 3}),
        "ip4": ip4,
        "deque": deque_instance,
        "tzn": tzn,
        "date": current_date,
        "time": current_time,
        "uid": uid,
        "timestamp": current_timestamp,
        "my_slotted_class": MyDataclassWSlots("bar", 2, InnerDataclass("hello")),
        "my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")),
        "my_enum": MyEnum.FOO,
        "my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")),
        "my_secret_str": SecretStr("meow"),
        "person": Person(name="foo"),
        "a_bool": True,
        "a_none": None,
        "a_str": "foo",
        "a_str_nuc": "foo\u0000",
        "a_str_uc": "foo ⛰️",
        "a_str_ucuc": "foo \u26f0\ufe0f\u0000",
        "a_str_ucucuc": "foo \\u26f0\\ufe0f",
        "an_int": 1,
        "a_float": 1.1,
        "a_bytes": b"my bytes",
        "a_bytearray": bytearray([42]),
        "my_item": Item(
            value={},
            key="my-key",
            namespace=("a", "name", " "),
            created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
            updated_at=datetime(2024, 9, 24, 17, 29, 11, 128397),
        ),
    }

    if sys.version_info < (3, 14):
        to_serialize["my_pydantic_v1"] = MyPydanticV1(
            foo="foo", bar=1, inner=InnerPydanticV1(hello="hello")
        )
        to_serialize["my_secret_str_v1"] = SecretStrV1("meow")

    serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json)

    dumped = serde.dumps_typed(to_serialize)

    assert dumped[0] == "msgpack"
    result = serde.loads_typed(dumped)

    expected_result = {
        "path": ["foo", "bar"],
        "re": ["foo", 48],
        "decimal": "1.10101",
        "set": [1, 2, [1, 2]],
        "frozen_set": [1, 2, 3],
        "ip4": "192.168.0.1",
        "deque": [1, 2, 3],
        "tzn": "America/New_York",
        "date": [2024, 4, 19],
        "time": {
            "hour": 23,
            "minute": 4,
            "second": 57,
            "microsecond": 51022,
            "tzinfo": [[0, 86340, 0]],
            "fold": 0,
        },
        "uid": "00000000-0000-0000-0000-000000000001",
        "timestamp": "2024-04-19T23:04:57.051022+23:59",
        "my_slotted_class": {"foo": "bar", "bar": 2, "inner": {"hello": "hello"}},
        "my_dataclass": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}},
        "my_enum": "foo",
        "my_pydantic": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}},
        "my_secret_str": "meow",
        "person": {"name": "foo"},
        "a_bool": True,
        "a_none": None,
        "a_str": "foo",
        "a_str_nuc": "foo\x00",
        "a_str_uc": "foo ⛰️",
        "a_str_ucuc": "foo ⛰️\x00",
        "a_str_ucucuc": "foo \\u26f0\\ufe0f",
        "an_int": 1,
        "a_float": 1.1,
        "a_bytes": b"my bytes",
        "a_bytearray": b"*",
        "my_item": {
            "namespace": ["a", "name", " "],
            "key": "my-key",
            "value": {},
            "created_at": "2024-09-24T17:29:10.128397",
            "updated_at": "2024-09-24T17:29:11.128397",
        },
    }

    if sys.version_info < (3, 14):
        expected_result["my_pydantic_v1"] = {
            "foo": "foo",
            "bar": 1,
            "inner": {"hello": "hello"},
        }
        expected_result["my_secret_str_v1"] = "meow"

    assert result == expected_result


def test_serde_jsonplus_bytes() -> None:
    serde = JsonPlusSerializer()

    some_bytes = b"my bytes"
    dumped = serde.dumps_typed(some_bytes)

    assert dumped == ("bytes", some_bytes)
    assert serde.loads_typed(dumped) == some_bytes


def test_lc2_json_safe_type_revives_without_allowlist() -> None:
    """Old 'json' blobs with lc=2 for safe types must revive without an explicit allowlist.

    Regression test for: https://github.com/langchain-ai/langgraph/issues/7498
    Threads checkpointed before v1.0.1 (pre-msgpack) stored messages as lc=2 JSON
    constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
    rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
    """
    from langchain_core.messages import AIMessage

    serde = JsonPlusSerializer()  # default: _allowed_json_modules=None

    human_blob = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "human", "HumanMessage"],
        "kwargs": {"content": "hello", "type": "human"},
    }
    ai_blob = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        "kwargs": {"content": "hi there", "type": "ai"},
    }
    result = serde.loads_typed(("json", json.dumps([human_blob, ai_blob]).encode()))

    assert len(result) == 2
    assert isinstance(result[0], HumanMessage), (
        f"Expected HumanMessage, got {type(result[0])}: {result[0]!r}\n"
        "lc=2 JSON blobs for safe types must deserialize without an explicit allowlist"
    )
    assert result[0].content == "hello"
    assert isinstance(result[1], AIMessage)
    assert result[1].content == "hi there"


def test_lc2_json_unknown_type_stays_blocked_without_allowlist() -> None:
    """lc=2 JSON blobs for types NOT in SAFE_MSGPACK_TYPES still require an allowlist."""
    serde = JsonPlusSerializer()
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["pprint", "pprint"],
        "kwargs": {"object": "HELLO"},
    }
    # No allowlist configured → raw dict returned (not raised, not reconstructed)
    result = serde.loads_typed(("json", json.dumps(load).encode()))
    assert isinstance(result, dict), "Unknown lc=2 type must stay as raw dict"
    assert result.get("lc") == 2


def test_deserde_invalid_module() -> None:
    serde = JsonPlusSerializer()
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["pprint", "pprint"],
        "kwargs": {"object": "HELLO"},
    }
    with pytest.raises(InvalidModuleError):
        serde._revive_lc2(load)
    serde = JsonPlusSerializer(allowed_json_modules=[("pprint", "pprint")])
    serde.loads_typed(("json", json.dumps(load).encode("utf-8")))


def test_lc2_json_method_field_is_ignored() -> None:
    """The `method` field on lc=2 envelopes is ignored.

    Regression test for GHSA-fjqc-hq36-qh5p: `_revive_lc2` previously resolved
    `getattr(cls, method)` from the envelope, which let an attacker pivot a
    safe pydantic class (e.g., AIMessage) to `parse_raw(..., allow_pickle=True)`
    and reach `pickle.loads`. Revival now uses only the default constructor.

    Verifies that an envelope carrying ``method="parse_raw"`` does not dispatch
    to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would
    produce, which proves the default constructor ran instead of ``parse_raw``.
    """
    from langchain_core.messages import AIMessage

    serde = JsonPlusSerializer()
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        "method": "parse_raw",
        "args": ["default-ctor-ran"],
        "kwargs": {"content_type": "application/pickle", "allow_pickle": True},
    }
    result = serde._revive_lc2(load)
    # Default constructor accepts `content` as first positional arg. If parse_raw
    # had been invoked instead, it would have attempted JSON/pickle parsing and
    # raised (or executed the pickle gadget); neither would produce this result.
    assert isinstance(result, AIMessage)
    assert result.content == "default-ctor-ran"


def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
    """The `method` field is ignored even when the class is explicitly allowlisted.

    A user who configures ``allowed_json_modules`` for a class no longer gets
    method dispatch as a side effect. Revival is restricted to the default
    constructor regardless of how the class reached the revival path.
    """
    from langchain_core.messages import AIMessage

    serde = JsonPlusSerializer(
        allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")]
    )
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        "method": "parse_raw",
        "args": ["default-ctor-ran"],
    }
    result = serde._revive_lc2(load)
    assert isinstance(result, AIMessage)
    assert result.content == "default-ctor-ran"


def test_lc2_json_safe_type_init_still_works() -> None:
    """SAFE-type lc=2 revival without a `method` field still constructs the class."""
    from langchain_core.messages import AIMessage

    serde = JsonPlusSerializer()
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        "kwargs": {"content": "hi", "type": "ai"},
    }
    result = serde._revive_lc2(load)
    assert isinstance(result, AIMessage)
    assert result.content == "hi"


def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None:
    """Legacy ``method=(None, "construct")`` envelopes still revive via the default ctor.

    Pre-October-2025 langgraph emitted pydantic models with
    ``method=(None, "construct")`` meaning "try default constructor, fall back
    to pydantic ``construct``". The first entry (``None``) was always the
    default constructor, which is what we now do unconditionally. Envelopes of
    this shape continue to revive correctly as long as the default constructor
    accepts the serialized kwargs.
    """
    from langchain_core.messages import AIMessage

    serde = JsonPlusSerializer()
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        "method": [None, "construct"],
        "kwargs": {"content": "legacy", "type": "ai"},
    }
    result = serde._revive_lc2(load)
    assert isinstance(result, AIMessage)
    assert result.content == "legacy"


def test_lc2_json_legacy_construct_payload_logs_warning_when_default_init_rejects(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Legacy `method=[None, "construct"]` envelopes whose kwargs the default
    `__init__` rejects now revive to `None` and emit an observable warning.

    Pre-October-2025 langgraph emitted pydantic payloads with
    `method=[None, "construct"]` so the reviver could fall back to
    `cls.construct(**kwargs)` when the default constructor raised a
    validation error. That fallback was removed with method-field dispatch
    (GHSA-fjqc-hq36-qh5p), so these payloads now silently fail validation. A
    `logger.warning` makes the regression observable to operators instead of
    letting the envelope quietly degrade to its raw-dict form.
    """
    serde = JsonPlusSerializer()
    load = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        # Legacy two-entry method tuple: try default ctor, fall back to construct.
        "method": [None, "construct"],
        # ``type="not-a-real-message-type"`` fails AIMessage's Literal["ai"]
        # validator under the default constructor. Before the GHSA patch this
        # would have fallen back to ``cls.construct(**kwargs)``; now it must
        # return None and log.
        "kwargs": {"content": "legacy", "type": "not-a-real-message-type"},
    }

    with caplog.at_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus"):
        result = serde._revive_lc2(load)

    assert result is None, (
        "Legacy method=[None, 'construct'] payloads with kwargs the default "
        "ctor rejects must now return None (no construct() fallback)."
    )

    matching = [
        r
        for r in caplog.records
        if r.name == "langgraph.checkpoint.serde.jsonplus"
        and r.levelno == logging.WARNING
        and "langchain_core.messages.ai.AIMessage" in r.getMessage()
        and "legacy_method_field=True" in r.getMessage()
    ]
    assert matching, (
        "Expected a WARNING from langgraph.checkpoint.serde.jsonplus "
        "referencing the class id and legacy_method_field=True; "
        f"got records: {[(r.name, r.levelname, r.getMessage()) for r in caplog.records]}"
    )


def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None:
    """End-to-end: a `parse_raw` pickle gadget payload on a SAFE type must not run.

    With method dispatch removed from `_revive_lc2`, the gadget bytes are never
    passed to `parse_raw` and therefore never reach `pickle.loads`.
    """
    import os
    import pickle
    import tempfile

    marker = tempfile.NamedTemporaryFile(
        prefix="lc2_block_proof_", suffix=".out", delete=False
    ).name
    os.remove(marker)  # ensure absent before the test runs

    class _Gadget:
        def __reduce__(self) -> tuple:
            return (os.system, (f"touch {marker}",))

    gadget_bytes = pickle.dumps(_Gadget(), protocol=0).decode("latin1")
    envelope = {
        "lc": 2,
        "type": "constructor",
        "id": ["langchain_core", "messages", "ai", "AIMessage"],
        "method": "parse_raw",
        "args": [gadget_bytes],
        "kwargs": {"content_type": "application/pickle", "allow_pickle": True},
    }

    serde = JsonPlusSerializer()
    try:
        serde.loads_typed(("json", json.dumps(envelope).encode()))
    except Exception:
        pass

    assert not os.path.exists(marker), (
        "Pickle gadget executed via parse_raw on AIMessage lc=2 envelope"
    )


def test_serde_jsonplus_bytearray() -> None:
    serde = JsonPlusSerializer()

    some_bytearray = bytearray([42])
    dumped = serde.dumps_typed(some_bytearray)

    assert dumped == ("bytearray", some_bytearray)
    assert serde.loads_typed(dumped) == some_bytearray


@pytest.mark.parametrize(
    "arr",
    [
        np.arange(9, dtype=np.int32).reshape(3, 3),
        np.asfortranarray(np.arange(9, dtype=np.float64).reshape(3, 3)),
        np.arange(12, dtype=np.int16)[::2].reshape(3, 2),
    ],
)
def test_serde_jsonplus_numpy_array(arr: np.ndarray) -> None:
    serde = JsonPlusSerializer()

    dumped = serde.dumps_typed(arr)
    assert dumped[0] == "msgpack"
    result = serde.loads_typed(dumped)
    assert isinstance(result, np.ndarray)
    assert result.dtype == arr.dtype
    assert np.array_equal(result, arr)


@pytest.mark.parametrize(
    "arr",
    [
        np.arange(6, dtype=np.float32).reshape(2, 3),
        np.asfortranarray(np.arange(4, dtype=np.complex128).reshape(2, 2)),
    ],
)
def test_serde_jsonplus_numpy_array_json_hook(arr: np.ndarray) -> None:
    serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json)
    dumped = serde.dumps_typed(arr)
    assert dumped[0] == "msgpack"
    result = serde.loads_typed(dumped)
    assert isinstance(result, list)
    assert result == arr.tolist()


@pytest.mark.parametrize(
    "df",
    [
        pd.DataFrame(),
        pd.DataFrame({"int_col": [1, 2, 3]}),
        pd.DataFrame({"float_col": [1.1, 2.2, 3.3]}),
        pd.DataFrame({"str_col": ["a", "b", "c"]}),
        pd.DataFrame({"bool_col": [True, False, True]}),
        pd.DataFrame(
            {
                "datetime_col": [
                    datetime(2024, 1, 1),
                    datetime(2024, 1, 2),
                    datetime(2024, 1, 3),
                ]
            }
        ),
        pd.DataFrame(
            {
                "int_col": [1, 2, 3],
                "float_col": [1.1, 2.2, 3.3],
                "str_col": ["a", "b", "c"],
            }
        ),
        pd.DataFrame(
            {
                "int_col": [1, 2, None],
                "float_col": [1.1, None, 3.3],
                "str_col": ["a", None, "c"],
            }
        ),
        pytest.param(
            pd.DataFrame({"cat_col": pd.Categorical(["a", "b", "a", "c"])}),
            marks=pytest.mark.skipif(
                sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
            ),
        ),
        pd.DataFrame(
            {
                "int8": pd.array([1, 2, 3], dtype="int8"),
                "int16": pd.array([10, 20, 30], dtype="int16"),
                "int32": pd.array([100, 200, 300], dtype="int32"),
                "int64": pd.array([1000, 2000, 3000], dtype="int64"),
                "float32": pd.array([1.1, 2.2, 3.3], dtype="float32"),
                "float64": pd.array([10.1, 20.2, 30.3], dtype="float64"),
            }
        ),
        pd.DataFrame({"value": [1, 2, 3]}, index=["x", "y", "z"]),
        pd.DataFrame(
            [[1, 2, 3, 4]],
            columns=pd.MultiIndex.from_tuples(
                [("A", "X"), ("A", "Y"), ("B", "X"), ("B", "Y")]
            ),
        ),
        pd.DataFrame(
            {"value": [1, 2, 3]}, index=pd.date_range("2024-01-01", periods=3, freq="D")
        ),
        pd.DataFrame(
            {
                "col1": range(1000),
                "col2": [f"str_{i}" for i in range(1000)],
                "col3": np.random.rand(1000),
            }
        ),
        pytest.param(
            pd.DataFrame(
                {
                    "tz_datetime": pd.date_range(
                        "2024-01-01", periods=3, freq="D", tz="UTC"
                    )
                }
            ),
            marks=pytest.mark.skipif(
                sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
            ),
        ),
        pd.DataFrame({"timedelta": pd.to_timedelta([1, 2, 3], unit="D")}),
        pytest.param(
            pd.DataFrame({"period": pd.period_range("2024-01", periods=3, freq="M")}),
            marks=pytest.mark.skipif(
                sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
            ),
        ),
        pd.DataFrame({"interval": pd.interval_range(start=0, end=3, periods=3)}),
        pd.DataFrame({"unicode": ["Hello 🌍", "Python 🐍", "Data 📊"]}),
        pd.DataFrame({"mixed": [1, "string", [1, 2, 3], {"key": "value"}]}),
        pd.DataFrame({"a": [1], "b": ["test"], "c": [3.14]}),
        pd.DataFrame({"single": [42]}),
        pd.DataFrame(
            {
                "small": [sys.float_info.min, 0, sys.float_info.max],
                "large_int": [-(2**63), 0, 2**63 - 1],
            }
        ),
        pd.DataFrame({"special_strings": ["", "null", "None", "NaN", "inf", "-inf"]}),
        pd.DataFrame({"bytes_col": [b"hello", b"world", b"\x00\x01\x02"]}),
    ],
)
def test_serde_jsonplus_pandas_dataframe(df: pd.DataFrame) -> None:
    serde = JsonPlusSerializer(pickle_fallback=True)

    dumped = serde.dumps_typed(df)
    assert dumped[0] == "pickle"
    result = serde.loads_typed(dumped)
    assert result.equals(df)


@pytest.mark.parametrize(
    "series",
    [
        pd.Series([]),
        pd.Series([1, 2, 3]),
        pd.Series([1.1, 2.2, 3.3]),
        pd.Series(["a", "b", "c"]),
        pd.Series([True, False, True]),
        pd.Series([datetime(2024, 1, 1), datetime(2024, 1, 2), datetime(2024, 1, 3)]),
        pd.Series([1, 2, None]),
        pd.Series([1.1, None, 3.3]),
        pd.Series(["a", None, "c"]),
        pytest.param(
            pd.Series(pd.Categorical(["a", "b", "a", "c"])),
            marks=pytest.mark.skipif(
                sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
            ),
        ),
        pd.Series([1, 2, 3], dtype="int8"),
        pd.Series([10, 20, 30], dtype="int16"),
        pd.Series([100, 200, 300], dtype="int32"),
        pd.Series([1000, 2000, 3000], dtype="int64"),
        pd.Series([1.1, 2.2, 3.3], dtype="float32"),
        pd.Series([10.1, 20.2, 30.3], dtype="float64"),
        pd.Series([1, 2, 3], index=["x", "y", "z"]),
        pd.Series([1, 2, 3], index=pd.date_range("2024-01-01", periods=3, freq="D")),
        pd.Series(range(1000)),
        pd.Series(pd.date_range("2024-01-01", periods=3, freq="D", tz="UTC")),
        pd.Series(pd.to_timedelta([1, 2, 3], unit="D")),
        pd.Series(pd.period_range("2024-01", periods=3, freq="M")),
        pd.Series(pd.interval_range(start=0, end=3, periods=3)),
        pd.Series(["Hello 🌍", "Python 🐍", "Data 📊"]),
        pd.Series([1, "string", [1, 2, 3], {"key": "value"}]),
        pd.Series([42], name="single"),
        pd.Series([sys.float_info.min, 0, sys.float_info.max]),
        pd.Series([-(2**63), 0, 2**63 - 1]),
        pd.Series(["", "null", "None", "NaN", "inf", "-inf"]),
        pd.Series([b"hello", b"world", b"\x00\x01\x02"]),
        pd.Series([1, 2, 3], name="named_series"),
        pd.Series(
            [10, 20],
            index=pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]),
        ),
    ],
)
def test_serde_jsonplus_pandas_series(series: pd.Series) -> None:
    serde = JsonPlusSerializer(pickle_fallback=True)
    dumped = serde.dumps_typed(series)

    assert dumped[0] == "pickle"
    result = serde.loads_typed(dumped)
    assert result.equals(series)


def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None:
    """Test safe types deserialize without warnings."""

    serde = JsonPlusSerializer()

    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)
        result = serde.loads_typed(dumped)
        assert "unregistered type" not in caplog.text.lower(), (
            f"Unexpected warning for {type(obj)}"
        )
        assert result is not None


@pytest.fixture(autouse=True)
def _reset_warned_types() -> 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_msgpack_pydantic_warns_by_default(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 = JsonPlusSerializer()

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

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

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

    # Second deserialization of the same type should NOT produce another warning
    caplog.clear()
    result2 = serde.loads_typed(dumped)
    assert "unregistered type" not in caplog.text.lower()
    assert result2 == obj
    _lg_msgpack.STRICT_MSGPACK_ENABLED = current


def test_msgpack_env_strict_default(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Strict msgpack env should default to blocking unregistered types."""
    current = _lg_msgpack.STRICT_MSGPACK_ENABLED
    _lg_msgpack.STRICT_MSGPACK_ENABLED = True
    serde = JsonPlusSerializer()

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

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

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


def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) -> None:
    """Types in allowed_msgpack_modules should deserialize without warnings."""

    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[
            ("tests.test_jsonplus", "MyPydantic"),
            ("tests.test_jsonplus", "InnerPydantic"),
        ]
    )

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

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

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


def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
    """allowed_msgpack_modules=None should block unregistered types."""
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)

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

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

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


def test_msgpack_allowlist_blocks_non_listed(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Allowlists should block unregistered types even if msgpack is enabled."""
    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
    )

    obj = AnotherPydantic(foo="nope")

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

    assert "blocked" in caplog.text.lower()
    expected = obj.model_dump()
    # It's not allowed, so we just leave it as a dict
    assert result == expected


def test_msgpack_blocked_emits_event() -> None:
    events: list[SerdeEvent] = []
    unregister = register_serde_event_listener(events.append)
    try:
        serde = JsonPlusSerializer(allowed_msgpack_modules=None)
        obj = AnotherPydantic(foo="nope")
        serde.loads_typed(serde.dumps_typed(obj))
    finally:
        unregister()

    assert {
        "kind": "msgpack_blocked",
        "module": "tests.test_jsonplus",
        "name": "AnotherPydantic",
    } in events


def test_msgpack_unregistered_allowed_emits_event() -> None:
    events: list[SerdeEvent] = []
    unregister = register_serde_event_listener(events.append)
    try:
        serde = JsonPlusSerializer(allowed_msgpack_modules=True)
        obj = AnotherPydantic(foo="ok")
        serde.loads_typed(serde.dumps_typed(obj))
    finally:
        unregister()

    assert {
        "kind": "msgpack_unregistered_allowed",
        "module": "tests.test_jsonplus",
        "name": "AnotherPydantic",
    } in events


def test_msgpack_strict_allows_safe_types(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Safe types should still deserialize in strict mode without warnings."""

    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    safe = uuid.uuid4()

    caplog.clear()
    dumped = serde.dumps_typed(safe)
    result = serde.loads_typed(dumped)

    assert "blocked" not in caplog.text.lower()
    assert result == safe


def test_msgpack_strict_allows_core_langchain_messages(
    caplog: pytest.LogCaptureFixture,
) -> None:
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    msg = HumanMessage(content="hello")

    caplog.clear()
    result = serde.loads_typed(serde.dumps_typed(msg))

    assert "blocked" not in caplog.text.lower()
    assert "unregistered" not in caplog.text.lower()
    assert isinstance(result, HumanMessage)
    assert result == msg


def test_msgpack_strict_allows_langchain_document(
    caplog: pytest.LogCaptureFixture,
) -> None:
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    doc = Document(page_content="hello", metadata={"k": "v"})

    caplog.clear()
    result = serde.loads_typed(serde.dumps_typed(doc))

    assert "blocked" not in caplog.text.lower()
    assert "unregistered" not in caplog.text.lower()
    assert isinstance(result, Document)
    assert result == doc


def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None:
    """re.compile patterns should deserialize without warnings as a safe type."""

    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)

    caplog.clear()
    dumped = serde.dumps_typed(pattern)
    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


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

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

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


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

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

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


def test_msgpack_regex_still_works_strict(caplog: pytest.LogCaptureFixture) -> None:
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    pattern = re.compile(r"pattern", re.IGNORECASE | re.MULTILINE)

    caplog.clear()
    result = serde.loads_typed(serde.dumps_typed(pattern))

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


def test_msgpack_path_constructor_still_works() -> None:
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    path_obj = pathlib.Path("/tmp/foo")

    result = serde.loads_typed(serde.dumps_typed(path_obj))

    assert result == path_obj


def test_with_msgpack_allowlist_noop_returns_same_instance() -> None:
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)

    result = serde.with_msgpack_allowlist(())

    assert result is serde


def test_with_msgpack_allowlist_supports_subclass_without_init_kwargs() -> None:
    class CustomSerializer(JsonPlusSerializer):
        def __init__(self) -> None:
            super().__init__(allowed_msgpack_modules=None)

    serde = CustomSerializer()
    result = serde.with_msgpack_allowlist([MyDataclass])

    assert isinstance(result, CustomSerializer)
    assert result is not serde
    assert serde._allowed_msgpack_modules is None
    assert result._allowed_msgpack_modules == {
        (MyDataclass.__module__, MyDataclass.__name__)
    }


def test_with_msgpack_allowlist_rebuilds_default_unpack_hook() -> None:
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    original_hook = serde._unpack_ext_hook

    result = serde.with_msgpack_allowlist([MyDataclass])

    assert result._unpack_ext_hook is not original_hook


def test_with_msgpack_allowlist_preserves_custom_unpack_hook() -> None:
    def custom_hook(code: int, data: bytes) -> None:
        return None

    serde = JsonPlusSerializer(
        allowed_msgpack_modules=None, __unpack_ext_hook__=custom_hook
    )
    result = serde.with_msgpack_allowlist([MyDataclass])

    assert result._unpack_ext_hook is custom_hook


@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+")
def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None:
    """Pydantic v1 models in allowlist should deserialize without warnings."""

    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[
            ("tests.test_jsonplus", "MyPydanticV1"),
            ("tests.test_jsonplus", "InnerPydanticV1"),
        ]
    )

    obj = MyPydanticV1(foo="test", bar=42, inner=InnerPydanticV1(hello="world"))

    caplog.clear()
    dumped = serde.dumps_typed(obj)
    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_msgpack_dataclass_allowlist(caplog: pytest.LogCaptureFixture) -> None:
    """Dataclasses in allowlist should deserialize without warnings."""

    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[
            ("tests.test_jsonplus", "MyDataclass"),
            ("tests.test_jsonplus", "InnerDataclass"),
        ]
    )

    obj = MyDataclass(foo="test", bar=42, inner=InnerDataclass(hello="world"))

    caplog.clear()
    dumped = serde.dumps_typed(obj)
    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_msgpack_safe_types_value_equality(caplog: pytest.LogCaptureFixture) -> None:
    """Verify safe types are correctly restored with proper values."""

    serde = JsonPlusSerializer(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)
        result = serde.loads_typed(dumped)

        assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
        # For regex patterns, compare pattern and flags
        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_msgpack_nested_pydantic_serializes_as_dict(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Nested Pydantic models are serialized via model_dump() as dicts.

    This means nested models don't go through the ext hook and don't need
    to be in the allowlist - only the outer type does.
    """

    # Only allow outer type - inner is serialized as dict via model_dump()
    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
    )

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

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

    # No blocking should occur - inner is serialized as dict, not ext
    assert "blocked" not in caplog.text.lower()
    assert result == obj
