package cn.yunzhixue.ability.center.examsprint.application.report;

import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportGenerationStatus;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
import cn.yunzhixue.ability.center.kernel.BusinessException;
import cn.yunzhixue.ability.center.kernel.ErrorCode;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;

import java.lang.reflect.RecordComponent;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportApplicationServiceTest {

    private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-02T00:00:00Z"), ZoneOffset.UTC);
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();

    /** 覆盖公开响应合约字段收敛场景，当检查下载响应 record 时，应只暴露 downloadUrl 而不再暴露预览地址。 */
    @Test
    void publicReportContractsExposeDownloadUrlOnly() {
        assertReportRecordUsesDownloadUrlOnly(CreateExamSprintReportWithUrlResponse.class);
        assertReportRecordUsesDownloadUrlOnly(ExamSprintReportDetailResponse.class);
    }

    /** 覆盖应用服务接口方法收敛场景，当反射检查接口声明时，应不存在已移除的 HTML 预览方法。 */
    @Test
    void applicationServiceDoesNotDeclareHtmlPreviewMethod() {
        assertThat(Arrays.stream(ExamSprintReportApplicationService.class.getDeclaredMethods())
                        .map(method -> method.getName())
                        .toList())
                .doesNotContain(removedHtmlPreviewMethodName());
    }

    /** 覆盖创建展望报告的上游词汇报文场景，当提交有效 payload 时，应保存 OUTLOOK 报告并归一化 StudentName/studentName。 */
    @Test
    void createOutlookReportStoresOutlookTypeAndReturnsReportId() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        var response = service.createOutlookReport(validOutlookPayload());

        assertThat(response.reportId()).isNotBlank();
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
        UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
        JsonNode savedPayload = (JsonNode) content.source();
        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
        assertThat(savedPayload.path("StageVocabulary").asInt()).isEqualTo(10);
    }

    /** 覆盖调用方直接提交上游词汇报文场景，当 payload 有完整 StudentWordsLatest 时，应创建并分发 OUTLOOK 报告。 */
    @Test
    void createOutlookReportAcceptsCallerVocabularyPayloadAndDispatches() throws Exception {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        List<String> dispatchedReportIds = new ArrayList<>();
        DefaultExamSprintReportApplicationService service = service(repository, dispatchedReportIds::add, storage);

        var response = service.createOutlookReport(callerVocabularyPayload());

        assertThat(response.reportId()).isNotBlank();
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
        assertThat(dispatchedReportIds).containsExactly(response.reportId());
    }

    /** 覆盖 mixed-field 展望报文场景，当 StudentName 为空白或 null 且 studentName 有效时，应允许通过并归一化到 lowercase 值。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("mixedOutlookPayloadsWithInvalidCanonicalStudentName")
    void createOutlookReportNormalizesLowercaseStudentNameWhenCanonicalStudentNameIsInvalid(
            String caseName,
            Consumer<ObjectNode> mutatePayload) throws Exception {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
        ObjectNode payload = callerVocabularyPayload().deepCopy();
        payload.put("studentName", "冯亿豪");
        mutatePayload.accept(payload);

        var response = service.createOutlookReport(payload);

        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) saved.content()).source();
        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("冯亿豪");
        assertThat(savedPayload.path("studentName").asText()).isEqualTo("冯亿豪");
    }

    /** 覆盖 canonical StudentName 类型边界场景，当 StudentName 为非 textual 且无有效 lowercase fallback 时，应在保存前校验失败。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidCanonicalStudentNameJsonTypes")
    void createOutlookReportRejectsNonTextualCanonicalStudentNameWithoutValidLowercaseFallback(
            String caseName,
            Consumer<ObjectNode> mutatePayload) throws Exception {
        ObjectNode payload = callerVocabularyPayload().deepCopy();
        mutatePayload.accept(payload);

        assertCreateOutlookReportRejectsInvalidPayload(payload);
    }

    /** 覆盖 lowercase studentName 类型边界场景，当它是唯一可用来源但为非 textual 时，应在保存前校验失败。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidLowercaseStudentNameJsonTypes")
    void createOutlookReportRejectsNonTextualLowercaseStudentNameWhenItIsOnlyAvailableSource(
            String caseName,
            Consumer<ObjectNode> mutatePayload) throws Exception {
        ObjectNode payload = callerVocabularyPayload().deepCopy();
        payload.putNull("StudentName");
        mutatePayload.accept(payload);

        assertCreateOutlookReportRejectsInvalidPayload(payload);
    }

    /** 覆盖 canonical 优先场景，当 StudentName 有效而 lowercase studentName 为非 textual 时，应继续使用 canonical 值。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidLowercaseStudentNameJsonTypes")
    void createOutlookReportUsesCanonicalStudentNameWhenLowercaseStudentNameIsNonTextual(
            String caseName,
            Consumer<ObjectNode> mutatePayload) throws Exception {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
        ObjectNode payload = callerVocabularyPayload().deepCopy();
        mutatePayload.accept(payload);

        var response = service.createOutlookReport(payload);

        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) repository.findById(response.reportId()).orElseThrow().content()).source();
        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
    }

    /** 覆盖非上游词汇展望报文被移除的场景，当只提交非官方结构时，应在保存前校验失败。 */
    @Test
    void createOutlookReportRejectsNonContractPayloadBeforeSaving() throws Exception {
        assertCreateOutlookReportRejectsInvalidPayload(nonContractOutlookPayload());
    }

    /** 覆盖展望报告参数诊断场景，当 StudentName 缺失时，应抛出包含报告类型和字段名的中文校验消息。 */
    @Test
    void createOutlookReportRejectsInvalidPayloadWithChineseFieldMessage() throws Exception {
        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
        invalidPayload.remove("StudentName");

        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
                        .createOutlookReport(invalidPayload))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("展望报告参数校验失败")
                .hasMessageContaining("StudentName")
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    /** 覆盖展望报告 JSON shape 校验场景，当 StudentStage 类型错误时，应返回字段级中文类型说明。 */
    @Test
    void createOutlookReportRejectsStudentStageTypeMismatchWithChineseFieldMessage() throws Exception {
        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
        invalidPayload.put("StudentStage", "2");

        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
                        .createOutlookReport(invalidPayload))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("展望报告参数校验失败")
                .hasMessageContaining("StudentStage")
                .hasMessageContaining("必须为数字")
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    /** 覆盖展望报告嵌套列表 Bean Validation 路径映射场景，应暴露请求 JSON 字段名并保留索引。 */
    @Test
    void createOutlookReportRejectsNestedStudentWordsLatestViolationWithApiFieldPath() throws Exception {
        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
        ((ObjectNode) invalidPayload.withArray("StudentWordsLatest").get(0)).put("WordFrequency", 0);

        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
                        .createOutlookReport(invalidPayload))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("展望报告参数校验失败")
                .hasMessageContaining("StudentWordsLatest[0].WordFrequency")
                .hasMessageContaining("必须大于等于 1")
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    /** 覆盖展望报告直接列表元素约束路径映射场景，应隐藏 Bean Validation 内部 list element 片段。 */
    @Test
    void createOutlookReportRejectsDirectListElementViolationWithCleanApiFieldPath() throws Exception {
        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
        invalidPayload.withArray("TestPaperWordIdArray").removeAll();
        invalidPayload.withArray("TestPaperWordIdArray").add(-1);

        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
                        .createOutlookReport(invalidPayload))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("展望报告参数校验失败")
                .hasMessageContaining("TestPaperWordIdArray[0]")
                .hasMessageNotContaining("<list element>")
                .hasMessageContaining("必须大于等于 0")
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    /** 覆盖上游词汇报文允许无已掌握真题词的场景，当 TestPaperMastedWords 为空且计数为 0 时，应正常创建并分发。 */
    @Test
    void createOutlookReportAcceptsCallerVocabularyPayloadWithNoMasteredPaperWords() throws Exception {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        List<String> dispatchedReportIds = new ArrayList<>();
        DefaultExamSprintReportApplicationService service = service(repository, dispatchedReportIds::add, storage);
        ObjectNode payload = callerVocabularyPayload().deepCopy();
        payload.putArray("TestPaperMastedWords");
        payload.put("TestPaperMastedWordCount", 0);

        var response = service.createOutlookReport(payload);

        assertThat(response.reportId()).isNotBlank();
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
        assertThat(dispatchedReportIds).containsExactly(response.reportId());
    }

    /** 覆盖上游词汇报文缺少词汇明细的场景，当 StudentWordsLatest 缺失时，应在保存前校验失败。 */
    @Test
    void createOutlookReportRejectsCallerVocabularyPayloadWithoutStudentWordsLatest() throws Exception {
        ObjectNode invalidPayload = callerVocabularyPayload();
        invalidPayload.remove("StudentWordsLatest");

        assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖上游词汇报文明细含 null 元素的场景，当 StudentWordsLatest 中存在 null 时，应在保存前校验失败。 */
    @Test
    void createOutlookReportRejectsCallerVocabularyPayloadWithNullStudentWordLatest() throws Exception {
        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
        ((com.fasterxml.jackson.databind.node.ArrayNode) invalidPayload.get("StudentWordsLatest")).addNull();

        assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖创建成果报告的调用方 PascalCase 报文场景，当提交有效 payload 时，应映射为领域内容并保存 ACHIEVEMENT 报告。 */
    @Test
    void createAchievementReportAcceptsCallerPascalCasePayloadAndStoresAchievementContent() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        var response = service.createAchievementReport(validAchievementPayload());

        assertThat(response.reportId()).isNotBlank();
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ReportType.ACHIEVEMENT);
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
        assertThat(saved.content()).isInstanceOf(AchievementReportContent.class);
        AchievementReportContent content = (AchievementReportContent) saved.content();
        assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
        assertThat(content.reportSubtitle()).isEqualTo("2024真题 · 临考突击 · 真实提分效果");
        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
        assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("1.93%");
        assertThat(content.stageVocabularySummary().stageName()).isEqualTo("高考");
        assertThat(content.stageVocabularySummary().stageVocabularyText()).isEqualTo("3500");
        assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("+0.55");
        assertThat(content.testPaperVocabularySummary().testPaperTitle()).isEqualTo("2024真题");
        assertThat(content.testPaperVocabularySummary().testPaperWordCountText()).isEqualTo("861");
        assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
        assertThat(content.examUnknownWordsHitStatus().hitWords())
                .containsExactly("number", "bear", "popular", "importance");
    }

    /** 覆盖成果报告调用方 PascalCase 类型边界场景，当核心数字字段为 string 时，应在保存前校验失败。 */
    @Test
    void createAchievementReportRejectsCallerPayloadWithWrongPascalCaseTypesBeforeSaving() {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        invalidPayload.put("PaperMasteryHitRate", "1.93");

        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖成果报告空提分词列表场景，当 TestPaperImprovedWords 为空数组时，应保存空 hitWords。 */
    @Test
    void createAchievementReportAcceptsEmptyImprovedWordsForEmptyState() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
        ObjectNode payload = validAchievementPayload().deepCopy();
        payload.putArray("TestPaperImprovedWords");
        payload.put("TestPaperImprovedWordCount", 0);

        var response = service.createAchievementReport(payload);

        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
        assertThat(content.examUnknownWordsHitStatus().hitWords()).isEmpty();
    }

    /** 覆盖成果报告非 delta 数值边界场景，当非 delta 展示数值为负数时，应在保存前校验失败。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidAchievementNonDeltaNegativeNumbers")
    void createAchievementReportRejectsNegativeNonDeltaAchievementNumbers(
            String caseName,
            Consumer<ObjectNode> mutatePayload) {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        mutatePayload.accept(invalidPayload);

        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖成果报告 delta 数值边界场景，当提分/增量字段为负数时，应允许保存并保留负号展示。 */
    @Test
    void createAchievementReportAcceptsNegativeDeltaAchievementNumbers() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
        ObjectNode payload = validAchievementPayload().deepCopy();
        payload.put("StudentImproveWordCount", -307);
        payload.put("StudentVocabulary", 2021);
        payload.put("StudentVocabMasteryImprovement", -12.79);
        payload.put("TestPaperImproveRate", -0.62);

        var response = service.createAchievementReport(payload);

        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("-307");
        assertThat(content.vocabularyComparison().growthText()).isEqualTo("-307");
        assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("-12.79");
        assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("-0.62");
    }

    /** 覆盖成果报告试卷熟词提升量 delta 场景，当 TestPaperImprovedWordCount 为负数时，应允许保存并保留负号展示。 */
    @Test
    void createAchievementReportAcceptsNegativeTestPaperImprovedWordCount() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
        ObjectNode payload = validAchievementPayload().deepCopy();
        payload.put("TestPaperImprovedWordCount", -4);

        var response = service.createAchievementReport(payload);

        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
        assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("-4");
        assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("-4");
        assertThat(content.examUnknownWordsHitStatus().reducedUnknownWordsText()).isEqualTo("-4");
    }

    /** 覆盖成果报告 BigDecimal 防御场景，当展示数值为极端 exponent 或超大数时，应在 mapper 前校验失败。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidAchievementExtremeNumbers")
    void createAchievementReportRejectsExtremeAchievementNumbersBeforeMapping(
            String caseName,
            Consumer<ObjectNode> mutatePayload) {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        mutatePayload.accept(invalidPayload);

        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖同步创建展望报告场景，当 StudentName 与 studentName 冲突时，应以 StudentName 为准生成文件名并归一化保存。 */
    @Test
    void createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("sync create must not dispatch async generation");
                },
                storage);

        ObjectNode payload = validOutlookPayload();
        payload.put("studentName", "冯亿豪");

        var response = service.createOutlookReportSync(payload);

        assertThat(response.reportId()).isNotBlank();
        assertThat(response.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK);
        assertThat(response.generationStatus())
                .isEqualTo(cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus.SUCCESS);
        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        String expectedFileName = "20260318测试-临考词汇突击潜力展望报告-20260102080000.pdf";
        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
        assertThat(saved.fileName()).isEqualTo(expectedFileName);
        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) saved.content()).source();
        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
    }

    /** 覆盖同步创建展望报告的调用方词汇报文场景，当只有 StudentName 时，应直接使用 StudentName 生成文件名。 */
    @Test
    void createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl() throws Exception {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("sync create must not dispatch async generation");
                },
                storage);

        var response = service.createOutlookReportSync(callerVocabularyPayload());

        assertThat(response.reportId()).isNotBlank();
        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        String expectedFileName = "20260318测试-临考词汇突击潜力展望报告-20260102080000.pdf";
        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
        assertThat(saved.fileName()).isEqualTo(expectedFileName);
        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
        assertThat(storage.uploadedContents).containsOnlyKeys(saved.storageObjectKey());
        assertThat(new String(storage.uploadedContents.get(saved.storageObjectKey()).bytes(), StandardCharsets.UTF_8))
                .contains("preview:20260318测试:")
                .contains(FIXED_CLOCK.instant().toString());
    }

    /** 覆盖同步创建展望报告的调用方词汇报文场景，当 lowercase studentName 为空白但 StudentName 有效时，应继续使用 StudentName。 */
    @Test
    void createOutlookReportSyncUsesCanonicalStudentNameWhenLowercaseStudentNameIsBlank() throws Exception {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("sync create must not dispatch async generation");
                },
                storage);
        ObjectNode payload = callerVocabularyPayload();
        payload.put("studentName", "   \t  \n ");

        var response = service.createOutlookReportSync(payload);

        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        String expectedFileName = "20260318测试-临考词汇突击潜力展望报告-20260102080000.pdf";
        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
        assertThat(saved.fileName()).isEqualTo(expectedFileName);
        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) saved.content()).source();
        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
    }

    /** 覆盖同步创建成果报告场景，当有效 achievement payload 生成成功时，应上传 PDF 并返回下载地址。 */
    @Test
    void createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("sync create must not dispatch async generation");
                },
                storage);

        var response = service.createAchievementReportSync(validAchievementPayload());

        assertThat(response.reportId()).isNotBlank();
        assertThat(response.reportType()).isEqualTo(ExamSprintReportType.ACHIEVEMENT);
        assertThat(response.generationStatus())
                .isEqualTo(cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus.SUCCESS);
        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        String expectedFileName = "吴泓妤-临考词汇突击成果报告-20260102080000.pdf";
        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
        assertThat(saved.fileName()).isEqualTo(expectedFileName);
        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
    }

    /** 覆盖同步展望报告下载地址生成失败场景，当存储层抛错时，应转换为下载不可用且日志不泄露异常消息。 */
    @Test
    void createOutlookReportSyncConvertsDownloadUrlGenerationFailureWithoutSensitiveLog(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        storage.failGenerateDownloadUrlWith(new IllegalStateException("SENSITIVE_DOWNLOAD_URL_DO_NOT_LOG"));
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("sync create must not dispatch async generation");
                },
                storage);

        assertThatThrownBy(() -> service.createOutlookReportSync(validOutlookPayload()))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);

        assertThat(output.getAll())
                .contains("reason=download_url_generation_failed")
                .contains("exceptionType=IllegalStateException")
                .doesNotContain("SENSITIVE_DOWNLOAD_URL_DO_NOT_LOG");
    }

    /** 覆盖同步展望报告非法 payload 场景，当 payload 非对象或为空时，应在保存前校验失败。 */
    @Test
    void createOutlookReportSyncRejectsInvalidPayloadBeforeSaving() {
        assertCreateOutlookReportSyncRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
    }

    /** 覆盖同步展望报告渲染失败场景，当 pipeline 生成失败时，应抛出下载不可用并保留失败报告。 */
    @Test
    void createOutlookReportSyncThrowsDownloadUnavailableAndKeepsFailedReportWhenGenerationFails() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> { },
                new TestStorage(),
                List.of(new FailingRenderer()),
                html -> html.getBytes(StandardCharsets.UTF_8));

        assertThatThrownBy(() -> service.createOutlookReportSync(validOutlookPayload()))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);

        assertThat(repository.storage.values())
                .singleElement()
                .extracting(ExamSprintReport::generationStatus)
                .isEqualTo(ReportGenerationStatus.FAILED);
    }

    /** 覆盖成果报告缺少必填字段场景，当 StudentName 缺失时，应在保存前校验失败。 */
    @Test
    void createAchievementReportRejectsInvalidAchievementPayloadBeforeSaving() {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        invalidPayload.remove("StudentName");

        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖成果报告参数诊断场景，当字段类型错误时，应抛出包含报告类型和字段名的中文校验消息。 */
    @Test
    void createAchievementReportRejectsInvalidPayloadWithChineseFieldMessage() {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        invalidPayload.put("StudentVocabularyBefore", "2328");

        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
                        .createAchievementReport(invalidPayload))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("成果报告参数校验失败")
                .hasMessageContaining("StudentVocabularyBefore")
                .hasMessageContaining("必须为数字")
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    /** 覆盖创建报告 payload 类型校验场景，当 payload 为 null 或非对象时，应在保存前拒绝展望和成果报告。 */
    @Test
    void createReportsRejectNullOrNonObjectPayloadBeforeSaving() {
        assertCreateOutlookReportRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
        assertCreateAchievementReportRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
        assertCreateOutlookReportRejectsInvalidPayload(OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
        assertCreateAchievementReportRejectsInvalidPayload(OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
    }

    /** 覆盖成果报告数值字段缺失场景，当 StudentVocabularyBefore 缺失时，应在保存前校验失败。 */
    @Test
    void createAchievementReportRejectsMissingAchievementBeforeValueBeforeSaving() {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        invalidPayload.remove("StudentVocabularyBefore");

        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖成果报告 JSON 类型错误场景，当关键字段类型不符时，应在保存前校验失败。 */
    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidAchievementPayloadJsonTypes")
    void createAchievementReportRejectsAchievementPayloadWithInvalidJsonTypes(
            String caseName,
            Consumer<ObjectNode> mutatePayload) {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        mutatePayload.accept(invalidPayload);

        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
    }

    /** 覆盖异步分发失败场景，当 dispatcher 抛错时，应保存失败状态且不泄露底层异常消息。 */
    @Test
    void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("task-executor-7 rejected by dispatcher unavailable");
                },
                new TestStorage());

        var response = service.createOutlookReport(validOutlookPayload());

        assertThat(response.reportId()).isNotBlank();
        assertThat(response.generationStatus())
                .isEqualTo(cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus.FAILED);
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.FAILED);
        assertThat(saved.failureReason()).isEqualTo("report_generation_dispatch_failed");
        assertThat(saved.failureReason()).doesNotContain("task-executor-7");
        assertThat(saved.failureReason()).doesNotContain("dispatcher unavailable");
        assertThat(repository.countByStatus(ReportGenerationStatus.PENDING)).isZero();
        assertThat(output.getAll())
                .contains("exam_sprint_report_submitted")
                .contains("reportType=OUTLOOK")
                .contains("mode=async")
                .contains("exam_sprint_report_dispatch_failed")
                .contains("failureReason=report_generation_dispatch_failed")
                .contains("exceptionType=IllegalStateException")
                .doesNotContain("task-executor-7 rejected")
                .doesNotContain("dispatcher unavailable");
    }

    /** 覆盖展望报告 payload 防御性复制场景，当调用方提交后继续修改 StudentName/studentName 时，已保存内容应保持归一化原值。 */
    @Test
    void createOutlookReportCopiesPayloadBeforeSaving() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
        ObjectNode payload = validOutlookPayload().deepCopy();

        var response = service.createOutlookReport(payload);

        payload.put("StudentName", "王同学");
        payload.put("studentName", "小写别名");

        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
        JsonNode savedPayload = (JsonNode) content.source();
        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
    }

    /** 覆盖查询成功报告场景，当报告已生成且未过期时，应返回下载地址并记录查询完成日志。 */
    @Test
    void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        ExamSprintReport report = ExamSprintReport.pending(
                        "report-success",
                        ReportType.ACHIEVEMENT,
                        validAchievementContent(),
                        FIXED_CLOCK.instant().minusSeconds(120),
                        FIXED_CLOCK.instant().plusSeconds(3600))
                .success(
                        FIXED_CLOCK.instant().minusSeconds(30),
                        "exam-sprint-achievement-report-report-success.pdf",
                        "exam-sprint-achievement-report-report-success.pdf");
        repository.save(report);
        storage.mapStorageObjectKeyToReportId("exam-sprint-achievement-report-report-success.pdf", "report-success");
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        var response = service.getReport("report-success");

        assertThat(response.generationStatus())
                .isEqualTo(cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus.SUCCESS);
        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
        assertThat(storage.generatedKeys)
                .containsExactly("exam-sprint-achievement-report-report-success.pdf");
        assertThat(output.getAll())
                .contains("exam_sprint_report_query_completed")
                .contains("reportId=report-success")
                .contains("reportType=ACHIEVEMENT")
                .contains("generationStatus=SUCCESS")
                .contains("downloadUrlIncluded=true");
    }

    /** 覆盖查询下载地址生成失败场景，当存储层抛错时，应保留响应且日志不泄露异常消息。 */
    @Test
    void getReportKeepsResponseWhenDownloadUrlGenerationFailsWithoutSensitiveLog(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        ExamSprintReport report = ExamSprintReport.pending(
                        "report-query-url-failure",
                        ReportType.ACHIEVEMENT,
                        validAchievementContent(),
                        FIXED_CLOCK.instant().minusSeconds(120),
                        FIXED_CLOCK.instant().plusSeconds(3600))
                .success(
                        FIXED_CLOCK.instant().minusSeconds(30),
                        "exam-sprint-achievement-report-report-query-url-failure.pdf",
                        "exam-sprint-achievement-report-report-query-url-failure.pdf");
        repository.save(report);
        storage.failGenerateDownloadUrlWith(new IllegalStateException("SENSITIVE_QUERY_URL_DO_NOT_LOG"));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        var response = service.getReport("report-query-url-failure");

        assertThat(response.generationStatus())
                .isEqualTo(cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus.SUCCESS);
        assertThat(response.downloadUrl()).isNull();
        assertThat(output.getAll())
                .contains("exam_sprint_report_download_url_generation_failed")
                .contains("exceptionType=IllegalStateException")
                .contains("storageObjectKey=exam-sprint-achievement-report-report-query-url-failure.pdf")
                .doesNotContain("SENSITIVE_QUERY_URL_DO_NOT_LOG");
    }

    /** 覆盖下载已过期报告场景，当报告过期但清理尚未运行时，应拒绝下载并记录 expired 原因。 */
    @Test
    void downloadReportRejectsExpiredReportBeforeCleanupRuns(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-expired",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().minusSeconds(1)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-outlook-report-report-expired.pdf",
                "exam-sprint-outlook-report-report-expired.pdf"));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        assertThatThrownBy(() -> service.downloadReport("report-expired"))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);

        assertThat(output.getAll())
                .contains("exam_sprint_report_download_unavailable")
                .contains("reportId=report-expired")
                .contains("generationStatus=EXPIRED")
                .contains("reason=expired");
    }

    /** 覆盖下载缺少存储内容场景，当 storageObjectKey 找不到内容时，应拒绝下载并记录缺失对象键。 */
    @Test
    void downloadReportLogsMissingStorageContent(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-missing-content",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().plusSeconds(3600)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-outlook-report-report-missing-content.pdf",
                "exam-sprint-outlook-report-report-missing-content.pdf"));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        assertThatThrownBy(() -> service.downloadReport("report-missing-content"))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);

        assertThat(output.getAll())
                .contains("exam_sprint_report_download_started")
                .contains("reportId=report-missing-content")
                .contains("reportType=OUTLOOK")
                .contains("exam_sprint_report_download_missing_storage_content")
                .contains("storageObjectKey=exam-sprint-outlook-report-report-missing-content.pdf");
    }

    /** 覆盖存储下载失败场景，当 storage.download 抛错时，应转换为下载不可用且日志不泄露异常消息。 */
    @Test
    void downloadReportConvertsStorageDownloadFailureWithoutSensitiveLog(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-storage-download-failure",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().plusSeconds(3600)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-outlook-report-report-storage-download-failure.pdf",
                "exam-sprint-outlook-report-report-storage-download-failure.pdf"));
        storage.failDownloadWith(new IllegalStateException("SENSITIVE_STORAGE_DOWNLOAD_DO_NOT_LOG"));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        assertThatThrownBy(() -> service.downloadReport("report-storage-download-failure"))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);

        assertThat(output.getAll())
                .contains("exam_sprint_report_download_unavailable")
                .contains("reason=storage_download_failed")
                .contains("exceptionType=IllegalStateException")
                .doesNotContain("SENSITIVE_STORAGE_DOWNLOAD_DO_NOT_LOG");
    }

    /** 覆盖过期边界场景，当 expiresAt 等于当前时间时，清理任务应将报告标记为 EXPIRED。 */
    @Test
    void cleanupExpiredReportsTreatsExpiresAtEqualsNowAsExpired() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-expired-at-boundary",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant()));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());

        service.cleanupExpiredReports();

        ExamSprintReport saved = repository.findById("report-expired-at-boundary").orElseThrow();
        assertThat(saved.isExpiredAt(FIXED_CLOCK.instant())).isTrue();
        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.EXPIRED);
    }

    /** 覆盖清理任务局部失败场景，当删除一个对象失败时，应继续清理其他过期报告并汇总失败数。 */
    @Test
    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-delete-fails",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().minusSeconds(1)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "first.pdf",
                "first.pdf"));
        repository.save(ExamSprintReport.pending(
                "report-delete-succeeds",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().minusSeconds(1)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "second.pdf",
                "second.pdf"));
        storage.failDeleteFor("first.pdf");
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        service.cleanupExpiredReports();

        ExamSprintReport failedDeleteReport = repository.findById("report-delete-fails").orElseThrow();
        ExamSprintReport successfulDeleteReport = repository.findById("report-delete-succeeds").orElseThrow();
        assertThat(failedDeleteReport.storageObjectKey())
                .isEqualTo("first.pdf");
        assertThat(successfulDeleteReport.generationStatus()).isEqualTo(ReportGenerationStatus.EXPIRED);
        assertThat(successfulDeleteReport.storageObjectKey()).isNull();
        assertThat(storage.deletedKeys)
                .contains("first.pdf")
                .contains("second.pdf");
        assertThat(output.getAll())
                .contains("exam_sprint_report_cleanup_item_failed")
                .contains("reportId=report-delete-fails")
                .contains("storageObjectKey=first.pdf")
                .contains("exceptionType=IllegalStateException")
                .contains("exam_sprint_report_cleanup_completed")
                .contains("scannedCount=2")
                .contains("storageClearedCount=1")
                .contains("markedExpiredCount=0")
                .contains("failedCount=1");
    }

    private DefaultExamSprintReportApplicationService service(
            TestRepository repository,
            ExamSprintReportGenerationDispatcher dispatcher,
            TestStorage storage) {
        return service(
                repository,
                dispatcher,
                storage,
                List.of(new PreviewTestRenderer()),
                html -> html.getBytes(StandardCharsets.UTF_8));
    }

    private DefaultExamSprintReportApplicationService service(
            TestRepository repository,
            ExamSprintReportGenerationDispatcher dispatcher,
            TestStorage storage,
            List<ExamSprintReportRenderer> renderers,
            ExamSprintReportPdfGenerator pdfGenerator) {
        return new DefaultExamSprintReportApplicationService(
                repository,
                dispatcher,
                new ExamSprintReportGenerationPipeline(
                        repository,
                        renderers,
                        pdfGenerator,
                        storage,
                        FIXED_CLOCK),
                storage,
                properties(),
                FIXED_CLOCK,
                OBJECT_MAPPER,
                VALIDATOR);
    }

    private ExamSprintReportProperties properties() {
        ExamSprintReportProperties properties = new ExamSprintReportProperties();
        properties.setRetention(Duration.ofDays(1));
        properties.setDownloadExpiry(Duration.ofMinutes(15));
        return properties;
    }

    private List<String> recordComponentNames(Class<? extends Record> recordType) {
        return Arrays.stream(recordType.getRecordComponents())
                .map(RecordComponent::getName)
                .toList();
    }

    private void assertReportRecordUsesDownloadUrlOnly(Class<? extends Record> recordType) {
        assertThat(recordComponentNames(recordType))
                .contains("downloadUrl")
                .doesNotContain(removedHtmlPreviewUrlFieldName());
    }

    private String removedHtmlPreviewUrlFieldName() {
        return "preview" + "HtmlUrl";
    }

    private String removedHtmlPreviewMethodName() {
        return "preview" + "ReportHtml";
    }

    private ObjectNode validOutlookPayload() {
        try {
            return callerVocabularyPayload();
        } catch (Exception exception) {
            throw new IllegalStateException("Failed to build valid outlook payload", exception);
        }
    }

    private ObjectNode nonContractOutlookPayload() throws Exception {
        return (ObjectNode) OBJECT_MAPPER.readTree("""
                {
                  "learnerProfile": {
                    "learnerName": "李同学",
                    "examName": "雅思 6.5"
                  },
                  "summaryCards": [
                    {"label": "词汇掌握", "value": 76},
                    {"label": "提分预测", "value": "+12 分"}
                  ]
                }
                """);
    }

    private ObjectNode callerVocabularyPayload() throws Exception {
        return (ObjectNode) OBJECT_MAPPER.readTree("""
                {
                  "StudentName": "20260318测试",
                  "StudentStage": 2,
                  "StageName": "初中",
                  "StageVocabulary": 10,
                  "StageExaminName": "中考",
                  "StageImportant": 3,
                  "StudentWordsLatest": [
                    {"WordId": 1, "MeanId": 1, "WordSpell": "w1", "WordFrequency": 1, "Mastery": 1.0, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 2, "MeanId": 1, "WordSpell": "w2", "WordFrequency": 2, "Mastery": 0.5, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 3, "MeanId": 1, "WordSpell": "w3", "WordFrequency": 3, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 4, "MeanId": 1, "WordSpell": "w4", "WordFrequency": 4, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 5, "MeanId": 1, "WordSpell": "w5", "WordFrequency": 5, "Mastery": 0.6, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 6, "MeanId": 1, "WordSpell": "w6", "WordFrequency": 6, "Mastery": 0.8, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 7, "MeanId": 1, "WordSpell": "w7", "WordFrequency": 7, "Mastery": 0.1, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 8, "MeanId": 1, "WordSpell": "w8", "WordFrequency": 8, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 9, "MeanId": 1, "WordSpell": "w9", "WordFrequency": 9, "Mastery": 0.3, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
                    {"WordId": 10, "MeanId": 1, "WordSpell": "w10", "WordFrequency": 10, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"}
                  ],
                  "MastedWordCount": 4,
                  "UnMastedWordCount": 6,
                  "ExamineStrangeWordCount": 3,
                  "TestPaperWordIdArray": [1, 2, 3, 4, 5],
                  "TestPaperTitle": "文章2.jpg",
                  "TestPaperUnMasterWords": ["lot", "father", "catch"],
                  "TestPaperMastedWords": ["a", "the"],
                  "TestPaperMastedWordCount": 2,
                  "TestPaperWordCount": 5,
                  "Complex": false
                }
                """);
    }

    private ObjectNode validAchievementPayload() {
        try {
            return (ObjectNode) OBJECT_MAPPER.readTree("""
                    {
                      "StudentName": "吴泓妤",
                      "StudentStage": 3,
                      "StageName": "高考",
                      "StageVocabulary": 3500,
                      "StudentVocabulary": 2347,
                      "StudentVocabularyBefore": 2328,
                      "StudentUnMastedWordCount": 1153,
                      "StudentImproveWordCount": 19,
                      "TestPaperTitle": "2024真题",
                      "TestPaperWordCount": 861,
                      "TestPaperBeforUnMastery": 207,
                      "TestPaperBeforMastery": 650,
                      "TestPaperLatestMastery": 654,
                      "TestPaperAfterUnMastery": 203,
                      "TestPaperImprovedWords": [
                        " number ",
                        {"WordSpell": "bear", "Ignored": true},
                        {"WordSpell": " popular "},
                        "importance"
                      ],
                      "TestPaperImprovedWordCount": 4,
                      "TestPaperImproveRate": 0.62,
                      "PaperMasteryHitRate": 1.93,
                      "ImproveStudyEfficiency": 0.48,
                      "StudentInitialVocabMastery": 66.51,
                      "StudentCurrentVocabMastery": 67.06,
                      "StudentVocabMasteryImprovement": 0.55,
                      "TestPaperBeforMasteryRate": 75.49,
                      "TestPaperLatestMasteryRate": 75.96,
                      "ShouldDisplaySigningGuarantee": true,
                      "SigningGuarantee": "签约保障",
                      "StudentWordsLatest": [{"WordSpell": "ignored-large-array"}],
                      "StudentWordsFirstPreExamAssaultAfter": [{"WordSpell": "ignored-large-array"}]
                    }
                    """);
        } catch (Exception exception) {
            throw new IllegalStateException("Failed to build valid achievement payload", exception);
        }
    }

    private ReportContent unmodeledOutlookContent() {
        return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
    }

    private AchievementReportContent validAchievementContent() {
        return AchievementReportContentMapper.toDomainContent(
                OBJECT_MAPPER.convertValue(validAchievementPayload(), AchievementExamSprintReportPayload.class));
    }

    private static Stream<Arguments> invalidAchievementPayloadJsonTypes() {
        return Stream.of(
                Arguments.of("StudentName boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("StudentName", true)),
                Arguments.of("StageName number is rejected", (Consumer<ObjectNode>) payload -> payload.put("StageName", 123)),
                Arguments.of("StudentVocabularyBefore string is rejected",
                        (Consumer<ObjectNode>) payload -> payload.put("StudentVocabularyBefore", "2328")),
                Arguments.of("TestPaperImprovedWords number element is rejected",
                        (Consumer<ObjectNode>) payload -> payload.putArray("TestPaperImprovedWords").add(123)),
                Arguments.of("TestPaperImprovedWords object without textual WordSpell is rejected",
                        (Consumer<ObjectNode>) payload -> payload.putArray("TestPaperImprovedWords")
                                .add(OBJECT_MAPPER.createObjectNode().put("WordSpell", 123))),
                Arguments.of("ShouldDisplaySigningGuarantee string is rejected",
                        (Consumer<ObjectNode>) payload -> payload.put("ShouldDisplaySigningGuarantee", "true")),
                Arguments.of("SigningGuarantee number is rejected",
                        (Consumer<ObjectNode>) payload -> payload.put("SigningGuarantee", 123)));
    }

    private static Stream<Arguments> invalidAchievementNonDeltaNegativeNumbers() {
        return Stream.of(
                Arguments.of("StageVocabulary negative is rejected", (Consumer<ObjectNode>) payload -> payload.put("StageVocabulary", -1)),
                Arguments.of("PaperMasteryHitRate negative is rejected", (Consumer<ObjectNode>) payload -> payload.put("PaperMasteryHitRate", -1)));
    }

    private static Stream<Arguments> invalidAchievementExtremeNumbers() {
        return Stream.of(
                Arguments.of("PaperMasteryHitRate extreme exponent is rejected",
                        (Consumer<ObjectNode>) payload -> payload.set("PaperMasteryHitRate",
                                com.fasterxml.jackson.databind.node.DecimalNode.valueOf(new java.math.BigDecimal("1e1000")))),
                Arguments.of("ImproveStudyEfficiency oversized integer is rejected",
                        (Consumer<ObjectNode>) payload -> payload.put("ImproveStudyEfficiency", 1_000_000_001)),
                Arguments.of("StudentCurrentVocabMastery excessive scale is rejected",
                        (Consumer<ObjectNode>) payload -> payload.set("StudentCurrentVocabMastery",
                                com.fasterxml.jackson.databind.node.DecimalNode.valueOf(new java.math.BigDecimal("39.1234567")))));
    }

    private static Stream<Arguments> mixedOutlookPayloadsWithInvalidCanonicalStudentName() {
        return Stream.of(
                Arguments.of("blank StudentName falls back to lowercase studentName",
                        (Consumer<ObjectNode>) payload -> payload.put("StudentName", "   \t  \n ")),
                Arguments.of("null StudentName falls back to lowercase studentName",
                        (Consumer<ObjectNode>) payload -> payload.putNull("StudentName")));
    }

    private static Stream<Arguments> invalidCanonicalStudentNameJsonTypes() {
        return Stream.of(
                Arguments.of("number StudentName is rejected without lowercase fallback",
                        (Consumer<ObjectNode>) payload -> {
                            payload.put("StudentName", 123);
                            payload.remove("studentName");
                        }),
                Arguments.of("boolean StudentName is rejected without lowercase fallback",
                        (Consumer<ObjectNode>) payload -> {
                            payload.put("StudentName", true);
                            payload.remove("studentName");
                        }),
                Arguments.of("object StudentName is rejected without lowercase fallback",
                        (Consumer<ObjectNode>) payload -> {
                            payload.set("StudentName", OBJECT_MAPPER.createObjectNode().put("value", "冯亿豪"));
                            payload.remove("studentName");
                        }),
                Arguments.of("array StudentName is rejected without lowercase fallback",
                        (Consumer<ObjectNode>) payload -> {
                            payload.set("StudentName", OBJECT_MAPPER.createArrayNode().add("冯亿豪"));
                            payload.remove("studentName");
                        }));
    }

    private static Stream<Arguments> invalidLowercaseStudentNameJsonTypes() {
        return Stream.of(
                Arguments.of("number studentName is rejected as fallback source",
                        (Consumer<ObjectNode>) payload -> payload.put("studentName", 123)),
                Arguments.of("boolean studentName is rejected as fallback source",
                        (Consumer<ObjectNode>) payload -> payload.put("studentName", true)),
                Arguments.of("object studentName is rejected as fallback source",
                        (Consumer<ObjectNode>) payload -> payload.set("studentName", OBJECT_MAPPER.createObjectNode().put("value", "冯亿豪"))),
                Arguments.of("array studentName is rejected as fallback source",
                        (Consumer<ObjectNode>) payload -> payload.set("studentName", OBJECT_MAPPER.createArrayNode().add("冯亿豪"))));
    }

    private void assertCreateOutlookReportRejectsInvalidPayload(JsonNode payload) {
        TestRepository repository = new TestRepository();
        boolean[] dispatched = {false};
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> dispatched[0] = true,
                new TestStorage());

        assertThatThrownBy(() -> service.createOutlookReport(payload))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);

        assertThat(repository.storage).isEmpty();
        assertThat(dispatched[0]).isFalse();
    }

    private void assertCreateAchievementReportRejectsInvalidPayload(JsonNode payload) {
        TestRepository repository = new TestRepository();
        boolean[] dispatched = {false};
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> dispatched[0] = true,
                new TestStorage());

        assertThatThrownBy(() -> service.createAchievementReport(payload))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);

        assertThat(repository.storage).isEmpty();
        assertThat(dispatched[0]).isFalse();
    }

    private void assertCreateOutlookReportSyncRejectsInvalidPayload(JsonNode payload) {
        TestRepository repository = new TestRepository();
        boolean[] dispatched = {false};
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> dispatched[0] = true,
                new TestStorage());

        assertThatThrownBy(() -> service.createOutlookReportSync(payload))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);

        assertThat(repository.storage).isEmpty();
        assertThat(dispatched[0]).isFalse();
    }

    private static class TestRepository implements ExamSprintReportRepository {
        private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();

        @Override
        public ExamSprintReport save(ExamSprintReport report) {
            storage.put(report.reportId(), report);
            return report;
        }

        @Override
        public Optional<ExamSprintReport> findById(String reportId) {
            return Optional.ofNullable(storage.get(reportId));
        }

        @Override
        public List<ExamSprintReport> findExpiredAtOrBefore(Instant instant) {
            return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList();
        }

        long countByStatus(ReportGenerationStatus status) {
            return storage.values().stream().filter(report -> report.generationStatus() == status).count();
        }
    }

    private static class TestStorage implements ExamSprintReportStorage {
        private final List<String> generatedKeys = new ArrayList<>();
        private final List<String> deletedKeys = new ArrayList<>();
        private final List<String> deleteFailures = new ArrayList<>();
        private final ConcurrentMap<String, StoredExamSprintReportContent> uploadedContents = new ConcurrentHashMap<>();
        private final ConcurrentMap<String, String> reportIdsByStorageObjectKey = new ConcurrentHashMap<>();
        private RuntimeException generateDownloadUrlFailure;
        private RuntimeException downloadFailure;

        @Override
        public StoredExamSprintReportFile upload(
                String reportId,
                ReportType reportType,
                String fileName,
                byte[] pdfBytes,
                Instant expiresAt) {
            uploadedContents.put(fileName, new StoredExamSprintReportContent(fileName, pdfBytes, "application/pdf"));
            reportIdsByStorageObjectKey.put(fileName, reportId);
            return new StoredExamSprintReportFile(fileName, fileName);
        }

        @Override
        public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
            if (generateDownloadUrlFailure != null) {
                throw generateDownloadUrlFailure;
            }
            generatedKeys.add(storageObjectKey);
            String reportId = reportIdsByStorageObjectKey.get(storageObjectKey);
            if (reportId == null) {
                throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
            }
            return URI.create("/api/exam-sprint/reports/" + reportId + "/download");
        }

        @Override
        public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
            if (downloadFailure != null) {
                throw downloadFailure;
            }
            return Optional.ofNullable(uploadedContents.get(storageObjectKey));
        }

        @Override
        public void delete(String storageObjectKey) {
            deletedKeys.add(storageObjectKey);
            if (deleteFailures.contains(storageObjectKey)) {
                throw new IllegalStateException("blob client delete failed for " + storageObjectKey);
            }
        }

        void failDeleteFor(String storageObjectKey) {
            deleteFailures.add(storageObjectKey);
        }

        void failGenerateDownloadUrlWith(RuntimeException failure) {
            generateDownloadUrlFailure = failure;
        }

        void failDownloadWith(RuntimeException failure) {
            downloadFailure = failure;
        }

        void mapStorageObjectKeyToReportId(String storageObjectKey, String reportId) {
            reportIdsByStorageObjectKey.put(storageObjectKey, reportId);
        }
    }

    private static class PreviewTestRenderer implements ExamSprintReportRenderer {

        @Override
        public boolean supports(ReportType reportType) {
            return reportType == ReportType.OUTLOOK || reportType == ReportType.ACHIEVEMENT;
        }

        @Override
        public String render(ReportContent content, Instant generatedAt) {
            String title = "";
            if (content instanceof AchievementReportContent achievementContent) {
                title = achievementContent.reportTitle();
            } else if (content instanceof UnmodeledReportContent unmodeledContent
                    && unmodeledContent.source() instanceof JsonNode payload) {
                title = payload.path("StudentName").asText("");
            }
            return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
        }
    }

    private static class FailingRenderer implements ExamSprintReportRenderer {

        @Override
        public boolean supports(ReportType reportType) {
            return reportType == ReportType.OUTLOOK;
        }

        @Override
        public String render(ReportContent content, Instant generatedAt) {
            throw new IllegalStateException("renderer exploded");
        }
    }
}
