using Hangfire;
using QYZH.InteractiveMagazine.Infrastructure.OSS;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.WorkService.Consumers;
using SqlSugar;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace QYZH.InteractiveMagazine.WorkService.Jobs;

/// <summary>
/// 期刊任务AI批改定时任务。
/// </summary>
public class JournalTaskAiScoreJob(
    ILogger<JournalTaskAiScoreJob> logger,
    IConfiguration configuration,
    IServiceScopeFactory scopeFactory,
    IHttpClientFactory httpClientFactory,
    OssService ossService)
{
    private const int DefaultPendingAnswerMinutes = 30;
    private const int DefaultPendingAnswerBatchSize = 100;
    private const int DefaultAiScoreMaxRetryCount = 3;
    private const int DefaultAiScoreRetryDelayMilliseconds = 1000;
    private const int DefaultAiMaxConcurrency = 1;
    private const long DefaultMaxImageBytes = 10 * 1024 * 1024;
    private const string InvalidAnswerResult = "作答内容不符合要求";
    private const string AiProcessingMessage = "AI批阅中，请稍后";
    private static readonly object AiSemaphoreLock = new();
    private static readonly object RunWindowLock = new();
    private static SemaphoreSlim? aiSemaphore;
    private static int aiSemaphoreLimit;
    private static DateTime? lastRunAt;

    /// <summary>
    /// 执行AI批改。
    /// </summary>
    [DisableConcurrentExecution(1800)]
    public void Execute()
    {
        ExecuteAsync().GetAwaiter().GetResult();
    }

    /// <summary>
    /// 异步执行AI批改。
    /// </summary>
    public async Task ExecuteAsync()
    {
        using var scope = scopeFactory.CreateScope();
        var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
        var windowEnd = DateTime.Now;
        var windowStart = GetWindowStart(windowEnd);
        var batchSize = GetPendingAnswerBatchSize();

        var pendingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
            .Where(a => !a.IsDeleted
                        && a.Status == (int)UserAnswerStatusEnum.Processing
                        && ((a.UpdatedAt != null && a.UpdatedAt > windowStart && a.UpdatedAt <= windowEnd)
                            || (a.UpdatedAt == null && a.CreatedAt > windowStart && a.CreatedAt <= windowEnd)))
            .OrderBy(a => a.UpdatedAt, OrderByType.Asc)
            .OrderBy(a => a.CreatedAt, OrderByType.Asc)
            .Take(batchSize)
            .ToListAsync();

        if (pendingAnswers.Count == 0)
        {
            SetLastRunAt(windowEnd);
            logger.LogInformation("期刊AI批改任务没有待处理答案，WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", windowStart, windowEnd);
            return;
        }

        logger.LogInformation("期刊AI批改任务开始，Count: {Count}, WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", pendingAnswers.Count, windowStart, windowEnd);
        foreach (var pendingAnswer in pendingAnswers)
        {
            try
            {
                await ProcessPendingAnswerAsync(client, pendingAnswer.Id, windowStart, windowEnd, CancellationToken.None);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "期刊AI批改答案失败，AnswerId: {AnswerId}", pendingAnswer.Id);
            }
        }

        SetLastRunAt(windowEnd);
    }

    private async Task ProcessPendingAnswerAsync(
        ISqlSugarClient client,
        long answerId,
        DateTime windowStart,
        DateTime windowEnd,
        CancellationToken cancellationToken)
    {
        var answer = await client.Queryable<JournalPageTaskUserAnswer>()
            .Where(a => a.Id == answerId && !a.IsDeleted)
            .FirstAsync(cancellationToken);
        var answerTime = answer == null ? default : GetPendingTime(answer);
        if (answer == null
            || answer.Status != (int)UserAnswerStatusEnum.Processing
            || answerTime <= windowStart
            || answerTime > windowEnd)
        {
            return;
        }

        var task = await client.Queryable<JournalPageTask>()
            .Where(t => t.Id == answer.JournalPageTaskId && !t.IsDeleted)
            .FirstAsync(cancellationToken);
        if (task == null)
        {
            logger.LogWarning("期刊AI批改未找到任务，AnswerId: {AnswerId}, TaskId: {TaskId}", answer.Id, answer.JournalPageTaskId);
            return;
        }

        if (!task.NeedAiProcess)
        {
            logger.LogInformation("期刊任务配置为人工批改，跳过AI批改，AnswerId: {AnswerId}, TaskId: {TaskId}", answer.Id, task.Id);
            return;
        }

        var originalUpdatedAt = answer.UpdatedAt;
        var originalAnswerUrl = answer.AnswerUrl ?? string.Empty;
        try
        {
            var scoreUnit = await BuildScoreUnitAsync(client, answer, task, cancellationToken);
            var referenceAnswerMap = await BuildReferenceAnswerMapAsync(client, scoreUnit, cancellationToken);
            var answerQuestion = BuildQuestionFromAnswer(answer);
            var scoreResult = await ScoreQuestionAsync(scoreUnit, answerQuestion, referenceAnswerMap, cancellationToken);
            var normalizedResult = NormalizeScoreResult(scoreResult, task);
            await CompleteAnswerAsync(client, answer.Id, originalUpdatedAt, originalAnswerUrl, normalizedResult, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "期刊AI批改调用失败，保持处理中等待下次重试，AnswerId: {AnswerId}", answer.Id);
            await client.Updateable<JournalPageTaskUserAnswer>()
                .SetColumns(a => new JournalPageTaskUserAnswer
                {
                    Result = AiProcessingMessage,
                    UpdatedBy = "AI",
                    UpdatedAt = DateTime.Now
                })
                .Where(a => a.Id == answer.Id && a.Status == (int)UserAnswerStatusEnum.Processing)
                .ExecuteCommandAsync(cancellationToken);
        }
    }

    private async Task CompleteAnswerAsync(
        ISqlSugarClient client,
        long answerId,
        DateTime? originalUpdatedAt,
        string originalAnswerUrl,
        JournalAnswerScoreResult scoreResult,
        CancellationToken cancellationToken)
    {
        client.Ado.BeginTran();
        try
        {
            var latest = await client.Queryable<JournalPageTaskUserAnswer>()
                .Where(a => a.Id == answerId && !a.IsDeleted)
                .FirstAsync(cancellationToken);
            if (latest == null
                || latest.Status != (int)UserAnswerStatusEnum.Processing
                || latest.UpdatedAt != originalUpdatedAt
                || !string.Equals(latest.AnswerUrl ?? string.Empty, originalAnswerUrl, StringComparison.Ordinal))
            {
                client.Ado.CommitTran();
                logger.LogInformation("期刊AI批改结果已过期，跳过写入，AnswerId: {AnswerId}", answerId);
                return;
            }

            await client.Insertable(BuildAnswerSnapshot(latest)).ExecuteCommandAsync(cancellationToken);

            latest.Result = scoreResult.Result;
            latest.Points = Math.Max(0, scoreResult.Points);
            latest.GrowthPoints = Math.Max(0, scoreResult.GrowthPoint);
            latest.Score = Math.Max(0, scoreResult.Score);
            latest.Comprehension = Math.Max(0, scoreResult.Comprehension);
            latest.Judgment = Math.Max(0, scoreResult.Judgment);
            latest.Expression = Math.Max(0, scoreResult.Expression);
            latest.Persuasiveness = Math.Max(0, scoreResult.Persuasiveness);
            latest.Status = (int)UserAnswerStatusEnum.Complete;
            latest.UpdatedBy = "AI";
            latest.UpdatedAt = DateTime.Now;

            await client.Updateable(latest)
                .IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt })
                .Where(a => a.Id == latest.Id)
                .ExecuteCommandAsync(cancellationToken);

            client.Ado.CommitTran();
            logger.LogInformation("期刊AI批改完成，AnswerId: {AnswerId}, Score: {Score}", latest.Id, latest.Score);
        }
        catch
        {
            client.Ado.RollbackTran();
            throw;
        }
    }

    private async Task<JournalScoreUnit> BuildScoreUnitAsync(
        ISqlSugarClient client,
        JournalPageTaskUserAnswer answer,
        JournalPageTask task,
        CancellationToken cancellationToken)
    {
        var groupId = task.GroupId > 0 ? task.GroupId : task.Id;
        var groupTasks = task.GroupId > 0
            ? await client.Queryable<JournalPageTask>()
                .Where(t => t.GroupId == task.GroupId && !t.IsDeleted)
                .ToListAsync(cancellationToken)
            : [task];
        var pageIds = groupTasks.Select(t => t.JournalPageId).Distinct().ToList();
        var pages = await client.Queryable<JournalPage>()
            .Where(p => pageIds.Contains(p.Id) && !p.IsDeleted)
            .ToListAsync(cancellationToken);
        var pageMap = pages.ToDictionary(p => p.Id);
        var question = BuildQuestionFromAnswer(answer);
        var contexts = groupTasks
            .OrderBy(t => pageMap.TryGetValue(t.JournalPageId, out var page) ? page.PageNum : 0)
            .ThenBy(t => ParseTaskNo(t.No))
            .ThenBy(t => t.Id)
            .Select(t =>
            {
                pageMap.TryGetValue(t.JournalPageId, out var page);
                return new JournalQuestionContext(question, t, page);
            })
            .ToList();

        return new JournalScoreUnit(groupId, contexts);
    }

    private static async Task<Dictionary<long, List<JournalPageTaskAnswer>>> BuildReferenceAnswerMapAsync(
        ISqlSugarClient client,
        JournalScoreUnit scoreUnit,
        CancellationToken cancellationToken)
    {
        var taskIds = scoreUnit.Questions.Select(q => q.Task.Id).Distinct().ToList();
        var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
            .Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
            .ToListAsync(cancellationToken);
        return referenceAnswers
            .GroupBy(a => a.JournalPageTaskId)
            .ToDictionary(g => g.Key, g => g.ToList());
    }

    private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
        JournalScoreUnit scoreUnit,
        Question answerQuestion,
        Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap,
        CancellationToken cancellationToken)
    {
        var apiKey = configuration["AiChat:ApiKey"];
        var baseUrl = configuration["AiChat:BaseUrl"];
        var model = configuration["AiChat:Model"];
        var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
        var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
        var temperature = configuration.GetValue<double?>("AiChat:Temperature");

        if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model))
        {
            throw new InvalidOperationException("AI聊天服务配置不完整，请检查 AiChat 配置节点");
        }

        var content = new List<object>
        {
            new
            {
                type = "text",
                text = BuildScorePrompt(scoreUnit, referenceAnswerMap)
            }
        };

        var answerImages = await BuildAnswerImageContentsAsync(answerQuestion, cancellationToken);
        if (answerImages.Count == 0)
        {
            throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片");
        }

        content.Add(new
        {
            type = "text",
            text = $"以下是学生作答图片，共 {answerImages.Count} 张。"
        });
        foreach (var answerImage in answerImages)
        {
            content.Add(new
            {
                type = "image_url",
                image_url = new { url = answerImage.DataUrl }
            });
        }

        foreach (var context in scoreUnit.Questions)
        {
            referenceAnswerMap.TryGetValue(context.Task.Id, out var referenceAnswers);
            var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(context.Task.Id, referenceAnswers ?? [], cancellationToken);
            if (referenceAnswerImages.Count == 0)
            {
                continue;
            }

            content.Add(new
            {
                type = "text",
                text = $"以下为任务 {context.Task.Id} 的参考答案图片，共 {referenceAnswerImages.Count} 张。"
            });
            foreach (var referenceAnswerImage in referenceAnswerImages)
            {
                content.Add(new
                {
                    type = "image_url",
                    image_url = new { url = referenceAnswerImage.DataUrl }
                });
            }
        }

        var requestBody = new
        {
            model,
            messages = new object[]
            {
                new
                {
                    role = "system",
                    content = "你是专业的学生作答评分助手。必须只返回合法 JSON，不要返回 Markdown、解释或代码块。"
                },
                new
                {
                    role = "user",
                    content
                }
            },
            max_tokens = maxTokens > 0 ? maxTokens : 2000,
            temperature = temperature is >= 0 ? temperature.Value : 0.1,
            stream = false,
            response_format = new { type = "json_object" }
        };

        var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
        {
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
        });

        var semaphore = GetAiSemaphore();
        await semaphore.WaitAsync(cancellationToken);
        try
        {
            var scoreResult = await SendAiScoreRequestWithRetryAsync(
                scoreUnit.GroupId,
                $"{baseUrl.TrimEnd('/')}/chat/completions",
                apiKey,
                requestJson,
                timeoutSeconds > 0 ? timeoutSeconds : 300,
                cancellationToken);
            scoreResult.Result = TrimResult(scoreResult.Result);
            return scoreResult;
        }
        finally
        {
            semaphore.Release();
        }
    }

    private static string BuildScorePrompt(
        JournalScoreUnit scoreUnit,
        Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap)
    {
        var referenceAnswerTexts = scoreUnit.Questions
            .SelectMany(q =>
            {
                referenceAnswerMap.TryGetValue(q.Task.Id, out var answers);
                return answers ?? [];
            })
            .Select(a => a.Answer?.Trim())
            .Where(a => !string.IsNullOrWhiteSpace(a))
            .Distinct()
            .ToList();

        var prompt = new StringBuilder();
        prompt.AppendLine(scoreUnit.Questions.Count > 1
            ? "这是同一跨页题目的多页作答，请综合全部学生作答图片评分。"
            : "这是单页题目作答，请根据学生作答图片评分。");
        prompt.AppendLine("以题目 Prompt 和学生答案为准评分；若有参考答案请结合参考答案。");
        if (referenceAnswerTexts.Count > 0)
        {
            prompt.AppendLine("参考答案文本：");
            for (var i = 0; i < referenceAnswerTexts.Count; i++)
            {
                prompt.AppendLine($"{i + 1}. {referenceAnswerTexts[i]}");
            }
        }

        prompt.AppendLine();
        prompt.AppendLine("题目信息：");
        foreach (var context in scoreUnit.Questions)
        {
            prompt.AppendLine($"  题目内容：{context.Task.Task}");
            prompt.AppendLine($"  评分Prompt：{context.Task.Prompt}");
            prompt.AppendLine($"  成长值上限：{context.Task.GrowthPoint}");
            prompt.AppendLine($"  积分上限：{context.Task.Points}");
            prompt.AppendLine($"  理解力上限：{context.Task.Comprehension}");
            prompt.AppendLine($"  判断力上限：{context.Task.Judgment}");
            prompt.AppendLine($"  表达力上限：{context.Task.Expression}");
            prompt.AppendLine($"  说服力上限：{context.Task.Persuasiveness}");
        }

        prompt.AppendLine();
        prompt.AppendLine("评分要求：");
        prompt.AppendLine("- 不得超过题目配置中的各项上限。");
        prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时，降低 Completion，不要猜测高分。");
        prompt.AppendLine("- Completion 表示作答完整度，范围 0-100。");
        prompt.AppendLine("- Result 返回 50 字内中文评语。");
        prompt.AppendLine("若作答内容与题目要求不符、答非所问、空白、仅抄题或无法形成有效答案，Score/GrowthPoint/Points/Comprehension/Judgment/Expression/Persuasiveness 均返回 0。");
        prompt.AppendLine("若作答内容不符合要求，Result 必须且只能返回：作答内容不符合要求。不要补充原因、建议或其他文字。");
        prompt.AppendLine();
        prompt.AppendLine("只返回如下 JSON 字段：");
        prompt.AppendLine("{");
        prompt.AppendLine("  \"Score\": 0,");
        prompt.AppendLine("  \"GrowthPoint\": 0,");
        prompt.AppendLine("  \"Points\": 0,");
        prompt.AppendLine("  \"Comprehension\": 0,");
        prompt.AppendLine("  \"Judgment\": 0,");
        prompt.AppendLine("  \"Expression\": 0,");
        prompt.AppendLine("  \"Persuasiveness\": 0,");
        prompt.AppendLine("  \"Completion\": 100,");
        prompt.AppendLine("  \"Result\": \"50字内的中文评语\"");
        prompt.AppendLine("}");

        return prompt.ToString();
    }

    private async Task<List<AnswerImageContent>> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken)
    {
        var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? [];
        var result = new List<AnswerImageContent>();
        foreach (var imageUrl in imageUrls)
        {
            var imageContent = await BuildImageContentAsync(imageUrl, question.Id, "答案图片", true, cancellationToken);
            if (imageContent != null)
            {
                result.Add(imageContent);
            }
        }

        return result;
    }

    private async Task<List<AnswerImageContent>> BuildReferenceAnswerImageContentsAsync(
        long taskId,
        List<JournalPageTaskAnswer> referenceAnswers,
        CancellationToken cancellationToken)
    {
        var imageUrls = referenceAnswers
            .Select(a => a.AnswerUrL)
            .Where(url => !string.IsNullOrWhiteSpace(url))
            .Distinct()
            .Select(url => url!)
            .ToList();
        var result = new List<AnswerImageContent>();
        foreach (var imageUrl in imageUrls)
        {
            var imageContent = await BuildImageContentAsync(imageUrl, taskId, "参考答案图片", false, cancellationToken);
            if (imageContent != null)
            {
                result.Add(imageContent);
            }
        }

        return result;
    }

    private async Task<AnswerImageContent?> BuildImageContentAsync(
        string imageUrl,
        long taskId,
        string imageRole,
        bool required,
        CancellationToken cancellationToken)
    {
        try
        {
            cancellationToken.ThrowIfCancellationRequested();
            await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken);
            if (imageStream == null)
            {
                throw new InvalidOperationException($"{imageRole}读取失败，TaskId: {taskId}, Url: {imageUrl}");
            }

            using var memoryStream = new MemoryStream();
            await imageStream.CopyToAsync(memoryStream, cancellationToken);
            if (memoryStream.Length == 0)
            {
                throw new InvalidOperationException($"{imageRole}内容为空，TaskId: {taskId}, Url: {imageUrl}");
            }

            var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
            if (maxImageBytes <= 0)
            {
                maxImageBytes = DefaultMaxImageBytes;
            }

            if (memoryStream.Length > maxImageBytes)
            {
                throw new InvalidOperationException($"{imageRole}超过大小限制，TaskId: {taskId}, Url: {imageUrl}, Size: {memoryStream.Length}");
            }

            var imageBytes = memoryStream.ToArray();
            var mimeType = GetImageMimeType(imageUrl, imageBytes);
            return new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}");
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex) when (!required)
        {
            logger.LogWarning(ex, "{ImageRole}读取失败，已跳过，TaskId: {TaskId}, Url: {Url}", imageRole, taskId, imageUrl);
            return null;
        }
    }

    private async Task<Stream?> GetImageStreamAsync(string imageUrl, CancellationToken cancellationToken)
    {
        if (imageUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
            imageUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
        {
            var httpClient = httpClientFactory.CreateClient();
            return await httpClient.GetStreamAsync(imageUrl, cancellationToken);
        }

        return ossService.GetObjectStream(imageUrl);
    }

    private async Task<JournalAnswerScoreResult> SendAiScoreRequestWithRetryAsync(
        long taskId,
        string requestUrl,
        string apiKey,
        string requestJson,
        int timeoutSeconds,
        CancellationToken cancellationToken)
    {
        var maxRetryCount = configuration.GetValue<int>("AiChat:ScoreMaxRetryCount");
        if (maxRetryCount <= 0)
        {
            maxRetryCount = DefaultAiScoreMaxRetryCount;
        }

        var retryDelayMilliseconds = configuration.GetValue<int>("AiChat:ScoreRetryDelayMilliseconds");
        if (retryDelayMilliseconds <= 0)
        {
            retryDelayMilliseconds = DefaultAiScoreRetryDelayMilliseconds;
        }

        Exception? lastException = null;
        for (var attempt = 1; attempt <= maxRetryCount; attempt++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            try
            {
                var httpClient = httpClientFactory.CreateClient();
                httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds);

                using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
                request.Content = new StringContent(requestJson, Encoding.UTF8, "application/json");

                using var response = await httpClient.SendAsync(request, cancellationToken);
                var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
                if (response.IsSuccessStatusCode)
                {
                    var resultJson = ExtractAssistantContent(responseContent);
                    return ParseScoreResult(resultJson);
                }

                logger.LogWarning("AI评分调用失败，TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}",
                    taskId, attempt, maxRetryCount, response.StatusCode, responseContent);
                if (!ShouldRetry(response.StatusCode) || attempt == maxRetryCount)
                {
                    throw new InvalidOperationException($"AI评分调用失败：{response.StatusCode}，响应：{responseContent}");
                }
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (NonRetryAiScoreException)
            {
                throw;
            }
            catch (Exception ex) when (attempt < maxRetryCount)
            {
                lastException = ex;
                logger.LogWarning(ex, "AI评分调用异常，准备重试，TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
            }

            await Task.Delay(TimeSpan.FromMilliseconds(retryDelayMilliseconds * attempt), cancellationToken);
        }

        throw new InvalidOperationException($"AI评分调用失败，TaskId: {taskId}", lastException);
    }

    private static bool ShouldRetry(System.Net.HttpStatusCode statusCode)
    {
        var status = (int)statusCode;
        return status == 408 || status == 429 || status >= 500;
    }

    private static string GetImageMimeType(string imageUrl, byte[] imageBytes)
    {
        if (imageBytes.Length >= 4)
        {
            if (imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47)
            {
                return "image/png";
            }

            if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8)
            {
                return "image/jpeg";
            }

            if (imageBytes[0] == 0x47 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46)
            {
                return "image/gif";
            }

            if (imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46)
            {
                return "image/webp";
            }
        }

        return Path.GetExtension(imageUrl).ToLowerInvariant() switch
        {
            ".png" => "image/png",
            ".jpg" or ".jpeg" => "image/jpeg",
            ".gif" => "image/gif",
            ".webp" => "image/webp",
            _ => "image/jpeg"
        };
    }

    private static string ExtractAssistantContent(string responseContent)
    {
        using var document = JsonDocument.Parse(responseContent);
        var message = document.RootElement.GetProperty("choices")[0].GetProperty("message");
        if (!message.TryGetProperty("content", out var contentElement))
        {
            throw new NonRetryAiScoreException("AI评分响应缺少 content");
        }

        return contentElement.ValueKind == JsonValueKind.String
            ? contentElement.GetString() ?? string.Empty
            : contentElement.GetRawText();
    }

    private static JournalAnswerScoreResult ParseScoreResult(string resultJson)
    {
        var cleanedJson = CleanJsonContent(resultJson);
        EnsureRequiredScoreFields(cleanedJson);
        var result = JsonSerializer.Deserialize<JournalAnswerScoreResult>(cleanedJson, new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });

        return result ?? throw new NonRetryAiScoreException("AI评分结果解析失败");
    }

    private static string CleanJsonContent(string content)
    {
        var text = content.Trim();
        if (text.StartsWith("```", StringComparison.Ordinal))
        {
            var firstLineEnd = text.IndexOf('\n');
            if (firstLineEnd >= 0)
            {
                text = text[(firstLineEnd + 1)..];
            }

            var fenceIndex = text.LastIndexOf("```", StringComparison.Ordinal);
            if (fenceIndex >= 0)
            {
                text = text[..fenceIndex];
            }
        }

        var start = text.IndexOf('{');
        var end = text.LastIndexOf('}');
        if (start >= 0 && end > start)
        {
            text = text[start..(end + 1)];
        }

        return text.Trim();
    }

    private static void EnsureRequiredScoreFields(string json)
    {
        using var document = JsonDocument.Parse(json);
        var requiredFields = new[]
        {
            nameof(JournalAnswerScoreResult.Score),
            nameof(JournalAnswerScoreResult.GrowthPoint),
            nameof(JournalAnswerScoreResult.Points),
            nameof(JournalAnswerScoreResult.Comprehension),
            nameof(JournalAnswerScoreResult.Judgment),
            nameof(JournalAnswerScoreResult.Expression),
            nameof(JournalAnswerScoreResult.Persuasiveness),
            nameof(JournalAnswerScoreResult.Completion),
            nameof(JournalAnswerScoreResult.Result)
        };

        foreach (var field in requiredFields)
        {
            if (!document.RootElement.EnumerateObject().Any(p => string.Equals(p.Name, field, StringComparison.OrdinalIgnoreCase)))
            {
                throw new NonRetryAiScoreException($"AI评分结果缺少字段：{field}");
            }
        }
    }

    private static JournalAnswerScoreResult NormalizeScoreResult(JournalAnswerScoreResult scoreResult, JournalPageTask task)
    {
        var scoreMax = task.Comprehension + task.Judgment + task.Expression + task.Persuasiveness;
        if (scoreMax <= 0)
        {
            scoreMax = 100;
        }

        return new JournalAnswerScoreResult
        {
            Score = Clamp(scoreResult.Score, 0, scoreMax),
            GrowthPoint = (int)Clamp(scoreResult.GrowthPoint, 0, task.GrowthPoint),
            Points = (int)Clamp(scoreResult.Points, 0, task.Points),
            Comprehension = Clamp(scoreResult.Comprehension, 0, task.Comprehension),
            Judgment = Clamp(scoreResult.Judgment, 0, task.Judgment),
            Expression = Clamp(scoreResult.Expression, 0, task.Expression),
            Persuasiveness = Clamp(scoreResult.Persuasiveness, 0, task.Persuasiveness),
            Completion = Clamp(scoreResult.Completion, 0, 100),
            Result = NormalizeResult(scoreResult.Result)
        };
    }

    private static string NormalizeResult(string? result)
    {
        var trimmedResult = TrimResult(result);
        if (trimmedResult.Contains("不符合要求", StringComparison.Ordinal) ||
            trimmedResult.Contains("不符", StringComparison.Ordinal))
        {
            return InvalidAnswerResult;
        }

        return trimmedResult;
    }

    private static float Clamp(float value, float min, float max)
    {
        if (float.IsNaN(value) || float.IsInfinity(value))
        {
            return min;
        }

        if (max < min)
        {
            max = min;
        }

        return Math.Min(Math.Max(value, min), max);
    }

    private static Question BuildQuestionFromAnswer(JournalPageTaskUserAnswer answer)
    {
        return new Question
        {
            Id = answer.JournalPageTaskId,
            Url = answer.QuestionAnswerUrl ?? string.Empty,
            AnswerUrl = DeserializeStringArray(answer.AnswerUrl),
            AnswerStartTime = answer.AnswerStartTime,
            AnswerEndTime = answer.AnswerEndTime,
            AnswerTime = answer.AnswerSeconds,
            BreakCount = answer.BreakCount,
            BreakTimes = DeserializeBreakTimes(answer.BreakTimes)
        };
    }

    private static string[] DeserializeStringArray(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return [];
        }

        try
        {
            return JsonSerializer.Deserialize<string[]>(json) ?? [];
        }
        catch
        {
            return [];
        }
    }

    private static List<BreakTime> DeserializeBreakTimes(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return [];
        }

        try
        {
            return JsonSerializer.Deserialize<List<BreakTime>>(json) ?? [];
        }
        catch
        {
            return [];
        }
    }

    private static JournalPageTaskUserAnswerSnapshot BuildAnswerSnapshot(JournalPageTaskUserAnswer answer)
    {
        var now = DateTime.Now;
        return new JournalPageTaskUserAnswerSnapshot
        {
            JournalPageTaskUserAnswerId = answer.Id,
            JournalId = answer.JournalId,
            JournalPageId = answer.JournalPageId,
            JournalPageTaskId = answer.JournalPageTaskId,
            JournalPageTaskGroupId = answer.JournalPageTaskGroupId,
            UserId = answer.UserId,
            Result = answer.Result,
            Points = answer.Points,
            GrowthPoints = answer.GrowthPoints,
            Score = answer.Score,
            Comprehension = answer.Comprehension,
            Judgment = answer.Judgment,
            Expression = answer.Expression,
            Persuasiveness = answer.Persuasiveness,
            QuestionAnswerUrl = answer.QuestionAnswerUrl,
            AnswerUrl = answer.AnswerUrl,
            PageAnswerUrl = answer.PageAnswerUrl,
            Revision = answer.Revision,
            AnswerStartTime = answer.AnswerStartTime,
            AnswerEndTime = answer.AnswerEndTime,
            AnswerSeconds = answer.AnswerSeconds,
            ImageRecognition = answer.ImageRecognition,
            JournalPageNum = answer.JournalPageNum,
            Modify = answer.Modify,
            LastTag = answer.LastTag,
            DotPageNum = answer.DotPageNum,
            PageResultUrl = answer.PageResultUrl,
            Type = answer.Type,
            DotPageNo = answer.DotPageNo,
            PageAnswerDotUrl = answer.PageAnswerDotUrl,
            BreakCount = answer.BreakCount,
            BreakTimes = answer.BreakTimes,
            AssignmentStatus = answer.AssignmentStatus,
            Status = answer.Status,
            CreatedBy = answer.UpdatedBy ?? answer.CreatedBy ?? string.Empty,
            CreatedAt = now,
            UpdatedBy = answer.UpdatedBy ?? string.Empty,
            UpdatedAt = now
        };
    }

    private SemaphoreSlim GetAiSemaphore()
    {
        var maxConcurrency = configuration.GetValue<int>("AiChat:MaxConcurrency");
        if (maxConcurrency <= 0)
        {
            maxConcurrency = DefaultAiMaxConcurrency;
        }

        lock (AiSemaphoreLock)
        {
            if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency)
            {
                aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
                aiSemaphoreLimit = maxConcurrency;
            }

            return aiSemaphore;
        }
    }

    private int GetPendingAnswerMinutes()
    {
        var minutes = configuration.GetValue<int>("AiChat:PendingAnswerMinutes");
        return minutes > 0 ? minutes : DefaultPendingAnswerMinutes;
    }

    private int GetPendingAnswerBatchSize()
    {
        var batchSize = configuration.GetValue<int>("AiChat:PendingAnswerBatchSize");
        return batchSize > 0 ? batchSize : DefaultPendingAnswerBatchSize;
    }

    private DateTime GetWindowStart(DateTime windowEnd)
    {
        lock (RunWindowLock)
        {
            return lastRunAt ?? windowEnd.AddMinutes(-GetPendingAnswerMinutes());
        }
    }

    private static void SetLastRunAt(DateTime runAt)
    {
        lock (RunWindowLock)
        {
            lastRunAt = runAt;
        }
    }

    private static DateTime GetPendingTime(JournalPageTaskUserAnswer answer)
    {
        return answer.UpdatedAt ?? answer.CreatedAt;
    }

    private static int ParseTaskNo(string? taskNo)
    {
        return int.TryParse(taskNo, out var no) ? no : int.MaxValue;
    }

    private static string TrimResult(string? result)
    {
        if (string.IsNullOrWhiteSpace(result))
        {
            return string.Empty;
        }

        return result.Length <= 50 ? result : result[..50];
    }
}
