package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook;

import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

class ClasspathOutlookExamSprintReportRendererTest {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final String TEMPLATE_PATH = "templates/outlook-exam-sprint-report-template.html";
    private static final String SVG_CJK_FONT_FAMILY = "font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
    private static final Pattern SVG_START_TAG_PATTERN = Pattern.compile("<svg\\b[^>]*>");

    /**
     * 覆盖官方上游词汇 payload 触发完整展望报告渲染时，应保留设计稿动态结构并输出由词汇数据计算出的核心数值。
     */
    @Test
    void renderBuildsOutlookHtmlAlignedWithDesignDraftDynamicStructure() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("class=\"analysis-table\"")
                .contains("class=\"analysis-row\"")
                .contains("class=\"analysis-cell analysis-cell-left\"")
                .contains("class=\"analysis-cell analysis-cell-right\"")
                .contains("class='card")
                .contains("class='chart-box'")
                .contains("class='data-text'")
                .contains("<table class='frequency-table' role='presentation'>")
                .contains("<tr class='frequency-row'>")
                .contains("<td class='frequency-cell frequency-cell-1'>")
                .contains("<td class='frequency-cell frequency-cell-2'>")
                .contains("<td class='frequency-cell frequency-cell-3'>")
                .doesNotContain("<td class='frequency-cell frequency-cell-4'>")
                .contains("class='freq-card card-2 active'")
                .contains("class='freq-header'>2套/周")
                .contains("class='freq-header'>3套/周 <span class='badge'>推荐</span>")
                .contains("class='freq-header-table'")
                .contains("class='freq-title-cell'><div class='freq-header'>5套/周</div>")
                .contains("class='freq-star-cell'><span class='crown'>★</span>")
                .contains("class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'")
                .doesNotContain("class='freq-title'>")
                .doesNotContain("class='freq-header'>1套/周")
                .doesNotContain("2套/周 <span class='badge'>")
                .doesNotContain("5套/周 <span class='badge'>")
                .doesNotContain("class='freq-header'>5套/周 <span class='crown'>★</span>")
                .doesNotContain("④")
                .contains("胜率 <strong>72%</strong>")
                .contains("class='suggest-box'")
                .contains("考前半个月·核心突击期")
                .contains("考前半小时·临阵巩固期")
                .contains("class='student-case'")
                .contains("<table class='student-case-table' role='presentation'>")
                .contains("<tr class='student-case-row'>")
                .contains("<td class='case-chart-cell'>")
                .contains("<td class='case-info-cell'>")
                .contains("class='case-chart'")
                .contains("class='case-info'")
                .contains("王雷宇")
                .contains("705词 | 高考命中：237词 | 命中率：33.8%")
                .contains("+19分")
                .contains("考纲总量：<span class='highlight'>10词</span>")
                .contains("已掌握：<span class='highlight'>4词（40.00%）</span>")
                .contains("未掌握：<span class='highlight'>6词（60.00%）</span>")
                .contains("真题总词：5词 | 生词量：3词（60.00%）")
                .contains("冲刺后生词：2词，生词占比降至40.00%")
                .doesNotContain("先压降真题生词占比。")
                .doesNotContain("拉分词是提分核心突破项")
                .contains("基础必会词：综合掌握率75%")
                .contains("核心常考词：综合掌握率50%")
                .contains("拔高拉分词：综合掌握率25%")
                .contains("高频词：56.7%（优先学习）")
                .contains("中频词：47.5%（重点突破）")
                .contains("低频词：30.0%（酌情学习）");
    }

    /**
     * 覆盖官方上游词汇 payload 中词频与掌握度组合变化时，应复用词频区间计算逻辑生成所有图表数值。
     */
    @Test
    void renderAdaptsCallerVocabularyPayloadIntoOutlookReportBands() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("考纲总量：<span class='highlight'>10词</span>")
                .contains("已掌握：<span class='highlight'>4词（40.00%）</span>")
                .contains("未掌握：<span class='highlight'>6词（60.00%）</span>")
                .contains("真题总词：5词 | 生词量：3词（60.00%）")
                .contains("冲刺后生词：2词，生词占比降至40.00%")
                .doesNotContain("先压降真题生词占比。")
                .doesNotContain("拉分词是提分核心突破项")
                .contains("基础必会词：综合掌握率75%")
                .contains("核心常考词：综合掌握率50%")
                .contains("拔高拉分词：综合掌握率25%")
                .contains("高频词：56.7%（优先学习）")
                .contains("中频词：47.5%（重点突破）")
                .contains("低频词：30.0%（酌情学习）")
                .contains("7 天提分冲刺是首选节奏")
                .contains("王雷宇")
                .contains("+19分");
    }

    /**
     * 覆盖应用层归一化后的官方词汇 payload 同时携带 StudentName/studentName 时，真实 renderer 应继续完成反序列化和 HTML 渲染。
     */
    @Test
    void renderAcceptsNormalizedOutlookPayloadWithCanonicalAndLowercaseStudentNameAliases() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(
                unmodeledOutlookContent(normalizedCallerVocabularyPayloadWithStudentNameAliases()),
                Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("考纲总量：<span class='highlight'>10词</span>")
                .contains("真题总词：5词 | 生词量：3词（60.00%）")
                .contains("7 天提分冲刺是首选节奏")
                .contains("王雷宇")
                .doesNotContain("{{syllabusMasterySection}}")
                .doesNotContain("{{pastPaperVocabularySection}}")
                .doesNotContain("{{studySuggestionSection}}");
    }

    /**
     * 覆盖官方词汇 payload 计算出 0 值区间时，对应柱状图高度应保持为 0 且不被最小高度兜底抬高。
     */
    @Test
    void renderUsesZeroHeightForZeroValueBarsWithoutForcingMinimumHeight() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayloadWithZeroValueBars()),
                Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("class='chart-column advanced-score-column' x='250' y='180' width='54' height='0'")
                .contains("拔高拉分词：综合掌握率0%")
                .contains("class='chart-column low-band-column' x='250' y='180' width='54' height='0'")
                .contains("低频词：0.0%（酌情学习）");
    }

    /**
     * 覆盖官方上游词汇 payload 渲染三组柱状图时，应为图表补充可见坐标轴刻度。
     */
    @Test
    void renderAddsAxisTicksToThreeBarCharts() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("class='chart-axis-tick chart-axis-tick-y'")
                .contains("class='chart-axis-tick chart-axis-tick-x'")
                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>100</text>")
                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>5</text>");
    }

    /**
     * 覆盖展望报告三组柱状图渲染时，应在 y 轴刻度位置输出横向辅助网格线便于读数。
     */
    @Test
    void renderAddsYAxisHorizontalGridLinesToOutlookBarCharts() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        String pastPaperSvg = extractSvgByClass(html, "past-paper-column-chart");
        assertThat(gridLineElements(pastPaperSvg))
                .containsExactly(
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='180' x2='296' y2='180' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='50' x2='296' y2='50' stroke='#edf2f7' stroke-width='1'/>");

        String highFrequencySvg = extractSvgByClass(html, "high-frequency-column-chart");
        assertThat(gridLineElements(highFrequencySvg))
                .containsExactly(
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='180' x2='336' y2='180' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='154' x2='336' y2='154' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='128' x2='336' y2='128' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='102' x2='336' y2='102' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='76' x2='336' y2='76' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='50' x2='336' y2='50' stroke='#edf2f7' stroke-width='1'/>");

        String frequencyBandSvg = extractSvgByClass(html, "frequency-band-column-chart");
        assertThat(gridLineElements(frequencyBandSvg))
                .containsExactly(
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='180' x2='336' y2='180' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='115' x2='336' y2='115' stroke='#edf2f7' stroke-width='1'/>",
                        "<line class='chart-grid-line chart-grid-line-y' x1='34' y1='50' x2='336' y2='50' stroke='#edf2f7' stroke-width='1'/>");
    }

    /**
     * 覆盖科学备考建议频率卡片的线性进度条时，应使用 PDF 友好的 SVG 几何图形，避免 CSS 圆角裁剪在 PDF 中变尖。
     */
    @Test
    void renderUsesSvgLinearProgressBarsForFrequencyPlanCards() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("<svg class='freq-progress-svg'")
                .contains("viewBox='0 0 100 8'")
                .contains("preserveAspectRatio='none'")
                .contains("<clipPath id='freq-progress-clip-1' clipPathUnits='userSpaceOnUse'>")
                .contains("<rect x='0' y='0' width='100' height='8' rx='4' ry='4' fill='#dfe6ef'/>")
                .contains("<rect x='0' y='0' width='72' height='8' fill='#2c8cff' clip-path='url(#freq-progress-clip-2)'/>")
                .doesNotContain("<div class='freq-progress'><span style='width:");
    }

    /**
     * 覆盖官方上游词汇 payload 渲染所有内联 SVG 时，每个 SVG 起始标签都应声明 Batik 可识别的 CJK 字体族。
     */
    @Test
    void renderDeclaresBatikCjkFontFamilyOnEveryInlineSvg() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("<svg class='syllabus-donut-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
                .contains("<svg class='past-paper-column-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
                .contains("<svg class='high-frequency-column-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
                .contains("<svg class='frequency-band-column-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
                .contains("<svg class='freq-progress-svg' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
                .contains("<svg font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260'");
        assertThat(svgStartTags(html)).hasSize(8).allSatisfy(svg -> assertThat(svg).contains(SVG_CJK_FONT_FAMILY));
    }

    /**
     * 覆盖 renderer 类型判断时，仅 OUTLOOK 报告类型应被该 renderer 支持。
     */
    @Test
    void supportsOnlyOutlookReportType() {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        assertThat(renderer.supports(ReportType.OUTLOOK)).isTrue();
        assertThat(renderer.supports(ReportType.ACHIEVEMENT)).isFalse();
    }

    /**
     * 覆盖渲染官方上游词汇 payload 时，应使用构造器注入的 ObjectMapper 完成唯一 public contract 反序列化。
     */
    @Test
    void renderUsesInjectedObjectMapperForPayloadDeserialization() throws Exception {
        TrackingObjectMapper objectMapper = new TrackingObjectMapper();
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(objectMapper);

        renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(objectMapper.treeToValueCalled).isTrue();
    }

    /**
     * 覆盖渲染官方上游词汇 payload 并加载 classpath 模板时，模板输入流应在渲染结束后关闭。
     */
    @Test
    void renderClosesTemplateInputStreamAfterLoadingClasspathTemplate() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
        ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
        TrackingTemplateClassLoader trackingClassLoader = new TrackingTemplateClassLoader(originalClassLoader);

        try {
            Thread.currentThread().setContextClassLoader(trackingClassLoader);

            renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
        } finally {
            Thread.currentThread().setContextClassLoader(originalClassLoader);
        }

        assertThat(trackingClassLoader.inputStream.closed).isTrue();
    }

    /**
     * 覆盖官方 payload 中当前不展示的调用方文本字段包含脚本时，渲染结果不应暴露这些外部注入文本且静态阶段文案保持不变。
     */
    @Test
    void renderDoesNotExposeCallerControlledTextFieldsThatAreNotPartOfHtmlView() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        JsonNode payload = payloadWithCallerControlledTextSamples();
        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("考前半个月·核心突击期")
                .contains("考前半小时·临阵巩固期")
                .doesNotContain("注入学生")
                .doesNotContain("evil-word")
                .doesNotContain("注入文章")
                .doesNotContain("<script>alert(1)</script>")
                .doesNotContain("<script>");
    }

    /**
     * 覆盖官方 payload 计算出的考纲掌握率达到 100% 时，考纲进度图应使用完整圆而非弧线。
     */
    @Test
    void renderUsesCircleForSyllabusProgressWhenPercentReachesHundred() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        JsonNode payload = payloadWithSyllabusProgressPercent(100);
        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("class='donut-mastered-full-circle'")
                .contains("class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'")
                .doesNotContain("class='donut-mastered-arc'")
                .doesNotContain("class='donut-unmastered-arc'");
    }

    /**
     * 覆盖官方 payload 计算出的考纲掌握率为 0% 时，考纲已掌握弧线应被省略且仅保留完整浅色底环。
     */
    @Test
    void renderSkipsProgressArcWhenSyllabusPercentIsZeroOrBelow() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        JsonNode payload = payloadWithSyllabusProgressPercent(0);
        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'")
                .doesNotContain("class='donut-mastered-arc'")
                .doesNotContain("class='donut-unmastered-arc'");
    }

    /**
     * 覆盖考纲环形图的常规进度场景时，浅色未掌握部分应由完整底环表达，避免额外浅色圆头覆盖蓝色进度弧。
     */
    @Test
    void renderSyllabusDonutUsesSingleTrackBehindProgressArc() throws Exception {
        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);

        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));

        assertThat(html)
                .contains("class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'")
                .contains("class='donut-mastered-arc'")
                .doesNotContain("class='donut-unmastered-arc'");
    }

    private static final class TrackingObjectMapper extends ObjectMapper {
        private boolean treeToValueCalled;

        @Override
        public <T> T treeToValue(com.fasterxml.jackson.core.TreeNode node, Class<T> valueType)
                throws com.fasterxml.jackson.core.JsonProcessingException {
            treeToValueCalled = true;
            return super.treeToValue(node, valueType);
        }
    }

    private static final class TrackingTemplateClassLoader extends ClassLoader {
        private final TrackingInputStream inputStream = new TrackingInputStream("template".getBytes(StandardCharsets.UTF_8));

        private TrackingTemplateClassLoader(ClassLoader parent) {
            super(parent);
        }

        @Override
        public java.io.InputStream getResourceAsStream(String name) {
            if (TEMPLATE_PATH.equals(name)) {
                return inputStream;
            }
            return super.getResourceAsStream(name);
        }
    }

    private static final class TrackingInputStream extends ByteArrayInputStream {
        private boolean closed;

        private TrackingInputStream(byte[] buf) {
            super(buf);
        }

        @Override
        public void close() throws IOException {
            closed = true;
            super.close();
        }
    }

    private JsonNode callerVocabularyPayload() throws Exception {
        return 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 JsonNode callerVocabularyPayloadWithZeroValueBars() throws Exception {
        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
        payload.withArray("StudentWordsLatest").forEach(node -> {
            ObjectNode word = (ObjectNode) node;
            if (word.path("WordFrequency").asInt() >= 7) {
                word.put("Mastery", 0.0);
            }
        });
        return payload;
    }

    private JsonNode normalizedCallerVocabularyPayloadWithStudentNameAliases() throws Exception {
        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
        payload.put("StudentName", "20260318测试");
        payload.put("studentName", "20260318测试");
        return payload;
    }

    private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
        return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
    }

    private List<String> svgStartTags(String html) {
        Matcher matcher = SVG_START_TAG_PATTERN.matcher(html);
        List<String> svgStartTags = new ArrayList<>();
        while (matcher.find()) {
            svgStartTags.add(matcher.group());
        }
        return svgStartTags;
    }

    private String extractSvgByClass(String html, String cssClass) {
        Pattern svgPattern = Pattern.compile("<svg\\b(?=[^>]*class='[^']*" + Pattern.quote(cssClass) + "[^']*')[\\s\\S]*?</svg>");
        Matcher matcher = svgPattern.matcher(html);
        if (matcher.find()) {
            return matcher.group();
        }
        throw new AssertionError("svg should exist for class '" + cssClass + "' in HTML fragment:\n" + html);
    }

    private List<String> gridLineElements(String svg) {
        Pattern gridLinePattern = Pattern.compile("<line class='chart-grid-line chart-grid-line-y'[^>]*/>");
        Matcher matcher = gridLinePattern.matcher(svg);
        List<String> gridLines = new ArrayList<>();
        while (matcher.find()) {
            gridLines.add(matcher.group());
        }
        return gridLines;
    }

    private JsonNode payloadWithCallerControlledTextSamples() throws Exception {
        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
        payload.put("StudentName", "注入学生<script>alert(1)</script>");
        payload.put("StageName", "注入阶段<script>alert(1)</script>");
        payload.put("StageExaminName", "注入考试<script>alert(1)</script>");
        payload.put("TestPaperTitle", "注入文章<script>alert(1)</script>");
        ((ObjectNode) payload.withArray("StudentWordsLatest").get(0)).put("WordSpell", "evil-word<script>alert(1)</script>");
        return payload;
    }

    private JsonNode payloadWithSyllabusProgressPercent(int masteryPercent) throws Exception {
        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
        int boundedMasteryPercent = Math.max(0, Math.min(100, masteryPercent));
        payload.put("StageVocabulary", 100);
        payload.put("MastedWordCount", boundedMasteryPercent);
        payload.put("UnMastedWordCount", 100 - boundedMasteryPercent);
        return payload;
    }
}
