"""Git worktree sandbox for git-checkout resources.

Creates an isolated git worktree where plan modifications are staged and
committed independently of the main working tree.  On ``commit``, changes
are merged or cherry-picked back.  On ``rollback``, the worktree is
discarded entirely.

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

from __future__ import annotations

import contextlib
import logging
import os
import re
import shutil
import subprocess
import tempfile
from datetime import datetime

from ulid import ULID

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

logger = logging.getLogger(__name__)

# Default timeout for git commands (seconds)
_GIT_TIMEOUT: int = 30

# Regex for sanitising branch names -- keep only alnum, hyphens, underscores,
# slashes, and dots; collapse runs of disallowed chars into a single hyphen.
_BRANCH_SANITISE_RE: re.Pattern[str] = re.compile(r"[^a-zA-Z0-9/_.\-]+")


def _sanitise_branch_name(raw: str) -> str:
    """Produce a git-safe branch name from an arbitrary string.

    Args:
        raw: The raw string to sanitise (typically a plan ID).

    Returns:
        A string safe for use as a git branch name.
    """
    sanitised = _BRANCH_SANITISE_RE.sub("-", raw).strip("-")
    if not sanitised:
        sanitised = "sandbox"
    return sanitised


def _run_git(
    args: list[str],
    cwd: str,
    timeout: int = _GIT_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
    """Run a git command with timeout and capture output.

    Args:
        args: Git sub-command and arguments (e.g. ``["worktree", "add", ...]``).
        cwd: Working directory to run in.
        timeout: Maximum seconds to wait.

    Returns:
        Completed process result.

    Raises:
        subprocess.TimeoutExpired: If the command exceeds *timeout*.
        subprocess.CalledProcessError: If the command returns non-zero.
    """
    cmd = ["git", *args]
    logger.debug("Running: %s (cwd=%s, timeout=%ds)", " ".join(cmd), cwd, timeout)
    return subprocess.run(
        cmd,
        cwd=cwd,
        capture_output=True,
        text=True,
        check=True,
        timeout=timeout,
    )


class GitWorktreeSandbox:
    """Sandbox that isolates changes in a git worktree.

    Creates a detached worktree rooted at a temporary directory with a
    dedicated branch.  The actor makes changes in the worktree; on commit
    those changes are merged back to the original branch.

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

    Lifecycle::

        sandbox = GitWorktreeSandbox(resource_id, original_path)
        ctx = sandbox.create(plan_id)       # creates worktree + branch
        path = sandbox.get_path("src/x.py") # resolve path in worktree
        # ... actor writes ...
        result = sandbox.commit("msg")      # commit in worktree, merge back
        sandbox.cleanup()                    # remove worktree + branch
    """

    def __init__(
        self,
        resource_id: str,
        original_path: str,
        git_timeout: int = _GIT_TIMEOUT,
    ) -> None:
        """Initialise a git worktree sandbox.

        Args:
            resource_id: Identifier of the resource being sandboxed.
            original_path: Path to the git repository root.
            git_timeout: Timeout in seconds for git commands.

        Raises:
            ValueError: If *resource_id* or *original_path* is empty.
            ValueError: If *git_timeout* is not positive.
        """
        if not resource_id:
            raise ValueError("resource_id cannot be empty")
        if not original_path:
            raise ValueError("original_path cannot be empty")
        if git_timeout <= 0:
            raise ValueError("git_timeout must be positive")

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

        # Set after create()
        self._worktree_path: str | None = None
        self._branch_name: str | None = None
        self._original_branch: str | None = None
        self._base_commit: str | None = None
        # Pre-merge commit for atomic rollback support
        self._pre_merge_commit: 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:
        """Create a git worktree for isolated modifications.

        Creates a new branch ``cleveragents/plan-<plan_id>`` and a worktree at a
        temporary directory.  The worktree starts from the current HEAD
        of the repository.

        Args:
            plan_id: The plan that owns this sandbox.

        Returns:
            A :class:`SandboxContext` with the worktree path.

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

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

        try:
            # Verify the original path is a git repo root (not just a
            # subdirectory of some unrelated parent repository).
            toplevel_result = _run_git(
                ["rev-parse", "--show-toplevel"],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )
            toplevel = os.path.realpath(toplevel_result.stdout.strip())
            actual = os.path.realpath(self._original_path)
            if toplevel != actual:
                raise subprocess.CalledProcessError(
                    returncode=1,
                    cmd="git rev-parse --show-toplevel",
                    stderr=(
                        f"Path {self._original_path} is not the root of a "
                        f"git repository (toplevel is {toplevel})"
                    ),
                )

            # Get the current branch name
            result = _run_git(
                ["rev-parse", "--abbrev-ref", "HEAD"],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )
            self._original_branch = result.stdout.strip()

            # Get the current HEAD commit
            result = _run_git(
                ["rev-parse", "HEAD"],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )
            self._base_commit = result.stdout.strip()

            # Create a sanitised branch name
            safe_plan_id = _sanitise_branch_name(plan_id)
            self._branch_name = f"cleveragents/plan-{safe_plan_id}"

            # Create a temporary directory for the worktree
            self._worktree_path = tempfile.mkdtemp(prefix=f"ca-sandbox-{safe_plan_id}-")
            # mkdtemp creates the dir; git worktree add needs it to not exist
            os.rmdir(self._worktree_path)

            # Create the worktree with a new branch
            _run_git(
                [
                    "worktree",
                    "add",
                    "-b",
                    self._branch_name,
                    self._worktree_path,
                    "HEAD",
                ],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )

        except subprocess.TimeoutExpired as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxCreationError(
                f"Git command timed out after {self._git_timeout}s "
                f"while creating worktree for resource {self._resource_id}"
            ) from exc
        except subprocess.CalledProcessError as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxCreationError(
                f"Failed to create git worktree for resource "
                f"{self._resource_id}: {exc.stderr.strip()}"
            ) from exc

        self._context = SandboxContext(
            sandbox_id=self._sandbox_id,
            sandbox_path=self._worktree_path,
            original_path=self._original_path,
            resource_id=self._resource_id,
            plan_id=plan_id,
            created_at=datetime.now(),
            metadata={
                "strategy": "git_worktree",
                "branch": self._branch_name,
                "original_branch": self._original_branch,
                "base_commit": self._base_commit,
                "worktree_path": self._worktree_path,
            },
        )
        self._status = SandboxStatus.CREATED

        logger.info(
            "Created git worktree sandbox: plan=%s resource=%s branch=%s path=%s",
            plan_id,
            self._resource_id,
            self._branch_name,
            self._worktree_path,
        )

        return self._context

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

        Args:
            resource_path: Path relative to the resource root.

        Returns:
            Absolute path inside the worktree.

        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._worktree_path is None:
            raise SandboxStateError("Worktree path not set")

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

    def commit(self, message: str | None = None) -> CommitResult:
        """Commit changes in the worktree and merge back to the original branch.

        Stages all changes in the worktree, commits them, then merges the
        sandbox branch into the original branch.

        Args:
            message: Commit message. Defaults to a generated message.

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

        Raises:
            SandboxCommitError: If the commit or merge 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._worktree_path is None or self._branch_name is None:
            raise SandboxStateError("Worktree not initialised")

        commit_message = message or (
            f"sandbox: changes from plan (sandbox_id={self._sandbox_id})"
        )

        try:
            # Stage all changes in the worktree
            _run_git(
                ["add", "-A"],
                cwd=self._worktree_path,
                timeout=self._git_timeout,
            )

            # Check if there are staged changes
            diff_result = subprocess.run(
                ["git", "diff", "--cached", "--name-status"],
                cwd=self._worktree_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=self._git_timeout,
            )
            diff_output = diff_result.stdout.strip()

            if diff_result.returncode != 0:
                raise subprocess.CalledProcessError(
                    returncode=diff_result.returncode,
                    cmd="git diff --cached --name-status",
                    stderr=diff_result.stderr,
                )

            if not diff_output:
                # No changes to commit
                self._status = SandboxStatus.COMMITTED
                return CommitResult(
                    sandbox_id=self._sandbox_id,
                    success=True,
                    commit_ref=self._base_commit,
                    changed_files=[],
                    added_files=[],
                    deleted_files=[],
                    error=None,
                    timestamp=datetime.now(),
                )

            # Parse changed/added/deleted files
            changed_files: list[str] = []
            added_files: list[str] = []
            deleted_files: list[str] = []

            for line in diff_output.splitlines():
                parts = line.split("\t", 1)
                if len(parts) == 2:
                    status_char, file_path = parts
                    if status_char.startswith("A"):
                        added_files.append(file_path)
                    elif status_char.startswith("D"):
                        deleted_files.append(file_path)
                    else:
                        changed_files.append(file_path)

            # Commit in the worktree
            _run_git(
                ["commit", "-m", commit_message],
                cwd=self._worktree_path,
                timeout=self._git_timeout,
            )

            # Get the commit hash
            result = _run_git(
                ["rev-parse", "HEAD"],
                cwd=self._worktree_path,
                timeout=self._git_timeout,
            )
            commit_ref = result.stdout.strip()

            # Record pre-merge commit for atomic rollback support
            pre_merge_result = _run_git(
                ["rev-parse", "HEAD"],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )
            self._pre_merge_commit = pre_merge_result.stdout.strip()

            # Merge the sandbox branch into the original branch
            _run_git(
                ["merge", self._branch_name, "--no-edit"],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )

        except subprocess.TimeoutExpired as exc:
            self._pre_merge_commit = None
            self._status = SandboxStatus.ERRORED
            raise SandboxCommitError(
                f"Git command timed out after {self._git_timeout}s "
                f"while committing sandbox {self._sandbox_id}"
            ) from exc
        except subprocess.CalledProcessError as exc:
            self._pre_merge_commit = None
            self._status = SandboxStatus.ERRORED
            raise SandboxCommitError(
                f"Failed to commit sandbox {self._sandbox_id}: {exc.stderr.strip()}"
            ) from exc

        self._status = SandboxStatus.COMMITTED

        logger.info(
            "Committed git worktree sandbox: sandbox_id=%s commit=%s "
            "changed=%d added=%d deleted=%d",
            self._sandbox_id,
            commit_ref,
            len(changed_files),
            len(added_files),
            len(deleted_files),
        )

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

    def rollback(self) -> None:
        """Discard all worktree changes by resetting to the base commit.

        When rolling back from ``ACTIVE``, resets the worktree branch to
        the base commit.  When rolling back from ``COMMITTED``, also
        resets the original branch to the pre-merge commit so that the
        merge is fully undone.  Transitions to ``ROLLED_BACK``.

        .. warning:: Multi-worktree safety

           Rolling back from ``COMMITTED`` executes ``git reset --hard``
           on the original branch.  If other git worktrees or external
           processes are tracking the same branch, they will be affected
           by this hard reset.

        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._worktree_path is None or self._base_commit is None:
            raise SandboxStateError("Worktree not initialised")

        try:
            if (
                self._status == SandboxStatus.COMMITTED
                and self._pre_merge_commit is not None
            ):
                # Undo the merge on the original branch
                _run_git(
                    ["reset", "--hard", self._pre_merge_commit],
                    cwd=self._original_path,
                    timeout=self._git_timeout,
                )
                self._pre_merge_commit = None

            # Reset the worktree to the base commit
            _run_git(
                ["reset", "--hard", self._base_commit],
                cwd=self._worktree_path,
                timeout=self._git_timeout,
            )
            _run_git(
                ["clean", "-fd"],
                cwd=self._worktree_path,
                timeout=self._git_timeout,
            )
        except subprocess.TimeoutExpired as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxRollbackError(
                f"Git command timed out after {self._git_timeout}s "
                f"while rolling back sandbox {self._sandbox_id}"
            ) from exc
        except subprocess.CalledProcessError as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxRollbackError(
                f"Failed to rollback sandbox {self._sandbox_id}: {exc.stderr.strip()}"
            ) from exc

        self._status = SandboxStatus.ROLLED_BACK

        logger.info(
            "Rolled back git worktree sandbox: sandbox_id=%s to commit=%s",
            self._sandbox_id,
            self._base_commit,
        )

    def cleanup(self) -> None:
        """Remove the worktree and sandbox branch.

        Idempotent -- safe to call multiple times.

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

        logger.debug(
            "Cleaning up git worktree sandbox: sandbox_id=%s branch=%s path=%s",
            self._sandbox_id,
            self._branch_name,
            self._worktree_path,
        )

        # Remove the worktree
        if self._worktree_path is not None and os.path.exists(self._worktree_path):
            try:
                _run_git(
                    ["worktree", "remove", "--force", self._worktree_path],
                    cwd=self._original_path,
                    timeout=self._git_timeout,
                )
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                # Fallback: manual removal
                logger.warning(
                    "git worktree remove failed; removing directory manually: %s",
                    self._worktree_path,
                )
                shutil.rmtree(self._worktree_path, ignore_errors=True)

        # Delete the sandbox branch
        if self._branch_name is not None:
            try:
                _run_git(
                    ["branch", "-D", self._branch_name],
                    cwd=self._original_path,
                    timeout=self._git_timeout,
                )
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                logger.warning(
                    "Failed to delete sandbox branch %s; it may need manual cleanup",
                    self._branch_name,
                )

        # Prune stale worktree entries
        with contextlib.suppress(
            subprocess.CalledProcessError, subprocess.TimeoutExpired
        ):
            _run_git(
                ["worktree", "prune"],
                cwd=self._original_path,
                timeout=self._git_timeout,
            )

        self._status = SandboxStatus.CLEANED_UP

        logger.info(
            "Cleaned up git worktree sandbox: sandbox_id=%s",
            self._sandbox_id,
        )
