"""AI-driven print failure recovery with automatic resume.

Detects print failures from telemetry data, classifies them, generates
recovery plans, and can execute recovery through printer adapters --
all without human intervention when the agent has sufficient confidence.

Supported failure types:
    - Layer shift, thermal runaway, filament runout, adhesion failure
    - Blob formation, power loss, nozzle clog, spaghetti, stringing, warping
    - Communication loss

Recovery strategies:
    - Resume from specific layer (with overlap for bonding)
    - Restart with compensated parameters
    - Partial recovery (print remaining portion)
    - Safe abort (controlled shutdown, preserve part)
    - Wait and retry (wait for condition to clear)

Thread safety is guaranteed via :class:`threading.Lock` on all mutable
state.  The module-level :func:`get_recovery_engine` convenience function
returns a lazy singleton.

Usage::

    from kiln.print_recovery import get_recovery_engine

    engine = get_recovery_engine()
    failure = engine.detect_failure(
        printer_name="voron-350",
        telemetry={"hotend_temp": 280, "hotend_target": 200, ...},
    )
    if failure:
        plan = engine.plan_recovery(failure)
        session = engine.start_recovery(plan, failure)
"""

from __future__ import annotations

import enum
import logging
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any

from kiln.events import Event, EventBus, EventType

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Thresholds for failure detection
# ---------------------------------------------------------------------------

_LAYER_SHIFT_THRESHOLD_MM = 0.5
_THERMAL_RUNAWAY_DELTA = 15.0
_THERMAL_RUNAWAY_ABS_MAX = 300.0
_FLOW_ANOMALY_THRESHOLD = 0.3  # ratio below expected
_TEMP_DROP_ADHESION_THRESHOLD = 10.0
_SPAGHETTI_EXTRUSION_RATIO_MIN = 0.2
_WARPING_TEMP_GRADIENT_MAX = 5.0  # degrees across bed

# Per patent claim 78: the monitoring-checks-required threshold is
# resolved from the failure severity at session-creation time.
# Critical failures (thermal runaway, bed adhesion) demand more passing
# observations before recovery may be declared COMPLETED.
_MIN_MONITORING_CHECKS = 3
_CRITICAL_MONITORING_CHECKS = 5


def _required_monitoring_checks(severity: str | None) -> int:
    """Return the monitoring-checks threshold for a given failure severity."""
    if (severity or "").strip().lower() == "critical":
        return _CRITICAL_MONITORING_CHECKS
    return _MIN_MONITORING_CHECKS

# Material-dependent overlap depth for layer resumption.
# More layers = stronger bonding at the recovery seam.
# Flexible/weak-bonding materials need more overlap for structural integrity.
_MATERIAL_OVERLAP_LAYERS: dict[str, int] = {
    "pla": 2,
    "petg": 3,
    "abs": 3,
    "asa": 3,
    "tpu": 4,
    "tpu_95a": 4,
    "tpu_85a": 5,
    "nylon": 4,
    "pa6": 4,
    "pa12": 4,
    "pc": 3,
    "pom": 3,
    "hips": 2,
    "pva": 2,
    "cf_nylon": 4,
    "gf_nylon": 4,
    "cf_petg": 3,
    "cf_abs": 3,
    "peek": 5,
    "pei": 5,
    "pps": 5,
}
_DEFAULT_OVERLAP_LAYERS = 3

# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class FailureType(str, enum.Enum):
    """Types of print failures that can be detected."""

    LAYER_SHIFT = "layer_shift"
    THERMAL_RUNAWAY = "thermal_runaway"
    FILAMENT_RUNOUT = "filament_runout"
    ADHESION_FAILURE = "adhesion_failure"
    BLOB_DETECTED = "blob_detected"
    POWER_LOSS = "power_loss"
    NOZZLE_CLOG = "nozzle_clog"
    SPAGHETTI = "spaghetti"
    STRINGING = "stringing"
    WARPING = "warping"
    COMMUNICATION_LOSS = "communication_loss"


class RecoveryStrategy(str, enum.Enum):
    """Strategy for recovering from a failure."""

    RESUME_FROM_LAYER = "resume_from_layer"
    RESTART_WITH_COMPENSATION = "restart_with_compensation"
    PARTIAL_RECOVERY = "partial_recovery"
    SAFE_ABORT = "safe_abort"
    WAIT_AND_RETRY = "wait_and_retry"
    NO_RECOVERY = "no_recovery"


class RecoveryConfidence(str, enum.Enum):
    """Confidence level in the recovery plan."""

    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"
    UNKNOWN = "unknown"


class RecoveryStatus(str, enum.Enum):
    """Status of a recovery session."""

    DETECTING = "detecting"
    PLANNING = "planning"
    AWAITING_CONFIRMATION = "awaiting_confirmation"
    EXECUTING = "executing"
    MONITORING = "monitoring"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"


# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------


@dataclass
class CheckpointData:
    """Per-job state snapshot used for resume-accuracy.

    Folded in from the deprecated :mod:`kiln.recovery` module.
    Populated either explicitly via :meth:`PrintRecovery.save_checkpoint`
    or implicitly from telemetry inside :meth:`PrintRecovery.detect_failure`.

    When attached to a :class:`FailureReport`, the resume planner reads
    ``z_height_mm`` directly instead of guessing the resume Z from
    ``failure_z_mm`` and an assumed layer height — which dramatically
    improves resume accuracy on prints whose layer heights vary
    (variable-layer-height slicing, adaptive layers, etc.).

    :param z_height_mm: Z position in millimetres at checkpoint time.
    :param layer_number: Layer index (0-based) at checkpoint time.
    :param hotend_temp_c: Hotend temperature in Celsius.
    :param bed_temp_c: Heated bed temperature in Celsius.
    :param filament_used_mm: Total filament extruded so far in millimetres.
    :param fan_speed_pct: Part cooling fan speed as a percentage (0-100).
    :param flow_rate_pct: Flow rate multiplier percentage (default 100).
    :param recorded_at: ISO-8601 timestamp the checkpoint was captured.
    :param extra: Additional printer-specific state (MMU slot, enclosure,
        accelerometer baselines, etc.).
    """

    z_height_mm: float = 0.0
    layer_number: int = 0
    hotend_temp_c: float = 0.0
    bed_temp_c: float = 0.0
    filament_used_mm: float = 0.0
    fan_speed_pct: float = 0.0
    flow_rate_pct: float = 100.0
    recorded_at: str = ""
    extra: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-serialisable dictionary."""
        return {
            "z_height_mm": self.z_height_mm,
            "layer_number": self.layer_number,
            "hotend_temp_c": self.hotend_temp_c,
            "bed_temp_c": self.bed_temp_c,
            "filament_used_mm": self.filament_used_mm,
            "fan_speed_pct": self.fan_speed_pct,
            "flow_rate_pct": self.flow_rate_pct,
            "recorded_at": self.recorded_at,
            "extra": dict(self.extra),
        }


@dataclass
class FailureReport:
    """Detailed analysis of a detected failure."""

    failure_id: str
    failure_type: FailureType
    detected_at: str
    printer_name: str
    job_name: str | None = None
    failed_layer: int | None = None
    total_layers: int | None = None
    failure_z_mm: float | None = None
    evidence: list[str] = field(default_factory=list)
    severity: str = "high"
    probable_cause: str = ""
    contributing_factors: list[str] = field(default_factory=list)
    telemetry_snapshot: dict[str, Any] = field(default_factory=dict)
    material_type: str | None = None
    # Path to the original G-code file.  Enables kiln-pro to generate a
    # resume-from-layer G-code artifact without re-slicing.
    gcode_path: str | None = None
    # Optional checkpoint at failure time.  Populated either by an
    # explicit save_checkpoint call before the failure or synthesized
    # from telemetry inside detect_failure.  The resume planner reads
    # ``checkpoint.z_height_mm`` for accurate resume Z.
    checkpoint: CheckpointData | None = None

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-serialisable dictionary."""
        return {
            "failure_id": self.failure_id,
            "failure_type": self.failure_type.value,
            "detected_at": self.detected_at,
            "printer_name": self.printer_name,
            "job_name": self.job_name,
            "failed_layer": self.failed_layer,
            "total_layers": self.total_layers,
            "failure_z_mm": self.failure_z_mm,
            "evidence": self.evidence,
            "severity": self.severity,
            "probable_cause": self.probable_cause,
            "contributing_factors": self.contributing_factors,
            "telemetry_snapshot": self.telemetry_snapshot,
            "material_type": self.material_type,
            "gcode_path": self.gcode_path,
            "checkpoint": self.checkpoint.to_dict() if self.checkpoint else None,
        }


@dataclass
class RecoveryPlan:
    """A plan for recovering from a print failure."""

    plan_id: str
    failure_id: str
    strategy: RecoveryStrategy
    confidence: RecoveryConfidence
    resume_layer: int | None = None
    layer_overlap: int = 2
    preparation_steps: list[str] = field(default_factory=list)
    parameter_adjustments: dict[str, Any] = field(default_factory=dict)
    estimated_success_pct: float = 0.0
    estimated_time_minutes: float | None = None
    risks: list[str] = field(default_factory=list)
    requires_confirmation: bool = True
    reason: str = ""

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-serialisable dictionary."""
        return {
            "plan_id": self.plan_id,
            "failure_id": self.failure_id,
            "strategy": self.strategy.value,
            "confidence": self.confidence.value,
            "resume_layer": self.resume_layer,
            "layer_overlap": self.layer_overlap,
            "preparation_steps": self.preparation_steps,
            "parameter_adjustments": self.parameter_adjustments,
            "estimated_success_pct": self.estimated_success_pct,
            "estimated_time_minutes": self.estimated_time_minutes,
            "risks": self.risks,
            "requires_confirmation": self.requires_confirmation,
            "reason": self.reason,
        }


@dataclass
class RecoverySession:
    """An active recovery attempt."""

    session_id: str
    plan: RecoveryPlan
    failure: FailureReport
    status: RecoveryStatus
    started_at: str
    completed_at: str | None = None
    steps_completed: list[str] = field(default_factory=list)
    steps_remaining: list[str] = field(default_factory=list)
    monitoring_checks: int = 0
    monitoring_passed: int = 0
    monitoring_required: int = _MIN_MONITORING_CHECKS
    result_notes: str = ""

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-serialisable dictionary."""
        return {
            "session_id": self.session_id,
            "plan": self.plan.to_dict(),
            "failure": self.failure.to_dict(),
            "status": self.status.value,
            "started_at": self.started_at,
            "completed_at": self.completed_at,
            "steps_completed": self.steps_completed,
            "steps_remaining": self.steps_remaining,
            "monitoring_checks": self.monitoring_checks,
            "monitoring_passed": self.monitoring_passed,
            "monitoring_required": self.monitoring_required,
            "result_notes": self.result_notes,
        }


class MonitoringThresholdNotMet(ValueError):
    """Raised when ``complete_recovery(success=True)`` is called before
    ``monitoring_passed >= monitoring_required`` (patent claim 76).

    Subclasses :class:`ValueError` so existing handlers still catch it,
    but exposes structured fields for the MCP tool wrapper to surface
    per claim 79.
    """

    def __init__(
        self,
        *,
        session_id: str,
        monitoring_checks: int,
        monitoring_passed: int,
        monitoring_required: int,
        severity: str,
    ) -> None:
        self.session_id = session_id
        self.monitoring_checks = monitoring_checks
        self.monitoring_passed = monitoring_passed
        self.monitoring_required = monitoring_required
        self.severity = severity
        self.deficit = monitoring_required - monitoring_passed
        plural = "" if self.deficit == 1 else "s"
        # Telling the operator what to DO next is the difference between
        # a useful safety gate and a confusing one.
        self.remediation = (
            f"Record {self.deficit} more passing monitoring check{plural} via "
            f"record_recovery_check before calling complete_recovery again."
        )
        super().__init__(
            f"Cannot complete recovery {session_id!r}: "
            f"{monitoring_passed}/{monitoring_required} passing checks recorded "
            f"({self.deficit} more required for severity={severity!r}). "
            f"{self.remediation}"
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            "code": "MONITORING_THRESHOLD_NOT_MET",
            "session_id": self.session_id,
            "monitoring_checks": self.monitoring_checks,
            "monitoring_passed": self.monitoring_passed,
            "monitoring_required": self.monitoring_required,
            "deficit": self.deficit,
            "severity": self.severity,
            "remediation": self.remediation,
            "message": str(self),
        }


# ---------------------------------------------------------------------------
# Recovery engine
# ---------------------------------------------------------------------------


class PrintRecovery:
    """AI-driven print failure recovery engine.

    All public methods are thread-safe.  The engine maintains failure
    history and active recovery sessions protected by a single lock.
    """

    def __init__(self) -> None:
        self._sessions: dict[str, RecoverySession] = {}
        self._failure_history: list[FailureReport] = []
        # Per-(printer, job) checkpoint stash — the most recent checkpoint
        # for each running job.  Populated explicitly via save_checkpoint
        # OR implicitly from telemetry inside detect_failure.  Read by
        # detect_failure to attach to the FailureReport, and by the
        # resume planner for accurate resume_z calculation.  Folded in
        # from the deprecated kiln.recovery.RecoveryManager.
        self._checkpoints: dict[tuple[str, str], CheckpointData] = {}
        self._max_history: int = 500
        self._lock = threading.Lock()
        self._recovery_strategies = self._build_strategy_map()

    # -- public API: checkpoints -------------------------------------------

    def save_checkpoint(
        self,
        *,
        printer_name: str,
        job_id: str,
        z_height_mm: float = 0.0,
        layer_number: int = 0,
        hotend_temp_c: float = 0.0,
        bed_temp_c: float = 0.0,
        filament_used_mm: float = 0.0,
        fan_speed_pct: float = 0.0,
        flow_rate_pct: float = 100.0,
        extra: dict[str, Any] | None = None,
    ) -> CheckpointData:
        """Stash a checkpoint for a running job.

        The checkpoint is keyed by ``(printer_name, job_id)`` and
        replaces any previous checkpoint for the same job.  The next
        :meth:`detect_failure` call for that printer attaches the
        latest checkpoint to the resulting :class:`FailureReport`,
        which the resume planner then reads for accurate resume Z.

        :param printer_name: Identifier of the printer.
        :param job_id: Identifier of the print job.
        :param z_height_mm: Z position in millimetres.
        :param layer_number: Layer index (0-based).
        :param hotend_temp_c: Hotend temperature.
        :param bed_temp_c: Bed temperature.
        :param filament_used_mm: Filament consumed so far.
        :param fan_speed_pct: Cooling fan percentage (0-100).
        :param flow_rate_pct: Flow-rate multiplier percentage.
        :param extra: Optional printer-specific extras.
        :returns: The stored :class:`CheckpointData`.
        """
        if not printer_name or not printer_name.strip():
            raise ValueError("printer_name is required")
        if not job_id or not job_id.strip():
            raise ValueError("job_id is required")

        cp = CheckpointData(
            z_height_mm=float(z_height_mm),
            layer_number=int(layer_number),
            hotend_temp_c=float(hotend_temp_c),
            bed_temp_c=float(bed_temp_c),
            filament_used_mm=float(filament_used_mm),
            fan_speed_pct=float(fan_speed_pct),
            flow_rate_pct=float(flow_rate_pct),
            recorded_at=datetime.now(tz=timezone.utc).isoformat(),
            extra=dict(extra or {}),
        )
        with self._lock:
            self._checkpoints[(printer_name.strip(), job_id.strip())] = cp
        return cp

    def get_latest_checkpoint(
        self,
        printer_name: str,
        job_id: str | None = None,
    ) -> CheckpointData | None:
        """Return the most recent checkpoint for a printer (or printer+job).

        When ``job_id`` is None, returns the freshest checkpoint across
        any job on that printer.  Otherwise returns the checkpoint
        scoped to the specific (printer, job) pair, or ``None``.
        """
        with self._lock:
            if job_id is not None:
                return self._checkpoints.get(
                    (printer_name.strip(), job_id.strip())
                )
            # Fold across all jobs for this printer; pick freshest.
            best: CheckpointData | None = None
            for (p, _j), cp in self._checkpoints.items():
                if p != printer_name.strip():
                    continue
                if best is None or cp.recorded_at > best.recorded_at:
                    best = cp
            return best

    def clear_checkpoints(
        self,
        printer_name: str | None = None,
        job_id: str | None = None,
    ) -> int:
        """Remove stashed checkpoints, optionally scoped.

        Returns the number of checkpoints cleared.
        """
        with self._lock:
            if printer_name is None and job_id is None:
                count = len(self._checkpoints)
                self._checkpoints.clear()
                return count
            keys_to_drop = [
                k for k in self._checkpoints
                if (printer_name is None or k[0] == printer_name.strip())
                and (job_id is None or k[1] == job_id.strip())
            ]
            for k in keys_to_drop:
                del self._checkpoints[k]
            return len(keys_to_drop)

    # -- public API --------------------------------------------------------

    def detect_failure(
        self,
        *,
        printer_name: str,
        telemetry: dict[str, Any],
        telemetry_history: list[dict[str, Any]] | None = None,
        job_info: dict[str, Any] | None = None,
    ) -> FailureReport | None:
        """Analyze telemetry to detect and classify a failure.

        Runs each detector in priority order and returns the first
        failure found, or ``None`` if no failure is detected.

        :param printer_name: Identifier of the printer.
        :param telemetry: Current telemetry snapshot.
        :param telemetry_history: Recent telemetry snapshots for trend analysis.
        :param job_info: Current job metadata (file_name, layer, total_layers, z_mm).
        :returns: :class:`FailureReport` or ``None``.
        """
        printer_name = printer_name.strip()
        if not printer_name:
            raise ValueError("printer_name is required")

        history = telemetry_history or []
        info = job_info or {}

        # Run detectors in priority order (most critical first).
        detectors = [
            self._detect_thermal_issue,
            self._detect_communication_loss,
            self._detect_layer_shift,
            self._detect_filament_runout,
            self._detect_adhesion_failure,
            self._detect_nozzle_clog,
            self._detect_spaghetti,
            self._detect_warping,
        ]

        for detector in detectors:
            report = detector(printer_name, telemetry, history, info)
            if report is not None:
                # Capture telemetry snapshot and material for G-code generation.
                report.telemetry_snapshot = {
                    "hotend_temp": telemetry.get("hotend_target"),
                    "bed_temp": telemetry.get("bed_target"),
                    "hotend_actual": telemetry.get("hotend_temp"),
                    "bed_actual": telemetry.get("bed_temp"),
                }
                report.material_type = info.get("material") or telemetry.get("material")
                # Stamp gcode_path so kiln-pro's resume engine can locate the
                # original .gcode without re-slicing.  Accept either
                # ``gcode_path`` (explicit) or ``gcode_file`` (legacy).
                gp = info.get("gcode_path") or info.get("gcode_file")
                if isinstance(gp, str) and gp:
                    report.gcode_path = gp
                # Attach the latest checkpoint for this printer (if any).
                # Prefers an explicitly-stashed checkpoint via
                # save_checkpoint; falls back to a synthetic checkpoint
                # built from current telemetry + job_info so the resume
                # planner always has SOMETHING to read for resume_z.
                report.checkpoint = self._resolve_checkpoint_for_failure(
                    printer_name, telemetry, info,
                )
                with self._lock:
                    self._failure_history.append(report)
                    if len(self._failure_history) > self._max_history:
                        self._failure_history = self._failure_history[-self._max_history :]
                logger.warning(
                    "Failure detected: type=%s printer=%s severity=%s",
                    report.failure_type.value,
                    printer_name,
                    report.severity,
                )
                self._emit_event(report)
                return report

        return None

    def plan_recovery(
        self,
        failure: FailureReport,
        *,
        printer_capabilities: dict[str, Any] | None = None,
        safety_profile: dict[str, Any] | None = None,
    ) -> RecoveryPlan:
        """Generate a recovery plan for a detected failure.

        Selects the best strategy based on failure type, confidence
        assessment, and printer capabilities.

        :param failure: The failure to recover from.
        :param printer_capabilities: Optional printer capabilities dict.
        :param safety_profile: Optional safety profile dict.
        :returns: :class:`RecoveryPlan`.
        """
        strategies = self._recovery_strategies.get(failure.failure_type, [])
        if not strategies:
            return self._plan_safe_abort(failure)

        primary_strategy = strategies[0]

        planners = {
            RecoveryStrategy.RESUME_FROM_LAYER: self._plan_resume,
            RecoveryStrategy.RESTART_WITH_COMPENSATION: self._plan_restart,
            RecoveryStrategy.PARTIAL_RECOVERY: self._plan_partial,
            RecoveryStrategy.SAFE_ABORT: self._plan_safe_abort,
            RecoveryStrategy.WAIT_AND_RETRY: self._plan_wait_retry,
            RecoveryStrategy.NO_RECOVERY: self._plan_no_recovery,
        }

        planner = planners.get(primary_strategy, self._plan_safe_abort)
        plan = planner(failure)

        # Override confidence based on actual assessment.
        plan.confidence = self._compute_confidence(failure, plan.strategy)
        plan.estimated_success_pct = self._estimate_success_rate(failure, plan.strategy)

        # High confidence plans can auto-execute.
        plan.requires_confirmation = plan.confidence != RecoveryConfidence.HIGH

        return plan

    def start_recovery(
        self,
        plan: RecoveryPlan,
        failure: FailureReport,
    ) -> RecoverySession:
        """Begin executing a recovery plan.

        If the plan requires confirmation, the session starts in
        AWAITING_CONFIRMATION status.  Otherwise, it starts in
        EXECUTING status.

        :param plan: The recovery plan to execute.
        :param failure: The failure being recovered from.
        :returns: :class:`RecoverySession`.
        """
        now = datetime.now(tz=timezone.utc).isoformat()
        session_id = str(uuid.uuid4())

        initial_status = (
            RecoveryStatus.AWAITING_CONFIRMATION
            if plan.requires_confirmation
            else RecoveryStatus.EXECUTING
        )

        recovery_steps = self._generate_recovery_gcode(plan, failure)

        session = RecoverySession(
            session_id=session_id,
            plan=plan,
            failure=failure,
            status=initial_status,
            started_at=now,
            steps_completed=[],
            steps_remaining=list(recovery_steps),
            monitoring_required=_required_monitoring_checks(failure.severity),
        )

        with self._lock:
            self._sessions[session_id] = session

        logger.info(
            "Recovery session started: id=%s strategy=%s status=%s",
            session_id,
            plan.strategy.value,
            initial_status.value,
        )
        return session

    def confirm_recovery(self, session_id: str) -> RecoverySession:
        """Human confirms recovery can proceed.

        Transitions session from AWAITING_CONFIRMATION to EXECUTING.

        :param session_id: Session to confirm.
        :returns: Updated :class:`RecoverySession`.
        :raises ValueError: If session not found or not awaiting confirmation.
        """
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise ValueError(f"Recovery session not found: {session_id!r}")
            if session.status != RecoveryStatus.AWAITING_CONFIRMATION:
                raise ValueError(
                    f"Session {session_id!r} is in {session.status.value} state, "
                    f"not awaiting_confirmation."
                )
            session.status = RecoveryStatus.EXECUTING
            return session

    def cancel_recovery(self, session_id: str, *, reason: str = "") -> RecoverySession:
        """Cancel an active recovery session.

        :param session_id: Session to cancel.
        :param reason: Why the recovery was cancelled.
        :returns: Updated :class:`RecoverySession`.
        :raises ValueError: If session not found or already terminal.
        """
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise ValueError(f"Recovery session not found: {session_id!r}")
            terminal = {RecoveryStatus.COMPLETED, RecoveryStatus.FAILED, RecoveryStatus.CANCELLED}
            if session.status in terminal:
                raise ValueError(
                    f"Session {session_id!r} is already in terminal state: {session.status.value}"
                )
            session.status = RecoveryStatus.CANCELLED
            session.completed_at = datetime.now(tz=timezone.utc).isoformat()
            session.result_notes = reason or "Cancelled by user"
            return session

    def get_recovery_steps(self, session_id: str) -> list[str]:
        """Get the list of G-code/commands for recovery execution.

        :param session_id: Session to query.
        :returns: List of G-code command strings.
        :raises ValueError: If session not found.
        """
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise ValueError(f"Recovery session not found: {session_id!r}")
            return list(session.steps_remaining)

    def record_monitoring_check(
        self,
        session_id: str,
        *,
        passed: bool,
        notes: str = "",
    ) -> RecoverySession:
        """Record a post-recovery monitoring check result.

        After sufficient passing checks, the session can be completed.

        :param session_id: Session to update.
        :param passed: Whether the check passed.
        :param notes: Optional notes about the check.
        :returns: Updated :class:`RecoverySession`.
        :raises ValueError: If session not found or not in monitoring state.
        """
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise ValueError(f"Recovery session not found: {session_id!r}")
            if session.status not in (RecoveryStatus.EXECUTING, RecoveryStatus.MONITORING):
                raise ValueError(
                    f"Session {session_id!r} is in {session.status.value} state, "
                    f"cannot record monitoring check."
                )
            session.status = RecoveryStatus.MONITORING
            session.monitoring_checks += 1
            if passed:
                session.monitoring_passed += 1
            if notes:
                check_label = f"Check #{session.monitoring_checks}: {'PASS' if passed else 'FAIL'}"
                if notes:
                    check_label += f" - {notes}"
                session.steps_completed.append(check_label)
            return session

    def complete_recovery(
        self,
        session_id: str,
        *,
        success: bool,
        notes: str = "",
    ) -> RecoverySession:
        """Mark recovery as completed (success or failed).

        :param session_id: Session to complete.
        :param success: Whether the recovery succeeded.
        :param notes: Final notes about the recovery.
        :returns: Updated :class:`RecoverySession`.
        :raises ValueError: If session not found or already terminal.
        """
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise ValueError(f"Recovery session not found: {session_id!r}")
            terminal = {RecoveryStatus.COMPLETED, RecoveryStatus.FAILED, RecoveryStatus.CANCELLED}
            if session.status in terminal:
                raise ValueError(
                    f"Session {session_id!r} is already in terminal state: {session.status.value}"
                )
            # Patent claim 76: success completion requires the
            # monitoring-checks threshold to be met. Failure completion is
            # always allowed (operator may give up at any time).
            if success and session.monitoring_passed < session.monitoring_required:
                raise MonitoringThresholdNotMet(
                    session_id=session_id,
                    monitoring_checks=session.monitoring_checks,
                    monitoring_passed=session.monitoring_passed,
                    monitoring_required=session.monitoring_required,
                    severity=session.failure.severity,
                )
            session.status = RecoveryStatus.COMPLETED if success else RecoveryStatus.FAILED
            session.completed_at = datetime.now(tz=timezone.utc).isoformat()
            session.result_notes = notes
            return session

    def get_session(self, session_id: str) -> RecoverySession | None:
        """Get a recovery session by ID.

        :param session_id: Session to retrieve.
        :returns: :class:`RecoverySession` or ``None``.
        """
        with self._lock:
            return self._sessions.get(session_id)

    def get_active_sessions(self) -> list[RecoverySession]:
        """Get all non-terminal recovery sessions.

        :returns: List of active :class:`RecoverySession` instances.
        """
        active_statuses = {
            RecoveryStatus.DETECTING,
            RecoveryStatus.PLANNING,
            RecoveryStatus.AWAITING_CONFIRMATION,
            RecoveryStatus.EXECUTING,
            RecoveryStatus.MONITORING,
        }
        with self._lock:
            return [s for s in self._sessions.values() if s.status in active_statuses]

    def get_failure_history(self, *, limit: int = 50) -> list[FailureReport]:
        """Get recent failure reports, newest first.

        :param limit: Maximum number of reports to return.
        :returns: List of :class:`FailureReport`.
        """
        with self._lock:
            history = list(self._failure_history)
        return list(reversed(history))[:limit]

    def get_recovery_statistics(self) -> dict[str, Any]:
        """Get historical recovery success rates and failure type distribution.

        :returns: Dict with statistics about recovery attempts.
        """
        with self._lock:
            sessions = list(self._sessions.values())
            failures = list(self._failure_history)

        total_sessions = len(sessions)
        completed = [s for s in sessions if s.status == RecoveryStatus.COMPLETED]
        failed = [s for s in sessions if s.status == RecoveryStatus.FAILED]
        cancelled = [s for s in sessions if s.status == RecoveryStatus.CANCELLED]
        active = [
            s for s in sessions
            if s.status not in {RecoveryStatus.COMPLETED, RecoveryStatus.FAILED, RecoveryStatus.CANCELLED}
        ]

        success_rate = len(completed) / total_sessions if total_sessions > 0 else 0.0

        # Failure type distribution.
        failure_counts: dict[str, int] = {}
        for f in failures:
            key = f.failure_type.value
            failure_counts[key] = failure_counts.get(key, 0) + 1

        # Strategy success rates.
        strategy_stats: dict[str, dict[str, int]] = {}
        for s in sessions:
            key = s.plan.strategy.value
            if key not in strategy_stats:
                strategy_stats[key] = {"total": 0, "successful": 0, "failed": 0}
            strategy_stats[key]["total"] += 1
            if s.status == RecoveryStatus.COMPLETED:
                strategy_stats[key]["successful"] += 1
            elif s.status == RecoveryStatus.FAILED:
                strategy_stats[key]["failed"] += 1

        return {
            "total_failures_detected": len(failures),
            "total_recovery_sessions": total_sessions,
            "completed": len(completed),
            "failed": len(failed),
            "cancelled": len(cancelled),
            "active": len(active),
            "success_rate": round(success_rate, 3),
            "failure_type_distribution": failure_counts,
            "strategy_statistics": strategy_stats,
        }

    # -- private: strategy map ---------------------------------------------

    def _build_strategy_map(self) -> dict[FailureType, list[RecoveryStrategy]]:
        """Map failure types to viable recovery strategies, ordered by preference."""
        return {
            FailureType.LAYER_SHIFT: [
                RecoveryStrategy.RESUME_FROM_LAYER,
                RecoveryStrategy.SAFE_ABORT,
            ],
            FailureType.THERMAL_RUNAWAY: [
                RecoveryStrategy.SAFE_ABORT,
            ],
            FailureType.FILAMENT_RUNOUT: [
                RecoveryStrategy.WAIT_AND_RETRY,
                RecoveryStrategy.RESUME_FROM_LAYER,
            ],
            FailureType.ADHESION_FAILURE: [
                RecoveryStrategy.RESTART_WITH_COMPENSATION,
                RecoveryStrategy.SAFE_ABORT,
            ],
            FailureType.BLOB_DETECTED: [
                RecoveryStrategy.SAFE_ABORT,
                RecoveryStrategy.RESUME_FROM_LAYER,
            ],
            FailureType.POWER_LOSS: [
                RecoveryStrategy.RESUME_FROM_LAYER,
                RecoveryStrategy.RESTART_WITH_COMPENSATION,
            ],
            FailureType.NOZZLE_CLOG: [
                RecoveryStrategy.WAIT_AND_RETRY,
                RecoveryStrategy.SAFE_ABORT,
            ],
            FailureType.SPAGHETTI: [
                RecoveryStrategy.SAFE_ABORT,
            ],
            FailureType.STRINGING: [
                RecoveryStrategy.NO_RECOVERY,
            ],
            FailureType.WARPING: [
                RecoveryStrategy.RESTART_WITH_COMPENSATION,
                RecoveryStrategy.SAFE_ABORT,
            ],
            FailureType.COMMUNICATION_LOSS: [
                RecoveryStrategy.WAIT_AND_RETRY,
                RecoveryStrategy.SAFE_ABORT,
            ],
        }

    # -- private: failure detectors ----------------------------------------

    def _detect_thermal_issue(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect thermal runaway from temperature readings."""
        hotend_actual = telemetry.get("hotend_temp")
        hotend_target = telemetry.get("hotend_target")

        if hotend_actual is None:
            return None

        evidence: list[str] = []
        is_runaway = False

        # Check for temperature exceeding absolute maximum.
        if hotend_actual > _THERMAL_RUNAWAY_ABS_MAX:
            evidence.append(
                f"Hotend temperature {hotend_actual}C exceeds absolute maximum {_THERMAL_RUNAWAY_ABS_MAX}C"
            )
            is_runaway = True

        # Check for temperature significantly above target.
        if hotend_target is not None and hotend_target > 0:
            delta = hotend_actual - hotend_target
            if delta > _THERMAL_RUNAWAY_DELTA:
                evidence.append(
                    f"Hotend {hotend_actual}C is {delta:.1f}C above target {hotend_target}C "
                    f"(threshold: {_THERMAL_RUNAWAY_DELTA}C)"
                )
                is_runaway = True

        # Check bed temperature if available.
        bed_actual = telemetry.get("bed_temp")
        bed_target = telemetry.get("bed_target")
        if bed_actual is not None and bed_target is not None and bed_target > 0:
            bed_delta = bed_actual - bed_target
            if bed_delta > _THERMAL_RUNAWAY_DELTA:
                evidence.append(
                    f"Bed {bed_actual}C is {bed_delta:.1f}C above target {bed_target}C"
                )
                is_runaway = True

        if not is_runaway:
            return None

        return FailureReport(
            failure_id=str(uuid.uuid4()),
            failure_type=FailureType.THERMAL_RUNAWAY,
            detected_at=datetime.now(tz=timezone.utc).isoformat(),
            printer_name=printer_name,
            job_name=job_info.get("file_name"),
            failed_layer=job_info.get("layer"),
            total_layers=job_info.get("total_layers"),
            failure_z_mm=job_info.get("z_mm"),
            evidence=evidence,
            severity="critical",
            probable_cause="Heater control failure or thermistor malfunction",
            contributing_factors=["PID tuning may be incorrect", "Thermistor wiring issue"],
        )

    def _detect_communication_loss(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect communication loss from connectivity signals."""
        connected = telemetry.get("connected")
        if connected is False:
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.COMMUNICATION_LOSS,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=["Printer reports disconnected state"],
                severity="high",
                probable_cause="Network or USB connection interrupted",
                contributing_factors=["Cable issue", "Wi-Fi instability", "Firmware crash"],
            )
        return None

    def _detect_layer_shift(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect layer shift from position data or history comparison."""
        x_actual = telemetry.get("x_position")
        y_actual = telemetry.get("y_position")
        x_expected = telemetry.get("x_expected")
        y_expected = telemetry.get("y_expected")

        if x_actual is None or x_expected is None:
            # Try history-based detection.
            if len(history) >= 2:
                prev = history[-2]
                curr = history[-1] if history[-1] is not telemetry else telemetry
                prev_x = prev.get("x_position")
                prev_y = prev.get("y_position")
                curr_x = curr.get("x_position")
                curr_y = curr.get("y_position")
                if all(v is not None for v in [prev_x, prev_y, curr_x, curr_y]):
                    dx = abs(curr_x - prev_x)
                    dy = abs(curr_y - prev_y)
                    # Large unexpected jumps indicate a shift.
                    if (dx > 10.0 or dy > 10.0) and telemetry.get("layer_shift_detected"):
                        return FailureReport(
                            failure_id=str(uuid.uuid4()),
                            failure_type=FailureType.LAYER_SHIFT,
                            detected_at=datetime.now(tz=timezone.utc).isoformat(),
                            printer_name=printer_name,
                            job_name=job_info.get("file_name"),
                            failed_layer=job_info.get("layer"),
                            total_layers=job_info.get("total_layers"),
                            failure_z_mm=job_info.get("z_mm"),
                            evidence=[f"Position jump detected: dX={dx:.1f}mm dY={dy:.1f}mm"],
                            severity="high",
                            probable_cause="Stepper motor skip or belt slip",
                            contributing_factors=[
                                "Belt tension too loose",
                                "Excessive print speed",
                                "Mechanical obstruction",
                            ],
                        )
            return None

        dx = abs(x_actual - x_expected)
        dy = abs(y_actual - y_expected)
        total_delta = (dx**2 + dy**2) ** 0.5

        if total_delta < _LAYER_SHIFT_THRESHOLD_MM:
            return None

        return FailureReport(
            failure_id=str(uuid.uuid4()),
            failure_type=FailureType.LAYER_SHIFT,
            detected_at=datetime.now(tz=timezone.utc).isoformat(),
            printer_name=printer_name,
            job_name=job_info.get("file_name"),
            failed_layer=job_info.get("layer"),
            total_layers=job_info.get("total_layers"),
            failure_z_mm=job_info.get("z_mm"),
            evidence=[
                f"Position mismatch: actual=({x_actual:.2f}, {y_actual:.2f}) "
                f"expected=({x_expected:.2f}, {y_expected:.2f}) delta={total_delta:.2f}mm"
            ],
            severity="high",
            probable_cause="Stepper motor skip or belt slip",
            contributing_factors=[
                "Belt tension too loose",
                "Excessive print speed",
                "Mechanical obstruction",
            ],
        )

    def _detect_filament_runout(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect filament runout from sensor signal or flow anomaly."""
        filament_detected = telemetry.get("filament_detected")
        if filament_detected is False:
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.FILAMENT_RUNOUT,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=["Filament runout sensor triggered"],
                severity="medium",
                probable_cause="Filament spool depleted or broken filament",
                contributing_factors=["End of spool", "Filament snag or tangle"],
            )

        # Flow-based detection.
        flow_rate = telemetry.get("flow_rate")
        expected_flow = telemetry.get("expected_flow")
        if flow_rate is not None and expected_flow is not None and expected_flow > 0:
            ratio = flow_rate / expected_flow
            if ratio < _FLOW_ANOMALY_THRESHOLD:
                return FailureReport(
                    failure_id=str(uuid.uuid4()),
                    failure_type=FailureType.FILAMENT_RUNOUT,
                    detected_at=datetime.now(tz=timezone.utc).isoformat(),
                    printer_name=printer_name,
                    job_name=job_info.get("file_name"),
                    failed_layer=job_info.get("layer"),
                    total_layers=job_info.get("total_layers"),
                    failure_z_mm=job_info.get("z_mm"),
                    evidence=[f"Flow rate {flow_rate:.2f} is {ratio:.0%} of expected {expected_flow:.2f}"],
                    severity="medium",
                    probable_cause="Filament depletion or partial clog reducing flow",
                    contributing_factors=["Low spool", "Partial nozzle obstruction"],
                )

        return None

    def _detect_adhesion_failure(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect adhesion failure from bed temperature drop or sensor signal."""
        adhesion_lost = telemetry.get("adhesion_lost")
        if adhesion_lost is True:
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.ADHESION_FAILURE,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=["Adhesion loss sensor triggered"],
                severity="high",
                probable_cause="Part detached from build plate",
                contributing_factors=[
                    "Insufficient bed adhesion",
                    "Bed temperature too low",
                    "No adhesion aid (glue/tape)",
                ],
            )

        # Temperature drop pattern: bed temp falling while target is constant.
        bed_actual = telemetry.get("bed_temp")
        bed_target = telemetry.get("bed_target")
        if (
            bed_actual is not None
            and bed_target is not None
            and bed_target > 0
            and (bed_target - bed_actual) > _TEMP_DROP_ADHESION_THRESHOLD
            and len(history) >= 2
        ):
            prev_bed = history[-1].get("bed_temp")
            if prev_bed is not None and prev_bed > bed_actual:
                return FailureReport(
                    failure_id=str(uuid.uuid4()),
                    failure_type=FailureType.ADHESION_FAILURE,
                    detected_at=datetime.now(tz=timezone.utc).isoformat(),
                    printer_name=printer_name,
                    job_name=job_info.get("file_name"),
                    failed_layer=job_info.get("layer"),
                    total_layers=job_info.get("total_layers"),
                    failure_z_mm=job_info.get("z_mm"),
                    evidence=[
                        f"Bed temp {bed_actual}C dropping from target {bed_target}C "
                        f"(prev: {prev_bed}C) -- possible heater failure causing adhesion loss"
                    ],
                    severity="high",
                    probable_cause="Bed heater failing, causing part to lose adhesion",
                    contributing_factors=["Bed heater malfunction", "Ambient temperature drop"],
                )

        return None

    def _detect_nozzle_clog(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect nozzle clog from pressure/flow anomaly."""
        nozzle_clogged = telemetry.get("nozzle_clogged")
        if nozzle_clogged is True:
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.NOZZLE_CLOG,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=["Nozzle clog sensor triggered"],
                severity="high",
                probable_cause="Partial or full nozzle obstruction",
                contributing_factors=["Carbonized filament", "Foreign debris", "Heat creep"],
            )

        # Pressure-based detection.
        extruder_pressure = telemetry.get("extruder_pressure")
        pressure_threshold = telemetry.get("pressure_threshold")
        if (
            extruder_pressure is not None
            and pressure_threshold is not None
            and extruder_pressure > pressure_threshold
        ):
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.NOZZLE_CLOG,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=[
                    f"Extruder pressure {extruder_pressure} exceeds threshold {pressure_threshold}"
                ],
                severity="high",
                probable_cause="Nozzle obstruction increasing backpressure",
                contributing_factors=["Carbonized material", "Heat creep", "Wrong nozzle size"],
            )

        return None

    def _detect_spaghetti(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect spaghetti (failed extrusion) from telemetry."""
        spaghetti_detected = telemetry.get("spaghetti_detected")
        if spaghetti_detected is True:
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.SPAGHETTI,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=["Spaghetti / failed extrusion detected by vision or sensor"],
                severity="critical",
                probable_cause="Nozzle printing in air after part detachment",
                contributing_factors=[
                    "Adhesion failure",
                    "Support structure failure",
                    "Excessive overhang",
                ],
            )
        return None

    def _detect_warping(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        history: list[dict[str, Any]],
        job_info: dict[str, Any],
    ) -> FailureReport | None:
        """Detect warping from bed temperature gradient or sensor signal."""
        warping_detected = telemetry.get("warping_detected")
        if warping_detected is True:
            return FailureReport(
                failure_id=str(uuid.uuid4()),
                failure_type=FailureType.WARPING,
                detected_at=datetime.now(tz=timezone.utc).isoformat(),
                printer_name=printer_name,
                job_name=job_info.get("file_name"),
                failed_layer=job_info.get("layer"),
                total_layers=job_info.get("total_layers"),
                failure_z_mm=job_info.get("z_mm"),
                evidence=["Warping detected by sensor or vision system"],
                severity="medium",
                probable_cause="Part corners lifting due to thermal contraction",
                contributing_factors=[
                    "Insufficient bed temperature",
                    "No enclosure",
                    "Large flat part geometry",
                ],
            )
        return None

    # -- private: checkpoint resolution -----------------------------------

    def _resolve_checkpoint_for_failure(
        self,
        printer_name: str,
        telemetry: dict[str, Any],
        job_info: dict[str, Any],
    ) -> CheckpointData | None:
        """Resolve the best-available checkpoint for a freshly-detected failure.

        Resolution order:

        1. If a checkpoint was explicitly stashed via :meth:`save_checkpoint`
           for this (printer, job_id), use it — the operator/agent
           supplied known-good state, which is the highest-fidelity
           source.
        2. Else if telemetry + job_info carry enough state to build a
           synthetic checkpoint (z_mm or z_height_mm + layer + temps),
           build one inline.  This is the common path: no explicit
           save_checkpoint, just the telemetry that came in alongside
           the failure.
        3. Else return None — the resume planner falls back to its
           calculated z_per_layer estimate.

        Always best-effort and never raises.  Failure to resolve a
        checkpoint must NOT block detect_failure — checkpoints are an
        accuracy improvement, not a correctness requirement.
        """
        try:
            job_id = job_info.get("job_id") or job_info.get("file_name")
            if isinstance(job_id, str) and job_id.strip():
                explicit = self.get_latest_checkpoint(printer_name, job_id)
                if explicit is not None:
                    return explicit

            # Synthesize from telemetry + job_info.  Prefer explicit
            # z_height fields; fall back to z_mm (the legacy key).
            z_height = (
                job_info.get("z_height_mm")
                or job_info.get("z_mm")
                or telemetry.get("z_height_mm")
                or telemetry.get("z_mm")
            )
            layer = job_info.get("layer") or job_info.get("layer_number")
            if z_height is None and layer is None:
                return None

            return CheckpointData(
                z_height_mm=float(z_height) if z_height is not None else 0.0,
                layer_number=int(layer) if layer is not None else 0,
                hotend_temp_c=float(telemetry.get("hotend_temp") or 0.0),
                bed_temp_c=float(telemetry.get("bed_temp") or 0.0),
                filament_used_mm=float(
                    job_info.get("filament_used_mm")
                    or telemetry.get("filament_used_mm")
                    or 0.0,
                ),
                fan_speed_pct=float(telemetry.get("fan_speed_pct") or 0.0),
                flow_rate_pct=float(telemetry.get("flow_rate_pct") or 100.0),
                recorded_at=datetime.now(tz=timezone.utc).isoformat(),
                extra={},
            )
        except (TypeError, ValueError) as exc:
            logger.debug(
                "Failed to resolve checkpoint for %s: %s",
                printer_name,
                exc,
            )
            return None

    # -- private: recovery planners ----------------------------------------

    def _plan_resume(self, failure: FailureReport) -> RecoveryPlan:
        """Plan resume from a specific layer.

        Overlap depth is material-dependent: flexible and high-performance
        materials (TPU, nylon, PEEK) need more overlap layers for
        structural integrity at the recovery seam, while rigid standard
        materials (PLA, HIPS) bond sufficiently with fewer layers.
        Layer shift failures always add one extra overlap layer because
        positional accuracy is already compromised.

        When ``failure.checkpoint`` is populated (from save_checkpoint
        or telemetry-derived in detect_failure), the resume Z is read
        directly from the checkpoint instead of being calculated from
        ``failure_z_mm / failed_layer * resume_layer`` — a meaningfully
        more accurate value when the print uses variable layer heights
        or adaptive layers.
        """
        # Determine overlap from material type.
        material = (failure.material_type or "").lower().strip().replace(" ", "_")
        base_overlap = _MATERIAL_OVERLAP_LAYERS.get(material, _DEFAULT_OVERLAP_LAYERS)

        # Layer shift gets +1 extra overlap for positional safety.
        layer_overlap = base_overlap + 1 if failure.failure_type == FailureType.LAYER_SHIFT else base_overlap

        resume_layer = (failure.failed_layer or 1) - layer_overlap
        resume_layer = max(1, resume_layer)

        # Resume Z resolution.  Checkpoint wins when available — a real
        # observed Z from telemetry / save_checkpoint beats calculated
        # estimates from layer * height, especially with variable-layer
        # slicers.  Falls back to z_per_layer * resume_layer when no
        # checkpoint exists.
        z_per_layer = 0.2  # default assumption
        if failure.failure_z_mm and failure.failed_layer and failure.failed_layer > 0:
            z_per_layer = failure.failure_z_mm / failure.failed_layer

        if failure.checkpoint is not None and failure.checkpoint.z_height_mm > 0:
            # Step back layer_overlap layers from the checkpoint's
            # observed Z, using the per-layer height as a fine-grained
            # delta.  This handles variable-layer-height prints where
            # resume_layer alone doesn't tell us the actual Z.
            checkpoint_z = failure.checkpoint.z_height_mm
            resume_z_mm = round(
                max(0.0, checkpoint_z - layer_overlap * z_per_layer), 2,
            )
            resume_z_source = "checkpoint"
        else:
            resume_z_mm = round(resume_layer * z_per_layer, 2)
            resume_z_source = "calculated"

        estimated_time = None
        if failure.total_layers and failure.failed_layer:
            remaining = failure.total_layers - resume_layer
            # Rough estimate: 2 minutes per layer.
            estimated_time = remaining * 2.0

        return RecoveryPlan(
            plan_id=str(uuid.uuid4()),
            failure_id=failure.failure_id,
            strategy=RecoveryStrategy.RESUME_FROM_LAYER,
            confidence=RecoveryConfidence.MEDIUM,
            resume_layer=resume_layer,
            layer_overlap=layer_overlap,
            preparation_steps=self._generate_preparation_steps(
                RecoveryStrategy.RESUME_FROM_LAYER, failure
            ),
            parameter_adjustments={
                "resume_z_mm": resume_z_mm,
                "resume_z_source": resume_z_source,
                "material_type": material or "unknown",
                "overlap_layers": layer_overlap,
            },
            estimated_time_minutes=estimated_time,
            risks=[
                "Layer bonding may be weak at the resume point",
                "Z-offset calibration drift during recovery",
                f"Material '{material or 'unknown'}' uses {layer_overlap}-layer overlap for bonding",
            ],
            reason=f"Resume printing from layer {resume_layer} with {layer_overlap}-layer "
            f"overlap for bonding (material: {material or 'unknown'}), "
            f"resume_z={resume_z_mm}mm ({resume_z_source})",
        )

    def _plan_restart(self, failure: FailureReport) -> RecoveryPlan:
        """Plan full restart with compensated parameters."""
        adjustments: dict[str, Any] = {}
        risks: list[str] = []

        if failure.failure_type == FailureType.ADHESION_FAILURE:
            adjustments["bed_temp_offset"] = 5  # +5C
            adjustments["first_layer_speed_pct"] = 80  # Slow first layer
            adjustments["brim_width_mm"] = 5.0
            risks.append("Higher bed temp may cause elephant's foot on first layer")
        elif failure.failure_type == FailureType.WARPING:
            adjustments["bed_temp_offset"] = 5
            adjustments["brim_width_mm"] = 8.0
            adjustments["enclosure_recommended"] = True
            risks.append("Brim may be difficult to remove on finished part")

        estimated_time = None
        if failure.total_layers:
            estimated_time = failure.total_layers * 2.0

        return RecoveryPlan(
            plan_id=str(uuid.uuid4()),
            failure_id=failure.failure_id,
            strategy=RecoveryStrategy.RESTART_WITH_COMPENSATION,
            confidence=RecoveryConfidence.MEDIUM,
            preparation_steps=self._generate_preparation_steps(
                RecoveryStrategy.RESTART_WITH_COMPENSATION, failure
            ),
            parameter_adjustments=adjustments,
            estimated_time_minutes=estimated_time,
            risks=risks or ["Full reprint uses additional material and time"],
            reason="Full restart with adjusted parameters to prevent recurrence",
        )

    def _plan_partial(self, failure: FailureReport) -> RecoveryPlan:
        """Plan partial recovery (print remaining portion only)."""
        return RecoveryPlan(
            plan_id=str(uuid.uuid4()),
            failure_id=failure.failure_id,
            strategy=RecoveryStrategy.PARTIAL_RECOVERY,
            confidence=RecoveryConfidence.LOW,
            resume_layer=failure.failed_layer,
            preparation_steps=self._generate_preparation_steps(
                RecoveryStrategy.PARTIAL_RECOVERY, failure
            ),
            risks=[
                "Partial prints may not be structurally sound",
                "Layer adhesion at the split point is unreliable",
            ],
            reason="Print only the remaining portion of the model",
        )

    def _plan_safe_abort(self, failure: FailureReport) -> RecoveryPlan:
        """Plan controlled shutdown to preserve the part."""
        return RecoveryPlan(
            plan_id=str(uuid.uuid4()),
            failure_id=failure.failure_id,
            strategy=RecoveryStrategy.SAFE_ABORT,
            confidence=RecoveryConfidence.HIGH,
            preparation_steps=self._generate_preparation_steps(
                RecoveryStrategy.SAFE_ABORT, failure
            ),
            risks=["Part is incomplete and may not be usable"],
            requires_confirmation=False,
            reason=f"Safe abort due to {failure.failure_type.value} -- "
            f"preserving partial print and preventing further damage",
        )

    def _plan_wait_retry(self, failure: FailureReport) -> RecoveryPlan:
        """Plan wait for condition to clear, then retry."""
        confidence = RecoveryConfidence.HIGH
        wait_reason = ""

        if failure.failure_type == FailureType.FILAMENT_RUNOUT:
            wait_reason = "Load new filament, then resume print"
            confidence = RecoveryConfidence.HIGH
        elif failure.failure_type == FailureType.NOZZLE_CLOG:
            wait_reason = "Perform cold pull procedure to clear clog, then resume"
            confidence = RecoveryConfidence.MEDIUM
        elif failure.failure_type == FailureType.COMMUNICATION_LOSS:
            wait_reason = "Wait for connection to re-establish, then resume"
            confidence = RecoveryConfidence.HIGH
        else:
            wait_reason = "Wait for condition to clear, then retry"

        return RecoveryPlan(
            plan_id=str(uuid.uuid4()),
            failure_id=failure.failure_id,
            strategy=RecoveryStrategy.WAIT_AND_RETRY,
            confidence=confidence,
            resume_layer=failure.failed_layer,
            preparation_steps=self._generate_preparation_steps(
                RecoveryStrategy.WAIT_AND_RETRY, failure
            ),
            risks=["Print quality may be affected at the interruption point"],
            reason=wait_reason,
        )

    def _plan_no_recovery(self, failure: FailureReport) -> RecoveryPlan:
        """Plan for failures that don't need recovery (minor issues)."""
        return RecoveryPlan(
            plan_id=str(uuid.uuid4()),
            failure_id=failure.failure_id,
            strategy=RecoveryStrategy.NO_RECOVERY,
            confidence=RecoveryConfidence.HIGH,
            preparation_steps=[],
            risks=[],
            requires_confirmation=False,
            reason=f"{failure.failure_type.value} is a minor issue; "
            f"print can continue, address in post-processing",
        )

    # -- private: confidence and estimation --------------------------------

    def _compute_confidence(
        self,
        failure: FailureReport,
        strategy: RecoveryStrategy,
    ) -> RecoveryConfidence:
        """Compute confidence level based on failure type and strategy."""
        # High confidence scenarios.
        high_confidence = {
            (FailureType.THERMAL_RUNAWAY, RecoveryStrategy.SAFE_ABORT),
            (FailureType.SPAGHETTI, RecoveryStrategy.SAFE_ABORT),
            (FailureType.FILAMENT_RUNOUT, RecoveryStrategy.WAIT_AND_RETRY),
            (FailureType.COMMUNICATION_LOSS, RecoveryStrategy.WAIT_AND_RETRY),
            (FailureType.STRINGING, RecoveryStrategy.NO_RECOVERY),
        }

        if (failure.failure_type, strategy) in high_confidence:
            return RecoveryConfidence.HIGH

        # Medium confidence scenarios.
        medium_confidence = {
            (FailureType.LAYER_SHIFT, RecoveryStrategy.RESUME_FROM_LAYER),
            (FailureType.ADHESION_FAILURE, RecoveryStrategy.RESTART_WITH_COMPENSATION),
            (FailureType.POWER_LOSS, RecoveryStrategy.RESUME_FROM_LAYER),
            (FailureType.NOZZLE_CLOG, RecoveryStrategy.WAIT_AND_RETRY),
            (FailureType.WARPING, RecoveryStrategy.RESTART_WITH_COMPENSATION),
            (FailureType.BLOB_DETECTED, RecoveryStrategy.SAFE_ABORT),
        }

        if (failure.failure_type, strategy) in medium_confidence:
            return RecoveryConfidence.MEDIUM

        # Everything else is low confidence.
        return RecoveryConfidence.LOW

    def _estimate_success_rate(
        self,
        failure: FailureReport,
        strategy: RecoveryStrategy,
    ) -> float:
        """Estimate success percentage for a given failure/strategy pair."""
        rates: dict[tuple[FailureType, RecoveryStrategy], float] = {
            (FailureType.THERMAL_RUNAWAY, RecoveryStrategy.SAFE_ABORT): 95.0,
            (FailureType.SPAGHETTI, RecoveryStrategy.SAFE_ABORT): 90.0,
            (FailureType.FILAMENT_RUNOUT, RecoveryStrategy.WAIT_AND_RETRY): 85.0,
            (FailureType.COMMUNICATION_LOSS, RecoveryStrategy.WAIT_AND_RETRY): 80.0,
            (FailureType.LAYER_SHIFT, RecoveryStrategy.RESUME_FROM_LAYER): 60.0,
            (FailureType.ADHESION_FAILURE, RecoveryStrategy.RESTART_WITH_COMPENSATION): 70.0,
            (FailureType.POWER_LOSS, RecoveryStrategy.RESUME_FROM_LAYER): 65.0,
            (FailureType.NOZZLE_CLOG, RecoveryStrategy.WAIT_AND_RETRY): 55.0,
            (FailureType.WARPING, RecoveryStrategy.RESTART_WITH_COMPENSATION): 65.0,
            (FailureType.BLOB_DETECTED, RecoveryStrategy.SAFE_ABORT): 90.0,
            (FailureType.STRINGING, RecoveryStrategy.NO_RECOVERY): 100.0,
        }

        return rates.get((failure.failure_type, strategy), 40.0)

    # -- private: step generation ------------------------------------------

    def _generate_preparation_steps(
        self,
        strategy: RecoveryStrategy,
        failure: FailureReport,
    ) -> list[str]:
        """Generate human-readable preparation steps for a recovery strategy."""
        steps: list[str] = []

        if strategy == RecoveryStrategy.RESUME_FROM_LAYER:
            steps = [
                "Pause current print (if still running)",
                "Inspect print surface for damage or debris",
                "Remove any loose material or blobs from the part",
                "Re-home all axes (G28)",
                "Heat hotend and bed to target temperatures",
                "Prime nozzle with a small extrusion",
                "Resume print from calculated resume layer",
            ]
            if failure.failure_type == FailureType.LAYER_SHIFT:
                steps.insert(2, "Check belt tension on X and Y axes")
                steps.insert(3, "Verify stepper motor connections")

        elif strategy == RecoveryStrategy.RESTART_WITH_COMPENSATION:
            steps = [
                "Cancel current print",
                "Wait for printer to cool down",
                "Clean build plate thoroughly",
                "Apply fresh adhesion aid if needed",
                "Re-slice with adjusted parameters",
                "Start new print with compensated settings",
            ]
            if failure.failure_type == FailureType.ADHESION_FAILURE:
                steps.insert(3, "Increase bed temperature by 5C")
            elif failure.failure_type == FailureType.WARPING:
                steps.insert(3, "Consider using an enclosure")

        elif strategy == RecoveryStrategy.PARTIAL_RECOVERY:
            steps = [
                "Pause current print",
                "Assess remaining portion that needs printing",
                "Split G-code at the failure point",
                "Resume with the partial G-code file",
            ]

        elif strategy == RecoveryStrategy.SAFE_ABORT:
            steps = [
                "Cancel print job",
                "Turn off hotend heater (M104 S0)",
                "Turn off bed heater (M140 S0)",
                "Move nozzle to safe position",
                "Disable steppers after cooldown (M84)",
            ]
            if failure.failure_type == FailureType.THERMAL_RUNAWAY:
                steps.insert(0, "IMMEDIATE: Execute emergency stop if temperature is rising")

        elif strategy == RecoveryStrategy.WAIT_AND_RETRY:
            if failure.failure_type == FailureType.FILAMENT_RUNOUT:
                steps = [
                    "Pause print (print pauses automatically on sensor-equipped printers)",
                    "Load new filament spool",
                    "Purge filament until clean extrusion",
                    "Resume print",
                ]
            elif failure.failure_type == FailureType.NOZZLE_CLOG:
                steps = [
                    "Pause print",
                    "Heat hotend to printing temperature",
                    "Perform cold pull procedure (heat, pull, repeat 2-3 times)",
                    "Verify clean extrusion with manual extrude test",
                    "Resume print",
                ]
            elif failure.failure_type == FailureType.COMMUNICATION_LOSS:
                steps = [
                    "Check physical cable connections",
                    "Verify network/Wi-Fi connectivity",
                    "Wait for connection to re-establish (auto-reconnect)",
                    "Verify printer state after reconnection",
                    "Resume print if printer was paused",
                ]
            else:
                steps = [
                    "Wait for condition to resolve",
                    "Verify printer state",
                    "Resume print",
                ]

        elif strategy == RecoveryStrategy.NO_RECOVERY:
            steps = [
                "Continue printing -- issue is cosmetic",
                "Address in post-processing after print completes",
            ]

        return steps

    def _generate_recovery_gcode(
        self,
        plan: RecoveryPlan,
        failure: FailureReport,
    ) -> list[str]:
        """Generate G-code commands for the recovery.

        Temperatures are resolved from the failure's telemetry snapshot
        (the target temps at the time of failure).  If telemetry is
        unavailable, conservative defaults are used (200C hotend, 60C bed).

        Returns a list of G-code command strings ready to send to the
        printer.
        """
        # Resolve actual temperatures from telemetry captured at failure time.
        snap = failure.telemetry_snapshot or {}
        hotend_temp = int(snap.get("hotend_temp") or 200)
        bed_temp = int(snap.get("bed_temp") or 60)

        commands: list[str] = []

        if plan.strategy == RecoveryStrategy.SAFE_ABORT:
            commands = [
                "M104 S0",  # Hotend heater off
                "M140 S0",  # Bed heater off
                "G91",  # Relative positioning
                "G1 Z10 F1000",  # Move up 10mm
                "G90",  # Absolute positioning
                "G28 X Y",  # Home X and Y (away from part)
                "M84",  # Disable steppers
            ]

        elif plan.strategy == RecoveryStrategy.RESUME_FROM_LAYER:
            resume_z = plan.parameter_adjustments.get("resume_z_mm", 0)
            safe_z = resume_z + 5  # 5mm above resume layer
            overlap = plan.parameter_adjustments.get("overlap_layers", plan.layer_overlap)
            material = plan.parameter_adjustments.get("material_type", "unknown")

            commands = [
                f"; Recovery: resume from layer {plan.resume_layer} "
                f"with {overlap}-layer overlap (material: {material})",
                "G28",  # Home all axes
                f"M104 S{hotend_temp}",  # Heat hotend to job temp
                f"M140 S{bed_temp}",  # Heat bed to job temp
                f"M109 S{hotend_temp}",  # Wait for hotend to stabilize
                f"M190 S{bed_temp}",  # Wait for bed to stabilize
                f"G1 Z{safe_z:.1f} F1000",  # Move to safe Z above resume
                "G1 E5 F300",  # Prime nozzle
                "G1 E-2 F2400",  # Small retract to prevent ooze during travel
                f"; Begin overlap bonding zone: layers {plan.resume_layer} to "
                f"{plan.resume_layer + overlap}",
                f"; Resume from layer {plan.resume_layer}, Z={resume_z:.2f}mm",
            ]

        elif plan.strategy == RecoveryStrategy.RESTART_WITH_COMPENSATION:
            commands = [
                "M104 S0",  # Cool down first
                "M140 S0",
                "G28",  # Home all
                "; Apply compensated parameters before starting new print",
            ]
            if plan.parameter_adjustments.get("bed_temp_offset"):
                offset = plan.parameter_adjustments["bed_temp_offset"]
                commands.append(f"; Bed temp offset: +{offset}C")
            if plan.parameter_adjustments.get("first_layer_speed_pct"):
                pct = plan.parameter_adjustments["first_layer_speed_pct"]
                commands.append(f"; First layer speed: {pct}%")

        elif plan.strategy == RecoveryStrategy.WAIT_AND_RETRY:
            if failure.failure_type == FailureType.FILAMENT_RUNOUT:
                commands = [
                    "; Waiting for filament load",
                    "G1 E5 F300",  # Prime after load
                    "; Resume print",
                ]
            elif failure.failure_type == FailureType.NOZZLE_CLOG:
                commands = [
                    "; Perform cold pull procedure",
                    "M104 S200",  # Heat for cold pull
                    "M109 S200",
                    "G1 E10 F100",  # Slow extrude to test
                    "; Resume print if extrusion is clean",
                ]
            else:
                commands = [
                    "; Wait for condition to clear",
                    "; Resume print",
                ]

        elif plan.strategy == RecoveryStrategy.NO_RECOVERY:
            commands = ["; No recovery action needed -- print continues"]

        elif plan.strategy == RecoveryStrategy.PARTIAL_RECOVERY:
            commands = [
                "G28",
                f"; Split G-code at layer {plan.resume_layer}",
                "; Load partial G-code file",
            ]

        return commands

    # -- private: event emission -------------------------------------------

    def _emit_event(self, failure: FailureReport) -> None:
        """Best-effort event emission for failure detection.  Never raises."""
        try:
            event = Event(
                type=EventType.PRINT_FAILED,
                data={
                    "failure_id": failure.failure_id,
                    "failure_type": failure.failure_type.value,
                    "printer_name": failure.printer_name,
                    "severity": failure.severity,
                    "probable_cause": failure.probable_cause,
                },
                source=f"recovery:{failure.printer_name}",
            )

            bus: EventBus | None = None
            try:
                from kiln.server import _event_bus as server_bus

                bus = server_bus
            except ImportError:
                pass

            if bus is not None:
                bus.publish(event)
        except Exception:
            logger.debug(
                "Failed to emit recovery event for %s",
                failure.printer_name,
                exc_info=True,
            )


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------

_engine: PrintRecovery | None = None
_engine_lock = threading.Lock()


def get_recovery_engine() -> PrintRecovery:
    """Return the module-level :class:`PrintRecovery` singleton.

    The engine is created lazily on first access.
    """
    global _engine
    if _engine is None:
        with _engine_lock:
            if _engine is None:
                _engine = PrintRecovery()
    return _engine
