"""Tests for MongoDBSession using in-process mock objects.

All tests run without a real MongoDB server — or even the ``pymongo``
package — by injecting lightweight fake classes into ``sys.modules``
before the module under test is imported.  This keeps the suite fast and
dependency-free while exercising the full session logic.
"""

from __future__ import annotations

import sys
import types
from collections import defaultdict
from datetime import datetime, timezone
from typing import Any
from unittest.mock import patch

import pytest

from agents import Agent, Runner, TResponseInputItem
from agents.memory.session_settings import SessionSettings
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message

pytestmark = pytest.mark.asyncio


# ---------------------------------------------------------------------------
# In-memory fake pymongo async types
# ---------------------------------------------------------------------------


class FakeObjectId:
    """Minimal ObjectId stand-in with a monotonic counter for sort order."""

    _counter = 0

    def __init__(self) -> None:
        FakeObjectId._counter += 1
        self._value = FakeObjectId._counter

    def __lt__(self, other: FakeObjectId) -> bool:
        return self._value < other._value

    def __repr__(self) -> str:
        return f"FakeObjectId({self._value})"


class FakeCursor:
    """Minimal async cursor returned by ``find()``."""

    def __init__(self, docs: list[dict[str, Any]]) -> None:
        self._docs = docs

    def sort(
        self,
        key: str | list[tuple[str, int]],
        direction: int | None = None,
    ) -> FakeCursor:
        if isinstance(key, list):
            pairs = key
        else:
            direction = direction if direction is not None else 1
            pairs = [(key, direction)]

        docs = list(self._docs)
        for field, dir_ in reversed(pairs):
            docs.sort(key=lambda d: d.get(field, 0), reverse=(dir_ == -1))
        self._docs = docs
        return self

    def limit(self, n: int) -> FakeCursor:
        self._docs = self._docs[:n]
        return self

    async def to_list(self) -> list[dict[str, Any]]:
        return list(self._docs)


class FakeAsyncCollection:
    """In-memory substitute for pymongo AsyncCollection."""

    def __init__(self) -> None:
        self._docs: dict[Any, dict[str, Any]] = {}

    async def create_index(self, keys: Any, **kwargs: Any) -> str:
        return "fake_index"

    def find(self, query: dict[str, Any] | None = None) -> FakeCursor:
        query = query or {}
        results = [doc for doc in self._docs.values() if self._matches(doc, query)]
        return FakeCursor(results)

    async def find_one_and_delete(
        self,
        query: dict[str, Any],
        sort: list[tuple[str, int]] | None = None,
    ) -> dict[str, Any] | None:
        matches = [doc for doc in self._docs.values() if self._matches(doc, query)]
        if not matches:
            return None
        if sort:
            field, dir_ = sort[0]
            matches.sort(key=lambda d: d.get(field, 0), reverse=(dir_ == -1))
        doc = matches[0]
        self._docs.pop(id(doc["_id"]))
        return doc

    async def insert_many(
        self,
        documents: list[dict[str, Any]],
        ordered: bool = True,
    ) -> Any:
        for doc in documents:
            if "_id" not in doc:
                doc["_id"] = FakeObjectId()
            self._docs[id(doc["_id"])] = dict(doc)

    async def find_one_and_update(
        self,
        query: dict[str, Any],
        update: dict[str, Any],
        upsert: bool = False,
        return_document: bool = False,
    ) -> dict[str, Any] | None:
        for doc in self._docs.values():
            if self._matches(doc, query):
                # Apply $inc fields.
                for field, delta in update.get("$inc", {}).items():
                    doc[field] = doc.get(field, 0) + delta
                for field, value in update.get("$set", {}).items():
                    doc[field] = value
                return dict(doc) if return_document else None
        if upsert:
            new_doc: dict[str, Any] = {"_id": FakeObjectId()}
            new_doc.update(update.get("$setOnInsert", {}))
            new_doc.update(update.get("$set", {}))
            for field, delta in update.get("$inc", {}).items():
                new_doc[field] = new_doc.get(field, 0) + delta
            self._docs[id(new_doc["_id"])] = new_doc
            return dict(new_doc) if return_document else None
        return None

    async def update_one(
        self,
        query: dict[str, Any],
        update: dict[str, Any],
        upsert: bool = False,
    ) -> None:
        for doc in self._docs.values():
            if self._matches(doc, query):
                return  # Exists — $setOnInsert is a no-op on existing docs.
        if upsert:
            new_doc2: dict[str, Any] = {"_id": FakeObjectId()}
            new_doc2.update(update.get("$setOnInsert", {}))
            self._docs[id(new_doc2["_id"])] = new_doc2

    async def delete_many(self, query: dict[str, Any]) -> None:
        to_remove = [k for k, d in self._docs.items() if self._matches(d, query)]
        for key in to_remove:
            del self._docs[key]

    async def delete_one(self, query: dict[str, Any]) -> None:
        for key, doc in list(self._docs.items()):
            if self._matches(doc, query):
                del self._docs[key]
                return

    @staticmethod
    def _matches(doc: dict[str, Any], query: dict[str, Any]) -> bool:
        return all(doc.get(k) == v for k, v in query.items())


class FakeAsyncDatabase:
    """In-memory substitute for a pymongo async Database."""

    def __init__(self) -> None:
        self._collections: dict[str, FakeAsyncCollection] = defaultdict(FakeAsyncCollection)

    def __getitem__(self, name: str) -> FakeAsyncCollection:
        return self._collections[name]


class FakeAdminDatabase:
    """Minimal admin database used by ping()."""

    def __init__(self) -> None:
        self._closed = False

    async def command(self, cmd: str) -> dict[str, Any]:
        if self._closed:
            raise ConnectionError("Client is closed.")
        return {"ok": 1}


class FakeDriverInfo:
    """Minimal stand-in for pymongo.driver_info.DriverInfo."""

    def __init__(self, name: str, version: str | None = None) -> None:
        self.name = name
        self.version = version


class FakeAsyncMongoClient:
    """In-memory substitute for pymongo AsyncMongoClient."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        self._databases: dict[str, FakeAsyncDatabase] = defaultdict(FakeAsyncDatabase)
        self._closed = False
        self.admin = FakeAdminDatabase()
        self._metadata_calls: list[FakeDriverInfo] = []

    def __getitem__(self, name: str) -> FakeAsyncDatabase:
        return self._databases[name]

    def append_metadata(self, driver_info: FakeDriverInfo) -> None:
        """Record append_metadata calls for test assertions."""
        self._metadata_calls.append(driver_info)

    async def close(self) -> None:
        """Async close — matches PyMongo's AsyncMongoClient.close() signature."""
        self._closed = True
        self.admin._closed = True


# ---------------------------------------------------------------------------
# Inject fake pymongo into sys.modules before importing the module under test
# ---------------------------------------------------------------------------


def _make_fake_pymongo_modules() -> None:
    """Populate sys.modules with stub pymongo async modules."""
    pymongo_mod = sys.modules.get("pymongo") or types.ModuleType("pymongo")

    async_pkg = types.ModuleType("pymongo.asynchronous")
    collection_mod = types.ModuleType("pymongo.asynchronous.collection")
    client_mod = types.ModuleType("pymongo.asynchronous.mongo_client")
    driver_info_mod = types.ModuleType("pymongo.driver_info")

    collection_mod.AsyncCollection = FakeAsyncCollection  # type: ignore[attr-defined]
    client_mod.AsyncMongoClient = FakeAsyncMongoClient  # type: ignore[attr-defined]
    driver_info_mod.DriverInfo = FakeDriverInfo  # type: ignore[attr-defined]

    sys.modules["pymongo"] = pymongo_mod
    sys.modules["pymongo.asynchronous"] = async_pkg
    sys.modules["pymongo.asynchronous.collection"] = collection_mod
    sys.modules["pymongo.asynchronous.mongo_client"] = client_mod
    sys.modules["pymongo.driver_info"] = driver_info_mod


_make_fake_pymongo_modules()

# Now it's safe to import the module under test.
from agents.extensions.memory.mongodb_session import MongoDBSession  # noqa: E402

# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------


def _make_session(session_id: str = "test-session", **kwargs: Any) -> MongoDBSession:
    """Create a MongoDBSession backed by a FakeAsyncMongoClient."""
    client = FakeAsyncMongoClient()
    MongoDBSession._init_state.clear()
    return MongoDBSession(
        session_id,
        client=client,  # type: ignore[arg-type]
        database="agents_test",
        **kwargs,
    )


@pytest.fixture
def session() -> MongoDBSession:
    return _make_session()


@pytest.fixture
def agent() -> Agent:
    return Agent(name="test", model=FakeModel())


# ---------------------------------------------------------------------------
# Core CRUD tests
# ---------------------------------------------------------------------------


async def test_add_and_get_items(session: MongoDBSession) -> None:
    """Items added to the session are retrievable in insertion order."""
    items: list[TResponseInputItem] = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi there!"},
    ]
    await session.add_items(items)

    retrieved = await session.get_items()
    assert len(retrieved) == 2
    assert retrieved[0].get("content") == "Hello"
    assert retrieved[1].get("content") == "Hi there!"


async def test_add_empty_list_is_noop(session: MongoDBSession) -> None:
    """Adding an empty list must not create any documents."""
    await session.add_items([])
    assert await session.get_items() == []


async def test_get_items_empty_session(session: MongoDBSession) -> None:
    """Retrieving items from a brand-new session returns an empty list."""
    assert await session.get_items() == []


async def test_pop_item_returns_last(session: MongoDBSession) -> None:
    """pop_item must return and remove the most recently added item."""
    items: list[TResponseInputItem] = [
        {"role": "user", "content": "first"},
        {"role": "assistant", "content": "second"},
    ]
    await session.add_items(items)

    popped = await session.pop_item()
    assert popped is not None
    assert popped.get("content") == "second"

    remaining = await session.get_items()
    assert len(remaining) == 1
    assert remaining[0].get("content") == "first"


async def test_pop_item_empty_session(session: MongoDBSession) -> None:
    """pop_item on an empty session must return None."""
    assert await session.pop_item() is None


async def test_clear_session(session: MongoDBSession) -> None:
    """clear_session must remove all items and session metadata."""
    await session.add_items([{"role": "user", "content": "x"}])
    await session.clear_session()
    assert await session.get_items() == []


async def test_multiple_add_calls_accumulate(session: MongoDBSession) -> None:
    """Items from separate add_items calls all appear in get_items."""
    await session.add_items([{"role": "user", "content": "a"}])
    await session.add_items([{"role": "assistant", "content": "b"}])
    await session.add_items([{"role": "user", "content": "c"}])

    items = await session.get_items()
    assert [i.get("content") for i in items] == ["a", "b", "c"]


async def test_session_metadata_timestamps_are_written(session: MongoDBSession) -> None:
    """Session metadata records creation time and last update time."""
    created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
    updated_at = datetime(2026, 1, 2, tzinfo=timezone.utc)

    with patch("agents.extensions.memory.mongodb_session.datetime") as mocked_datetime:
        mocked_datetime.now.side_effect = [created_at, updated_at]

        await session.add_items([{"role": "user", "content": "first"}])
        session_doc: dict[str, Any] = next(iter(session._sessions._docs.values()))
        assert session_doc["session_id"] == session.session_id
        assert session_doc["created_at"] == created_at
        assert session_doc["updated_at"] == created_at

        await session.add_items([{"role": "assistant", "content": "second"}])
        assert session_doc["created_at"] == created_at
        assert session_doc["updated_at"] == updated_at
        assert session_doc["_seq"] == 2


# ---------------------------------------------------------------------------
# Limit / SessionSettings tests
# ---------------------------------------------------------------------------


async def test_get_items_with_explicit_limit(session: MongoDBSession) -> None:
    """Explicit limit returns the N most recent items in chronological order."""
    await session.add_items([{"role": "user", "content": str(i)} for i in range(6)])

    result = await session.get_items(limit=3)
    assert len(result) == 3
    assert [r.get("content") for r in result] == ["3", "4", "5"]


async def test_get_items_limit_zero(session: MongoDBSession) -> None:
    """A limit of 0 must return an empty list immediately."""
    await session.add_items([{"role": "user", "content": "x"}])
    assert await session.get_items(limit=0) == []


async def test_get_items_limit_exceeds_count(session: MongoDBSession) -> None:
    """Requesting more items than exist returns all items without error."""
    await session.add_items([{"role": "user", "content": "only"}])
    result = await session.get_items(limit=100)
    assert len(result) == 1


async def test_session_settings_limit_used_as_default() -> None:
    """session_settings.limit is applied when no explicit limit is given."""
    MongoDBSession._init_state.clear()
    s = MongoDBSession(
        "ls-test",
        client=FakeAsyncMongoClient(),  # type: ignore[arg-type]
        database="agents_test",
        session_settings=SessionSettings(limit=2),
    )
    await s.add_items([{"role": "user", "content": str(i)} for i in range(5)])

    result = await s.get_items()
    assert len(result) == 2
    assert result[0].get("content") == "3"
    assert result[1].get("content") == "4"


async def test_explicit_limit_overrides_session_settings() -> None:
    """An explicit limit passed to get_items must override session_settings.limit."""
    MongoDBSession._init_state.clear()
    s = MongoDBSession(
        "override-test",
        client=FakeAsyncMongoClient(),  # type: ignore[arg-type]
        database="agents_test",
        session_settings=SessionSettings(limit=10),
    )
    await s.add_items([{"role": "user", "content": str(i)} for i in range(8)])

    result = await s.get_items(limit=2)
    assert len(result) == 2
    assert result[0].get("content") == "6"
    assert result[1].get("content") == "7"


# ---------------------------------------------------------------------------
# Session isolation
# ---------------------------------------------------------------------------


async def test_sessions_are_isolated() -> None:
    """Two sessions with different IDs must not share data."""
    MongoDBSession._init_state.clear()
    client = FakeAsyncMongoClient()
    s1 = MongoDBSession("alice", client=client, database="agents_test")  # type: ignore[arg-type]
    s2 = MongoDBSession("bob", client=client, database="agents_test")  # type: ignore[arg-type]

    await s1.add_items([{"role": "user", "content": "alice msg"}])
    await s2.add_items([{"role": "user", "content": "bob msg"}])

    assert [i.get("content") for i in await s1.get_items()] == ["alice msg"]
    assert [i.get("content") for i in await s2.get_items()] == ["bob msg"]


async def test_clear_does_not_affect_other_sessions() -> None:
    """Clearing one session must leave sibling sessions untouched."""
    MongoDBSession._init_state.clear()
    client = FakeAsyncMongoClient()
    s1 = MongoDBSession("s1", client=client, database="agents_test")  # type: ignore[arg-type]
    s2 = MongoDBSession("s2", client=client, database="agents_test")  # type: ignore[arg-type]

    await s1.add_items([{"role": "user", "content": "keep"}])
    await s2.add_items([{"role": "user", "content": "delete"}])

    await s2.clear_session()

    assert len(await s1.get_items()) == 1
    assert await s2.get_items() == []


# ---------------------------------------------------------------------------
# Serialisation / unicode safety
# ---------------------------------------------------------------------------


async def test_unicode_content_roundtrip(session: MongoDBSession) -> None:
    """Unicode and emoji content must survive the serialisation round-trip."""
    items: list[TResponseInputItem] = [
        {"role": "user", "content": "こんにちは"},
        {"role": "assistant", "content": "😊👍"},
        {"role": "user", "content": "Привет"},
    ]
    await session.add_items(items)
    result = await session.get_items()
    assert result[0].get("content") == "こんにちは"
    assert result[1].get("content") == "😊👍"
    assert result[2].get("content") == "Привет"


async def test_json_special_characters(session: MongoDBSession) -> None:
    """Items containing JSON-special strings must be stored without corruption."""
    items: list[TResponseInputItem] = [
        {"role": "user", "content": '{"nested": "value"}'},
        {"role": "assistant", "content": 'Quote: "Hello"'},
        {"role": "user", "content": "Line1\nLine2\tTabbed"},
    ]
    await session.add_items(items)
    result = await session.get_items()
    assert result[0].get("content") == '{"nested": "value"}'
    assert result[1].get("content") == 'Quote: "Hello"'
    assert result[2].get("content") == "Line1\nLine2\tTabbed"


async def test_corrupted_document_is_skipped(session: MongoDBSession) -> None:
    """Documents with invalid JSON in message_data are silently skipped."""
    await session.add_items([{"role": "user", "content": "valid"}])

    # Inject a corrupted document directly into the fake collection.
    bad_doc = {
        "_id": FakeObjectId(),
        "session_id": session.session_id,
        "message_data": "not valid json {{{",
    }
    session._messages._docs[id(bad_doc["_id"])] = bad_doc

    items = await session.get_items()
    assert len(items) == 1
    assert items[0].get("content") == "valid"


async def test_missing_message_data_field_is_skipped(session: MongoDBSession) -> None:
    """Documents without a message_data field are silently skipped."""
    await session.add_items([{"role": "user", "content": "valid"}])

    bad_doc = {"_id": FakeObjectId(), "session_id": session.session_id}
    session._messages._docs[id(bad_doc["_id"])] = bad_doc

    items = await session.get_items()
    assert len(items) == 1


async def test_non_string_message_data_is_skipped(session: MongoDBSession) -> None:
    """Documents whose message_data is a non-string BSON type are silently skipped."""
    await session.add_items([{"role": "user", "content": "valid"}])

    # Inject a document where message_data is an integer — json.loads raises TypeError.
    bad_doc = {"_id": FakeObjectId(), "session_id": session.session_id, "message_data": 42}
    session._messages._docs[id(bad_doc["_id"])] = bad_doc

    items = await session.get_items()
    assert len(items) == 1
    assert items[0].get("content") == "valid"


async def test_pop_item_skips_corrupt_most_recent(session: MongoDBSession) -> None:
    """pop_item must skip a corrupt most-recent document and return the next valid one."""
    await session.add_items([{"role": "user", "content": "valid"}])

    # Inject a corrupt document with a higher seq so it sorts as "most recent".
    bad_doc = {
        "_id": FakeObjectId(),
        "session_id": session.session_id,
        "seq": 999,
        "message_data": "not valid json {{{",
    }
    session._messages._docs[id(bad_doc["_id"])] = bad_doc

    popped = await session.pop_item()
    assert popped is not None
    assert popped.get("content") == "valid"

    # Both the corrupt doc and the valid one are now gone.
    assert await session.get_items() == []


async def test_pop_item_returns_none_when_only_corrupt_docs_remain(
    session: MongoDBSession,
) -> None:
    """pop_item must drop every corrupt doc and return None when nothing valid remains."""
    bad1 = {
        "_id": FakeObjectId(),
        "session_id": session.session_id,
        "seq": 1,
        "message_data": "garbage",
    }
    bad2 = {
        "_id": FakeObjectId(),
        "session_id": session.session_id,
        "seq": 2,
        "message_data": 42,  # non-string — TypeError
    }
    session._messages._docs[id(bad1["_id"])] = bad1
    session._messages._docs[id(bad2["_id"])] = bad2

    assert await session.pop_item() is None
    # Both corrupt docs must have been removed in the process.
    assert session._messages._docs == {}


# ---------------------------------------------------------------------------
# Index initialisation (idempotency)
# ---------------------------------------------------------------------------


async def test_index_creation_runs_only_once(session: MongoDBSession) -> None:
    """_ensure_indexes must call create_index only on the very first call."""
    call_count = 0
    original_messages = session._messages.create_index
    original_sessions = session._sessions.create_index

    async def counting(*args: Any, **kwargs: Any) -> str:
        nonlocal call_count
        call_count += 1
        return "fake_index"

    session._messages.create_index = counting  # type: ignore[method-assign]
    session._sessions.create_index = counting  # type: ignore[method-assign]

    await session._ensure_indexes()
    await session._ensure_indexes()  # Second call must be a no-op.

    # Exactly one call per collection (sessions + messages).
    assert call_count == 2

    session._messages.create_index = original_messages  # type: ignore[method-assign]
    session._sessions.create_index = original_sessions  # type: ignore[method-assign]


async def test_different_clients_each_run_index_init() -> None:
    """Each distinct AsyncMongoClient gets its own index-creation pass."""
    MongoDBSession._init_state.clear()

    client_a = FakeAsyncMongoClient()
    client_b = FakeAsyncMongoClient()

    call_counts: dict[str, int] = {"a": 0, "b": 0}

    async def counting_a(*args: Any, **kwargs: Any) -> str:
        call_counts["a"] += 1
        return "fake_index"

    async def counting_b(*args: Any, **kwargs: Any) -> str:
        call_counts["b"] += 1
        return "fake_index"

    s_a = MongoDBSession("x", client=client_a, database="agents_test")  # type: ignore[arg-type]
    s_b = MongoDBSession("x", client=client_b, database="agents_test")  # type: ignore[arg-type]

    s_a._messages.create_index = counting_a  # type: ignore[method-assign]
    s_a._sessions.create_index = counting_a  # type: ignore[method-assign]
    s_b._messages.create_index = counting_b  # type: ignore[method-assign]
    s_b._sessions.create_index = counting_b  # type: ignore[method-assign]

    await s_a._ensure_indexes()
    await s_b._ensure_indexes()

    # Each client must trigger its own index creation (2 calls = sessions + messages).
    assert call_counts["a"] == 2
    assert call_counts["b"] == 2


# ---------------------------------------------------------------------------
# Connectivity and lifecycle
# ---------------------------------------------------------------------------


async def test_ping_success(session: MongoDBSession) -> None:
    """ping() must return True when the client responds normally."""
    assert await session.ping() is True


async def test_ping_failure(session: MongoDBSession) -> None:
    """ping() must return False when the server raises an exception."""
    original = session._client.admin.command

    async def _fail(*args: Any, **kwargs: Any) -> dict[str, Any]:
        raise ConnectionError("unreachable")

    session._client.admin.command = _fail  # type: ignore[method-assign, assignment]
    assert await session.ping() is False
    session._client.admin.command = original  # type: ignore[method-assign]


async def test_close_external_client_not_closed() -> None:
    """close() must NOT close a client that was injected externally."""
    MongoDBSession._init_state.clear()
    client = FakeAsyncMongoClient()
    s = MongoDBSession("x", client=client, database="agents_test")  # type: ignore[arg-type]
    assert s._owns_client is False

    await s.close()
    assert not client._closed


async def test_close_owned_client_is_closed() -> None:
    """close() must close a client created by from_uri."""
    MongoDBSession._init_state.clear()
    fake_client = FakeAsyncMongoClient()
    with patch(
        "agents.extensions.memory.mongodb_session.AsyncMongoClient",
        return_value=fake_client,
    ):
        s = MongoDBSession.from_uri("owned", uri="mongodb://localhost:27017", database="t")
        assert s._owns_client is True

        await s.close()
        assert fake_client._closed


# ---------------------------------------------------------------------------
# Runner integration
# ---------------------------------------------------------------------------


async def test_runner_integration(agent: Agent) -> None:
    """MongoDBSession must supply conversation history to the Runner."""
    session = _make_session("runner-test")

    assert isinstance(agent.model, FakeModel)
    agent.model.set_next_output([get_text_message("San Francisco")])
    result1 = await Runner.run(agent, "Where is the Golden Gate Bridge?", session=session)
    assert result1.final_output == "San Francisco"

    agent.model.set_next_output([get_text_message("California")])
    result2 = await Runner.run(agent, "What state is it in?", session=session)
    assert result2.final_output == "California"

    last_input = agent.model.last_turn_args["input"]
    assert len(last_input) > 1
    assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)


async def test_runner_session_isolation(agent: Agent) -> None:
    """Two independent sessions must not bleed history into each other."""
    MongoDBSession._init_state.clear()
    client = FakeAsyncMongoClient()
    s1 = MongoDBSession("user-a", client=client, database="agents_test")  # type: ignore[arg-type]
    s2 = MongoDBSession("user-b", client=client, database="agents_test")  # type: ignore[arg-type]

    assert isinstance(agent.model, FakeModel)
    agent.model.set_next_output([get_text_message("I like cats.")])
    await Runner.run(agent, "I like cats.", session=s1)

    agent.model.set_next_output([get_text_message("I like dogs.")])
    await Runner.run(agent, "I like dogs.", session=s2)

    agent.model.set_next_output([get_text_message("You said you like cats.")])
    result = await Runner.run(agent, "What animal did I mention?", session=s1)
    assert "cats" in result.final_output.lower()
    assert "dogs" not in result.final_output.lower()


async def test_runner_with_session_settings_limit(agent: Agent) -> None:
    """RunConfig.session_settings.limit must cap the history sent to the model."""
    from agents import RunConfig

    MongoDBSession._init_state.clear()
    session = MongoDBSession(
        "limit-test",
        client=FakeAsyncMongoClient(),  # type: ignore[arg-type]
        database="agents_test",
        session_settings=SessionSettings(limit=100),
    )

    history: list[TResponseInputItem] = [
        {"role": "user", "content": f"Turn {i}"} for i in range(10)
    ]
    await session.add_items(history)

    assert isinstance(agent.model, FakeModel)
    agent.model.set_next_output([get_text_message("Got it")])
    await Runner.run(
        agent,
        "New question",
        session=session,
        run_config=RunConfig(session_settings=SessionSettings(limit=2)),
    )

    last_input = agent.model.last_turn_args["input"]
    history_items = [i for i in last_input if i.get("content") != "New question"]
    assert len(history_items) == 2


# ---------------------------------------------------------------------------
# Client metadata (driver handshake)
# ---------------------------------------------------------------------------


async def test_injected_client_receives_append_metadata() -> None:
    """Append_metadata is called on a caller-supplied client."""
    MongoDBSession._init_state.clear()
    client = FakeAsyncMongoClient()

    MongoDBSession("meta-test", client=client, database="agents_test")  # type: ignore[arg-type]

    assert len(client._metadata_calls) == 1
    info = client._metadata_calls[0]
    assert info.name == "openai-agents"


async def test_from_uri_passes_driver_info_to_constructor() -> None:
    """driver=_DRIVER_INFO is forwarded to AsyncMongoClient via from_uri."""
    MongoDBSession._init_state.clear()

    captured_kwargs: dict[str, Any] = {}

    def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient:
        captured_kwargs.update(kwargs)
        return FakeAsyncMongoClient()

    with patch(
        "agents.extensions.memory.mongodb_session.AsyncMongoClient",
        side_effect=_fake_client,
    ):
        MongoDBSession.from_uri("uri-test", uri="mongodb://localhost:27017", database="t")

    assert "driver" in captured_kwargs
    assert captured_kwargs["driver"].name == "openai-agents"


async def test_caller_supplied_driver_info_is_not_overwritten() -> None:
    """A caller-supplied driver kwarg must not be silently replaced."""
    MongoDBSession._init_state.clear()

    captured_kwargs: dict[str, Any] = {}
    custom_info = FakeDriverInfo(name="MyApp")

    def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient:
        captured_kwargs.update(kwargs)
        return FakeAsyncMongoClient()

    with patch(
        "agents.extensions.memory.mongodb_session.AsyncMongoClient",
        side_effect=_fake_client,
    ):
        MongoDBSession.from_uri(
            "uri-test",
            uri="mongodb://localhost:27017",
            database="t",
            client_kwargs={"driver": custom_info},
        )

    # The caller's value must be preserved — setdefault must not overwrite it.
    assert captured_kwargs["driver"] is custom_info
