"""Tests for tui_gateway JSON-RPC protocol plumbing."""

import io
import json
import sys
import threading
import time
import types
from unittest.mock import MagicMock, patch
from pathlib import Path

import pytest

_original_stdout = sys.stdout


@pytest.fixture(autouse=True)
def _restore_stdout():
    yield
    sys.stdout = _original_stdout


@pytest.fixture()
def server():
    # The sys.modules mocks only need to cover the *initial* import — once
    # tui_gateway.server is cached, they are inert. Keeping them active for
    # the whole test poisons any module first imported inside a test body:
    # e.g. hermes_cli.active_sessions would bind the mocked get_hermes_home
    # (a fixed shared path) forever, leaking active-session registry entries
    # across every later test in the process. Scope the patch to the import.
    with patch.dict("sys.modules", {
        "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
        "hermes_cli.env_loader": MagicMock(),
        "hermes_cli.banner": MagicMock(),
        "hermes_state": MagicMock(),
    }):
        import importlib
        mod = importlib.import_module("tui_gateway.server")

    # Snapshot the RPC registry: several tests below stub handlers
    # ("slash.exec", "fast.ping", ...) directly in the module-level dict,
    # which is shared with every other test file in the process.
    methods = dict(mod._methods)
    real_stdout = mod._real_stdout
    yield mod
    # Reset module-level state without re-importing. importlib.reload
    # would re-register the module's atexit hooks (ThreadPoolExecutor
    # shutdown, _shutdown_sessions); the duplicates race the stderr
    # buffer at interpreter shutdown and surface as Fatal Python error:
    # _enter_buffered_busy. Restoring the dicts in place gives the next
    # test a clean slate.
    mod._methods.clear()
    mod._methods.update(methods)
    mod._real_stdout = real_stdout
    for sid in list(mod._sessions):
        mod._close_session_by_id(sid, end_reason="test_cleanup")
    mod._pending.clear()
    mod._answers.clear()
    mod._live_transports.clear()


def test_shared_fixture_cleanup_uses_full_session_teardown(server, monkeypatch):
    """The cross-file autouse cleanup must close every retained resource."""
    from tests import conftest

    closed = {"worker": 0, "agent": 0, "lease": 0}

    class _Closable:
        def __init__(self, key):
            self.key = key

        def close(self):
            closed[self.key] += 1

    class _Lease:
        def release(self):
            closed["lease"] += 1

    monkeypatch.setattr(server, "_get_db", lambda: None)
    server._sessions["leaked"] = {
        "session_key": "leaked",
        "agent": _Closable("agent"),
        "slash_worker": _Closable("worker"),
        "active_session_lease": _Lease(),
        "history": [],
    }

    conftest._teardown_tui_server_sessions(server)

    assert server._sessions == {}
    assert closed == {"worker": 1, "agent": 1, "lease": 1}


@pytest.fixture()
def capture(server):
    """Redirect server's real stdout to a StringIO and return (server, buf)."""
    buf = io.StringIO()
    server._real_stdout = buf
    return server, buf


# ── JSON-RPC envelope ────────────────────────────────────────────────


def test_unknown_method(server):
    resp = server.handle_request({"id": "1", "method": "bogus"})
    assert resp["error"]["code"] == -32601


def test_ok_envelope(server):
    assert server._ok("r1", {"x": 1}) == {
        "jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
    }


def test_err_envelope(server):
    assert server._err("r2", 4001, "nope") == {
        "jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
    }


@pytest.mark.parametrize("kind", ["legacy", "hard-only", "dynamic-getattr"])
def test_session_interrupt_uses_explicit_stop_compatibility(server, monkeypatch, kind):
    calls = []

    class _Legacy:
        def interrupt(self):
            calls.append("legacy")

    class _HardOnly:
        def hard_interrupt(self):
            calls.append("hard")

    class _Dynamic:
        def interrupt(self):
            calls.append("legacy")

        def __getattr__(self, name):
            if name == "hard_interrupt":
                return lambda: calls.append("fabricated-hard")
            raise AttributeError(name)

    agent = {
        "legacy": _Legacy(),
        "hard-only": _HardOnly(),
        "dynamic-getattr": _Dynamic(),
    }[kind]
    session = {
        "agent": agent,
        "history_lock": threading.Lock(),
        "running": True,
        "queued_prompt": "later",
        "session_key": "session-key",
        "_run_thread": None,
    }
    monkeypatch.setattr(server, "_tts_stream_stop", lambda: None)
    monkeypatch.setattr(server, "_sess_nowait", lambda _params, _rid: (session, None))
    monkeypatch.setattr(server, "_sess", lambda _params, _rid: (session, None))
    monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: False)
    monkeypatch.setattr(server, "_clear_pending", lambda _sid: None)
    response = server._methods["session.interrupt"](
        "stop", {"session_id": "ui-session"}
    )

    assert response["result"]["status"] == "interrupted"
    assert calls == ["hard" if kind == "hard-only" else "legacy"]


# ── write_json ────────────────────────────────────────────────


def test_write_json(capture):
    server, buf = capture
    assert server.write_json({"test": True})
    assert json.loads(buf.getvalue()) == {"test": True}


def test_live_session_payload_replays_pending_approval(server, monkeypatch):
    """A reattached client receives the approval that was emitted while detached."""
    from tools import approval
    from tools import approval_gateway_wait

    session = {
        "agent": types.SimpleNamespace(),
        "cols": 80,
        "created_at": 1.0,
        "history": [],
        "history_lock": threading.Lock(),
        "running": True,
        "session_key": "stored-session",
    }
    first = {
        "choices": ["once", "deny"],
        "command": "rm -rf /tmp/example",
        "description": "recursive delete",
    }
    second = {"command": "rm -rf /tmp/later", "description": "later"}
    saved_queue = approval._gateway_queues.pop("stored-session", None)
    approval._gateway_queues["stored-session"] = [
        approval_gateway_wait._ApprovalEntry(first),
        approval_gateway_wait._ApprovalEntry(second),
    ]
    monkeypatch.setattr(server, "_approval_request_payload", lambda data: dict(data or {}))

    try:
        payload = server._live_session_payload("runtime-session", session)
    finally:
        approval._gateway_queues.pop("stored-session", None)
        if saved_queue is not None:
            approval._gateway_queues["stored-session"] = saved_queue

    assert payload["pending_approval"] is not first
    replayed = payload["pending_approval"]
    # request_id is injected by _ApprovalEntry so reconnecting clients can
    # correlate their approval.respond with the exact queued request.
    assert replayed.pop("request_id")
    assert replayed == first


def test_live_session_payload_replays_pending_clarify(server):
    """A reattached client also receives a clarify question emitted while detached."""
    session = {
        "agent": types.SimpleNamespace(),
        "cols": 80,
        "created_at": 1.0,
        "history": [],
        "history_lock": threading.Lock(),
        "running": True,
        "session_key": "stored-session",
    }
    clarify_payload = {
        "choices": ["staging", "production"],
        "question": "Which deployment target?",
        "request_id": "rid-clarify",
    }
    with server._prompt_lock:
        server._pending["rid-clarify"] = ("runtime-session", threading.Event())
        server._pending_prompt_payloads["rid-clarify"] = (
            "clarify.request",
            dict(clarify_payload),
        )

    try:
        payload = server._live_session_payload("runtime-session", session)
        other = server._live_session_payload("other-session", session)
    finally:
        with server._prompt_lock:
            server._pending.pop("rid-clarify", None)
            server._pending_prompt_payloads.pop("rid-clarify", None)

    assert payload["pending_clarify"] == clarify_payload
    # Snapshot, not a live reference into the registry.
    assert payload["pending_clarify"] is not clarify_payload
    # Scoped to the owning runtime session only.
    assert "pending_clarify" not in other


def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
    """End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
    `tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.

    Reloads only the transport module — server.py is untouched so its
    atexit hooks/worker pool stay intact."""
    import importlib

    monkeypatch.setenv("HERMES_TUI_GATEWAY_NO_FLUSH", "1")
    transport_mod = importlib.reload(importlib.import_module("tui_gateway.transport"))

    try:
        assert transport_mod._DISABLE_FLUSH is True
    finally:
        # Restore the env-disabled state so other tests see the default.
        monkeypatch.delenv("HERMES_TUI_GATEWAY_NO_FLUSH", raising=False)
        importlib.reload(transport_mod)


# ── _emit ────────────────────────────────────────────────────────────


def test_emit_with_payload(capture):
    server, buf = capture
    server._emit("test.event", "s1", {"key": "val"})
    msg = json.loads(buf.getvalue())

    assert msg["method"] == "event"
    assert msg["params"]["type"] == "test.event"
    assert msg["params"]["session_id"] == "s1"
    assert msg["params"]["payload"]["key"] == "val"


# ── Blocking prompt round-trip ───────────────────────────────────────


def test_block_and_respond(capture):
    server, _ = capture
    result = [None]

    threading.Thread(
        target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
    ).start()

    for _ in range(100):
        if server._pending:
            break
        threading.Event().wait(0.01)

    rid = next(iter(server._pending))
    server._answers[rid] = "my_answer"
    # _pending values are (sid, Event) tuples — unpack to set the Event
    _, ev = server._pending[rid]
    ev.set()

    threading.Event().wait(0.1)
    assert result[0] == "my_answer"


@pytest.mark.parametrize(
    "event",
    ["secret.request", "sudo.request", "clarify.request", "terminal.read.request"],
)
def test_sensitive_prompt_timeout_emits_expiry(capture, event):
    server, buf = capture

    assert server._block(event, "s1", {}, timeout=0) == ""

    messages = [json.loads(line) for line in buf.getvalue().splitlines()]
    request, expiry = [message["params"] for message in messages]
    assert request["type"] == event
    assert expiry["type"] == event.removesuffix(".request") + ".expire"
    assert expiry["session_id"] == "s1"
    assert expiry["payload"]["request_id"] == request["payload"]["request_id"]


@pytest.mark.parametrize(
    ("method", "value_key"),
    [
        ("secret.respond", "value"),
        ("sudo.respond", "password"),
        ("clarify.respond", "answer"),
        ("terminal.read.respond", "text"),
    ],
)
def test_late_prompt_response_is_idempotent(server, method, value_key):
    """All four blocking bridges tolerate a late reply after their request has
    expired — the `*.respond` returns a graceful `{"status": "expired"}` instead
    of the raw 4009 protocol error a client would otherwise surface verbatim."""
    response = server.handle_request(
        {
            "id": "late-response",
            "method": method,
            "params": {"request_id": "expired-request", value_key: ""},
        }
    )

    assert response["result"] == {"status": "expired"}


# ── clarify batch (multi-question) bridge ────────────────────────────


def _drain_batch_block(server, qids, timeout=5, payload=None):
    """Run a batch _block on a worker thread and return (thread, result box,
    emitted request payload). The caller resolves questions via
    handle_request and then joins."""
    box = {}

    def run():
        box["answer"] = server._block(
            "clarify.request",
            "s1",
            dict(payload or {"questions": [{"qid": q, "question": q} for q in qids]}),
            timeout=timeout,
            batch_qids=list(qids),
        )

    thread = threading.Thread(target=run, daemon=True)
    thread.start()
    # Wait for the request to be registered so respond calls can find it.
    deadline = time.monotonic() + 2
    while time.monotonic() < deadline:
        with server._prompt_lock:
            if server._batch_clarify:
                rid = next(iter(server._batch_clarify))
                return thread, box, rid
        time.sleep(0.01)
    raise AssertionError("batch clarify request never registered")


def test_clarify_batch_resolves_when_all_questions_locked(capture):
    server, buf = capture
    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])

    first = server.handle_request({
        "id": "a1", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q1", "answer": "beta"},
    })
    assert first["result"]["status"] == "ok"
    assert first["result"]["remaining"] == ["q0"]
    assert thread.is_alive()  # one question left — still blocking

    second = server.handle_request({
        "id": "a2", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "alpha"},
    })
    assert second["result"]["status"] == "ok"
    assert second["result"]["remaining"] == []

    thread.join(timeout=5)
    assert not thread.is_alive()
    assert json.loads(box["answer"]) == {"answers": {"q0": "alpha", "q1": "beta"}}


def test_clarify_batch_answer_update_overwrites_before_completion(server):
    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])

    server.handle_request({
        "id": "a1", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "first"},
    })
    server.handle_request({
        "id": "a2", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "changed"},
    })
    server.handle_request({
        "id": "a3", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q1", "answer": "done"},
    })

    thread.join(timeout=5)
    assert json.loads(box["answer"])["answers"]["q0"] == "changed"


def test_clarify_batch_empty_answer_is_a_locked_skip(server):
    """Skipping one question locks an empty answer — it counts toward
    completion instead of leaving the batch waiting."""
    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])

    server.handle_request({
        "id": "a1", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": ""},
    })
    server.handle_request({
        "id": "a2", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q1", "answer": "kept"},
    })

    thread.join(timeout=5)
    assert json.loads(box["answer"]) == {"answers": {"q0": "", "q1": "kept"}}


def test_clarify_batch_unknown_question_id_rejected(server):
    thread, box, rid = _drain_batch_block(server, ["q0"])

    response = server.handle_request({
        "id": "bad", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q9", "answer": "x"},
    })
    assert response["error"]["code"] == 4002

    server.handle_request({
        "id": "ok", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "fine"},
    })
    thread.join(timeout=5)


def test_clarify_batch_timeout_keeps_locked_answers(capture):
    """Locked answers survive the deadline: the tool sees the partials plus
    timed_out instead of an empty string."""
    server, buf = capture
    thread, box, rid = _drain_batch_block(server, ["q0", "q1"], timeout=1)

    server.handle_request({
        "id": "a1", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "kept"},
    })

    thread.join(timeout=10)
    assert not thread.is_alive()
    result = json.loads(box["answer"])
    assert result == {"answers": {"q0": "kept"}, "timed_out": True}
    # The expire notification still fires for the un-finished batch.
    messages = [json.loads(line) for line in buf.getvalue().splitlines()]
    assert any(m["params"]["type"] == "clarify.expire" for m in messages)


def test_clarify_batch_cancel_all_returns_empty(server):
    """A respond without question_id cancels the whole batch (Esc path)."""
    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])

    server.handle_request({
        "id": "cancel", "method": "clarify.respond",
        "params": {"request_id": rid, "answer": ""},
    })

    thread.join(timeout=5)
    assert box["answer"] == ""


def test_clarify_batch_late_question_respond_is_idempotent(server):
    response = server.handle_request({
        "id": "late", "method": "clarify.respond",
        "params": {"request_id": "gone", "question_id": "q0", "answer": "x"},
    })
    assert response["result"] == {"status": "expired"}


def test_clarify_batch_state_cleared_after_resolution(server):
    thread, box, rid = _drain_batch_block(server, ["q0"])
    server.handle_request({
        "id": "a", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "x"},
    })
    thread.join(timeout=5)
    with server._prompt_lock:
        assert rid not in server._batch_clarify
        assert rid not in server._pending


def test_clarify_block_helper_builds_batch_payload(capture):
    """_clarify_block forwards only wire fields (qid/question/choices/
    multi_select) — the tool-side normalized entries carry extra keys the
    renderer must not see."""
    server, buf = capture
    normalized = [
        {
            "qid": "q0", "id": "approach", "question": "Which?",
            "choices": ["a (Recommended)", "b"], "choices_offered": ["a", "b"],
            "multi_select": False,
        },
    ]

    box = {}

    def run():
        box["answer"] = server._clarify_block("s1", "", None, questions=normalized)

    thread = threading.Thread(target=run, daemon=True)
    thread.start()
    deadline = time.monotonic() + 2
    rid = None
    while time.monotonic() < deadline and rid is None:
        with server._prompt_lock:
            rid = next(iter(server._batch_clarify), None)
        time.sleep(0.01)
    assert rid

    server.handle_request({
        "id": "a", "method": "clarify.respond",
        "params": {"request_id": rid, "question_id": "q0", "answer": "a"},
    })
    thread.join(timeout=5)

    messages = [json.loads(line) for line in buf.getvalue().splitlines()]
    request = messages[0]["params"]
    assert request["type"] == "clarify.request"
    sent = request["payload"]["questions"][0]
    assert set(sent) == {"qid", "question", "choices", "multi_select"}
    assert "id" not in sent and "choices_offered" not in sent


def test_approval_pending_replays_unresolved_requests(server, monkeypatch):
    from tools import approval

    server._sessions["ui-1"] = {"session_key": "agent-1", "history": []}
    pending = [{"request_id": "req-1", "command": "danger"}]
    monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: pending if key == "agent-1" else [])

    response = server.handle_request(
        {"id": "r1", "method": "approval.pending", "params": {"session_id": "ui-1"}}
    )

    assert response["result"] == {"approvals": pending}


def test_approval_received_acknowledges_exact_request(server, monkeypatch):
    from tools import approval

    server._sessions["ui-1"] = {"session_key": "agent-1", "history": []}
    calls = []
    monkeypatch.setattr(
        approval,
        "ack_gateway_approval",
        lambda key, request_id: calls.append((key, request_id)) or True,
    )

    response = server.handle_request(
        {
            "id": "r2",
            "method": "approval.received",
            "params": {"session_id": "ui-1", "request_id": "req-1"},
        }
    )

    assert response["result"] == {"acknowledged": True}
    assert calls == [("agent-1", "req-1")]


def test_approval_response_correlates_request_id(server, monkeypatch):
    from tools import approval

    server._sessions["ui-1"] = {"session_key": "agent-1", "history": []}
    calls = []
    monkeypatch.setattr(
        approval,
        "resolve_gateway_approval",
        lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1,
    )

    response = server.handle_request(
        {
            "id": "r3",
            "method": "approval.respond",
            "params": {"session_id": "ui-1", "request_id": "req-1", "choice": "once"},
        }
    )

    assert response["result"] == {"resolved": 1}
    assert calls == [("agent-1", "once", {"resolve_all": False, "request_id": "req-1"})]


def test_approval_respond_falls_back_to_request_id_lookup(server, monkeypatch):
    """A stale live sid must not 4001 an approval answer when the request_id
    resolves to a live session (durable-identity fallback, #91684)."""
    from tools import approval

    live = {"session_key": "agent-live", "history": []}
    server._sessions["ui-live"] = live
    calls = []
    monkeypatch.setattr(
        approval,
        "list_gateway_approvals",
        lambda key: [{"request_id": "req-91684"}] if key == "agent-live" else [],
    )
    monkeypatch.setattr(
        approval,
        "resolve_gateway_approval",
        lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1,
    )

    response = server.handle_request(
        {
            "id": "r-fallback",
            "method": "approval.respond",
            "params": {
                "session_id": "gone-sid",
                "request_id": "req-91684",
                "choice": "once",
            },
        }
    )

    assert response["result"] == {"resolved": 1}
    assert calls == [
        ("agent-live", "once", {"resolve_all": False, "request_id": "req-91684"})
    ]


def test_approval_respond_falls_back_to_stored_session_id(server, monkeypatch):
    """session_id holding a STORED id maps to the live runtime record."""
    from tools import approval

    live = {"session_key": "stored-91684", "history": []}
    server._sessions["ui-stored"] = live
    calls = []
    monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: [])
    monkeypatch.setattr(
        approval,
        "resolve_gateway_approval",
        lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1,
    )

    response = server.handle_request(
        {
            "id": "r-stored",
            "method": "approval.respond",
            "params": {"session_id": "stored-91684", "choice": "deny"},
        }
    )

    assert response["result"] == {"resolved": 1}
    assert calls == [
        ("stored-91684", "deny", {"resolve_all": False, "request_id": None})
    ]


def test_approval_respond_4001_when_nothing_resolves(server, monkeypatch):
    from tools import approval

    monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: [])
    response = server.handle_request(
        {
            "id": "r-nope",
            "method": "approval.respond",
            "params": {"session_id": "nope", "request_id": "req-x", "choice": "once"},
        }
    )

    assert response["error"]["code"] == 4001


def test_clear_pending(server):
    ev = threading.Event()
    # _pending values are (sid, Event) tuples
    server._pending["r1"] = ("sid-x", ev)
    server._clear_pending()

    assert ev.is_set()
    assert server._answers["r1"] == ""


# ── Session lookup ───────────────────────────────────────────────────


def test_sess_missing(server):
    _, err = server._sess({"session_id": "nope"}, "r1")
    assert err["error"]["code"] == 4001


# ── session.resume payload ────────────────────────────────────────────


def test_session_resume_returns_hydrated_messages(server, monkeypatch):
    class _DB:
        def get_session(self, _sid):
            return {"id": "20260409_010101_abc123"}

        def get_session_by_title(self, _title):
            return None

        def reopen_session(self, _sid):
            return None

        def get_resume_conversations(self, session_id):
            return (
                self.get_messages_as_conversation(session_id, repair_alternation=True),
                self.get_messages_as_conversation(session_id, include_ancestors=True),
            )

        def get_ancestor_display_prefix(self, _sid):
            return []

        def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
            return [
                {"role": "user", "content": "hello"},
                {"role": "assistant", "content": "yo", "reasoning": "thoughts"},
                {"role": "tool", "content": "searched"},
                {"role": "assistant", "content": "   "},
                {"role": "assistant", "content": None},
                {"role": "narrator", "content": "skip"},
            ]

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kwargs: object())
    monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None)
    monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"})

    resp = server.handle_request(
        {
            "id": "r1",
            "method": "session.resume",
            # eager_build: exercise the synchronous build path (this test
            # monkeypatches _make_agent/_init_session/_session_info).
            "params": {"session_id": "20260409_010101_abc123", "cols": 100, "eager_build": True},
        }
    )

    assert "error" not in resp
    assert resp["result"]["message_count"] == 3
    assert resp["result"]["messages"] == [
        {"role": "user", "text": "hello"},
        {"role": "assistant", "text": "yo", "reasoning": "thoughts"},
        {"role": "tool", "name": "tool", "context": ""},
    ]


def test_session_resume_rejects_runaway_transcript_before_history_load(
    server, monkeypatch
):
    class _DB:
        def get_session(self, sid):
            return {"id": sid, "message_count": 20_001}

        def get_session_by_title(self, _title):
            return None

        def resolve_resume_session_id(self, sid):
            return sid

        def reopen_session(self, _sid):
            raise AssertionError("oversized session must be rejected before reopen")

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    response = server.handle_request(
        {
            "id": "r1",
            "method": "session.resume",
            "params": {
                "session_id": "runaway-session",
                "omit_messages": True,
            },
        }
    )

    assert response["error"]["code"] == 4130
    assert "safe resume limit is 20000" in response["error"]["message"]


def test_session_resume_deferred_and_omitted_paths_guard_the_tip_only(server, monkeypatch):
    """A deep compression lineage behind a small tip must open on Desktop.

    Desktop's cold resume sends ``defer_history`` + ``omit_messages`` and pages
    the transcript over REST, so the process only ever holds the tip segment.
    Counting the whole lineage there returned 4130 for the healthiest sessions
    (85 compaction segments / ~29k rows / ~700-row tip: Bot Chat stuck on
    "Waking up…"). The guard must count what each path loads.
    """
    calls = []

    class _DB:
        def get_session(self, sid):
            return {"id": sid, "message_count": 28_730}

        def get_session_by_title(self, _title):
            return None

        def resolve_resume_session_id(self, sid):
            return sid

        def assert_resume_safe(self, sid, max_messages=None, *, tip_only=False):
            calls.append(tip_only)
            if not tip_only:
                from hermes_state import SessionResumeTooLargeError

                raise SessionResumeTooLargeError(20_001, 20_000)
            return 666

        def reopen_session(self, _sid):
            raise RuntimeError("stop before history load")

    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)

    for params in (
        {"defer_history": True, "omit_messages": True, "source": "desktop"},
        {"omit_messages": True},
        {"lazy": True},
    ):
        calls.clear()
        response = server.handle_request(
            {
                "id": "r-tip",
                "method": "session.resume",
                "params": {"session_id": "deep-lineage", **params},
            }
        )
        err = response.get("error") or {}
        assert err.get("code") != 4130, params
        assert calls == [True], params

    # The non-deferred, non-omitted resume materializes the full lineage in
    # memory, so it keeps the lineage-wide bound.
    calls.clear()
    response = server.handle_request(
        {"id": "r-full", "method": "session.resume", "params": {"session_id": "deep-lineage"}}
    )
    assert response["error"]["code"] == 4130
    assert calls == [False]


def test_deferred_hydration_falls_back_to_tip_when_lineage_exceeds_limit(server, monkeypatch):
    """The hydration worker never loads a lineage the guard would refuse."""
    import threading

    from hermes_state import SessionResumeTooLargeError

    tip = [{"role": "user", "content": "tip"}]
    reads = []

    class _DB:
        def reopen_session(self, _sid):
            return True

        def assert_resume_safe(self, sid, max_messages=None, *, tip_only=False):
            if not tip_only:
                raise SessionResumeTooLargeError(20_001, 20_000)
            return 1

        def get_resume_conversations(self, _sid):
            reads.append("lineage")
            raise AssertionError("must not materialize the runaway lineage")

        def get_ancestor_display_prefix(self, _sid):
            reads.append("prefix")
            raise AssertionError("must not materialize the runaway lineage")

        def get_messages_as_conversation(self, sid, **kwargs):
            reads.append(("tip", kwargs.get("repair_alternation")))
            return list(tip)

    built = threading.Event()
    monkeypatch.setattr(server, "_start_agent_build", lambda _sid, _session: built.set())
    monkeypatch.setattr(server, "_maybe_schedule_auto_continue", lambda *_a, **_k: None)

    session = server._deferred_session_record(
        "deep-lineage", cols=80, cwd="/tmp", history=[], lease=None
    )
    session["resume_history_ready"] = threading.Event()
    session["resume_hydrating"] = True
    session["resume_message_count"] = 28_730
    server._sessions["hyd"] = session
    try:
        server._schedule_resume_hydration("hyd", "deep-lineage", _DB())
        assert session["resume_history_ready"].wait(timeout=5)
        assert built.wait(timeout=5)
        assert session.get("resume_history_error") is None
        assert session["history"] == tip
        assert session["display_history_prefix"] == []
        assert session["resume_message_count"] == 1
        assert reads == [("tip", True)]
    finally:
        server._sessions.pop("hyd", None)


def test_session_resume_guard_failure_fails_open(server, monkeypatch):
    """A transient guard error must not block resume (fail open, log only)."""
    reopened = []

    class _DB:
        def get_session(self, sid):
            return {"id": sid}

        def get_session_by_title(self, _title):
            return None

        def resolve_resume_session_id(self, sid):
            return sid

        def assert_resume_safe(self, _sid):
            raise RuntimeError("database is locked")

        def reopen_session(self, sid):
            reopened.append(sid)
            return True

    monkeypatch.setattr(server, "_get_db", lambda: _DB())

    response = server.handle_request(
        {
            "id": "r-open",
            "method": "session.resume",
            "params": {
                "session_id": "transient-guard-session",
                "omit_messages": True,
            },
        }
    )

    # The guard must not block: no 4130, and any downstream failure must not
    # be the guard's own "resume safety check failed" error. Reopen being
    # attempted proves execution moved past the guard.
    err = response.get("error") or {}
    assert err.get("code") != 4130
    assert "resume safety check failed" not in str(err.get("message", ""))
    assert reopened == ["transient-guard-session"]


def test_session_resume_active_turn_payload_matches_desktop_fixture(server, monkeypatch):
    """A live resume serializes the exact timer payload consumed by Desktop."""
    fixture = json.loads(
        (Path(__file__).parents[1] / "fixtures" / "session-resume-active-turn.json").read_text(
            encoding="utf-8"
        )
    )

    class _DB:
        def get_session(self, session_id):
            return {"id": session_id}

        def get_session_by_title(self, _title):
            return None

        def resolve_resume_session_id(self, session_id):
            return session_id

    active_turn = {
        "assistant": "partial answer",
        "started_at": fixture["turn_started_at"],
        "streaming": True,
        "user": "current prompt",
    }
    server._sessions[fixture["session_id"]] = {
        "agent": types.SimpleNamespace(session_id=fixture["session_key"]),
        "created_at": fixture["started_at"],
        "history": [{"content": "earlier prompt", "role": "user"}],
        "history_lock": threading.Lock(),
        "inflight_turn": active_turn,
        "running": True,
        "session_key": fixture["session_key"],
    }
    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: fixture["info"])

    # JSON round-trip the real RPC envelope: the desktop fixture must stay
    # faithful to what the gateway actually serializes, not a copied shape.
    response = json.loads(
        json.dumps(
            server.handle_request(
                {
                    "id": "resume-running",
                    "method": "session.resume",
                    "params": {"session_id": fixture["session_key"]},
                }
            )
        )
    )
    result = response["result"]

    assert result["running"] is True
    assert result["turn_started_at"] == active_turn["started_at"]
    assert result == fixture


def test_enforce_session_cap_evicts_oldest_detached_only(server, monkeypatch):
    """The LRU cap frees the least-recently-active DETACHED sessions when over
    the limit, and never a live-transport / running / mid-build one."""

    monkeypatch.setattr(server, "_load_cfg", lambda: {"max_live_sessions": 2})
    evicted: list[str] = []
    monkeypatch.setattr(
        server,
        "_close_session_by_id",
        lambda sid, end_reason=None, predicate=None: evicted.append(sid),
    )

    def _ready() -> threading.Event:
        ev = threading.Event()
        ev.set()
        return ev

    detached = server._detached_ws_transport
    live = object()  # no _closed attr -> live transport, never evictable

    server._sessions.clear()
    server._sessions.update(
        {
            "old_detached": {"transport": detached, "last_active": 100.0, "agent_ready": _ready()},
            "new_detached": {"transport": detached, "last_active": 300.0, "agent_ready": _ready()},
            "running_detached": {
                "transport": detached,
                "last_active": 50.0,
                "running": True,
                "agent_ready": _ready(),
            },
            "focused_live": {"transport": live, "last_active": 200.0, "agent_ready": _ready()},
        }
    )

    server._enforce_session_cap()

    # 4 sessions, cap 2 -> evict 2. Only detached+idle+built are eligible, oldest
    # first; the running one and the live-transport one are exempt.
    assert evicted == ["old_detached", "new_detached"]


def test_sync_session_key_after_compress_reanchors_active_session_lease(
    server, monkeypatch, tmp_path
):
    home = tmp_path / ".hermes"
    monkeypatch.setenv("HERMES_HOME", str(home))

    from hermes_cli.active_sessions import (
        active_session_registry_snapshot,
        try_acquire_active_session,
    )

    lease, message = try_acquire_active_session(
        session_id="session-old",
        surface="tui",
        config={"max_concurrent_sessions": 1},
        metadata={"live_session_id": "ui-1"},
    )
    assert message is None
    assert lease is not None

    session = {
        "active_session_lease": lease,
        "agent": types.SimpleNamespace(session_id="session-new"),
        "session_key": "session-old",
    }
    fake_approval = types.SimpleNamespace(
        disable_session_yolo=lambda *_args, **_kwargs: None,
        enable_session_yolo=lambda *_args, **_kwargs: None,
        is_session_yolo_enabled=lambda *_args, **_kwargs: False,
        register_gateway_notify=lambda *_args, **_kwargs: None,
        unregister_gateway_notify=lambda *_args, **_kwargs: None,
    )
    monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args, **_kwargs: None)

    with patch.dict(sys.modules, {"tools.approval": fake_approval}):
        server._sync_session_key_after_compress("ui-1", session)

    snapshot = active_session_registry_snapshot()
    assert session["session_key"] == "session-new"
    assert lease.session_id == "session-new"
    assert [entry["session_id"] for entry in snapshot] == ["session-new"]
    lease.release()


def test_make_agent_accepts_list_system_prompt(server, monkeypatch):
    captured = {}

    class _Agent:
        def __init__(self, **kwargs):
            captured.update(kwargs)
            self.model = kwargs.get("model", "")

    monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=_Agent))
    monkeypatch.setitem(
        sys.modules,
        "hermes_cli.runtime_provider",
        types.SimpleNamespace(
            resolve_runtime_provider=lambda **_kwargs: {
                "provider": "test",
                "base_url": None,
                "api_key": None,
                "api_mode": None,
            }
        ),
    )
    monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"system_prompt": ["one", "two"]}})
    monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test/model", "test"))
    monkeypatch.setattr(server, "_get_db", lambda: None)

    server._make_agent("sid", "session-key", session_id="session-key")

    assert captured["ephemeral_system_prompt"] == "one\ntwo"


# ── Config I/O ───────────────────────────────────────────────────────


def test_config_roundtrip(server, tmp_path):
    server._hermes_home = tmp_path
    server._save_cfg({"model": "test/model"})
    assert server._load_cfg()["model"] == "test/model"


# ── _cli_exec_blocked ────────────────────────────────────────────────


@pytest.mark.parametrize("argv", [
    [],
    ["setup"],
    ["gateway"],
    ["sessions", "browse"],
    ["config", "edit"],
])
def test_cli_exec_blocked(server, argv):
    assert server._cli_exec_blocked(argv) is not None


# ── slash.exec skill command interception ────────────────────────────


def test_slash_exec_rejects_skill_commands(server):
    """slash.exec must reject skill commands so the TUI falls through to command.dispatch."""
    # Register a mock session
    sid = "test-session"
    server._sessions[sid] = {"session_key": sid, "agent": None}

    # Mock scan_skill_commands to return a known skill
    fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}}

    with patch("agent.skill_commands.get_skill_commands", return_value=fake_skills):
        resp = server.handle_request({
            "id": "r1",
            "method": "slash.exec",
            "params": {"command": "hermes-agent-dev", "session_id": sid},
        })

    # Should return an error so the TUI's .catch() fires command.dispatch
    assert "error" in resp
    assert resp["error"]["code"] == 4018
    assert "skill command" in resp["error"]["message"]


def test_slash_exec_scopes_skill_lookup_to_session_profile(server, tmp_path):
    """slash.exec must resolve get_skill_commands() against the session's own
    profile_home rather than the gateway process's ambient HERMES_HOME
    (#88023). A Desktop session that switches profiles mid-session shares
    the same gateway process, so a skill declared only under the new
    profile's skills.external_dirs must still be recognized here — else the
    command falls through to the slash-worker dead path instead of routing
    to command.dispatch.
    """
    import agent.skill_commands as sc_mod

    empty_local_dir = tmp_path / "no-local-skills"
    empty_local_dir.mkdir()

    profile_b = tmp_path / "profile_b"
    external_b = tmp_path / "external_b"
    profile_b.mkdir()
    skill_dir = external_b / "b-only"
    skill_dir.mkdir(parents=True)
    (skill_dir / "SKILL.md").write_text(
        "---\nname: b-only\ndescription: Only in profile b.\n---\n\n# b-only\n\nDo the thing.\n"
    )
    (profile_b / "config.yaml").write_text(
        f"skills:\n  external_dirs:\n    - {external_b}\n"
    )

    sid = "test-session-profile-b"
    server._sessions[sid] = {
        "session_key": sid,
        "agent": None,
        "profile_home": str(profile_b),
    }

    with (
        patch("tools.skills_tool.SKILLS_DIR", empty_local_dir),
        patch.object(sc_mod, "_skill_commands", {}),
        patch.object(sc_mod, "_skill_commands_platform", None),
        patch.object(sc_mod, "_skill_commands_home", None),
    ):
        resp = server.handle_request({
            "id": "r1",
            "method": "slash.exec",
            "params": {"command": "b-only", "session_id": sid},
        })

    # The gateway's own HERMES_HOME (the test-isolation tempdir, no
    # skills.external_dirs) has no "b-only" skill — the only way this
    # resolves is by scoping the lookup to the session's profile_home.
    assert "error" in resp
    assert resp["error"]["code"] == 4018
    assert "skill command" in resp["error"]["message"]


def test_command_dispatch_queue_sends_message(server):
    """command.dispatch /queue returns {type: 'send', message: ...} for the TUI."""
    sid = "test-session"
    server._sessions[sid] = {"session_key": sid}

    resp = server.handle_request({
        "id": "r1",
        "method": "command.dispatch",
        "params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid},
    })

    assert "error" not in resp
    result = resp["result"]
    assert result["type"] == "send"
    assert result["message"] == "tell me about quantum computing"


def test_skills_manage_search_uses_tools_hub_sources(server):
    result = type("Result", (), {
        "description": "Build better terminal demos",
        "name": "showroom",
    })()
    auth = MagicMock(return_value="auth")
    router = MagicMock(return_value=["source"])
    search = MagicMock(return_value=[result])
    fake_search = types.SimpleNamespace(create_source_router=router, unified_search=search)
    fake_github = types.SimpleNamespace(GitHubAuth=auth)

    with patch.dict(sys.modules, {"tools.skills_hub_search": fake_search, "tools.skills_hub_github": fake_github}):
        resp = server.handle_request({
            "id": "skills-search",
            "method": "skills.manage",
            "params": {"action": "search", "query": "showroom"},
        })

    assert "error" not in resp
    assert resp["result"] == {
        "results": [{"description": "Build better terminal demos", "name": "showroom"}]
    }
    auth.assert_called_once_with()
    router.assert_called_once_with("auth")
    search.assert_called_once_with("showroom", ["source"], source_filter="all", limit=20)


# ── dispatch(): pool routing for long handlers (#12546) ──────────────


def test_dispatch_runs_short_handlers_inline(server):
    """Non-long handlers return their response synchronously from dispatch()."""
    server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})

    resp = server.dispatch({"id": "r1", "method": "fast.ping", "params": {}})

    assert resp == {"jsonrpc": "2.0", "id": "r1", "result": {"pong": True}}


@pytest.mark.parametrize("completion_method", ["complete.path", "complete.slash"])
def test_completion_handlers_are_pool_routed(completion_method, server):
    """complete.path/complete.slash must run on the pool, never the reader thread.

    Regression for #21123: completion ran inline, so a slow git ls-files /
    skill-scan blocked prompt.submit and froze the TUI for the 120s RPC timeout.
    """
    assert completion_method in server._LONG_HANDLERS


@pytest.mark.parametrize(
    "voice_or_wake_method",
    ["voice.toggle", "voice.record", "voice.tts", "wake.start", "wake.status"],
)
def test_voice_and_wake_handlers_are_pool_routed(voice_or_wake_method, server):
    """Voice and wake RPCs must run on the pool, never the WS reader thread.

    Regression: voice.toggle (status) triggers check_voice_requirements() →
    STT provider auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip
    subprocess, up to a 300s timeout). Inline on the WS reader loop it blocked
    prompt.submit / session.list frames queued behind it — the desktop showed
    sent messages that never reached the agent. Same bug class as #21123 /
    #50005: anything that can stall for seconds must stay off the reader thread.

    wake.start and wake.status share the same STT lazy-install path via
    check_wake_word_requirements() → _stt_ready() → _get_provider(), and
    wake.start additionally calls lazy_deps.ensure() for wake-word engine deps.
    The desktop polls wake.status on every gateway-ready.
    """
    assert voice_or_wake_method in server._LONG_HANDLERS


def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch):
    """Real config + skin files: activating a skin (as `hermes config set` does)
    makes the per-tool reconcile broadcast skin.changed with the resolved palette.
    Exercises _load_cfg → _skin_sig → resolve_skin → _emit with no mocks in between."""
    import hermes_cli.skin_engine as skin_engine

    (tmp_path / "skins").mkdir()
    (tmp_path / "skins" / "midnight.yaml").write_text(
        "name: midnight\ndescription: t\ncolors:\n  banner_title: '#00ffcc'\n  background: '#001010'\n"
    )
    monkeypatch.setattr(skin_engine, "get_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(server, "_hermes_home", tmp_path)
    monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
    server._cfg_cache = server._cfg_mtime = server._cfg_path = None

    emitted = []
    monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))

    # Baseline (default) — seeds the signature.
    (tmp_path / "config.yaml").write_text("display:\n  skin: default\n", encoding="utf-8")
    server._broadcast_skin_if_changed()
    emitted.clear()

    # Activate midnight, as `hermes config set display.skin midnight` would.
    time.sleep(0.01)  # ensure the config mtime moves
    (tmp_path / "config.yaml").write_text("display:\n  skin: midnight\n", encoding="utf-8")
    server._broadcast_skin_if_changed()

    assert [ev for ev, _ in emitted] == ["skin.changed"]
    assert emitted[0][1]["name"] == "midnight"
    assert emitted[0][1]["colors"]["banner_title"] == "#00ffcc"


def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch):
    """A skin the agent changes mid-turn goes live once per real move: a name
    switch (incl. switch-then-revert) OR an in-place color edit to the active skin
    (same name, new file mtime). An unchanged signature never re-broadcasts."""
    emitted = []
    # switch, no-op, switch, then a color edit (same name, bumped mtime).
    sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)])
    monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
    monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
    monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs))
    monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}})

    for _ in range(4):
        server._broadcast_skin_if_changed()

    assert [ev for ev, _ in emitted] == ["skin.changed"] * 3


# ── global-event broadcast (session-less events reach every WS client) ──


class _RecordingTransport:
    """Minimal Transport stand-in that records the frames written to it."""

    def __init__(self) -> None:
        self.frames: list[dict] = []

    def write(self, obj: dict) -> bool:
        self.frames.append(obj)
        return True

    def close(self) -> None:
        pass


def test_unregister_live_transport_stops_delivery(capture):
    """A disconnected peer (unregistered in the ws finally block) receives nothing
    — and a stale write is never attempted against its closed socket."""
    server, buf = capture
    a = _RecordingTransport()
    server.register_live_transport(a)
    server.unregister_live_transport(a)

    server._broadcast_global_event("skin.changed", {"name": "x"})

    assert a.frames == []
    # No live transports left → fell back to stdio.
    assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed"
