"""Copy-on-write sandbox for filesystem resources.

Creates an isolated copy of a directory tree where plan modifications are
made.  On ``commit``, changed files are synced back to the original.
On ``rollback``, the copy is discarded.

Suitable for ``fs-mount``, ``fs-directory``, and any non-git resource
that needs filesystem-level isolation.

Stage B3.4 / TASK-006 of the implementation plan.
"""

from __future__ import annotations

import logging
import os
import shutil
import tempfile
from datetime import datetime

from ulid import ULID

from cleveragents.infrastructure.sandbox._fs_utils import (
    backup_directory,
    compute_diff,
    safe_restore,
)
from cleveragents.infrastructure.sandbox.protocol import (
    CommitResult,
    SandboxCommitError,
    SandboxContext,
    SandboxCreationError,
    SandboxRollbackError,
    SandboxStateError,
    SandboxStatus,
)

logger = logging.getLogger(__name__)


class CopyOnWriteSandbox:
    """Sandbox that isolates changes in a filesystem copy.

    Copies the original directory to a temporary location.  The actor
    modifies the copy; on commit a diff-based sync copies only changed
    files back to the original.  On rollback the copy is simply deleted.

    Implements the
    :class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox` protocol.

    Lifecycle::

        sandbox = CopyOnWriteSandbox(resource_id, original_path)
        ctx = sandbox.create(plan_id)       # copies directory
        path = sandbox.get_path("data.csv") # resolve path in copy
        # ... actor writes ...
        result = sandbox.commit("msg")      # sync changed files back
        sandbox.cleanup()                   # remove the copy
    """

    def __init__(
        self,
        resource_id: str,
        original_path: str,
    ) -> None:
        """Initialise a copy-on-write sandbox.

        Args:
            resource_id: Identifier of the resource being sandboxed.
            original_path: Path to the directory to sandbox.

        Raises:
            ValueError: If *resource_id* or *original_path* is empty.
        """
        if not resource_id:
            raise ValueError("resource_id cannot be empty")
        if not original_path:
            raise ValueError("original_path cannot be empty")

        self._sandbox_id: str = str(ULID())
        self._resource_id: str = resource_id
        self._original_path: str = os.path.abspath(original_path)
        self._status: SandboxStatus = SandboxStatus.PENDING
        self._context: SandboxContext | None = None

        # Set after create()
        self._sandbox_path: str | None = None
        # Pre-commit backup for atomic rollback support
        self._pre_commit_backup: str | None = None

    # -- protocol properties -------------------------------------------------

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

    @property
    def status(self) -> SandboxStatus:
        """Current lifecycle status."""
        return self._status

    @property
    def context(self) -> SandboxContext | None:
        """Context after creation, ``None`` before ``create``."""
        return self._context

    # -- protocol methods ----------------------------------------------------

    def create(self, plan_id: str) -> SandboxContext:
        """Copy the original directory to a temporary sandbox location.

        Args:
            plan_id: The plan that owns this sandbox.

        Returns:
            A :class:`SandboxContext` with the sandbox copy path.

        Raises:
            ValueError: If *plan_id* is empty.
            SandboxStateError: If not in ``PENDING`` status.
            SandboxCreationError: If the directory copy fails.
        """
        if not plan_id:
            raise ValueError("plan_id cannot be empty")

        SandboxStatus.assert_transition(self._status, SandboxStatus.CREATED)

        try:
            if not os.path.isdir(self._original_path):
                raise SandboxCreationError(
                    f"Original path is not a directory: {self._original_path}"
                )

            # Create the sandbox copy in a temp directory
            parent_dir = tempfile.mkdtemp(prefix="ca-cow-sandbox-")
            self._sandbox_path = os.path.join(parent_dir, "sandbox")

            shutil.copytree(
                self._original_path,
                self._sandbox_path,
                symlinks=True,
                dirs_exist_ok=False,
            )

        except SandboxCreationError:
            self._status = SandboxStatus.ERRORED
            raise
        except OSError as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxCreationError(
                f"Failed to copy directory for resource {self._resource_id}: {exc}"
            ) from exc

        self._context = SandboxContext(
            sandbox_id=self._sandbox_id,
            sandbox_path=self._sandbox_path,
            original_path=self._original_path,
            resource_id=self._resource_id,
            plan_id=plan_id,
            created_at=datetime.now(),
            metadata={
                "strategy": "copy_on_write",
                "sandbox_path": self._sandbox_path,
            },
        )
        self._status = SandboxStatus.CREATED

        logger.info(
            "Created copy-on-write sandbox: plan=%s resource=%s path=%s",
            plan_id,
            self._resource_id,
            self._sandbox_path,
        )

        return self._context

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

        Args:
            resource_path: Path relative to the resource root.

        Returns:
            Absolute path inside the sandbox copy.

        Raises:
            SandboxStateError: If sandbox is not in a usable status.
            ValueError: If *resource_path* attempts directory traversal.
        """
        if self._status not in (
            SandboxStatus.CREATED,
            SandboxStatus.ACTIVE,
            SandboxStatus.ROLLED_BACK,
        ):
            raise SandboxStateError(
                f"Cannot resolve path in status {self._status.value}"
            )

        if ".." in resource_path.split("/"):
            raise ValueError(f"Path traversal not allowed: {resource_path}")

        if self._status in (SandboxStatus.CREATED, SandboxStatus.ROLLED_BACK):
            self._status = SandboxStatus.ACTIVE

        if self._sandbox_path is None:
            raise SandboxStateError("Sandbox path not set")

        return os.path.join(self._sandbox_path, resource_path)

    def commit(self, message: str | None = None) -> CommitResult:
        """Sync changed files from the sandbox copy back to the original.

        Compares the sandbox directory to the original and copies only
        files that differ.  New files in the sandbox are added; files
        deleted from the sandbox are removed from the original.

        Args:
            message: Optional log message (stored in result metadata).

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

        Raises:
            SandboxCommitError: If the sync fails.
            SandboxStateError: If sandbox is not in a committable status.
        """
        if self._status not in (
            SandboxStatus.CREATED,
            SandboxStatus.ACTIVE,
        ):
            raise SandboxStateError(f"Cannot commit from status {self._status.value}")

        SandboxStatus.assert_transition(self._status, SandboxStatus.COMMITTED)

        if self._sandbox_path is None:
            raise SandboxStateError("Sandbox path not set")

        try:
            changed_files, added_files, deleted_files = compute_diff(
                self._sandbox_path, self._original_path
            )

            # Save pre-commit backup for atomic rollback support.
            # Skip when there are no changes to avoid copying a potentially
            # large directory tree unnecessarily.
            #
            # IMPORTANT: ``_pre_commit_backup`` is assigned only AFTER
            # ``backup_directory`` succeeds.  If the backup itself fails
            # (e.g. disk full), the original directory is still intact and
            # the ``except OSError`` handler must NOT attempt
            # ``safe_restore`` with a partial backup.
            if changed_files or added_files or deleted_files:
                # Create backup on the same filesystem as the original to
                # avoid cross-device copy overhead and ensure os.rename
                # compatibility in safe_restore().
                backup_dir = tempfile.mkdtemp(
                    prefix="ca-cow-backup-",
                    dir=os.path.dirname(self._original_path),
                )
                try:
                    backup_directory(self._original_path, backup_dir)
                except Exception:
                    shutil.rmtree(backup_dir, ignore_errors=True)
                    raise
                self._pre_commit_backup = backup_dir

            # Apply changes: copy modified and new files
            for rel_path in changed_files + added_files:
                src = os.path.join(self._sandbox_path, rel_path)
                dst = os.path.join(self._original_path, rel_path)
                dst_dir = os.path.dirname(dst)
                if dst_dir:
                    os.makedirs(dst_dir, exist_ok=True)
                shutil.copy2(src, dst)

            # Apply deletions
            for rel_path in deleted_files:
                dst = os.path.join(self._original_path, rel_path)
                if os.path.exists(dst):
                    os.remove(dst)

        except Exception as exc:
            # Attempt to restore the original from the pre-commit backup
            # so that a partial copy does not leave the original corrupted.
            # Catches Exception (not just OSError) so that unexpected errors
            # during the file-copy phase (e.g. MemoryError wrapping,
            # TypeError) also trigger the restore.  Without this, the
            # backup would exist but never be applied, leaving the original
            # directory partially modified.
            # If the restore itself fails, keep the backup for manual
            # recovery — cleanup() will remove it later.
            if self._pre_commit_backup is not None:
                try:
                    safe_restore(self._pre_commit_backup, self._original_path)
                    self._pre_commit_backup = None
                except Exception:
                    logger.warning(
                        "Failed to restore original from backup for sandbox %s; "
                        "backup preserved at %s for manual recovery",
                        self._sandbox_id,
                        self._pre_commit_backup,
                    )
            self._status = SandboxStatus.ERRORED
            raise SandboxCommitError(
                f"Failed to sync sandbox {self._sandbox_id} back to original: {exc}"
            ) from exc

        self._status = SandboxStatus.COMMITTED

        logger.info(
            "Committed copy-on-write sandbox: sandbox_id=%s "
            "changed=%d added=%d deleted=%d",
            self._sandbox_id,
            len(changed_files),
            len(added_files),
            len(deleted_files),
        )

        return CommitResult(
            sandbox_id=self._sandbox_id,
            success=True,
            commit_ref=None,
            changed_files=changed_files,
            added_files=added_files,
            deleted_files=deleted_files,
            error=None,
            timestamp=datetime.now(),
        )

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

        When rolling back from ``ACTIVE``, re-copies the original directory
        over the sandbox.  When rolling back from ``COMMITTED``, restores
        the original directory from the pre-commit backup so that the
        commit's effects are fully undone.  The sandbox transitions to
        ``ROLLED_BACK`` and can be re-activated via ``get_path``.

        Raises:
            SandboxRollbackError: If the rollback fails.
            SandboxStateError: If called in an invalid status.
        """
        if self._status not in (SandboxStatus.ACTIVE, SandboxStatus.COMMITTED):
            raise SandboxStateError(f"Cannot rollback from status {self._status.value}")

        SandboxStatus.assert_transition(self._status, SandboxStatus.ROLLED_BACK)

        if self._sandbox_path is None:
            raise SandboxStateError("Sandbox path not set")

        try:
            if self._status == SandboxStatus.COMMITTED:
                if self._pre_commit_backup is None:
                    # No pre-commit backup means commit() detected no
                    # changes and skipped the backup.  The original
                    # directory is unmodified, so rollback is a no-op.
                    logger.debug(
                        "Rollback from COMMITTED with no backup (no changes "
                        "were applied) for sandbox %s — no-op",
                        self._sandbox_id,
                    )
                else:
                    # Atomically restore original from pre-commit backup.
                    # Uses rename-based swap so that a failure during copy
                    # does not leave the original directory deleted.
                    safe_restore(self._pre_commit_backup, self._original_path)
                    self._pre_commit_backup = None

                    # Reset the sandbox copy from the restored original so
                    # that a subsequent get_path() (ROLLED_BACK → ACTIVE)
                    # does not expose stale pre-rollback modifications.
                    shutil.rmtree(self._sandbox_path, ignore_errors=True)
                    shutil.copytree(
                        self._original_path,
                        self._sandbox_path,
                        symlinks=True,
                        dirs_exist_ok=True,
                    )
            else:
                # Remove the current sandbox copy
                shutil.rmtree(self._sandbox_path, ignore_errors=True)

                # Re-copy from original
                shutil.copytree(
                    self._original_path,
                    self._sandbox_path,
                    symlinks=True,
                    dirs_exist_ok=True,
                )

        except Exception as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxRollbackError(
                f"Failed to rollback sandbox {self._sandbox_id}: {exc}"
            ) from exc

        self._status = SandboxStatus.ROLLED_BACK

        logger.info(
            "Rolled back copy-on-write sandbox: sandbox_id=%s",
            self._sandbox_id,
        )

    def cleanup(self) -> None:
        """Remove the sandbox copy directory.

        Idempotent -- safe to call multiple times.

        Raises:
            SandboxError: On unexpected errors during cleanup.
        """
        if self._status == SandboxStatus.CLEANED_UP:
            return

        logger.debug(
            "Cleaning up copy-on-write sandbox: sandbox_id=%s path=%s",
            self._sandbox_id,
            self._sandbox_path,
        )

        if self._sandbox_path is not None:
            # Remove the sandbox copy and its parent temp dir
            parent = os.path.dirname(self._sandbox_path)
            if os.path.exists(parent):
                shutil.rmtree(parent, ignore_errors=True)

        # Clean up any pre-commit backup
        if self._pre_commit_backup is not None:
            if os.path.exists(self._pre_commit_backup):
                shutil.rmtree(self._pre_commit_backup, ignore_errors=True)
            self._pre_commit_backup = None

        self._status = SandboxStatus.CLEANED_UP

        logger.info(
            "Cleaned up copy-on-write sandbox: sandbox_id=%s",
            self._sandbox_id,
        )
