using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using PlotLine.Models;
using PlotLine.ViewModels;

namespace PlotLine.Services;

public interface IStoryPromptRepository
{
    Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken);
}

public interface IStoryPromptBuilder
{
    string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText);
}

public interface IStoryPromptVersionService
{
    string GetPromptVersion(string fileName);
}

public interface IStoryIntelligenceClient
{
    Task<StoryIntelligenceClientResult> ExecutePromptAsync(
        string completedPrompt,
        string promptVersion,
        CancellationToken cancellationToken,
        string? modelOverride = null,
        int? maxOutputTokensOverride = null);
    StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
}

public interface IStoryIntelligenceDiagnosticsService
{
    Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken);
}

public interface IStoryIntelligenceDryRunService
{
    StoryIntelligenceDryRunViewModel GetDefault();
    Task<StoryIntelligenceDryRunViewModel> ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken);
}

public interface IChapterStructureDryRunService
{
    ChapterStructureDryRunViewModel GetDefault();
    Task<ChapterStructureDryRunViewModel> ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken);
}

public sealed class StoryPromptRepository(
    IWebHostEnvironment environment,
    IMemoryCache cache,
    ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
{
    private const string PromptFolder = "Docs/AI";
    private const string RepositoryPromptFolder = "PlotLine/Docs/AI";

    public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(fileName)
            || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
            || fileName.Contains(Path.DirectorySeparatorChar)
            || fileName.Contains(Path.AltDirectorySeparatorChar))
        {
            throw new InvalidOperationException("Prompt filename is invalid.");
        }

        var cacheKey = $"story-prompt:{fileName}";
        if (cache.TryGetValue(cacheKey, out string? cachedPrompt) && !string.IsNullOrWhiteSpace(cachedPrompt))
        {
            return cachedPrompt;
        }

        var promptPath = ResolvePromptPath(fileName);
        if (promptPath is null)
        {
            throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /{PromptFolder}/ or /{RepositoryPromptFolder}/.", Path.Combine(environment.ContentRootPath, PromptFolder, fileName));
        }

        var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken);
        if (string.IsNullOrWhiteSpace(prompt))
        {
            throw new InvalidOperationException($"Story Intelligence prompt file '{fileName}' is empty.");
        }

        cache.Set(cacheKey, prompt, TimeSpan.FromMinutes(15));
        logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
        return prompt;
    }

    private string? ResolvePromptPath(string fileName)
    {
        var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
        if (File.Exists(promptPath))
        {
            return promptPath;
        }

        var repositoryPromptPath = Path.Combine(environment.ContentRootPath, RepositoryPromptFolder, fileName);
        if (File.Exists(repositoryPromptPath))
        {
            return repositoryPromptPath;
        }

        return null;
    }
}

public sealed class StoryPromptBuilder : IStoryPromptBuilder
{
    public string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText)
    {
        if (string.IsNullOrWhiteSpace(promptTemplate))
        {
            throw new InvalidOperationException("Prompt template is empty.");
        }

        return promptTemplate
            .Replace("{{SCENE_CONTEXT_JSON}}", sceneContextJson ?? string.Empty, StringComparison.Ordinal)
            .Replace("{{SCENE_TEXT}}", sceneText ?? string.Empty, StringComparison.Ordinal);
    }
}

public sealed class StoryPromptVersionService : IStoryPromptVersionService
{
    public string GetPromptVersion(string fileName)
    {
        if (string.IsNullOrWhiteSpace(fileName))
        {
            throw new InvalidOperationException("Prompt filename is required.");
        }

        var safeName = Path.GetFileName(fileName);
        return Path.GetFileNameWithoutExtension(safeName);
    }
}

public sealed class StoryIntelligenceClient(
    HttpClient httpClient,
    IOptions<StoryIntelligenceOptions> options,
    ILogger<StoryIntelligenceClient> logger) : IStoryIntelligenceClient
{
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
    private readonly StoryIntelligenceOptions settings = options.Value;

    public StoryIntelligenceClientConfigurationStatus GetConfigurationStatus()
        => new()
        {
            ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.ApiKey),
            Model = settings.EffectiveModel,
            ChapterStructureModel = settings.EffectiveChapterStructureModel,
            SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
            MaxOutputTokens = settings.MaxOutputTokens,
            TimeoutSeconds = settings.TimeoutSeconds,
            ClientConstructed = true,
            ConnectionStatus = "Not checked on diagnostics page. Only the admin dry-run page makes a live OpenAI request."
        };

    public async Task<StoryIntelligenceClientResult> ExecutePromptAsync(
        string completedPrompt,
        string promptVersion,
        CancellationToken cancellationToken,
        string? modelOverride = null,
        int? maxOutputTokensOverride = null)
    {
        if (string.IsNullOrWhiteSpace(settings.ApiKey))
        {
            throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables.");
        }

        var model = string.IsNullOrWhiteSpace(modelOverride)
            ? settings.EffectiveModel
            : modelOverride.Trim();

        if (string.IsNullOrWhiteSpace(model))
        {
            throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model or StoryIntelligence stage models in configuration.");
        }

        if (string.IsNullOrWhiteSpace(completedPrompt))
        {
            throw new InvalidOperationException("Completed prompt is empty.");
        }

        var maxOutputTokens = maxOutputTokensOverride.GetValueOrDefault(settings.MaxOutputTokens);
        if (maxOutputTokens <= 0)
        {
            maxOutputTokens = settings.MaxOutputTokens;
        }

        var requestBody = JsonSerializer.Serialize(new
        {
            model = model,
            input = completedPrompt,
            max_output_tokens = maxOutputTokens,
            store = false,
            reasoning = new
            {
                effort = "minimal"
            },
            text = new
            {
                verbosity = "low"
            }
        }, JsonOptions);

        var stopwatch = Stopwatch.StartNew();
        try
        {
            var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken);
            stopwatch.Stop();
            logger.LogInformation(
                "Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
                promptVersion,
                model,
                stopwatch.ElapsedMilliseconds,
                retryCount,
                null,
                null,
                null,
                true);

            return new StoryIntelligenceClientResult
            {
                RawResponseText = responseText,
                Model = model,
                Duration = stopwatch.Elapsed,
                RetryCount = retryCount,
                InputTokens = GetTokenUsage(responseText).InputTokens,
                OutputTokens = GetTokenUsage(responseText).OutputTokens
            };
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            stopwatch.Stop();
            logger.LogError(
                ex,
                "Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
                promptVersion,
                model,
                stopwatch.ElapsedMilliseconds,
                null,
                null,
                null,
                null,
                false);
            throw;
        }
    }

    private async Task<(string ResponseText, int RetryCount)> SendWithRetryAsync(string requestBody, CancellationToken cancellationToken)
    {
        const int maxAttempts = 3;
        var retryCount = 0;
        for (var attempt = 1; attempt <= maxAttempts; attempt++)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/responses")
            {
                Content = new StringContent(requestBody, Encoding.UTF8, "application/json")
            };
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);

            try
            {
                using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, settings.TimeoutSeconds)));
                using var response = await httpClient.SendAsync(request, timeout.Token);
                var responseText = await response.Content.ReadAsStringAsync(cancellationToken);

                if (response.IsSuccessStatusCode)
                {
                    return (responseText, retryCount);
                }

                if (IsInsufficientQuota(responseText))
                {
                    throw new InvalidOperationException($"OpenAI billing quota exhausted; request was not retried: {TrimForLog(responseText)}");
                }

                if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
                {
                    throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
                }

                retryCount++;
            }
            catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
            {
                if (attempt == maxAttempts)
                {
                    throw new InvalidOperationException($"OpenAI request timed out after {settings.TimeoutSeconds} seconds.");
                }

                retryCount++;
                await DelayForRetryAsync(attempt, cancellationToken);
                continue;
            }
            catch (HttpRequestException) when (attempt < maxAttempts)
            {
                retryCount++;
                await DelayForRetryAsync(attempt, cancellationToken);
                continue;
            }

            await DelayForRetryAsync(attempt, cancellationToken);
        }

        throw new InvalidOperationException("OpenAI request failed after retry attempts.");
    }

    private static bool ShouldRetry(HttpStatusCode statusCode)
        => statusCode is HttpStatusCode.RequestTimeout
            or HttpStatusCode.TooManyRequests
            or HttpStatusCode.InternalServerError
            or HttpStatusCode.BadGateway
            or HttpStatusCode.ServiceUnavailable
            or HttpStatusCode.GatewayTimeout;

    internal static bool IsInsufficientQuota(string responseText)
        => responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase)
            || responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase);

    private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);

    private static string TrimForLog(string value)
        => string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500];

    private static (int? InputTokens, int? OutputTokens) GetTokenUsage(string responseText)
    {
        try
        {
            var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(responseText, JsonOptions);
            return (response?.Usage?.InputTokens, response?.Usage?.OutputTokens);
        }
        catch (JsonException)
        {
            return (null, null);
        }
    }
}

public sealed class StoryIntelligenceDiagnosticsService(
    IStoryPromptRepository prompts,
    IStoryPromptVersionService versions,
    IStoryPromptBuilder builder,
    IStoryIntelligenceClient client) : IStoryIntelligenceDiagnosticsService
{
    private const string ScenePromptFile = "Scene-Prompt-V2.md";

    public async Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken)
    {
        var clientStatus = client.GetConfigurationStatus();
        var model = new StoryIntelligenceDiagnosticsViewModel
        {
            Model = clientStatus.Model,
            ChapterStructureModel = clientStatus.ChapterStructureModel,
            SceneIntelligenceModel = clientStatus.SceneIntelligenceModel,
            ApiKeyConfigured = clientStatus.ApiKeyConfigured,
            ClientConstructed = clientStatus.ClientConstructed,
            ConnectionStatus = clientStatus.ConnectionStatus,
            PromptFileName = ScenePromptFile,
            PromptVersion = versions.GetPromptVersion(ScenePromptFile),
            MaxOutputTokens = clientStatus.MaxOutputTokens,
            TimeoutSeconds = clientStatus.TimeoutSeconds
        };

        try
        {
            var prompt = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
            model.PromptRepositoryHealthy = true;
            model.ScenePromptLoaded = true;
            model.ScenePromptLength = prompt.Length;
            var completed = builder.BuildPrompt(prompt, "{\"sceneId\":0}", "Diagnostic scene text.");
            model.RuntimeSubstitutionWorks = completed.Contains("\"sceneId\":0", StringComparison.Ordinal)
                && completed.Contains("Diagnostic scene text.", StringComparison.Ordinal)
                && !completed.Contains("{{SCENE_CONTEXT_JSON}}", StringComparison.Ordinal)
                && !completed.Contains("{{SCENE_TEXT}}", StringComparison.Ordinal);
        }
        catch (Exception ex)
        {
            model.PromptRepositoryHealthy = false;
            model.ScenePromptLoaded = false;
            model.ErrorMessage = ex.Message;
        }

        return model;
    }
}

public sealed class StoryIntelligenceDryRunService(
    IStoryPromptRepository prompts,
    IStoryPromptVersionService versions,
    IStoryPromptBuilder builder,
    IStoryIntelligenceClient client,
    IStorySceneValidator validator,
    ILogger<StoryIntelligenceDryRunService> logger) : IStoryIntelligenceDryRunService
{
    private const string ScenePromptFile = "Scene-Prompt-V2.md";
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true,
        WriteIndented = true
    };

    public StoryIntelligenceDryRunViewModel GetDefault()
        => new();

    public async Task<StoryIntelligenceDryRunViewModel> ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken)
    {
        var model = new StoryIntelligenceDryRunViewModel { Form = form };
        var promptVersion = versions.GetPromptVersion(ScenePromptFile);
        var clientStatus = client.GetConfigurationStatus();
        var contextJson = BuildSceneContextJson(form);

        try
        {
            if (string.IsNullOrWhiteSpace(form.SceneText))
            {
                throw new InvalidOperationException("Enter short fictional scene text before running the dry run.");
            }

            var template = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
            var completedPrompt = builder.BuildPrompt(template, contextJson, form.SceneText);
            var result = await client.ExecutePromptAsync(
                completedPrompt,
                promptVersion,
                cancellationToken,
                clientStatus.SceneIntelligenceModel);
            var sceneJson = ExtractSceneJson(result.RawResponseText);
            var parsedScene = DeserializeScene(sceneJson);
            var validation = validator.Validate(parsedScene);

            model.Result = new StoryIntelligenceDryRunResultViewModel
            {
                Success = true,
                PromptVersion = promptVersion,
                Model = result.Model,
                SceneContextJson = contextJson,
                Duration = FormatDuration(result.Duration),
                RetryCount = result.RetryCount,
                InputTokens = result.InputTokens,
                OutputTokens = result.OutputTokens,
                RawResponseText = result.RawResponseText,
                SceneJsonText = sceneJson,
                ParsedScene = parsedScene,
                Validation = validation
            };

            logger.LogInformation(
                "Admin Story Intelligence dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}",
                promptVersion,
                result.Model,
                result.Duration.TotalMilliseconds,
                result.RetryCount,
                result.InputTokens,
                result.OutputTokens,
                validation.IsValid,
                validation.Warnings.Count,
                validation.Errors.Count,
                true);
        }
        catch (JsonException ex)
        {
            model.Result = new StoryIntelligenceDryRunResultViewModel
            {
                Success = false,
                PromptVersion = promptVersion,
                Model = clientStatus.SceneIntelligenceModel,
                SceneContextJson = contextJson,
                ErrorMessage = $"Scene Intelligence JSON could not be deserialised: {ex.Message}",
                Validation = validator.Validate(null)
            };

            logger.LogWarning(
                ex,
                "Admin Story Intelligence dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
                promptVersion,
                clientStatus.SceneIntelligenceModel,
                false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            model.Result = new StoryIntelligenceDryRunResultViewModel
            {
                Success = false,
                PromptVersion = promptVersion,
                Model = clientStatus.SceneIntelligenceModel,
                SceneContextJson = contextJson,
                ErrorMessage = ex.Message
            };

            logger.LogWarning(
                ex,
                "Admin Story Intelligence dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
                promptVersion,
                clientStatus.SceneIntelligenceModel,
                false);
        }

        return model;
    }

    private static string BuildSceneContextJson(StoryIntelligenceDryRunForm form)
        => JsonSerializer.Serialize(new
        {
            projectId = form.ProjectID,
            bookId = form.BookID,
            chapterId = form.ChapterID,
            sceneId = form.SceneID,
            chapterNumber = form.ChapterNumber,
            sceneNumber = form.SceneNumber,
            sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test scene" : form.SourceLabel.Trim()
        }, JsonOptions);

    private static string FormatDuration(TimeSpan duration)
        => duration.TotalSeconds < 1
            ? $"{duration.TotalMilliseconds:0} ms"
            : $"{duration.TotalSeconds:0.00} sec";

    private static string ExtractSceneJson(string rawResponseText)
    {
        var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
            ?? throw new JsonException("OpenAI response envelope was empty.");
        var outputText = response.Output?
            .SelectMany(item => item.Content ?? [])
            .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
                && !string.IsNullOrWhiteSpace(content.Text))
            ?.Text;

        if (string.IsNullOrWhiteSpace(outputText))
        {
            throw new JsonException("OpenAI response did not contain output_text content.");
        }

        return outputText.Trim();
    }

    private static SceneIntelligenceScene? DeserializeScene(string sceneJson)
        => JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
}

public sealed class ChapterStructureDryRunService(
    IStoryPromptRepository prompts,
    IStoryPromptVersionService versions,
    IStoryIntelligenceClient client,
    IChapterStructureValidator validator,
    ILogger<ChapterStructureDryRunService> logger) : IChapterStructureDryRunService
{
    private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true,
        WriteIndented = true
    };

    public ChapterStructureDryRunViewModel GetDefault()
        => new();

    public async Task<ChapterStructureDryRunViewModel> ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken)
    {
        var model = new ChapterStructureDryRunViewModel { Form = form };
        var promptVersion = versions.GetPromptVersion(ChapterPromptFile);
        var clientStatus = client.GetConfigurationStatus();
        var contextJson = BuildChapterContextJson(form);
        var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText);
        var paragraphCount = paragraphs.Count;

        try
        {
            if (string.IsNullOrWhiteSpace(form.ChapterText))
            {
                throw new InvalidOperationException("Enter fictional chapter text before running the chapter structure test.");
            }

            var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
            var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
            var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText);
            var result = await client.ExecutePromptAsync(
                completedPrompt,
                promptVersion,
                cancellationToken,
                clientStatus.ChapterStructureModel);
            var chapterJson = ExtractOutputText(result.RawResponseText);
            var parsedChapter = DeserializeChapter(chapterJson);
            var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphCount);
            var chapterForValidation = chapterNormalisation.ChapterStructure;
            var validation = validator.Validate(chapterForValidation, paragraphCount);
            ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, chapterNormalisation);

            model.Result = new ChapterStructureDryRunResultViewModel
            {
                Success = true,
                PromptVersion = promptVersion,
                Model = result.Model,
                ChapterContextJson = contextJson,
                ParagraphCount = paragraphCount,
                Duration = FormatDuration(result.Duration),
                RetryCount = result.RetryCount,
                InputTokens = result.InputTokens,
                OutputTokens = result.OutputTokens,
                RawResponseText = result.RawResponseText,
                ChapterJsonText = chapterJson,
                ParsedChapter = chapterForValidation,
                Validation = validation
            };

            logger.LogInformation(
                "Admin Chapter Structure dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}",
                promptVersion,
                result.Model,
                result.Duration.TotalMilliseconds,
                result.RetryCount,
                result.InputTokens,
                result.OutputTokens,
                validation.IsValid,
                validation.Warnings.Count,
                validation.Errors.Count,
                true);
        }
        catch (JsonException ex)
        {
            model.Result = new ChapterStructureDryRunResultViewModel
            {
                Success = false,
                PromptVersion = promptVersion,
                Model = clientStatus.ChapterStructureModel,
                ChapterContextJson = contextJson,
                ParagraphCount = paragraphCount,
                ErrorMessage = $"Chapter Structure JSON could not be deserialised: {ex.Message}",
                Validation = validator.Validate(null, paragraphCount)
            };

            logger.LogWarning(
                ex,
                "Admin Chapter Structure dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
                promptVersion,
                clientStatus.ChapterStructureModel,
                false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            model.Result = new ChapterStructureDryRunResultViewModel
            {
                Success = false,
                PromptVersion = promptVersion,
                Model = clientStatus.ChapterStructureModel,
                ChapterContextJson = contextJson,
                ParagraphCount = paragraphCount,
                ErrorMessage = ex.Message
            };

            logger.LogWarning(
                ex,
                "Admin Chapter Structure dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
                promptVersion,
                clientStatus.ChapterStructureModel,
                false);
        }

        return model;
    }

    private static string BuildChapterContextJson(ChapterStructureDryRunForm form)
        => JsonSerializer.Serialize(new
        {
            projectId = form.ProjectID,
            bookId = form.BookID,
            chapterId = form.ChapterID,
            chapterNumber = form.ChapterNumber,
            sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test chapter" : form.SourceLabel.Trim()
        }, JsonOptions);

    private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
    {
        if (string.IsNullOrWhiteSpace(promptTemplate))
        {
            throw new InvalidOperationException("Prompt template is empty.");
        }

        return promptTemplate
            .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)
            .Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal);
    }

    private static string ExtractOutputText(string rawResponseText)
    {
        var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
            ?? throw new JsonException("OpenAI response envelope was empty.");
        var outputText = response.Output?
            .SelectMany(item => item.Content ?? [])
            .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
                && !string.IsNullOrWhiteSpace(content.Text))
            ?.Text;

        if (string.IsNullOrWhiteSpace(outputText))
        {
            throw new JsonException("OpenAI response did not contain output_text content.");
        }

        return outputText.Trim();
    }

    private static ChapterStructureModel? DeserializeChapter(string chapterJson)
        => JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);

    private static string FormatDuration(TimeSpan duration)
        => duration.TotalSeconds < 1
            ? $"{duration.TotalMilliseconds:0} ms"
            : $"{duration.TotalSeconds:0.00} sec";
}

internal sealed class OpenAIResponseEnvelope
{
    public List<OpenAIResponseOutputItem>? Output { get; init; }
    public OpenAIResponseUsage? Usage { get; init; }
}

internal sealed class OpenAIResponseOutputItem
{
    public List<OpenAIResponseContentItem>? Content { get; init; }
}

internal sealed class OpenAIResponseContentItem
{
    public string? Type { get; init; }
    public string? Text { get; init; }
}

internal sealed class OpenAIResponseUsage
{
    [JsonPropertyName("input_tokens")]
    public int? InputTokens { get; init; }

    [JsonPropertyName("output_tokens")]
    public int? OutputTokens { get; init; }
}

public sealed class StoryIntelligenceClientConfigurationStatus
{
    public bool ApiKeyConfigured { get; init; }
    public string Model { get; init; } = string.Empty;
    public string ChapterStructureModel { get; init; } = string.Empty;
    public string SceneIntelligenceModel { get; init; } = string.Empty;
    public int MaxOutputTokens { get; init; }
    public int TimeoutSeconds { get; init; }
    public bool ClientConstructed { get; init; }
    public string ConnectionStatus { get; init; } = string.Empty;
}
