"""Execution environment resolver for CleverAgents.

Implements the spec's 6-level execution environment precedence chain:

    1. Plan override (priority=override)
    2. Project override (priority=override)
    3. Nearest-ancestor devcontainer (auto-discovered)
    4. Plan fallback (priority=fallback)
    5. Project fallback (priority=fallback)
    6. Host default

When ``container`` is resolved but no container resource is available
on the project, :class:`ContainerUnavailableError` is raised with an
actionable message.

Based on issue #512 — Execution Environment Routing, and
specification lines 19324-19386.
"""

from __future__ import annotations

from collections import deque

from cleveragents.domain.models.core.plan import (
    ExecutionEnvironment,
    ExecutionEnvPriority,
)

# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------

_CONTAINER_HINT = (
    "No container resource is linked to this project. "
    "Link a devcontainer or container-instance resource with "
    "'agents resource add' and 'agents project link-resource', "
    "then retry."
)


class ContainerUnavailableError(Exception):
    """Raised when container execution is requested but no resource exists."""

    def __init__(self, project_name: str = "") -> None:
        detail = f" for project '{project_name}'" if project_name else ""
        super().__init__(f"Container resource unavailable{detail}. {_CONTAINER_HINT}")
        self.project_name = project_name


# ---------------------------------------------------------------------------
# Resource type labels that qualify as "container resources"
# ---------------------------------------------------------------------------

CONTAINER_RESOURCE_TYPES: frozenset[str] = frozenset(
    {
        "container-instance",
        "devcontainer-instance",
        "devcontainer-file",
    }
)

_DEVCONTAINER_TYPE = "devcontainer-instance"


# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------


class ExecutionEnvironmentResolver:
    """Resolve execution environment using a 6-level precedence chain.

    Precedence (highest to lowest):
        1. Plan override (``plan_env`` with ``plan_priority=override``)
        2. Project override (``project_env`` with ``project_priority=override``)
        3. Nearest-ancestor devcontainer (auto-discovered)
        4. Plan fallback (``plan_env`` with ``plan_priority=fallback``)
        5. Project fallback (``project_env`` with ``project_priority=fallback``)
        6. Host (default)

    The old 4-level API (``resolve(tool_env, plan_env, project_env, default)``)
    is preserved for backward compatibility.  The new 6-level API is
    ``resolve_with_precedence()``.
    """

    # Default when nothing is configured
    DEFAULT = ExecutionEnvironment.HOST

    # ------------------------------------------------------------------
    # New 6-level API
    # ------------------------------------------------------------------

    def resolve_with_precedence(
        self,
        *,
        tool_env: str | None = None,
        plan_env: str | None = None,
        plan_priority: str | None = None,
        project_env: str | None = None,
        project_priority: str | None = None,
        devcontainer_available: bool = False,
    ) -> ExecutionEnvironment:
        """Resolve using the full 6-level precedence chain.

        Args:
            tool_env: Per-tool override (highest priority, above all 6 levels).
            plan_env: Plan-level execution environment.
            plan_priority: ``"override"`` or ``"fallback"`` for plan_env.
            project_env: Project-level execution environment.
            project_priority: ``"override"`` or ``"fallback"`` for project_env.
            devcontainer_available: Whether a nearest-ancestor devcontainer
                was auto-discovered for the current resource context.

        Returns:
            Resolved :class:`ExecutionEnvironment`.
        """
        # Tool-level override (above the 6-level chain)
        if tool_env is not None:
            return self._coerce(tool_env)

        p_plan = self._parse_priority(plan_priority)
        p_project = self._parse_priority(project_priority)

        # Level 1: plan override
        if plan_env is not None and p_plan == ExecutionEnvPriority.OVERRIDE:
            return self._coerce(plan_env)

        # Level 2: project override
        if project_env is not None and p_project == ExecutionEnvPriority.OVERRIDE:
            return self._coerce(project_env)

        # Level 3: nearest-ancestor devcontainer
        if devcontainer_available:
            return ExecutionEnvironment.CONTAINER

        # Level 4: plan fallback
        if plan_env is not None and p_plan == ExecutionEnvPriority.FALLBACK:
            return self._coerce(plan_env)

        # Level 5: project fallback
        if project_env is not None and p_project == ExecutionEnvPriority.FALLBACK:
            return self._coerce(project_env)

        # Level 6: host default
        return self.DEFAULT

    # ------------------------------------------------------------------
    # Legacy 4-level API (backward compatible)
    # ------------------------------------------------------------------

    def resolve(
        self,
        tool_env: str | None = None,
        plan_env: str | None = None,
        project_env: str | None = None,
        default: str | None = None,
    ) -> ExecutionEnvironment:
        """Return the effective execution environment (legacy 4-level).

        This is the original API preserved for backward compatibility.
        Use :meth:`resolve_with_precedence` for the full 6-level chain.
        """
        for raw in (tool_env, plan_env, project_env, default):
            if raw is not None:
                return self._coerce(raw)
        return self.DEFAULT

    def validate_container_available(
        self,
        linked_resource_types: list[str],
        project_name: str = "",
    ) -> bool:
        """Check that at least one container resource exists.

        Returns:
            ``True`` when a container resource is present.

        Raises:
            ContainerUnavailableError: When no container resource is found.
        """
        for rtype in linked_resource_types:
            if rtype in CONTAINER_RESOURCE_TYPES:
                return True
        raise ContainerUnavailableError(project_name)

    def has_devcontainer(self, linked_resource_types: list[str]) -> bool:
        """Check whether any linked resource is a devcontainer-instance."""
        return _DEVCONTAINER_TYPE in linked_resource_types

    def resolve_and_validate(
        self,
        linked_resource_types: list[str],
        project_name: str = "",
        tool_env: str | None = None,
        plan_env: str | None = None,
        project_env: str | None = None,
        default: str | None = None,
    ) -> ExecutionEnvironment:
        """Resolve environment and validate container availability.

        Combines :meth:`resolve` and :meth:`validate_container_available`.
        """
        env = self.resolve(
            tool_env=tool_env,
            plan_env=plan_env,
            project_env=project_env,
            default=default,
        )
        if env == ExecutionEnvironment.CONTAINER:
            self.validate_container_available(linked_resource_types, project_name)
        return env

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    # ------------------------------------------------------------------
    # Nearest-ancestor devcontainer resolution (Subtask 2+3)
    # ------------------------------------------------------------------

    def find_nearest_devcontainer(
        self,
        resource_id: str,
        linked_resource_types: dict[str, str],
        parent_map: dict[str, list[str]],
    ) -> str | None:
        """Walk the resource DAG upward to find the nearest devcontainer.

        Performs BFS from ``resource_id`` through ``parent_map`` looking
        for a resource whose type is ``devcontainer-instance``.

        Args:
            resource_id: Starting resource ID.
            linked_resource_types: Map of resource_id -> type_name.
            parent_map: Map of resource_id -> list of parent resource IDs.

        Returns:
            Resource ID of the nearest devcontainer, or ``None``.
        """
        visited: set[str] = set()
        queue: deque[str] = deque([resource_id])

        while queue:
            current = queue.popleft()
            if current in visited:
                continue
            visited.add(current)

            rtype = linked_resource_types.get(current, "")
            # Skip the starting resource itself — we're looking for an
            # ancestor devcontainer, not asking "am I a devcontainer?"
            if rtype == _DEVCONTAINER_TYPE and current != resource_id:
                return current

            for parent_id in parent_map.get(current, []):
                if parent_id not in visited:
                    queue.append(parent_id)

        return None

    def resolve_with_dag(
        self,
        *,
        resource_id: str,
        linked_resource_types: dict[str, str],
        parent_map: dict[str, list[str]],
        tool_env: str | None = None,
        plan_env: str | None = None,
        plan_priority: str | None = None,
        project_env: str | None = None,
        project_priority: str | None = None,
    ) -> ExecutionEnvironment:
        """Resolve using the full 6-level chain with DAG walk for Level 3.

        This is the production API that integrates auto-discovery.
        It walks the resource DAG to check for nearest-ancestor
        devcontainer instead of relying on a ``devcontainer_available``
        boolean.
        """
        dc_id = self.find_nearest_devcontainer(
            resource_id,
            linked_resource_types,
            parent_map,
        )
        return self.resolve_with_precedence(
            tool_env=tool_env,
            plan_env=plan_env,
            plan_priority=plan_priority,
            project_env=project_env,
            project_priority=project_priority,
            devcontainer_available=dc_id is not None,
        )

    @staticmethod
    def parse_container_ref(value: str) -> tuple[ExecutionEnvironment, str]:
        """Parse a ``container://<name>`` reference.

        Returns:
            Tuple of (ExecutionEnvironment.CONTAINER_REF, resource_name).

        Raises:
            ValueError: If the value is not a valid container reference.
        """
        prefix = "container://"
        if value.startswith(prefix):
            name = value[len(prefix) :]
            if not name:
                raise ValueError("Empty container reference after 'container://'")
            return ExecutionEnvironment.CONTAINER_REF, name
        raise ValueError(
            f"Invalid container reference '{value}'. "
            f"Expected 'container://<resource-name>'."
        )

    @staticmethod
    def _coerce(value: str) -> ExecutionEnvironment:
        """Convert a string to :class:`ExecutionEnvironment`."""
        # Support container:// references
        if value.startswith("container://"):
            return ExecutionEnvironment.CONTAINER_REF
        try:
            return ExecutionEnvironment(value.lower())
        except ValueError:
            valid = ", ".join(e.value for e in ExecutionEnvironment)
            raise ValueError(
                f"Invalid execution environment '{value}'. Valid values: {valid}"
            ) from None

    @staticmethod
    def _parse_priority(raw: str | None) -> ExecutionEnvPriority:
        """Parse a priority string, defaulting to FALLBACK."""
        if raw is None:
            return ExecutionEnvPriority.FALLBACK
        try:
            return ExecutionEnvPriority(raw.lower())
        except ValueError:
            return ExecutionEnvPriority.FALLBACK
