"""Regression tests for #73503 — codex_app_server compression must not be a no-op.

On the codex_app_server runtime the model's real working context is the
app-server's server-side thread (CodexAppServerSession is constructed with no
history and each turn submits only the new user message), so:

* the old detached-agent hygiene path could only rewrite the transcript
  mirror — a guaranteed no-op ("compressed 150 -> 150 msgs") — and then
  evicted the live cached agent, destroying the thread that held the only
  real context;
* the fix routes hygiene and manual /compress to the LIVE cached agent's
  ``thread/compact/start`` (asserted here at the compact_thread RPC-stub
  boundary) and keeps that agent cached;
* ``compression.codex_app_server_auto`` semantics hold: only ``hermes``
  lets Hermes' threshold start a compaction; ``native``/``off`` skip
  cleanly, and no mode ever runs the local transcript compressor.
"""

import asyncio
from types import SimpleNamespace

import pytest

from agent.conversation_compression import compress_context
from agent.transports.codex_app_server_session import TurnResult
from gateway.run import run_codex_hygiene_compaction


class FakeCodexSession:
    """RPC-stub boundary: stands in for the app-server thread client."""

    def __init__(self, result=None):
        self.result = result or TurnResult(
            thread_id="thread-1", turn_id="compact-1", compacted=True
        )
        self.compact_calls = 0
        self.closed = False

    def compact_thread(self):
        self.compact_calls += 1
        return self.result

    def close(self):
        self.closed = True


class LiveCodexAgent:
    """Minimal live cached agent whose _compress_context is the REAL routing.

    The forwarder mirrors run_agent.AIAgent._compress_context: it calls the
    module-level compress_context(), so these tests exercise the genuine
    codex route (mode gate, cooldown, compact_thread RPC) rather than a stub
    of the code under test.
    """

    def __init__(self, mode="hermes", session=None):
        self.api_mode = "codex_app_server"
        self.codex_app_server_auto_compaction = mode
        self.session_id = "sess-1"
        self.platform = "telegram"
        self._cached_system_prompt = "cached prompt"
        self._codex_session = session if session is not None else FakeCodexSession()
        self.context_compressor = SimpleNamespace(
            compression_count=0,
            last_compression_rough_tokens=0,
            last_prompt_tokens=100,
            last_completion_tokens=10,
            awaiting_real_usage_after_compression=False,
        )
        self.local_compress_calls = 0
        self.warnings = []

    # -- surface used by compress_context --------------------------------
    def _touch_activity(self, *a, **k):
        pass

    def _emit_status(self, m):
        pass

    def _emit_warning(self, m):
        self.warnings.append(m)

    def _build_system_prompt(self, s):
        return "built prompt"

    def _compress_context(self, messages, system_message, **kwargs):
        return compress_context(self, messages, system_message, **kwargs)


def _history(n=150):
    return [
        {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}" * 50}
        for i in range(n)
    ]


def _gateway(tmp_path, session_key="tg:123", agent=None):
    from hermes_state import SessionDB

    db = SessionDB(db_path=tmp_path / "state.db")
    gw = SimpleNamespace(
        _agent_cache={} if agent is None else {session_key: (agent, 0.0)},
        _agent_cache_lock=None,
        _session_db=db,
    )
    return gw, db


# ---------------------------------------------------------------------------
# Core no-op regression: hermes mode + live thread => thread/compact runs
# ---------------------------------------------------------------------------

def test_hermes_mode_compacts_live_thread_at_rpc_boundary(tmp_path):
    agent = LiveCodexAgent(mode="hermes")
    key = "tg:123"
    gw, db = _gateway(tmp_path, key, agent)
    # Pre-arm a persisted failure streak so success provably resets it
    # through the REAL SessionDB (hygiene path involves the DB).
    assert db.increment_hygiene_failure_streak(key) == 1

    outcome = asyncio.run(
        run_codex_hygiene_compaction(
            gw,
            key,
            agent.session_id,
            auto_mode="hermes",
            history=_history(),
            approx_tokens=345_000,
            timeout_seconds=30.0,
        )
    )

    assert outcome == "compacted"
    # The no-op is gone: the thread genuinely shrank via the app-server's own
    # mechanism — asserted at the RPC-stub boundary.
    assert agent._codex_session.compact_calls == 1
    # A compaction boundary was recorded on the live agent's compressor.
    assert agent.context_compressor.compression_count == 1
    # The live agent is STILL cached — hygiene must not evict the thread owner.
    assert gw._agent_cache[key][0] is agent
    # Success reset the persisted hygiene failure streak.
    assert db.increment_hygiene_failure_streak(key) == 1  # was cleared to 0


def test_hermes_mode_without_cached_agent_skips_without_local_compression(tmp_path):
    # Detached case: no live agent → no thread → nothing real to compact.
    # The old behavior "compressed" the mirror (a no-op) and then evicted;
    # the new behavior is an honest skip with the transcript untouched.
    gw, _ = _gateway(tmp_path, agent=None)
    history = _history()
    before = [dict(m) for m in history]

    outcome = asyncio.run(
        run_codex_hygiene_compaction(
            gw,
            "tg:123",
            "sess-1",
            auto_mode="hermes",
            history=history,
            approx_tokens=345_000,
            timeout_seconds=5.0,
        )
    )

    assert outcome == "skipped:no-cached-agent"
    assert history == before


# ---------------------------------------------------------------------------
# Mode semantics: native / off never touch the thread nor run local fallback
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("mode", ["native", "off"])
def test_native_and_off_modes_skip_cleanly(tmp_path, mode):
    agent = LiveCodexAgent(mode=mode)
    key = "tg:123"
    gw, _ = _gateway(tmp_path, key, agent)

    outcome = asyncio.run(
        run_codex_hygiene_compaction(
            gw,
            key,
            agent.session_id,
            auto_mode=mode,
            history=_history(),
            approx_tokens=345_000,
            timeout_seconds=5.0,
        )
    )

    assert outcome == f"skipped:mode={mode}"
    # off must not silently compress; native leaves scheduling to codex.
    assert agent._codex_session.compact_calls == 0
    assert agent.context_compressor.compression_count == 0
    # And the agent stays cached in every skip path.
    assert gw._agent_cache[key][0] is agent


def test_unknown_mode_falls_back_to_native_semantics(tmp_path):
    agent = LiveCodexAgent(mode="banana")
    gw, _ = _gateway(tmp_path, "tg:123", agent)
    outcome = asyncio.run(
        run_codex_hygiene_compaction(
            gw,
            "tg:123",
            agent.session_id,
            auto_mode="banana",
            history=_history(),
            approx_tokens=345_000,
            timeout_seconds=5.0,
        )
    )
    assert outcome == "skipped:mode=native"
    assert agent._codex_session.compact_calls == 0


# ---------------------------------------------------------------------------
# force=True (manual /compress) must never violate the "no local fallback"
# contract: it compacts the THREAD in every mode, and never rewrites the
# transcript mirror.
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("mode", ["native", "hermes", "off"])
def test_force_compacts_thread_never_local_fallback(mode):
    agent = LiveCodexAgent(mode=mode)
    messages = _history()
    before = [dict(m) for m in messages]

    returned, prompt = compress_context(
        agent, messages, "system", approx_tokens=345_000, force=True
    )

    # Thread compaction ran (manual force is an explicit user decision)...
    assert agent._codex_session.compact_calls == 1
    # ...and the local transcript mirror was NOT rewritten in any mode.
    assert returned is messages
    assert returned == before
    assert prompt == "cached prompt"


@pytest.mark.parametrize("mode", ["native", "off"])
def test_force_without_live_thread_does_not_run_local_compressor(mode):
    # The #73715 branch let force=True fall through to the local Hermes
    # compressor in native/off when no thread existed. That is wrong on this
    # runtime in every mode: rewriting the mirror cannot shrink the thread.
    agent = LiveCodexAgent(mode=mode, session=None)
    agent._codex_session = None
    messages = _history()
    before = [dict(m) for m in messages]

    returned, _ = compress_context(
        agent, messages, "system", approx_tokens=345_000, force=True
    )

    assert returned is messages
    assert returned == before
    assert agent.context_compressor.compression_count == 0


# ---------------------------------------------------------------------------
# Failure handling: a wedged compaction records a DB cooldown (real SessionDB)
# ---------------------------------------------------------------------------

def test_timeout_records_persistent_cooldown(tmp_path):
    class HangingSession(FakeCodexSession):
        def compact_thread(self):
            self.compact_calls += 1
            import time

            time.sleep(1.5)
            return self.result

    agent = LiveCodexAgent(mode="hermes", session=HangingSession())
    key = "tg:123"
    gw, db = _gateway(tmp_path, key, agent)
    db.create_session(agent.session_id, "gateway")

    outcome = asyncio.run(
        run_codex_hygiene_compaction(
            gw,
            key,
            agent.session_id,
            auto_mode="hermes",
            history=_history(),
            approx_tokens=345_000,
            timeout_seconds=1.0,
            failure_cooldown_seconds=300.0,
        )
    )

    assert outcome == "failed:timeout"
    state = db.get_compression_failure_cooldown(agent.session_id)
    assert state is not None and state.get("remaining_seconds", 0) > 0
    # Even on failure the live agent stays cached — its thread still holds
    # the only real context.
    assert gw._agent_cache[key][0] is agent


# ---------------------------------------------------------------------------
# Manual /compress gateway surface
# ---------------------------------------------------------------------------

def _slash_host(agent, session_key="tg:123"):
    from gateway.slash_commands import GatewaySlashCommandsMixin

    host = SimpleNamespace(
        _agent_cache={session_key: (agent, 0.0)} if agent is not None else {},
        _agent_cache_lock=None,
    )

    async def _run_in_executor_with_context(fn):
        return await asyncio.get_running_loop().run_in_executor(None, fn)

    host._run_in_executor_with_context = _run_in_executor_with_context
    host._cached_agent_for = GatewaySlashCommandsMixin._cached_agent_for.__get__(host)
    host._compress_codex_app_server_session = (
        GatewaySlashCommandsMixin._compress_codex_app_server_session.__get__(host)
    )
    return host


def test_manual_compress_routes_to_live_thread():
    agent = LiveCodexAgent(mode="off")  # even 'off': manual is a user decision
    host = _slash_host(agent)

    reply = asyncio.run(
        host._compress_codex_app_server_session("tg:123", agent.session_id)
    )

    assert agent._codex_session.compact_calls == 1
    assert reply


@pytest.mark.parametrize("entry", ["hygiene", "manual"])
def test_codex_compaction_releases_the_live_sessions_read_dedup(tmp_path, entry, monkeypatch):
    """Both out-of-turn codex compaction entry points reset the read_file dedup under the LIVE
    session row id — the task_id the main turn hands to run_conversation — not the "default"
    bucket, so a post-compaction re-read returns content instead of a stub (#98206)."""
    from tools import file_tools_read_tracking as tracking

    tracker = {
        "sess-1": {"dedup_generation_reads": {"/live/file.py"}},
        "default": {"dedup_generation_reads": {"/other/file.py"}},
    }
    monkeypatch.setattr(tracking, "_read_tracker", tracker)
    agent = LiveCodexAgent(mode="hermes")

    if entry == "hygiene":
        gw, _db = _gateway(tmp_path, "tg:123", agent)
        outcome = asyncio.run(run_codex_hygiene_compaction(
            gw, "tg:123", agent.session_id, auto_mode="hermes", history=_history(),
            approx_tokens=345_000, timeout_seconds=30.0))
        assert outcome == "compacted"
    else:
        reply = asyncio.run(_slash_host(agent)._compress_codex_app_server_session("tg:123", agent.session_id))
        assert "compacted" in reply

    assert tracker["sess-1"]["dedup_generation_reads"] == set()
    assert tracker["default"]["dedup_generation_reads"] == {"/other/file.py"}




# ---------------------------------------------------------------------------
# Multiplexed gateway: the hygiene worker must see the caller's ContextVars
# (profile secret scope / HERMES_HOME override). A bare run_in_executor worker
# starts with an EMPTY Context, so get_secret(<PROVIDER>_API_KEY) inside the
# summary path fails closed and every hygiene compaction degrades to a lossy
# truncation (#100849 bundle).
# ---------------------------------------------------------------------------

def test_hygiene_worker_inherits_caller_contextvars(tmp_path):
    import contextvars
    import threading

    marker = contextvars.ContextVar("hygiene_scope_marker", default=None)
    seen = {}

    class ScopeProbeAgent(LiveCodexAgent):
        def _compress_context(self, messages, system_message, **kwargs):
            seen["value"] = marker.get()
            seen["thread"] = threading.current_thread().name
            return super()._compress_context(messages, system_message, **kwargs)

    agent = ScopeProbeAgent(mode="hermes")
    key = "tg:ctx"
    gw, _db = _gateway(tmp_path, key, agent)

    async def _scoped():
        token = marker.set("profile-scope")
        try:
            return await run_codex_hygiene_compaction(
                gw, key, agent.session_id, auto_mode="hermes",
                history=_history(), approx_tokens=345_000, timeout_seconds=30.0,
            )
        finally:
            marker.reset(token)

    assert asyncio.run(_scoped()) == "compacted"
    assert seen["thread"] != "MainThread", "compaction must still run off-loop"
    assert seen["value"] == "profile-scope", (
        "hygiene worker lost the caller's ContextVars — under multiplex_profiles "
        "this is the UnscopedSecretError / lossy-truncation regression"
    )
