"""Step definitions for container_executor_coverage.feature.

These steps target specific uncovered lines in container_executor.py:
- Lines 184-185: Symlink default sandbox path rejection
- Lines 536-545: subprocess.TimeoutExpired handling in _run_command
- Lines 555-562: stdout truncation defense-in-depth
- Lines 563-570: stderr truncation defense-in-depth
- Lines 736-740: _read_bounded drain and partial chunk paths
- Line 759 branches: _looks_like_path with \\r and \\t
- Line 712: _parse_output RecursionError/MemoryError fallback
- Lines 582-590: _run_command OSError at Popen level
"""

from __future__ import annotations

import io
import subprocess
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

from behave import given, then, when

from cleveragents.tool.container_executor import (
    _MAX_OUTPUT_BYTES,
    ContainerConfig,
    ContainerExecutionError,
    ContainerToolExecutor,
    _looks_like_path,
    _read_bounded,
)

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

_FAKE_DEVCONTAINER_BIN = "/usr/local/bin/devcontainer"


def _make_executor(
    host_sandbox_path: str = "/tmp/sandbox",
    workspace_folder: str = "/workspace",
    container_id: str = "test-ctr",
    timeout_seconds: int = 120,
) -> ContainerToolExecutor:
    """Create a ContainerToolExecutor with a fake devcontainer binary."""
    config = ContainerConfig(
        workspace_folder=workspace_folder,
        container_id=container_id,
        host_sandbox_path=host_sandbox_path,
        timeout_seconds=timeout_seconds,
    )
    executor = ContainerToolExecutor(config)
    executor._devcontainer_bin = _FAKE_DEVCONTAINER_BIN
    return executor


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


@given("the container executor coverage module is imported")
def step_coverage_module_imported(context: Any) -> None:
    """Ensure the module is importable."""
    assert ContainerToolExecutor is not None
    assert _read_bounded is not None
    assert _looks_like_path is not None


# =========================================================================
# Symlink default sandbox path rejection (lines 184-185)
# =========================================================================


@given("the default sandbox path is a symlink")
def step_default_sandbox_is_symlink(context: Any) -> None:
    """Set up the mock so that Path('/tmp/sandbox').is_symlink() returns True."""
    context.symlink_patcher = patch.object(Path, "is_symlink", return_value=True)
    context.symlink_patcher.start()
    context.add_cleanup(context.symlink_patcher.stop)


@when("I try to create a ContainerToolExecutor with empty host_sandbox_path")
def step_create_executor_empty_sandbox(context: Any) -> None:
    """Attempt to create executor with empty host_sandbox_path (uses default)."""
    config = ContainerConfig(
        workspace_folder="/workspace",
        container_id="test",
        host_sandbox_path="",
    )
    try:
        ContainerToolExecutor(config)
        context.symlink_error = None
    except ContainerExecutionError as exc:
        context.symlink_error = exc


@then("a ContainerExecutionError should be raised about symlink default sandbox")
def step_check_symlink_error(context: Any) -> None:
    assert context.symlink_error is not None, (
        "Expected ContainerExecutionError for symlink default sandbox"
    )
    assert "symlink" in str(context.symlink_error).lower(), (
        f"Expected 'symlink' in error: {context.symlink_error}"
    )


# =========================================================================
# Real subprocess.TimeoutExpired in _run_command (lines 536-545)
# =========================================================================


@given(
    "I have a container executor with a Popen mock that raises TimeoutExpired on wait"
)
def step_executor_popen_timeout_expired(context: Any) -> None:
    """Mock subprocess.Popen so proc.wait() raises TimeoutExpired.

    The code calls proc.wait(timeout=...) in the try block which raises
    TimeoutExpired, then calls proc.kill() and proc.wait() (no timeout)
    in the except block.  The second wait() must succeed.
    """
    executor = _make_executor(timeout_seconds=5)

    mock_proc = MagicMock()
    mock_proc.stdout = io.BytesIO(b"partial output")
    mock_proc.stderr = io.BytesIO(b"partial error")
    mock_proc.stdin = None
    mock_proc.returncode = -9
    mock_proc.wait = MagicMock(
        side_effect=[
            subprocess.TimeoutExpired(cmd="test", timeout=5),
            None,
        ]
    )
    mock_proc.kill = MagicMock()

    patcher = patch(
        "cleveragents.tool.container_executor.subprocess.Popen",
        return_value=mock_proc,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)
    context.executor = executor
    context.mock_proc = mock_proc


@when("I invoke _run_command with the timeout-raising command")
def step_invoke_run_command_timeout(context: Any) -> None:
    context.exec_result = context.executor._run_command(["fake", "command"], timeout=5)


@then("the exec result should indicate timed_out true")
def step_check_timed_out_true(context: Any) -> None:
    assert context.exec_result.timed_out is True, (
        f"Expected timed_out=True, got {context.exec_result.timed_out}"
    )


@then('the exec result stderr should mention "timed out"')
def step_check_exec_result_stderr_timeout(context: Any) -> None:
    assert "timed out" in context.exec_result.stderr.lower(), (
        f"Expected 'timed out' in stderr: {context.exec_result.stderr!r}"
    )


@then("the exec result exit_code should be -1")
def step_check_exec_result_exit_code_neg1(context: Any) -> None:
    assert context.exec_result.exit_code == -1, (
        f"Expected exit_code=-1, got {context.exec_result.exit_code}"
    )


@then("the mock process should have been killed")
def step_check_process_killed(context: Any) -> None:
    context.mock_proc.kill.assert_called_once()


# =========================================================================
# stdout truncation defense-in-depth (lines 555-562)
# =========================================================================


@given("I have a container executor with _read_bounded returning oversized stdout")
def step_executor_oversized_stdout(context: Any) -> None:
    """Mock _read_bounded to return data larger than MAX_OUTPUT_BYTES for stdout."""
    executor = _make_executor(timeout_seconds=30)

    oversized = b"X" * (_MAX_OUTPUT_BYTES + 2048)
    normal_stderr = b"ok"

    call_count = {"n": 0}

    def fake_read_bounded(stream: Any, max_bytes: int) -> bytes:
        call_count["n"] += 1
        if call_count["n"] == 1:
            return oversized  # stdout
        return normal_stderr  # stderr

    mock_proc = MagicMock()
    mock_proc.stdout = io.BytesIO(b"placeholder")
    mock_proc.stderr = io.BytesIO(b"placeholder")
    mock_proc.stdin = None
    mock_proc.returncode = 0
    mock_proc.wait = MagicMock(return_value=0)

    popen_patcher = patch(
        "cleveragents.tool.container_executor.subprocess.Popen",
        return_value=mock_proc,
    )
    read_patcher = patch(
        "cleveragents.tool.container_executor._read_bounded",
        side_effect=fake_read_bounded,
    )
    popen_patcher.start()
    read_patcher.start()
    context.add_cleanup(popen_patcher.stop)
    context.add_cleanup(read_patcher.stop)
    context.executor = executor


@when("I invoke _run_command for the oversized stdout scenario")
def step_run_command_oversized_stdout(context: Any) -> None:
    context.exec_result = context.executor._run_command(["fake", "cmd"], timeout=30)


@then("the exec result stdout length in bytes should not exceed MAX_OUTPUT_BYTES")
def step_check_stdout_truncated(context: Any) -> None:
    stdout_bytes = context.exec_result.stdout.encode("utf-8", errors="replace")
    assert len(stdout_bytes) <= _MAX_OUTPUT_BYTES, (
        f"Expected stdout <= {_MAX_OUTPUT_BYTES} bytes, got {len(stdout_bytes)}"
    )


# =========================================================================
# stderr truncation defense-in-depth (lines 563-570)
# =========================================================================


@given("I have a container executor with _read_bounded returning oversized stderr")
def step_executor_oversized_stderr(context: Any) -> None:
    """Mock _read_bounded to return data larger than MAX_OUTPUT_BYTES for stderr."""
    executor = _make_executor(timeout_seconds=30)

    normal_stdout = b"ok"
    oversized = b"E" * (_MAX_OUTPUT_BYTES + 4096)

    call_count = {"n": 0}

    def fake_read_bounded(stream: Any, max_bytes: int) -> bytes:
        call_count["n"] += 1
        if call_count["n"] == 1:
            return normal_stdout  # stdout
        return oversized  # stderr

    mock_proc = MagicMock()
    mock_proc.stdout = io.BytesIO(b"placeholder")
    mock_proc.stderr = io.BytesIO(b"placeholder")
    mock_proc.stdin = None
    mock_proc.returncode = 0
    mock_proc.wait = MagicMock(return_value=0)

    popen_patcher = patch(
        "cleveragents.tool.container_executor.subprocess.Popen",
        return_value=mock_proc,
    )
    read_patcher = patch(
        "cleveragents.tool.container_executor._read_bounded",
        side_effect=fake_read_bounded,
    )
    popen_patcher.start()
    read_patcher.start()
    context.add_cleanup(popen_patcher.stop)
    context.add_cleanup(read_patcher.stop)
    context.executor = executor


@when("I invoke _run_command for the oversized stderr scenario")
def step_run_command_oversized_stderr(context: Any) -> None:
    context.exec_result = context.executor._run_command(["fake", "cmd"], timeout=30)


@then("the exec result stderr length in bytes should not exceed MAX_OUTPUT_BYTES")
def step_check_stderr_truncated(context: Any) -> None:
    stderr_bytes = context.exec_result.stderr.encode("utf-8", errors="replace")
    assert len(stderr_bytes) <= _MAX_OUTPUT_BYTES, (
        f"Expected stderr <= {_MAX_OUTPUT_BYTES} bytes, got {len(stderr_bytes)}"
    )


# =========================================================================
# _read_bounded drain and partial chunk (lines 736-740)
# =========================================================================


@given("I have a stream with data exceeding the byte cap")
def step_stream_exceeding_cap(context: Any) -> None:
    """Create a BytesIO stream with substantially more data than the cap."""
    context.test_max_bytes = 100
    # Provide 300 bytes so that after reading 100, we continue draining
    context.bounded_stream = io.BytesIO(b"A" * 300)


@when("I call _read_bounded with a small max_bytes limit")
def step_call_read_bounded_small(context: Any) -> None:
    context.bounded_result = _read_bounded(
        context.bounded_stream, context.test_max_bytes
    )


@then("the returned bytes should be exactly max_bytes long")
def step_check_bounded_exact_length(context: Any) -> None:
    assert len(context.bounded_result) == context.test_max_bytes, (
        f"Expected {context.test_max_bytes} bytes, got {len(context.bounded_result)}"
    )


@then("the stream should have been fully consumed")
def step_check_stream_consumed(context: Any) -> None:
    remaining = context.bounded_stream.read()
    assert len(remaining) == 0, (
        f"Expected stream to be fully consumed, but {len(remaining)} bytes remain"
    )


@given("I have a stream where a chunk crosses the max_bytes boundary")
def step_stream_chunk_crosses_boundary(context: Any) -> None:
    """Create a stream where a single 64KB chunk would exceed the cap.

    _read_bounded reads in _READ_CHUNK_SIZE (65536) byte chunks.
    If max_bytes is less than the chunk size, the first chunk itself
    crosses the boundary, exercising the partial-slice path.
    """
    context.boundary_max_bytes = 100
    # Provide one full chunk worth of data (> 100 bytes)
    context.boundary_stream = io.BytesIO(b"B" * 70_000)


@when("I call _read_bounded with a boundary-crossing limit")
def step_call_read_bounded_boundary(context: Any) -> None:
    context.bounded_result = _read_bounded(
        context.boundary_stream, context.boundary_max_bytes
    )


@then("the returned bytes should be exactly the boundary limit")
def step_check_boundary_exact(context: Any) -> None:
    assert len(context.bounded_result) == context.boundary_max_bytes, (
        f"Expected {context.boundary_max_bytes} bytes, "
        f"got {len(context.bounded_result)}"
    )


# =========================================================================
# _looks_like_path branch coverage for \r and \t (line 759)
# =========================================================================


@when("I check whether a string with carriage return looks like a path")
def step_looks_like_path_cr(context: Any) -> None:
    context.path_check_result = _looks_like_path("/some/path\rwith cr")


@when("I check whether a string with tab character looks like a path")
def step_looks_like_path_tab(context: Any) -> None:
    context.path_check_result = _looks_like_path("/some/path\twith tab")


@when("I check whether a relative string looks like a path")
def step_looks_like_path_relative(context: Any) -> None:
    context.path_check_result = _looks_like_path("relative/path/file.py")


@then("the path check result should be false")
def step_check_path_result_false(context: Any) -> None:
    assert context.path_check_result is False, (
        "Expected _looks_like_path to return False"
    )


# =========================================================================
# _parse_output RecursionError / MemoryError fallback (line 712)
# =========================================================================


@given("json.loads is patched to raise RecursionError")
def step_patch_json_recursion(context: Any) -> None:
    patcher = patch(
        "cleveragents.tool.container_executor.json.loads",
        side_effect=RecursionError("maximum recursion depth exceeded"),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@when("I call _parse_output with a non-empty string")
def step_call_parse_output_nonempty(context: Any) -> None:
    context.parse_input_str = "  some non-json content  "
    context.parse_result = ContainerToolExecutor._parse_output(context.parse_input_str)


@then("the parsed result should contain raw_output with the stripped string")
def step_check_raw_output_stripped(context: Any) -> None:
    assert "raw_output" in context.parse_result, (
        f"Expected 'raw_output' key, got {context.parse_result}"
    )
    assert context.parse_result["raw_output"] == context.parse_input_str.strip(), (
        f"Expected stripped input, got {context.parse_result['raw_output']!r}"
    )


@given("json.loads is patched to raise MemoryError")
def step_patch_json_memory(context: Any) -> None:
    patcher = patch(
        "cleveragents.tool.container_executor.json.loads",
        side_effect=MemoryError("out of memory"),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


@when("I call _parse_output with a non-empty JSON-like string")
def step_call_parse_output_json_like(context: Any) -> None:
    context.parse_input_str = '  {"key": "value"}  '
    context.parse_result = ContainerToolExecutor._parse_output(context.parse_input_str)


# =========================================================================
# _run_command OSError at Popen level (lines 582-590)
# =========================================================================


@given("I have a container executor with Popen raising OSError")
def step_executor_popen_oserror(context: Any) -> None:
    executor = _make_executor(timeout_seconds=10)
    patcher = patch(
        "cleveragents.tool.container_executor.subprocess.Popen",
        side_effect=OSError("No such file or directory"),
    )
    patcher.start()
    context.add_cleanup(patcher.stop)
    context.executor = executor


@when("I invoke _run_command for the OSError scenario")
def step_run_command_oserror(context: Any) -> None:
    context.exec_result = context.executor._run_command(
        ["nonexistent", "command"], timeout=10
    )


@then("the exec result should indicate failure with OSError message")
def step_check_oserror_result(context: Any) -> None:
    assert context.exec_result.exit_code == -1, (
        f"Expected exit_code=-1, got {context.exec_result.exit_code}"
    )
    assert "No such file" in context.exec_result.stderr, (
        f"Expected OSError message in stderr: {context.exec_result.stderr!r}"
    )


@then("the exec result timed_out should be false")
def step_check_oserror_not_timed_out(context: Any) -> None:
    assert context.exec_result.timed_out is False


# =========================================================================
# _map_value_host_to_container with non-path strings (line 624)
# =========================================================================


@given("I have a coverage boost container executor with path mapping")
def step_coverage_boost_executor_path_mapping(context: Any) -> None:
    context.cov_executor = _make_executor()


@when("I map an input dict containing non-path string values")
def step_map_input_non_path_strings(context: Any) -> None:
    inputs = {
        "message": "hello world",
        "flag": "true",
        "number": 42,
        "url": "https://example.com/api",
        "abs_path_outside": "/usr/lib/something",
    }
    context.mapped_non_path_inputs = context.cov_executor._map_input_paths(inputs)


@then("non-path string values should remain unchanged")
def step_check_non_path_inputs_unchanged(context: Any) -> None:
    m = context.mapped_non_path_inputs
    assert m["message"] == "hello world"
    assert m["flag"] == "true"
    assert m["number"] == 42
    assert m["url"] == "https://example.com/api"
    # Absolute path outside host root should remain unchanged
    assert m["abs_path_outside"] == "/usr/lib/something"


# =========================================================================
# _map_value_container_to_host with non-path strings
# =========================================================================


@when("I map an output dict containing non-path string values")
def step_map_output_non_path_strings(context: Any) -> None:
    output = {
        "message": "result text",
        "code": "200",
        "count": 5,
        "external_path": "/usr/local/bin/tool",
    }
    context.mapped_non_path_outputs = context.cov_executor._map_output_paths(output)


@then("non-path output string values should remain unchanged")
def step_check_non_path_outputs_unchanged(context: Any) -> None:
    m = context.mapped_non_path_outputs
    assert m["message"] == "result text"
    assert m["code"] == "200"
    assert m["count"] == 5
    assert m["external_path"] == "/usr/local/bin/tool"


# =========================================================================
# _run_command stdin_data piping (lines 524-528)
# =========================================================================


@given("I have a container executor with a Popen mock that accepts stdin")
def step_executor_popen_stdin(context: Any) -> None:
    executor = _make_executor(timeout_seconds=10)

    mock_stdin = MagicMock()
    mock_stdin.closed = False

    mock_proc = MagicMock()
    mock_proc.stdout = io.BytesIO(b'{"status": "ok"}')
    mock_proc.stderr = io.BytesIO(b"")
    mock_proc.stdin = mock_stdin
    mock_proc.returncode = 0
    mock_proc.wait = MagicMock(return_value=0)

    patcher = patch(
        "cleveragents.tool.container_executor.subprocess.Popen",
        return_value=mock_proc,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)
    context.executor = executor
    context.mock_stdin = mock_stdin


@when("I invoke _run_command with stdin_data provided")
def step_run_command_with_stdin(context: Any) -> None:
    context.exec_result = context.executor._run_command(
        ["fake", "cmd"], timeout=10, stdin_data='{"input": "test"}'
    )


@then("stdin should have been written with the encoded data")
def step_check_stdin_written(context: Any) -> None:
    context.mock_stdin.write.assert_called_once_with(b'{"input": "test"}')


@then("stdin should have been closed after writing")
def step_check_stdin_closed(context: Any) -> None:
    context.mock_stdin.close.assert_called()


# =========================================================================
# _build_exec_command with low timeout
# =========================================================================


@given("I have a coverage boost container executor with devcontainer binary")
def step_coverage_boost_executor_with_bin(context: Any) -> None:
    context.cov_executor = _make_executor(container_id="low-timeout-ctr")


@when("I build an exec command with timeout value {timeout:d}")
def step_build_exec_low_timeout(context: Any, timeout: int) -> None:
    cmd, stdin_data = context.cov_executor._build_exec_command(
        "test_tool", {"key": "val"}, timeout
    )
    context.built_cmd = cmd
    context.built_stdin = stdin_data


@then("the container-side timeout in the command should be 1 or greater")
def step_check_container_timeout_minimum(context: Any) -> None:
    # The command ends with "sh -c timeout N cleveragents-tool-exec ..."
    # Find the "timeout" argument in the sh -c string
    sh_c_arg = context.built_cmd[-1]  # last element is the sh -c argument
    # Parse "timeout N cleveragents-tool-exec ..."
    parts = sh_c_arg.split()
    timeout_idx = parts.index("timeout")
    container_timeout = int(parts[timeout_idx + 1])
    assert container_timeout >= 1, (
        f"Expected container timeout >= 1, got {container_timeout}"
    )


# =========================================================================
# _run_command stream cleanup in finally block (lines 547-551)
# =========================================================================


@given("I have a container executor with a Popen mock with open streams")
def step_executor_popen_open_streams(context: Any) -> None:
    executor = _make_executor(timeout_seconds=10)

    # Create mock streams that report as not closed and return data
    # then empty bytes (so _read_bounded terminates its loop)
    mock_stdout = MagicMock()
    mock_stdout.closed = False
    mock_stdout.read = MagicMock(side_effect=[b"output data", b""])

    mock_stderr = MagicMock()
    mock_stderr.closed = False
    mock_stderr.read = MagicMock(side_effect=[b"error data", b""])

    mock_stdin = MagicMock()
    mock_stdin.closed = False

    mock_proc = MagicMock()
    mock_proc.stdout = mock_stdout
    mock_proc.stderr = mock_stderr
    mock_proc.stdin = mock_stdin
    mock_proc.returncode = 0
    mock_proc.wait = MagicMock(return_value=0)

    patcher = patch(
        "cleveragents.tool.container_executor.subprocess.Popen",
        return_value=mock_proc,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)
    context.executor = executor
    context.mock_stdout = mock_stdout
    context.mock_stderr = mock_stderr
    context.mock_stdin = mock_stdin


@when("I invoke _run_command for the stream cleanup scenario")
def step_run_command_stream_cleanup(context: Any) -> None:
    context.exec_result = context.executor._run_command(
        ["fake", "cmd"], timeout=10, stdin_data="some data"
    )


@then("all process streams should have been closed")
def step_check_all_streams_closed(context: Any) -> None:
    # The finally block should close all open streams
    context.mock_stdout.close.assert_called()
    context.mock_stderr.close.assert_called()
    context.mock_stdin.close.assert_called()
