"""Devcontainer resource handler for CleverAgents.

Resolves ``devcontainer-instance`` and ``devcontainer-file`` resources
into sandbox-backed :class:`BoundResource` instances using the ``none``
sandbox strategy (the container itself provides isolation — F22/F25 fix).

The handler:

1. For ``devcontainer-instance``: triggers lazy activation via
   ``activate_container()`` when the resource is in a non-running
   state (F16 fix).
2. Validates that the resource has a non-empty ``location``.
3. Determines the sandbox strategy: resource-level override takes
   precedence over the default ``none``.
4. Calls :meth:`SandboxManager.get_or_create_sandbox` to provision
   (or reuse) the sandbox.
5. Returns a :class:`BoundResource` with ``sandbox_path`` set to the
   sandbox root.

## Lifecycle Management (issue #514)

Devcontainer-instance resources support a full lifecycle:

- **Lazy activation**: containers start on first tool invocation.
- **Health checking**: periodic liveness probes via ``devcontainer exec``.
- **Manual stop / rebuild**: CLI commands for explicit control.

Implementation is split across focused modules to comply with the
500-line file limit (F28 fix):

- ``_devcontainer_internals`` — shared registry state, accessors, parsing
- ``devcontainer_health`` — background health check threads
- ``devcontainer_lifecycle`` — activate / stop / rebuild operations
- ``devcontainer_cleanup`` — session cleanup and tracker eviction

This module re-exports all public (and selected private) symbols from
the sub-modules so that existing import paths continue to work.

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

from __future__ import annotations

import hashlib
import logging
import os
import subprocess

from cleveragents.domain.models.core.container_lifecycle import (
    ContainerLifecycleState,
)
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.resource.handlers._base import (
    EMPTY_CONTENT_HASH,
    BaseResourceHandler,
)
from cleveragents.resource.handlers._devcontainer_internals import (
    _CONTAINER_ID_PATTERN,
    _MAX_TERMINAL_TRACKERS,
    DEFAULT_HEALTH_CHECK_INTERVAL,
    _health_check_stop_events,
    _health_check_threads,
    _lifecycle_registry,
    _parse_devcontainer_up_output,
    _registry_lock,
    get_lifecycle_tracker,
    list_active_containers,
    set_lifecycle_tracker,
)
from cleveragents.resource.handlers.devcontainer_cleanup import (
    evict_terminal_trackers,
    list_active_containers_for_session,
    stop_all_active_containers,
)
from cleveragents.resource.handlers.devcontainer_health import (
    _health_check_loop,
    _single_probe,
    _stop_health_check,
    start_health_check,
)
from cleveragents.resource.handlers.devcontainer_lifecycle import (
    activate_container,
    clear_lifecycle_registry,
    rebuild_container,
    stop_container,
)
from cleveragents.resource.handlers.protocol import (
    Content,
    DeleteResult,
    DiffResult,
    SandboxResult,
    WriteResult,
)
from cleveragents.tool.context import BoundResource

logger = logging.getLogger(__name__)

# Re-exported symbols for backward compatibility — keep in sync with
# the sub-modules.  These names are consumed by step files, Robot
# helpers, benchmarks, and application services via
# ``from cleveragents.resource.handlers.devcontainer import <name>``.
__all__ = [
    "DEFAULT_HEALTH_CHECK_INTERVAL",
    "_CONTAINER_ID_PATTERN",
    "_MAX_TERMINAL_TRACKERS",
    "DevcontainerHandler",
    "_health_check_loop",
    "_health_check_stop_events",
    "_health_check_threads",
    "_lifecycle_registry",
    "_parse_devcontainer_up_output",
    "_registry_lock",
    "_single_probe",
    "_stop_health_check",
    "activate_container",
    "clear_lifecycle_registry",
    "evict_terminal_trackers",
    "get_lifecycle_tracker",
    "list_active_containers",
    "list_active_containers_for_session",
    "rebuild_container",
    "set_lifecycle_tracker",
    "start_health_check",
    "stop_all_active_containers",
    "stop_container",
]


class DevcontainerHandler(BaseResourceHandler):
    """Handler for ``devcontainer-instance`` and ``devcontainer-file`` types.

    ``devcontainer-instance`` inherits from ``container-instance``
    per ADR-042 (resource type inheritance).

    Supports lazy activation: when a tool targets a detected
    devcontainer-instance, the handler transitions to ``building``
    and invokes ``devcontainer up`` before resolving the sandbox.

    F22/F25 fix: uses ``none`` sandbox strategy instead of ``snapshot``
    because the container itself provides isolation and
    ``SandboxFactory`` has not yet implemented the ``snapshot``
    strategy.  ``devcontainer-file`` resources also use ``none``
    (read-only config file).
    """

    # F22/F25 fix: SNAPSHOT raises NotImplementedError in SandboxFactory.
    # The container IS the sandbox for devcontainer-instance, so NONE is
    # semantically correct until a dedicated container-snapshot strategy
    # is implemented.
    _default_strategy = SandboxStrategy.NONE
    _type_label = "devcontainer"

    def content_hash(
        self,
        resource: Resource,
        *,
        algorithm: str = "sha256",
    ) -> str:
        """Compute content hash for devcontainer resources.

        Strategy (in priority order):

        1. **Exec hash** — If a running container ID is available
           (from the lifecycle tracker), executes
           ``docker exec <id> find /workspaces -type f -exec sha256sum {} +``
           inside the container and hashes the output.
        2. **Config file hash** — Falls back to hashing the
           devcontainer JSON config file.  Checks
           ``.devcontainer/devcontainer.json`` first, then
           ``.devcontainer.json`` (first found wins).
        3. Returns :data:`EMPTY_CONTENT_HASH` if neither is available.
        """
        if not resource.location:
            return EMPTY_CONTENT_HASH

        # Strategy 1: exec hash inside running container
        exec_hash = self._try_exec_hash(resource, algorithm)
        if exec_hash is not None:
            return exec_hash

        # Strategy 2: hash the config file
        return self._config_file_hash(resource, algorithm)

    def _try_exec_hash(
        self,
        resource: Resource,
        algorithm: str,
    ) -> str | None:
        """Attempt to compute hash by execing into the container.

        Uses ``docker ps`` to find a running container whose label
        matches the resource location, then execs ``find ... -exec
        sha256sum`` inside it.
        """
        container_id = self._find_running_container(resource)
        if not container_id:
            return None

        try:
            result = subprocess.run(
                [
                    "docker",
                    "exec",
                    container_id,
                    "find",
                    "/workspaces",
                    "-type",
                    "f",
                    "-exec",
                    "sha256sum",
                    "{}",
                    "+",
                ],
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            )
            if result.returncode != 0 or not result.stdout.strip():
                return None

            h = hashlib.new(algorithm)
            # Hash the sorted output for determinism
            for line in sorted(result.stdout.strip().splitlines()):
                h.update(line.encode("utf-8"))
                h.update(b"\n")
            return h.hexdigest()
        except (OSError, subprocess.TimeoutExpired):
            return None

    @staticmethod
    def _find_running_container(resource: Resource) -> str | None:
        """Find a running Docker container for this devcontainer resource.

        Fast path: if the resource has a stored ``container_id`` property
        (set via ``--container-id`` when registering a ``container-instance``),
        return it directly without querying Docker.

        Fallback: uses ``docker ps`` filtered by the devcontainer label that
        matches the resource location (for ``devcontainer-instance`` resources).
        """
        # Fast path: use the stored container ID when available.
        if resource.properties:
            stored_id = resource.properties.get("container_id")
            if stored_id and isinstance(stored_id, str):
                return stored_id

        if not resource.location:
            return None
        try:
            result = subprocess.run(
                [
                    "docker",
                    "ps",
                    "-q",
                    "--filter",
                    f"label=devcontainer.local_folder={resource.location}",
                ],
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
            if result.returncode != 0 or not result.stdout.strip():
                return None
            # Return first matching container ID
            return result.stdout.strip().splitlines()[0]
        except (OSError, subprocess.TimeoutExpired):
            return None

    def _config_file_hash(
        self,
        resource: Resource,
        algorithm: str,
    ) -> str:
        """Hash the devcontainer config file as fallback."""
        loc = resource.location
        if not loc:
            return EMPTY_CONTENT_HASH
        config_paths = [
            os.path.join(loc, ".devcontainer", "devcontainer.json"),
            os.path.join(loc, ".devcontainer.json"),
        ]

        h = hashlib.new(algorithm)
        for config_path in config_paths:
            if os.path.isfile(config_path):
                with open(config_path, "rb") as f:
                    for chunk in iter(lambda: f.read(65536), b""):
                        h.update(chunk)
                return h.hexdigest()

        return EMPTY_CONTENT_HASH

    # -- F16 fix: lazy activation on first resolve -------------------------

    _ACTIVATABLE_STATES = frozenset(
        {
            ContainerLifecycleState.DETECTED,
            ContainerLifecycleState.STOPPED,
            ContainerLifecycleState.FAILED,
        }
    )

    def resolve(
        self,
        *,
        resource: Resource,
        plan_id: str,
        slot_name: str,
        sandbox_manager: SandboxManager,
        access: str = "read_only",
    ) -> BoundResource:
        """Resolve a devcontainer resource, lazily activating if needed.

        For ``devcontainer-instance`` resources in a non-running state,
        this triggers :func:`activate_container` before delegating to the
        base-class sandbox resolution.  For other resource types (e.g.
        ``devcontainer-file``), it delegates directly.

        Args:
            resource: The resource domain object to resolve.
            plan_id: The plan requesting the sandbox.
            slot_name: Name of the tool resource slot being filled.
            sandbox_manager: The sandbox lifecycle manager.
            access: Access mode (``read_only`` or ``read_write``).

        Returns:
            A :class:`BoundResource` with ``sandbox_path`` populated.
        """
        if resource.resource_type_name == "devcontainer-instance" and resource.location:
            tracker = get_lifecycle_tracker(resource.resource_id)
            if tracker.current_state in self._ACTIVATABLE_STATES:
                logger.info(
                    "Lazy-activating devcontainer %s (state=%s)",
                    resource.resource_id,
                    tracker.current_state.value,
                )
                activate_container(
                    resource.resource_id,
                    resource.location,
                    session_id=plan_id,
                )

        return super().resolve(
            resource=resource,
            plan_id=plan_id,
            slot_name=slot_name,
            sandbox_manager=sandbox_manager,
            access=access,
        )

    # -- Content CRUD (issue #827) -----------------------------------------
    # Devcontainer content operations delegate to ``devcontainer exec``
    # where possible.  The container must be in RUNNING state.

    def read(self, *, resource: Resource, path: str = "") -> Content:
        """Read a file from a running devcontainer via ``devcontainer exec cat``.

        Args:
            resource: The devcontainer resource.
            path: Path inside the container.

        Returns:
            A :class:`Content` with the file bytes.

        Raises:
            NotImplementedError: If the resource has no location (cannot
                exec into the container).
        """
        location = self._require_location(resource)
        if not path:
            return self._exec_ls(resource, location, ".")

        result = subprocess.run(
            ["devcontainer", "exec", "--workspace-folder", location, "cat", path],
            capture_output=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            raise FileNotFoundError(
                f"Path '{path}' not found in devcontainer at '{location}': "
                f"{result.stderr.decode(errors='replace').strip()}"
            )
        return Content(data=result.stdout, encoding="utf-8")

    def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult:
        """Write a file into a running devcontainer.

        Uses ``devcontainer exec tee`` to write the bytes.

        Args:
            resource: The devcontainer resource.
            path: Path inside the container.
            data: Raw bytes to write.

        Returns:
            A :class:`WriteResult` indicating success.
        """

        location = self._require_location(resource)
        result = subprocess.run(
            [
                "devcontainer",
                "exec",
                "--workspace-folder",
                location,
                "tee",
                path,
            ],
            input=data,
            capture_output=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            return WriteResult(
                success=False,
                message=(
                    f"Write to devcontainer failed: "
                    f"{result.stderr.decode(errors='replace').strip()}"
                ),
            )
        return WriteResult(
            success=True,
            bytes_written=len(data),
            message=f"Wrote {len(data)} bytes to {path} in devcontainer",
        )

    def discover_children(self, *, resource: Resource) -> list[Resource]:
        """Discover directories inside a running devcontainer via ``ls``.

        Args:
            resource: The devcontainer resource.

        Returns:
            List of child :class:`Resource` objects for top-level
            directories in the workspace.
        """

        location = self._require_location(resource)
        result = subprocess.run(
            [
                "devcontainer",
                "exec",
                "--workspace-folder",
                location,
                "ls",
                "-1",
            ],
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
        children: list[Resource] = []
        if result.returncode != 0:
            return children

        for name in sorted(result.stdout.strip().split("\n")):
            name = name.strip()
            if not name:
                continue
            children.append(
                Resource(
                    resource_id=self._derive_child_id(resource.resource_id, name),
                    name=name,
                    resource_type_name="fs-directory",
                    classification=resource.classification,
                    description=f"'{name}' in devcontainer {resource.resource_id}",
                    location=f"{location}/{name}",
                    parents=[resource.resource_id],
                )
            )
        return children

    def _exec_ls(self, resource: Resource, location: str, path: str) -> Content:
        """Run ``ls`` inside the container and return as Content."""

        result = subprocess.run(
            [
                "devcontainer",
                "exec",
                "--workspace-folder",
                location,
                "ls",
                "-1",
                path,
            ],
            capture_output=True,
            timeout=30,
            check=False,
        )
        data = result.stdout if result.returncode == 0 else b""
        return Content(data=data, encoding="utf-8")

    # -- Missing protocol methods (issue #1242) ----------------------------

    def delete(self, *, resource: Resource, path: str = "") -> DeleteResult:
        """Delete a file inside a running devcontainer via ``devcontainer exec rm``.

        If *path* is empty, the workspace root is targeted (``rm -rf .``).
        The container must be reachable; a missing or stopped container
        results in a failure :class:`DeleteResult` rather than an exception.

        Args:
            resource: The devcontainer resource.
            path: Path inside the container to delete.  Empty string
                targets the workspace root.

        Returns:
            A :class:`DeleteResult` indicating success or failure.

        Raises:
            ValueError: If the resource has no location.
        """
        location = self._require_location(resource)
        target = path if path else "."
        result = subprocess.run(
            [
                "devcontainer",
                "exec",
                "--workspace-folder",
                location,
                "rm",
                "-rf",
                target,
            ],
            capture_output=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            stderr = result.stderr.decode(errors="replace").strip()
            return DeleteResult(
                success=False,
                message=f"Delete failed in devcontainer at '{location}': {stderr}",
            )
        return DeleteResult(
            success=True,
            message=f"Deleted '{target}' in devcontainer at '{location}'",
        )

    def list_children(self, *, resource: Resource) -> list[str]:
        """List direct children of the workspace inside a running devcontainer.

        Uses ``devcontainer exec ls -1`` to enumerate entries at the
        workspace root.  Returns an empty list if the container is
        missing or stopped rather than raising an exception.

        Args:
            resource: The devcontainer resource.

        Returns:
            Sorted list of entry names (files and directories) at the
            workspace root.  Empty list on container failure.

        Raises:
            ValueError: If the resource has no location.
        """
        location = self._require_location(resource)
        result = subprocess.run(
            [
                "devcontainer",
                "exec",
                "--workspace-folder",
                location,
                "ls",
                "-1",
            ],
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            return []
        names: list[str] = []
        for name in result.stdout.strip().split("\n"):
            name = name.strip()
            if name:
                names.append(name)
        return sorted(names)

    def diff(self, *, resource: Resource, other_location: str) -> DiffResult:
        """Compare the devcontainer workspace against another location.

        Computes the content hash of this resource and the content hash
        of the *other_location* (using the base-class filesystem hash)
        and reports whether they differ.  A full unified diff is not
        produced because the container filesystem is not directly
        accessible from the host; instead ``has_changes`` is set based
        on hash comparison and ``unified_diff`` is left empty.

        If the container is missing or stopped, the exec hash falls back
        to the config-file hash (see :meth:`content_hash`).

        Args:
            resource: The devcontainer resource.
            other_location: Filesystem path to compare against.

        Returns:
            A :class:`DiffResult` with ``has_changes`` set.

        Raises:
            ValueError: If the resource has no location.
        """
        self._require_location(resource)
        this_hash = self.content_hash(resource)

        # Hash the other location using the base-class filesystem logic.
        # We construct a temporary resource pointing at other_location and
        # call the base-class content_hash directly (bypassing the
        # devcontainer override which would try to exec into the container).
        from cleveragents.resource.handlers._base import (
            EMPTY_CONTENT_HASH,
            BaseResourceHandler,
        )

        other_resource = resource.__class__(
            resource_id=resource.resource_id,
            name=resource.name,
            resource_type_name=resource.resource_type_name,
            classification=resource.classification,
            description=resource.description,
            location=other_location,
        )
        other_hash = BaseResourceHandler.content_hash(self, other_resource)

        has_changes = this_hash != other_hash
        # Both hashes being EMPTY_CONTENT_HASH means both sides are absent —
        # treat as no changes.
        if this_hash == EMPTY_CONTENT_HASH and other_hash == EMPTY_CONTENT_HASH:
            has_changes = False

        return DiffResult(
            has_changes=has_changes,
            unified_diff="",
            files_changed=1 if has_changes else 0,
        )

    def create_sandbox(
        self,
        *,
        resource: Resource,
        plan_id: str,
        sandbox_manager: SandboxManager,
    ) -> SandboxResult:
        """Create an isolated sandbox for a devcontainer resource.

        For devcontainer resources the container itself provides
        isolation (``none`` sandbox strategy — F22/F25 fix).  This
        method delegates to the base-class implementation which calls
        :meth:`SandboxManager.get_or_create_sandbox` with the ``none``
        strategy.

        If the container is in an activatable state (DETECTED, STOPPED,
        FAILED) it is lazily activated before the sandbox is created so
        that the workspace path is available.

        Args:
            resource: The devcontainer resource.
            plan_id: The plan requesting the sandbox.
            sandbox_manager: The sandbox lifecycle manager.

        Returns:
            A :class:`SandboxResult` with the sandbox path and strategy.

        Raises:
            ValueError: If the resource has no location.
            RuntimeError: If the sandbox was created but has no context.
        """
        if resource.resource_type_name == "devcontainer-instance" and resource.location:
            tracker = get_lifecycle_tracker(resource.resource_id)
            if tracker.current_state in self._ACTIVATABLE_STATES:
                logger.info(
                    "Lazy-activating devcontainer %s before sandbox creation"
                    " (state=%s)",
                    resource.resource_id,
                    tracker.current_state.value,
                )
                activate_container(
                    resource.resource_id,
                    resource.location,
                    session_id=plan_id,
                )

        return super().create_sandbox(
            resource=resource,
            plan_id=plan_id,
            sandbox_manager=sandbox_manager,
        )
