"""Step definitions for TDD issue #10438 — McpClient.start() race condition.

This module provides the Behave step implementations for the TDD bug-capture
test that proves and verifies the fix for bug #10438.

The bug was a race condition in ``McpClient.start()``: the method released the
``threading.RLock`` after checking ``_started`` but before calling
``connect()`` and ``discover_tools()``.  Concurrent callers could both pass
the idempotency check and initialise the MCP server connection multiple times,
causing resource leaks and state corruption.

The fix adds ``_state == McpClientState.STARTING`` to the guard inside the
lock so that a second concurrent caller returns immediately without calling
``connect()`` or ``discover_tools()`` again.

Race detection uses a counting transport that records how many times
``connect()`` and ``discover_tools()`` are called.  A ``threading.Barrier``
synchronises the threads so they all enter ``start()`` at approximately the
same instant, maximising the chance of the race manifesting.

See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/10438
"""

from __future__ import annotations

import threading

from behave import given, then, when
from behave.runner import Context

from cleveragents.mcp.adapter import MCPServerConfig
from cleveragents.mcp.client import McpClient, McpClientConfig, McpClientState
from features.mocks.counting_mcp_transport import CountingMCPTransport


# ── Given ─────────────────────────────────────────────────────────


@given("an MCP client configured for concurrent start testing")
def step_mcp_client_for_concurrent_start(context: Context) -> None:
    """Create an MCP client with a counting transport for race testing.

    The counting transport records how many times ``connect()`` and
    ``discover_tools()`` are called so that the test can assert exactly
    one invocation of each after concurrent ``start()`` calls.
    """
    tools = [{"name": "test_tool", "description": "Test tool", "inputSchema": {}}]
    transport = CountingMCPTransport(tools=tools)

    config = McpClientConfig(
        server=MCPServerConfig(name="test-server", transport="stdio", command="echo"),
        lazy_start=False,
        idle_timeout_seconds=0,
        health_check_interval_seconds=0,
    )
    client = McpClient(config=config, transport=transport)

    context.mcp_race_client = client
    context.mcp_race_transport = transport
    context.mcp_race_thread_errors: list[Exception] = []
    context.mcp_race_thread_count = 0


# ── When ──────────────────────────────────────────────────────────


@when("{n:d} threads call start() simultaneously through a barrier")
def step_concurrent_start(context: Context, n: int) -> None:
    """Launch *n* threads that all call ``start()`` simultaneously.

    A ``threading.Barrier`` synchronises the threads so they all enter
    ``start()`` at (approximately) the same instant, maximising the chance
    of the race manifesting.

    The counting transport records how many times ``connect()`` and
    ``discover_tools()`` are called.
    """
    client: McpClient = context.mcp_race_client
    barrier = threading.Barrier(n)
    errors: list[Exception] = []
    collect_lock = threading.Lock()

    def worker() -> None:
        """Thread worker: wait on barrier, then call start()."""
        try:
            barrier.wait(timeout=10)
            client.start()
        except Exception as exc:
            with collect_lock:
                errors.append(exc)

    threads = [threading.Thread(target=worker, daemon=True) for _ in range(n)]
    for t in threads:
        t.start()
    for t in threads:
        t.join(timeout=30)

    # Verify no threads are still alive after join timeout
    for t in threads:
        assert not t.is_alive(), (
            f"Thread {t.name} is still alive after join timeout — "
            f"possible deadlock in start()"
        )

    barrier_errors = [
        err for err in errors if isinstance(err, threading.BrokenBarrierError)
    ]
    assert not barrier_errors, (
        "Barrier synchronization failed while coordinating concurrent "
        f"start() calls. Barrier errors: {barrier_errors}"
    )

    context.mcp_race_thread_errors = errors
    context.mcp_race_thread_count = n


@when("I call start() sequentially {n:d} times")
def step_sequential_start(context: Context, n: int) -> None:
    """Call ``start()`` sequentially *n* times on the same client."""
    client: McpClient = context.mcp_race_client
    for _ in range(n):
        client.start()

    context.mcp_race_thread_errors = []
    context.mcp_race_thread_count = n


# ── Then ──────────────────────────────────────────────────────────


@then("connect should have been called exactly once")
def step_connect_called_once(context: Context) -> None:
    """Assert that ``connect()`` was called exactly once.

    With the bug present (no STARTING state guard), multiple threads would
    pass the ``_started`` check and each call ``connect()``, so the count
    would be > 1.

    With the fix applied, only the first thread calls ``connect()`` and
    subsequent concurrent callers see ``_state == STARTING`` and return.
    """
    transport: CountingMCPTransport = context.mcp_race_transport
    count = transport.connect_count
    assert count == 1, (
        f"connect() was called {count} time(s), expected exactly 1. "
        f"This indicates the race condition in McpClient.start() is present — "
        f"multiple threads bypassed the idempotency guard. "
        f"Thread errors (if any): {context.mcp_race_thread_errors}"
    )


@then("discover_tools should have been called exactly once")
def step_discover_tools_called_once(context: Context) -> None:
    """Assert that ``discover_tools()`` was called exactly once.

    With the bug present, multiple threads would call ``discover_tools()``
    concurrently, so the count would be > 1.

    With the fix applied, only the first thread calls ``discover_tools()``
    and subsequent concurrent callers return early.
    """
    transport: CountingMCPTransport = context.mcp_race_transport
    count = transport.discover_count
    assert count == 1, (
        f"discover_tools() was called {count} time(s), expected exactly 1. "
        f"This indicates the race condition in McpClient.start() is present — "
        f"multiple threads bypassed the idempotency guard. "
        f"Thread errors (if any): {context.mcp_race_thread_errors}"
    )


@then("the MCP client state should be running after concurrent starts")
def step_client_state_running_after_concurrent(context: Context) -> None:
    """Assert the client is in RUNNING state after concurrent start() calls."""
    client: McpClient = context.mcp_race_client
    actual = client.state
    assert actual == McpClientState.RUNNING, (
        f"Expected client state to be '{McpClientState.RUNNING}', got '{actual}'. "
        f"Thread errors (if any): {context.mcp_race_thread_errors}"
    )


@then("all concurrent start threads should have completed without error")
def step_all_threads_no_error(context: Context) -> None:
    """Assert that all concurrent start() threads completed without error."""
    errors = context.mcp_race_thread_errors
    assert not errors, f"Expected no thread errors but got {len(errors)}: {errors}"
