"""Step definitions for LSP StdioTransport coverage tests.

Exercises every uncovered branch in ``cleveragents.lsp.transport``:
start() error paths, stop() variations, send_message(), read_message()
header/body parsing, and all error handling paths.

Uses the ``ltcov`` prefix on all steps to avoid AmbiguousStep errors.
"""

from __future__ import annotations

import json
import subprocess
from unittest.mock import MagicMock, PropertyMock, patch

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

from cleveragents.lsp.errors import LspError
from cleveragents.lsp.transport import _MAX_CONTENT_LENGTH, StdioTransport

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_mock_process(
    *,
    poll_return: int | None = None,
    returncode: int = 0,
    stdin: object | None = "auto",
    stdout: object | None = "auto",
    stderr: object | None = "auto",
    pid: int = 12345,
) -> MagicMock:
    """Create a ``MagicMock`` resembling ``subprocess.Popen``."""
    proc = MagicMock(spec=subprocess.Popen)
    proc.pid = pid
    proc.poll.return_value = poll_return
    proc.returncode = returncode
    if stdin == "auto":
        proc.stdin = MagicMock()
    else:
        proc.stdin = stdin
    if stdout == "auto":
        proc.stdout = MagicMock()
    else:
        proc.stdout = stdout
    if stderr == "auto":
        proc.stderr = MagicMock()
    else:
        proc.stderr = stderr
    return proc


def _build_lsp_frame(body_dict: dict) -> bytes:
    """Encode a dict as a Content-Length framed LSP message."""
    payload = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
    header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
    return header + payload


# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------


@given('ltcov I create a StdioTransport for command "{cmd}"')
def step_ltcov_create_transport(context: Context, cmd: str) -> None:
    context.ltcov_transport = StdioTransport(command=cmd)
    context.ltcov_error = None
    context.ltcov_result = None


@given("ltcov the transport has a running mock process")
def step_ltcov_running_mock_process(context: Context) -> None:
    proc = _make_mock_process(poll_return=None)
    context.ltcov_transport._process = proc


@given("ltcov Popen is mocked to raise FileNotFoundError")
def step_ltcov_popen_fnf(context: Context) -> None:
    patcher = patch(
        "cleveragents.lsp.transport.subprocess.Popen",
        side_effect=FileNotFoundError("No such file"),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given('ltcov Popen is mocked to raise OSError with "{msg}"')
def step_ltcov_popen_oserror(context: Context, msg: str) -> None:
    patcher = patch(
        "cleveragents.lsp.transport.subprocess.Popen",
        side_effect=OSError(msg),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process that already exited with code {code:d}")
def step_ltcov_exited_process(context: Context, code: int) -> None:
    proc = _make_mock_process(poll_return=code, returncode=code)
    context.ltcov_transport._process = proc


@given("ltcov the transport has a mock process that ignores terminate")
def step_ltcov_stubborn_process(context: Context) -> None:
    proc = _make_mock_process(poll_return=None, returncode=-9)
    # First poll returns None (alive), after kill returns -9
    proc.poll.return_value = None
    # terminate() does nothing; wait() after terminate times out
    proc.wait.side_effect = [
        subprocess.TimeoutExpired(cmd="sleep", timeout=0.1),
        None,  # second wait after kill succeeds
    ]
    # After kill, returncode becomes -9
    type(proc).returncode = PropertyMock(return_value=-9)
    context.ltcov_transport._process = proc
    context.ltcov_mock_process = proc


@given("ltcov the transport has a mock process with no stdin")
def step_ltcov_no_stdin(context: Context) -> None:
    proc = _make_mock_process(stdin=None)
    context.ltcov_transport._process = proc


@given("ltcov the transport has a mock process with writable stdin")
def step_ltcov_writable_stdin(context: Context) -> None:
    mock_stdin = MagicMock()
    mock_stdin.write = MagicMock()
    mock_stdin.flush = MagicMock()
    proc = _make_mock_process()
    proc.stdin = mock_stdin
    context.ltcov_transport._process = proc
    context.ltcov_mock_stdin = mock_stdin


@given("ltcov the transport has a mock process with broken stdin")
def step_ltcov_broken_stdin(context: Context) -> None:
    mock_stdin = MagicMock()
    mock_stdin.write.side_effect = BrokenPipeError("pipe closed")
    proc = _make_mock_process()
    proc.stdin = mock_stdin
    context.ltcov_transport._process = proc


@given("ltcov the transport has a mock process with no stdout")
def step_ltcov_no_stdout(context: Context) -> None:
    proc = _make_mock_process(stdout=None)
    context.ltcov_transport._process = proc


@given("ltcov the transport has a mock process with empty stdout")
def step_ltcov_empty_stdout(context: Context) -> None:
    mock_stdout = MagicMock()
    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc
    context.ltcov_mock_stdout = mock_stdout


@given("ltcov select is mocked to return not ready")
def step_ltcov_select_not_ready(context: Context) -> None:
    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with stdout that returns EOF")
def step_ltcov_stdout_eof(context: Context) -> None:
    mock_stdout = MagicMock()
    # select says ready, but readline returns empty (EOF)
    mock_stdout.readline.return_value = b""
    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with a valid JSON-RPC response")
def step_ltcov_valid_response(context: Context) -> None:
    body = {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {}}}
    payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
    header_line = f"Content-Length: {len(payload)}\r\n".encode("ascii")
    empty_line = b"\r\n"

    mock_stdout = MagicMock()
    # readline() returns: header line, then empty line (end of headers)
    mock_stdout.readline.side_effect = [header_line, empty_line]
    # read() returns the JSON body
    mock_stdout.read.return_value = payload

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    # select always says ready
    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with invalid content-length header")
def step_ltcov_invalid_cl(context: Context) -> None:
    mock_stdout = MagicMock()
    # Return a header with non-numeric content-length, then empty line
    mock_stdout.readline.side_effect = [
        b"Content-Length: not_a_number\r\n",
        b"\r\n",
    ]

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with no content-length header")
def step_ltcov_no_cl(context: Context) -> None:
    mock_stdout = MagicMock()
    # Return a random header (not Content-Length), then empty line
    mock_stdout.readline.side_effect = [
        b"X-Custom-Header: something\r\n",
        b"\r\n",
    ]

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with oversized content-length")
def step_ltcov_oversized_cl(context: Context) -> None:
    huge_length = _MAX_CONTENT_LENGTH + 1
    mock_stdout = MagicMock()
    mock_stdout.readline.side_effect = [
        f"Content-Length: {huge_length}\r\n".encode("ascii"),
        b"\r\n",
    ]

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with headers but body times out")
def step_ltcov_body_timeout(context: Context) -> None:
    mock_stdout = MagicMock()
    mock_stdout.readline.side_effect = [
        b"Content-Length: 50\r\n",
        b"\r\n",
    ]

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    # First select call returns ready (for headers), second returns not-ready (body timeout)
    call_count = [0]

    def select_side_effect(rlist, wlist, xlist, timeout=None):
        call_count[0] += 1
        if call_count[0] <= 2:
            # Headers: two readline calls both need select to report ready
            return (rlist, [], [])
        else:
            # Body: timeout
            return ([], [], [])

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        side_effect=select_side_effect,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with truncated body")
def step_ltcov_truncated_body(context: Context) -> None:
    mock_stdout = MagicMock()
    mock_stdout.readline.side_effect = [
        b"Content-Length: 50\r\n",
        b"\r\n",
    ]
    # Server sends fewer bytes than Content-Length claims
    mock_stdout.read.return_value = b"short"

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@given("ltcov the transport has a mock process with invalid JSON body")
def step_ltcov_invalid_json_body(context: Context) -> None:
    bad_body = b"this is not valid json!!"
    mock_stdout = MagicMock()
    mock_stdout.readline.side_effect = [
        f"Content-Length: {len(bad_body)}\r\n".encode("ascii"),
        b"\r\n",
    ]
    mock_stdout.read.return_value = bad_body

    proc = _make_mock_process()
    proc.stdout = mock_stdout
    context.ltcov_transport._process = proc

    patcher = patch(
        "cleveragents.lsp.transport.select.select",
        return_value=([mock_stdout], [], []),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------


@when("ltcov I try to start the transport")
def step_ltcov_try_start(context: Context) -> None:
    try:
        context.ltcov_transport.start()
    except Exception as exc:
        context.ltcov_error = exc


@when("ltcov I stop the transport")
def step_ltcov_stop(context: Context) -> None:
    context.ltcov_result = context.ltcov_transport.stop()


@when("ltcov I stop the transport with timeout {timeout:g}")
def step_ltcov_stop_timeout(context: Context, timeout: float) -> None:
    context.ltcov_result = context.ltcov_transport.stop(timeout=timeout)


@when("ltcov I try to send a message")
def step_ltcov_try_send(context: Context) -> None:
    try:
        context.ltcov_transport.send_message(
            {"jsonrpc": "2.0", "method": "test", "id": 1}
        )
    except Exception as exc:
        context.ltcov_error = exc


@when("ltcov I send the message {body_json}")
def step_ltcov_send_message(context: Context, body_json: str) -> None:
    body = json.loads(body_json)
    context.ltcov_transport.send_message(body)


@when("ltcov I try to read a message")
def step_ltcov_try_read(context: Context) -> None:
    try:
        context.ltcov_result = context.ltcov_transport.read_message()
    except Exception as exc:
        context.ltcov_error = exc


@when("ltcov I read a message with timeout {timeout:g}")
def step_ltcov_read_with_timeout(context: Context, timeout: float) -> None:
    try:
        context.ltcov_result = context.ltcov_transport.read_message(timeout=timeout)
    except Exception as exc:
        context.ltcov_error = exc


@when("ltcov I read a message with default timeout")
def step_ltcov_read_default_timeout(context: Context) -> None:
    try:
        context.ltcov_result = context.ltcov_transport.read_message(timeout=None)
    except Exception as exc:
        context.ltcov_error = exc


# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------


@then('ltcov the error should be a RuntimeError with message "{fragment}"')
def step_ltcov_runtime_error(context: Context, fragment: str) -> None:
    assert context.ltcov_error is not None, "Expected an error but none was raised"
    assert isinstance(context.ltcov_error, RuntimeError), (
        f"Expected RuntimeError, got {type(context.ltcov_error).__name__}"
    )
    assert fragment in str(context.ltcov_error), (
        f"Expected '{fragment}' in '{context.ltcov_error}'"
    )


@then('ltcov the error should be an LspError with message "{fragment}"')
def step_ltcov_lsp_error(context: Context, fragment: str) -> None:
    assert context.ltcov_error is not None, "Expected an error but none was raised"
    assert isinstance(context.ltcov_error, LspError), (
        f"Expected LspError, got {type(context.ltcov_error).__name__}"
    )
    assert fragment in str(context.ltcov_error), (
        f"Expected '{fragment}' in '{context.ltcov_error}'"
    )


@then("ltcov the stop result should be None")
def step_ltcov_stop_none(context: Context) -> None:
    assert context.ltcov_result is None, f"Expected None, got {context.ltcov_result}"


@then("ltcov the stop result should be {code:d}")
def step_ltcov_stop_code(context: Context, code: int) -> None:
    assert context.ltcov_result == code, f"Expected {code}, got {context.ltcov_result}"


@then("ltcov the internal process should be None")
def step_ltcov_process_none(context: Context) -> None:
    assert context.ltcov_transport._process is None


@then("ltcov the mock process should have been killed")
def step_ltcov_killed(context: Context) -> None:
    context.ltcov_mock_process.kill.assert_called_once()


@then("ltcov the stop result should be an integer")
def step_ltcov_stop_is_int(context: Context) -> None:
    assert isinstance(context.ltcov_result, int), (
        f"Expected int, got {type(context.ltcov_result).__name__}"
    )


@then('ltcov the written bytes should contain "{fragment}"')
def step_ltcov_written_contains(context: Context, fragment: str) -> None:
    written_calls = context.ltcov_mock_stdin.write.call_args_list
    assert len(written_calls) > 0, "No data was written to stdin"
    written_data = b"".join(call.args[0] for call in written_calls)
    assert fragment.encode() in written_data, f"Expected '{fragment}' in written data"


@then("ltcov the error should be a BrokenPipeError")
def step_ltcov_broken_pipe(context: Context) -> None:
    assert context.ltcov_error is not None, "Expected an error but none was raised"
    assert isinstance(context.ltcov_error, BrokenPipeError), (
        f"Expected BrokenPipeError, got {type(context.ltcov_error).__name__}"
    )


@then("ltcov the read result should be None")
def step_ltcov_read_none(context: Context) -> None:
    assert context.ltcov_result is None, f"Expected None, got {context.ltcov_result}"


@then('ltcov the read result should have key "{key}" with value "{value}"')
def step_ltcov_read_key_value(context: Context, key: str, value: str) -> None:
    assert context.ltcov_result is not None, "Expected a result but got None"
    assert isinstance(context.ltcov_result, dict), (
        f"Expected dict, got {type(context.ltcov_result)}"
    )
    assert key in context.ltcov_result, f"Key '{key}' not in result"
    assert context.ltcov_result[key] == value, (
        f"Expected '{value}', got '{context.ltcov_result[key]}'"
    )


@then('ltcov the error should be a ValueError with message "{fragment}"')
def step_ltcov_value_error(context: Context, fragment: str) -> None:
    assert context.ltcov_error is not None, "Expected an error but none was raised"
    assert isinstance(context.ltcov_error, ValueError), (
        f"Expected ValueError, got {type(context.ltcov_error).__name__}"
    )
    assert fragment in str(context.ltcov_error), (
        f"Expected '{fragment}' in '{context.ltcov_error}'"
    )
