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

using AIRuntime.Runtime.Models;
using AIRuntime.Runtime.Providers;
using AIRuntime.Runtime.Providers.Models;
using AIRuntime.Runtime.Providers.OpenAI;

using Newtonsoft.Json.Linq;

using NUnit.Framework;


namespace AIRuntime.Tests
{
    public sealed class OpenAIProviderTests
    {
        [Test]
        public void ConstructorRequiresClient()
        {
            Assert.Throws<ArgumentNullException>(
                () => new OpenAIProvider(
                    null));
        }


        [Test]
        public void ProviderMapsRequestConfiguration()
        {
            FakeOpenAIClient client =
                new FakeOpenAIClient(
                    FinalResponseJson);

            OpenAIProvider provider =
                new OpenAIProvider(
                    client);


            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>
                        {
                            new ConversationMessage
                            {
                                Role =
                                    "user",

                                Content =
                                    "Read README.md"
                            }
                        },
                        new List<ProviderTool>
                        {
                            new ProviderTool
                            {
                                Type =
                                    "function",

                                Function =
                                    new ProviderToolFunction
                                    {
                                        Name =
                                            "read_file",

                                        Description =
                                            "Reads a file.",

                                        Parameters =
                                            JObject.Parse(
                                                "{\"type\":\"object\"}")
                                    }
                            }
                        },
                        new ProviderRequestOptions(
                            0.4,
                            2048))
                    .GetAwaiter()
                    .GetResult();


            JObject request =
                JObject.Parse(
                    client.LastRequestJson);


            Assert.AreEqual(
                OpenAIClient.DefaultModel,
                (string)request["model"]);

            Assert.AreEqual(
                0.4,
                (double)request["temperature"]);

            Assert.AreEqual(
                2048,
                (int)request["max_completion_tokens"]);

            Assert.AreEqual(
                "none",
                (string)request["reasoning_effort"]);

            Assert.AreEqual(
                false,
                (bool)request["stream"]);

            Assert.AreEqual(
                "Read README.md",
                (string)request["messages"][0]["content"]);

            Assert.AreEqual(
                "read_file",
                (string)request["tools"][0]["function"]["name"]);

            Assert.AreEqual(
                "object",
                (string)request["tools"][0]["function"]["parameters"]["type"]);

            Assert.AreEqual(
                "Done.",
                completion.Content);
        }


        [Test]
        public void ZeroMaxTokensOmitsProviderCeiling()
        {
            FakeOpenAIClient client =
                new FakeOpenAIClient(
                    FinalResponseJson);

            OpenAIProvider provider =
                new OpenAIProvider(
                    client);

            provider.ChatAsync(
                    new List<ConversationMessage>(),
                    new List<ProviderTool>(),
                    new ProviderRequestOptions(
                        0.2,
                        0))
                .GetAwaiter()
                .GetResult();

            JObject request =
                JObject.Parse(
                    client.LastRequestJson);

            Assert.IsNull(request["max_completion_tokens"]);
            Assert.IsNull(request["max_tokens"]);
        }


        [Test]
        public void ProviderMapsImageAttachmentToMultimodalContent()
        {
            string imagePath = Path.Combine(Path.GetTempPath(), "airuntime-provider-" + Guid.NewGuid().ToString("N") + ".png");
            try
            {
                File.WriteAllBytes(imagePath, new byte[] { 137, 80, 78, 71, 1, 2, 3, 4 });
                FakeOpenAIClient client = new FakeOpenAIClient(FinalResponseJson);
                OpenAIProvider provider = new OpenAIProvider(client) { SupportsImageInput = true };
                provider.ChatAsync(
                    new List<ConversationMessage>
                    {
                        new ConversationMessage
                        {
                            Role = "user",
                            Content = "Inspect this evidence.",
                            Attachments = new List<ConversationAttachment>
                            {
                                new ConversationAttachment
                                {
                                    Kind = "Image",
                                    Path = imagePath,
                                    MediaType = "image/png",
                                    Bytes = 8
                                }
                            }
                        }
                    },
                    new List<ProviderTool>(),
                    ProviderRequestOptions.Default).GetAwaiter().GetResult();

                JObject request = JObject.Parse(client.LastRequestJson);
                JArray content = (JArray)request["messages"][0]["content"];
                Assert.AreEqual("text", (string)content[0]["type"]);
                Assert.AreEqual("image_url", (string)content[1]["type"]);
                StringAssert.StartsWith("data:image/png;base64,", (string)content[1]["image_url"]["url"]);
            }
            finally
            {
                if (File.Exists(imagePath)) File.Delete(imagePath);
            }
        }


        [Test]
        public void ProviderMapsToolCallRoundTrip()
        {
            FakeOpenAIClient client =
                new FakeOpenAIClient(
                    ToolCallResponseJson);

            OpenAIProvider provider =
                new OpenAIProvider(
                    client);


            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>
                        {
                            new ConversationMessage
                            {
                                Role =
                                    "assistant",

                                ToolCalls =
                                    new List<ToolCall>
                                    {
                                        new ToolCall(
                                            "previous-call",
                                            "read_file",
                                            "{\"path\":\"old.txt\"}")
                                    }
                            },

                            new ConversationMessage
                            {
                                Role =
                                    "tool",

                                ToolCallId =
                                    "previous-call",

                                Name =
                                    "read_file",

                                Content =
                                    "old contents"
                            }
                        },
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();


            JObject request =
                JObject.Parse(
                    client.LastRequestJson);


            Assert.IsNull(
                request["tools"]);

            Assert.IsNull(
                request["reasoning_effort"]);

            Assert.AreEqual(
                "previous-call",
                (string)request["messages"][0]["tool_calls"][0]["id"]);

            Assert.AreEqual(
                "previous-call",
                (string)request["messages"][1]["tool_call_id"]);

            Assert.IsTrue(
                completion.HasToolCalls);

            Assert.AreEqual(
                "call-123",
                completion.ToolCalls[0].Id);

            Assert.AreEqual(
                "write_file",
                completion.ToolCalls[0].Name);

            Assert.AreEqual(
                "{\"path\":\"new.txt\"}",
                completion.ToolCalls[0].Arguments);
        }


        [Test]
        public void ProviderRejectsCancelledRequestBeforeTransport()
        {
            FakeOpenAIClient client =
                new FakeOpenAIClient(
                    FinalResponseJson);

            OpenAIProvider provider =
                new OpenAIProvider(
                    client);


            using CancellationTokenSource cancellation =
                new CancellationTokenSource();


            cancellation.Cancel();


            Assert.Throws<OperationCanceledException>(
                () => provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>(),
                        cancellation.Token)
                    .GetAwaiter()
                    .GetResult());

            Assert.IsNull(
                client.LastRequestJson);
        }


        [Test]
        public void ProviderRejectsResponseWithoutChoices()
        {
            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        "{\"choices\":[]}"));


            Assert.Throws<InvalidOperationException>(
                () => provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult());
        }


        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[null]}}]}")]
        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"call-1\"}]}}]}")]
        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{}\"}}]}}]}")]
        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"call-1\",\"function\":{\"name\":\"\",\"arguments\":\"{}\"}}]}}]}")]
        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\"}}]}}]}")]
        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"not-json\"}}]}}]}")]
        [TestCase(
            "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"[]\"}}]}}]}")]
        public void ProviderPreservesMalformedToolCallForConversationRepair(
            string responseJson)
        {
            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        responseJson));


            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();


            Assert.IsFalse(
                completion.HasToolCalls);

            Assert.IsTrue(
                completion.HasRejectedToolCalls);

            Assert.AreEqual(
                1,
                completion.RejectedToolCalls.Count);

            StringAssert.Contains(
                "invalid response",
                completion.RejectedToolCalls[0].Reason);
        }


        [Test]
        public void ProviderNormalizesDoubleEncodedToolArguments()
        {
            const string response =
                "{\"choices\":[{\"message\":{\"tool_calls\":[" +
                "{\"id\":\"call-1\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"read_file\",\"arguments\":\"\\\"{\\\\\\\"path\\\\\\\":\\\\\\\"README.md\\\\\\\"}\\\"\"}}]}}]}";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        response));

            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();

            Assert.AreEqual(
                "{\"path\":\"README.md\"}",
                completion.ToolCalls[0].Arguments);
        }


        [Test]
        public void ProviderNormalizesSingleObjectArrayToolArguments()
        {
            const string response =
                "{\"choices\":[{\"message\":{\"tool_calls\":[" +
                "{\"id\":\"call-1\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"create_editor_scene\",\"arguments\":\"[{\\\"scenePath\\\":\\\"Assets/Test.unity\\\"}]\"}}]}}]}";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        response));

            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();

            Assert.AreEqual(
                "{\"scenePath\":\"Assets/Test.unity\"}",
                completion.ToolCalls[0].Arguments);
        }


        [Test]
        public void ProviderExpandsConcatenatedObjectArgumentsIntoOrderedCalls()
        {
            const string response =
                "{\"choices\":[{\"message\":{\"tool_calls\":[" +
                "{\"id\":\"call-batch\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"list_directory\",\"arguments\":" +
                "\"{\\\"path\\\":\\\"Assets/Game\\\"}{\\\"path\\\":\\\"Assets/Game/Prefabs\\\"}{\\\"path\\\":\\\"Assets/Game/Materials\\\"}\"}}]}}]}";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        response));


            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();


            Assert.AreEqual(
                3,
                completion.ToolCalls.Count);

            CollectionAssert.AreEqual(
                new[]
                {
                    "call-batch__airuntime_part_1",
                    "call-batch__airuntime_part_2",
                    "call-batch__airuntime_part_3"
                },
                completion.ToolCalls
                    .Select(call => call.Id)
                    .ToArray());

            CollectionAssert.AreEqual(
                new[]
                {
                    "{\"path\":\"Assets/Game\"}",
                    "{\"path\":\"Assets/Game/Prefabs\"}",
                    "{\"path\":\"Assets/Game/Materials\"}"
                },
                completion.ToolCalls
                    .Select(call => call.Arguments)
                    .ToArray());

            Assert.IsFalse(
                completion.HasRejectedToolCalls);

            Assert.AreEqual(
                1,
                completion.NormalizationMessages.Count);

            StringAssert.Contains(
                "3 ordered calls",
                completion.NormalizationMessages[0]);
        }


        [Test]
        public void ProviderExpandsMultiObjectArrayArgumentsIntoOrderedCalls()
        {
            const string response =
                "{\"choices\":[{\"message\":{\"tool_calls\":[" +
                "{\"id\":\"call-array\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"read_file\",\"arguments\":" +
                "\"[{\\\"path\\\":\\\"one.txt\\\"},{\\\"path\\\":\\\"two.txt\\\"}]\"}}]}}]}";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        response));


            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();


            Assert.AreEqual(
                2,
                completion.ToolCalls.Count);

            CollectionAssert.AreEqual(
                new[]
                {
                    "{\"path\":\"one.txt\"}",
                    "{\"path\":\"two.txt\"}"
                },
                completion.ToolCalls
                    .Select(call => call.Arguments)
                    .ToArray());
        }


        [Test]
        public void ProviderRejectsPartiallyValidConcatenatedArguments()
        {
            const string response =
                "{\"choices\":[{\"message\":{\"tool_calls\":[" +
                "{\"id\":\"call-mixed\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"read_file\",\"arguments\":" +
                "\"{\\\"path\\\":\\\"safe.txt\\\"}false\"}}]}}]}";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        response));


            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();


            Assert.IsFalse(
                completion.HasToolCalls);

            Assert.AreEqual(
                1,
                completion.RejectedToolCalls.Count);
        }


        [Test]
        public void ProviderPreservesValidCallsWhenAnotherBatchedCallIsMalformed()
        {
            const string response =
                "{\"choices\":[{\"message\":{\"tool_calls\":[" +
                "{\"id\":\"call-1\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}," +
                "{\"id\":\"call-2\",\"type\":\"function\",\"function\":{" +
                "\"name\":\"read_file\",\"arguments\":\"not-json\"}}]}}]}";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        response));

            ChatCompletion completion =
                provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>())
                    .GetAwaiter()
                    .GetResult();

            Assert.AreEqual(
                1,
                completion.ToolCalls.Count);

            Assert.AreEqual(
                "call-1",
                completion.ToolCalls[0].Id);

            Assert.AreEqual(
                1,
                completion.RejectedToolCalls.Count);

            Assert.AreEqual(
                "not-json",
                completion.RejectedToolCalls[0].Arguments);

            StringAssert.Contains(
                "rejected malformed provider tool calls",
                completion.Content);

            StringAssert.Contains(
                "index 1",
                completion.Content);
        }


        [Test]
        public void ProviderRejectsTemperatureAboveOpenAIRange()
        {
            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        FinalResponseJson));


            Assert.Throws<ArgumentOutOfRangeException>(
                () => provider.ChatAsync(
                        new List<ConversationMessage>(),
                        new List<ProviderTool>(),
                        new ProviderRequestOptions(
                            2.1,
                            100))
                    .GetAwaiter()
                    .GetResult());
        }


        [Test]
        public void ProviderAssemblesStreamingTextDeltas()
        {
            const string stream =
                "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n" +
                "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n" +
                "data: [DONE]\n\n";

            FakeOpenAIClient client =
                new FakeOpenAIClient(
                    stream);

            OpenAIProvider provider =
                new OpenAIProvider(
                    client);

            ChatCompletion completion;


            using (
                ProviderStreamingContext.Begin(
                    text =>
                    {
                    }))
            {
                completion =
                    provider.ChatAsync(
                            new List<ConversationMessage>(),
                            new List<ProviderTool>())
                        .GetAwaiter()
                        .GetResult();
            }


            JObject request =
                JObject.Parse(
                    client.LastRequestJson);

            Assert.IsTrue(
                (bool)request["stream"]);

            Assert.AreEqual(
                "Hello",
                completion.Content);
        }


        [Test]
        public void ProviderRejectsReasoningOnlyStream()
        {
            const string stream =
                "data: {\"choices\":[{\"delta\":{\"reasoning\":\"Thinking\"}}]}\n\n" +
                "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" +
                "data: [DONE]\n\n";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        stream));


            using (
                ProviderStreamingContext.Begin(
                    text =>
                    {
                    }))
            {
                Assert.Throws<ProviderEmptyResponseException>(
                    () => provider.ChatAsync(
                            new List<ConversationMessage>(),
                            new List<ProviderTool>())
                        .GetAwaiter()
                        .GetResult());
            }
        }


        [TestCase("OpenRouter")]
        [TestCase("Nous Portal")]
        public void RoutedProvidersExcludeReasoningFromResponses(
            string providerName)
        {
            FakeOpenAIClient client =
                new FakeOpenAIClient(
                    FinalResponseJson);

            OpenAICompatibleProvider provider =
                new OpenAICompatibleProvider(
                    providerName,
                    client);

            provider.ChatAsync(
                    new List<ConversationMessage>(),
                    new List<ProviderTool>())
                .GetAwaiter()
                .GetResult();

            JObject request =
                JObject.Parse(
                    client.LastRequestJson);

            Assert.IsTrue(
                (bool)request["reasoning"]["exclude"]);
            Assert.AreEqual(
                "none",
                (string)request["reasoning"]["effort"]);
        }


        [Test]
        public void ProviderRejectsStreamingResponseWithNoUsableOutput()
        {
            const string stream =
                "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" +
                "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" +
                "data: [DONE]\n\n";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        stream));


            using (
                ProviderStreamingContext.Begin(
                    text =>
                    {
                    }))
            {
                ProviderEmptyResponseException exception =
                    Assert.Throws<ProviderEmptyResponseException>(
                        () => provider.ChatAsync(
                                new List<ConversationMessage>(),
                                new List<ProviderTool>())
                            .GetAwaiter()
                            .GetResult());

                StringAssert.Contains(
                    "neither assistant text nor tool calls",
                    exception.Message);
            }
        }


        [Test]
        public void ProviderAssemblesStreamingToolCallFragments()
        {
            const string stream =
                "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"read_\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n" +
                "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"file\",\"arguments\":\"\\\"README.md\\\"}\"}}]}}]}\n\n" +
                "data: [DONE]\n\n";

            OpenAIProvider provider =
                new OpenAIProvider(
                    new FakeOpenAIClient(
                        stream));

            ChatCompletion completion;


            using (
                ProviderStreamingContext.Begin(
                    text =>
                    {
                    }))
            {
                completion =
                    provider.ChatAsync(
                            new List<ConversationMessage>(),
                            new List<ProviderTool>())
                        .GetAwaiter()
                        .GetResult();
            }


            Assert.AreEqual(
                1,
                completion.ToolCalls.Count);

            Assert.AreEqual(
                "read_file",
                completion.ToolCalls[0].Name);

            Assert.AreEqual(
                "{\"path\":\"README.md\"}",
                completion.ToolCalls[0].Arguments);
        }


        private const string FinalResponseJson =
            "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"}}]}";

        [TestCase("503", "service_unavailable", "Unavailable")]
        [TestCase("\"503\"", "service_unavailable", "Unavailable")]
        [TestCase("429", "rate_limit_error", "RateLimited")]
        [TestCase("401", "authentication_error", "Authentication")]
        [TestCase("400", "invalid_request_error", "InvalidRequest")]
        [TestCase("null", "service_unavailable", "Unavailable")]
        [TestCase("null", "unexpected_error", "InvalidResponse")]
        public void ProviderPreservesErrorsInsideSuccessfulStreams(string code, string type, string expectedKind)
        {
            string frame = "{\"error\":{\"message\":\"Service temporarily overloaded\",\"type\":\"" + type + "\",\"code\":" + code + "}}";
            var provider = new OpenAICompatibleProvider("NVIDIA Developer",
                new FakeOpenAIClient("data: " + frame + "\n\ndata: [DONE]\n\n"));
            using (ProviderStreamingContext.Begin(_ => { }))
            {
                var error = Assert.Throws<ProviderStreamErrorException>(() => provider.ChatAsync(
                    new List<ConversationMessage>(), new List<ProviderTool>()).GetAwaiter().GetResult());
                Assert.AreEqual("NVIDIA Developer", error.ProviderName);
                Assert.AreEqual(frame, error.ErrorBody);
                Assert.AreEqual(type, error.ErrorType);
                var failure = new AIRuntime.Runtime.Providers.Fallback.DefaultProviderFailureClassifier().Classify(provider, error);
                Assert.AreEqual(expectedKind, failure.Kind.ToString());
                StringAssert.DoesNotContain("EmptyResponse", failure.Message);
                StringAssert.Contains("Service temporarily overloaded", failure.Message);
            }
        }

        [Test]
        public void ProviderRejectsPartialToolCallWhenStreamEndsWithAnError()
        {
            const string stream = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"write_file\",\"arguments\":\"{}\"}}]}}]}\n\n" +
                "data: {\"error\":{\"code\":503,\"type\":\"service_unavailable\",\"message\":\"Service temporarily overloaded\"}}\n\ndata: [DONE]\n\n";
            var provider = new OpenAIProvider(new FakeOpenAIClient(stream));
            var records = new List<ProviderTransportRecord>();
            Action<ProviderTransportRecord> collect = records.Add;
            ProviderTransportDebug.RecordPublished += collect;
            try
            {
                using (ProviderStreamingContext.Begin(_ => { }))
                    Assert.Throws<ProviderStreamErrorException>(() => provider.ChatAsync(
                        new List<ConversationMessage>(), new List<ProviderTool>()).GetAwaiter().GetResult());
                Assert.IsTrue(records.Any(record => record.Direction == "STREAM ERROR"));
                Assert.IsFalse(records.Any(record => record.Direction == "ADAPTER RESULT"),
                    "No completion or callable partial tool batch may escape a failed stream.");
            }
            finally { ProviderTransportDebug.RecordPublished -= collect; }
        }

        [Test]
        public void EmptyCompletionTracePreservesProviderFieldsAndConversionFailure()
        {
            const string response = "{\"id\":\"response-empty\",\"usage\":{\"completion_tokens\":4096}," +
                "\"choices\":[{\"finish_reason\":\"length\",\"message\":{\"role\":\"assistant\",\"content\":null," +
                "\"reasoning_content\":\"unfinished reasoning\"}}]}";
            var records = new List<ProviderTransportRecord>();
            Action<ProviderTransportRecord> collect = records.Add;
            ProviderTransportDebug.RecordPublished += collect;
            try
            {
                var provider = new OpenAIProvider(new FakeOpenAIClient(response));
                Assert.Throws<ProviderEmptyResponseException>(() => provider.ChatAsync(
                    new List<ConversationMessage>(), new List<ProviderTool>()).GetAwaiter().GetResult());
                ProviderTransportRecord input = records.Find(record => record.Direction == "ADAPTER INPUT");
                ProviderTransportRecord rejected = records.Find(record => record.Direction == "RESPONSE REJECTED");
                Assert.IsNotNull(input);
                Assert.AreEqual(response, input.Payload);
                Assert.IsNotNull(rejected);
                StringAssert.Contains("Completion conversion", rejected.Payload);
                StringAssert.Contains("ProviderEmptyResponseException", rejected.Payload);
                Assert.AreEqual(input.RequestId, rejected.RequestId);
            }
            finally { ProviderTransportDebug.RecordPublished -= collect; }
        }

        private const string ToolCallResponseJson =
            "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"tool_calls\":[" +
            "{\"id\":\"call-123\",\"type\":\"function\",\"function\":{" +
            "\"name\":\"write_file\",\"arguments\":\"{\\\"path\\\":\\\"new.txt\\\"}\"}}]}}]}";


        private sealed class FakeOpenAIClient :
            IOpenAIClient
        {
            private readonly string _responseJson;


            public string Model =>
                OpenAIClient.DefaultModel;

            public string LastRequestJson { get; private set; }


            public FakeOpenAIClient(
                string responseJson)
            {
                _responseJson =
                    responseJson;
            }


            public Task<string> ChatAsync(
                string jsonBody,
                CancellationToken cancellationToken = default)
            {
                cancellationToken
                    .ThrowIfCancellationRequested();

                LastRequestJson =
                    jsonBody;


                return Task.FromResult(
                    _responseJson);
            }
        }
    }
}
