using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

using AIRuntime.Runtime.Models;
using AIRuntime.Runtime.Providers;
using AIRuntime.Runtime.Providers.Fallback;
using AIRuntime.Runtime.Providers.Models;
using AIRuntime.Runtime.Services.Agent;

using NUnit.Framework;

using UnityEngine;
using UnityEngine.TestTools;


namespace AIRuntime.Tests
{
    public sealed class ProviderFallbackTests
    {
        private string _workspace;


        [SetUp]
        public void Setup()
        {
            _workspace =
                Path.Combine(
                    Application.temporaryCachePath,
                    "AIRuntimeProviderFallbackTests");


            if (Directory.Exists(_workspace))
            {
                Directory.Delete(
                    _workspace,
                    true);
            }


            Directory.CreateDirectory(
                _workspace);
        }


        [TearDown]
        public void Cleanup()
        {
            if (Directory.Exists(_workspace))
            {
                Directory.Delete(
                    _workspace,
                    true);
            }
        }


        //--------------------------------------------------
        // Chain Configuration
        //--------------------------------------------------

        [Test]
        public void Providers_AreStoredInPriorityOrder()
        {
            NamedProvider primary =
                new NamedProvider(
                    "Primary");


            NamedProvider fallback1 =
                new NamedProvider(
                    "Fallback 1");


            NamedProvider fallback2 =
                new NamedProvider(
                    "Fallback 2");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntimeWithoutProvider();


            runtime.UseProvider(
                primary);


            runtime.AddFallbackProvider(
                fallback1);


            runtime.AddFallbackProvider(
                fallback2);


            IReadOnlyList<IProvider> providers =
                runtime.GetProvidersInPriorityOrder();


            Assert.AreEqual(
                3,
                providers.Count);


            Assert.AreSame(
                primary,
                providers[0]);


            Assert.AreSame(
                fallback1,
                providers[1]);


            Assert.AreSame(
                fallback2,
                providers[2]);
        }


        [Test]
        public void InsertFallbackProvider_ChangesPriority()
        {
            NamedProvider primary =
                new NamedProvider(
                    "Primary");


            NamedProvider fallback1 =
                new NamedProvider(
                    "Fallback 1");


            NamedProvider fallback2 =
                new NamedProvider(
                    "Fallback 2");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntimeWithoutProvider();


            runtime.UseProvider(
                primary);


            runtime.AddFallbackProvider(
                fallback1);


            runtime.InsertFallbackProvider(
                0,
                fallback2);


            IReadOnlyList<IProvider> providers =
                runtime.GetProvidersInPriorityOrder();


            Assert.AreSame(
                primary,
                providers[0]);


            Assert.AreSame(
                fallback2,
                providers[1]);


            Assert.AreSame(
                fallback1,
                providers[2]);
        }


        [Test]
        public void ReplacingPrimary_PreservesFallbacks()
        {
            NamedProvider firstPrimary =
                new NamedProvider(
                    "Primary 1");


            NamedProvider secondPrimary =
                new NamedProvider(
                    "Primary 2");


            NamedProvider fallback =
                new NamedProvider(
                    "Fallback");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntimeWithoutProvider();


            runtime.UseProvider(
                firstPrimary);


            runtime.AddFallbackProvider(
                fallback);


            runtime.UseProvider(
                secondPrimary);


            IReadOnlyList<IProvider> providers =
                runtime.GetProvidersInPriorityOrder();


            Assert.AreEqual(
                2,
                providers.Count);


            Assert.AreSame(
                secondPrimary,
                providers[0]);


            Assert.AreSame(
                fallback,
                providers[1]);
        }


        [Test]
        public void SupportsManyFallbackProviders()
        {
            const int fallbackCount =
                128;


            NamedProvider primary =
                new NamedProvider(
                    "Primary");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntimeWithoutProvider();


            runtime.UseProvider(
                primary);


            List<IProvider> expectedFallbacks =
                new List<IProvider>(
                    fallbackCount);


            for (
                int index = 0;
                index < fallbackCount;
                index++)
            {
                NamedProvider fallback =
                    new NamedProvider(
                        $"Fallback {index + 1}");


                expectedFallbacks.Add(
                    fallback);


                runtime.AddFallbackProvider(
                    fallback);
            }


            IReadOnlyList<IProvider> providers =
                runtime.GetProvidersInPriorityOrder();


            Assert.AreEqual(
                fallbackCount,
                runtime.FallbackProviderCount);


            Assert.AreEqual(
                fallbackCount + 1,
                providers.Count);


            Assert.AreSame(
                primary,
                providers[0]);


            for (
                int index = 0;
                index < fallbackCount;
                index++)
            {
                Assert.AreSame(
                    expectedFallbacks[index],
                    providers[index + 1]);
            }
        }


        //--------------------------------------------------
        // Primary Success
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator PrimarySuccess_DoesNotCallFallback()
        {
            RecordingProvider primary =
                RecordingProvider.CreateSuccess(
                    "Primary",
                    "Primary response.");


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Fallback response.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Use the primary provider.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Primary success run");


            Assert.AreEqual(
                AgentRunStatus.Succeeded,
                task.Result.Status);


            Assert.AreEqual(
                "Primary response.",
                task.Result.Response);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                0,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // Ordered Fallback
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator Providers_AreAttemptedInPriorityOrder()
        {
            List<string> attempts =
                new List<string>();


            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Primary",
                    new TimeoutException(
                        "Primary timeout."),
                    attempts);


            RecordingProvider fallback1 =
                RecordingProvider.CreateFailure(
                    "Fallback 1",
                    new InvalidOperationException(
                        "Service unavailable."),
                    attempts);


            RecordingProvider fallback2 =
                RecordingProvider.CreateSuccess(
                    "Fallback 2",
                    "Fallback 2 response.",
                    attempts);


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback1);


            runtime.AddFallbackProvider(
                fallback2);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Use provider fallbacks.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Ordered fallback run");


            Assert.AreEqual(
                AgentRunStatus.Succeeded,
                task.Result.Status);


            Assert.AreEqual(
                "Fallback 2 response.",
                task.Result.Response);


            CollectionAssert.AreEqual(
                new[]
                {
                    "Primary",
                    "Fallback 1",
                    "Fallback 2"
                },
                attempts);
        }


        //--------------------------------------------------
        // Timeout Fallback
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator Timeout_UsesNextProvider()
        {
            BlockingProvider primary =
                new BlockingProvider(
                    "Slow Primary");


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Recovered from timeout.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.ProviderTimeout =
                TimeSpan.FromMilliseconds(
                    100);


            runtime.RunTimeout =
                TimeSpan.FromSeconds(
                    5);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Fallback after timeout.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Timeout fallback run");


            Assert.AreEqual(
                AgentRunStatus.Succeeded,
                task.Result.Status);


            Assert.AreEqual(
                "Recovered from timeout.",
                task.Result.Response);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                1,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // Rate Limit
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator RateLimitedProvider_UsesNextProvider()
        {
            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Rate Limited",
                    new InvalidOperationException(
                        "429 Too Many Requests: rate limit exceeded."));


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Fallback after rate limit.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Recover from a rate limit.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Rate-limit fallback run");


            Assert.AreEqual(
                AgentRunStatus.Succeeded,
                task.Result.Status);


            Assert.AreEqual(
                "Fallback after rate limit.",
                task.Result.Response);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                1,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // Model Unavailable
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator ModelUnavailable_UsesNextProvider()
        {
            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Missing Model",
                    new InvalidOperationException(
                        "Model not found."));


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Fallback model response.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Use another model.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Model fallback run");


            Assert.AreEqual(
                AgentRunStatus.Succeeded,
                task.Result.Status);


            Assert.AreEqual(
                "Fallback model response.",
                task.Result.Response);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                1,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // Invalid Response
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator InvalidResponse_UsesNextProvider()
        {
            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Malformed Provider",
                    new InvalidOperationException(
                        "Malformed response received from provider."));


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Recovered from invalid response.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Recover from malformed response.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Invalid-response fallback run");


            Assert.AreEqual(
                AgentRunStatus.Succeeded,
                task.Result.Status);


            Assert.AreEqual(
                "Recovered from invalid response.",
                task.Result.Response);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                1,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // Invalid Request Stops
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator InvalidRequest_DoesNotUseFallback()
        {
            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Primary",
                    new InvalidOperationException(
                        "400 Bad Request: invalid request."));


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Should not be used.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Send invalid request.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Invalid request run");


            Assert.AreEqual(
                AgentRunStatus.Failed,
                task.Result.Status);


            StringAssert.Contains(
                "invalid request",
                task.Result.ErrorMessage.ToLowerInvariant());


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                0,
                fallback.CallCount);


            Assert.AreEqual(
                0,
                runtime.MessageCount);
        }


        //--------------------------------------------------
        // Unknown Error Stops
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator UnknownFailure_DoesNotUseFallback()
        {
            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Primary",
                    new InvalidOperationException(
                        "Unexpected provider implementation bug."));


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Should not be used.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Trigger an unknown failure.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Unknown failure run");


            Assert.AreEqual(
                AgentRunStatus.Failed,
                task.Result.Status);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                0,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // All Time Out
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator AllProvidersTimeout_ReturnsTimedOutStatus()
        {
            BlockingProvider primary =
                new BlockingProvider(
                    "Primary");


            BlockingProvider fallback =
                new BlockingProvider(
                    "Fallback");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.ProviderTimeout =
                TimeSpan.FromMilliseconds(
                    100);


            runtime.RunTimeout =
                TimeSpan.FromSeconds(
                    5);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "All providers time out.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "All-provider timeout run");


            Assert.AreEqual(
                AgentRunStatus.TimedOut,
                task.Result.Status);


            StringAssert.Contains(
                "All configured providers timed out",
                task.Result.ErrorMessage);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                1,
                fallback.CallCount);


            Assert.AreEqual(
                0,
                runtime.MessageCount);
        }


        //--------------------------------------------------
        // Mixed Failures
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator AllEligibleProvidersFail_ReturnsFailedStatus()
        {
            RecordingProvider primary =
                RecordingProvider.CreateFailure(
                    "Primary",
                    new InvalidOperationException(
                        "429 rate limit exceeded."));


            RecordingProvider fallback =
                RecordingProvider.CreateFailure(
                    "Fallback",
                    new InvalidOperationException(
                        "Service unavailable."));


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "All eligible providers fail.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "All-provider failure run");


            Assert.AreEqual(
                AgentRunStatus.Failed,
                task.Result.Status);


            StringAssert.Contains(
                "All eligible providers failed",
                task.Result.ErrorMessage);


            StringAssert.Contains(
                "RateLimited",
                task.Result.ErrorMessage);


            StringAssert.Contains(
                "Unavailable",
                task.Result.ErrorMessage);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                1,
                fallback.CallCount);


            Assert.AreEqual(
                0,
                runtime.MessageCount);
        }


        //--------------------------------------------------
        // Cancellation
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator CallerCancellation_StopsFallbackChain()
        {
            BlockingProvider primary =
                new BlockingProvider(
                    "Primary");


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Should not run.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.ProviderTimeout =
                TimeSpan.FromSeconds(
                    10);


            runtime.RunTimeout =
                TimeSpan.FromSeconds(
                    10);


            runtime.AddFallbackProvider(
                fallback);


            using CancellationTokenSource cancellation =
                new CancellationTokenSource();


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Cancel this request.",
                    cancellation.Token);


            yield return WaitForProviderStart(
                primary);


            cancellation.Cancel();


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Cancelled fallback run");


            Assert.AreEqual(
                AgentRunStatus.Cancelled,
                task.Result.Status);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                0,
                fallback.CallCount);


            Assert.AreEqual(
                0,
                runtime.MessageCount);
        }


        //--------------------------------------------------
        // Full Run Timeout
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator FullRunTimeout_StopsFallbackChain()
        {
            BlockingProvider primary =
                new BlockingProvider(
                    "Primary");


            RecordingProvider fallback =
                RecordingProvider.CreateSuccess(
                    "Fallback",
                    "Should not run.");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.ProviderTimeout =
                TimeSpan.FromSeconds(
                    10);


            runtime.RunTimeout =
                TimeSpan.FromMilliseconds(
                    100);


            runtime.AddFallbackProvider(
                fallback);


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Run timeout should stop the chain.");


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Full-run timeout test");


            Assert.AreEqual(
                AgentRunStatus.TimedOut,
                task.Result.Status);


            Assert.AreEqual(
                1,
                primary.CallCount);


            Assert.AreEqual(
                0,
                fallback.CallCount);
        }


        //--------------------------------------------------
        // Provider Configuration Lock
        //--------------------------------------------------

        [UnityTest]
        public IEnumerator FallbackProviders_CannotChangeDuringRun()
        {
            BlockingProvider primary =
                new BlockingProvider(
                    "Primary");


            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntime(
                    primary);


            runtime.ProviderTimeout =
                TimeSpan.FromSeconds(
                    10);


            runtime.RunTimeout =
                TimeSpan.FromSeconds(
                    10);


            using CancellationTokenSource cancellation =
                new CancellationTokenSource();


            Task<AgentRunResult> task =
                runtime.RunAsync(
                    "Keep the provider chain active.",
                    cancellation.Token);


            yield return WaitForProviderStart(
                primary);


            InvalidOperationException exception =
                Assert.Throws<InvalidOperationException>(
                    () =>
                    {
                        runtime.AddFallbackProvider(
                            RecordingProvider.CreateSuccess(
                                "Late Fallback",
                                "Not allowed."));
                    });


            Assert.AreEqual(
                "Provider configuration cannot be changed while an agent run is in progress.",
                exception.Message);


            cancellation.Cancel();


            yield return WaitForTask(
                task);


            AssertTaskCompletedSuccessfully(
                task,
                "Provider configuration lock test");


            Assert.AreEqual(
                AgentRunStatus.Cancelled,
                task.Result.Status);
        }


        //--------------------------------------------------
        // Classifier Unit Tests
        //--------------------------------------------------

        [Test]
        public void Classifier_RecognisesRateLimit()
        {
            DefaultProviderFailureClassifier classifier =
                new DefaultProviderFailureClassifier();


            NamedProvider provider =
                new NamedProvider(
                    "Provider");


            ProviderFailure failure =
                classifier.Classify(
                    provider,
                    new InvalidOperationException(
                        "429 Too Many Requests: rate limit exceeded."));


            Assert.AreEqual(
                ProviderFailureKind.RateLimited,
                failure.Kind);


            Assert.IsTrue(
                failure.ShouldFallback);
        }


        [Test]
        public void Classifier_RecognisesInvalidRequest()
        {
            DefaultProviderFailureClassifier classifier =
                new DefaultProviderFailureClassifier();


            NamedProvider provider =
                new NamedProvider(
                    "Provider");


            ProviderFailure failure =
                classifier.Classify(
                    provider,
                    new InvalidOperationException(
                        "400 Bad Request: invalid parameter."));


            Assert.AreEqual(
                ProviderFailureKind.InvalidRequest,
                failure.Kind);


            Assert.IsFalse(
                failure.ShouldFallback);
        }


        [Test]
        public void Classifier_UnknownFailuresAreConservative()
        {
            DefaultProviderFailureClassifier classifier =
                new DefaultProviderFailureClassifier();


            NamedProvider provider =
                new NamedProvider(
                    "Provider");


            ProviderFailure failure =
                classifier.Classify(
                    provider,
                    new InvalidOperationException(
                        "Unexpected internal implementation fault."));


            Assert.AreEqual(
                ProviderFailureKind.Unknown,
                failure.Kind);


            Assert.IsFalse(
                failure.ShouldFallback);
        }


        //--------------------------------------------------
        // Runtime Creation
        //--------------------------------------------------

        private AIRuntime.Runtime.AIRuntime CreateRuntimeWithoutProvider()
        {
            AIRuntime.Runtime.AIRuntime runtime =
                new AIRuntime.Runtime.AIRuntime(
                    _workspace);


            //--------------------------------------------------
            // This suite verifies fallback behaviour only.
            // Retry behaviour is covered by ProviderRetryTests.
            //--------------------------------------------------

            runtime.ProviderRetryCount =
                0;


            runtime.ProviderRetryDelay =
                TimeSpan.Zero;


            runtime.ProviderMaximumRetryDelay =
                TimeSpan.Zero;


            return runtime;
        }


        private AIRuntime.Runtime.AIRuntime CreateRuntime(
            IProvider primaryProvider)
        {
            AIRuntime.Runtime.AIRuntime runtime =
                CreateRuntimeWithoutProvider();


            runtime.ProviderTimeout =
                TimeSpan.FromSeconds(
                    2);


            runtime.RunTimeout =
                TimeSpan.FromSeconds(
                    10);


            runtime.UseProvider(
                primaryProvider);


            return runtime;
        }


        //--------------------------------------------------
        // Providers
        //--------------------------------------------------

        private sealed class NamedProvider :
            IProvider
        {
            public NamedProvider(
                string name)
            {
                Name =
                    name;
            }


            public string Name
            {
                get;
            }


            public Task<ChatCompletion> ChatAsync(
                IReadOnlyList<ConversationMessage> messages,
                IReadOnlyList<ProviderTool> tools,
                CancellationToken cancellationToken = default)
            {
                cancellationToken
                    .ThrowIfCancellationRequested();


                return Task.FromResult(
                    new ChatCompletion
                    {
                        Content =
                            Name
                    });
            }
        }


        private sealed class RecordingProvider :
            IProvider
        {
            private readonly string _response;

            private readonly Exception _exception;

            private readonly List<string> _attempts;


            private RecordingProvider(
                string name,
                string response,
                Exception exception,
                List<string> attempts)
            {
                Name =
                    name;


                _response =
                    response;


                _exception =
                    exception;


                _attempts =
                    attempts;
            }


            public string Name
            {
                get;
            }


            public int CallCount
            {
                get;
                private set;
            }


            public static RecordingProvider CreateSuccess(
                string name,
                string response,
                List<string> attempts = null)
            {
                return new RecordingProvider(
                    name,
                    response,
                    exception: null,
                    attempts);
            }


            public static RecordingProvider CreateFailure(
                string name,
                Exception exception,
                List<string> attempts = null)
            {
                return new RecordingProvider(
                    name,
                    response: null,
                    exception,
                    attempts);
            }


            public Task<ChatCompletion> ChatAsync(
                IReadOnlyList<ConversationMessage> messages,
                IReadOnlyList<ProviderTool> tools,
                CancellationToken cancellationToken = default)
            {
                cancellationToken
                    .ThrowIfCancellationRequested();


                CallCount++;


                _attempts?.Add(
                    Name);


                if (_exception != null)
                {
                    return Task.FromException<ChatCompletion>(
                        _exception);
                }


                return Task.FromResult(
                    new ChatCompletion
                    {
                        Content =
                            _response
                    });
            }
        }


        private sealed class BlockingProvider :
            IProvider
        {
            public BlockingProvider(
                string name)
            {
                Name =
                    name;
            }


            public string Name
            {
                get;
            }


            public int CallCount
            {
                get;
                private set;
            }


            public bool HasStarted
            {
                get;
                private set;
            }


            public async Task<ChatCompletion> ChatAsync(
                IReadOnlyList<ConversationMessage> messages,
                IReadOnlyList<ProviderTool> tools,
                CancellationToken cancellationToken = default)
            {
                CallCount++;


                HasStarted =
                    true;


                await Task.Delay(
                    Timeout.Infinite,
                    cancellationToken);


                throw new InvalidOperationException(
                    "Blocking provider completed unexpectedly.");
            }
        }


        //--------------------------------------------------
        // Coroutine Helpers
        //--------------------------------------------------

        private static IEnumerator WaitForTask(
            Task task)
        {
            while (!task.IsCompleted)
            {
                yield return null;
            }
        }


        private static IEnumerator WaitForProviderStart(
            BlockingProvider provider)
        {
            while (!provider.HasStarted)
            {
                yield return null;
            }
        }


        //--------------------------------------------------
        // Assertions
        //--------------------------------------------------

        private static void AssertTaskCompletedSuccessfully(
            Task task,
            string operation)
        {
            Assert.IsNotNull(
                task,
                operation +
                " returned a null task.");


            if (task.IsCanceled)
            {
                Assert.Fail(
                    operation +
                    " was unexpectedly cancelled.");
            }


            if (!task.IsFaulted)
                return;


            Exception exception =
                UnwrapException(
                    task.Exception);


            Assert.Fail(
                operation +
                " failed:\n" +
                (exception?.ToString() ??
                 "Unknown task failure."));
        }


        private static Exception UnwrapException(
            Exception exception)
        {
            if (exception == null)
                return null;


            if (!(exception is AggregateException aggregate))
                return exception;


            AggregateException flattened =
                aggregate.Flatten();


            if (flattened.InnerExceptions.Count == 1)
            {
                return flattened.InnerExceptions[0];
            }


            return flattened;
        }
    }
}
