"""Declared-conversation identity on the API server (#96811).

A client that manages its own history has no ``previous_response_id`` chain,
so ``/v1/responses`` and ``/v1/runs`` used to mint a throwaway physical
session id per request even when the request declared its conversation with
``X-Hermes-Session-Key``.  Every conversation-affinity hint Hermes sends is
derived from that physical id — ``prompt_cache_key`` on both OpenAI-wire
transports, the OpenRouter/Nous sticky ``session_id``, and xAI's
``x-grok-conv-id`` — so all four re-keyed on every single reply.

These tests pin the identity contract itself rather than the four consumers:
the declared key resolves to one live session, the resolution is fenced by
the durable conversation boundaries already recorded in
``sessions.end_reason``, and nothing that does not declare a key changes.
"""

import asyncio
import types
from unittest.mock import MagicMock

import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer

from gateway.config import PlatformConfig
from gateway.platforms.api_server import (
    APIServerAdapter,
    cors_middleware,
    security_headers_middleware,
)
from hermes_state import SessionDB

KEY = "agent:main:api_server:room-42:member-7"
OTHER_KEY = "agent:main:api_server:room-42:member-8"
SOURCE = "api_server"


@pytest.fixture
def adapter(tmp_path):
    """An adapter whose lazy SessionDB is pinned to a scratch state.db."""
    a = APIServerAdapter(
        PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": 0, "key": "k"})
    )
    db = SessionDB(tmp_path / "state.db")
    a._session_db = db
    try:
        yield a, db
    finally:
        db.close()


def _seed(db, session_id, *, key=KEY, source=SOURCE):
    db.create_session(session_id=session_id, source=source, model="m")
    if key:
        db.record_gateway_session_peer(session_id, source=source, session_key=key)


class TestDeclaredConversationResolution:
    def test_declared_key_resolves_the_live_session(self, adapter):
        a, db = adapter
        _seed(db, "sess-live")
        assert a._declared_conversation_session(KEY) == "sess-live"


    def test_no_declared_key_resolves_nothing(self, adapter):
        """Undeclared clients keep today's per-request identity."""
        a, db = adapter
        _seed(db, "sess-live")
        assert a._declared_conversation_session(None) is None
        assert a._declared_conversation_session("") is None
        assert a._declared_conversation_session("   ") is None

    def test_unknown_key_resolves_nothing(self, adapter):
        a, db = adapter
        _seed(db, "sess-live")
        assert a._declared_conversation_session(OTHER_KEY) is None

    def test_distinct_keys_stay_isolated(self, adapter):
        a, db = adapter
        _seed(db, "sess-mine", key=KEY)
        _seed(db, "sess-theirs", key=OTHER_KEY)
        assert a._declared_conversation_session(KEY) == "sess-mine"
        assert a._declared_conversation_session(OTHER_KEY) == "sess-theirs"

    def test_another_platforms_key_is_not_adopted(self, adapter):
        """The source filter keeps a telegram chat key out of the API server."""
        a, db = adapter
        _seed(db, "sess-telegram", key=KEY, source="telegram")
        assert a._declared_conversation_session(KEY) is None

    def test_db_failure_degrades_to_a_fresh_id(self, adapter):
        a, _ = adapter

        class _Boom:
            def find_latest_gateway_session_for_peer(self, **_kw):
                raise RuntimeError("db down")

        a._session_db = _Boom()
        assert a._declared_conversation_session(KEY) is None

    def test_missing_db_degrades_to_a_fresh_id(self, adapter, monkeypatch):
        a, _ = adapter
        monkeypatch.setattr(a, "_ensure_session_db", lambda: None)
        assert a._declared_conversation_session(KEY) is None


class TestConversationBoundariesRotate:
    """The boundary the recovery fence honours is durable in end_reason.

    #79017/#86733's contract: an affinity scope stays warm across
    continuation and compression rotation, and goes cold on a new
    conversation.  ``_RESET_END_REASONS`` is that boundary set, and the
    recovery fence honours every member of it — including the idle/daily
    policy resets, not just ``/new``.
    """

    @pytest.mark.parametrize(
        "reason",
        ["session_reset", "session_switch", "idle", "daily", "suspended",
         "resume_pending_expired"],
    )
    def test_boundary_rotates_the_conversation(self, adapter, reason):
        a, db = adapter
        _seed(db, "sess-old")
        db.end_session("sess-old", reason)
        assert a._declared_conversation_session(KEY) is None

    def test_boundary_cannot_be_reached_behind(self, adapter):
        """A later row wins, and the retired one never comes back (no ABA)."""
        a, db = adapter
        _seed(db, "sess-gen1")
        db.end_session("sess-gen1", "session_reset")
        _seed(db, "sess-gen2")
        assert a._declared_conversation_session(KEY) == "sess-gen2"
        db.end_session("sess-gen2", "session_reset")
        assert a._declared_conversation_session(KEY) is None

    def test_accidental_end_stays_resumable(self, adapter):
        """An accidental close is not a conversation boundary."""
        a, db = adapter
        _seed(db, "sess-live")
        db.end_session("sess-live", "agent_close")
        assert a._declared_conversation_session(KEY) == "sess-live"


class TestBindDeclaredConversation:
    def test_bind_makes_the_row_resolvable(self, adapter):
        """Without the bind the row is written unkeyed and is invisible."""
        a, db = adapter
        db.create_session(session_id="sess-new", source=SOURCE, model="m")
        assert a._declared_conversation_session(KEY) is None

        a._bind_declared_conversation("sess-new", KEY)
        assert a._declared_conversation_session(KEY) == "sess-new"

    def test_bind_is_a_noop_without_a_key(self, adapter):
        a, db = adapter
        db.create_session(session_id="sess-new", source=SOURCE, model="m")
        a._bind_declared_conversation("sess-new", None)
        a._bind_declared_conversation("sess-new", "  ")
        assert db.get_session("sess-new").get("session_key") in (None, "")

    def test_bind_is_a_noop_without_a_session(self, adapter):
        a, _ = adapter
        a._bind_declared_conversation(None, KEY)
        a._bind_declared_conversation("", KEY)
        assert a._declared_conversation_session(KEY) is None

    def test_bind_survives_a_db_failure(self, adapter):
        a, _ = adapter

        class _Boom:
            def record_gateway_session_peer(self, *_a, **_kw):
                raise RuntimeError("db down")

        a._session_db = _Boom()
        a._bind_declared_conversation("sess-new", KEY)  # must not raise

    def test_bind_follows_a_compression_rotation(self, adapter):
        """The turn binds the row it ended on; the retired parent follows.

        ``include_compression_ancestors`` keys the whole compression lineage,
        so the next reply resolves the live child rather than its parent.
        """
        a, db = adapter
        db.create_session(session_id="sess-parent", source=SOURCE, model="m")
        db.end_session("sess-parent", "compression")
        db.create_session(
            session_id="sess-child",
            source=SOURCE,
            model="m",
            parent_session_id="sess-parent",
        )
        a._bind_declared_conversation("sess-child", KEY)
        assert a._declared_conversation_session(KEY) == "sess-child"


class TestOtherStructuresUnaffected:
    """The bind uses the same routing-peer record every native platform uses.

    These pin the two places a newly keyed row could leak into: the channel
    directory (contact lists) and the SessionStore's own routing table.
    """

    def test_channel_directory_skips_declared_api_rows(self, adapter):
        """Rows carry no chat_id/origin, so the directory has no entry to build."""
        from gateway import channel_directory

        a, db = adapter
        _seed(db, "sess-live")
        row = db.get_session("sess-live")
        origin = {
            "chat_id": row.get("chat_id"),
            "thread_id": row.get("thread_id"),
            "chat_name": row.get("display_name"),
        }
        assert channel_directory._session_entry_id(origin) is None

    def test_session_store_routing_table_is_untouched(self, adapter):
        """SessionStore loads from gateway_routing, not from sessions.session_key."""
        a, db = adapter
        _seed(db, "sess-live")
        assert db.load_gateway_routing_entries() == {}


class TestBindFollowsPrecedence:
    """Recording is gated on the declared key actually selecting the session.

    `record_gateway_session_peer` performs `SET session_key = ?`, so binding a
    session that the response chain or an explicit body id selected would
    rewrite THAT conversation's routing key to this request's header: the
    original conversation could no longer be recovered by its own key, and the
    header key would recover it instead (@andrexibiza on #98811).
    """

    def test_a_foreign_binding_is_never_overwritten(self, adapter):
        """Defence in depth behind the gate, at the DB layer."""
        a, db = adapter
        _seed(db, "sess-A", key=KEY)

        a._bind_declared_conversation("sess-A", OTHER_KEY)

        assert db.get_session("sess-A")["session_key"] == KEY
        assert a._declared_conversation_session(KEY) == "sess-A"
        assert a._declared_conversation_session(OTHER_KEY) is None

    def test_rebinding_the_same_key_is_idempotent(self, adapter):
        a, db = adapter
        _seed(db, "sess-A", key=KEY)
        a._bind_declared_conversation("sess-A", KEY)
        assert db.get_session("sess-A")["session_key"] == KEY
        assert a._declared_conversation_session(KEY) == "sess-A"


API_KEY = "test-api-key"


@pytest.fixture
def live(tmp_path):
    """A real adapter behind real routes, with a scratch state.db attached."""
    adapter = APIServerAdapter(
        PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": 0, "key": API_KEY})
    )
    db = SessionDB(tmp_path / "state.db")
    adapter._session_db = db

    mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
    app = web.Application(middlewares=mws)
    app["api_server_adapter"] = adapter
    app.router.add_post("/v1/responses", adapter._handle_responses)
    app.router.add_post("/v1/runs", adapter._handle_runs)
    try:
        yield adapter, db, app
    finally:
        db.close()


def _spy_run_agent(adapter, seen):
    """Stand in for _run_agent, faithful to its bind contract.

    Production records the peer in _run_agent's ``finally`` when the caller
    opted in; mirroring that here keeps the assertion on the real handler's
    decision AND on the row it produces, instead of on a gate expression
    restated inside the test.
    """

    async def _fake(**kwargs):
        seen.append(kwargs)
        # AIAgent._ensure_db_session() creates the row during the turn.
        sid = kwargs.get("session_id")
        db = adapter._ensure_session_db()
        if sid and db is not None and db.get_session(sid) is None:
            db.create_session(session_id=sid, source=SOURCE, model="m")
        if kwargs.get("bind_declared_conversation"):
            adapter._bind_declared_conversation(
                kwargs.get("session_id"), kwargs.get("gateway_session_key")
            )
        return (
            {"final_response": "ok", "messages": [], "api_calls": 1},
            {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
        )

    return _fake


def _headers(session_key=None):
    h = {"Authorization": f"Bearer {API_KEY}"}
    if session_key:
        h["X-Hermes-Session-Key"] = session_key
    return h


class TestResponsesHandlerPrecedence:
    """POST /v1/responses driven end to end, not a restated gate."""


    @pytest.mark.asyncio
    async def test_undeclared_request_keeps_a_per_request_id(self, live):
        adapter, db, app = live
        seen = []
        adapter._run_agent = _spy_run_agent(adapter, seen)

        async with TestClient(TestServer(app)) as cli:
            for _ in range(2):
                resp = await cli.post(
                    "/v1/responses",
                    json={"model": "hermes-agent", "input": "hi"},
                    headers=_headers(),
                )
                assert resp.status == 200

        assert len({k["session_id"] for k in seen}) == 2
        assert all(k["bind_declared_conversation"] is False for k in seen)


class TestRunsHandlerPrecedence:
    """POST /v1/runs driven end to end.

    /v1/runs owns its agent lifecycle rather than routing through _run_agent,
    so the session it settled on is captured where it builds the agent.
    """

    @staticmethod
    def _capture_agent(adapter, seen):
        real = adapter._create_agent

        def _spy(*a, **kw):
            seen.append(kw)
            agent = types.SimpleNamespace(
                session_id=kw.get("session_id"),
                session_prompt_tokens=0,
                session_completion_tokens=0,
                session_total_tokens=0,
                run_conversation=lambda **_kw: {"final_response": "ok"},
                interrupt=lambda *_a, **_k: None,
            )
            return agent

        adapter._create_agent = _spy
        return real

    @pytest.mark.asyncio
    async def test_declared_key_selects_the_conversation(self, live):
        adapter, db, app = live
        seen = []
        self._capture_agent(adapter, seen)
        _seed(db, "sess-live", key=KEY)

        async with TestClient(TestServer(app)) as cli:
            resp = await cli.post(
                "/v1/runs",
                json={"model": "hermes-agent", "input": "hi"},
                headers=_headers(KEY),
            )
            assert resp.status in (200, 202)

        assert seen and seen[0]["session_id"] == "sess-live"

    @pytest.mark.asyncio
    async def test_explicit_body_session_outranks_the_header_key(self, live):
        adapter, db, app = live
        seen = []
        self._capture_agent(adapter, seen)
        _seed(db, "sess-live", key=KEY)

        async with TestClient(TestServer(app)) as cli:
            resp = await cli.post(
                "/v1/runs",
                json={"model": "hermes-agent", "input": "hi",
                      "session_id": "explicit-session"},
                headers=_headers(KEY),
            )
            assert resp.status in (200, 202)

        assert seen and seen[0]["session_id"] == "explicit-session"
        # KEY still resolves to its own conversation, never the explicit one.
        assert adapter._declared_conversation_session(KEY) == "sess-live"


async def _await_run(adapter, run_id, timeout=10.0):
    """Wait for a /v1/runs worker to finish, so settlement has happened."""
    deadline = asyncio.get_running_loop().time() + timeout
    while asyncio.get_running_loop().time() < deadline:
        if run_id not in adapter._active_run_agents:
            # One more tick so the executor thread's finally can retire.
            await asyncio.sleep(0.05)
            return True
        await asyncio.sleep(0.02)
    return False


def _stub_agent(adapter, session_id, seen):
    """An agent good enough for the REAL _run_agent to drive end to end.

    Replacing `_run_agent` itself cannot exercise its settlement block, which
    is where a caller-local name leaked in and raised `NameError` on every
    opted-in bind while mocked tests stayed green (@andrexibiza on #98811).
    Stubbing one layer lower — `_create_agent` — leaves that block real.
    """
    agent = MagicMock()
    agent.session_id = session_id
    agent.session_prompt_tokens = 0
    agent.session_completion_tokens = 0
    agent.session_total_tokens = 0

    def _run(**kwargs):
        seen.append(kwargs)
        db = adapter._ensure_session_db()
        # AIAgent._ensure_db_session() creates the row during the turn.
        if db is not None and db.get_session(session_id) is None:
            db.create_session(session_id=session_id, source=SOURCE, model="m")
        return {"final_response": "ok", "messages": [], "api_calls": 1}

    agent.run_conversation.side_effect = _run
    return agent


class TestRealRunAgentSettlement:
    """The real `_run_agent` settlement block, not a stand-in for it."""

    @pytest.mark.asyncio
    async def test_declared_bind_settles_without_raising(self, live):
        adapter, db, app = live
        seen = []
        created = {}

        def _create(**kw):
            created.update(kw)
            return _stub_agent(adapter, kw["session_id"], seen)

        adapter._create_agent = _create

        async with TestClient(TestServer(app)) as cli:
            resp = await cli.post(
                "/v1/responses",
                json={"model": "hermes-agent", "input": "hi"},
                headers=_headers(KEY),
            )
            assert resp.status == 200
            body = await resp.json()

        assert body["status"] == "completed"
        sid = created["session_id"]
        # Settlement ran for real: the row is bound and recoverable.
        assert db.get_session(sid)["session_key"] == KEY
        assert adapter._declared_conversation_session(KEY) == sid

    @pytest.mark.asyncio
    async def test_two_replies_settle_on_one_conversation(self, live):
        adapter, db, app = live
        seen = []
        ids = []

        def _create(**kw):
            ids.append(kw["session_id"])
            return _stub_agent(adapter, kw["session_id"], seen)

        adapter._create_agent = _create

        async with TestClient(TestServer(app)) as cli:
            for _ in range(2):
                resp = await cli.post(
                    "/v1/responses",
                    json={"model": "hermes-agent", "input": "hi"},
                    headers=_headers(KEY),
                )
                assert resp.status == 200

        assert len(set(ids)) == 1

    @pytest.mark.asyncio
    async def test_a_chained_turn_never_rebinds_through_real_settlement(self, live):
        adapter, db, app = live
        seen = []

        def _create(**kw):
            return _stub_agent(adapter, kw["session_id"], seen)

        adapter._create_agent = _create

        _seed(db, "sess-A", key=KEY)
        adapter._response_store.put(
            "resp_A",
            {"conversation_history": [], "session_id": "sess-A", "instructions": None},
        )

        async with TestClient(TestServer(app)) as cli:
            resp = await cli.post(
                "/v1/responses",
                json={"model": "hermes-agent", "input": "hi",
                      "previous_response_id": "resp_A"},
                headers=_headers(OTHER_KEY),
            )
            assert resp.status == 200

        assert db.get_session("sess-A")["session_key"] == KEY
        assert adapter._declared_conversation_session(OTHER_KEY) is None

    @pytest.mark.asyncio
    async def test_runs_explicit_unkeyed_session_stays_unbound(self, live):
        """The reviewer's exact case: an unkeyed explicit row must not be taken.

        `_run_sync` bound unconditionally, so an explicit body session_id that
        existed with an empty `session_key` was silently adopted by the header
        key even though the header lost precedence.
        """
        adapter, db, app = live
        seen = []

        def _create(**kw):
            return _stub_agent(adapter, kw["session_id"], seen)

        adapter._create_agent = _create

        # Explicit session exists and carries NO routing key.
        db.create_session(session_id="explicit-session", source=SOURCE, model="m")
        _seed(db, "sess-live", key=KEY)

        async with TestClient(TestServer(app)) as cli:
            resp = await cli.post(
                "/v1/runs",
                json={"model": "hermes-agent", "input": "hi",
                      "session_id": "explicit-session"},
                headers=_headers(KEY),
            )
            assert resp.status in (200, 202)
            run_id = (await resp.json()).get("run_id")
            assert run_id
            # /v1/runs answers before the turn settles, so the assertion must
            # wait for the worker's finally to run. Without this the test
            # passes on timing rather than on the precedence gate.
            assert await _await_run(adapter, run_id), "run never settled"

        row = db.get_session("explicit-session")
        assert not (row.get("session_key") or "")
        assert adapter._declared_conversation_session(KEY) == "sess-live"
