"""Plan Resume Service for CleverAgents v3.

Implements step-level progress persistence and plan resume with
graceful shutdown handling.

## Resume Flow

1. Validate resume eligibility (non-terminal plans only).
2. Build a ``ResumeSummary`` (phase, step, decision_id).
3. In dry-run mode, return the summary without changing state.
4. In live mode, reset the plan to ``PROCESSING`` and restart
   execution from ``last_completed_step + 1``.

## Checkpoints

Checkpoints are recorded at each completed step, tied to a
decision ID and sandbox reference. On resume, execution picks up
after the last checkpoint.

## Graceful Shutdown

``record_shutdown`` persists the current step index before the
process exits so that ``plan resume`` can pick up later.
"""

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING

import structlog
from ulid import ULID

from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.plan import (
    PlanPhase,
    ProcessingState,
)
from cleveragents.domain.models.core.resume import (
    ResumeCheckpoint,
    ResumeEligibility,
    ResumeIneligibleReason,
    ResumeMetadata,
    ResumeSummary,
)

if TYPE_CHECKING:
    from cleveragents.application.services.plan_lifecycle_service import (
        PlanLifecycleService,
    )

logger = structlog.get_logger(__name__)


class PlanResumeService:
    """Service for resuming interrupted or errored plan executions.

    Works with ``PlanLifecycleService`` to validate state and
    ``PlanExecutor`` to re-drive execution from the resume point.
    """

    def __init__(
        self,
        lifecycle_service: PlanLifecycleService,
    ) -> None:
        """Initialize the plan resume service.

        Args:
            lifecycle_service: The plan lifecycle service for plan access.

        Raises:
            ValidationError: If lifecycle_service is None.
        """
        if lifecycle_service is None:
            raise ValidationError("lifecycle_service must not be None")
        self._lifecycle = lifecycle_service
        self._logger = logger.bind(service="plan_resume")
        self._resume_metadata: dict[str, ResumeMetadata] = {}

    def get_resume_metadata(self, plan_id: str) -> ResumeMetadata:
        """Get or create resume metadata for a plan.

        Args:
            plan_id: The plan ULID.

        Returns:
            The ResumeMetadata for the plan.

        Raises:
            ValidationError: If plan_id is empty.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        plan = self._lifecycle.get_plan(plan_id)

        if plan_id not in self._resume_metadata:
            self._resume_metadata[plan_id] = ResumeMetadata(
                last_completed_step=plan.last_completed_step,
                last_checkpoint_id=plan.last_checkpoint_id,
            )
        return self._resume_metadata[plan_id]

    def validate_eligibility(self, plan_id: str) -> ResumeEligibility:
        """Validate whether a plan can be resumed.

        A plan is eligible for resume when it is not in a permanently
        terminal state (applied, cancelled, constrained).  Note that
        ``ERRORED`` is terminal per ``Plan.is_terminal`` but is still
        eligible for resume here — it represents a recoverable failure,
        not a permanent outcome.

        Args:
            plan_id: The plan ULID.

        Returns:
            A ResumeEligibility result.

        Raises:
            ValidationError: If plan_id is empty.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        plan = self._lifecycle.get_plan(plan_id)

        # Terminal states cannot be resumed
        if plan.processing_state == ProcessingState.APPLIED:
            return ResumeEligibility(
                eligible=False,
                reason=(
                    f"Plan {plan_id} is in terminal state 'applied' "
                    "and cannot be resumed."
                ),
                ineligible_reason=ResumeIneligibleReason.TERMINAL_APPLIED,
            )

        if plan.processing_state == ProcessingState.CANCELLED:
            return ResumeEligibility(
                eligible=False,
                reason=(
                    f"Plan {plan_id} is in terminal state 'cancelled' "
                    "and cannot be resumed."
                ),
                ineligible_reason=ResumeIneligibleReason.TERMINAL_CANCELLED,
            )

        if plan.processing_state == ProcessingState.CONSTRAINED:
            return ResumeEligibility(
                eligible=False,
                reason=(
                    f"Plan {plan_id} is in terminal state 'constrained' "
                    "and cannot be resumed."
                ),
                ineligible_reason=ResumeIneligibleReason.TERMINAL_CONSTRAINED,
            )

        # ACTION phase cannot be resumed (it's a template phase)
        if plan.phase == PlanPhase.ACTION:
            return ResumeEligibility(
                eligible=False,
                reason=(
                    f"Plan {plan_id} is in ACTION phase "
                    "(template phase) and cannot be resumed."
                ),
                ineligible_reason=ResumeIneligibleReason.ACTION_PHASE,
            )

        # Eligible for resume
        return ResumeEligibility(
            eligible=True,
            reason=f"Plan {plan_id} is eligible for resume.",
        )

    def build_resume_summary(self, plan_id: str) -> ResumeSummary:
        """Build a summary of where execution will resume.

        Args:
            plan_id: The plan ULID.

        Returns:
            A ResumeSummary with phase, step, decision_id details.

        Raises:
            ValidationError: If plan_id is empty.
            PlanError: If plan is not eligible for resume.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        eligibility = self.validate_eligibility(plan_id)
        if not eligibility.eligible:
            raise PlanError(eligibility.reason)

        plan = self._lifecycle.get_plan(plan_id)
        metadata = self.get_resume_metadata(plan_id)

        # Resolve decision_id from last checkpoint
        decision_id: str | None = None
        sandbox_ref: str | None = None
        if metadata.checkpoints:
            last_cp = metadata.checkpoints[-1]
            decision_id = last_cp.decision_id
            sandbox_ref = last_cp.sandbox_ref

        return ResumeSummary(
            plan_id=plan.identity.plan_id,
            phase=plan.phase.value,
            processing_state=plan.processing_state.value,
            last_completed_step=metadata.last_completed_step,
            next_step_index=metadata.next_step_index,
            total_steps=metadata.total_steps,
            decision_id=decision_id,
            last_checkpoint_id=metadata.last_checkpoint_id,
            sandbox_ref=sandbox_ref,
        )

    def resume_plan(
        self,
        plan_id: str,
        dry_run: bool = False,
    ) -> ResumeSummary:
        """Resume a plan from its last checkpoint.

        In dry-run mode, shows where execution will resume without
        changing any state. In live mode, resets the plan to
        PROCESSING and returns the summary for the caller to
        re-drive execution.

        Args:
            plan_id: The plan ULID.
            dry_run: If True, show resume point without executing.

        Returns:
            A ResumeSummary describing the resume point.

        Raises:
            ValidationError: If plan_id is empty.
            PlanError: If plan is not eligible for resume.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        summary = self.build_resume_summary(plan_id)

        if dry_run:
            self._logger.info(
                "Resume dry-run",
                plan_id=plan_id,
                next_step=summary.next_step_index,
                total_steps=summary.total_steps,
            )
            return summary

        # Live resume: reset to PROCESSING if currently ERRORED or QUEUED
        plan = self._lifecycle.get_plan(plan_id)

        if plan.processing_state == ProcessingState.ERRORED:
            plan.processing_state = ProcessingState.PROCESSING
            plan.error_message = None
            plan.timestamps.updated_at = datetime.now()
            self._lifecycle._commit_plan(plan)

        elif plan.processing_state == ProcessingState.QUEUED:
            plan.processing_state = ProcessingState.PROCESSING
            plan.timestamps.updated_at = datetime.now()
            self._lifecycle._commit_plan(plan)

        self._logger.info(
            "Plan resumed",
            plan_id=plan_id,
            phase=plan.phase.value,
            next_step=summary.next_step_index,
            total_steps=summary.total_steps,
        )

        return summary

    def record_step_checkpoint(
        self,
        plan_id: str,
        step_index: int,
        decision_id: str,
        step_text: str,
        sandbox_ref: str | None = None,
    ) -> ResumeCheckpoint:
        """Record a checkpoint after a step completes.

        Updates both the in-memory metadata and the plan's
        persistent fields (last_completed_step, last_checkpoint_id).

        Args:
            plan_id: The plan ULID.
            step_index: Zero-based index of the completed step.
            decision_id: Decision node ID for this step.
            step_text: Description of the step.
            sandbox_ref: Optional sandbox reference.

        Returns:
            The created ResumeCheckpoint.

        Raises:
            ValidationError: If required arguments are invalid.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if step_index < 0:
            raise ValidationError("step_index must be >= 0")
        if not decision_id:
            raise ValidationError("decision_id must not be empty")
        if not step_text:
            raise ValidationError("step_text must not be empty")

        checkpoint_id = str(ULID())
        checkpoint = ResumeCheckpoint(
            checkpoint_id=checkpoint_id,
            plan_id=plan_id,
            decision_id=decision_id,
            step_index=step_index,
            step_text=step_text,
            sandbox_ref=sandbox_ref,
        )

        metadata = self.get_resume_metadata(plan_id)
        metadata.record_checkpoint(checkpoint)

        # Update plan persistent fields
        plan = self._lifecycle.get_plan(plan_id)
        plan.last_completed_step = step_index
        plan.last_checkpoint_id = checkpoint_id
        plan.timestamps.updated_at = datetime.now()
        self._lifecycle._commit_plan(plan)

        self._logger.debug(
            "Step checkpoint recorded",
            plan_id=plan_id,
            step_index=step_index,
            checkpoint_id=checkpoint_id,
            decision_id=decision_id,
        )

        return checkpoint

    def record_shutdown(self, plan_id: str) -> None:
        """Record graceful shutdown for a plan.

        Marks the plan's resume metadata as interrupted so the next
        ``plan resume`` knows the execution was not completed cleanly.

        Args:
            plan_id: The plan ULID.

        Raises:
            ValidationError: If plan_id is empty.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        metadata = self.get_resume_metadata(plan_id)
        metadata.interrupted = True
        metadata.interrupted_at = datetime.now()

        # Persist the interrupted state on the plan
        plan = self._lifecycle.get_plan(plan_id)
        plan.timestamps.updated_at = datetime.now()
        self._lifecycle._commit_plan(plan)

        self._logger.info(
            "Graceful shutdown recorded",
            plan_id=plan_id,
            last_completed_step=metadata.last_completed_step,
        )

    def set_total_steps(self, plan_id: str, total: int) -> None:
        """Set the total number of steps for a plan.

        Args:
            plan_id: The plan ULID.
            total: Total number of steps.

        Raises:
            ValidationError: If arguments are invalid.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if total < 0:
            raise ValidationError("total must be >= 0")

        metadata = self.get_resume_metadata(plan_id)
        metadata.total_steps = total
