"""Sandbox protocol and supporting types for CleverAgents.

Defines the ``Sandbox`` protocol that all sandbox implementations must satisfy,
along with the ``SandboxStatus`` lifecycle enum, ``SandboxContext`` and
``CommitResult`` value objects, and exception hierarchy.

Stage B3.1 + B3.2 of the implementation plan.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import Any, Protocol, runtime_checkable

# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class SandboxError(Exception):
    """Base exception for all sandbox-related errors."""


class SandboxCreationError(SandboxError):
    """Raised when a sandbox cannot be initialised."""


class SandboxCommitError(SandboxError):
    """Raised when committing sandbox changes fails."""


class SandboxRollbackError(SandboxError):
    """Raised when rolling back sandbox changes fails."""


class SandboxStateError(SandboxError):
    """Raised when an operation is invalid for the current sandbox status."""


class AtomicCommitError(SandboxError):
    """Raised when an atomic ``commit_all`` fails due to a non-sandbox exception.

    Wraps the original exception as ``__cause__`` and carries rollback
    metadata so the caller can determine which sandboxes were rolled
    back and which rollbacks failed.

    Attributes:
        rolled_back_ids: Sandbox IDs that were successfully rolled back.
        failed_rollback_ids: Sandbox IDs where rollback failed.
    """

    def __init__(
        self,
        message: str,
        *,
        rolled_back_ids: list[str] | None = None,
        failed_rollback_ids: list[str] | None = None,
    ) -> None:
        super().__init__(message)
        self.rolled_back_ids: list[str] = rolled_back_ids or []
        self.failed_rollback_ids: list[str] = failed_rollback_ids or []


# ---------------------------------------------------------------------------
# SandboxStatus enum (B3.2)
# ---------------------------------------------------------------------------


class SandboxStatus(StrEnum):
    """Lifecycle status of a sandbox instance.

    Transition graph::

        PENDING ──► CREATED ──► ACTIVE ──► COMMITTED ──┬──► CLEANED_UP
            │           │           │                   │
            │           ├──► COMMITTED                  └──► ROLLED_BACK ──┐
            │           │                                                  │
            │           └──► CLEANED_UP    ACTIVE ◄──── ROLLED_BACK ◄──┘  │
            │                                │                             │
            │                                ├──► ERRORED ──► CLEANED_UP   │
            │                                │                             │
            └──► ERRORED ──► CLEANED_UP      └────────────► CLEANED_UP ◄──┘

    The ``COMMITTED → ROLLED_BACK`` transition supports the atomic
    ``commit_all`` protocol: if a later sandbox in the batch fails to
    commit, already-committed sandboxes are rolled back to their
    pre-commit state.

    Terminal state: ``CLEANED_UP``.
    """

    PENDING = "pending"
    CREATED = "created"
    ACTIVE = "active"
    COMMITTED = "committed"
    ROLLED_BACK = "rolled_back"
    CLEANED_UP = "cleaned_up"
    ERRORED = "errored"

    # -- transition validation ------------------------------------------------

    @classmethod
    def valid_transitions(cls) -> dict[SandboxStatus, list[SandboxStatus]]:
        """Return the map of allowed status transitions.

        Returns:
            A dict mapping each ``SandboxStatus`` to the list of statuses it
            may transition to.
        """
        return {
            cls.PENDING: [cls.CREATED, cls.ERRORED],
            cls.CREATED: [cls.ACTIVE, cls.COMMITTED, cls.CLEANED_UP],
            cls.ACTIVE: [cls.COMMITTED, cls.ROLLED_BACK, cls.ERRORED],
            cls.COMMITTED: [cls.ROLLED_BACK, cls.CLEANED_UP],
            cls.ROLLED_BACK: [cls.ACTIVE, cls.CLEANED_UP],
            cls.ERRORED: [cls.CLEANED_UP],
            cls.CLEANED_UP: [],
        }

    @classmethod
    def can_transition(
        cls, from_status: SandboxStatus, to_status: SandboxStatus
    ) -> bool:
        """Check whether a transition between two statuses is allowed.

        Args:
            from_status: The current status.
            to_status: The desired target status.

        Returns:
            ``True`` if the transition is valid according to
            :meth:`valid_transitions`.
        """
        return to_status in cls.valid_transitions().get(from_status, [])

    @classmethod
    def assert_transition(
        cls, from_status: SandboxStatus, to_status: SandboxStatus
    ) -> None:
        """Raise :class:`SandboxStateError` if a transition is not allowed.

        Args:
            from_status: The current status.
            to_status: The desired target status.

        Raises:
            SandboxStateError: If the transition is invalid.
        """
        if not cls.can_transition(from_status, to_status):
            raise SandboxStateError(
                f"Invalid sandbox status transition: {from_status.value} -> "
                f"{to_status.value}"
            )


# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class SandboxContext:
    """Contextual information about an initialised sandbox.

    Created by :meth:`Sandbox.create` and available via
    :attr:`Sandbox.context` once the sandbox is ready.
    """

    sandbox_id: str
    """Unique ULID identifier for this sandbox instance."""

    sandbox_path: str
    """Root filesystem path where sandboxed files reside."""

    original_path: str
    """Original resource location (before sandboxing)."""

    resource_id: str
    """ID of the resource being sandboxed."""

    plan_id: str
    """ID of the plan that owns this sandbox."""

    created_at: datetime
    """When this sandbox was initialised."""

    metadata: dict[str, Any] = field(default_factory=dict)
    """Implementation-specific data (e.g. git branch name, worktree ref)."""


@dataclass(frozen=True)
class CommitResult:
    """Result of committing sandbox changes back to the original resource.

    Produced by :meth:`Sandbox.commit`.
    """

    sandbox_id: str
    """Which sandbox was committed."""

    success: bool
    """Whether the commit operation succeeded."""

    commit_ref: str | None = None
    """Git commit hash or equivalent reference, if applicable."""

    changed_files: list[str] = field(default_factory=list)
    """Paths of files that were modified."""

    added_files: list[str] = field(default_factory=list)
    """Paths of files that were created."""

    deleted_files: list[str] = field(default_factory=list)
    """Paths of files that were removed."""

    error: str | None = None
    """Error message when ``success`` is ``False``."""

    timestamp: datetime = field(default_factory=datetime.now)
    """When the commit occurred."""

    metadata: dict[str, Any] = field(default_factory=dict)
    """Arbitrary key-value metadata (e.g. commit message)."""


# ---------------------------------------------------------------------------
# Sandbox protocol (B3.1d)
# ---------------------------------------------------------------------------


@runtime_checkable
class Sandbox(Protocol):
    """Protocol for resource sandboxing implementations.

    A sandbox provides an isolated environment where a plan can read and
    write to a resource without affecting the original until the changes
    are explicitly committed.

    Lifecycle::

        sandbox = SandboxFactory.create_sandbox(resource)
        ctx = sandbox.create(plan_id)          # PENDING -> CREATED
        path = sandbox.get_path("src/main.py") # resolve file in sandbox
        # ... actor writes to files at *path* ...
        result = sandbox.commit("Apply edits") # ACTIVE  -> COMMITTED
        sandbox.cleanup()                       # COMMITTED -> CLEANED_UP

    All implementations must respect the status transitions defined by
    :class:`SandboxStatus`.
    """

    @property
    def sandbox_id(self) -> str:
        """Unique identifier for this sandbox instance."""
        ...

    @property
    def status(self) -> SandboxStatus:
        """Current lifecycle status of the sandbox."""
        ...

    @property
    def context(self) -> SandboxContext | None:
        """Context after sandbox is created, ``None`` before ``create``."""
        ...

    def create(self, plan_id: str) -> SandboxContext:
        """Initialise the sandbox environment.

        Args:
            plan_id: The plan that owns this sandbox.

        Returns:
            A :class:`SandboxContext` describing the created sandbox.

        Raises:
            SandboxCreationError: If creation fails.
            SandboxStateError: If called in an invalid status.
        """
        ...

    def get_path(self, resource_path: str) -> str:
        """Translate a resource-relative path to an absolute sandbox path.

        Args:
            resource_path: Path relative to the resource root.

        Returns:
            Absolute path inside the sandbox.

        Raises:
            SandboxStateError: If sandbox is not in a usable status.
            ValueError: If ``resource_path`` attempts directory traversal.
        """
        ...

    def commit(self, message: str | None = None) -> CommitResult:
        """Finalise sandbox changes, applying them to the original resource.

        Args:
            message: Optional human-readable commit/log message.

        Returns:
            A :class:`CommitResult` describing the outcome.

        Raises:
            SandboxCommitError: If the commit operation fails.
            SandboxStateError: If called in an invalid status.
        """
        ...

    def rollback(self) -> None:
        """Discard all sandbox changes, resetting to original state.

        After rollback the sandbox may be re-used (transitions back to
        ``ACTIVE`` or ``CREATED`` depending on implementation).

        Raises:
            SandboxRollbackError: If the rollback fails.
            SandboxStateError: If called in an invalid status.
        """
        ...

    def cleanup(self) -> None:
        """Remove all sandbox artefacts.

        After cleanup the sandbox **cannot** be used again.  This method
        is safe to call multiple times (idempotent on ``CLEANED_UP``).

        Raises:
            SandboxError: If cleanup encounters an unrecoverable error.
        """
        ...
