"""
AI Consultation Orchestrator.

Central orchestrator for AI consultation workflows. Coordinates between
prompt templates, the provider registry, and the result cache to execute
consultation requests. Handles provider selection, timeout management,
retry/fallback logic, and error handling.
"""

from __future__ import annotations

import asyncio
import concurrent.futures
import hashlib
import json
import logging
import time
from dataclasses import replace
from typing import Any, Dict, List, Optional, Sequence

from foundry_mcp.core.errors.provider import ProviderUnavailableError
from foundry_mcp.core.providers import (
    ProviderHooks,
    ProviderRequest,
    ProviderResult,
    ProviderStatus,
    available_providers,
    check_provider_available,
    resolve_provider,
)
from foundry_mcp.core.llm_config.provider_spec import ProviderSpec

from foundry_mcp.core.ai_consultation.types import (
    ConsensusResult,
    ConsultationOutcome,
    ConsultationRequest,
    ConsultationResult,
    ConsultationWorkflow,
    ProviderResponse,
    ResolvedProvider,
)
from foundry_mcp.core.ai_consultation.cache import ResultCache

logger = logging.getLogger(__name__)


def _collect_provider_error(
    provider_id: str,
    error: Exception,
    request_context: dict,
) -> None:
    """
    Collect AI provider error data for later introspection.

    Uses lazy import to avoid circular dependencies and only
    collects if error collection is enabled.

    Args:
        provider_id: The provider that raised the error
        error: The exception that was raised
        request_context: Context about the request (workflow, prompt_id, etc.)
    """
    try:
        # Lazy import to avoid circular dependencies
        from foundry_mcp.config.server import get_config

        config = get_config()
        if not config.error_collection.enabled:
            return

        from foundry_mcp.core.error_collection import get_error_collector

        collector = get_error_collector()
        collector.collect_provider_error(
            provider_id=provider_id,
            error=error,
            request_context=request_context,
        )
    except Exception as collect_error:
        # Never let error collection failures affect consultation execution
        logger.debug(
            f"Error collection failed for provider {provider_id}: {collect_error}"
        )


class ConsultationOrchestrator:
    """
    Central orchestrator for AI consultation workflows.

    Coordinates between prompt templates, the provider registry, and
    the result cache to execute consultation requests. Handles provider
    selection, timeout management, and error handling.

    Attributes:
        cache: ResultCache instance for caching results
        preferred_providers: Ordered list of preferred provider IDs
        default_timeout: Default timeout in seconds

    Example:
        orchestrator = ConsultationOrchestrator()

        if orchestrator.is_available():
            request = ConsultationRequest(
                workflow=ConsultationWorkflow.PLAN_REVIEW,
                prompt_id="spec_review",
                context={"spec_content": "..."},
            )
            result = orchestrator.consult(request)
    """

    def __init__(
        self,
        cache: Optional[ResultCache] = None,
        default_timeout: Optional[float] = None,
        config: Optional["ConsultationConfig"] = None,
    ):
        """
        Initialize the consultation orchestrator.

        Args:
            cache: ResultCache instance (creates default if None)
            default_timeout: Default timeout in seconds (uses config if None)
            config: ConsultationConfig instance (uses global config if None)
        """
        # Lazy import to avoid circular dependency
        from foundry_mcp.core.llm_config.consultation import (
            ConsultationConfig,
            get_consultation_config,
        )

        self._config: ConsultationConfig = config or get_consultation_config()
        self.cache = cache or ResultCache(default_ttl=self._config.cache_ttl)
        self.default_timeout = (
            default_timeout
            if default_timeout is not None
            else self._config.default_timeout
        )

        # Parse priority list from config into ProviderSpec objects
        # Priority: 1) config.priority specs
        self._priority_specs: List[ProviderSpec] = []
        if self._config.priority:
            for spec_str in self._config.priority:
                try:
                    self._priority_specs.append(ProviderSpec.parse(spec_str))
                except ValueError as e:
                    logger.warning(
                        f"Invalid provider spec in priority list: {spec_str}: {e}"
                    )

    def is_available(self, provider_id: Optional[str] = None) -> bool:
        """
        Check if consultation services are available.

        Args:
            provider_id: Check specific provider, or any available if None

        Returns:
            True if at least one provider is available
        """
        if provider_id:
            return check_provider_available(provider_id)

        # Check priority providers first
        for spec in self._priority_specs:
            if check_provider_available(spec.provider):
                return True

        # Fall back to any available provider
        return len(available_providers()) > 0

    def get_available_providers(self) -> List[str]:
        """
        Return list of available provider IDs.

        Returns:
            List of available provider IDs
        """
        return sorted(available_providers())

    def _select_provider(self, request: ConsultationRequest) -> str:
        """
        Select the provider to use for a request.

        Args:
            request: The consultation request

        Returns:
            Provider ID to use

        Raises:
            ProviderUnavailableError: If no providers are available
        """
        # Explicit provider requested
        if request.provider_id:
            if check_provider_available(request.provider_id):
                return request.provider_id
            raise ProviderUnavailableError(
                f"Requested provider '{request.provider_id}' is not available",
                provider=request.provider_id,
            )

        # Try priority providers
        for spec in self._priority_specs:
            if check_provider_available(spec.provider):
                return spec.provider

        # Fall back to first available
        providers = available_providers()
        if providers:
            return providers[0]

        raise ProviderUnavailableError(
            "No AI providers are currently available",
            provider=None,
        )

    def _generate_cache_key(self, request: ConsultationRequest) -> str:
        """
        Generate a cache key for a consultation request.

        Args:
            request: The consultation request

        Returns:
            Cache key string
        """
        if request.cache_key:
            return request.cache_key

        # Build a deterministic key from request parameters
        key_parts = [
            request.prompt_id,
            json.dumps(request.context, sort_keys=True),
            request.model or "default",
        ]
        key_string = "|".join(key_parts)
        return hashlib.sha256(key_string.encode()).hexdigest()[:32]

    def _build_prompt(self, request: ConsultationRequest) -> str:
        """
        Build the full prompt from template and context.

        This method delegates to workflow-specific prompt builders.

        Args:
            request: The consultation request

        Returns:
            The rendered prompt string
        """
        # Import prompt builders lazily to avoid circular imports
        from foundry_mcp.core.prompts import get_prompt_builder

        builder = get_prompt_builder(request.workflow)
        return builder.build(request.prompt_id, request.context)

    def _resolve_spec_to_provider(
        self, spec: ProviderSpec
    ) -> Optional[ResolvedProvider]:
        """
        Resolve a ProviderSpec to a ResolvedProvider if available.

        For CLI providers, checks registry availability.
        For API providers, logs a warning (not yet implemented).

        Args:
            spec: The provider specification to resolve

        Returns:
            ResolvedProvider if available, None otherwise
        """
        if spec.type == "api":
            # API providers not yet integrated into registry
            # TODO: Register API providers (openai, anthropic, local) in registry
            logger.debug(
                f"API provider spec '{spec}' skipped - API providers not yet "
                "integrated into consultation registry"
            )
            return None

        # CLI provider - check registry availability
        if not check_provider_available(spec.provider):
            return None

        # Build model string - include backend routing if specified
        model = None
        if spec.backend and spec.model:
            # Backend routing: "openai/gpt-5.1-codex"
            model = f"{spec.backend}/{spec.model}"
        elif spec.model:
            model = spec.model

        # Get overrides from config
        overrides = self._config.get_override(str(spec))

        return ResolvedProvider(
            provider_id=spec.provider,
            model=model,
            overrides=overrides,
            spec_str=str(spec),
        )

    def _get_providers_to_try(
        self, request: ConsultationRequest
    ) -> List[ResolvedProvider]:
        """
        Get ordered list of providers to try for a request.

        Provider selection priority:
        1. Explicit provider_id in request (wraps to ResolvedProvider)
        2. Priority specs from config (parsed ProviderSpec list)
        3. Legacy preferred_providers (for backwards compatibility)
        4. Available providers from registry (fallback)

        Args:
            request: The consultation request

        Returns:
            Ordered list of ResolvedProvider instances to try
        """
        result: List[ResolvedProvider] = []
        seen_providers: set = set()

        # 1. Explicit provider requested - only try that one
        if request.provider_id:
            return [
                ResolvedProvider(
                    provider_id=request.provider_id,
                    model=request.model,
                    spec_str=f"explicit:{request.provider_id}",
                )
            ]

        # 2. Priority specs from config
        for spec in self._priority_specs:
            resolved = self._resolve_spec_to_provider(spec)
            if resolved and resolved.provider_id not in seen_providers:
                result.append(resolved)
                seen_providers.add(resolved.provider_id)

        # 3. Fallback to available providers from registry
        for prov_id in available_providers():
            if prov_id not in seen_providers:
                result.append(
                    ResolvedProvider(
                        provider_id=prov_id,
                        spec_str=f"fallback:{prov_id}",
                    )
                )
                seen_providers.add(prov_id)

        return result

    def _is_retryable_error(self, error: Exception) -> bool:
        """
        Determine if an error warrants a retry.

        Retryable errors include timeouts and rate limits.
        Non-retryable errors include authentication failures and invalid prompts.

        Args:
            error: The exception that occurred

        Returns:
            True if the error is transient and retry may succeed
        """
        error_str = str(error).lower()

        # Timeout errors are retryable
        if "timeout" in error_str or "timed out" in error_str:
            return True

        # Rate limit errors are retryable
        if "rate limit" in error_str or "rate_limit" in error_str or "429" in error_str:
            return True

        # Connection errors may be transient
        if "connection" in error_str and (
            "reset" in error_str or "refused" in error_str
        ):
            return True

        # Server errors (5xx) are potentially retryable
        if any(code in error_str for code in ["500", "502", "503", "504"]):
            return True

        return False

    def _should_try_next_provider(self, error: Exception) -> bool:
        """
        Determine if we should try the next provider after an error.

        Args:
            error: The exception that occurred

        Returns:
            True if fallback to next provider is appropriate
        """
        # Don't fallback if disabled
        if not self._config.fallback_enabled:
            return False

        error_str = str(error).lower()

        # Don't fallback for prompt-level errors (these will fail with any provider)
        if "prompt" in error_str and (
            "too long" in error_str or "invalid" in error_str
        ):
            return False

        # Don't fallback for authentication errors specific to all providers
        if "api key" in error_str or "authentication" in error_str:
            # This might be provider-specific, so allow fallback
            return True

        # Fallback for most other errors
        return True

    def _try_provider_with_retries(
        self,
        request: ConsultationRequest,
        prompt: str,
        resolved: ResolvedProvider,
        warnings: List[str],
    ) -> Optional[ProviderResult]:
        """
        Try a single provider with retry logic.

        Args:
            request: The consultation request
            prompt: The rendered prompt
            resolved: Resolved provider information (includes model and overrides)
            warnings: List to append warnings to

        Returns:
            ProviderResult on success, None on failure
        """
        hooks = ProviderHooks()
        last_error: Optional[Exception] = None
        provider_id = resolved.provider_id

        max_attempts = self._config.max_retries + 1  # +1 for initial attempt

        # Determine model: request.model > resolved.model > None
        effective_model = request.model or resolved.model

        # Apply overrides from config
        effective_timeout = (
            resolved.overrides.get("timeout", request.timeout) or self.default_timeout
        )
        effective_temperature = resolved.overrides.get(
            "temperature", request.temperature
        )
        effective_max_tokens = resolved.overrides.get("max_tokens", request.max_tokens)

        for attempt in range(max_attempts):
            try:
                provider = resolve_provider(
                    provider_id, hooks=hooks, model=effective_model
                )
                provider_request = ProviderRequest(
                    prompt=prompt,
                    system_prompt=request.system_prompt_override,
                    model=effective_model,
                    timeout=effective_timeout,
                    temperature=effective_temperature,
                    max_tokens=effective_max_tokens,
                    metadata={
                        "workflow": request.workflow.value,
                        "prompt_id": request.prompt_id,
                    },
                )
                result = provider.generate(provider_request)

                # Success
                if result.status == ProviderStatus.SUCCESS:
                    if attempt > 0:
                        warnings.append(
                            f"Provider {provider_id} succeeded on attempt {attempt + 1}"
                        )
                    return result

                # Non-success status from provider
                error_msg = (
                    f"Provider {provider_id} returned status: {result.status.value}"
                )
                if result.stderr:
                    error_msg += f" - {result.stderr}"
                last_error = Exception(error_msg)

                # Check if this error type is retryable
                if not self._is_retryable_error(last_error):
                    break

            except ProviderUnavailableError as exc:
                last_error = exc
                # Provider unavailable - don't retry, move to fallback
                break

            except Exception as exc:  # noqa: BLE001
                last_error = exc
                if not self._is_retryable_error(exc):
                    break

            # Retry delay
            if attempt < max_attempts - 1:
                warnings.append(
                    f"Provider {provider_id} attempt {attempt + 1} failed: {last_error}, "
                    f"retrying in {self._config.retry_delay}s..."
                )
                time.sleep(self._config.retry_delay)

        # All retries exhausted - collect error for introspection
        if last_error:
            warnings.append(
                f"Provider {provider_id} failed after {max_attempts} attempt(s): {last_error}"
            )
            # Collect provider error for future introspection
            _collect_provider_error(
                provider_id=provider_id,
                error=last_error,
                request_context={
                    "workflow": request.workflow.value,
                    "prompt_id": request.prompt_id,
                    "model": effective_model,
                    "attempts": max_attempts,
                },
            )
        return None

    async def _try_provider_with_retries_async(
        self,
        request: ConsultationRequest,
        prompt: str,
        resolved: ResolvedProvider,
        warnings: List[str],
    ) -> Optional[ProviderResult]:
        """
        Async version of provider execution with retry logic.

        Uses asyncio.sleep() for non-blocking retry delays and runs the
        synchronous provider.generate() in a thread pool executor to avoid
        blocking the event loop.

        Args:
            request: The consultation request
            prompt: The rendered prompt
            resolved: Resolved provider information (includes model and overrides)
            warnings: List to append warnings to

        Returns:
            ProviderResult on success, None on failure
        """
        hooks = ProviderHooks()
        last_error: Optional[Exception] = None
        provider_id = resolved.provider_id

        max_attempts = self._config.max_retries + 1  # +1 for initial attempt

        # Determine model: request.model > resolved.model > None
        effective_model = request.model or resolved.model

        # Apply overrides from config
        effective_timeout = (
            resolved.overrides.get("timeout", request.timeout) or self.default_timeout
        )
        effective_temperature = resolved.overrides.get(
            "temperature", request.temperature
        )
        effective_max_tokens = resolved.overrides.get("max_tokens", request.max_tokens)

        for attempt in range(max_attempts):
            try:
                provider = resolve_provider(
                    provider_id, hooks=hooks, model=effective_model
                )
                provider_request = ProviderRequest(
                    prompt=prompt,
                    system_prompt=request.system_prompt_override,
                    model=effective_model,
                    timeout=effective_timeout,
                    temperature=effective_temperature,
                    max_tokens=effective_max_tokens,
                    metadata={
                        "workflow": request.workflow.value,
                        "prompt_id": request.prompt_id,
                    },
                )

                # Run sync provider.generate() in executor to avoid blocking
                loop = asyncio.get_running_loop()
                result = await loop.run_in_executor(
                    None, provider.generate, provider_request
                )

                # Success
                if result.status == ProviderStatus.SUCCESS:
                    if attempt > 0:
                        warnings.append(
                            f"Provider {provider_id} succeeded on attempt {attempt + 1}"
                        )
                    return result

                # Non-success status from provider
                error_msg = (
                    f"Provider {provider_id} returned status: {result.status.value}"
                )
                if result.stderr:
                    error_msg += f" - {result.stderr}"
                last_error = Exception(error_msg)

                # Check if this error type is retryable
                if not self._is_retryable_error(last_error):
                    break

            except ProviderUnavailableError as exc:
                last_error = exc
                # Provider unavailable - don't retry, move to fallback
                break

            except Exception as exc:  # noqa: BLE001
                last_error = exc
                if not self._is_retryable_error(exc):
                    break

            # Async retry delay (non-blocking)
            if attempt < max_attempts - 1:
                warnings.append(
                    f"Provider {provider_id} attempt {attempt + 1} failed: {last_error}, "
                    f"retrying in {self._config.retry_delay}s..."
                )
                await asyncio.sleep(self._config.retry_delay)

        # All retries exhausted - collect error for introspection
        if last_error:
            warnings.append(
                f"Provider {provider_id} failed after {max_attempts} attempt(s): {last_error}"
            )
            # Collect provider error for future introspection
            _collect_provider_error(
                provider_id=provider_id,
                error=last_error,
                request_context={
                    "workflow": request.workflow.value,
                    "prompt_id": request.prompt_id,
                    "model": effective_model,
                    "attempts": max_attempts,
                },
            )
        return None

    async def _execute_single_provider_async(
        self,
        request: ConsultationRequest,
        prompt: str,
        resolved: ResolvedProvider,
    ) -> ProviderResponse:
        """
        Execute a single provider asynchronously and return a ProviderResponse.

        Wraps _try_provider_with_retries_async and converts the result to
        a ProviderResponse for use in multi-model consensus workflows.

        Args:
            request: The consultation request
            prompt: The rendered prompt
            resolved: Resolved provider information

        Returns:
            ProviderResponse with success/failure status and content
        """
        warnings: List[str] = []
        start_time = time.time()

        result = await self._try_provider_with_retries_async(
            request, prompt, resolved, warnings
        )

        duration_ms = int((time.time() - start_time) * 1000)

        if result is None:
            # Provider failed after all retries
            error_msg = (
                warnings[-1] if warnings else f"Provider {resolved.provider_id} failed"
            )
            return ProviderResponse(
                provider_id=resolved.provider_id,
                model_used=resolved.model or "unknown",
                content="",
                success=False,
                error=error_msg,
                duration_ms=duration_ms,
                cache_hit=False,
            )

        # Success - convert ProviderResult to ProviderResponse
        total_tokens = None
        if result.tokens:
            total_tokens = result.tokens.total_tokens

        return ProviderResponse(
            provider_id=result.provider_id,
            model_used=result.model_used,
            content=result.content,
            success=True,
            error=None,
            tokens=total_tokens,
            duration_ms=duration_ms,
            cache_hit=False,
        )

    async def _execute_parallel_providers_async(
        self,
        request: ConsultationRequest,
        prompt: str,
        providers: List[ResolvedProvider],
        min_models: int = 1,
    ) -> ConsensusResult:
        """
        Execute multiple providers in parallel and return a ConsensusResult.

        Uses asyncio.gather to run all provider executions concurrently,
        then aggregates the results into a ConsensusResult with agreement
        metadata.

        Args:
            request: The consultation request
            prompt: The rendered prompt
            providers: List of resolved providers to execute
            min_models: Minimum successful models required (for warnings)

        Returns:
            ConsensusResult with all provider responses and agreement metadata
        """
        start_time = time.time()
        warnings: List[str] = []

        if not providers:
            return ConsensusResult(
                workflow=request.workflow,
                responses=[],
                duration_ms=0.0,
                warnings=["No providers available for parallel execution"],
            )

        # Create tasks for all providers
        tasks = [
            self._execute_single_provider_async(request, prompt, resolved)
            for resolved in providers
        ]

        # Execute all providers in parallel
        responses: List[ProviderResponse] = await asyncio.gather(*tasks)

        duration_ms = (time.time() - start_time) * 1000

        # Check if we met the minimum model requirement
        successful_count = sum(1 for r in responses if r.success)
        if successful_count < min_models:
            warnings.append(
                f"Only {successful_count} of {min_models} required models succeeded"
            )

        # Log failed providers
        for response in responses:
            if not response.success:
                warnings.append(
                    f"Provider {response.provider_id} failed: {response.error}"
                )

        return ConsensusResult(
            workflow=request.workflow,
            responses=responses,
            duration_ms=duration_ms,
            warnings=warnings,
        )

    async def _execute_parallel_providers_with_fallback_async(
        self,
        request: ConsultationRequest,
        prompt: str,
        all_providers: List[ResolvedProvider],
        min_models: int = 1,
    ) -> ConsensusResult:
        """
        Execute providers in parallel with sequential fallback on failures.

        Uses a two-phase approach:
        1. Execute first min_models providers in parallel
        2. If any fail and fallback_enabled, try remaining providers sequentially
           until min_models succeed or providers exhausted

        Args:
            request: The consultation request
            prompt: The rendered prompt
            all_providers: Complete priority list of providers to try
            min_models: Minimum successful models required

        Returns:
            ConsensusResult with all attempted provider responses
        """
        start_time = time.time()
        warnings: List[str] = []
        all_responses: List[ProviderResponse] = []

        if not all_providers:
            return ConsensusResult(
                workflow=request.workflow,
                responses=[],
                duration_ms=0.0,
                warnings=["No providers available for parallel execution"],
            )

        # Phase 1: Initial parallel execution of first min_models providers
        initial_providers = all_providers[:min_models]
        logger.debug(
            f"Phase 1: Executing {len(initial_providers)} providers in parallel"
        )

        tasks = [
            self._execute_single_provider_async(request, prompt, resolved)
            for resolved in initial_providers
        ]
        initial_responses: List[ProviderResponse] = await asyncio.gather(*tasks)
        all_responses.extend(initial_responses)

        # Count successes and log failures
        # A response is only truly successful if it has non-empty content
        successful_count = sum(
            1 for r in initial_responses if r.success and r.content.strip()
        )
        for response in initial_responses:
            if not response.success:
                warnings.append(
                    f"Provider {response.provider_id} failed: {response.error}"
                )
            elif not response.content.strip():
                warnings.append(
                    f"Provider {response.provider_id} returned empty content"
                )

        # Phase 2: Sequential fallback if needed and enabled
        if successful_count < min_models and self._config.fallback_enabled:
            needed = min_models - successful_count
            remaining_providers = all_providers[min_models:]

            if remaining_providers:
                warnings.append(
                    f"Initial parallel execution yielded {successful_count}/{min_models} "
                    f"successes, attempting fallback for {needed} more"
                )

                for fallback_provider in remaining_providers:
                    # Skip if already tried (shouldn't happen, but safety check)
                    if any(
                        r.provider_id == fallback_provider.provider_id
                        for r in all_responses
                    ):
                        continue

                    # Check if provider is available
                    if not check_provider_available(fallback_provider.provider_id):
                        warnings.append(
                            f"Fallback provider {fallback_provider.provider_id} "
                            "is not available, skipping"
                        )
                        continue

                    logger.debug(
                        f"Fallback attempt: trying provider {fallback_provider.provider_id}"
                    )

                    response = await self._execute_single_provider_async(
                        request, prompt, fallback_provider
                    )
                    all_responses.append(response)

                    if response.success and response.content.strip():
                        successful_count += 1
                        warnings.append(
                            f"Fallback provider {fallback_provider.provider_id} succeeded"
                        )
                        if successful_count >= min_models:
                            logger.debug(
                                f"Reached {min_models} successful providers via fallback"
                            )
                            break
                    elif response.success and not response.content.strip():
                        warnings.append(
                            f"Fallback provider {fallback_provider.provider_id} "
                            "returned empty content"
                        )
                    else:
                        warnings.append(
                            f"Fallback provider {fallback_provider.provider_id} "
                            f"failed: {response.error}"
                        )

        duration_ms = (time.time() - start_time) * 1000

        # Final warning if still insufficient
        if successful_count < min_models:
            warnings.append(
                f"Only {successful_count} of {min_models} required models succeeded "
                f"after trying {len(all_responses)} provider(s)"
            )

        return ConsensusResult(
            workflow=request.workflow,
            responses=all_responses,
            duration_ms=duration_ms,
            warnings=warnings,
        )

    def _execute_with_fallback(
        self,
        request: ConsultationRequest,
        prompt: str,
        providers: List[ResolvedProvider],
        warnings: List[str],
    ) -> tuple[Optional[ProviderResult], str, Optional[str]]:
        """
        Execute request with fallback across providers.

        Args:
            request: The consultation request
            prompt: The rendered prompt
            providers: Ordered list of ResolvedProvider instances to try
            warnings: List to append warnings to

        Returns:
            Tuple of (result, provider_id, error_message)
        """
        if not providers:
            return None, "none", "No AI providers are currently available"

        last_error: Optional[str] = None
        last_provider_id = providers[0].provider_id

        for i, resolved in enumerate(providers):
            provider_id = resolved.provider_id
            last_provider_id = provider_id

            # Check if provider is available (may have changed since _get_providers_to_try)
            if not check_provider_available(provider_id):
                warnings.append(f"Provider {provider_id} is not available, skipping")
                continue

            logger.debug(
                f"Trying provider {provider_id} (spec: {resolved.spec_str}, "
                f"model: {resolved.model})"
            )
            result = self._try_provider_with_retries(
                request, prompt, resolved, warnings
            )

            if result is not None:
                return result, provider_id, None

            # Determine if we should try next provider
            if i < len(providers) - 1:
                # Check the last warning for the error
                last_warning = warnings[-1] if warnings else ""
                # Create a pseudo-error from the warning to check fallback eligibility
                pseudo_error = Exception(last_warning)
                if self._should_try_next_provider(pseudo_error):
                    warnings.append("Falling back to next provider...")
                else:
                    last_error = (
                        f"Provider {provider_id} failed and fallback is not appropriate"
                    )
                    break
            else:
                last_error = f"All {len(providers)} provider(s) failed"

        return None, last_provider_id, last_error or "All providers failed"

    def consult(
        self,
        request: ConsultationRequest,
        *,
        use_cache: bool = True,
        cache_ttl: Optional[int] = None,
        workflow_name: Optional[str] = None,
    ) -> ConsultationOutcome:
        """
        Execute a consultation request with retry, fallback, and multi-model support.

        This is the synchronous wrapper for consult_async(). It routes to either
        single-provider or multi-model parallel execution based on the workflow
        configuration's min_models setting.

        The consultation process:
        1. Check cache for existing result (single-model mode only)
        2. Build prompt from template and context
        3. Get ordered list of providers to try
        4. Execute based on min_models:
           - min_models=1: Sequential with fallback, returns ConsultationResult
           - min_models>1: Parallel execution, returns ConsensusResult
        5. Cache successful results (single-model mode only)

        Retry behavior (configurable via ConsultationConfig):
        - max_retries: Number of retry attempts per provider (default: 2)
        - retry_delay: Delay between retries in seconds (default: 5.0)
        - Retries occur for transient errors (timeouts, rate limits, 5xx errors)

        Fallback behavior (configurable via ConsultationConfig):
        - fallback_enabled: Whether to try next provider on failure (default: True)
        - Fallback skipped for prompt-level errors that would fail with any provider

        Args:
            request: The consultation request
            use_cache: Whether to use cached results (default: True)
            cache_ttl: Cache TTL override in seconds
            workflow_name: Override workflow name for config lookup
                          (defaults to request.workflow.value)

        Returns:
            ConsultationOutcome: Either ConsultationResult (min_models=1) or
                                ConsensusResult (min_models>1)
        """
        # Delegate to async implementation
        # Check if we're already in an async context
        try:
            asyncio.get_running_loop()
            # Already in async context - use thread pool to avoid nested asyncio.run()
            with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
                future = executor.submit(
                    asyncio.run,
                    self.consult_async(
                        request,
                        use_cache=use_cache,
                        cache_ttl=cache_ttl,
                        workflow_name=workflow_name,
                    ),
                )
                return future.result()
        except RuntimeError:
            # No running loop - safe to use asyncio.run()
            return asyncio.run(
                self.consult_async(
                    request,
                    use_cache=use_cache,
                    cache_ttl=cache_ttl,
                    workflow_name=workflow_name,
                )
            )

    def consult_multiple(
        self,
        requests: Sequence[ConsultationRequest],
        *,
        use_cache: bool = True,
    ) -> List[ConsultationResult]:
        """
        Execute multiple consultation requests sequentially.

        Args:
            requests: Sequence of consultation requests
            use_cache: Whether to use cached results

        Returns:
            List of ConsultationResult objects in the same order as requests
        """
        return [self.consult(req, use_cache=use_cache) for req in requests]

    async def consult_async(
        self,
        request: ConsultationRequest,
        *,
        use_cache: bool = True,
        cache_ttl: Optional[int] = None,
        workflow_name: Optional[str] = None,
    ) -> ConsultationOutcome:
        """
        Execute a consultation request asynchronously with multi-model support.

        Routes to single-provider or parallel execution based on the workflow
        configuration's min_models setting. Returns ConsultationResult for
        single-provider mode or ConsensusResult for multi-model mode.

        Args:
            request: The consultation request
            use_cache: Whether to use cached results (default: True)
            cache_ttl: Cache TTL override in seconds
            workflow_name: Override workflow name for config lookup
                          (defaults to request.workflow.value)

        Returns:
            ConsultationOutcome: Either ConsultationResult (min_models=1) or
                                ConsensusResult (min_models>1)
        """
        start_time = time.time()

        # Get workflow config (determines single vs multi-model mode)
        effective_workflow = workflow_name or request.workflow.value
        workflow_config = self._config.get_workflow_config(effective_workflow)
        min_models = workflow_config.min_models

        # Apply workflow-specific timeout override if configured
        if workflow_config.timeout_override is not None:
            request = replace(request, timeout=workflow_config.timeout_override)

        # Generate cache key
        cache_key = self._generate_cache_key(request)

        # Check cache (only for single-model mode for now)
        if use_cache and min_models == 1:
            cached = self.cache.get(request.workflow, cache_key)
            if cached:
                duration_ms = (time.time() - start_time) * 1000
                return ConsultationResult(
                    workflow=request.workflow,
                    content=cached.get("content", ""),
                    provider_id=cached.get("provider_id", "cached"),
                    model_used=cached.get("model_used", "cached"),
                    tokens=cached.get("tokens", {}),
                    duration_ms=duration_ms,
                    cache_hit=True,
                )

        # Build prompt
        try:
            prompt = self._build_prompt(request)
        except Exception as exc:  # noqa: BLE001 - wrap prompt build errors
            duration_ms = (time.time() - start_time) * 1000
            if min_models > 1:
                return ConsensusResult(
                    workflow=request.workflow,
                    responses=[],
                    duration_ms=duration_ms,
                    warnings=[f"Failed to build prompt: {exc}"],
                )
            return ConsultationResult(
                workflow=request.workflow,
                content="",
                provider_id="none",
                model_used="none",
                duration_ms=duration_ms,
                error=f"Failed to build prompt: {exc}",
            )

        # Get providers to try
        providers = self._get_providers_to_try(request)

        if min_models > 1:
            # Multi-model mode: execute providers in parallel with fallback support
            # Pass full provider list - fallback will try additional providers if needed
            result = await self._execute_parallel_providers_with_fallback_async(
                request, prompt, providers, min_models
            )
            return result
        else:
            # Single-model mode: execute with fallback (using first success)
            if not providers:
                duration_ms = (time.time() - start_time) * 1000
                return ConsultationResult(
                    workflow=request.workflow,
                    content="",
                    provider_id="none",
                    model_used="none",
                    duration_ms=duration_ms,
                    error="No AI providers are currently available",
                )

            # Try providers in order until one succeeds
            warnings: List[str] = []
            for resolved in providers:
                if not check_provider_available(resolved.provider_id):
                    warnings.append(
                        f"Provider {resolved.provider_id} is not available, skipping"
                    )
                    continue

                response = await self._execute_single_provider_async(
                    request, prompt, resolved
                )

                if response.success:
                    duration_ms = (time.time() - start_time) * 1000
                    result = ConsultationResult(
                        workflow=request.workflow,
                        content=response.content,
                        provider_id=response.provider_id,
                        model_used=response.model_used,
                        tokens={"total_tokens": response.tokens}
                        if response.tokens
                        else {},
                        duration_ms=duration_ms,
                        cache_hit=False,
                        warnings=warnings,
                        error=None,
                    )

                    # Cache successful results
                    if use_cache:
                        self.cache.set(
                            request.workflow, cache_key, result, ttl=cache_ttl
                        )

                    return result

                # Provider failed, try next
                warnings.append(
                    f"Provider {resolved.provider_id} failed: {response.error}"
                )

                if not self._config.fallback_enabled:
                    break

            # All providers failed
            duration_ms = (time.time() - start_time) * 1000
            return ConsultationResult(
                workflow=request.workflow,
                content="",
                provider_id=providers[0].provider_id if providers else "none",
                model_used="none",
                duration_ms=duration_ms,
                warnings=warnings,
                error="All providers failed",
            )


__all__ = [
    "ConsultationOrchestrator",
    "_collect_provider_error",
]
