"""Step definitions for LSP server stub BDD tests.

Covers the JSON-RPC transport, protocol handshake, error handling,
A2A facade wiring, and CLI serve command for the LSP server stub.
"""

from __future__ import annotations

import contextlib
import io
import json
from collections.abc import Generator
from typing import Any

import structlog
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner

from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.cli.commands.lsp import app as lsp_app
from cleveragents.lsp import server as _server_module
from cleveragents.lsp.server import (
    MAX_CONTENT_LENGTH,
    MAX_HEADER_LINE_LENGTH,
    MAX_HEADER_LINES,
    LspServer,
)
from features.mocks.lsp_transport_mock import MockLspTransport


@contextlib.contextmanager
def _capture_structlogs() -> Generator[list[dict[str, Any]]]:
    """Capture structlog entries emitted by the LSP server module.

    ``structlog.testing.capture_logs()`` does not work when the global
    configuration has ``cache_logger_on_first_use=True`` because the
    module-level ``logger`` in ``cleveragents.lsp.server`` caches its
    processor chain on first use and never picks up the replacement
    processors installed by ``capture_logs()``.

    This helper works around the problem by:
    1. Saving and temporarily replacing the module-level ``logger``
       with a fresh proxy obtained *after* reconfiguring structlog
       with ``cache_logger_on_first_use=False``.
    2. Restoring both the structlog configuration and the original
       module ``logger`` when the context manager exits.
    """
    cap = structlog.testing.LogCapture()
    old_config = structlog.get_config()
    old_logger = _server_module.logger

    structlog.configure(
        processors=[cap],
        wrapper_class=structlog.stdlib.BoundLogger,
        logger_factory=structlog.PrintLoggerFactory(),
        cache_logger_on_first_use=False,
    )
    # Replace the module-level logger so calls inside ``server.py``
    # go through the new (uncached) configuration.
    _server_module.logger = structlog.get_logger(_server_module.__name__)  # type: ignore[assignment]
    try:
        yield cap.entries
    finally:
        _server_module.logger = old_logger
        structlog.configure(**old_config)


_cli_runner = CliRunner()

# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a mock LSP transport")
def step_mock_transport(context: Context) -> None:
    context.lsp_transport = MockLspTransport()
    context.lsp_server: LspServer | None = None
    context.lsp_responses: list[dict[str, Any]] = []
    context.lsp_exit_code: int | None = None
    context.lsp_facade: A2aLocalFacade | None = None
    context.lsp_error: Exception | None = None


# ---------------------------------------------------------------------------
# Given — messages
# ---------------------------------------------------------------------------


@given("an initialize request with id {req_id:d}")
def step_initialize_request(context: Context, req_id: int) -> None:
    context.lsp_transport.send_request(
        "initialize",
        {"capabilities": {}},
        request_id=req_id,
    )


@given('an initialize request with id {req_id:d} and clientInfo name "{name}"')
def step_initialize_with_client_info(context: Context, req_id: int, name: str) -> None:
    context.lsp_transport.send_request(
        "initialize",
        {"capabilities": {}, "clientInfo": {"name": name}},
        request_id=req_id,
    )


@given("a shutdown request with id {req_id:d}")
def step_shutdown_request(context: Context, req_id: int) -> None:
    context.lsp_transport.send_request("shutdown", request_id=req_id)


@given("an exit notification")
def step_exit_notification(context: Context) -> None:
    context.lsp_transport.send_notification("exit")


@given('a request for method "{method}" with id {req_id:d}')
def step_request_method(context: Context, method: str, req_id: int) -> None:
    context.lsp_transport.send_request(method, {}, request_id=req_id)


@given('a notification for method "{method}"')
def step_notification_method(context: Context, method: str) -> None:
    context.lsp_transport.send_notification(method)


@given("raw bytes that are not valid JSON-RPC")
def step_raw_invalid_bytes(context: Context) -> None:
    bad_payload = b"this is not json at all"
    framed = f"Content-Length: {len(bad_payload)}\r\n\r\n".encode() + bad_payload
    context.lsp_transport.send_raw(framed)


@given("a message with missing jsonrpc version and id {req_id:d}")
def step_missing_jsonrpc_version(context: Context, req_id: int) -> None:
    msg = {"id": req_id, "method": "test"}
    payload = json.dumps(msg).encode("utf-8")
    framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
    context.lsp_transport.send_raw(framed)


@given("a message with missing method and id {req_id:d}")
def step_missing_method(context: Context, req_id: int) -> None:
    msg = {"jsonrpc": "2.0", "id": req_id}
    payload = json.dumps(msg).encode("utf-8")
    framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
    context.lsp_transport.send_raw(framed)


@given("an LSP server with A2A facade")
def step_server_with_facade(context: Context) -> None:
    context.lsp_facade = A2aLocalFacade()


# ---------------------------------------------------------------------------
# When — server execution
# ---------------------------------------------------------------------------


@when("the LSP server processes all messages")
def step_process_messages(context: Context) -> None:
    facade = getattr(context, "lsp_facade", None)
    server = LspServer(
        input_stream=context.lsp_transport.input_stream,
        output_stream=context.lsp_transport.output_stream,
        facade=facade,
    )
    context.lsp_server = server
    with _capture_structlogs() as captured:
        context.lsp_exit_code = server.run()
    context.lsp_captured_logs = captured
    context.lsp_responses = context.lsp_transport.read_responses()


@when("the LSP server processes all messages with empty input")
def step_process_empty(context: Context) -> None:
    empty_input = io.BytesIO(b"")
    output_buf = io.BytesIO()
    server = LspServer(input_stream=empty_input, output_stream=output_buf)
    context.lsp_server = server
    context.lsp_exit_code = server.run()
    context.lsp_responses = []


@when("the LSP server processes all messages with a broken output stream")
def step_process_broken_output(context: Context) -> None:
    """Run the server with an output stream that raises OSError on write."""

    class _BrokenOutput:
        """Mock output stream that raises on any write."""

        def write(self, _data: bytes) -> int:
            raise OSError("broken pipe")

        def flush(self) -> None:
            raise OSError("broken pipe")

    server = LspServer(
        input_stream=context.lsp_transport.input_stream,
        output_stream=_BrokenOutput(),  # type: ignore[arg-type]
    )
    context.lsp_server = server
    with _capture_structlogs() as captured:
        context.lsp_exit_code = server.run()
    context.lsp_captured_logs = captured
    context.lsp_responses = []


@when("I try to create an LspServer with invalid input stream")
def step_invalid_input_stream(context: Context) -> None:
    try:
        LspServer(input_stream="not a stream")  # type: ignore[arg-type]
        context.lsp_error = None
    except TypeError as exc:
        context.lsp_error = exc


@when("I try to create an LspServer with invalid output stream")
def step_invalid_output_stream(context: Context) -> None:
    try:
        LspServer(output_stream=42)  # type: ignore[arg-type]
        context.lsp_error = None
    except TypeError as exc:
        context.lsp_error = exc


# ---------------------------------------------------------------------------
# Then — response assertions
# ---------------------------------------------------------------------------


def _find_response(context: Context, req_id: int) -> dict[str, Any]:
    for resp in context.lsp_responses:
        if resp.get("id") == req_id:
            return resp
    raise AssertionError(
        f"No response with id={req_id} found in {context.lsp_responses}"
    )


def _find_response_any(
    context: Context,
    req_id: int | str,
) -> dict[str, Any]:
    """Find a response by id, supporting both int and str ids."""
    for resp in context.lsp_responses:
        if resp.get("id") == req_id:
            return resp
    raise AssertionError(
        f"No response with id={req_id!r} found in {context.lsp_responses}"
    )


@then("the response for id {req_id:d} should have result")
def step_response_has_result(context: Context, req_id: int) -> None:
    resp = _find_response(context, req_id)
    assert "result" in resp, f"Response {resp} missing 'result'"
    assert resp["result"] is not None, f"Response result is None: {resp}"


@then("the response for id {req_id:d} should have null result")
def step_response_null_result(context: Context, req_id: int) -> None:
    resp = _find_response(context, req_id)
    assert "result" in resp, f"Response {resp} missing 'result'"
    assert resp["result"] is None, f"Expected null result, got {resp['result']}"


@then('the result for id {req_id:d} should contain serverInfo with name "{name}"')
def step_result_server_name(context: Context, req_id: int, name: str) -> None:
    resp = _find_response(context, req_id)
    result = resp.get("result", {})
    server_info = result.get("serverInfo", {})
    assert server_info.get("name") == name, (
        f"Expected serverInfo.name={name}, got {server_info}"
    )


@then('the result for id {req_id:d} should contain serverInfo with version "{version}"')
def step_result_server_version(context: Context, req_id: int, version: str) -> None:
    resp = _find_response(context, req_id)
    result = resp.get("result", {})
    server_info = result.get("serverInfo", {})
    assert server_info.get("version") == version, (
        f"Expected version={version}, got {server_info}"
    )


@then('the result for id {req_id:d} should contain capabilities key "{key}"')
def step_result_capabilities_key(context: Context, req_id: int, key: str) -> None:
    resp = _find_response(context, req_id)
    result = resp.get("result", {})
    caps = result.get("capabilities", {})
    assert key in caps, f"Capabilities missing key '{key}': {caps}"


@then("the response for id {req_id:d} should have error code {code:d}")
def step_response_error_code(context: Context, req_id: int, code: int) -> None:
    resp = _find_response(context, req_id)
    assert "error" in resp, f"Response {resp} missing 'error'"
    assert resp["error"]["code"] == code, (
        f"Expected error code {code}, got {resp['error']['code']}"
    )


@then('the error message for id {req_id:d} should contain "{text}"')
def step_error_message_for_id(context: Context, req_id: int, text: str) -> None:
    resp = _find_response(context, req_id)
    error = resp.get("error")
    assert error is not None, f"Response for id={req_id} has no error: {resp}"
    msg = error.get("message", "")
    assert text in msg, (
        f"Error message for id={req_id} does not contain '{text}': {msg}"
    )


@then("a parse error response should have been sent")
def step_parse_error_sent(context: Context) -> None:
    errors = [
        r
        for r in context.lsp_responses
        if "error" in r and r["error"].get("code") == -32700
    ]
    assert errors, f"No parse error (-32700) response found in {context.lsp_responses}"


@then("there should be no error responses")
def step_no_error_responses(context: Context) -> None:
    errors = [r for r in context.lsp_responses if "error" in r]
    assert not errors, f"Unexpected error responses: {errors}"


@then("the server exit code should be {code:d}")
def step_exit_code(context: Context, code: int) -> None:
    assert context.lsp_exit_code == code, (
        f"Expected exit code {code}, got {context.lsp_exit_code}"
    )


@then("the server should have stopped")
def step_server_stopped(context: Context) -> None:
    assert context.lsp_server is not None, "No server was created"
    assert context.lsp_server.is_running is False, "Server is still running"


@then('a transport warning should have been logged for "{event}"')
def step_transport_warning_logged(context: Context, event: str) -> None:
    captured = getattr(context, "lsp_captured_logs", [])
    matches = [
        e
        for e in captured
        if e.get("event") == event and e.get("log_level", "") == "warning"
    ]
    assert matches, f"No warning log with event={event!r} found in {captured}"


@then("the server should have logged its PID")
def step_server_logged_pid(context: Context) -> None:
    assert context.lsp_server is not None, "No server was created"
    assert context.lsp_exit_code is not None, "Server did not complete"
    captured = getattr(context, "lsp_captured_logs", [])
    pid_entries = [
        e for e in captured if "pid" in e and e.get("event") == "lsp.server.starting"
    ]
    assert pid_entries, (
        f"No log entry with PID found for 'lsp.server.starting' in {captured}"
    )
    assert isinstance(pid_entries[0]["pid"], int), "PID should be an integer"


@then("an InvalidRequest error response should have been sent")
def step_invalid_request_sent(context: Context) -> None:
    errors = [
        r
        for r in context.lsp_responses
        if "error" in r and r["error"].get("code") == -32600
    ]
    assert errors, (
        f"No InvalidRequest (-32600) response found in {context.lsp_responses}"
    )


@then("the error response for id {req_id:d} should not contain a data field")
def step_error_no_data(context: Context, req_id: int) -> None:
    resp = _find_response(context, req_id)
    assert "error" in resp, f"Response {resp} missing 'error'"
    assert "data" not in resp["error"], (
        f"Error response unexpectedly contains 'data': {resp['error']}"
    )


@then("the server should hold the provided A2A facade")
def step_server_holds_facade(context: Context) -> None:
    assert context.lsp_server is not None, "No server was created"
    assert context.lsp_facade is not None, "No facade was provided"
    assert context.lsp_server.facade is context.lsp_facade, (
        "Server does not hold the provided A2A facade instance"
    )


@then("the server facade should be lazily created")
def step_server_facade_lazy(context: Context) -> None:
    assert context.lsp_server is not None, "No server was created"
    # Access the public property — this triggers lazy creation
    facade = context.lsp_server.facade
    assert facade is not None, "Facade was not lazily created"
    from cleveragents.a2a.facade import A2aLocalFacade

    assert isinstance(facade, A2aLocalFacade), (
        f"Expected A2aLocalFacade, got {type(facade).__name__}"
    )


@then("a TypeError should be raised for input stream")
def step_type_error_input(context: Context) -> None:
    assert context.lsp_error is not None, "Expected TypeError"
    assert isinstance(context.lsp_error, TypeError), (
        f"Expected TypeError, got {type(context.lsp_error).__name__}"
    )
    assert "input_stream" in str(context.lsp_error)


@then("a TypeError should be raised for output stream")
def step_type_error_output(context: Context) -> None:
    assert context.lsp_error is not None, "Expected TypeError"
    assert isinstance(context.lsp_error, TypeError), (
        f"Expected TypeError, got {type(context.lsp_error).__name__}"
    )
    assert "output_stream" in str(context.lsp_error)


# ---------------------------------------------------------------------------
# Transport edge case steps
# ---------------------------------------------------------------------------


@given("a request with non-dict params and id {req_id:d}")
def step_non_dict_params(context: Context, req_id: int) -> None:
    msg: dict[str, Any] = {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": "textDocument/completion",
        "params": "not-a-dict",
    }
    payload = json.dumps(msg).encode("utf-8")
    framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
    context.lsp_transport.send_raw(framed)


@given("a message with a JSON array body")
def step_json_array_body(context: Context) -> None:
    # Send a valid JSON array instead of a JSON object so that
    # _handle_message's isinstance(msg, dict) guard is exercised.
    payload = json.dumps([1, "not", "a", "dict"]).encode("utf-8")
    framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
    context.lsp_transport.send_raw(framed)


@given("a message with non-integer content length")
def step_non_integer_content_length(context: Context) -> None:
    raw = b"Content-Length: abc\r\n\r\n"
    context.lsp_transport.send_raw(raw)


@given("a message with initialize request without params key and id {req_id:d}")
def step_initialize_no_params(context: Context, req_id: int) -> None:
    msg: dict[str, Any] = {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": "initialize",
    }
    payload = json.dumps(msg).encode("utf-8")
    framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
    context.lsp_transport.send_raw(framed)


@given("a message with negative content length")
def step_negative_content_length(context: Context) -> None:
    raw = b"Content-Length: -5\r\n\r\n"
    context.lsp_transport.send_raw(raw)


@given("a message with incomplete body")
def step_incomplete_body(context: Context) -> None:
    # Declare 100 bytes but only provide 5
    raw = b"Content-Length: 100\r\n\r\nhello"
    context.lsp_transport.send_raw(raw)


@given("a message with missing content length header")
def step_missing_content_length(context: Context) -> None:
    raw = b"Content-Type: application/json\r\n\r\n{}"
    context.lsp_transport.send_raw(raw)


@given("a message with content length exceeding the maximum")
def step_oversized_content_length(context: Context) -> None:
    # Declare a Content-Length above the limit.  The *actual* body is
    # ``MAX_CONTENT_LENGTH`` bytes — matching the server's capped
    # discard — so ``_discard_body`` consumes the entire body and
    # subsequent messages (the exit notification) are not corrupted.
    declared_length = MAX_CONTENT_LENGTH + 64
    body = b"X" * MAX_CONTENT_LENGTH
    raw = f"Content-Length: {declared_length}\r\n\r\n".encode() + body
    context.lsp_transport.send_raw(raw)


@given("a message with content length zero")
def step_zero_content_length(context: Context) -> None:
    raw = b"Content-Length: 0\r\n\r\n"
    context.lsp_transport.send_raw(raw)


@given("a message with duplicate content-length headers and id {req_id:d}")
def step_duplicate_content_length(context: Context, req_id: int) -> None:
    # Build an initialize request.  The first Content-Length is wrong
    # (too small), but the server takes the *last* value so the
    # message is parsed correctly.
    msg: dict[str, Any] = {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": "initialize",
        "params": {"capabilities": {}},
    }
    payload = json.dumps(msg).encode("utf-8")
    header = (f"Content-Length: 1\r\nContent-Length: {len(payload)}\r\n\r\n").encode()
    context.lsp_transport.send_raw(header + payload)


@given("a message with deeply nested JSON")
def step_deeply_nested_json(context: Context) -> None:
    """Build a Content-Length framed message whose body is a deeply nested
    JSON array (10 000 levels).  When ``json.loads`` attempts to parse
    this payload it triggers a ``RecursionError``, exercising the guard
    in ``_read_message``.
    """
    depth = 10000
    payload = ("[" * depth + "]" * depth).encode("utf-8")
    framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
    context.lsp_transport.send_raw(framed)


@given("an oversized message with a short body that triggers EOF during discard")
def step_oversized_short_body_eof(context: Context) -> None:
    """Build a message whose declared CL exceeds the limit but whose actual
    body is much shorter than the discard cap.  This forces
    ``_discard_body`` to hit EOF mid-read (the early-exit ``break``
    inside ``LspServer._discard_body``'s read loop —
    ``cleveragents.lsp.server.LspServer._discard_body``), covering
    the early-exit path.
    """
    declared_length = MAX_CONTENT_LENGTH + 64
    # Only provide a tiny body — far less than the capped discard amount
    body = b"X" * 128
    raw = f"Content-Length: {declared_length}\r\n\r\n".encode() + body
    context.lsp_transport.send_raw(raw)


@given("a message with too many header lines")
def step_too_many_header_lines(context: Context) -> None:
    lines = [f"X-Spam-{i}: value\r\n" for i in range(MAX_HEADER_LINES + 1)]
    raw = "".join(lines).encode()
    context.lsp_transport.send_raw(raw)


@given("a message with a header line exceeding the maximum line length")
def step_oversized_header_line(context: Context) -> None:
    """Build a single header line that exceeds ``MAX_HEADER_LINE_LENGTH``.

    When ``readline(MAX_HEADER_LINE_LENGTH)`` is used the oversized
    line is returned in multiple chunks, each consuming one slot in
    the ``MAX_HEADER_LINES`` budget.  Eventually the header-flood
    guard fires, exercising the ``readline()`` per-line size cap
    path described in ``cleveragents.lsp.server.LspServer._read_message``.
    """
    # A single line without a newline that far exceeds the per-line
    # limit.  ``readline(MAX_HEADER_LINE_LENGTH)`` will return it in
    # chunks of MAX_HEADER_LINE_LENGTH bytes until MAX_HEADER_LINES
    # is exceeded.
    oversized_line = b"X-Huge: " + b"A" * (
        MAX_HEADER_LINE_LENGTH * (MAX_HEADER_LINES + 2)
    )
    context.lsp_transport.send_raw(oversized_line)


# ---------------------------------------------------------------------------
# Stream desync recovery steps
# ---------------------------------------------------------------------------


@given(
    "an oversized message with real body followed by"
    " an initialize request with id {req_id:d}"
)
def step_oversized_with_body_then_init(context: Context, req_id: int) -> None:
    """Build an oversized message with actual body bytes and append a valid init.

    The declared Content-Length exceeds the limit so the server rejects the
    message.  The *actual* body is ``MAX_CONTENT_LENGTH`` bytes — matching
    the server's capped discard — so that ``_discard_body`` consumes
    exactly the available body and the stream stays aligned for the next
    message.
    """
    declared_length = MAX_CONTENT_LENGTH + 64
    # Actual body matches the discard cap so recovery succeeds.
    body = b"X" * MAX_CONTENT_LENGTH
    header = f"Content-Length: {declared_length}\r\n\r\n".encode()
    raw = header + body
    # Append the valid initialize request after the oversized body.
    init_msg: dict[str, Any] = {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": "initialize",
        "params": {"capabilities": {}},
    }
    init_payload = json.dumps(init_msg).encode("utf-8")
    init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload
    context.lsp_transport.send_raw(raw + init_framed)


@given(
    "a message with non-integer content length followed by"
    " an initialize request with id {req_id:d}"
)
def step_invalid_cl_then_init(context: Context, req_id: int) -> None:
    """Build a message with invalid CL header followed by valid init."""
    # Invalid CL header + additional header + blank line (body-less)
    bad_raw = b"Content-Length: abc\r\nX-Extra: foo\r\n\r\n"
    init_msg: dict[str, Any] = {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": "initialize",
        "params": {"capabilities": {}},
    }
    init_payload = json.dumps(init_msg).encode("utf-8")
    init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload
    context.lsp_transport.send_raw(bad_raw + init_framed)


@given(
    "a message with too many header lines followed by"
    " an initialize request with id {req_id:d}"
)
def step_too_many_headers_then_init(context: Context, req_id: int) -> None:
    """Build a message with excess headers + blank terminator, then valid init."""
    lines = [f"X-Spam-{i}: value\r\n" for i in range(MAX_HEADER_LINES + 1)]
    # Append the blank line terminator so _drain_headers can reach it.
    lines.append("\r\n")
    bad_raw = "".join(lines).encode()
    init_msg: dict[str, Any] = {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": "initialize",
        "params": {"capabilities": {}},
    }
    init_payload = json.dumps(init_msg).encode("utf-8")
    init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload
    context.lsp_transport.send_raw(bad_raw + init_framed)


# ---------------------------------------------------------------------------
# CLI serve command steps
# ---------------------------------------------------------------------------


@when('I run lsp CLI serve with log level "{level}"')
def step_run_lsp_serve_bad_level(context: Context, level: str) -> None:
    result = _cli_runner.invoke(lsp_app, ["serve", "--log-level", level])
    context.lsp_cli_result = result


@when("I run lsp CLI serve with piped empty stdin")
def step_run_lsp_serve_empty_stdin(context: Context) -> None:
    result = _cli_runner.invoke(lsp_app, ["serve"], input="")
    context.lsp_cli_result = result


@when('I run lsp CLI serve with log level "{level}" and piped empty stdin')
def step_run_lsp_serve_level_empty_stdin(context: Context, level: str) -> None:
    result = _cli_runner.invoke(lsp_app, ["serve", "--log-level", level], input="")
    context.lsp_cli_result = result


@then("the lsp serve CLI should have failed")
def step_lsp_serve_failed(context: Context) -> None:
    result = context.lsp_cli_result
    assert result is not None, "No CLI result captured"
    assert result.exit_code != 0, (
        f"Expected non-zero exit code, got {result.exit_code}. Output:\n{result.output}"
    )


@then("the lsp serve CLI should have exited")
def step_lsp_serve_exited(context: Context) -> None:
    result = context.lsp_cli_result
    assert result is not None, "No CLI result captured"
    # The server exits with code 0 (clean shutdown) or 1 (no shutdown sent).
    assert result.exit_code in (0, 1), (
        f"Unexpected exit code {result.exit_code}. Output:\n{result.output}"
    )


@then("the lsp serve CLI should have exited with code {code:d}")
def step_lsp_serve_exited_with_code(context: Context, code: int) -> None:
    result = context.lsp_cli_result
    assert result is not None, "No CLI result captured"
    assert result.exit_code == code, (
        f"Expected exit code {code}, got {result.exit_code}. Output:\n{result.output}"
    )


@then('the lsp serve CLI output should contain "{text}"')
def step_lsp_serve_output_contains(context: Context, text: str) -> None:
    # NOTE: The serve command writes its startup banner to stderr via
    # ``_serve_console = Console(stderr=True)``.  This assertion checks
    # ``result.output`` (stdout) which works because ``CliRunner()``
    # defaults to ``mix_stderr=True``, merging stderr into stdout.
    result = context.lsp_cli_result
    assert result is not None, "No CLI result captured"
    assert text in result.output, f"Expected '{text}' in output, got:\n{result.output}"


# ---------------------------------------------------------------------------
# String request id steps (JSON-RPC 2.0 allows string ids)
# ---------------------------------------------------------------------------


@given('an initialize request with string id "{req_id}"')
def step_initialize_string_id(context: Context, req_id: str) -> None:
    context.lsp_transport.send_request(
        "initialize",
        {"capabilities": {}},
        request_id=req_id,
    )


@then('the response for string id "{req_id}" should have result')
def step_response_string_id_has_result(context: Context, req_id: str) -> None:
    resp = _find_response_any(context, req_id)
    assert "result" in resp, f"Response {resp} missing 'result'"
    assert resp["result"] is not None, f"Response result is None: {resp}"


@then(
    'the result for string id "{req_id}" should contain serverInfo with name "{name}"'
)
def step_result_string_id_server_name(
    context: Context,
    req_id: str,
    name: str,
) -> None:
    resp = _find_response_any(context, req_id)
    result = resp.get("result", {})
    server_info = result.get("serverInfo", {})
    assert server_info.get("name") == name, (
        f"Expected serverInfo.name={name}, got {server_info}"
    )
