"""Devcontainer lifecycle operations for CleverAgents.

Core lifecycle transitions for ``devcontainer-instance`` resources:
activation (``devcontainer up``), stop (``docker stop``), and rebuild
(``devcontainer up --reset-container``).

Extracted from ``devcontainer.py`` to comply with the 500-line file
limit (F28 fix).  The ``devcontainer`` module re-exports all public
symbols from this module for backward compatibility.

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

from __future__ import annotations

import logging
import os
import subprocess
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,
    _lifecycle_registry,
    _parse_devcontainer_up_output,
    _registry_lock,
    get_lifecycle_tracker,
    set_lifecycle_tracker,
)
from cleveragents.resource.handlers.devcontainer_health import (
    _stop_health_check,
    start_health_check,
)

logger = logging.getLogger(__name__)


# ── Registry management ──────────────────────────────────────────


def clear_lifecycle_registry() -> None:
    """Clear all lifecycle trackers and stop health checks.

    Signals all health check threads to stop, waits for them to
    finish (up to 2 s each), then clears the registry.

    Intended for testing and cleanup.
    """
    with _registry_lock:
        # Signal all health check threads to stop
        for resource_id in list(_health_check_stop_events):
            _health_check_stop_events[resource_id].set()
        # Join threads with a timeout to avoid dangling background work
        threads_to_join = list(_health_check_threads.values())
        _health_check_threads.clear()
        _health_check_stop_events.clear()
        _lifecycle_registry.clear()
    # Join outside the lock to avoid blocking registry operations
    for thread in threads_to_join:
        thread.join(timeout=2.0)


# ── Activation ───────────────────────────────────────────────────


def activate_container(
    resource_id: str,
    workspace_folder: str,
    *,
    run_command: Any = None,
    session_id: str | None = None,
    rebuild: bool = False,
) -> ContainerLifecycleTracker:
    """Lazily activate a devcontainer by running ``devcontainer up``.

    Transitions: ``detected`` / ``stopped`` / ``failed`` → ``building`` → ``running``.

    Args:
        resource_id: ULID of the devcontainer resource.
        workspace_folder: Path to the workspace containing devcontainer config.
        run_command: Optional callable replacing subprocess.run for testing.
        session_id: Optional session/plan ID to associate with this container
            for scoped cleanup.
        rebuild: When ``True``, pass ``--reset-container`` to
            ``devcontainer up`` to force container recreation (F10 fix).

    Returns:
        Updated lifecycle tracker in ``running`` state.

    Raises:
        ValueError: If the resource is already running or in an
            invalid state for activation.
        RuntimeError: If ``devcontainer up`` fails or container_id
            cannot be extracted from the output.
    """
    if not resource_id:
        raise ValueError("resource_id must not be empty")
    if not workspace_folder:
        raise ValueError("workspace_folder must not be empty")
    if not os.path.isabs(workspace_folder):
        raise ValueError("workspace_folder must be an absolute path")
    # SEC-1 fix: canonicalize to resolve symlinks and '..' components,
    # preventing path-traversal via inputs like /tmp/../etc/devcontainer.json.
    workspace_folder = os.path.realpath(workspace_folder)

    # R3 fix: hold lock across get→transition→set to prevent
    # duplicate activations from concurrent callers.
    with _registry_lock:
        tracker = get_lifecycle_tracker(resource_id)
        # R7-F1 fix: store session_id on tracker for scoped cleanup.
        # R8-F4 fix: use model_copy to avoid fragile manual reconstruction.
        if session_id and not tracker.session_id:
            tracker = tracker.model_copy(update={"session_id": session_id})
            set_lifecycle_tracker(tracker)
        tracker = transition_state(
            tracker,
            ContainerLifecycleState.BUILDING,
            reason="lazy activation",
        )
        set_lifecycle_tracker(tracker)

    runner = run_command or subprocess.run
    try:
        # F10 fix: when rebuild=True, pass --reset-container to force
        # container recreation, differentiating rebuild from re-activate.
        cmd = [
            "devcontainer",
            "up",
            "--workspace-folder",
            workspace_folder,
        ]
        if rebuild:
            cmd.append("--reset-container")
        result = runner(
            cmd,
            capture_output=True,
            text=True,
            timeout=300,
            check=False,
        )

        if result.returncode != 0:
            with _registry_lock:
                tracker = get_lifecycle_tracker(resource_id)
                tracker = transition_state(
                    tracker,
                    ContainerLifecycleState.FAILED,
                    reason=f"devcontainer up failed (rc={result.returncode})",
                )
                set_lifecycle_tracker(tracker)
            raise RuntimeError(
                f"devcontainer up failed (rc={result.returncode}): {result.stderr}"
            )

        # Parse JSON output
        container_id, ws_path = _parse_devcontainer_up_output(result.stdout)

        # Finding 10 fix: treat missing container_id as activation failure
        if not container_id:
            with _registry_lock:
                tracker = get_lifecycle_tracker(resource_id)
                tracker = transition_state(
                    tracker,
                    ContainerLifecycleState.FAILED,
                    reason="devcontainer up returned no container ID",
                )
                set_lifecycle_tracker(tracker)
            raise RuntimeError(
                "devcontainer up succeeded but output contained no containerId"
            )

        # R7-F6 fix: re-read tracker from registry to avoid stale reference.
        # R8-F4 fix: use model_copy to avoid fragile manual reconstruction.
        # F15 fix: store both the remote workspace path (container-side)
        # and the host workspace folder.
        with _registry_lock:
            tracker = get_lifecycle_tracker(resource_id)
            tracker = tracker.model_copy(
                update={
                    "container_id": container_id,
                    "workspace_path": ws_path,
                    "host_workspace_path": workspace_folder,
                },
            )
            tracker = transition_state(
                tracker,
                ContainerLifecycleState.RUNNING,
                reason="devcontainer up succeeded",
            )
            set_lifecycle_tracker(tracker)

        # R7-F3 fix: start health checking now that container is running.
        start_health_check(resource_id, run_command=run_command)

    except RuntimeError:
        raise
    except ValueError:
        raise
    except subprocess.TimeoutExpired as exc:
        _handle_activation_timeout(resource_id, workspace_folder, run_command, exc)
    except Exception as exc:
        with _registry_lock:
            tracker = get_lifecycle_tracker(resource_id)
            tracker = transition_state(
                tracker,
                ContainerLifecycleState.FAILED,
                reason=f"activation error: {exc}",
            )
            set_lifecycle_tracker(tracker)
        raise RuntimeError(f"Failed to activate container: {exc}") from exc

    return tracker


def _handle_activation_timeout(
    resource_id: str,
    workspace_folder: str,
    run_command: Any,
    exc: subprocess.TimeoutExpired,
) -> None:
    """Handle activation timeout by cleaning up orphaned containers.

    Extracted from :func:`activate_container` to reduce method length.

    Args:
        resource_id: ULID of the devcontainer resource.
        workspace_folder: Canonicalized workspace folder path.
        run_command: Optional callable replacing subprocess.run.
        exc: The original TimeoutExpired exception.

    Raises:
        RuntimeError: Always raised after cleanup attempt.
    """
    # S2 fix: attempt to clean up orphaned containers on timeout
    logger.warning(
        "devcontainer up timed out for %s — attempting cleanup",
        resource_id,
    )
    cleanup_runner = run_command or subprocess.run
    try:
        ps_result = cleanup_runner(
            [
                "docker",
                "ps",
                "-q",
                "--filter",
                f"label=devcontainer.local_folder={workspace_folder}",
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        orphan_ids = [
            cid.strip()
            for cid in getattr(ps_result, "stdout", "").splitlines()
            if cid.strip()
        ]
        for cid in orphan_ids:
            try:
                cleanup_runner(
                    ["docker", "stop", cid],
                    capture_output=True,
                    text=True,
                    timeout=30,
                    check=False,
                )
                logger.info("Stopped orphaned container %s for %s", cid, resource_id)
            except Exception:
                logger.debug("Failed to stop orphaned container %s", cid)
    except Exception:
        logger.debug("Orphaned container cleanup lookup failed for %s", resource_id)
    with _registry_lock:
        tracker = get_lifecycle_tracker(resource_id)
        tracker = transition_state(
            tracker,
            ContainerLifecycleState.FAILED,
            reason=f"activation timed out after {exc.timeout}s",
        )
        set_lifecycle_tracker(tracker)
    raise RuntimeError(f"devcontainer up timed out after {exc.timeout}s") from exc


# ── Stop ─────────────────────────────────────────────────────────


def stop_container(
    resource_id: str,
    *,
    run_command: Any = None,
) -> ContainerLifecycleTracker:
    """Stop a running devcontainer.

    Transitions: ``running`` → ``stopping`` → ``stopped``.

    Args:
        resource_id: ULID of the devcontainer resource.
        run_command: Optional callable replacing subprocess.run for testing.

    Returns:
        Updated tracker in ``stopped`` state.

    Raises:
        ValueError: If the container is not in ``running`` state.
        RuntimeError: If stop fails.
    """
    if not resource_id:
        raise ValueError("resource_id must not be empty")

    # Stop health check BEFORE acquiring the lock to avoid deadlock:
    # the health check thread may be waiting on _registry_lock inside
    # get_lifecycle_tracker, and joining it while holding the lock
    # would deadlock.
    _stop_health_check(resource_id)

    # R3 fix: hold lock across get→transition→set to prevent
    # concurrent stop calls from racing.
    # R8-F2 fix: if the health check thread already transitioned
    # the container to a terminal state, return early.
    with _registry_lock:
        tracker = get_lifecycle_tracker(resource_id)
        if tracker.current_state in (
            ContainerLifecycleState.STOPPED,
            ContainerLifecycleState.FAILED,
        ):
            logger.info(
                "Container %s already in terminal state '%s'; skipping stop.",
                resource_id,
                tracker.current_state.value,
            )
            return tracker
        tracker = transition_state(
            tracker,
            ContainerLifecycleState.STOPPING,
            reason="manual stop",
        )
        set_lifecycle_tracker(tracker)

    runner = run_command or subprocess.run
    try:
        container_id = tracker.container_id
        if container_id:
            result = runner(
                ["docker", "stop", container_id],
                capture_output=True,
                text=True,
                timeout=60,
                check=False,
            )
            if result.returncode != 0:
                with _registry_lock:
                    tracker = get_lifecycle_tracker(resource_id)
                    tracker = transition_state(
                        tracker,
                        ContainerLifecycleState.FAILED,
                        reason=f"docker stop failed (rc={result.returncode})",
                    )
                    set_lifecycle_tracker(tracker)
                raise RuntimeError(
                    f"docker stop failed (rc={result.returncode}): "
                    f"{getattr(result, 'stderr', '')}"
                )

        with _registry_lock:
            tracker = get_lifecycle_tracker(resource_id)
            tracker = transition_state(
                tracker,
                ContainerLifecycleState.STOPPED,
                reason="container stopped",
            )
            set_lifecycle_tracker(tracker)

    except RuntimeError:
        raise
    except Exception as exc:
        with _registry_lock:
            tracker = get_lifecycle_tracker(resource_id)
            tracker = transition_state(
                tracker,
                ContainerLifecycleState.FAILED,
                reason=f"stop failed: {exc}",
            )
            set_lifecycle_tracker(tracker)
        raise RuntimeError(f"Failed to stop container: {exc}") from exc

    return tracker


# ── Rebuild ──────────────────────────────────────────────────────


def rebuild_container(
    resource_id: str,
    workspace_folder: str,
    *,
    run_command: Any = None,
    session_id: str | None = None,
) -> ContainerLifecycleTracker:
    """Rebuild a stopped or failed devcontainer.

    Transitions: ``stopped``/``failed`` → ``building`` → ``running``.

    Unlike :func:`activate_container`, rebuild passes
    ``--reset-container`` to ``devcontainer up`` to force container
    recreation (F10 fix).

    Args:
        resource_id: ULID of the resource.
        workspace_folder: Path to workspace with devcontainer config.
        run_command: Optional callable for testing.
        session_id: Optional session/plan ID to associate with the
            rebuilt container (BUG-5 fix: forwarded to activate_container).

    Returns:
        Updated tracker in ``running`` state.
    """
    if not resource_id:
        raise ValueError("resource_id must not be empty")
    if not workspace_folder:
        raise ValueError("workspace_folder must not be empty")

    # BUG-5 fix: forward session_id so the rebuilt container is
    # associated with the calling session for scoped cleanup.
    # F10 fix: pass rebuild=True to add --reset-container flag.
    return activate_container(
        resource_id,
        workspace_folder,
        run_command=run_command,
        session_id=session_id,
        rebuild=True,
    )
