"""Error recovery domain models.

Provides structured error classification, recovery hints, and retry
policy logic for plan execution failures.

Core types:

- **ErrorCategory**: Classifies errors as transient, validation,
  configuration, etc.
- **RecoveryAction**: Suggested recovery actions (retry, revert, abort).
- **RecoveryHint**: A structured suggestion for recovering from an error,
  including a human-readable message and a CLI command to execute.
- **ErrorRecord**: A single recorded error with metadata (phase, category,
  message, stack summary, recovery hints, timestamp).
- **ErrorHistory**: An ordered collection of ErrorRecords for a plan.
- **ErrorRecoveryPolicy**: Determines whether retry is appropriate given
  the error category and automation profile thresholds.
"""

from __future__ import annotations

import re
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

# ---------------------------------------------------------------------------
# Error classification
# ---------------------------------------------------------------------------


class ErrorCategory(StrEnum):
    """Classification of plan execution errors.

    Used to determine whether an error is retriable, what recovery
    hints to suggest, and how to display the error in CLI output.
    """

    TRANSIENT = "transient"
    """Temporary failure (network, rate-limit, timeout)."""

    VALIDATION = "validation"
    """Validation failure during Execute or Apply phase."""

    CONFIGURATION = "configuration"
    """Bad config, missing required fields, invalid arguments."""

    RESOURCE = "resource"
    """Missing or conflicting resource (file, sandbox, etc.)."""

    MERGE_CONFLICT = "merge_conflict"
    """Sandbox merge failed with conflicts."""

    AUTHENTICATION = "authentication"
    """Auth/authz failure accessing provider or resource."""

    PROVIDER = "provider"
    """LLM provider error (model unavailable, token limit, etc.)."""

    INTERNAL = "internal"
    """Unexpected internal error (bug, assertion, etc.)."""

    UNKNOWN = "unknown"
    """Error could not be classified."""


class RecoveryAction(StrEnum):
    """Suggested recovery action for a plan error.

    Each action maps to a concrete CLI command or user workflow.
    """

    RETRY = "retry"
    """Retry the failed phase (transient / provider errors)."""

    REVERT_STRATEGIZE = "revert_strategize"
    """Revert to Strategize phase and re-plan."""

    CORRECT = "correct"
    """Provide corrections and re-run validation."""

    PROMPT = "prompt"
    """Resume with additional user guidance."""

    CANCEL = "cancel"
    """Cancel the plan (non-recoverable error)."""

    MANUAL = "manual"
    """Requires manual investigation (internal errors)."""


# ---------------------------------------------------------------------------
# Recovery hint
# ---------------------------------------------------------------------------


class RecoveryHint(BaseModel):
    """A structured suggestion for recovering from an error.

    Each hint includes a human-readable message explaining what to do
    and an optional CLI command that can be copy-pasted.
    """

    action: RecoveryAction = Field(..., description="The suggested recovery action")
    message: str = Field(..., description="Human-readable recovery suggestion")
    cli_command: str | None = Field(
        default=None,
        description="CLI command to execute for recovery (copy-pasteable)",
    )
    priority: int = Field(
        default=0,
        ge=0,
        description="Priority of this hint (0 = highest priority)",
    )

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


# ---------------------------------------------------------------------------
# Error record
# ---------------------------------------------------------------------------


class ErrorRecord(BaseModel):
    """A single recorded error event for a plan.

    Captures structured metadata about what went wrong, in which phase,
    what actor/tool was involved, and what recovery options are available.
    """

    error_id: str = Field(..., description="Unique identifier for this error event")
    phase: str = Field(
        ..., description="Plan phase when error occurred (strategize/execute/apply)"
    )
    category: ErrorCategory = Field(..., description="Error classification")
    message: str = Field(..., description="Error message")
    actor: str | None = Field(
        default=None, description="Actor name involved in the error"
    )
    tool_call: str | None = Field(
        default=None, description="Tool call that triggered the error"
    )
    stack_summary: str | None = Field(
        default=None,
        description="Abbreviated stack trace (last 3 frames)",
    )
    recovery_hints: list[RecoveryHint] = Field(
        default_factory=list,
        description="Ordered recovery suggestions",
    )
    retry_count: int = Field(
        default=0,
        ge=0,
        description="Number of retry attempts already made for this error",
    )
    max_retries: int = Field(
        default=3,
        ge=0,
        description="Maximum retry attempts allowed",
    )
    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When the error was recorded (UTC)",
    )

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

    @property
    def is_retriable(self) -> bool:
        """Whether this error can be retried.

        An error is retriable if:
        - Its category is in the retriable set
        - The retry count has not exceeded max_retries
        """
        retriable_categories = {
            ErrorCategory.TRANSIENT,
            ErrorCategory.VALIDATION,
            ErrorCategory.MERGE_CONFLICT,
            ErrorCategory.PROVIDER,
        }
        return (
            self.category in retriable_categories
            and self.retry_count < self.max_retries
        )

    @property
    def retries_exhausted(self) -> bool:
        """Whether all retry attempts have been used."""
        return self.retry_count >= self.max_retries

    def to_cli_dict(self) -> dict[str, Any]:
        """Convert to a dictionary suitable for CLI display."""
        result: dict[str, Any] = {
            "error_id": self.error_id,
            "phase": self.phase,
            "category": self.category.value,
            "message": self.message,
            "retry_count": self.retry_count,
            "max_retries": self.max_retries,
            "is_retriable": self.is_retriable,
            "timestamp": self.timestamp.isoformat(),
        }
        if self.actor:
            result["actor"] = self.actor
        if self.tool_call:
            result["tool_call"] = self.tool_call
        if self.stack_summary:
            result["stack_summary"] = self.stack_summary
        if self.recovery_hints:
            result["recovery_hints"] = [
                {
                    "action": h.action.value,
                    "message": h.message,
                    "cli_command": h.cli_command,
                }
                for h in sorted(self.recovery_hints, key=lambda h: h.priority)
            ]
        return result


# ---------------------------------------------------------------------------
# Error history
# ---------------------------------------------------------------------------


class ErrorHistory(BaseModel):
    """Ordered collection of error records for a plan.

    Provides convenience methods for querying error state and
    formatting for CLI output.
    """

    plan_id: str = Field(..., description="Plan ULID")
    records: list[ErrorRecord] = Field(
        default_factory=list,
        description="Error records in chronological order",
    )

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

    def add(self, record: ErrorRecord) -> None:
        """Append an error record."""
        self.records.append(record)

    @property
    def latest(self) -> ErrorRecord | None:
        """Return the most recent error, or None."""
        return self.records[-1] if self.records else None

    @property
    def total_errors(self) -> int:
        """Total number of recorded errors."""
        return len(self.records)

    @property
    def total_retries(self) -> int:
        """Sum of all retry counts across records."""
        return sum(r.retry_count for r in self.records)

    @property
    def has_retriable(self) -> bool:
        """Whether any error in the history is still retriable."""
        return any(r.is_retriable for r in self.records)

    def errors_by_category(self) -> dict[str, int]:
        """Count errors by category."""
        counts: dict[str, int] = {}
        for record in self.records:
            key = record.category.value
            counts[key] = counts.get(key, 0) + 1
        return counts

    def to_cli_dict(self) -> dict[str, Any]:
        """Convert to a dictionary suitable for CLI display."""
        return {
            "plan_id": self.plan_id,
            "total_errors": self.total_errors,
            "total_retries": self.total_retries,
            "has_retriable": self.has_retriable,
            "errors_by_category": self.errors_by_category(),
            "records": [r.to_cli_dict() for r in self.records],
        }


# ---------------------------------------------------------------------------
# Recovery hint generation
# ---------------------------------------------------------------------------

# Maps error categories to default recovery hints
_DEFAULT_HINTS: dict[ErrorCategory, list[RecoveryHint]] = {
    ErrorCategory.TRANSIENT: [
        RecoveryHint(
            action=RecoveryAction.RETRY,
            message="Retry the operation — the failure was transient.",
            cli_command="agents plan prompt {plan_id}",
            priority=0,
        ),
    ],
    ErrorCategory.VALIDATION: [
        RecoveryHint(
            action=RecoveryAction.CORRECT,
            message="Fix validation issues and re-run.",
            cli_command="agents plan correct {plan_id}",
            priority=0,
        ),
        RecoveryHint(
            action=RecoveryAction.PROMPT,
            message="Provide additional guidance to help resolve the issue.",
            cli_command="agents plan prompt {plan_id}",
            priority=1,
        ),
    ],
    ErrorCategory.CONFIGURATION: [
        RecoveryHint(
            action=RecoveryAction.CANCEL,
            message="Fix the configuration and re-create the plan.",
            priority=0,
        ),
    ],
    ErrorCategory.RESOURCE: [
        RecoveryHint(
            action=RecoveryAction.REVERT_STRATEGIZE,
            message=("Revert to Strategize to re-plan with updated resources."),
            cli_command=("agents plan revert {plan_id} --to-phase strategize"),
            priority=0,
        ),
    ],
    ErrorCategory.MERGE_CONFLICT: [
        RecoveryHint(
            action=RecoveryAction.REVERT_STRATEGIZE,
            message="Resolve merge conflicts and re-run from Strategize.",
            cli_command=("agents plan revert {plan_id} --to-phase strategize"),
            priority=0,
        ),
        RecoveryHint(
            action=RecoveryAction.RETRY,
            message="Retry apply after resolving conflicts manually.",
            cli_command="agents plan prompt {plan_id}",
            priority=1,
        ),
    ],
    ErrorCategory.AUTHENTICATION: [
        RecoveryHint(
            action=RecoveryAction.CANCEL,
            message=(
                "Check authentication credentials and retry. "
                "This error is not auto-retriable."
            ),
            priority=0,
        ),
    ],
    ErrorCategory.PROVIDER: [
        RecoveryHint(
            action=RecoveryAction.RETRY,
            message="Retry — the LLM provider may be temporarily unavailable.",
            cli_command="agents plan prompt {plan_id}",
            priority=0,
        ),
    ],
    ErrorCategory.INTERNAL: [
        RecoveryHint(
            action=RecoveryAction.MANUAL,
            message=(
                "An internal error occurred. Check logs and report "
                "the issue if it persists."
            ),
            priority=0,
        ),
        RecoveryHint(
            action=RecoveryAction.CANCEL,
            message="Cancel the plan and start fresh if the error persists.",
            cli_command="agents plan cancel {plan_id}",
            priority=1,
        ),
    ],
    ErrorCategory.UNKNOWN: [
        RecoveryHint(
            action=RecoveryAction.MANUAL,
            message="Unknown error — investigate logs for details.",
            priority=0,
        ),
    ],
}


def get_recovery_hints(
    category: ErrorCategory,
    plan_id: str,
) -> list[RecoveryHint]:
    """Return recovery hints for an error category.

    Substitutes ``{plan_id}`` placeholders in CLI commands with
    the actual plan ID.

    Args:
        category: The error classification.
        plan_id: The plan ULID to substitute into CLI commands.

    Returns:
        List of ``RecoveryHint`` objects ordered by priority.
    """
    templates = _DEFAULT_HINTS.get(category, _DEFAULT_HINTS[ErrorCategory.UNKNOWN])
    hints: list[RecoveryHint] = []
    for tmpl in templates:
        cli_cmd = (
            tmpl.cli_command.replace("{plan_id}", plan_id) if tmpl.cli_command else None
        )
        hints.append(
            RecoveryHint(
                action=tmpl.action,
                message=tmpl.message,
                cli_command=cli_cmd,
                priority=tmpl.priority,
            )
        )
    return hints


# ---------------------------------------------------------------------------
# Error classification helper
# ---------------------------------------------------------------------------

# Compiled regex patterns for classifying error messages.
# Ordered list of (category, pattern) — checked in order, first match wins.
# More specific categories (authentication, merge_conflict) must appear
# before broader ones (validation, resource) to avoid false matches.
_CATEGORY_PATTERNS: list[tuple[ErrorCategory, re.Pattern[str]]] = [
    (
        ErrorCategory.AUTHENTICATION,
        re.compile(
            r"authentication|unauthorized|forbidden|permission denied",
        ),
    ),
    (
        ErrorCategory.MERGE_CONFLICT,
        re.compile(r"merge conflict|merge failed"),
    ),
    (
        ErrorCategory.TRANSIENT,
        re.compile(
            r"timeout|timed out|temporary|connection reset"
            r"|connection refused|rate limit|throttl",
        ),
    ),
    (
        ErrorCategory.PROVIDER,
        re.compile(
            r"model not available|token limit|context length|provider error",
        ),
    ),
    (
        ErrorCategory.CONFIGURATION,
        re.compile(
            r"configuration|config|missing setting|not configured",
        ),
    ),
    (
        ErrorCategory.VALIDATION,
        re.compile(
            r"validation|invalid|constraint|does not match"
            r"|required field|schema",
        ),
    ),
    (
        ErrorCategory.RESOURCE,
        re.compile(r"not found|missing resource|no such file"),
    ),
    (
        ErrorCategory.INTERNAL,
        re.compile(r"internal error|assertion|unexpected|bug"),
    ),
]

# Exception-type → category mapping (checked before keyword patterns).
_EXCEPTION_TYPE_MAP: dict[str, ErrorCategory] = {
    "ratelimiterror": ErrorCategory.TRANSIENT,
    "networkerror": ErrorCategory.TRANSIENT,
    "timeouterror": ErrorCategory.TRANSIENT,
    "validationerror": ErrorCategory.VALIDATION,
    "configurationerror": ErrorCategory.CONFIGURATION,
    "missingconfigurationerror": ErrorCategory.CONFIGURATION,
    "resourcenotfounderror": ErrorCategory.RESOURCE,
    "resourceconflicterror": ErrorCategory.RESOURCE,
    "authenticationerror": ErrorCategory.AUTHENTICATION,
    "authorizationerror": ErrorCategory.AUTHENTICATION,
    "providererror": ErrorCategory.PROVIDER,
    "modelnotavailableerror": ErrorCategory.PROVIDER,
    "tokenlimitexceedederror": ErrorCategory.PROVIDER,
}


def classify_error(
    message: str,
    exception_type: str | None = None,
) -> ErrorCategory:
    """Classify an error message into an ErrorCategory.

    Uses a compiled regex per category for keyword matching on the
    message text. If ``exception_type`` is provided, it takes
    precedence.

    Args:
        message: The error message to classify.
        exception_type: Optional exception class name.

    Returns:
        The best-matching ``ErrorCategory``.
    """
    lower_exc = (exception_type or "").lower()

    # Check exception type first (more specific)
    if lower_exc in _EXCEPTION_TYPE_MAP:
        return _EXCEPTION_TYPE_MAP[lower_exc]

    # Single compiled-regex search per category (first match wins)
    lower_msg = message.lower()
    for category, pattern in _CATEGORY_PATTERNS:
        if pattern.search(lower_msg):
            return category

    return ErrorCategory.UNKNOWN


# ---------------------------------------------------------------------------
# Error recovery policy
# ---------------------------------------------------------------------------


class ErrorRecoveryPolicy:
    """Determines whether retry is appropriate for a given error.

    Uses the automation profile thresholds to decide between automatic
    retry (threshold 0.0) and human intervention (threshold 1.0).

    Attributes:
        auto_retry_threshold: Threshold from automation profile
            (0.0 = always auto-retry, 1.0 = always ask human).
        max_retries: Maximum retry attempts.
    """

    def __init__(
        self,
        auto_retry_threshold: float = 0.0,
        max_retries: int = 3,
    ) -> None:
        self.auto_retry_threshold = auto_retry_threshold
        self.max_retries = max_retries

    def should_retry(self, record: ErrorRecord) -> bool:
        """Determine whether the error should be retried.

        Returns ``True`` if:
        - The error is in a retriable category
        - Retries have not been exhausted
        - The automation profile allows auto-retry (threshold < 1.0)

        Args:
            record: The error record to evaluate.

        Returns:
            ``True`` if retry should proceed automatically.
        """
        if not record.is_retriable:
            return False
        if record.retries_exhausted:
            return False
        # Threshold < 1.0 means auto-retry is enabled
        return self.auto_retry_threshold < 1.0

    def should_escalate(self, record: ErrorRecord) -> bool:
        """Determine whether the error requires human intervention.

        Returns ``True`` if:
        - The error is not retriable, OR
        - Retries are exhausted, OR
        - The automation profile requires human decision

        Args:
            record: The error record to evaluate.

        Returns:
            ``True`` if the error should be escalated to a human.
        """
        if not record.is_retriable:
            return True
        if record.retries_exhausted:
            return True
        return self.auto_retry_threshold >= 1.0

    def format_recovery_output(
        self,
        record: ErrorRecord,
    ) -> str:
        """Format a human-readable recovery output for CLI display.

        Includes error details, recovery hints with CLI commands,
        and retry status.

        Args:
            record: The error record to format.

        Returns:
            Formatted string for CLI output.
        """
        lines: list[str] = []
        lines.append(f"Error [{record.category.value}]: {record.message}")
        lines.append(f"  Phase: {record.phase}")
        if record.actor:
            lines.append(f"  Actor: {record.actor}")
        if record.tool_call:
            lines.append(f"  Tool:  {record.tool_call}")
        lines.append(f"  Retry: {record.retry_count}/{record.max_retries}")

        if record.recovery_hints:
            lines.append("  Recovery suggestions:")
            for hint in sorted(record.recovery_hints, key=lambda h: h.priority):
                lines.append(f"    → {hint.message}")
                if hint.cli_command:
                    lines.append(f"      $ {hint.cli_command}")

        if record.retries_exhausted:
            lines.append("  ⚠ All retry attempts exhausted.")
        elif self.should_retry(record):
            lines.append("  ✓ Automatic retry available.")
        else:
            lines.append("  ⚠ Human intervention required.")

        return "\n".join(lines)
