"""Correction service implementing revert and append flows.

Orchestrates impact analysis using BFS subtree traversal over both the
structural tree (parent-child) and the influence DAG
(``decision_dependencies`` edges), dry-run reporting, revert execution
(checkpoint restoration + actor state recovery + re-execution signalling),
and append execution (child plan spawning).

The revert flow implements the full re-execution pipeline specified in
§ Correction Flow (Revert Mode):

1. **Resource rollback** — delegates to ``CheckpointService`` when a
   decision-aligned checkpoint exists for the target decision.
2. **Reasoning rollback** — extracts ``actor_state_ref`` from the target
   decision's ``ContextSnapshot`` and includes it in the result for
   downstream LangGraph checkpoint restoration.
3. **Guidance injection** — creates a ``user_intervention`` decision ID
   so callers can record the user's correction guidance in the tree.
4. **Phase transition** — signals that the plan should re-enter the
   Strategize phase from the corrected decision point.
"""

from __future__ import annotations

from collections import deque
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import structlog
from ulid import ULID

from cleveragents.application.services.checkpoint_service import CheckpointService
from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError
from cleveragents.domain.models.core.correction import (
    CorrectionAttempt,
    CorrectionDryRunReport,
    CorrectionImpact,
    CorrectionMode,
    CorrectionRequest,
    CorrectionResult,
    CorrectionStatus,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.domain.models.core.decision import ContextSnapshot, Decision
    from cleveragents.infrastructure.events.protocol import EventBus

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# Risk-level thresholds
# ---------------------------------------------------------------------------
_RISK_LOW_MAX = 3
_RISK_MEDIUM_MAX = 10

# ---------------------------------------------------------------------------
# Cost / time estimation constants
# ---------------------------------------------------------------------------
_COST_PER_DECISION = 1.5
_RECOMPUTE_SECONDS_PER_DECISION = 2.0

# ---------------------------------------------------------------------------
# Terminal correction statuses (immutable after execution)
# ---------------------------------------------------------------------------
_TERMINAL_STATUSES: frozenset[CorrectionStatus] = frozenset(
    {
        CorrectionStatus.APPLIED,
        CorrectionStatus.FAILED,
        CorrectionStatus.CANCELLED,
        CorrectionStatus.REJECTED,
    }
)

# ---------------------------------------------------------------------------
# Executable / cancellable statuses (shared by guards)
# ---------------------------------------------------------------------------
_EXECUTABLE_STATUSES: frozenset[CorrectionStatus] = frozenset(
    {CorrectionStatus.PENDING, CorrectionStatus.ANALYZING}
)

# ---------------------------------------------------------------------------
# Maximum decision tree size (DoS protection)
# ---------------------------------------------------------------------------
_MAX_TREE_NODES = 50_000


class CorrectionService:
    """Service for creating, analysing, and executing decision corrections.

    State is held in-memory via dictionaries keyed by ``correction_id``.
    A production deployment would swap these for repository adapters.

    When a ``CheckpointService`` is provided, revert execution will
    delegate sandbox restoration to the checkpoint rollback flow, allowing
    reuse of the same mechanism for both explicit CLI rollback and
    decision-correction reverts.
    """

    def __init__(
        self,
        checkpoint_service: CheckpointService | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        self._corrections: dict[str, CorrectionRequest] = {}
        self._impacts: dict[str, CorrectionImpact] = {}
        self._attempts: dict[str, list[CorrectionAttempt]] = {}
        self._results: dict[str, CorrectionResult] = {}
        self._checkpoint_service = checkpoint_service
        self._event_bus = event_bus

    # ------------------------------------------------------------------
    # Event emission helper
    # ------------------------------------------------------------------

    def _emit_correction_applied(
        self,
        correction_id: str,
        request: CorrectionRequest,
        result: CorrectionResult,
        attempt_id: str | None = None,
    ) -> None:
        """Emit a ``CORRECTION_APPLIED`` event when the result is successful."""
        if self._event_bus is not None and result.status == CorrectionStatus.APPLIED:
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.CORRECTION_APPLIED,
                        plan_id=request.plan_id,
                        details={
                            "correction_id": correction_id,
                            "attempt_id": attempt_id,
                            "target_decision_id": request.target_decision_id,
                            "mode": request.mode.value
                            if hasattr(request.mode, "value")
                            else str(request.mode),
                            "guidance": request.guidance,
                        },
                    )
                )
            except Exception:
                logger.error(
                    "event_bus_emit_failed",
                    event_type="CORRECTION_APPLIED",
                    correction_id=correction_id,
                    exc_info=True,
                )

    # ------------------------------------------------------------------
    # Creation
    # ------------------------------------------------------------------

    def request_correction(
        self,
        plan_id: str,
        target_decision_id: str,
        mode: CorrectionMode,
        guidance: str = "",
        dry_run: bool = False,
    ) -> CorrectionRequest:
        """Create and register a new correction request.

        Args:
            plan_id: Plan owning the decision tree.
            target_decision_id: Decision node to target.
            mode: ``CorrectionMode.REVERT`` or ``CorrectionMode.APPEND``.
            guidance: Optional human guidance text.
            dry_run: If *True*, only impact analysis will be performed.

        Returns:
            The newly created ``CorrectionRequest``.

        Raises:
            ValidationError: If required parameters are empty.
        """
        if not plan_id or not plan_id.strip():
            raise ValidationError("plan_id must not be empty")
        if not target_decision_id or not target_decision_id.strip():
            raise ValidationError("target_decision_id must not be empty")

        request = CorrectionRequest(
            plan_id=plan_id,
            target_decision_id=target_decision_id,
            mode=mode,
            guidance=guidance,
            dry_run=dry_run,
        )
        self._corrections[request.correction_id] = request
        self._attempts[request.correction_id] = []

        logger.info(
            "correction.requested",
            correction_id=request.correction_id,
            plan_id=plan_id,
            mode=mode,
            dry_run=dry_run,
        )
        return request

    # ------------------------------------------------------------------
    # Impact analysis
    # ------------------------------------------------------------------

    def analyze_impact(
        self,
        correction_id: str,
        decision_tree: dict[str, list[str]] | None = None,
        influence_edges: dict[str, list[str]] | None = None,
    ) -> CorrectionImpact:
        """Compute the impact of a correction via BFS subtree traversal.

        Traverses **both** the structural tree (parent → children) and
        the influence DAG (``decision_dependencies`` edges) to compute
        the full set of transitively affected decisions.  This ensures
        corrections cascade through influence relationships as required
        by the specification (§ Affected Subtree Computation).

        After computing the affected subtree, populates
        ``excluded_decisions`` with all plan decisions that are **not**
        in the affected set, enabling callers to understand which parts
        of the tree remain untouched.

        Args:
            correction_id: Previously created correction request ID.
            decision_tree: Adjacency list mapping parent → children.
            influence_edges: Adjacency list mapping source → targets
                in the influence DAG (``decision_dependencies``).

        Returns:
            ``CorrectionImpact`` with affected nodes, excluded nodes,
            rollback tier depth, and risk level.

        Raises:
            ResourceNotFoundError: If the correction does not exist.
        """
        request = self._get_request_or_raise(correction_id)

        # Guard against pathologically large inputs that could cause
        # unbounded memory / CPU consumption during BFS traversal.
        tree = decision_tree or {}
        dag = influence_edges or {}
        total_keys = len(tree) + len(dag)
        if total_keys > _MAX_TREE_NODES:
            raise ValidationError(
                f"Decision tree + influence DAG too large ({total_keys} keys, "
                f"max {_MAX_TREE_NODES}). Reduce tree size before analyzing."
            )

        # Reject analysis on terminal states to prevent audit-data
        # corruption (the stored impact must match what was used during
        # execution).
        if request.status in _TERMINAL_STATUSES:
            raise ValidationError(
                f"Cannot analyze correction in terminal '{request.status}' "
                "status. Impact data is immutable after execution."
            )

        if request.status == CorrectionStatus.PENDING:
            request.status = CorrectionStatus.ANALYZING

        affected = self._compute_affected_subtree(request.target_decision_id, tree, dag)
        risk = self._classify_risk(len(affected))

        # Collect all decisions known in the plan from the adjacency lists
        all_decisions = self._collect_all_decisions(tree, dag)
        # Guarantee the target is in the universe even if it does not
        # appear in any adjacency list (isolated single-node plan).
        all_decisions.add(request.target_decision_id)
        affected_set = set(affected)
        excluded = sorted(d for d in all_decisions if d not in affected_set)

        # Compute rollback tier depth (hops from target to root)
        tier_depth = self._compute_rollback_tier_depth(request.target_decision_id, tree)

        # Derive artefacts from decision IDs (convention: <id>.artifact)
        artifacts = [f"{d}.artifact" for d in affected]

        impact = CorrectionImpact(
            affected_decisions=affected,
            excluded_decisions=excluded,
            # TODO: affected_files and artifacts_to_archive use synthetic
            # placeholders derived from decision IDs.  Replace with real
            # file / artifact tracking once the resource-rollback layer
            # is integrated (see spec § Mid-Execute Correction).
            affected_files=[f"{d}.py" for d in affected],
            affected_child_plans=[],
            estimated_cost=float(len(affected)) * _COST_PER_DECISION,
            risk_level=risk,
            rollback_tier="full"
            if request.mode == CorrectionMode.REVERT
            else "append_only",
            rollback_tier_depth=tier_depth,
            artifacts_to_archive=artifacts,
        )
        self._impacts[correction_id] = impact

        logger.info(
            "correction.impact_analyzed",
            correction_id=correction_id,
            affected_count=len(affected),
            excluded_count=len(excluded),
            rollback_tier_depth=tier_depth,
            risk_level=risk,
        )
        return impact

    # ------------------------------------------------------------------
    # Dry-run report
    # ------------------------------------------------------------------

    def generate_dry_run_report(
        self,
        correction_id: str,
        decision_tree: dict[str, list[str]] | None = None,
        influence_edges: dict[str, list[str]] | None = None,
    ) -> CorrectionDryRunReport:
        """Generate a dry-run report without executing the correction.

        Calls ``analyze_impact`` internally to compute the affected
        subtree, then assembles warnings, excluded decisions, rollback
        tier depth, and estimated recompute time.  The request status
        is preserved (not mutated) so that generating a preview does
        not advance the correction lifecycle.

        A tier-0 warning is emitted when the root decision is targeted,
        indicating the entire decision tree will be affected.

        Args:
            correction_id: Correction request ID.
            decision_tree: Optional decision tree adjacency list.
            influence_edges: Optional influence DAG adjacency list.

        Returns:
            ``CorrectionDryRunReport`` describing what *would* happen.
        """
        request = self._get_request_or_raise(correction_id)

        # Preserve original status and impact — dry-run is conceptually
        # read-only.  The try/finally ensures both are restored even when
        # analyze_impact raises after transitioning the status.
        original_status = request.status
        original_impact = self._impacts.get(correction_id)
        try:
            impact = self.analyze_impact(correction_id, decision_tree, influence_edges)
        finally:
            request.status = original_status
            if original_impact is None:
                self._impacts.pop(correction_id, None)
            else:
                self._impacts[correction_id] = original_impact

        warnings: list[str] = []
        if impact.risk_level == "high":
            warnings.append(
                "High risk: more than 10 decisions affected. "
                "Review carefully before executing."
            )
        elif impact.risk_level == "medium":
            warnings.append("Medium risk: 4-10 decisions affected.")
        if request.mode == CorrectionMode.REVERT and len(impact.affected_decisions) > 1:
            warnings.append(
                f"Revert will invalidate {len(impact.affected_decisions)} decisions "
                "and archive associated artifacts."
            )
        # Only emit a tier-0 warning when the target is genuinely the
        # tree root.  Compare against the actual root determined by
        # _find_root to avoid false positives for non-root subtree
        # heads in forest (disconnected) topologies.
        tree = decision_tree or {}
        actual_root = self._find_root(tree)
        if (
            impact.rollback_tier_depth == 0
            and len(impact.affected_decisions) > 1
            and actual_root is not None
            and request.target_decision_id == actual_root
        ):
            warnings.append(
                "Tier 0: root decision targeted — entire decision tree "
                "will be affected."
            )

        recompute_seconds = (
            float(len(impact.affected_decisions)) * _RECOMPUTE_SECONDS_PER_DECISION
        )

        report = CorrectionDryRunReport(
            correction_id=correction_id,
            mode=request.mode,
            impact=impact,
            decisions_to_invalidate=impact.affected_decisions
            if request.mode == CorrectionMode.REVERT
            else [],
            estimated_recompute_time_seconds=recompute_seconds,
            warnings=warnings,
        )

        logger.info(
            "correction.dry_run_generated",
            correction_id=correction_id,
            warning_count=len(warnings),
            rollback_tier_depth=impact.rollback_tier_depth,
            excluded_count=len(impact.excluded_decisions),
        )
        return report

    # ------------------------------------------------------------------
    # Execution: revert
    # ------------------------------------------------------------------

    def execute_revert(
        self,
        correction_id: str,
        decision_tree: dict[str, list[str]] | None = None,
        influence_edges: dict[str, list[str]] | None = None,
        decisions: dict[str, Decision] | None = None,
    ) -> CorrectionResult:
        """Execute a revert correction with full re-execution pipeline.

        Implements the specification's Correction Flow (Revert Mode):

        1. **Resource rollback**: When a ``CheckpointService`` is
           available and the target decision has a decision-aligned
           checkpoint, delegates to ``rollback_to_checkpoint`` to
           execute a real ``git reset --hard`` in the sandbox.

        2. **Reasoning rollback**: Extracts the ``actor_state_ref``
           from the target decision's ``context_snapshot`` and returns
           it in the result so downstream consumers can restore the
           LangGraph actor's reasoning state.

        3. **Guidance injection**: Generates a ``user_intervention``
           decision ID for callers to record the user's correction
           guidance in the decision tree.

        4. **Phase transition**: Signals that the plan should re-enter
           the Strategize phase from the corrected decision point by
           setting ``phase_transition_target`` to ``"strategize"``.

        Args:
            correction_id: Correction request ID.
            decision_tree: Optional structural tree adjacency list.
            influence_edges: Optional influence DAG adjacency list.
            decisions: Optional mapping of decision_id → ``Decision``
                objects.  When provided, the target decision's
                ``context_snapshot.actor_state_ref`` is extracted for
                reasoning rollback, and its ``decision_id`` is used to
                look up the checkpoint for resource rollback.

        Returns:
            ``CorrectionResult`` with reverted decisions plus
            re-execution metadata (checkpoint_restored,
            actor_state_ref, user_intervention_decision_id,
            phase_transition_target).

        Raises:
            ResourceNotFoundError: If correction does not exist.
            ValidationError: If correction is not in PENDING or ANALYZING
                status, or if the correction mode is not REVERT.
        """
        request = self._get_request_or_raise(correction_id)
        if request.mode != CorrectionMode.REVERT:
            raise ValidationError(
                f"execute_revert requires mode=REVERT, got mode={request.mode.value!r}."
            )
        self._assert_executable(request)

        attempt = CorrectionAttempt(correction_id=correction_id)
        self._attempts[correction_id].append(attempt)

        # Transition through ANALYZING before EXECUTING so the full
        # state-machine lifecycle is honoured.
        request.status = CorrectionStatus.ANALYZING

        try:
            # Use previously cached impact when available to avoid
            # redundant O(V+E) recomputation.
            impact = self._impacts.get(correction_id)
            if impact is None:
                impact = self.analyze_impact(
                    correction_id, decision_tree, influence_edges
                )
            request.status = CorrectionStatus.EXECUTING

            # --- Resource rollback (spec § Mid-Execute Correction) ---
            checkpoint_restored = self._try_checkpoint_restoration(
                request.plan_id,
                request.target_decision_id,
            )

            # --- Reasoning rollback (spec § actor_state_ref) ---
            actor_state_ref = self._extract_actor_state_ref(
                request.target_decision_id,
                decisions,
            )

            # --- Guidance injection (spec § user_intervention) ---
            user_intervention_id = str(ULID())

            # --- Phase transition signal ---
            # Revert always re-enters Strategize from the decision point.
            phase_target = "strategize"

            # --- Physical artifact archival ---
            archived = self._archive_decision_artifacts(
                request.plan_id,
                impact.artifacts_to_archive,
            )

            result = CorrectionResult(
                correction_id=correction_id,
                status=CorrectionStatus.APPLIED,
                reverted_decisions=impact.affected_decisions,
                archived_artifacts=archived or impact.artifacts_to_archive,
                checkpoint_restored=checkpoint_restored,
                actor_state_ref=actor_state_ref,
                user_intervention_decision_id=user_intervention_id,
                phase_transition_target=phase_target,
            )
            request.status = CorrectionStatus.APPLIED
            attempt.success = True
            attempt.details = {
                "checkpoint_restored": checkpoint_restored,
                "actor_state_ref": actor_state_ref,
                "user_intervention_decision_id": user_intervention_id,
                "phase_transition_target": phase_target,
            }
        except Exception as exc:
            logger.error(
                "correction.revert_failed",
                correction_id=correction_id,
                exc_info=True,
            )
            result = CorrectionResult(
                correction_id=correction_id,
                status=CorrectionStatus.FAILED,
                error_message=str(exc),
            )
            request.status = CorrectionStatus.FAILED
            attempt.success = False
            attempt.details = {"error": str(exc)}
        finally:
            attempt.completed_at = datetime.now(UTC)

        self._results[correction_id] = result
        logger.info(
            "correction.revert_executed",
            correction_id=correction_id,
            status=result.status,
            checkpoint_restored=result.checkpoint_restored,
            actor_state_ref=result.actor_state_ref,
            phase_transition_target=result.phase_transition_target,
        )
        self._emit_correction_applied(
            correction_id, request, result, attempt_id=attempt.attempt_id
        )
        return result

    # ------------------------------------------------------------------
    # Execution: append
    # ------------------------------------------------------------------

    def execute_append(
        self,
        correction_id: str,
    ) -> CorrectionResult:
        """Execute an append correction.

        Spawns a new child plan reference and preserves the original
        decision node.

        Args:
            correction_id: Correction request ID.

        Returns:
            ``CorrectionResult`` with the spawned child plan ID.

        Raises:
            ResourceNotFoundError: If correction does not exist.
            ValidationError: If correction is not in PENDING or ANALYZING
                status, or if the correction mode is not APPEND.
        """
        request = self._get_request_or_raise(correction_id)
        if request.mode != CorrectionMode.APPEND:
            raise ValidationError(
                f"execute_append requires mode=APPEND, got mode={request.mode.value!r}."
            )
        self._assert_executable(request)

        attempt = CorrectionAttempt(correction_id=correction_id)
        self._attempts[correction_id].append(attempt)

        # Transition through ANALYZING → EXECUTING for consistent
        # lifecycle across both correction modes.
        request.status = CorrectionStatus.ANALYZING
        request.status = CorrectionStatus.EXECUTING

        try:
            child_plan_id = str(ULID())
            new_decision_id = str(ULID())

            result = CorrectionResult(
                correction_id=correction_id,
                status=CorrectionStatus.APPLIED,
                new_decisions=[new_decision_id],
                spawned_child_plan_id=child_plan_id,
            )
            request.status = CorrectionStatus.APPLIED
            attempt.success = True
            attempt.details = {
                "spawned_child_plan_id": child_plan_id,
                "new_decision_id": new_decision_id,
            }
        except Exception as exc:
            logger.error(
                "correction.append_failed",
                correction_id=correction_id,
                exc_info=True,
            )
            result = CorrectionResult(
                correction_id=correction_id,
                status=CorrectionStatus.FAILED,
                error_message=str(exc),
            )
            request.status = CorrectionStatus.FAILED
            attempt.success = False
            attempt.details = {"error": str(exc)}
        finally:
            attempt.completed_at = datetime.now(UTC)

        self._results[correction_id] = result
        logger.info(
            "correction.append_executed",
            correction_id=correction_id,
            status=result.status,
        )
        self._emit_correction_applied(
            correction_id, request, result, attempt_id=attempt.attempt_id
        )
        return result

    # ------------------------------------------------------------------
    # Dispatch
    # ------------------------------------------------------------------

    def execute_correction(
        self,
        correction_id: str,
        decision_tree: dict[str, list[str]] | None = None,
        influence_edges: dict[str, list[str]] | None = None,
        decisions: dict[str, Decision] | None = None,
    ) -> CorrectionResult:
        """Execute a correction, dispatching to revert or append.

        Args:
            correction_id: Correction request ID.
            decision_tree: Optional structural tree adjacency list
                (used for revert).
            influence_edges: Optional influence DAG adjacency list
                (used for revert).
            decisions: Optional mapping of decision_id → ``Decision``
                objects (used for revert re-execution).

        Returns:
            ``CorrectionResult`` from the chosen strategy.
        """
        request = self._get_request_or_raise(correction_id)

        if request.mode == CorrectionMode.REVERT:
            return self.execute_revert(
                correction_id, decision_tree, influence_edges, decisions
            )
        return self.execute_append(correction_id)

    # ------------------------------------------------------------------
    # Revert decisions (full rollback + artifact archival)
    # ------------------------------------------------------------------

    def revert_decisions(
        self,
        plan_id: str,
        target_decision_id: str,
        decision_tree: dict[str, list[str]] | None = None,
        influence_edges: dict[str, list[str]] | None = None,
        decisions: dict[str, Decision] | None = None,
        guidance: str = "",
    ) -> CorrectionResult:
        """Revert decisions with checkpoint rollback and physical artifact archival.

        This is the high-level entry point that combines:

        1. Creating a correction request.
        2. Computing impact analysis.
        3. Invoking checkpoint rollback via ``CheckpointService``.
        4. Physically archiving artifacts from reverted decisions.
        5. Returning a complete ``CorrectionResult``.

        The method is atomic: if checkpoint rollback fails, no artifacts
        are archived and the correction is marked as FAILED.

        Args:
            plan_id: Plan owning the decision tree.
            target_decision_id: Decision to revert from.
            decision_tree: Structural tree adjacency list.
            influence_edges: Influence DAG adjacency list.
            decisions: Mapping of decision_id → ``Decision`` objects.
            guidance: Human-supplied correction guidance.

        Returns:
            ``CorrectionResult`` with reverted decisions, archived
            artifacts, and re-execution metadata.
        """
        request = self.request_correction(
            plan_id=plan_id,
            target_decision_id=target_decision_id,
            mode=CorrectionMode.REVERT,
            guidance=guidance,
        )
        return self.execute_revert(
            request.correction_id,
            decision_tree=decision_tree,
            influence_edges=influence_edges,
            decisions=decisions,
        )

    # ------------------------------------------------------------------
    # Query helpers
    # ------------------------------------------------------------------

    def get_correction(self, correction_id: str) -> CorrectionRequest:
        """Retrieve a correction request by ID.

        Raises:
            ResourceNotFoundError: If not found.
        """
        return self._get_request_or_raise(correction_id)

    def list_corrections(self, plan_id: str | None = None) -> list[CorrectionRequest]:
        """List corrections, optionally filtered by plan_id."""
        corrections = list(self._corrections.values())
        if plan_id is not None:
            corrections = [c for c in corrections if c.plan_id == plan_id]
        return corrections

    def list_attempts(self, correction_id: str) -> list[CorrectionAttempt]:
        """List execution attempts for a correction.

        Raises:
            ResourceNotFoundError: If correction does not exist.
        """
        self._get_request_or_raise(correction_id)
        return list(self._attempts.get(correction_id, []))

    def cancel_correction(self, correction_id: str) -> CorrectionRequest:
        """Cancel a pending correction.

        Raises:
            ResourceNotFoundError: If correction does not exist.
            ValidationError: If correction is not in a cancellable status.
        """
        request = self._get_request_or_raise(correction_id)
        if request.status not in _EXECUTABLE_STATUSES:
            raise ValidationError(
                f"Cannot cancel correction in '{request.status}' status. "
                f"Cancellation is only allowed in: {sorted(_EXECUTABLE_STATUSES)}"
            )
        request.status = CorrectionStatus.CANCELLED
        logger.info(
            "correction.cancelled",
            correction_id=correction_id,
        )
        return request

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _archive_decision_artifacts(
        self,
        plan_id: str,
        artifact_paths: list[str],
    ) -> list[str]:
        """Physically archive artifacts via the checkpoint service.

        Delegates to ``CheckpointService.archive_artifacts`` when a
        checkpoint service and sandbox path are available.  Falls back
        gracefully when the service is absent or the sandbox cannot be
        resolved.

        Args:
            plan_id: Plan owning the artifacts.
            artifact_paths: Relative paths to archive.

        Returns:
            List of successfully archived paths (may be empty).
        """
        if self._checkpoint_service is None:
            return []
        try:
            sandbox_path = self._checkpoint_service._resolve_sandbox_path(plan_id)
            return self._checkpoint_service.archive_artifacts(
                sandbox_path, artifact_paths
            )
        except Exception as exc:
            logger.debug(
                "correction.artifact_archival_skipped",
                plan_id=plan_id,
                reason=str(exc),
            )
            return []

    # ------------------------------------------------------------------
    # Revert re-execution helpers
    # ------------------------------------------------------------------

    def _try_checkpoint_restoration(
        self,
        plan_id: str,
        target_decision_id: str,
    ) -> bool:
        """Attempt checkpoint restoration for the target decision.

        Queries the ``CheckpointService`` for checkpoints belonging to
        the plan and aligned to the target decision.  If a matching
        checkpoint is found, delegates to
        ``rollback_to_checkpoint`` for real ``git reset --hard``.

        Args:
            plan_id: Plan owning the decision tree.
            target_decision_id: Decision whose checkpoint to restore.

        Returns:
            ``True`` if a checkpoint was successfully restored,
            ``False`` if no checkpoint service or no matching
            checkpoint was available.
        """
        if self._checkpoint_service is None:
            logger.info(
                "correction.checkpoint_skip",
                reason="no_checkpoint_service",
                plan_id=plan_id,
                target_decision_id=target_decision_id,
            )
            return False

        try:
            checkpoints = self._checkpoint_service.list_checkpoints(plan_id)
        except Exception:
            logger.warning(
                "correction.checkpoint_list_failed",
                plan_id=plan_id,
                exc_info=True,
            )
            return False

        # Find the checkpoint aligned to the target decision.
        matching = [cp for cp in checkpoints if cp.decision_id == target_decision_id]

        if not matching:
            logger.info(
                "correction.checkpoint_not_found",
                plan_id=plan_id,
                target_decision_id=target_decision_id,
            )
            return False

        # Use the most recent matching checkpoint.
        checkpoint = matching[-1]
        try:
            self._checkpoint_service.rollback_to_checkpoint(
                plan_id, checkpoint.checkpoint_id
            )
            logger.info(
                "correction.checkpoint_restored",
                plan_id=plan_id,
                checkpoint_id=checkpoint.checkpoint_id,
                target_decision_id=target_decision_id,
            )
            return True
        except Exception:
            logger.warning(
                "correction.checkpoint_rollback_failed",
                plan_id=plan_id,
                checkpoint_id=checkpoint.checkpoint_id,
                exc_info=True,
            )
            return False

    @staticmethod
    def _extract_actor_state_ref(
        target_decision_id: str,
        decisions: dict[str, Decision] | None,
    ) -> str:
        """Extract the actor state reference from the target decision.

        Looks up the target decision in the provided mapping and
        returns its ``context_snapshot.actor_state_ref``.  When the
        mapping is not provided or the decision is not found, returns
        an empty string.

        Args:
            target_decision_id: Decision to extract state from.
            decisions: Optional mapping of decision_id → ``Decision``.

        Returns:
            The ``actor_state_ref`` string, or ``""`` if unavailable.
        """
        if decisions is None:
            return ""
        decision = decisions.get(target_decision_id)
        if decision is None:
            return ""
        snapshot: ContextSnapshot = decision.context_snapshot
        return snapshot.actor_state_ref

    def _get_request_or_raise(self, correction_id: str) -> CorrectionRequest:
        """Look up a correction or raise ``ResourceNotFoundError``."""
        request = self._corrections.get(correction_id)
        if request is None:
            raise ResourceNotFoundError(
                resource_type="correction",
                resource_id=correction_id,
            )
        return request

    def _assert_executable(self, request: CorrectionRequest) -> None:
        """Ensure the correction is in an executable status.

        Raises:
            ValidationError: If the correction was created as dry-run
                only, or if its status is not in the executable set.
        """
        if request.dry_run:
            raise ValidationError(
                "Cannot execute a dry-run correction. "
                "Use generate_dry_run_report() or analyze_impact() instead."
            )
        if request.status not in _EXECUTABLE_STATUSES:
            raise ValidationError(
                f"Cannot execute correction in '{request.status}' status. "
                f"Execution requires status in: {sorted(_EXECUTABLE_STATUSES)}"
            )

    # ------------------------------------------------------------------
    # Rollback tier computation
    # ------------------------------------------------------------------

    def compute_rollback_tier(
        self,
        target_decision_id: str,
        plan_id: str,
        decision_tree: dict[str, list[str]] | None = None,
    ) -> int:
        """Compute the rollback tier (depth) for a target decision.

        The tier is the number of parent hops from the target to the
        tree root:

        - **Tier 0**: the root decision itself is targeted.
        - **Tier 1**: a direct child of the root is targeted.
        - **Tier N**: the target is *N* levels below the root.

        Args:
            target_decision_id: Decision to compute the tier for.
            plan_id: Plan owning the decision tree (reserved for
                future repository look-ups; currently unused beyond
                logging).
            decision_tree: Adjacency list (parent → children).

        Returns:
            Non-negative integer representing the tier depth.
        """
        tree = decision_tree or {}
        depth = self._compute_rollback_tier_depth(target_decision_id, tree)
        logger.info(
            "correction.rollback_tier_computed",
            target_decision_id=target_decision_id,
            plan_id=plan_id,
            tier_depth=depth,
        )
        return depth

    # ------------------------------------------------------------------
    # Subtree isolation validation
    # ------------------------------------------------------------------

    def validate_subtree_isolation(
        self,
        target_decision_id: str,
        decision_tree: dict[str, list[str]],
        influence_edges: dict[str, list[str]] | None = None,
    ) -> bool:
        """Validate that the affected subtree is correctly isolated.

        Confirms two invariants for non-root corrections:

        1. The **root decision** (the node with no parent in the tree)
           is never in the *structural* affected set unless it is
           explicitly the *target* decision.
        2. **Sibling decisions** (children of the same parent as the
           target, excluding the target itself) are not in the
           *structural* affected set.

        Both invariants are checked against the **structural-only**
        affected set (tree traversal without influence edges).  If the
        influence DAG legitimately pulls in a sibling or the root,
        that is expected behaviour per the spec (§ Affected Subtree
        Computation) and is not an isolation violation.

        Args:
            target_decision_id: Decision node that was targeted.
            decision_tree: Structural tree adjacency list.
            influence_edges: Optional influence DAG adjacency list.
                Accepted for API consistency but **not used** in
                isolation checks — structural-only BFS is used per
                the specification (§ Affected Subtree Computation).

        Returns:
            ``True`` if isolation invariants hold, ``False`` otherwise.
        """
        tree = decision_tree

        # Use structural-only BFS for isolation invariant checks so
        # that influence-DAG-caused reachability is not misreported
        # as a violation.
        structural_affected = self._compute_affected_subtree(
            target_decision_id,
            tree,
            influence_edges=None,
        )
        structural_set = set(structural_affected)

        # Find the root (node that never appears as a child)
        root = self._find_root(tree)
        if root is None:
            # Empty or flat tree — nothing to validate
            return True

        # Invariant 1: root not in structural affected set unless
        # explicitly targeted
        if root in structural_set and root != target_decision_id:
            logger.warning(
                "correction.isolation_violation_root",
                root=root,
                target=target_decision_id,
            )
            return False

        # Invariant 2: siblings of target not in structural affected set
        parent = self._find_parent(target_decision_id, tree)
        if parent is not None:
            siblings = [
                child for child in tree.get(parent, []) if child != target_decision_id
            ]
            for sibling in siblings:
                if sibling in structural_set:
                    logger.warning(
                        "correction.isolation_violation_sibling",
                        sibling=sibling,
                        target=target_decision_id,
                    )
                    return False

        return True

    @staticmethod
    def _compute_affected_subtree(
        target_id: str,
        tree: dict[str, list[str]],
        influence_edges: dict[str, list[str]] | None = None,
    ) -> list[str]:
        """BFS walk from *target_id* through structural tree AND influence DAG.

        Traverses **both** the structural tree (parent → children) and
        the influence DAG (``decision_dependencies`` edges) to compute
        the union of all transitively affected decisions.

        Cycle detection is built-in via the ``visited`` set: if a node
        has already been visited it is skipped, preventing infinite
        loops even when the influence DAG contains corrupted cycles.

        Complexity: O(V + E) where V = decisions, E = tree + DAG edges.

        Args:
            target_id: Root decision to start BFS from.
            tree: Structural tree adjacency list (parent → children).
            influence_edges: Influence DAG adjacency list
                (source → targets).  Optional; when ``None`` only the
                structural tree is traversed.

        Returns:
            All reachable node IDs (inclusive of the target itself),
            in BFS visit order.
        """
        dag = influence_edges or {}
        affected: list[str] = []
        # A single ``visited`` set tracks every node that has been
        # dequeued and processed.  New neighbors are only enqueued
        # when not yet in ``visited``, preventing both duplicate
        # processing and infinite loops from cycles.  When a neighbor
        # is already visited it means we encountered a back-edge
        # (cycle in the structural tree or influence DAG); we log the
        # cycle for operational observability.
        visited: set[str] = set()
        queue: deque[str] = deque([target_id])

        while queue:
            node = queue.popleft()
            if node in visited:
                # Node was already enqueued by a different parent
                # (convergent / diamond topology) — skip silently.
                continue
            visited.add(node)
            affected.append(node)

            # Follow structural tree children first, then influence DAG
            # dependents.
            for neighbor in tree.get(node, []):
                if neighbor in visited:
                    logger.warning(
                        "correction.cycle_detected",
                        node=neighbor,
                        source=node,
                        edge_type="structural",
                        msg="Back-edge detected during BFS "
                        "(cycle in decision tree or influence DAG)",
                    )
                elif neighbor not in visited:
                    queue.append(neighbor)
            for neighbor in dag.get(node, []):
                if neighbor in visited:
                    logger.warning(
                        "correction.cycle_detected",
                        node=neighbor,
                        source=node,
                        edge_type="influence",
                        msg="Back-edge detected during BFS "
                        "(cycle in decision tree or influence DAG)",
                    )
                elif neighbor not in visited:
                    queue.append(neighbor)

        influence_count = sum(len(v) for v in dag.values()) if dag else 0
        if influence_count > 0:
            logger.info(
                "correction.influence_traversal",
                target_id=target_id,
                total_affected=len(affected),
                influence_edge_count=influence_count,
            )

        return affected

    @staticmethod
    def _classify_risk(affected_count: int) -> str:
        """Classify risk level based on affected subtree size."""
        if affected_count <= _RISK_LOW_MAX:
            return "low"
        if affected_count <= _RISK_MEDIUM_MAX:
            return "medium"
        return "high"

    @staticmethod
    def _collect_all_decisions(
        tree: dict[str, list[str]],
        dag: dict[str, list[str]],
    ) -> set[str]:
        """Collect every decision ID from both the tree and DAG edges.

        Gathers all nodes that appear as either keys or values in the
        structural tree and influence DAG adjacency lists.
        """
        all_ids: set[str] = set()
        for parent, children in tree.items():
            all_ids.add(parent)
            all_ids.update(children)
        for source, targets in dag.items():
            all_ids.add(source)
            all_ids.update(targets)
        return all_ids

    @staticmethod
    def _compute_rollback_tier_depth(
        target_id: str,
        tree: dict[str, list[str]],
    ) -> int:
        """Count parent hops from *target_id* up to the tree root.

        Builds a child → parent mapping from the adjacency list, then
        walks upward from the target.  Returns ``0`` when the target
        is the root **or** when the target is not present in the tree.

        .. note::

            A return value of ``0`` is ambiguous: it can mean either
            "the target is the tree root" or "the target was not found
            in the tree."  Callers that need to distinguish these cases
            should check whether the target appears in the tree before
            calling this method.
        """
        child_to_parent: dict[str, str] = {}
        for parent, children in tree.items():
            for child in children:
                child_to_parent[child] = parent

        depth = 0
        current = target_id
        visited: set[str] = set()
        while current in child_to_parent and current not in visited:
            visited.add(current)
            current = child_to_parent[current]
            depth += 1

        return depth

    @staticmethod
    def _find_root(tree: dict[str, list[str]]) -> str | None:
        """Find the root node of the tree (not a child of any node).

        Returns ``None`` if the tree is empty.

        .. note::

            If the tree is a forest (multiple disconnected subtrees)
            the first parent that is not a child of any other node is
            returned, following Python dict insertion order.  For
            degenerate cases where every node appears as someone's
            child (cycles), the first key is returned as a fallback.
        """
        if not tree:
            return None
        all_children: set[str] = set()
        for children in tree.values():
            all_children.update(children)
        for parent in tree:
            if parent not in all_children:
                return parent
        # Fallback: return the first key (degenerate tree)
        return next(iter(tree))

    @staticmethod
    def _find_parent(
        target_id: str,
        tree: dict[str, list[str]],
    ) -> str | None:
        """Find the parent of *target_id* in the tree, or ``None``."""
        for parent, children in tree.items():
            if target_id in children:
                return parent
        return None


__all__ = [
    "CorrectionService",
]
