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

import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
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.kernel.BusinessException;
import cn.yunzhixue.ability.center.kernel.ErrorCode;
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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
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;

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();

    @Test
    void createReportStoresOutlookTypeAndReturnsReportId() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
                ExamSprintReportType.OUTLOOK,
                validOutlookPayload());

        var response = service.createReport(request);

        assertThat(response.reportId()).isNotBlank();
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK);
        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING);
    }

    @Test
    void createReportStoresAchievementTypeAndReturnsReportId() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
                ExamSprintReportType.ACHIEVEMENT,
                validAchievementPayload());

        var response = service.createReport(request);

        assertThat(response.reportId()).isNotBlank();
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.ACHIEVEMENT);
        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING);
        assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
    }

    @Test
    void createReportRejectsInvalidAchievementPayloadBeforeSaving() {
        TestRepository repository = new TestRepository();
        boolean[] dispatched = {false};
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> dispatched[0] = true,
                new TestStorage());
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        invalidPayload.remove("reportTitle");

        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
                ExamSprintReportType.ACHIEVEMENT,
                invalidPayload)))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);

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

    @Test
    void createReportRejectsNullOrNonObjectPayloadBeforeSaving() {
        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.nullNode());
        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.nullNode());
        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
    }

    @Test
    void createReportRejectsMissingAchievementBeforeValueBeforeSaving() {
        TestRepository repository = new TestRepository();
        boolean[] dispatched = {false};
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> dispatched[0] = true,
                new TestStorage());
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        invalidPayload.withObject("vocabularyComparison").remove("beforeValue");

        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
                ExamSprintReportType.ACHIEVEMENT,
                invalidPayload)))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);

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

    @ParameterizedTest(name = "{0}")
    @MethodSource("invalidAchievementPayloadJsonTypes")
    void createReportRejectsAchievementPayloadWithInvalidJsonTypes(
            String caseName,
            Consumer<ObjectNode> mutatePayload) {
        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
        mutatePayload.accept(invalidPayload);

        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, invalidPayload);
    }

    @Test
    void createReportReturnsFailedStatusWhenDispatchFails() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> {
                    throw new IllegalStateException("task-executor-7 rejected by dispatcher unavailable");
                },
                new TestStorage());

        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
                ExamSprintReportType.OUTLOOK,
                validOutlookPayload());

        var response = service.createReport(request);

        assertThat(response.reportId()).isNotBlank();
        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.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(ExamSprintReportGenerationStatus.PENDING)).isZero();
    }

    @Test
    void createReportCopiesPayloadBeforeSaving() {
        TestRepository repository = new TestRepository();
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
        ObjectNode payload = validOutlookPayload().deepCopy();

        var response = service.createReport(new CreateExamSprintReportRequest(ExamSprintReportType.OUTLOOK, payload));

        payload.withObject("reportMetadata").put("learnerName", "王同学");

        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
        assertThat(saved.payload().path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
    }

    @Test
    void getReportReturnsDownloadAndPreviewUrlsForSuccessfulReport() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        ExamSprintReport report = ExamSprintReport.pending(
                        "report-success",
                        ExamSprintReportType.ACHIEVEMENT,
                        validAchievementPayload(),
                        FIXED_CLOCK.instant().minusSeconds(120),
                        FIXED_CLOCK.instant().plusSeconds(3600))
                .success(
                        FIXED_CLOCK.instant().minusSeconds(30),
                        "exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf",
                        "exam-sprint-achievement-report-report-success.pdf");
        repository.save(report);
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

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

        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
        assertThat(response.previewHtmlUrl()).isEqualTo("/api/exam-sprint/reports/report-success/preview/html");
        assertThat(storage.generatedKeys)
                .containsExactly("exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf");
    }

    @Test
    void previewReportHtmlRendersSavedPayloadForSuccessfulReport() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                        "report-preview",
                        ExamSprintReportType.ACHIEVEMENT,
                        validAchievementPayload(),
                        FIXED_CLOCK.instant().minusSeconds(120),
                        FIXED_CLOCK.instant().plusSeconds(3600))
                .success(
                        FIXED_CLOCK.instant().minusSeconds(30),
                        "exam-sprint-reports/achievement/report-preview/exam-sprint-achievement-report-report-preview.pdf",
                        "exam-sprint-achievement-report-report-preview.pdf"));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());

        var content = service.previewReportHtml("report-preview");

        assertThat(content.contentType()).isEqualTo("text/html;charset=UTF-8");
        assertThat(content.html()).contains("preview:高考英语临考突击学习成果报告:2026-01-01T23:59:30Z");
    }

    @Test
    void previewReportHtmlRejectsPendingReport() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-pending",
                ExamSprintReportType.ACHIEVEMENT,
                validAchievementPayload(),
                FIXED_CLOCK.instant().minusSeconds(120),
                FIXED_CLOCK.instant().plusSeconds(3600)));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());

        assertThatThrownBy(() -> service.previewReportHtml("report-pending"))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
    }

    @Test
    void downloadReportRejectsExpiredReportBeforeCleanupRuns() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-expired",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().minusSeconds(1)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-reports/outlook/report-expired/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);
    }

    @Test
    void cleanupExpiredReportsTreatsExpiresAtEqualsNowAsExpired() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-expired-at-boundary",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                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(ExamSprintReportGenerationStatus.EXPIRED);
    }

    @Test
    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-delete-fails",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().minusSeconds(1)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-reports/outlook/report-delete-fails/first.pdf",
                "first.pdf"));
        repository.save(ExamSprintReport.pending(
                "report-delete-succeeds",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().minusSeconds(1)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-reports/outlook/report-delete-succeeds/second.pdf",
                "second.pdf"));
        storage.failDeleteFor("exam-sprint-reports/outlook/report-delete-fails/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("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
        assertThat(successfulDeleteReport.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.EXPIRED);
        assertThat(successfulDeleteReport.storageObjectKey()).isNull();
        assertThat(storage.deletedKeys)
                .contains("exam-sprint-reports/outlook/report-delete-fails/first.pdf")
                .contains("exam-sprint-reports/outlook/report-delete-succeeds/second.pdf");
    }

    private DefaultExamSprintReportApplicationService service(
            TestRepository repository,
            ExamSprintReportGenerationDispatcher dispatcher,
            TestStorage storage) {
        return new DefaultExamSprintReportApplicationService(
                repository,
                dispatcher,
                storage,
                properties(),
                FIXED_CLOCK,
                OBJECT_MAPPER,
                VALIDATOR,
                List.of(new PreviewTestRenderer()));
    }

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

    private ObjectNode validOutlookPayload() {
        return (ObjectNode) OBJECT_MAPPER.valueToTree(new OutlookExamSprintReportPayload(
                new OutlookExamSprintReportPayload.ReportMetadata(
                        "2026 词汇展望报告",
                        "李同学",
                        "雅思 6.5",
                        "2026 春季冲刺",
                        "Ability Bot"),
                new OutlookExamSprintReportPayload.ReadinessOverview(
                        "词汇能力进入提分窗口，适合围绕考纲和高频场景做集中突破。",
                        "当前阶段：稳态提升",
                        "核心观察：阅读词汇优于写作输出，仍需补齐同义替换。",
                        78),
                new OutlookExamSprintReportPayload.SyllabusMasteryChart(
                        4200,
                        3192,
                        1008,
                        76,
                        "考纲词汇掌握概览",
                        "先补齐教育、科技和环境主题词，再做套题复盘。"),
                new OutlookExamSprintReportPayload.PastPaperVocabularyChart(
                        800,
                        180,
                        120,
                        "预计提分 5-10 分",
                        "每次精听后补 5 组同义替换并做口头复述。"),
                new OutlookExamSprintReportPayload.HighFrequencyVocabularyChart(
                        77,
                        62,
                        "Common 高频词覆盖较广，但易混词记忆不牢。"),
                new OutlookExamSprintReportPayload.VocabularyFrequencyBandChart(
                        List.of(
                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar("2k 高频", 86, "优先学习", "#448aff"),
                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar("3k 高频", 78, "重点突破", "#4caf50"),
                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar("学术词", 62, "酌情学习", "#ff9800"))),
                new OutlookExamSprintReportPayload.FrequencyPlan(
                        List.of(
                                new OutlookExamSprintReportPayload.FrequencyPlanCard(3, "+12 分", 78, true, "推荐", "③"),
                                new OutlookExamSprintReportPayload.FrequencyPlanCard(2, "+8 分", 61, false, "稳妥", "②")),
                        "💡建议策略",
                        "7 天提分冲刺优先保证高频词正确率，再逐步覆盖中频词。",
                        List.of(
                                new OutlookExamSprintReportPayload.PhaseSuggestion("考前半个月·核心突击期", "围绕高频词建立记忆闭环。"),
                                new OutlookExamSprintReportPayload.PhaseSuggestion("考前一周·强化巩固期", "结合真题错词做循环复盘。"))),
                new OutlookExamSprintReportPayload.ScoreImprovementCaseStudy(
                        "教育类阅读题案例",
                        "李同学",
                        "考前半个月·核心突击期",
                        705,
                        237,
                        33.6,
                        "70 分以下",
                        89,
                        19)));
    }

    private ObjectNode validAchievementPayload() {
        return (ObjectNode) OBJECT_MAPPER.valueToTree(new AchievementExamSprintReportPayload(
                "高考英语临考突击学习成果报告",
                "2024真题 · 两周专项训练 · 真实提分效果",
                "恭喜完成两周考前突击专项训练",
                "基于2024英语真题试卷 · 真实学习效果分析",
                new AchievementExamSprintReportPayload.SummaryMetrics(
                        "+19",
                        "+4",
                        "1.93%",
                        "0.48倍"),
                new AchievementExamSprintReportPayload.Comparison(
                        2328.0,
                        2347.0,
                        "2328 词",
                        "2347 词",
                        "+19 词"),
                new AchievementExamSprintReportPayload.Comparison(
                        650.0,
                        654.0,
                        "650 个",
                        "654 个",
                        "+4 个"),
                new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus(
                        "1.93%",
                        "0.48倍",
                        "207 个",
                        "203 个",
                        "4 个",
                        List.of("number", "bear", "popular", "importance"))));
    }

    private static Stream<Arguments> invalidAchievementPayloadJsonTypes() {
        return Stream.of(
                Arguments.of("reportTitle boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", true)),
                Arguments.of("reportTitle number is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", 123)),
                Arguments.of("vocabularyComparison.beforeValue string is rejected",
                        (Consumer<ObjectNode>) payload -> payload.withObject("vocabularyComparison").put("beforeValue", "2328")),
                Arguments.of("examUnknownWordsHitStatus.hitWords number element is rejected",
                        (Consumer<ObjectNode>) payload -> payload.withObject("examUnknownWordsHitStatus").putArray("hitWords").add(123)));
    }

    private void assertCreateReportRejectsInvalidPayload(ExamSprintReportType reportType, com.fasterxml.jackson.databind.JsonNode payload) {
        TestRepository repository = new TestRepository();
        boolean[] dispatched = {false};
        DefaultExamSprintReportApplicationService service = service(
                repository,
                reportId -> dispatched[0] = true,
                new TestStorage());

        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(reportType, 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(ExamSprintReportGenerationStatus 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<>();

        @Override
        public StoredExamSprintReportFile upload(
                String reportId,
                ExamSprintReportType reportType,
                String fileName,
                byte[] pdfBytes,
                Instant expiresAt) {
            return new StoredExamSprintReportFile("blob/" + reportId + "/" + fileName, fileName);
        }

        @Override
        public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
            generatedKeys.add(storageObjectKey);
            return URI.create("/api/exam-sprint/reports/" + storageObjectKey.split("/")[2] + "/download");
        }

        @Override
        public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
            return Optional.empty();
        }

        @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);
        }
    }

    private static class PreviewTestRenderer implements ExamSprintReportRenderer {

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

        @Override
        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
            return "<html><body>preview:" + payload.path("reportTitle").asText() + ":" + generatedAt + "</body></html>";
        }
    }
}
