"""Abstract interfaces and data models for fair task queue.

This module contains:
- Protocol definitions for storage, adapters, and algorithms
- Pydantic models for data structures (schemas)
"""

from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import Enum
from typing import Any, Protocol, TypeVar

from pydantic import BaseModel, ConfigDict, Field, PrivateAttr

T_Task = TypeVar("T_Task")

# ============================================================================
# Pydantic Models (Data Schemas)
# ============================================================================


class TaskStatus(str, Enum):
    """Task status enumeration."""

    PENDING = "pending"
    PROCESSING = "processing"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"
    EXPIRED = "expired"


class TaskMetadata(BaseModel):
    """Task metadata.

    Flexible metadata container for task-specific information.
    Can be extended with additional fields as needed.
    """

    processing_started_at: str | None = None  # ISO format datetime string
    processing_completed_at: str | None = None  # ISO format datetime string
    error_message: str | None = None
    error_type: str | None = None
    custom_data: dict[str, Any] = Field(default_factory=dict, description="Custom metadata fields")
    _processing_span: Any | None = PrivateAttr(default=None)  # OpenTelemetry span for processing tracking


class FairTask(BaseModel):
    """Task in the fair queue."""

    task_id: str
    group_id: str
    task_data: Any  # Serialized TaskInvocation data (bytes)
    enqueued_at: datetime = Field(default_factory=datetime.now)
    scheduled_at: datetime | None = None  # Optional scheduled execution time (delayed execution)
    expires_at: datetime | None = None  # Optional TTL
    priority: int = 0  # Higher = more priority
    status: TaskStatus = TaskStatus.PENDING
    retry_count: int = 0
    max_retries: int = 3
    metadata: TaskMetadata = Field(default_factory=TaskMetadata)
    idempotency_key: str | None = None  # For deduplication
    depends_on: list[str] = Field(default_factory=list)  # List of task IDs this task depends on


class RateLimit(BaseModel):
    """Rate limit configuration for a group."""

    max_tasks: int
    per_time: timedelta  # Flexible time window (e.g., timedelta(seconds=1), timedelta(minutes=5))
    current_count: int = 0
    window_start: datetime = Field(default_factory=datetime.now)


class GroupMetadata(BaseModel):
    """Group metadata.

    Flexible metadata container for group-specific information.
    """

    custom_data: dict[str, Any] = Field(default_factory=dict, description="Custom metadata fields")


class GroupQueueState(BaseModel):
    """State of a group's queue."""

    group_id: str
    queue_length: int
    last_processed_at: datetime | None = None
    virtual_finish_time: float = 0.0
    weight: float = 1.0
    rate_limit: RateLimit | None = None
    metadata: GroupMetadata = Field(default_factory=GroupMetadata)


class GroupStates(BaseModel):
    """Collection of group states.

    Maps group IDs to their queue states.
    """

    groups: dict[str, GroupQueueState] = Field(default_factory=dict, description="Group ID to GroupQueueState mapping")


class EnqueueOptions(BaseModel):
    """Configuration options for enqueuing tasks.

    This is the primary and recommended way to configure task enqueuing.
    Provides a cleaner, type-safe API compared to passing many individual parameters.

    Example:
        ```python
        # Simple usage
        options = EnqueueOptions(
            group_id="user_123",
            priority=10,
            max_retries=5,
        )
        task_id = await queue.enqueue(task, options=options)

        # Schedule task for delayed execution
        from datetime import timedelta
        scheduled_options = EnqueueOptions(
            group_id="user_123",
            scheduled_at=datetime.now() + timedelta(hours=1),
        )
        task_id = await queue.enqueue(task, options=scheduled_options)

        # With backpressure
        options = EnqueueOptions(
            group_id="user_123",
            backpressure=True,
            backpressure_max_wait=timedelta(seconds=60),
        )
        task_id = await queue.enqueue(task, options=options)
        ```

    """

    group_id: str | None = None
    priority: int = 0
    rate_limit: RateLimit | None = None
    scheduled_at: datetime | None = Field(default=None, description="Schedule task for delayed execution at this time")
    expires_at: datetime | None = None
    idempotency_key: str | None = None
    max_retries: int | None = None
    depends_on: list[str] = Field(
        default_factory=list,
        description="List of task IDs this task depends on (task will not execute until dependencies complete)",
    )
    backpressure: bool = Field(
        default=False,
        description="If True, wait and retry when queue is full instead of raising error immediately",
    )
    backpressure_max_wait: timedelta = Field(
        default=timedelta(seconds=30),
        description="Maximum time to wait when backpressure is enabled",
    )
    backpressure_retry_interval: timedelta = Field(
        default=timedelta(seconds=1.0),
        description="Interval between retry attempts when backpressure is enabled",
    )


class TaskInfo(BaseModel):
    """Comprehensive task information.

    This model provides all available information about a task in the queue.
    """

    task_id: str
    group_id: str
    status: str | None = None  # TaskStatus value as string
    enqueued_at: str | None = None  # ISO format datetime string
    scheduled_at: str | None = None  # ISO format datetime string
    expires_at: str | None = None  # ISO format datetime string
    priority: int
    retry_count: int
    max_retries: int
    is_processing: bool
    result_available: bool
    idempotency_key: str | None = None


class TaskResultUpdate(BaseModel):
    """Task result update for streaming.

    This model represents a status update when streaming task results.
    """

    status: TaskStatus
    result: Any | None = None
    error: str | None = None


class GroupQueueLengths(BaseModel):
    """Group queue lengths mapping."""

    lengths: dict[str, int] = Field(default_factory=dict, description="Group ID to queue length mapping")


class QueueStats(BaseModel):
    """Queue statistics.

    Provides summary statistics about the queue state.
    """

    total_groups: int
    total_queue_length: int
    groups: GroupQueueLengths = Field(default_factory=GroupQueueLengths)
    error: str | None = None


class SchedulerMetrics(BaseModel):
    """Scheduler-specific metrics."""

    active_tasks: int
    max_concurrent_tasks: int
    healthy: bool


class GroupMetrics(BaseModel):
    """Per-group metrics."""

    queue_length: int
    last_processed_at: str | None = None  # ISO format datetime string
    weight: float


class GroupMetricsMap(BaseModel):
    """Mapping of group IDs to their metrics."""

    metrics: dict[str, GroupMetrics] = Field(default_factory=dict, description="Group ID to GroupMetrics mapping")


class QueueMetrics(BaseModel):
    """Comprehensive queue metrics.

    Provides detailed metrics about queue performance and state.
    """

    total_groups: int
    total_queue_length: int
    total_processing: int
    total_completed_sample: int
    total_failed_sample: int
    is_paused: bool
    scheduler: SchedulerMetrics
    groups: GroupMetricsMap = Field(default_factory=GroupMetricsMap)
    error: str | None = None


class SchedulerDetails(BaseModel):
    """Scheduler health details."""

    stopped: bool
    storage_healthy: bool
    scheduler_task: str  # "running", "stopped", or "not_started"
    active_tasks_count: int = 0
    max_concurrent_tasks: int = 0


class StorageDetails(BaseModel):
    """Storage health details."""

    redis_connection: str = "ok"  # "ok" or "failed"
    read_write_test: str = "ok"  # "ok" or "failed"
    key_prefix: str | None = None


class HealthDetails(BaseModel):
    """Generic health details.

    Can contain component-specific information.
    """

    custom_data: dict[str, Any] = Field(default_factory=dict, description="Component-specific health data")


class ComponentHealthStatus(BaseModel):
    """Health status for a single component.

    Represents the health status of a queue component (storage, scheduler, etc.).
    """

    healthy: bool
    component: str
    details: HealthDetails = Field(default_factory=HealthDetails)
    error: str | None = None
    timestamp: datetime = Field(default_factory=datetime.now)


class ComponentHealthStatusMap(BaseModel):
    """Mapping of component names to their health status."""

    components: dict[str, ComponentHealthStatus] = Field(
        default_factory=dict, description="Component name to ComponentHealthStatus mapping"
    )


class OverallHealthStatusDetails(BaseModel):
    """Details for overall health status."""

    components: ComponentHealthStatusMap = Field(default_factory=ComponentHealthStatusMap)
    queue_stats: QueueStats | None = None


class OverallHealthStatus(BaseModel):
    """Overall health status for the queue system.

    Aggregates health status from all components.
    """

    healthy: bool
    component: str
    details: OverallHealthStatusDetails = Field(default_factory=OverallHealthStatusDetails)
    error: str | None = None
    timestamp: datetime = Field(default_factory=datetime.now)


class LogEntry(BaseModel):
    """Structured log entry."""

    event_type: str
    task_id: str | None = None
    group_id: str | None = None
    priority: int | None = None
    retry_count: int | None = None
    max_retries: int | None = None
    processing_time_seconds: float | None = None
    error_type: str | None = None
    error_message: str | None = None
    error_traceback: list[str] | None = None
    timestamp: str = Field(default_factory=lambda: datetime.now(tz=UTC).isoformat())
    custom_fields: dict[str, Any] = Field(default_factory=dict, description="Additional custom log fields")


# ============================================================================
# Task Invocation and Registration Models (New Design)
# ============================================================================


class SerializationStrategy(str, Enum):
    """Serialization strategy for task arguments."""

    JSON = "json"  # JSON-compatible (safe, but limited types)
    PICKLE = "pickle"  # Pickle (supports all types, but security concerns)
    MSGPACK = "msgpack"  # MessagePack (fast, binary, limited types)
    CUSTOM = "custom"  # Adapter-specific serialization


@dataclass(frozen=True)
class TaskInvocation:
    """Immutable task invocation data.

    This represents a single invocation of a task with specific arguments.
    It's separate from task registration, allowing the same task to be
    invoked multiple times with different arguments.

    Attributes:
        task_name: Unique identifier for the task (e.g., function name)
        args: Positional arguments for the task
        kwargs: Keyword arguments for the task
        task_id: Unique task ID (generated by adapter or queue)
        serialization_strategy: Strategy used to serialize args/kwargs
        invocation_metadata: Framework-specific metadata (serialized)

    """

    task_name: str
    args: tuple[Any, ...]
    kwargs: dict[str, Any]
    task_id: str
    serialization_strategy: SerializationStrategy = SerializationStrategy.JSON
    invocation_metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Ensure kwargs is a dict (frozen dataclass requirement)."""
        # Ensure kwargs is a dict (frozen dataclass requirement)
        object.__setattr__(self, "kwargs", dict(self.kwargs) if self.kwargs else {})


class TaskRegistration(BaseModel):
    """Task registration metadata.

    This represents a registered task (the function itself, not an invocation).
    It's created once when a task is registered and reused for all invocations.

    Attributes:
        task_name: Unique identifier for the task
        registration_metadata: Framework-specific registration metadata
        default_serialization_strategy: Default serialization strategy for this task

    """

    task_name: str
    registration_metadata: dict[str, Any] = Field(default_factory=dict)
    default_serialization_strategy: SerializationStrategy = SerializationStrategy.JSON

    model_config = ConfigDict(frozen=True)


# ============================================================================
# Protocol Definitions
# ============================================================================


class InvocationSerializer(Protocol):
    """Protocol for serializing task invocations.

    Allows adapters to use different serialization strategies
    for args/kwargs based on the invocation's strategy.
    """

    def serialize_args(self, args: tuple[Any, ...], strategy: SerializationStrategy) -> bytes:
        """Serialize args using specified strategy.

        Args:
            args: Arguments to serialize
            strategy: Serialization strategy to use

        Returns:
            Serialized bytes

        """
        ...

    def deserialize_args(self, data: bytes, strategy: SerializationStrategy) -> tuple[Any, ...]:
        """Deserialize args using specified strategy.

        Args:
            data: Serialized bytes
            strategy: Serialization strategy used

        Returns:
            Deserialized arguments

        """
        ...

    def serialize_kwargs(self, kwargs: dict[str, Any], strategy: SerializationStrategy) -> bytes:
        """Serialize kwargs using specified strategy.

        Args:
            kwargs: Keyword arguments to serialize
            strategy: Serialization strategy to use

        Returns:
            Serialized bytes

        """
        ...

    def deserialize_kwargs(self, data: bytes, strategy: SerializationStrategy) -> dict[str, Any]:
        """Deserialize kwargs using specified strategy.

        Args:
            data: Serialized bytes
            strategy: Serialization strategy used

        Returns:
            Deserialized keyword arguments

        """
        ...


# Type alias for group key extractor - a callable that extracts group ID from task
type GroupKeyExtractor[T_Task] = Callable[[T_Task], str]


class AbstractTaskAdapter(Protocol):
    """Abstract task adapter protocol for task execution frameworks.

    This protocol allows the fair queue to work with any task execution
    framework (TaskIQ, Celery, RQ, etc.) in an implementation-agnostic way.

    The adapter is responsible for:
    1. **Task Setup**: Registering tasks and creating TaskDescriptors
    2. **Task Dispatch**: Executing tasks from TaskInvocations
    3. **Result Retrieval**: Getting task results from framework backends

    Clear separation:
    - register_task(): Register a function as a task (once)
    - create_invocation(): Create an invocation descriptor (per call)
    - dispatch(): Execute an invocation

    Example:
        ```python
        class TaskIQAdapter:
            async def register_task(
                self,
                func: Callable,
                task_name: str | None = None,
            ) -> TaskRegistration:
                # Register with TaskIQ broker
                decorated_task = self._broker.task(func, task_name=task_name)
                return TaskRegistration(
                    task_name=decorated_task.task_name,
                    registration_metadata={"broker_id": id(self._broker)},
                )

            async def dispatch(self, invocation: TaskInvocation) -> None:
                # Create message and dispatch
                message = self._create_message(invocation)
                await self._broker.kick(message)
        ```

    """

    async def register_task(
        self,
        func: Callable[..., Any],
        task_name: str | None = None,
        default_serialization_strategy: SerializationStrategy = SerializationStrategy.JSON,
        **registration_kwargs: Any,
    ) -> TaskRegistration:
        """Set up a task for execution (register with framework).

        This method is called when a task is first registered. It should:
        - Register the task with the execution framework (if needed)
        - Return a TaskRegistration that can be used later to create invocations

        Args:
            func: Task function to register
            task_name: Optional custom task name
            default_serialization_strategy: Default serialization for this task
            **registration_kwargs: Additional framework-specific setup options

        Returns:
            TaskRegistration containing task metadata

        Note:
            This method should be idempotent - calling it multiple times
            with the same function should return equivalent registrations.

        """
        ...

    def create_invocation(
        self,
        registration: TaskRegistration,
        *args: Any,
        task_id: str | None = None,
        serialization_strategy: SerializationStrategy | None = None,
        **kwargs: Any,
    ) -> TaskInvocation:
        """Create a task invocation descriptor.

        This creates an immutable descriptor for a single task invocation.
        It does NOT execute the task - that happens in dispatch().

        Args:
            registration: Task registration (from register_task)
            *args: Task function arguments
            task_id: Optional task ID (generated if not provided)
            serialization_strategy: Override default serialization strategy
            **kwargs: Task function keyword arguments

        Returns:
            Immutable TaskInvocation

        """
        ...

    async def dispatch(self, invocation: TaskInvocation) -> None:
        """Dispatch a task invocation for execution.

        Args:
            invocation: Task invocation descriptor

        Note:
            This should not block - fire-and-forget pattern.

        """
        ...

    async def get_result(self, task_id: str) -> Any | None:
        """Retrieve task result.

        Args:
            task_id: Task ID

        Returns:
            Result or None if not available

        """
        ...

    def serialize_invocation(self, invocation: TaskInvocation) -> bytes:
        """Serialize invocation for storage.

        Args:
            invocation: Task invocation

        Returns:
            Serialized bytes

        """
        ...

    def deserialize_invocation(self, data: bytes) -> TaskInvocation:
        """Deserialize invocation from storage.

        Args:
            data: Serialized bytes

        Returns:
            Deserialized TaskInvocation

        """
        ...


class AbstractFairQueueStorage(Protocol):
    """Abstract storage for fair task queue."""

    async def enqueue_task(self, task: FairTask) -> None:
        """Enqueue a task to storage."""

    async def enqueue_tasks_batch(self, tasks: list[FairTask]) -> list[tuple[str, bool, str | None]]:
        """Enqueue multiple tasks atomically.

        Args:
            tasks: List of tasks to enqueue

        Returns:
            List of (task_id, success, error) tuples where:
            - task_id: Task ID
            - success: True if enqueued successfully, False otherwise
            - error: Error message if failed, None otherwise

        """
        ...

    async def dequeue_task(self, group_id: str) -> FairTask | None:
        """Dequeue next task from a group's queue."""

    async def get_next_group(self, algorithm: FairnessAlgorithm) -> str | None:
        """Get next group to process based on fairness algorithm."""

    async def get_group_queue_length(self, group_id: str) -> int:
        """Get queue length for a group."""
        ...

    async def get_group_state(self, group_id: str) -> GroupQueueState | None:
        """Get current state of a group's queue."""
        ...

    async def get_all_groups(self) -> list[str]:
        """Get all active group IDs.

        Returns:
            List of all active group IDs

        """
        ...

    async def update_group_state(self, state: GroupQueueState) -> None:
        """Update group queue state."""
        ...

    async def get_task_by_idempotency_key(self, idempotency_key: str) -> str | None:
        """Get task ID by idempotency key.

        Args:
            idempotency_key: Idempotency key

        Returns:
            Task ID if found, None otherwise

        """
        ...

    async def get_group_id_by_task_id(self, task_id: str) -> str | None:
        """Get group ID by task ID.

        Args:
            task_id: Task ID

        Returns:
            Group ID if found, None otherwise

        """
        ...

    async def get_task_status(self, task_id: str, group_id: str | None = None) -> TaskStatus | None:
        """Get task status by task ID and optional group ID.

        If group_id is not provided, it will be derived from task_id.

        Args:
            task_id: Task ID
            group_id: Optional group ID (will be derived if not provided)

        Returns:
            Task status or None if task not found

        """
        ...

    async def update_task_status(
        self, task_id: str, group_id: str, status: TaskStatus, metadata: TaskMetadata | None = None
    ) -> None:
        """Update task status.

        Args:
            task_id: Task ID
            group_id: Group ID
            status: New task status
            metadata: Optional metadata to update in task data

        """
        ...

    async def remove_task(self, task_id: str, group_id: str) -> None:
        """Remove a task from the queue."""

    async def mark_task_completed(self, task_id: str, group_id: str) -> None:
        """Mark task as completed and clean up task data.

        This should be called after successful task processing to clean up task data.

        Args:
            task_id: Task ID
            group_id: Group ID

        """
        ...

    async def mark_task_failed(self, task_id: str, group_id: str) -> None:
        """Mark task as failed and preserve task data for DLQ.

        Task data should be preserved so it can be moved to dead letter queue.

        Args:
            task_id: Task ID
            group_id: Group ID

        """
        ...

    async def add_to_dead_letter_queue(self, task: FairTask) -> None:
        """Add a task to the dead letter queue.

        This should be called when a task has exhausted all retries.
        The task should already be marked as failed.

        Args:
            task: Failed task to add to DLQ

        """
        ...

    async def store_task_result(self, task_id: str, group_id: str, result: Any, ttl_seconds: int | None = None) -> None:
        """Store task result.

        Args:
            task_id: Task ID
            group_id: Group ID
            result: Task result to store
            ttl_seconds: Optional TTL for result in seconds

        """
        ...

    async def get_task(self, task_id: str, group_id: str) -> FairTask | None:
        """Get task by ID and group ID.

        Args:
            task_id: Task ID
            group_id: Group ID

        Returns:
            FairTask if found, None otherwise

        """
        ...

    async def get_task_result(self, task_id: str, group_id: str | None = None) -> Any | None:
        """Get task result.

        If group_id is not provided, it will be derived from task_id.

        Args:
            task_id: Task ID
            group_id: Optional group ID (will be derived if not provided)

        Returns:
            Task result or None if not found

        """
        ...

    async def purge_expired_tasks(self, before: datetime) -> int:
        """Remove all expired tasks before the given timestamp.

        Args:
            before: Remove tasks expired before this timestamp

        Returns:
            Number of tasks removed

        """
        ...

    async def get_dead_letter_queue(
        self,
        group_id: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[FairTask]:
        """Get tasks from dead letter queue with pagination.

        Args:
            group_id: Optional group ID filter
            limit: Maximum number of tasks to return
            offset: Number of tasks to skip

        Returns:
            List of failed tasks

        """
        ...

    async def get_tasks_by_status(
        self,
        status: TaskStatus,
        group_id: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[tuple[str, str]]:
        """Get task IDs by status with optional group filter.

        This method is useful for recovery operations, monitoring, and debugging.

        Args:
            status: Task status to filter by
            group_id: Optional group ID filter
            limit: Maximum number of tasks to return
            offset: Number of tasks to skip

        Returns:
            List of (task_id, group_id) tuples

        """
        ...

    async def health_check(self) -> bool:
        """Check if storage is healthy.

        This should verify that the storage backend is accessible and functional.
        It may perform basic read/write operations to ensure the storage is working.

        Returns:
            True if storage is healthy and functional, False otherwise

        """
        ...

    async def get_health_status(self) -> ComponentHealthStatus:
        """Get detailed health status.

        Returns:
            ComponentHealthStatus model with health status details

        """
        ...

    async def check_and_increment_rate_limit(
        self,
        group_id: str,
        max_tasks: int,
        per_time: timedelta,
    ) -> tuple[bool, datetime, int]:
        """Atomically check and increment rate limit.

        This method is REQUIRED for all storage implementations.
        It must provide atomic rate limiting to prevent race conditions.

        Args:
            group_id: Group ID
            max_tasks: Maximum tasks allowed
            per_time: Time window

        Returns:
            Tuple of (allowed, window_start, current_count)

        Raises:
            FairQueueStorageError: If storage operation fails

        """
        ...

    async def check_and_increment_global_queue_size(
        self,
        max_size: int,
    ) -> tuple[bool, int]:
        """Atomically check and increment global queue size.

        This method is REQUIRED for all storage implementations when
        max_global_queue_size is configured. It must provide atomic
        global queue size tracking to prevent race conditions.

        Args:
            max_size: Maximum global queue size

        Returns:
            Tuple of (allowed, current_size) where:
            - allowed: True if enqueue is allowed, False if limit exceeded
            - current_size: Current global queue size after increment

        Raises:
            FairQueueStorageError: If storage operation fails

        """
        ...

    async def check_and_increment_global_queue_size_batch(
        self,
        max_size: int,
        batch_size: int,
    ) -> tuple[bool, int]:
        """Atomically check and increment global queue size for a batch of tasks.

        This method is REQUIRED for all storage implementations when
        max_global_queue_size is configured and batch operations are used.
        It must provide atomic global queue size tracking to prevent race conditions.

        Args:
            max_size: Maximum global queue size
            batch_size: Number of tasks to add in the batch

        Returns:
            Tuple of (allowed, current_size) where:
            - allowed: True if batch enqueue is allowed, False if limit exceeded
            - current_size: Current global queue size after batch increment

        Raises:
            FairQueueStorageError: If storage operation fails

        """
        ...

    async def get_global_queue_size(self) -> int:
        """Get current global queue size without incrementing.

        Returns:
            Current global queue size

        Raises:
            FairQueueStorageError: If storage operation fails

        """
        ...

    async def decrement_global_queue_size(self) -> None:
        """Atomically decrement global queue size.

        This should be called when a task is dequeued or removed.

        Raises:
            FairQueueStorageError: If storage operation fails

        """
        ...


class FairnessAlgorithm(Protocol):
    """Protocol for fairness algorithms."""

    def select_next_group(self, group_states: GroupStates) -> str | None:
        """Select next group to process.

        Args:
            group_states: GroupStates model containing group states

        Returns:
            Group ID to process next, or None if no groups available

        """
        ...

    def update_after_processing(
        self,
        group_state: GroupQueueState,
        processing_time: timedelta,
    ) -> None:
        """Update group state after processing a task.

        Args:
            group_state: The group state to update
            processing_time: Time taken to process the task

        """
        ...


class RetryStrategy(Protocol):
    """Protocol for retry strategies."""

    def should_retry(
        self,
        task: FairTask,
        error: Exception,
        retry_count: int,
        max_retries: int,
    ) -> bool:
        """Determine if a task should be retried.

        Args:
            task: The task that failed
            error: The exception that occurred
            retry_count: Current retry count
            max_retries: Maximum retry attempts

        Returns:
            True if task should be retried, False otherwise

        """
        ...

    def get_backoff_delay(
        self,
        task: FairTask,
        error: Exception,
        retry_count: int,
    ) -> float:
        """Calculate backoff delay before retry.

        Args:
            task: The task that failed
            error: The exception that occurred
            retry_count: Current retry count

        Returns:
            Backoff delay in seconds

        """
        ...
