"""Step definitions for devcontainer_handler_protocol_methods.feature.

Exercises the four new protocol methods added to DevcontainerHandler
by issue #1242:
- delete()          — devcontainer exec rm
- list_children()   — devcontainer exec ls (returns list[str])
- diff()            — content hash comparison
- create_sandbox()  — container clone/snapshot via base-class delegation

All subprocess.run calls are mocked via unittest.mock.patch.
Step prefix: dcproto (devcontainer protocol methods).
"""

from __future__ import annotations

import os
import tempfile
from unittest.mock import MagicMock, patch

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

from cleveragents.domain.models.core.container_lifecycle import (
    ContainerLifecycleState,
    ContainerLifecycleTracker,
)
from cleveragents.domain.models.core.resource import PhysVirt, Resource
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

# Valid 26-char Crockford Base32 ULID for test resources.
# Crockford Base32 excludes I, L, O, U — use only [0-9A-HJKMNP-TV-Z].
_TEST_ULID = "01JTESTDCPRTM000000000000A"


def _make_resource(
    location: str | None = "/ws/project",
    resource_id: str = _TEST_ULID,
) -> Resource:
    """Create a minimal devcontainer-instance Resource for testing."""
    return Resource(
        resource_id=resource_id,
        name="test-devcontainer",
        resource_type_name="devcontainer-instance",
        classification=PhysVirt.PHYSICAL,
        description="Test devcontainer resource",
        location=location,
    )


# ── Given steps ──────────────────────────────────────────────


@given("dcproto a devcontainer handler")
def step_dcproto_create_handler(context: Context) -> None:
    context.dcproto_handler = DevcontainerHandler()


@given('dcproto a devcontainer-instance resource with location "{loc}"')
def step_dcproto_resource_with_location(context: Context, loc: str) -> None:
    context.dcproto_resource = _make_resource(location=loc)


@given("dcproto a devcontainer-instance resource with no location")
def step_dcproto_resource_no_location(context: Context) -> None:
    context.dcproto_resource = _make_resource(location=None)


@given("dcproto subprocess returns successful rm output")
def step_dcproto_subprocess_rm_success(context: Context) -> None:
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = b""
    mock_result.stderr = b""
    context.dcproto_mock_result = mock_result
    context.dcproto_mock_mode = "rm_success"


@given('dcproto subprocess returns failed rm output with stderr "{stderr}"')
def step_dcproto_subprocess_rm_failure(context: Context, stderr: str) -> None:
    mock_result = MagicMock()
    mock_result.returncode = 1
    mock_result.stdout = b""
    mock_result.stderr = stderr.encode("utf-8")
    context.dcproto_mock_result = mock_result
    context.dcproto_mock_mode = "rm_failure"


@given("dcproto subprocess returns failed ls output")
def step_dcproto_subprocess_ls_failure(context: Context) -> None:
    mock_result = MagicMock()
    mock_result.returncode = 1
    mock_result.stdout = ""
    mock_result.stderr = "ls: cannot access"
    context.dcproto_mock_result = mock_result
    context.dcproto_mock_mode = "ls_failure"


@given('dcproto subprocess returns successful ls listing "{text}"')
def step_dcproto_subprocess_ls_listing(context: Context, text: str) -> None:
    """Configure mock for list_children which uses text=True."""
    mock_result = MagicMock()
    mock_result.returncode = 0
    raw = text.replace("\\n", "\n")
    mock_result.stdout = raw
    mock_result.stderr = ""
    context.dcproto_mock_result = mock_result
    context.dcproto_mock_mode = "ls_listing"


@given('dcproto the container content hash is "{fake_hash}"')
def step_dcproto_container_hash(context: Context, fake_hash: str) -> None:
    """Patch content_hash to return a fixed value for the container side."""
    context.dcproto_container_hash = fake_hash


@given("dcproto a temporary directory with a different file")
def step_dcproto_temp_dir_different(context: Context) -> None:
    """Create a temp dir with a file so its hash differs from the container."""
    tmpdir = tempfile.mkdtemp()
    context.add_cleanup(lambda: __import__("shutil").rmtree(tmpdir, ignore_errors=True))
    with open(os.path.join(tmpdir, "different.txt"), "w") as f:
        f.write("different content that will produce a unique hash")
    context.dcproto_other_location = tmpdir


@given("dcproto the container content hash matches the other location")
def step_dcproto_hash_matches(context: Context) -> None:
    """Signal that we want the hashes to match (same location used for both)."""
    context.dcproto_hash_should_match = True


@given("dcproto I diff the resource against the same location")
def step_dcproto_same_location_setup(context: Context) -> None:
    """Use the resource's own location as other_location for a no-diff scenario."""
    context.dcproto_other_location = context.dcproto_resource.location


@given("dcproto the container is in RUNNING state")
def step_dcproto_container_running(context: Context) -> None:
    from cleveragents.resource.handlers._devcontainer_internals import (
        set_lifecycle_tracker,
    )

    # Use model_copy to bypass transition validation for test setup.
    tracker = ContainerLifecycleTracker(
        resource_id=context.dcproto_resource.resource_id,
    )
    tracker = tracker.model_copy(
        update={"current_state": ContainerLifecycleState.RUNNING}
    )
    set_lifecycle_tracker(tracker)
    context.dcproto_container_state = ContainerLifecycleState.RUNNING


@given("dcproto the container is in DETECTED state")
def step_dcproto_container_detected(context: Context) -> None:
    from cleveragents.resource.handlers._devcontainer_internals import (
        set_lifecycle_tracker,
    )

    # DETECTED is the initial state — no transition needed.
    tracker = ContainerLifecycleTracker(
        resource_id=context.dcproto_resource.resource_id,
    )
    set_lifecycle_tracker(tracker)
    context.dcproto_container_state = ContainerLifecycleState.DETECTED


@given("dcproto the container is in STOPPED state")
def step_dcproto_container_stopped(context: Context) -> None:
    from cleveragents.resource.handlers._devcontainer_internals import (
        set_lifecycle_tracker,
    )

    # Use model_copy to bypass transition validation for test setup.
    tracker = ContainerLifecycleTracker(
        resource_id=context.dcproto_resource.resource_id,
    )
    tracker = tracker.model_copy(
        update={"current_state": ContainerLifecycleState.STOPPED}
    )
    set_lifecycle_tracker(tracker)
    context.dcproto_container_state = ContainerLifecycleState.STOPPED


@given("dcproto a mock sandbox manager that returns a valid sandbox")
def step_dcproto_mock_sandbox_manager(context: Context) -> None:
    """Create a mock SandboxManager that returns a valid sandbox."""
    mock_context = MagicMock()
    mock_context.sandbox_path = "/tmp/sandbox/test"

    mock_sandbox = MagicMock()
    mock_sandbox.sandbox_id = "sandbox-001"
    mock_sandbox.context = mock_context

    mock_manager = MagicMock()
    mock_manager.get_sandbox.return_value = None
    mock_manager.get_or_create_sandbox.return_value = mock_sandbox

    context.dcproto_sandbox_manager = mock_manager


@given("dcproto activate_container is mocked to succeed")
def step_dcproto_mock_activate(context: Context) -> None:
    """Patch activate_container to avoid real subprocess calls."""
    context.dcproto_activate_calls: list[tuple[object, ...]] = []

    def _fake_activate(resource_id: str, location: str, *, session_id: str) -> None:
        context.dcproto_activate_calls.append((resource_id, location, session_id))

    patcher = patch(
        "cleveragents.resource.handlers.devcontainer.activate_container",
        side_effect=_fake_activate,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)


# ── When steps ───────────────────────────────────────────────


def _do_delete(context: Context, path: str) -> None:
    """Shared implementation for delete steps."""
    context.dcproto_error = None
    context.dcproto_delete_result = None
    context.dcproto_subprocess_calls = []

    if not hasattr(context, "dcproto_mock_result"):
        try:
            context.dcproto_delete_result = context.dcproto_handler.delete(
                resource=context.dcproto_resource,
                path=path,
            )
        except (ValueError, NotImplementedError) as exc:
            context.dcproto_error = exc
        return

    def _capture_run(*args: object, **kwargs: object) -> object:
        context.dcproto_subprocess_calls.append((args, kwargs))
        return context.dcproto_mock_result

    patcher = patch(
        "cleveragents.resource.handlers.devcontainer.subprocess.run",
        side_effect=_capture_run,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)

    try:
        context.dcproto_delete_result = context.dcproto_handler.delete(
            resource=context.dcproto_resource,
            path=path,
        )
    except (ValueError, NotImplementedError) as exc:
        context.dcproto_error = exc


@when('dcproto I delete path "{path}" from the resource')
def step_dcproto_delete_path(context: Context, path: str) -> None:
    _do_delete(context, path)


@when("dcproto I delete with empty path from the resource")
def step_dcproto_delete_empty_path(context: Context) -> None:
    _do_delete(context, "")


@when("dcproto I list children of the resource")
def step_dcproto_list_children(context: Context) -> None:
    context.dcproto_error = None
    context.dcproto_list_result = None
    context.dcproto_subprocess_calls = []

    if not hasattr(context, "dcproto_mock_result"):
        try:
            context.dcproto_list_result = context.dcproto_handler.list_children(
                resource=context.dcproto_resource,
            )
        except (ValueError, NotImplementedError) as exc:
            context.dcproto_error = exc
        return

    def _capture_run(*args: object, **kwargs: object) -> object:
        context.dcproto_subprocess_calls.append((args, kwargs))
        return context.dcproto_mock_result

    patcher = patch(
        "cleveragents.resource.handlers.devcontainer.subprocess.run",
        side_effect=_capture_run,
    )
    patcher.start()
    context.add_cleanup(patcher.stop)

    try:
        context.dcproto_list_result = context.dcproto_handler.list_children(
            resource=context.dcproto_resource,
        )
    except (ValueError, NotImplementedError) as exc:
        context.dcproto_error = exc


@when("dcproto I diff the resource against the temporary directory")
def step_dcproto_diff_against_temp(context: Context) -> None:
    context.dcproto_error = None
    context.dcproto_diff_result = None

    # Patch content_hash to return the configured fake hash for the container
    if hasattr(context, "dcproto_container_hash"):
        fake_hash = context.dcproto_container_hash

        def _fake_content_hash(resource: Resource, *, algorithm: str = "sha256") -> str:
            # Only intercept for the resource under test (not the other_location)
            if resource.location == context.dcproto_resource.location:
                return fake_hash
            # Fall through to real implementation for other_location
            from cleveragents.resource.handlers._base import BaseResourceHandler

            return BaseResourceHandler.content_hash(
                context.dcproto_handler, resource, algorithm=algorithm
            )

        patcher = patch.object(
            context.dcproto_handler,
            "content_hash",
            side_effect=_fake_content_hash,
        )
        patcher.start()
        context.add_cleanup(patcher.stop)

    try:
        context.dcproto_diff_result = context.dcproto_handler.diff(
            resource=context.dcproto_resource,
            other_location=context.dcproto_other_location,
        )
    except (ValueError, NotImplementedError) as exc:
        context.dcproto_error = exc


@when('dcproto I diff the resource against "/another/nonexistent/path"')
def step_dcproto_diff_both_absent(context: Context) -> None:
    context.dcproto_error = None
    context.dcproto_diff_result = None
    try:
        context.dcproto_diff_result = context.dcproto_handler.diff(
            resource=context.dcproto_resource,
            other_location="/another/nonexistent/path",
        )
    except (ValueError, NotImplementedError) as exc:
        context.dcproto_error = exc


@when('dcproto I diff the resource against "/some/path"')
def step_dcproto_diff_no_location(context: Context) -> None:
    context.dcproto_error = None
    context.dcproto_diff_result = None
    try:
        context.dcproto_diff_result = context.dcproto_handler.diff(
            resource=context.dcproto_resource,
            other_location="/some/path",
        )
    except (ValueError, NotImplementedError) as exc:
        context.dcproto_error = exc


@when("dcproto I diff the resource against the same location")
def step_dcproto_diff_same_location(context: Context) -> None:
    context.dcproto_error = None
    context.dcproto_diff_result = None
    # Use the resource's own location as other_location
    other = context.dcproto_resource.location or "/ws/project"
    try:
        context.dcproto_diff_result = context.dcproto_handler.diff(
            resource=context.dcproto_resource,
            other_location=other,
        )
    except (ValueError, NotImplementedError) as exc:
        context.dcproto_error = exc


@when('dcproto I create a sandbox for the resource with plan "{plan_id}"')
def step_dcproto_create_sandbox(context: Context, plan_id: str) -> None:
    context.dcproto_error = None
    context.dcproto_sandbox_result = None
    try:
        context.dcproto_sandbox_result = context.dcproto_handler.create_sandbox(
            resource=context.dcproto_resource,
            plan_id=plan_id,
            sandbox_manager=context.dcproto_sandbox_manager,
        )
    except (ValueError, RuntimeError, NotImplementedError) as exc:
        context.dcproto_error = exc


# ── Then steps ───────────────────────────────────────────────


@then("dcproto the delete should succeed")
def step_dcproto_delete_success(context: Context) -> None:
    assert context.dcproto_error is None, (
        f"Expected no error, got {context.dcproto_error!r}"
    )
    assert context.dcproto_delete_result is not None, "No delete result returned"
    assert context.dcproto_delete_result.success is True, (
        f"Expected success, got failure: {context.dcproto_delete_result.message}"
    )


@then('dcproto the delete message should contain "{text}"')
def step_dcproto_delete_msg_contains(context: Context, text: str) -> None:
    assert context.dcproto_delete_result is not None
    assert text in context.dcproto_delete_result.message, (
        f"Expected '{text}' in message: {context.dcproto_delete_result.message}"
    )


@then("dcproto the delete should fail")
def step_dcproto_delete_failure(context: Context) -> None:
    assert context.dcproto_error is None, (
        f"Unexpected exception: {context.dcproto_error!r}"
    )
    assert context.dcproto_delete_result is not None, "No delete result returned"
    assert context.dcproto_delete_result.success is False, (
        "Expected delete to fail but it succeeded"
    )


@then('dcproto the delete failure message should contain "{text}"')
def step_dcproto_delete_fail_msg(context: Context, text: str) -> None:
    assert context.dcproto_delete_result is not None
    assert context.dcproto_delete_result.success is False
    assert text in context.dcproto_delete_result.message, (
        f"Expected '{text}' in failure message: {context.dcproto_delete_result.message}"
    )


@then('dcproto the delete should raise ValueError containing "{text}"')
def step_dcproto_delete_value_error(context: Context, text: str) -> None:
    assert context.dcproto_error is not None, "Expected ValueError"
    assert isinstance(context.dcproto_error, ValueError), (
        f"Expected ValueError, got {type(context.dcproto_error).__name__}"
    )
    assert text in str(context.dcproto_error), (
        f"Expected '{text}' in error message: {context.dcproto_error}"
    )


@then('dcproto the subprocess should have been called with "{cmd}" and "{arg}"')
def step_dcproto_check_subprocess_args(context: Context, cmd: str, arg: str) -> None:
    assert len(context.dcproto_subprocess_calls) > 0, "No subprocess calls recorded"
    call_args, _call_kwargs = context.dcproto_subprocess_calls[0]
    cmd_list = call_args[0]
    assert cmd in cmd_list, f"Expected '{cmd}' in command {cmd_list}"
    assert arg in cmd_list, f"Expected '{arg}' in command {cmd_list}"


@then("dcproto the children list should have {count:d} entries")
def step_dcproto_children_count(context: Context, count: int) -> None:
    assert context.dcproto_error is None, (
        f"Expected no error, got {context.dcproto_error!r}"
    )
    assert context.dcproto_list_result is not None, "No list_children result"
    assert len(context.dcproto_list_result) == count, (
        f"Expected {count} children, got {len(context.dcproto_list_result)}: "
        f"{context.dcproto_list_result}"
    )


@then('dcproto the children list should be "{expected_csv}"')
def step_dcproto_children_list_values(context: Context, expected_csv: str) -> None:
    expected = expected_csv.split(",")
    assert context.dcproto_list_result == expected, (
        f"Expected {expected}, got {context.dcproto_list_result}"
    )


@then('dcproto the list children should raise ValueError containing "{text}"')
def step_dcproto_list_value_error(context: Context, text: str) -> None:
    assert context.dcproto_error is not None, "Expected ValueError"
    assert isinstance(context.dcproto_error, ValueError), (
        f"Expected ValueError, got {type(context.dcproto_error).__name__}"
    )
    assert text in str(context.dcproto_error), (
        f"Expected '{text}' in error message: {context.dcproto_error}"
    )


@then("dcproto the diff should report has_changes true")
def step_dcproto_diff_has_changes_true(context: Context) -> None:
    assert context.dcproto_error is None, (
        f"Expected no error, got {context.dcproto_error!r}"
    )
    assert context.dcproto_diff_result is not None, "No diff result returned"
    assert context.dcproto_diff_result.has_changes is True, (
        "Expected has_changes=True but got False"
    )


@then("dcproto the diff should report has_changes false")
def step_dcproto_diff_has_changes_false(context: Context) -> None:
    assert context.dcproto_error is None, (
        f"Expected no error, got {context.dcproto_error!r}"
    )
    assert context.dcproto_diff_result is not None, "No diff result returned"
    assert context.dcproto_diff_result.has_changes is False, (
        "Expected has_changes=False but got True"
    )


@then("dcproto the diff files_changed should be {count:d}")
def step_dcproto_diff_files_changed(context: Context, count: int) -> None:
    assert context.dcproto_diff_result is not None
    assert context.dcproto_diff_result.files_changed == count, (
        f"Expected files_changed={count}, got {context.dcproto_diff_result.files_changed}"
    )


@then("dcproto the diff unified_diff should be empty")
def step_dcproto_diff_unified_empty(context: Context) -> None:
    assert context.dcproto_diff_result is not None
    assert context.dcproto_diff_result.unified_diff == "", (
        f"Expected empty unified_diff, got: {context.dcproto_diff_result.unified_diff!r}"
    )


@then('dcproto the diff should raise ValueError containing "{text}"')
def step_dcproto_diff_value_error(context: Context, text: str) -> None:
    assert context.dcproto_error is not None, "Expected ValueError"
    assert isinstance(context.dcproto_error, ValueError), (
        f"Expected ValueError, got {type(context.dcproto_error).__name__}"
    )
    assert text in str(context.dcproto_error), (
        f"Expected '{text}' in error message: {context.dcproto_error}"
    )


@then('dcproto the sandbox result should have strategy "{strategy}"')
def step_dcproto_sandbox_strategy(context: Context, strategy: str) -> None:
    assert context.dcproto_error is None, (
        f"Expected no error, got {context.dcproto_error!r}"
    )
    assert context.dcproto_sandbox_result is not None, "No sandbox result returned"
    assert context.dcproto_sandbox_result.strategy == strategy, (
        f"Expected strategy '{strategy}', got '{context.dcproto_sandbox_result.strategy}'"
    )


@then("dcproto the sandbox result should have a sandbox path")
def step_dcproto_sandbox_has_path(context: Context) -> None:
    assert context.dcproto_sandbox_result is not None
    assert context.dcproto_sandbox_result.sandbox_path, (
        "Expected a non-empty sandbox_path"
    )


@then("dcproto activate_container should have been called")
def step_dcproto_activate_was_called(context: Context) -> None:
    assert context.dcproto_error is None, (
        f"Expected no error, got {context.dcproto_error!r}"
    )
    assert hasattr(context, "dcproto_activate_calls"), (
        "activate_container mock was not set up"
    )
    assert len(context.dcproto_activate_calls) > 0, (
        "Expected activate_container to be called but it was not"
    )


@then('dcproto the create_sandbox should raise ValueError containing "{text}"')
def step_dcproto_sandbox_value_error(context: Context, text: str) -> None:
    assert context.dcproto_error is not None, "Expected ValueError"
    assert isinstance(context.dcproto_error, ValueError), (
        f"Expected ValueError, got {type(context.dcproto_error).__name__}"
    )
    assert text in str(context.dcproto_error), (
        f"Expected '{text}' in error message: {context.dcproto_error}"
    )
