"""Fix-then-Revalidate orchestration loop for required validations.

Implements the diagnosis -> self-fix -> re-validation -> retry limit ->
strategy revision escalation -> user escalation -> terminal failure flow
described in the specification section "Validation Failure Handling."

When a required validation fails during the Execute phase, this
orchestrator drives a bounded retry loop:

1. **Diagnosis** -- the execution actor examines the validation's
   ``message`` and ``data`` fields to understand the failure.
2. **Self-fix attempt** -- the actor attempts to correct the issue
   within strategy constraints (via ``fix_callback``).
3. **Re-validation** -- the failing validation is re-invoked (via
   ``revalidate_callback``).
4. **Retry limit** -- configurable via the ``max_retries`` constructor
   parameter (default 3, range 0--100 per spec Safety Profile
   ``max_retries_per_step``).
5. **Strategy revision** -- if the failure cannot be resolved and the
   ``auto_strategy_revision`` confidence threshold is met, the result
   signals escalation so the caller can trigger a strategy revision.
6. **User escalation** -- if strategy revision is not available, the
   result signals that user guidance is needed via
   ``agents plan prompt``.
7. **Terminal failure** -- reserved for the calling ``PlanExecutor``
   when user escalation also fails; the plan transitions to
   ``state: failed`` and the sandbox is preserved for inspection.

Callback exceptions are caught and treated as failed attempts to ensure
retry loop resilience.  This prevents a single misbehaving callback from
crashing the entire orchestration loop.

Validation errors (runtime exceptions during validation execution) are
treated as required failures regardless of the validation's mode, per
the specification section "Validation Error vs. Validation Failure."
"""

from __future__ import annotations

import re
import threading
from collections import defaultdict
from collections.abc import Callable
from datetime import UTC, datetime

import structlog
from pydantic import BaseModel, ConfigDict, Field, model_validator

from cleveragents.application.services.validation_pipeline import (
    ValidationPipeline,
    ValidationResult,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.tool import ValidationMode
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.protocol import EventBus
from cleveragents.infrastructure.events.types import EventType

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# ULID pattern for plan_id validation (matches domain model convention)
# ---------------------------------------------------------------------------
_ULID_PATTERN = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")

# ---------------------------------------------------------------------------
# Callback type aliases
# ---------------------------------------------------------------------------

FixCallback = Callable[[ValidationResult], str | None]
"""Callable that attempts to fix a failed validation.

Receives the failed :class:`ValidationResult` and returns a
human-readable description of the fix that was applied, or ``None``
to signal that the failure is unfixable within current strategy
constraints and the orchestrator should escalate immediately.

May raise exceptions, which are caught by the orchestrator and
treated as failed fix attempts.
"""

RevalidateCallback = Callable[[ValidationResult], ValidationResult]
"""Callable that re-runs a single validation after a fix attempt.

Receives the original failed :class:`ValidationResult` (for context)
and returns a fresh :class:`ValidationResult` from re-execution.
"""

# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------

_MAX_VALIDATION_NAME_LEN = 255


class FixAttemptRecord(BaseModel):
    """Record of a single fix-then-revalidate attempt.

    Attributes:
        timestamp: When the attempt was made.
        attempt_number: 1-based attempt counter for this validation.
        validation_name: Name of the validation that was retried.
        fix_description: Human-readable description of the fix applied.
        success: Whether re-validation passed after the fix.
    """

    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When the attempt was made (UTC)",
    )
    attempt_number: int = Field(..., ge=1, description="1-based attempt counter")
    validation_name: str = Field(
        ...,
        min_length=1,
        max_length=_MAX_VALIDATION_NAME_LEN,
        description="Validation that was retried",
    )
    fix_description: str = Field(
        ..., max_length=2000, description="Description of the fix applied"
    )
    success: bool = Field(..., description="Whether re-validation passed after the fix")

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
    )


class FixThenRevalidateResult(BaseModel):
    """Aggregated result from the fix-then-revalidate orchestration loop.

    Attributes:
        validation_attempts: Total fix attempts across all validations.
        final_passed: Whether all required validations eventually passed.
        validation_fix_history: Ordered list of all fix attempt records.
        escalated: Whether the loop escalated to strategy revision.
        needs_user_escalation: Whether user guidance is needed.
        terminal_failure: Whether the plan reached terminal failure.
        validation_summary: Array of all intermediate validation results
            collected during the loop, including attempt numbers.
        final_validation_results: Snapshot of the final validation state
            for each processed validation.
    """

    validation_attempts: int = Field(
        ..., ge=0, description="Total fix attempts across all validations"
    )
    final_passed: bool = Field(
        ..., description="Whether all required validations eventually passed"
    )
    validation_fix_history: list[FixAttemptRecord] = Field(
        default_factory=list,
        description="Ordered list of all fix attempt records",
    )
    escalated: bool = Field(
        default=False,
        description="Whether the loop escalated to strategy revision",
    )
    needs_user_escalation: bool = Field(
        default=False,
        description="Whether user guidance is needed via agents plan prompt",
    )
    terminal_failure: bool = Field(
        default=False,
        description="Whether the plan reached terminal failure",
    )
    validation_summary: list[ValidationResult] = Field(
        default_factory=list,
        description=(
            "Array of all intermediate validation results collected "
            "during the loop, including per-attempt outcomes"
        ),
    )
    final_validation_results: list[ValidationResult] = Field(
        default_factory=list,
        description=(
            "Snapshot of the final validation state for each processed validation"
        ),
    )

    @model_validator(mode="after")
    def _check_mutually_exclusive_flags(self) -> FixThenRevalidateResult:
        """Enforce mutual exclusivity of outcome flags.

        Invalid combinations:
        - At most one of ``escalated``, ``needs_user_escalation``, and
          ``terminal_failure`` can be True.
        - ``final_passed`` True with any failure/escalation flag True.
        """
        flags = [self.escalated, self.needs_user_escalation, self.terminal_failure]
        if sum(flags) > 1:
            msg = (
                "At most one of escalated, needs_user_escalation, "
                "and terminal_failure can be True"
            )
            raise ValueError(msg)
        if self.final_passed and any(flags):
            msg = (
                "final_passed cannot be True when escalated, "
                "needs_user_escalation, or terminal_failure is True"
            )
            raise ValueError(msg)
        return self

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
    )


# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------

_DEFAULT_MAX_RETRIES = 3
_MIN_MAX_RETRIES = 0
_MAX_MAX_RETRIES = 100
_MAX_FIX_DESCRIPTION_LEN = 2000
_MAX_EVENT_BUS_CONSECUTIVE_FAILURES = 5


def _sanitize_for_log(value: str) -> str:
    """Remove control characters from a string for safe log output."""
    return value.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")


class FixThenRevalidateOrchestrator:
    """Orchestrates the fix-then-revalidate loop for failed validations.

    Only processes **required** validations that have failed;
    informational failures are silently skipped.  Validation errors
    (runtime exceptions) are treated as required failures regardless
    of the validation's mode.

    Args:
        validation_pipeline: The ``ValidationPipeline`` instance used for
            context (not re-invoked directly -- use callbacks instead).
        max_retries: Maximum fix attempts per validation (default 3,
            range 0--100 per spec Safety Profile ``max_retries_per_step``).
        auto_strategy_revision: Confidence threshold (0.0--1.0) for
            automatic strategy revision when retries are exhausted.
            0.0 = always auto-escalate, 1.0 = always require manual
            approval.
        auto_validation_fix: Confidence threshold (0.0--1.0) for
            automatic validation fix attempts.  0.0 = always auto,
            1.0 = always manual.  Stored for caller use.
        event_bus: Optional event bus for emitting domain events.

    Raises:
        ValidationError: If *validation_pipeline* is ``None``,
            *max_retries* is out of range, or type constraints
            are violated.
    """

    def __init__(
        self,
        validation_pipeline: ValidationPipeline,
        max_retries: int = _DEFAULT_MAX_RETRIES,
        auto_strategy_revision: float = 0.0,
        auto_validation_fix: float = 0.0,
        event_bus: EventBus | None = None,
    ) -> None:
        if validation_pipeline is None:
            raise ValidationError("validation_pipeline must not be None")
        if isinstance(max_retries, bool) or not isinstance(max_retries, int):
            raise ValidationError("max_retries must be an integer")
        if max_retries < _MIN_MAX_RETRIES or max_retries > _MAX_MAX_RETRIES:
            raise ValidationError(
                f"max_retries must be between {_MIN_MAX_RETRIES} and "
                f"{_MAX_MAX_RETRIES}, got {max_retries}"
            )
        if not isinstance(auto_strategy_revision, (int, float)) or isinstance(
            auto_strategy_revision, bool
        ):
            raise ValidationError("auto_strategy_revision must be a float")
        if not (0.0 <= float(auto_strategy_revision) <= 1.0):
            raise ValidationError(
                "auto_strategy_revision must be between 0.0 and 1.0, "
                f"got {auto_strategy_revision}"
            )
        if not isinstance(auto_validation_fix, (int, float)) or isinstance(
            auto_validation_fix, bool
        ):
            raise ValidationError("auto_validation_fix must be a float")
        if not (0.0 <= float(auto_validation_fix) <= 1.0):
            raise ValidationError(
                "auto_validation_fix must be between 0.0 and 1.0, "
                f"got {auto_validation_fix}"
            )
        if event_bus is not None and not isinstance(event_bus, EventBus):
            raise ValidationError("event_bus must be an EventBus instance or None")

        self._pipeline = validation_pipeline
        self._max_retries = max_retries
        self._auto_strategy_revision = float(auto_strategy_revision)
        self._auto_validation_fix = float(auto_validation_fix)
        self._event_bus = event_bus
        # Per-(plan_id, validation_key) retry counters.
        # Inner key is (validation_name, resource_id) to prevent
        # same-named validations on different resources from sharing
        # a retry budget.
        self._retry_counts: dict[str, dict[tuple[str, str], int]] = defaultdict(
            lambda: defaultdict(int)
        )
        self._lock = threading.RLock()
        self._event_bus_consecutive_failures = 0

    @property
    def max_retries(self) -> int:
        """Maximum fix attempts per validation."""
        return self._max_retries

    @property
    def auto_strategy_revision(self) -> float:
        """Confidence threshold for automatic strategy revision."""
        return self._auto_strategy_revision

    @property
    def auto_validation_fix(self) -> float:
        """Confidence threshold for automatic validation fix attempts."""
        return self._auto_validation_fix

    @property
    def event_bus(self) -> EventBus | None:
        """Return the event bus, if configured."""
        return self._event_bus

    def get_retry_count(
        self,
        plan_id: str,
        validation_name: str,
        resource_id: str = "",
    ) -> int:
        """Return the current fix attempt count for a validation in a plan.

        Args:
            plan_id: The plan identifier.
            validation_name: The validation name.
            resource_id: The resource identifier (defaults to empty string
                for backward compatibility).

        Returns:
            Number of fix attempts already made (0 if none).
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if not validation_name:
            raise ValidationError("validation_name must not be empty")
        vkey = (validation_name, resource_id)
        with self._lock:
            plan_counts = self._retry_counts.get(plan_id)
            if plan_counts is None:
                return 0
            return plan_counts.get(vkey, 0)

    def reset_retry_counts(self, plan_id: str) -> None:
        """Reset all retry counters for a plan.

        Args:
            plan_id: The plan identifier.

        Raises:
            ValidationError: If *plan_id* is empty.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        with self._lock:
            self._retry_counts.pop(plan_id, None)

    def run_fix_loop(
        self,
        plan_id: str,
        failed_results: list[ValidationResult],
        fix_callback: FixCallback,
        revalidate_callback: RevalidateCallback,
        *,
        actor_name: str | None = None,
    ) -> FixThenRevalidateResult:
        """Run the fix-then-revalidate loop for failed validations.

        Only **required** validations are processed.  Informational
        failures are silently skipped unless they have a validation error,
        in which case they are treated as required failures per the
        specification section "Validation Error vs. Validation Failure."

        Args:
            plan_id: Identifier of the plan being executed (ULID format).
            failed_results: List of failed validation results to process.
            fix_callback: Callable that attempts to fix a failure.
            revalidate_callback: Callable that re-runs a validation.
            actor_name: Optional actor name for event correlation.

        Returns:
            :class:`FixThenRevalidateResult` describing the loop outcome.

        Raises:
            ValidationError: If required arguments are invalid.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if failed_results is None:
            raise ValidationError("failed_results must not be None")
        if fix_callback is None:
            raise ValidationError("fix_callback must not be None")
        if not callable(fix_callback):
            raise ValidationError("fix_callback must be callable")
        if revalidate_callback is None:
            raise ValidationError("revalidate_callback must not be None")
        if not callable(revalidate_callback):
            raise ValidationError("revalidate_callback must be callable")

        # Filter to required failures AND validation errors (regardless
        # of mode).  Per spec: validation errors are "always treated as
        # required failure regardless of mode."
        required_failures = [
            r
            for r in failed_results
            if (r.mode == ValidationMode.REQUIRED and not r.passed)
            or (r.error is not None)
        ]

        if not required_failures:
            log = logger.bind(plan_id=plan_id)
            log.info("no_required_validation_failures_to_fix")
            return FixThenRevalidateResult(
                validation_attempts=0,
                final_passed=True,
                validation_fix_history=[],
                escalated=False,
                needs_user_escalation=False,
                terminal_failure=False,
                validation_summary=[],
                final_validation_results=[],
            )

        validation_fix_history: list[FixAttemptRecord] = []
        validation_summary: list[ValidationResult] = []
        final_validation_results: list[ValidationResult] = []
        all_resolved = True

        for failed_result in required_failures:
            resolved, last_result, intermediate_results = self._fix_single_validation(
                plan_id=plan_id,
                failed_result=failed_result,
                fix_callback=fix_callback,
                revalidate_callback=revalidate_callback,
                validation_fix_history=validation_fix_history,
                actor_name=actor_name,
            )
            validation_summary.extend(intermediate_results)
            final_validation_results.append(last_result)
            if not resolved:
                all_resolved = False

        validation_attempts = len(validation_fix_history)

        # Auto-reset retry counts for this plan after the loop completes
        # to prevent cross-invocation budget exhaustion when the caller
        # re-invokes after strategy revision.
        self.reset_retry_counts(plan_id)

        if all_resolved:
            log = logger.bind(plan_id=plan_id, validation_attempts=validation_attempts)
            log.info("all_required_validations_resolved")
            return FixThenRevalidateResult(
                validation_attempts=validation_attempts,
                final_passed=True,
                validation_fix_history=validation_fix_history,
                escalated=False,
                needs_user_escalation=False,
                terminal_failure=False,
                validation_summary=validation_summary,
                final_validation_results=final_validation_results,
            )

        # --- Escalation path: strategy revision ---
        if self._auto_strategy_revision < 1.0:
            log = logger.bind(plan_id=plan_id)
            log.warning("fix_loop_exhausted_escalating_to_strategy_revision")
            self._emit_event(
                EventType.VALIDATION_FIX_EXHAUSTED,
                plan_id=plan_id,
                actor_name=actor_name,
                details={
                    "validation_attempts": validation_attempts,
                    "escalated": True,
                    "needs_user_escalation": False,
                    "terminal_failure": False,
                },
            )
            return FixThenRevalidateResult(
                validation_attempts=validation_attempts,
                final_passed=False,
                validation_fix_history=validation_fix_history,
                escalated=True,
                needs_user_escalation=False,
                terminal_failure=False,
                validation_summary=validation_summary,
                final_validation_results=final_validation_results,
            )

        # --- User escalation (strategy revision not available) ---
        log = logger.bind(plan_id=plan_id)
        log.warning("fix_loop_exhausted_needs_user_escalation")
        self._emit_event(
            EventType.VALIDATION_FIX_EXHAUSTED,
            plan_id=plan_id,
            actor_name=actor_name,
            details={
                "validation_attempts": validation_attempts,
                "escalated": False,
                "needs_user_escalation": True,
                "terminal_failure": False,
            },
        )
        return FixThenRevalidateResult(
            validation_attempts=validation_attempts,
            final_passed=False,
            validation_fix_history=validation_fix_history,
            escalated=False,
            needs_user_escalation=True,
            terminal_failure=False,
            validation_summary=validation_summary,
            final_validation_results=final_validation_results,
        )

    def _fix_single_validation(
        self,
        plan_id: str,
        failed_result: ValidationResult,
        fix_callback: FixCallback,
        revalidate_callback: RevalidateCallback,
        validation_fix_history: list[FixAttemptRecord],
        actor_name: str | None = None,
    ) -> tuple[bool, ValidationResult, list[ValidationResult]]:
        """Attempt to fix and revalidate a single failed validation.

        Callback exceptions are caught and treated as failed attempts
        to ensure retry loop resilience.

        Args:
            plan_id: The plan identifier.
            failed_result: The validation result that failed.
            fix_callback: Callback to attempt a fix.
            revalidate_callback: Callback to re-run the validation.
            validation_fix_history: Mutable list to append records to.
            actor_name: Optional actor name for event correlation.

        Returns:
            Tuple of (resolved, last_result, intermediate_results) where
            resolved is ``True`` if the validation was fixed, last_result
            is the final ``ValidationResult`` for this validation, and
            intermediate_results is a list of all ``ValidationResult``
            objects produced during the attempts (for validation_summary).
        """
        vname = failed_result.validation_name
        safe_vname = _sanitize_for_log(vname)
        resource_id = failed_result.resource_id
        # Defensive truncation to prevent FixAttemptRecord Pydantic error
        # when ValidationResult.validation_name exceeds max_length.
        truncated_vname = vname[:_MAX_VALIDATION_NAME_LEN]
        vkey = (vname, resource_id)
        attempted = False
        last_result = failed_result
        intermediate_results: list[ValidationResult] = []

        for _ in range(self._max_retries):
            # Atomically claim a retry slot.  This prevents the TOCTOU
            # race where concurrent threads could both read the same
            # counter value, pass the budget guard, and exceed the
            # configured retry limit.
            with self._lock:
                plan_counts = self._retry_counts.get(plan_id)
                current = plan_counts.get(vkey, 0) if plan_counts is not None else 0
                if current >= self._max_retries:
                    break
                attempt_num = current + 1
                self._retry_counts[plan_id][vkey] = attempt_num

            attempted = True

            log = logger.bind(
                plan_id=plan_id,
                validation_name=safe_vname,
                attempt=attempt_num,
                max_retries=self._max_retries,
            )
            log.info("fix_attempt_started")

            # Step 1: Diagnosis + fix attempt.
            # Exceptions from the callback are treated as failed fix
            # attempts to ensure loop resilience.
            try:
                fix_description = fix_callback(failed_result)
            except Exception as exc:
                fix_description = (
                    f"Fix callback raised {type(exc).__name__} for "
                    f"'{safe_vname}' (attempt {attempt_num})"
                )
                log.warning(
                    "fix_callback_exception",
                    error_type=type(exc).__name__,
                    error_msg=str(exc)[:200],
                )
                record = FixAttemptRecord(
                    attempt_number=attempt_num,
                    validation_name=truncated_vname,
                    fix_description=fix_description[:_MAX_FIX_DESCRIPTION_LEN],
                    success=False,
                )
                validation_fix_history.append(record)
                self._emit_event(
                    EventType.VALIDATION_FIX_ATTEMPTED,
                    plan_id=plan_id,
                    actor_name=actor_name,
                    details={
                        "validation_name": vname,
                        "attempt_number": attempt_num,
                        "success": False,
                        "error": "fix_callback_exception",
                    },
                )
                continue

            # Handle early-exit signal: None means "unfixable, escalate"
            if fix_description is None:
                log.info("fix_callback_signalled_unfixable")
                record = FixAttemptRecord(
                    attempt_number=attempt_num,
                    validation_name=truncated_vname,
                    fix_description="Callback signalled unfixable",
                    success=False,
                )
                validation_fix_history.append(record)
                self._emit_event(
                    EventType.VALIDATION_FIX_ATTEMPTED,
                    plan_id=plan_id,
                    actor_name=actor_name,
                    details={
                        "validation_name": vname,
                        "attempt_number": attempt_num,
                        "success": False,
                        "error": "unfixable_within_strategy",
                    },
                )
                return False, last_result, intermediate_results

            # Defensive type coercion for non-string returns
            if not isinstance(fix_description, str):
                fix_description = str(fix_description)

            # Truncate fix_description to prevent PydanticValidationError
            # from the max_length constraint on FixAttemptRecord.
            fix_description = fix_description[:_MAX_FIX_DESCRIPTION_LEN]

            # Step 2: Re-validation.
            # Exceptions are treated as validation errors (passed=False).
            try:
                new_result = revalidate_callback(failed_result)
            except Exception as exc:
                log.warning(
                    "revalidate_callback_exception",
                    error_type=type(exc).__name__,
                    error_msg=str(exc)[:200],
                )
                new_result = ValidationResult(
                    validation_name=failed_result.validation_name,
                    resource_id=failed_result.resource_id,
                    resource_name=failed_result.resource_name,
                    mode=failed_result.mode,
                    passed=False,
                    message=(
                        f"Revalidation raised an exception (attempt {attempt_num})"
                    ),
                    data=failed_result.data,
                    duration_ms=0.0,
                    error=(f"revalidate_callback exception (attempt {attempt_num})"),
                )

            # Verify the returned result matches the expected validation.
            if new_result.validation_name != vname:
                log.warning(
                    "revalidate_validation_name_mismatch",
                    expected=safe_vname,
                    got=_sanitize_for_log(new_result.validation_name),
                )
                new_result = ValidationResult(
                    validation_name=vname,
                    resource_id=failed_result.resource_id,
                    resource_name=failed_result.resource_name,
                    mode=failed_result.mode,
                    passed=False,
                    message=(
                        f"Revalidation returned mismatched "
                        f"validation_name (expected '{safe_vname}', "
                        f"got '{_sanitize_for_log(new_result.validation_name)}')"
                    ),
                    data=failed_result.data,
                    duration_ms=new_result.duration_ms,
                    error=(f"validation_name mismatch (attempt {attempt_num})"),
                )

            # Handle contradictory passed=True with error set.
            # Per spec: validation errors are always treated as required
            # failure regardless of mode.
            if new_result.passed and new_result.error is not None:
                log.warning(
                    "contradictory_passed_with_error",
                    error=new_result.error[:200],
                )
                new_result = ValidationResult(
                    validation_name=new_result.validation_name,
                    resource_id=new_result.resource_id,
                    resource_name=new_result.resource_name,
                    mode=new_result.mode,
                    passed=False,
                    message=(
                        f"Validation reported passed=True with error "
                        f"set; treating as failed: {new_result.error}"
                    ),
                    data=new_result.data,
                    duration_ms=new_result.duration_ms,
                    error=new_result.error,
                )

            last_result = new_result
            intermediate_results.append(new_result)

            record = FixAttemptRecord(
                attempt_number=attempt_num,
                validation_name=truncated_vname,
                fix_description=fix_description,
                success=new_result.passed,
            )
            validation_fix_history.append(record)

            self._emit_event(
                EventType.VALIDATION_FIX_ATTEMPTED,
                plan_id=plan_id,
                actor_name=actor_name,
                details={
                    "validation_name": vname,
                    "attempt_number": attempt_num,
                    "success": new_result.passed,
                },
            )

            if new_result.passed:
                log.info("validation_passed_after_fix")
                self._emit_event(
                    EventType.VALIDATION_FIX_SUCCEEDED,
                    plan_id=plan_id,
                    actor_name=actor_name,
                    details={
                        "validation_name": vname,
                        "attempt_number": attempt_num,
                    },
                )
                return True, last_result, intermediate_results

            # Update failed_result for next iteration's diagnosis
            failed_result = new_result

        if not attempted:
            log = logger.bind(
                plan_id=plan_id,
                validation_name=safe_vname,
                current_count=self.get_retry_count(plan_id, vname, resource_id),
                max_retries=self._max_retries,
            )
            log.warning("retry_budget_already_exhausted")
        else:
            log = logger.bind(
                plan_id=plan_id,
                validation_name=safe_vname,
                max_retries=self._max_retries,
            )
            log.warning("retry_limit_exhausted")
        return False, last_result, intermediate_results

    def _emit_event(
        self,
        event_type: EventType,
        plan_id: str,
        details: dict[str, object],
        actor_name: str | None = None,
    ) -> None:
        """Emit a domain event if an event bus is configured.

        Includes a simple circuit breaker: after
        ``_MAX_EVENT_BUS_CONSECUTIVE_FAILURES`` consecutive failures,
        event emission is silently disabled until a successful emit
        resets the counter.

        Args:
            event_type: The event type to emit.
            plan_id: The plan identifier for correlation.
            details: Event-specific payload.
            actor_name: Optional actor name for event correlation.
        """
        if self._event_bus is None:
            return
        with self._lock:
            if (
                self._event_bus_consecutive_failures
                >= _MAX_EVENT_BUS_CONSECUTIVE_FAILURES
            ):
                return
        try:
            self._event_bus.emit(
                DomainEvent(
                    event_type=event_type,
                    plan_id=plan_id,
                    actor_name=actor_name,
                    details=details,
                )
            )
            with self._lock:
                self._event_bus_consecutive_failures = 0
        except Exception:
            with self._lock:
                self._event_bus_consecutive_failures += 1
                consecutive_failures = self._event_bus_consecutive_failures
            log = logger.bind(
                event_type=str(event_type),
                plan_id=plan_id,
                consecutive_failures=consecutive_failures,
            )
            log.warning("event_bus_emit_failed")


__all__ = [
    "FixAttemptRecord",
    "FixCallback",
    "FixThenRevalidateOrchestrator",
    "FixThenRevalidateResult",
    "RevalidateCallback",
]
