"""Step definitions for lsp_path_traversal_security.feature.

Tests the path traversal vulnerability fix in LspRuntime._read_file
and the new _validate_workspace_path helper.

Uses the ``lspsec`` step prefix to avoid Behave AmbiguousStep errors.
"""

from __future__ import annotations

import os
import tempfile
from unittest.mock import MagicMock

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

from cleveragents.lsp.errors import LspError
from cleveragents.lsp.lifecycle import LspLifecycleManager
from cleveragents.lsp.models import LspServerConfig
from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.runtime import LspRuntime

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


def _make_mock_client() -> MagicMock:
    """Create a mock LSP client with the methods runtime calls."""
    client = MagicMock(name="mock_lsp_client")
    client.did_open = MagicMock()
    client.did_close = MagicMock()
    client.get_diagnostics = MagicMock(return_value=[])
    client.get_completions = MagicMock(return_value=[])
    return client


def _make_config(name: str = "local/pyright") -> LspServerConfig:
    """Build a minimal server config for testing."""
    return LspServerConfig(
        name=name,
        command="echo",
        languages=["python"],
    )


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


@given("lspsec a workspace root at a temp directory")
def step_lspsec_workspace_root(context: Context) -> None:
    tmpdir = tempfile.mkdtemp(prefix="lspsec_ws_")
    context.lspsec_workspace = tmpdir
    context.lspsec_error = None

    def cleanup() -> None:
        import shutil

        shutil.rmtree(tmpdir, ignore_errors=True)

    context.add_cleanup(cleanup)


@given('lspsec a file "{filename}" inside the workspace')
def step_lspsec_file_in_workspace(context: Context, filename: str) -> None:
    filepath = os.path.join(context.lspsec_workspace, filename)
    os.makedirs(os.path.dirname(filepath), exist_ok=True)
    with open(filepath, "w", encoding="utf-8") as f:
        f.write("")
    context.lspsec_file_in_workspace = filepath


@given('lspsec a file "{filename}" inside the workspace with content "{content}"')
def step_lspsec_file_in_workspace_with_content(
    context: Context, filename: str, content: str
) -> None:
    filepath = os.path.join(context.lspsec_workspace, filename)
    os.makedirs(os.path.dirname(filepath), exist_ok=True)
    with open(filepath, "w", encoding="utf-8") as f:
        f.write(content)
    context.lspsec_file_in_workspace = filepath


@given("lspsec a sibling directory exists next to the workspace")
def step_lspsec_sibling_dir(context: Context) -> None:
    sibling = tempfile.mkdtemp(prefix="lspsec_sibling_")
    context.lspsec_sibling_dir = sibling
    sibling_file = os.path.join(sibling, "secret.txt")
    with open(sibling_file, "w", encoding="utf-8") as f:
        f.write("sibling secret")
    context.lspsec_sibling_file = sibling_file

    def cleanup() -> None:
        import shutil

        shutil.rmtree(sibling, ignore_errors=True)

    context.add_cleanup(cleanup)


@given('lspsec a symlink "{linkname}" inside the workspace pointing to "{target}"')
def step_lspsec_symlink_in_workspace(
    context: Context, linkname: str, target: str
) -> None:
    link_path = os.path.join(context.lspsec_workspace, linkname)
    os.symlink(target, link_path)
    context.lspsec_symlink_path = link_path

    def cleanup() -> None:
        if os.path.lexists(link_path):
            os.unlink(link_path)

    context.add_cleanup(cleanup)


@given('lspsec a temp file outside any workspace with content "{content}"')
def step_lspsec_temp_file_outside_workspace(context: Context, content: str) -> None:
    fd, path = tempfile.mkstemp(prefix="lspsec_outside_", suffix=".txt")
    with os.fdopen(fd, "w") as f:
        f.write(content)
    context.lspsec_outside_file = path

    def cleanup() -> None:
        if os.path.exists(path):
            os.unlink(path)

    context.add_cleanup(cleanup)


@given("lspsec an LspRuntime with workspace root at a temp directory")
def step_lspsec_runtime_with_workspace(context: Context) -> None:
    tmpdir = tempfile.mkdtemp(prefix="lspsec_rt_ws_")
    context.lspsec_workspace = tmpdir
    context.lspsec_error = None

    def cleanup() -> None:
        import shutil

        shutil.rmtree(tmpdir, ignore_errors=True)

    context.add_cleanup(cleanup)

    # Create runtime with a mock lifecycle (no real server needed)
    mock_lifecycle = MagicMock(spec=LspLifecycleManager)
    mock_lifecycle.start_server = MagicMock()
    mock_lifecycle.stop_server = MagicMock()
    mock_lifecycle.stop_all = MagicMock()
    mock_lifecycle.health_check = MagicMock(return_value=True)
    mock_lifecycle.get_client = MagicMock(return_value=_make_mock_client())

    context.lspsec_runtime = LspRuntime(lifecycle_manager=mock_lifecycle)
    context.lspsec_mock_lifecycle = mock_lifecycle


@given('lspsec a healthy mock server "{name}" registered for the workspace')
def step_lspsec_register_server_for_workspace(context: Context, name: str) -> None:
    registry = LspRegistry()
    config = _make_config(name)
    registry.register(config)
    context.lspsec_runtime._registry = registry

    mock_client = _make_mock_client()
    context.lspsec_mock_lifecycle.get_client = MagicMock(return_value=mock_client)
    context.lspsec_mock_client = mock_client

    # Simulate that start_server was called so workspace root is stored
    context.lspsec_runtime._workspace_roots[name] = context.lspsec_workspace
    context.lspsec_server_name = name


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


@when("lspsec I call validate_workspace_path with the file inside workspace")
def step_lspsec_validate_file_inside(context: Context) -> None:
    resolved = os.path.realpath(context.lspsec_file_in_workspace)
    try:
        LspRuntime._validate_workspace_path(resolved, context.lspsec_workspace)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when("lspsec I call validate_workspace_path with the nested file")
def step_lspsec_validate_nested_file(context: Context) -> None:
    resolved = os.path.realpath(context.lspsec_file_in_workspace)
    try:
        LspRuntime._validate_workspace_path(resolved, context.lspsec_workspace)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when('lspsec I call validate_workspace_path with absolute path "{path}"')
def step_lspsec_validate_absolute_path(context: Context, path: str) -> None:
    resolved = os.path.realpath(path)
    try:
        LspRuntime._validate_workspace_path(resolved, context.lspsec_workspace)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when("lspsec I call validate_workspace_path with a file in the sibling directory")
def step_lspsec_validate_sibling_file(context: Context) -> None:
    resolved = os.path.realpath(context.lspsec_sibling_file)
    try:
        LspRuntime._validate_workspace_path(resolved, context.lspsec_workspace)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when("lspsec I call validate_workspace_path with the workspace root itself")
def step_lspsec_validate_workspace_root_itself(context: Context) -> None:
    resolved = os.path.realpath(context.lspsec_workspace)
    try:
        LspRuntime._validate_workspace_path(resolved, context.lspsec_workspace)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when('lspsec I call read_file on workspace file "{filename}" with the workspace root')
def step_lspsec_read_workspace_file(context: Context, filename: str) -> None:
    filepath = os.path.join(context.lspsec_workspace, filename)
    try:
        context.lspsec_result = LspRuntime._read_file(
            filepath, workspace_root=context.lspsec_workspace
        )
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when("lspsec I call read_file on a dot-dot traversal path targeting etc-passwd")
def step_lspsec_read_file_dotdot(context: Context) -> None:
    # Build a path like /tmp/lspsec_ws_xxx/../../etc/passwd
    traversal = os.path.join(context.lspsec_workspace, "..", "..", "etc", "passwd")
    try:
        context.lspsec_result = LspRuntime._read_file(
            traversal, workspace_root=context.lspsec_workspace
        )
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when('lspsec I call read_file on absolute path "{path}" with the workspace root')
def step_lspsec_read_file_absolute_path(context: Context, path: str) -> None:
    try:
        context.lspsec_result = LspRuntime._read_file(
            path, workspace_root=context.lspsec_workspace
        )
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when("lspsec I call read_file on the symlink with the workspace root")
def step_lspsec_read_file_symlink(context: Context) -> None:
    try:
        context.lspsec_result = LspRuntime._read_file(
            context.lspsec_symlink_path,
            workspace_root=context.lspsec_workspace,
        )
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when("lspsec I call read_file on the temp file without workspace_root")
def step_lspsec_read_file_no_workspace(context: Context) -> None:
    try:
        context.lspsec_result = LspRuntime._read_file(context.lspsec_outside_file)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when('lspsec I try to get diagnostics for "{name}" on absolute path "{path}"')
def step_lspsec_get_diagnostics_traversal(
    context: Context, name: str, path: str
) -> None:
    try:
        context.lspsec_runtime.get_diagnostics(name, path)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when('lspsec I get diagnostics for "{name}" on workspace file "{filename}"')
def step_lspsec_get_diagnostics_valid(
    context: Context, name: str, filename: str
) -> None:
    filepath = os.path.join(context.lspsec_workspace, filename)
    try:
        context.lspsec_result = context.lspsec_runtime.get_diagnostics(name, filepath)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


@when(
    'lspsec I try to get completions for "{name}" on absolute path "{path}"'
    " at line {line:d} column {col:d}"
)
def step_lspsec_get_completions_traversal(
    context: Context, name: str, path: str, line: int, col: int
) -> None:
    try:
        context.lspsec_runtime.get_completions(name, path, line, col)
        context.lspsec_error = None
    except LspError as exc:
        context.lspsec_error = exc


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


@then("lspsec no error should be raised")
def step_lspsec_no_error(context: Context) -> None:
    assert context.lspsec_error is None, (
        f"Expected no error but got: {context.lspsec_error}"
    )


@then('lspsec an LspError should be raised with message containing "{msg}"')
def step_lspsec_lsp_error_msg(context: Context, msg: str) -> None:
    assert context.lspsec_error is not None, (
        "Expected an LspError but no error was raised"
    )
    assert isinstance(context.lspsec_error, LspError), (
        f"Expected LspError, got {type(context.lspsec_error).__name__}: "
        f"{context.lspsec_error}"
    )
    assert msg in str(context.lspsec_error), (
        f"Expected '{msg}' in error message, got: {context.lspsec_error}"
    )


@then('lspsec the file content should be "{expected}"')
def step_lspsec_file_content(context: Context, expected: str) -> None:
    assert context.lspsec_error is None, (
        f"Expected no error but got: {context.lspsec_error}"
    )
    assert context.lspsec_result == expected, (
        f"Expected content '{expected}', got '{context.lspsec_result}'"
    )


@then("lspsec diagnostics should be returned as a list")
def step_lspsec_diagnostics_list(context: Context) -> None:
    assert context.lspsec_error is None, (
        f"Expected no error but got: {context.lspsec_error}"
    )
    assert isinstance(context.lspsec_result, list), (
        f"Expected list, got {type(context.lspsec_result)}"
    )
