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

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.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 com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;

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.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

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

@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportGenerationWorkerTest {

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

    /**
     * 覆盖 OUTLOOK 报告上传成功且 payload 只带 canonical StudentName 时，期望用学生姓名、潜力展望标题和上海时间生成文件名。
     */
    @Test
    void processMarksReportSuccessAfterUpload() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-success",
                ReportType.OUTLOOK,
                unmodeledOutlookContentWithCanonicalStudentName("20260318测试"),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-success");

        ExamSprintReport report = repository.findById("report-success").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        assertThat(report.storageObjectKey()).isEqualTo("20260318测试-临考词汇突击潜力展望报告-20260101080000.pdf");
        assertThat(report.fileName()).isEqualTo("20260318测试-临考词汇突击潜力展望报告-20260101080000.pdf");
    }

    /**
     * 覆盖成功生成日志在 OUTLOOK 空 payload 触发 reportId fallback 时，期望记录新的潜力展望 storageObjectKey。
     */
    @Test
    void processLogsSuccessfulGenerationStages(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-log-success",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new SensitiveHtmlRenderer()), storage);

        worker.process("report-log-success");

        assertThat(output.getAll())
                .contains("exam_sprint_report_generation_started")
                .contains("reportId=report-log-success")
                .contains("reportType=OUTLOOK")
                .contains("stage=render_html")
                .contains("stage=pdf_generation")
                .contains("pdfByteLength=")
                .contains("stage=storage_upload")
                .contains("exam_sprint_report_generation_succeeded")
                .contains("storageObjectKey=report-log-success-临考词汇突击潜力展望报告-20260101080000.pdf")
                .doesNotContain("SENSITIVE_HTML_DO_NOT_LOG")
                .doesNotContain("<html><body>SENSITIVE_HTML_DO_NOT_LOG</body></html>");
    }

    /**
     * 覆盖 ACHIEVEMENT 报告上传成功且 content 携带 lowercase studentName 时，期望用学生姓名生成成果报告文件名。
     */
    @Test
    void processCreatesAchievementFileNameAndStorageKeyAfterUpload() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-achievement",
                ReportType.ACHIEVEMENT,
                achievementContentWithStudentName("吴泓妤"),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(
                repository,
                List.of(new TestRenderer(ReportType.ACHIEVEMENT)),
                storage);

        worker.process("report-achievement");

        ExamSprintReport report = repository.findById("report-achievement").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        assertThat(report.fileName()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
        assertThat(report.storageObjectKey()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
    }

    /**
     * 覆盖 OUTLOOK payload 中 lowercase studentName 为空白时，期望回退到 reportId 生成潜力展望文件名。
     */
    @Test
    void processFallsBackToReportIdWhenStudentNameIsBlank() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-blank-student",
                ReportType.OUTLOOK,
                unmodeledOutlookContentWithStudentName("   \t  \n "),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-blank-student");

        ExamSprintReport report = repository.findById("report-blank-student").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        assertThat(report.storageObjectKey()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
        assertThat(report.fileName()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
    }

    /**
     * 覆盖 OUTLOOK payload 同时存在 StudentName 和 studentName 时，期望优先使用 canonical StudentName。
     */
    @Test
    void processPrefersCanonicalStudentNameWhenAliasesConflict() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-conflict-student",
                ReportType.OUTLOOK,
                unmodeledOutlookContentWithStudentNames("canonical学生", "lowercase学生"),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-conflict-student");

        ExamSprintReport report = repository.findById("report-conflict-student").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        assertThat(report.storageObjectKey()).isEqualTo("canonical学生-临考词汇突击潜力展望报告-20260101080000.pdf");
        assertThat(report.fileName()).isEqualTo("canonical学生-临考词汇突击潜力展望报告-20260101080000.pdf");
    }

    /**
     * 覆盖 OUTLOOK payload 中 canonical StudentName 为空白时，期望回退到 lowercase studentName。
     */
    @Test
    void processFallsBackToLowercaseStudentNameWhenCanonicalIsBlank() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-canonical-blank-student",
                ReportType.OUTLOOK,
                unmodeledOutlookContentWithStudentNames("   ", "lowercase学生"),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-canonical-blank-student");

        ExamSprintReport report = repository.findById("report-canonical-blank-student").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        assertThat(report.storageObjectKey()).isEqualTo("lowercase学生-临考词汇突击潜力展望报告-20260101080000.pdf");
        assertThat(report.fileName()).isEqualTo("lowercase学生-临考词汇突击潜力展望报告-20260101080000.pdf");
    }

    /**
     * 覆盖 OUTLOOK payload 中 lowercase studentName 首尾有空白时，期望 trim 后生成潜力展望文件名。
     */
    @Test
    void processTrimsStudentNameForFileName() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-trim-student",
                ReportType.OUTLOOK,
                unmodeledOutlookContentWithStudentName("  冯亿豪  "),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-trim-student");

        ExamSprintReport report = repository.findById("report-trim-student").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
        assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
        assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
    }

    @Test
    void processMarksReportFailedWhenGenerationPipelineThrows() {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-failed",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));

        ExamSprintReportGenerationWorker worker = createWorker(
                repository,
                List.of(new FailingRenderer()),
                new TestStorage());

        worker.process("report-failed");

        ExamSprintReport report = repository.findById("report-failed").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.FAILED);
        assertThat(report.failureReason()).isEqualTo("renderer exploded");
    }

    @Test
    void processLogsFailedGenerationStage(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-log-failed",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));

        ExamSprintReportGenerationWorker worker = createWorker(
                repository,
                List.of(new FailingRenderer()),
                new TestStorage());

        worker.process("report-log-failed");

        assertThat(output.getAll())
                .contains("exam_sprint_report_generation_failed")
                .contains("reportId=report-log-failed")
                .contains("reportType=OUTLOOK")
                .contains("stage=render_html")
                .contains("failureReason=IllegalStateException")
                .contains("exceptionType=IllegalStateException")
                .doesNotContain("renderer exploded");
    }

    @Test
    void processPersistsRawFailureReasonButLogsSafeFailureReason(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-sensitive-failed",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));

        ExamSprintReportGenerationWorker worker = createWorker(
                repository,
                List.of(new SensitiveFailureRenderer()),
                new TestStorage());

        worker.process("report-sensitive-failed");

        ExamSprintReport report = repository.findById("report-sensitive-failed").orElseThrow();
        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.FAILED);
        assertThat(report.failureReason()).isEqualTo("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
        assertThat(output.getAll())
                .contains("exam_sprint_report_generation_failed")
                .contains("failureReason=IllegalStateException")
                .doesNotContain("SENSITIVE_FAILURE_DO_NOT_LOG")
                .doesNotContain("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
    }

    @Test
    void processDoesNotResurrectReportDeletedBeforeFinalSuccessSave(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-deleted",
                ReportType.OUTLOOK,
                unmodeledOutlookContent(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        storage.onUpload(() -> repository.delete("report-deleted"));
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-deleted");

        assertThat(repository.findById("report-deleted")).isEmpty();
        assertThat(output.getAll())
                .contains("reason=not_found_before_success_save")
                .doesNotContain("exam_sprint_report_generation_failed");
    }

    private ExamSprintReportGenerationWorker createWorker(
            TestRepository repository,
            List<ExamSprintReportRenderer> renderers,
            TestStorage storage) {
        return new ExamSprintReportGenerationWorker(
                new ExamSprintReportGenerationPipeline(
                        repository,
                        renderers,
                        html -> html.getBytes(StandardCharsets.UTF_8),
                        storage,
                        FIXED_CLOCK));
    }

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

    private ReportContent unmodeledOutlookContentWithStudentName(String studentName) {
        return new UnmodeledReportContent(
                ReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode().put("studentName", studentName));
    }

    private ReportContent unmodeledOutlookContentWithCanonicalStudentName(String studentName) {
        return new UnmodeledReportContent(
                ReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode().put("StudentName", studentName));
    }

    private ReportContent unmodeledOutlookContentWithStudentNames(String canonicalStudentName, String lowercaseStudentName) {
        return new UnmodeledReportContent(
                ReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode()
                        .put("StudentName", canonicalStudentName)
                        .put("studentName", lowercaseStudentName));
    }

    private ReportContent achievementContentWithStudentName(String studentName) {
        return new AchievementReportContent(
                studentName,
                "高考英语临考突击学习成果报告",
                "2024真题 · 两周专项训练 · 真实提分效果",
                "恭喜完成两周考前突击专项训练",
                "基于2024英语真题试卷 · 真实学习效果分析",
                new AchievementReportContent.SummaryMetrics("+19", "+4", "0.0193", "0.48"),
                new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
                new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
                new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
                new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
                new AchievementReportContent.ExamUnknownWordsHitStatus(
                        "0.0193",
                        "0.48",
                        "207",
                        "203",
                        "4",
                        List.of("number", "bear", "popular", "importance")));
    }

    private static class TestRenderer implements ExamSprintReportRenderer {
        private final ReportType supportedReportType;

        TestRenderer() {
            this(ReportType.OUTLOOK);
        }

        TestRenderer(ReportType supportedReportType) {
            this.supportedReportType = supportedReportType;
        }

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

        @Override
        public String render(ReportContent content, Instant generatedAt) {
            return "<html><body>ok</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");
        }
    }

    private static class SensitiveHtmlRenderer implements ExamSprintReportRenderer {
        @Override
        public boolean supports(ReportType reportType) {
            return reportType == ReportType.OUTLOOK;
        }

        @Override
        public String render(ReportContent content, Instant generatedAt) {
            return "<html><body>SENSITIVE_HTML_DO_NOT_LOG</body></html>";
        }
    }

    private static class SensitiveFailureRenderer implements ExamSprintReportRenderer {
        @Override
        public boolean supports(ReportType reportType) {
            return reportType == ReportType.OUTLOOK;
        }

        @Override
        public String render(ReportContent content, Instant generatedAt) {
            throw new IllegalStateException("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
        }
    }

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

        void delete(String reportId) {
            storage.remove(reportId);
        }

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

    private static class TestStorage implements ExamSprintReportStorage {
        private Runnable uploadCallback = () -> { };

        @Override
        public StoredExamSprintReportFile upload(
                String reportId,
                ReportType reportType,
                String fileName,
                byte[] pdfBytes,
                Instant expiresAt) {
            uploadCallback.run();
            return new StoredExamSprintReportFile(fileName, fileName);
        }

        void onUpload(Runnable uploadCallback) {
            this.uploadCallback = uploadCallback;
        }

        @Override
        public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
            return URI.create("https://download.example.local/" + storageObjectKey + "?sig=test");
        }

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

        @Override
        public void delete(String storageObjectKey) {
        }
    }
}
