"""Checkpoint service for creating, restoring, and pruning snapshots.

Orchestrates checkpoint lifecycle: snapshot creation via git-worktree
commit hashes, rollback restoration, retention enforcement, and guard
validation (e.g. rejecting rollback on applied plans or missing sandboxes).

When a ``CheckpointRepository`` is injected (the default in production via
the DI container), all persistence is delegated to the database.  When no
repository is provided the service falls back to an in-memory store, which
is useful for unit tests that do not require database infrastructure.

Guard state (whether a plan has been applied, whether a sandbox exists)
is resolved via the ``PlanLifecycleService`` when it is wired.  This
queries the persistent plan state (``processing_state``, ``sandbox_refs``)
rather than relying on in-memory flags.  The in-memory helpers
(``mark_plan_applied``, ``register_sandbox``) remain available for unit
tests that do not require the lifecycle service.
"""

from __future__ import annotations

import hashlib
import logging
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

from ulid import ULID

from cleveragents.core.exceptions import (
    BusinessRuleViolation,
    ResourceNotFoundError,
    ValidationError,
)
from cleveragents.domain.models.core.checkpoint import (
    DEFAULT_RETENTION_POLICY,
    Checkpoint,
    CheckpointMetadata,
    CheckpointRetentionPolicy,
    RollbackResult,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.application.services.plan_lifecycle_service import (
        PlanLifecycleService,
    )
    from cleveragents.infrastructure.database.repositories import (
        CheckpointRepository,
    )
    from cleveragents.infrastructure.events.protocol import EventBus

logger = logging.getLogger(__name__)


class CheckpointService:
    """Service for managing plan checkpoints and rollback operations.

    When *repository* is supplied, all CRUD is delegated to the
    ``CheckpointRepository`` (database-backed).  Otherwise an in-memory
    dict is used, suitable for tests that do not need persistence.

    When *plan_lifecycle_service* is supplied, guard checks (whether a
    plan has been applied, whether a sandbox exists) query the persistent
    plan state via ``get_plan()``.  This avoids relying on in-memory
    flags that are lost across DI ``Factory`` instantiations.  When the
    lifecycle service is *not* available (e.g. in unit tests), the
    service falls back to the in-memory ``_plan_applied`` /
    ``_plan_sandbox_refs`` helpers.

    When *event_bus* is supplied, domain events are emitted on rollback
    operations (``CHECKPOINT_RESTORED``).
    """

    def __init__(
        self,
        repository: CheckpointRepository | None = None,
        plan_lifecycle_service: PlanLifecycleService | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        self._repository = repository
        self._plan_lifecycle_service = plan_lifecycle_service
        self._event_bus = event_bus
        # In-memory fallback stores (used only when repository is None)
        self._checkpoints: dict[str, Checkpoint] = {}
        self._plan_index: dict[str, list[str]] = {}
        # In-memory guard state (fallback when lifecycle service is absent)
        self._plan_applied: set[str] = set()
        self._plan_sandbox_refs: dict[str, str] = {}

    # ------------------------------------------------------------------
    # Plan state helpers (for guards)
    # ------------------------------------------------------------------

    def mark_plan_applied(self, plan_id: str) -> None:
        """Mark a plan as applied so rollback is rejected.

        Args:
            plan_id: The plan to mark.
        """
        self._plan_applied.add(plan_id)
        # Emit SANDBOX_COMMITTED event when plan is applied
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.SANDBOX_COMMITTED,
                        plan_id=plan_id,
                        details={
                            "sandbox_ref": self._plan_sandbox_refs.get(plan_id, ""),
                        },
                    )
                )
            except Exception:
                logger.warning(
                    "event_bus_emit_failed: SANDBOX_COMMITTED for plan %s",
                    plan_id,
                )

    def register_sandbox(self, plan_id: str, sandbox_ref: str) -> None:
        """Register a sandbox reference for a plan.

        Args:
            plan_id: The plan owning the sandbox.
            sandbox_ref: Sandbox identifier (e.g. worktree path).
        """
        self._plan_sandbox_refs[plan_id] = sandbox_ref
        # Emit SANDBOX_CREATED event
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.SANDBOX_CREATED,
                        plan_id=plan_id,
                        details={
                            "sandbox_ref": sandbox_ref,
                        },
                    )
                )
            except Exception:
                logger.warning(
                    "event_bus_emit_failed: SANDBOX_CREATED for plan %s",
                    plan_id,
                )

    def unregister_sandbox(self, plan_id: str) -> None:
        """Remove the sandbox reference for a plan.

        Args:
            plan_id: The plan whose sandbox was cleaned up.
        """
        self._plan_sandbox_refs.pop(plan_id, None)

    # ------------------------------------------------------------------
    # Core operations
    # ------------------------------------------------------------------

    def create_checkpoint(
        self,
        plan_id: str,
        sandbox_ref: str,
        reason: str = "",
        source_tool: str = "",
        phase: str = "",
        decision_id: str | None = None,
        checkpoint_type: str = "manual",
        resource_id: str | None = None,
        filesystem_path: str = "",
        size_bytes: int | None = None,
        retention_policy: CheckpointRetentionPolicy | None = None,
    ) -> Checkpoint:
        """Create a new checkpoint snapshot.

        Args:
            plan_id: Plan that owns this checkpoint.
            sandbox_ref: Git commit hash or patch reference.
            reason: Human-readable reason.
            source_tool: Tool name that triggered this.
            phase: Plan phase (e.g. ``execute``).
            decision_id: Optional decision ULID this checkpoint aligns to.
            checkpoint_type: One of ``pre_write``, ``post_step``, ``manual``.
            resource_id: Optional resource ULID association.
            filesystem_path: Relative path within checkpoint directory.
            size_bytes: Size of the checkpoint data in bytes.
            retention_policy: Optional policy to enforce on creation.

        Returns:
            The newly created ``Checkpoint``.

        Raises:
            ValidationError: If plan_id or sandbox_ref is empty (via model).
        """
        checkpoint = Checkpoint(
            checkpoint_id=str(ULID()),
            plan_id=plan_id,
            sandbox_ref=sandbox_ref,
            decision_id=decision_id,
            checkpoint_type=checkpoint_type,
            resource_id=resource_id,
            filesystem_path=filesystem_path,
            size_bytes=size_bytes,
            created_at=datetime.now(UTC),
            metadata=CheckpointMetadata(
                reason=reason,
                source_tool=source_tool,
                phase=phase,
            ),
        )

        if self._repository is not None:
            checkpoint = self._repository.create(checkpoint)
        else:
            self._checkpoints[checkpoint.checkpoint_id] = checkpoint
            plan_list = self._plan_index.setdefault(plan_id, [])
            plan_list.append(checkpoint.checkpoint_id)

        logger.info(
            "checkpoint.created",
            extra={
                "checkpoint_id": checkpoint.checkpoint_id,
                "plan_id": plan_id,
                "reason": reason,
            },
        )
        # Emit CHECKPOINT_CREATED event
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.CHECKPOINT_CREATED,
                        plan_id=plan_id,
                        details={
                            "checkpoint_id": checkpoint.checkpoint_id,
                            "sandbox_ref": sandbox_ref,
                            "reason": reason,
                            "checkpoint_type": checkpoint_type,
                        },
                    )
                )
            except Exception:
                logger.warning(
                    "event_bus_emit_failed: CHECKPOINT_CREATED for plan %s",
                    plan_id,
                )

        # Auto-prune: use the supplied policy, or fall back to the default.
        effective_policy = (
            retention_policy
            if retention_policy is not None
            else DEFAULT_RETENTION_POLICY
        )
        self.prune_checkpoints(plan_id, effective_policy)

        return checkpoint

    def rollback_to_checkpoint(
        self,
        plan_id: str,
        checkpoint_id: str,
    ) -> RollbackResult:
        """Restore sandbox state to a named checkpoint via ``git reset --hard``.

        Executes a real ``git reset --hard <sandbox_ref>`` followed by
        ``git clean -fd`` inside the sandbox working directory to revert
        all tracked file changes and remove untracked files added after
        the checkpoint.

        Args:
            plan_id: Plan owning the checkpoint.
            checkpoint_id: Checkpoint to restore.

        Returns:
            A ``RollbackResult`` describing restored files.

        Raises:
            BusinessRuleViolation: If plan is applied, sandbox missing,
                sandbox path is not a git repository, or the git reset
                operation fails.
            ResourceNotFoundError: If checkpoint does not exist.
            ValidationError: If checkpoint does not belong to the plan.
        """
        # Guard: plan must not be applied and sandbox must exist.
        # Prefer querying persistent plan state via the lifecycle service;
        # fall back to in-memory flags when the service is not wired.
        sandbox_path = self._resolve_sandbox_path(plan_id)

        # Look up checkpoint
        checkpoint = self._get_checkpoint(checkpoint_id)

        # Validate ownership
        if checkpoint.plan_id != plan_id:
            raise ValidationError(
                f"Checkpoint {checkpoint_id} does not belong to plan {plan_id}"
            )

        sandbox_ref = checkpoint.sandbox_ref

        # Validate sandbox boundary: path must exist and be a git repo
        self._validate_sandbox(sandbox_path)

        # Compute the list of changed paths before reset for reporting
        changed_paths = self._git_changed_paths(sandbox_path, sandbox_ref)

        # Execute real git reset --hard to restore sandbox to checkpoint
        self._git_reset_hard(sandbox_path, sandbox_ref)

        # Remove untracked files that were added after the checkpoint
        self._git_clean(sandbox_path)

        restored_count = len(changed_paths)

        logger.info(
            "checkpoint.rollback",
            extra={
                "checkpoint_id": checkpoint_id,
                "plan_id": plan_id,
                "sandbox_ref": sandbox_ref,
                "restored_files_count": restored_count,
                "changed_paths": changed_paths,
            },
        )

        # Emit domain events for rollback
        if self._event_bus is not None:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.CHECKPOINT_RESTORED,
                        plan_id=plan_id,
                        details={
                            "checkpoint_id": checkpoint_id,
                            "sandbox_ref": sandbox_ref,
                            "restored_files_count": restored_count,
                            "changed_paths": changed_paths,
                        },
                    )
                )
            except Exception:
                logger.warning(
                    "event_bus_emit_failed: CHECKPOINT_RESTORED for plan %s",
                    plan_id,
                )
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.SANDBOX_ROLLED_BACK,
                        plan_id=plan_id,
                        details={
                            "checkpoint_id": checkpoint_id,
                            "sandbox_ref": sandbox_ref,
                            "restored_files_count": restored_count,
                        },
                    )
                )
            except Exception:
                logger.warning(
                    "event_bus_emit_failed: SANDBOX_ROLLED_BACK for plan %s",
                    plan_id,
                )

        return RollbackResult(
            restored_files_count=restored_count,
            changed_paths=changed_paths,
            from_checkpoint_id=checkpoint_id,
        )

    def list_checkpoints(self, plan_id: str) -> list[Checkpoint]:
        """List all checkpoints for a plan, ordered by creation time.

        Args:
            plan_id: Plan to list checkpoints for.

        Returns:
            List of ``Checkpoint`` instances in creation order.
        """
        if self._repository is not None:
            return self._repository.list_by_plan(plan_id)

        checkpoint_ids = self._plan_index.get(plan_id, [])
        result: list[Checkpoint] = []
        for cid in checkpoint_ids:
            cp = self._checkpoints.get(cid)
            if cp is not None:
                result.append(cp)
        return result

    def get_checkpoint(self, checkpoint_id: str) -> Checkpoint:
        """Retrieve a single checkpoint by ID.

        Args:
            checkpoint_id: The checkpoint ULID.

        Returns:
            The ``Checkpoint`` instance.

        Raises:
            ResourceNotFoundError: If not found.
        """
        return self._get_checkpoint(checkpoint_id)

    # ------------------------------------------------------------------
    # Workspace snapshot (diff-based)
    # ------------------------------------------------------------------

    def create_workspace_snapshot(
        self,
        plan_id: str,
        sandbox_ref: str,
        decision_id: str,
        *,
        reason: str = "pre-decision snapshot",
        phase: str = "execute",
    ) -> Checkpoint:
        """Create a diff-based workspace snapshot before decision execution.

        Captures only files that differ from the previous checkpoint (or
        the initial sandbox state when no prior checkpoints exist).  The
        diff manifest is stored in the checkpoint metadata ``extra`` dict
        under the ``"diff_paths"`` key.

        Args:
            plan_id: Plan owning the workspace.
            sandbox_ref: Git commit hash of the current HEAD.
            decision_id: Decision about to execute.
            reason: Human-readable snapshot reason.
            phase: Plan phase (defaults to ``"execute"``).

        Returns:
            The newly created ``Checkpoint`` with diff metadata.
        """
        # Compute diff paths against the previous checkpoint
        diff_paths = self._compute_diff_snapshot(plan_id, sandbox_ref)
        diff_size = sum(len(p.encode()) for p in diff_paths)

        checkpoint = self.create_checkpoint(
            plan_id=plan_id,
            sandbox_ref=sandbox_ref,
            reason=reason,
            source_tool="workspace_snapshot",
            phase=phase,
            decision_id=decision_id,
            checkpoint_type="pre_decision",
            size_bytes=diff_size or 0,
        )

        # Store diff manifest in metadata extra
        checkpoint.metadata.extra["diff_paths"] = diff_paths
        checkpoint.metadata.extra["diff_based"] = True
        checkpoint.metadata.extra["diff_hash"] = hashlib.sha256(
            "|".join(sorted(diff_paths)).encode()
        ).hexdigest()

        logger.info(
            "checkpoint.workspace_snapshot_created",
            extra={
                "checkpoint_id": checkpoint.checkpoint_id,
                "plan_id": plan_id,
                "decision_id": decision_id,
                "diff_paths_count": len(diff_paths),
            },
        )

        return checkpoint

    def selective_rollback(
        self,
        plan_id: str,
        checkpoint_id: str,
    ) -> RollbackResult:
        """Roll back to a specific checkpoint with atomic semantics.

        This method wraps :meth:`rollback_to_checkpoint` with
        all-or-nothing transaction semantics.  If the rollback fails
        partway through, a best-effort recovery attempt restores the
        sandbox to its pre-rollback HEAD.

        Args:
            plan_id: Plan owning the checkpoint.
            checkpoint_id: Any checkpoint to roll back to (not just the
                most recent).

        Returns:
            A ``RollbackResult`` on success.

        Raises:
            BusinessRuleViolation: If any rollback step fails and
                recovery also fails.
            ResourceNotFoundError: If checkpoint does not exist.
            ValidationError: If checkpoint does not belong to the plan.
        """
        # Capture current HEAD for recovery
        sandbox_path = self._resolve_sandbox_path(plan_id)
        self._validate_sandbox(sandbox_path)

        try:
            current_head = self._run_git(
                ["rev-parse", "HEAD"], cwd=sandbox_path
            ).stdout.strip()
        except Exception:
            current_head = None

        try:
            result = self.rollback_to_checkpoint(plan_id, checkpoint_id)
            logger.info(
                "checkpoint.selective_rollback_success",
                extra={
                    "plan_id": plan_id,
                    "checkpoint_id": checkpoint_id,
                    "restored_files_count": result.restored_files_count,
                },
            )
            return result
        except Exception:
            # Atomic rollback: attempt recovery to original HEAD
            if current_head is not None:
                try:
                    self._git_reset_hard(sandbox_path, current_head)
                    self._git_clean(sandbox_path)
                    logger.warning(
                        "checkpoint.selective_rollback_recovered",
                        extra={
                            "plan_id": plan_id,
                            "checkpoint_id": checkpoint_id,
                            "recovered_to": current_head,
                        },
                    )
                except Exception:
                    logger.error(
                        "checkpoint.selective_rollback_recovery_failed",
                        extra={
                            "plan_id": plan_id,
                            "checkpoint_id": checkpoint_id,
                        },
                        exc_info=True,
                    )
            raise

    def archive_artifacts(
        self,
        sandbox_path: str,
        artifact_paths: list[str],
        archive_dir: str | None = None,
    ) -> list[str]:
        """Physically move artifact files to an archive directory.

        Moves each file from ``sandbox_path / artifact`` to the archive
        directory, preserving relative paths.  Files that do not exist
        are silently skipped.

        Args:
            sandbox_path: Filesystem path to the sandbox root.
            artifact_paths: Relative paths of artifacts to archive.
            archive_dir: Override archive directory.  Defaults to
                ``sandbox_path / .cleveragents / archived_artifacts``.

        Returns:
            List of paths that were successfully archived.
        """
        sandbox = Path(sandbox_path)
        archive_root = (
            Path(archive_dir)
            if archive_dir
            else sandbox / ".cleveragents" / "archived_artifacts"
        )
        archived: list[str] = []

        for rel_path in artifact_paths:
            source = sandbox / rel_path
            if not source.exists():
                continue
            dest = archive_root / rel_path
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(source), str(dest))
            archived.append(rel_path)

        if archived:
            logger.info(
                "checkpoint.artifacts_archived",
                extra={
                    "sandbox_path": sandbox_path,
                    "archived_count": len(archived),
                    "archived_paths": archived,
                },
            )

        return archived

    # ------------------------------------------------------------------
    # Diff computation
    # ------------------------------------------------------------------

    def _compute_diff_snapshot(
        self,
        plan_id: str,
        current_ref: str,
    ) -> list[str]:
        """Compute changed file paths between the last checkpoint and current ref.

        If no previous checkpoint exists, returns an empty list (the
        first snapshot is the baseline).

        Args:
            plan_id: Plan to query checkpoints for.
            current_ref: Current git ref (commit hash).

        Returns:
            List of relative paths that changed since the last checkpoint.
        """
        existing = self.list_checkpoints(plan_id)
        if not existing:
            return []

        previous_ref = existing[-1].sandbox_ref
        if previous_ref == current_ref:
            return []

        # Try to resolve sandbox path for git diff
        try:
            sandbox_path = self._resolve_sandbox_path(plan_id)
        except Exception:
            # No sandbox available — return empty diff
            return []

        try:
            result = self._run_git(
                ["diff", "--name-only", previous_ref, current_ref],
                cwd=sandbox_path,
            )
            return [line for line in result.stdout.strip().splitlines() if line]
        except Exception:
            logger.warning(
                "checkpoint.diff_computation_failed",
                extra={
                    "plan_id": plan_id,
                    "previous_ref": previous_ref,
                    "current_ref": current_ref,
                },
                exc_info=True,
            )
            return []

    def delete_checkpoint(self, checkpoint_id: str) -> None:
        """Delete a single checkpoint.

        Args:
            checkpoint_id: The checkpoint to remove.

        Raises:
            ResourceNotFoundError: If not found.
        """
        if self._repository is not None:
            deleted = self._repository.delete(checkpoint_id)
            if not deleted:
                raise ResourceNotFoundError(
                    resource_type="checkpoint",
                    resource_id=checkpoint_id,
                )
        else:
            checkpoint = self._checkpoints.pop(checkpoint_id, None)
            if checkpoint is None:
                raise ResourceNotFoundError(
                    resource_type="checkpoint",
                    resource_id=checkpoint_id,
                )
            plan_list = self._plan_index.get(checkpoint.plan_id, [])
            if checkpoint_id in plan_list:
                plan_list.remove(checkpoint_id)

        logger.info(
            "checkpoint.deleted",
            extra={"checkpoint_id": checkpoint_id},
        )

    def prune_checkpoints(
        self,
        plan_id: str,
        policy: CheckpointRetentionPolicy,
    ) -> list[str]:
        """Enforce retention policy by removing oldest checkpoints.

        Preserves the first (earliest) and most recent checkpoints.
        Only interior checkpoints are eligible for removal.

        Args:
            plan_id: Plan to prune checkpoints for.
            policy: The retention policy to enforce.

        Returns:
            List of pruned checkpoint IDs.
        """
        if not policy.auto_prune:
            return []

        if self._repository is not None:
            pruned = self._repository.prune(plan_id, policy.max_checkpoints)
        else:
            pruned = self._prune_in_memory(plan_id, policy.max_checkpoints)

        if pruned:
            logger.info(
                "checkpoint.pruned",
                extra={
                    "plan_id": plan_id,
                    "pruned_count": len(pruned),
                    "pruned_ids": pruned,
                },
            )

        return pruned

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _resolve_sandbox_path(self, plan_id: str) -> str:
        """Resolve and validate the sandbox working directory for a plan.

        Applies guard checks (plan must not be applied, sandbox must
        exist) and returns the filesystem path of the sandbox.

        Args:
            plan_id: The plan whose sandbox to resolve.

        Returns:
            The sandbox filesystem path.

        Raises:
            BusinessRuleViolation: If plan is applied or sandbox missing.
            ResourceNotFoundError: If plan not found (lifecycle path).
        """
        if self._plan_lifecycle_service is not None:
            plan = self._plan_lifecycle_service.get_plan(plan_id)
            if plan is None:  # pragma: no cover - defensive
                raise ResourceNotFoundError(resource_type="plan", resource_id=plan_id)
            from cleveragents.domain.models.core.plan import ProcessingState

            if plan.processing_state == ProcessingState.APPLIED:
                raise BusinessRuleViolation("Cannot rollback: plan is already applied")
            if not plan.sandbox_refs:
                raise BusinessRuleViolation(
                    "Cannot rollback: sandbox is missing for this plan"
                )
            return str(plan.sandbox_refs[0])

        # In-memory fallback (used in unit tests without lifecycle service)
        if plan_id in self._plan_applied:
            raise BusinessRuleViolation("Cannot rollback: plan is already applied")
        if plan_id not in self._plan_sandbox_refs:
            raise BusinessRuleViolation(
                "Cannot rollback: sandbox is missing for this plan"
            )
        return self._plan_sandbox_refs[plan_id]

    def _validate_sandbox(self, sandbox_path: str) -> None:
        """Validate that the sandbox path exists and is a git repository.

        Args:
            sandbox_path: Filesystem path to the sandbox.

        Raises:
            BusinessRuleViolation: If the path does not exist or is not
                a git repository.
        """
        path = Path(sandbox_path)
        if not path.is_dir():
            raise BusinessRuleViolation(
                f"Cannot rollback: sandbox path does not exist: {sandbox_path}"
            )
        git_dir = path / ".git"
        if not git_dir.exists():
            raise BusinessRuleViolation(
                f"Cannot rollback: sandbox is not a git repository: {sandbox_path}"
            )

    def _run_git(
        self,
        args: list[str],
        cwd: str,
    ) -> subprocess.CompletedProcess[str]:
        """Run a git command inside the sandbox directory.

        Args:
            args: Git subcommand and arguments.
            cwd: Working directory for the git process.

        Returns:
            The completed process result.

        Raises:
            BusinessRuleViolation: If the git command fails.
        """
        try:
            return subprocess.run(
                ["git", *args],
                cwd=cwd,
                capture_output=True,
                text=True,
                check=True,
                timeout=60,
            )
        except subprocess.CalledProcessError as exc:
            raise BusinessRuleViolation(
                f"Git operation failed: git {' '.join(args)}: {exc.stderr.strip()}"
            ) from exc
        except subprocess.TimeoutExpired as exc:
            raise BusinessRuleViolation(
                f"Git operation timed out: git {' '.join(args)}"
            ) from exc

    def _git_changed_paths(self, sandbox_path: str, target_ref: str) -> list[str]:
        """Compute the list of files that differ between HEAD and target ref.

        Args:
            sandbox_path: Filesystem path to the sandbox.
            target_ref: Git commit reference to compare against.

        Returns:
            List of relative file paths that changed.
        """
        result = self._run_git(
            ["diff", "--name-only", target_ref, "HEAD"],
            cwd=sandbox_path,
        )
        paths = [line for line in result.stdout.strip().splitlines() if line]
        # Also include untracked files that would be removed by git clean
        untracked_result = self._run_git(
            ["ls-files", "--others", "--exclude-standard"],
            cwd=sandbox_path,
        )
        untracked = [
            line for line in untracked_result.stdout.strip().splitlines() if line
        ]
        all_paths = list(dict.fromkeys(paths + untracked))
        return all_paths

    def _git_reset_hard(self, sandbox_path: str, target_ref: str) -> None:
        """Execute ``git reset --hard <target_ref>`` in the sandbox.

        Enforces sandbox boundary by running the command only within the
        sandbox directory.

        Args:
            sandbox_path: Filesystem path to the sandbox.
            target_ref: Git commit reference to reset to.
        """
        self._run_git(["reset", "--hard", target_ref], cwd=sandbox_path)

    def _git_clean(self, sandbox_path: str) -> None:
        """Execute ``git clean -fd`` to remove untracked files and dirs.

        Args:
            sandbox_path: Filesystem path to the sandbox.
        """
        self._run_git(["clean", "-fd"], cwd=sandbox_path)

    def _get_checkpoint(self, checkpoint_id: str) -> Checkpoint:
        """Retrieve a checkpoint via repo or in-memory store."""
        if self._repository is not None:
            from cleveragents.infrastructure.database.repositories import (
                CheckpointNotFoundError,
            )

            try:
                return self._repository.get_by_id(checkpoint_id)
            except CheckpointNotFoundError:
                raise ResourceNotFoundError(
                    resource_type="checkpoint",
                    resource_id=checkpoint_id,
                ) from None

        checkpoint = self._checkpoints.get(checkpoint_id)
        if checkpoint is None:
            raise ResourceNotFoundError(
                resource_type="checkpoint",
                resource_id=checkpoint_id,
            )
        return checkpoint

    def _prune_in_memory(self, plan_id: str, max_checkpoints: int) -> list[str]:
        """Prune in-memory checkpoints, preserving first and most recent."""
        plan_list = self._plan_index.get(plan_id, [])
        if len(plan_list) <= max_checkpoints or len(plan_list) < 3:
            return []

        # Preserve first and last; only interior IDs are eligible for removal.
        interior = plan_list[1:-1]

        excess = len(plan_list) - max_checkpoints
        to_remove = interior[:excess]

        pruned: list[str] = []
        for cid in to_remove:
            self._checkpoints.pop(cid, None)
            pruned.append(cid)

        # Rebuild plan_list without pruned IDs
        self._plan_index[plan_id] = [cid for cid in plan_list if cid not in set(pruned)]

        return pruned


__all__ = [
    "CheckpointService",
]
