"""Devcontainer health checking for CleverAgents.

Background health-check threads that periodically probe running
devcontainers via ``devcontainer exec echo ping``.  When a probe
fails the container is transitioned to the ``failed`` state.

Extracted from ``devcontainer.py`` to comply with the 500-line file
limit (F28 fix).

Based on:
    - docs/specification.md -- Devcontainer, Resource Types
    - Issue #514: Devcontainer lifecycle management
"""

from __future__ import annotations

import logging
import subprocess
import threading
from typing import Any

from cleveragents.domain.models.core.container_lifecycle import (
    ContainerLifecycleState,
    ContainerLifecycleTracker,
    transition_state,
)
from cleveragents.resource.handlers._devcontainer_internals import (
    _health_check_stop_events,
    _health_check_threads,
    _registry_lock,
    get_lifecycle_tracker,
    set_lifecycle_tracker,
)

logger = logging.getLogger(__name__)


def start_health_check(
    resource_id: str,
    *,
    interval: float = 30.0,
    run_command: Any = None,
) -> None:
    """Start a background health check thread for an active container.

    Args:
        resource_id: ULID of the resource.
        interval: Seconds between probes.
        run_command: Optional callable for testing.
    """
    if not resource_id:
        raise ValueError("resource_id must not be empty")

    _stop_health_check(resource_id)

    stop_event = threading.Event()
    thread = threading.Thread(
        target=_health_check_loop,
        args=(resource_id, interval, stop_event, run_command),
        daemon=True,
        name=f"health-check-{resource_id[:8]}",
    )

    # R2 fix: protect dict writes with _registry_lock
    with _registry_lock:
        _health_check_stop_events[resource_id] = stop_event
        _health_check_threads[resource_id] = thread

    thread.start()


def _stop_health_check(resource_id: str) -> None:
    """Signal and remove a health check thread.

    Signals the stop event, then joins the thread with a 2 s timeout
    to avoid leaving dangling background work (R1 fix).
    """
    # R2 fix: protect dict access with _registry_lock
    with _registry_lock:
        stop_event = _health_check_stop_events.pop(resource_id, None)
        thread = _health_check_threads.pop(resource_id, None)
    if stop_event is not None:
        stop_event.set()
    # R1 fix: join thread to avoid racing with stop_container
    if thread is not None and thread is not threading.current_thread():
        thread.join(timeout=2.0)


def _single_probe(
    tracker: ContainerLifecycleTracker,
    runner: Any,
) -> bool:
    """Execute a single health check probe against a container.

    Args:
        tracker: Current lifecycle tracker (must be in ``running`` state).
        runner: Callable matching ``subprocess.run`` signature.

    Returns:
        ``True`` if the probe succeeded (returncode == 0), ``False`` otherwise.

    Raises:
        Exception: Re-raised from the subprocess call on unexpected errors.
    """
    # F15 fix: use host_workspace_path for devcontainer exec
    # (--workspace-folder expects the host-side path, not the
    # remote/container-side workspace_path).
    # SEC-2 fix: warn when the path is None and the fallback is used.
    probe_workspace = tracker.host_workspace_path
    if probe_workspace is None:
        logger.warning(
            "Health probe for %s: host_workspace_path is None, "
            "falling back to /workspace — this may target the wrong container",
            tracker.resource_id,
        )
        probe_workspace = "/workspace"
    result = runner(
        [
            "devcontainer",
            "exec",
            "--workspace-folder",
            probe_workspace,
            "echo",
            "ping",
        ],
        capture_output=True,
        text=True,
        timeout=10,
        check=False,
    )
    return result.returncode == 0


def _health_check_loop(
    resource_id: str,
    interval: float,
    stop_event: threading.Event,
    run_command: Any,
) -> None:
    """Run periodic health probes until stopped."""
    runner = run_command or subprocess.run
    while not stop_event.is_set():
        stop_event.wait(interval)
        if stop_event.is_set():
            break
        tracker = get_lifecycle_tracker(resource_id)
        if tracker.current_state != ContainerLifecycleState.RUNNING:
            break
        try:
            healthy = _single_probe(tracker, runner)
            if not healthy:
                # R11 fix: hold lock across re-read + transition to
                # close TOCTOU gap with concurrent stop_container.
                with _registry_lock:
                    tracker = get_lifecycle_tracker(resource_id)
                    if tracker.current_state != ContainerLifecycleState.RUNNING:
                        break
                    try:
                        tracker = transition_state(
                            tracker,
                            ContainerLifecycleState.STOPPING,
                            reason="health check failed",
                        )
                        set_lifecycle_tracker(tracker)
                        tracker = transition_state(
                            tracker,
                            ContainerLifecycleState.FAILED,
                            reason="health check failed",
                        )
                        set_lifecycle_tracker(tracker)
                    except ValueError:
                        # R19 fix: log swallowed ValueError for observability
                        logger.debug(
                            "Health check transition skipped for %s: "
                            "state already changed by concurrent operation",
                            resource_id,
                        )
                break
        except Exception as exc:
            logger.warning("Health check error for %s: %s", resource_id, exc)
            # R11 fix: hold lock across re-read + transition
            with _registry_lock:
                tracker = get_lifecycle_tracker(resource_id)
                if tracker.current_state != ContainerLifecycleState.RUNNING:
                    break
                try:
                    tracker = transition_state(
                        tracker,
                        ContainerLifecycleState.STOPPING,
                        reason=f"health check error: {exc}",
                    )
                    set_lifecycle_tracker(tracker)
                    tracker = transition_state(
                        tracker,
                        ContainerLifecycleState.FAILED,
                        reason=f"health check error: {exc}",
                    )
                    set_lifecycle_tracker(tracker)
                except ValueError:
                    # R19 fix: log swallowed ValueError for observability
                    logger.debug(
                        "Health check transition skipped for %s: "
                        "state already changed by concurrent operation",
                        resource_id,
                    )
            break
