"""Step definitions for devcontainer health check and concurrency operations.

Covers: single health probe, health check loop iterations, start/stop
health check threads, thread registration and join, probe workspace
fallback, health check argument validation, concurrent activation,
and concurrent stop.

All mocks are in ``features/mocks/mock_devcontainer_cli.py``.
"""

from __future__ import annotations

import threading
import time
from typing import Any, cast
from unittest.mock import MagicMock
from unittest.mock import patch as _patch

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

from cleveragents.domain.models.core.container_lifecycle import (
    ContainerLifecycleState,
    ContainerLifecycleTracker,
)
from cleveragents.resource.handlers import devcontainer as _dc_mod
from cleveragents.resource.handlers.devcontainer import (
    DevcontainerHandler,
    _health_check_loop,
    _health_check_stop_events,
    _health_check_threads,
    _single_probe,
    _stop_health_check,
    activate_container,
    clear_lifecycle_registry,
    get_lifecycle_tracker,
    set_lifecycle_tracker,
    start_health_check,
    stop_container,
)

# Import _OneShotEvent from the core lifecycle steps module to avoid
# duplicating the helper class.
from features.steps.devcontainer_lifecycle_steps import _OneShotEvent

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


@given("the runner configured for failed health check")
def step_configure_hc_failure(context: Context) -> None:
    context.mock_runner.set_exec_failure()


@given("the runner configured for successful health check")
def step_configure_hc_success(context: Context) -> None:
    context.mock_runner.set_exec_result(returncode=0, stdout="ping")


@given("the runner configured to raise exception on exec")
def step_configure_exec_exception(context: Context) -> None:
    """Replace mock_runner with a wrapper that raises on exec."""
    inner = context.mock_runner

    def raising_runner(args: list[str], **kwargs: object) -> object:
        inner.calls.append((list(args), dict(kwargs)))
        if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "exec":
            raise OSError("connection refused")
        return inner(args, **kwargs)

    context.mock_runner = cast(Any, raising_runner)


@given('a registered health check for "{resource_id}"')
def step_register_health_check(context: Context, resource_id: str) -> None:
    """Register a real (but idle) health check thread so _stop_health_check
    can join it and we can verify the full thread-join path (F9 fix).
    """
    stop_event = threading.Event()
    _health_check_stop_events[resource_id] = stop_event

    # F9 fix: use a real short-lived thread instead of current_thread()
    # so _stop_health_check actually calls thread.join() rather than
    # skipping the join due to the ``is not current_thread()`` guard.
    def _idle_loop() -> None:
        stop_event.wait(timeout=5.0)

    thread = threading.Thread(target=_idle_loop, daemon=True)
    _health_check_threads[resource_id] = thread
    thread.start()


@given('a running health check for "{resource_id}"')
def step_start_health_check(context: Context, resource_id: str) -> None:
    """Start a health check thread that we can later verify was joined."""
    context.mock_runner.set_exec_result(returncode=0, stdout="ping")
    start_health_check(resource_id, interval=0.1, run_command=context.mock_runner)
    # Give it a moment to start
    time.sleep(0.05)


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


@when('I run a single health check probe for "{resource_id}"')
def step_single_health_probe(context: Context, resource_id: str) -> None:
    """Run a single health check probe via the production _health_check_loop.

    Uses the shared _OneShotEvent to run exactly one probe iteration
    through the real production code path, avoiding reimplementation
    of transition logic in the test step.
    """
    _health_check_loop(
        resource_id,
        interval=0.0,
        stop_event=cast(threading.Event, _OneShotEvent()),
        run_command=context.mock_runner,
    )


@when('I run one iteration of the health check loop for "{resource_id}"')
def step_run_health_check_loop_once(
    context: Context,
    resource_id: str,
) -> None:
    """Run _health_check_loop with the shared _OneShotEvent."""
    context.hc_resource_id = resource_id

    _health_check_loop(
        resource_id,
        interval=0.0,
        stop_event=cast(threading.Event, _OneShotEvent()),
        run_command=context.mock_runner,
    )


@when('I call start_health_check for "{resource_id}"')
def step_call_start_health_check(context: Context, resource_id: str) -> None:
    """Call start_health_check and immediately stop the thread."""
    start_health_check(
        resource_id,
        interval=60.0,  # long interval — we stop it immediately
        run_command=context.mock_runner,
    )
    context.hc_resource_id = resource_id
    # Give thread just enough time to register
    time.sleep(0.05)


@when('I stop the health check for "{resource_id}"')
def step_stop_health_check(context: Context, resource_id: str) -> None:
    _stop_health_check(resource_id)
    context.health_check_resource_id = resource_id


@when("I attempt to start health check with empty resource_id")
def step_start_hc_empty_id(context: Context) -> None:
    context.hc_error = None
    try:
        start_health_check("")
    except ValueError as exc:
        context.hc_error = exc


@when("I run a single probe with no workspace_path set")
def step_probe_no_workspace(context: Context) -> None:
    """Run _single_probe with a tracker that has workspace_path=None."""
    tracker = ContainerLifecycleTracker(
        resource_id="01TESTLIFECYCLE0000000320",
        current_state=ContainerLifecycleState.RUNNING,
        container_id="ctr-t3",
        workspace_path=None,
    )
    set_lifecycle_tracker(tracker)
    context.mock_runner.set_exec_result(returncode=0, stdout="ping")
    _single_probe(tracker, context.mock_runner)
    context.probe_calls = context.mock_runner.exec_calls


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


@then('the health check should have probed "{resource_id}"')
def step_check_health_probe(context: Context, resource_id: str) -> None:
    exec_calls = context.mock_runner.exec_calls
    assert len(exec_calls) > 0, "Expected at least one exec call from health check"


@then('a health check thread should be registered for "{resource_id}"')
def step_check_thread_registered(context: Context, resource_id: str) -> None:
    assert resource_id in _health_check_threads, (
        f"Thread not registered for {resource_id}"
    )
    assert resource_id in _health_check_stop_events, (
        f"Stop event not registered for {resource_id}"
    )
    # Clean up — stop the thread
    clear_lifecycle_registry()


@then('the health check for "{resource_id}" should be unregistered')
def step_check_hc_unregistered(context: Context, resource_id: str) -> None:
    assert resource_id not in _health_check_stop_events, (
        f"Stop event still registered for {resource_id}"
    )
    assert resource_id not in _health_check_threads, (
        f"Thread still registered for {resource_id}"
    )


@then("the health check should raise ValueError")
def step_check_hc_value_error(context: Context) -> None:
    assert context.hc_error is not None
    assert isinstance(context.hc_error, ValueError)


@then("the health check thread should have been joined")
def step_check_thread_joined(context: Context) -> None:
    rid = context.health_check_resource_id
    # After _stop_health_check, thread should have been removed from registry
    assert rid not in _health_check_threads, (
        f"Thread for {rid} still in _health_check_threads after stop"
    )
    assert rid not in _health_check_stop_events, (
        f"Stop event for {rid} still in _health_check_stop_events after stop"
    )


@then('the probe command should use "{path}" as workspace folder')
def step_check_probe_workspace(context: Context, path: str) -> None:
    assert len(context.probe_calls) >= 1, "Expected at least one exec call"
    args, _ = context.probe_calls[-1]
    # args: ["devcontainer", "exec", "--workspace-folder", path, "echo", "ping"]
    ws_idx = args.index("--workspace-folder") + 1
    assert args[ws_idx] == path, f"Expected workspace '{path}', got '{args[ws_idx]}'"


# ── Concurrent operation steps ───────────────────────────────


@when("I activate the container concurrently from two threads")
def step_concurrent_activation(context: Context) -> None:
    """Launch two threads both trying to activate the same resource.

    TEST-1 fix: use ``threading.Barrier(2)`` so both threads reach the
    activation call at the same time, preventing the first thread from
    completing before the second starts.
    """
    resource_id = context.resource_id
    results: list[tuple[str, object]] = []
    lock = threading.Lock()
    barrier = threading.Barrier(2, timeout=5.0)

    def _activate(label: str) -> None:
        try:
            barrier.wait()
            activate_container(
                resource_id,
                "/workspace",
                run_command=context.mock_runner,
            )
            with lock:
                results.append((label, "ok"))
        except (ValueError, RuntimeError) as exc:
            with lock:
                results.append((label, exc))

    t1 = threading.Thread(target=_activate, args=("t1",))
    t2 = threading.Thread(target=_activate, args=("t2",))
    t1.start()
    t2.start()
    t1.join(timeout=5.0)
    t2.join(timeout=5.0)

    context.concurrent_results = results


@when("I stop the container concurrently from two threads")
def step_concurrent_stop(context: Context) -> None:
    """Launch two threads both trying to stop the same resource.

    TEST-1 fix: use ``threading.Barrier(2)`` so both threads reach the
    stop call at the same time, preventing sequential execution.
    """
    resource_id = "01TESTLIFECYCLE0000000390"
    results: list[tuple[str, object]] = []
    lock = threading.Lock()
    barrier = threading.Barrier(2, timeout=5.0)

    def _stop(label: str) -> None:
        try:
            barrier.wait()
            stop_container(
                resource_id,
                run_command=context.mock_runner,
            )
            with lock:
                results.append((label, "ok"))
        except (ValueError, RuntimeError) as exc:
            with lock:
                results.append((label, exc))

    t1 = threading.Thread(target=_stop, args=("t1",))
    t2 = threading.Thread(target=_stop, args=("t2",))
    t1.start()
    t2.start()
    t1.join(timeout=5.0)
    t2.join(timeout=5.0)

    context.concurrent_stop_results = results


@then("exactly one activation should succeed")
def step_one_success(context: Context) -> None:
    successes = [r for r in context.concurrent_results if r[1] == "ok"]
    assert len(successes) == 1, (
        f"Expected exactly 1 success, got {len(successes)}: {context.concurrent_results}"
    )


@then("exactly one activation should fail with ValueError")
def step_one_failure(context: Context) -> None:
    failures = [r for r in context.concurrent_results if isinstance(r[1], ValueError)]
    assert len(failures) == 1, (
        f"Expected exactly 1 ValueError, got {len(failures)}: {context.concurrent_results}"
    )


@then("both stops should succeed")
def step_both_stops_succeed(context: Context) -> None:
    # R8-F2 fix: stop_container is now idempotent — the second caller
    # sees a terminal state and returns early instead of raising.
    successes = [r for r in context.concurrent_stop_results if r[1] == "ok"]
    assert len(successes) == 2, (
        f"Expected 2 stop successes (idempotent), got {len(successes)}: "
        f"{context.concurrent_stop_results}"
    )


@then('the container state for "{resource_id}" should not be "{state}"')
def step_check_state_not(context: Context, resource_id: str, state: str) -> None:
    tracker = get_lifecycle_tracker(resource_id)
    assert tracker.current_state != ContainerLifecycleState(state), (
        f"Expected state NOT to be {state}, but got {tracker.current_state.value}"
    )


@then("exactly {count:d} docker stop call should have been made")
def step_check_docker_stop_count(context: Context, count: int) -> None:
    actual = len(context.mock_runner.stop_calls)
    assert actual == count, (
        f"Expected exactly {count} docker stop call(s), got {actual}"
    )


# ── F16: resolve() lazy activation steps ─────────────────────


@when('I resolve a devcontainer-instance "{resource_id}" at "{location}"')
def step_resolve_devcontainer(
    context: Context, resource_id: str, location: str
) -> None:
    """Call DevcontainerHandler.resolve() with mocked Resource and SandboxManager.

    The handler's resolve() triggers activate_container() for
    non-running devcontainer-instance resources (F16 fix), then
    delegates to super().resolve() which needs a SandboxManager.
    We patch activate_container with a side_effect that injects
    the mock runner, so the real activation logic executes.
    """
    handler = DevcontainerHandler()
    mock_resource = MagicMock()
    mock_resource.resource_id = resource_id
    mock_resource.resource_type_name = "devcontainer-instance"
    mock_resource.location = location
    mock_resource.sandbox_strategy = None

    mock_sandbox_mgr = MagicMock()
    mock_sandbox_ctx = MagicMock()
    mock_sandbox_ctx.sandbox_path = "/tmp/sandbox"
    mock_sandbox = MagicMock()
    mock_sandbox.context = mock_sandbox_ctx
    mock_sandbox_mgr.get_or_create_sandbox.return_value = mock_sandbox

    real_activate = _dc_mod.activate_container

    def _activate_with_mock(
        rid: str,
        ws: str,
        session_id: str | None = None,
        **kwargs: object,
    ) -> object:
        return real_activate(
            rid, ws, run_command=context.mock_runner, session_id=session_id
        )

    context.resolve_error = None
    with _patch.object(
        _dc_mod, "activate_container", side_effect=_activate_with_mock
    ) as mock_activate:
        try:
            context.bound_resource = handler.resolve(
                resource=mock_resource,
                plan_id="plan-001",
                slot_name="workspace",
                sandbox_manager=mock_sandbox_mgr,
            )
        except (RuntimeError, ValueError) as exc:
            context.resolve_error = exc
        context.resolve_activate_mock = mock_activate
