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

import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
import org.junit.jupiter.api.Test;

import java.time.Instant;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

class ClasspathAchievementExamSprintReportRendererTest {

    private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");

    @Test
    void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();

        String html = renderer.render(sampleContent(), Instant.parse("2026-04-25T08:00:00Z"));

        assertSectionClassForTitle(html, "模块一：词汇量对比", "section comparison-section");
        assertSectionClassForTitle(html, "模块二：试卷熟词量对比", "section comparison-section");
        assertSectionClassForTitle(html, "模块三：实考生词命中状况", "section");

        assertModuleUsesVerticalCard(
                html,
                "<h2 class=\"section-title\">模块一：词汇量对比</h2>",
                "vocabulary-growth-chart",
                "训练前词汇量：<span class=\"highlight\">2328 词</span><br/>",
                "训练后词汇量：<span class=\"highlight\">2347 词</span><br/>",
                "本次提升：<span class=\"highlight\">+19 词</span>",
                "高考词汇量：<span class=\"highlight\">3500 词</span><br/>",
                "掌握率：<span class=\"highlight\">66.51% -&gt; 67.06%</span><br/>",
                "掌握率提升：<span class=\"highlight\">+0.55%</span>");
        assertModuleUsesVerticalCard(
                html,
                "<h2 class=\"section-title\">模块二：试卷熟词量对比</h2>",
                "paper-known-words-chart",
                "训练前熟词量：<span class=\"highlight\">650 个</span><br/>",
                "训练后熟词量：<span class=\"highlight\">654 个</span><br/>",
                "本次提升：<span class=\"highlight\">+4 个</span>",
                "试卷标题：<span class=\"highlight\">2024真题</span><br/>",
                "试卷总词量：<span class=\"highlight\">861 词</span><br/>",
                "训练前/后生词：<span class=\"highlight\">207 个 -&gt; 203 个</span><br/>",
                "试卷词率：<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>",
                "试卷词率提升：<span class=\"highlight\">+0.62%</span>");
        assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-before", "#448aff");
        assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-after", "#448aff");
        assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-before", "#34a853");
        assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-after", "#34a853");

        assertCssRuleContains(html, ".section", "margin-top: 24px;", "page-break-inside: avoid;");
        assertCssRuleContains(html, ".section-title", "margin: 0 0 14px;");
        assertCssRuleContains(html, ".comparison-section", "margin-top: 0;", "margin-bottom: 50px;");
        assertCssRuleContains(html, ".comparison-section .section-title", "margin: 0 0 25px;");
        assertCssRuleContains(html, ".card", "background: #fafbfc;", "padding: 25px;");
        assertCssRuleContains(html, ".chart-box", "height: 260px;");
        assertCssRuleContains(html, ".data-text", "line-height: 1.8;");

        assertThat(html)
                .contains("高考英语临考突击学习成果报告")
                .contains("2024真题 · 两周专项训练 · 真实提分效果")
                .contains("恭喜完成两周考前突击专项训练")
                .contains("class=\"report-container\"")
                .contains("class=\"result-header\"")
                .contains("class=\"result-grid\"")
                .contains("词汇量提升（个）")
                .contains("试卷熟词提升（个）")
                .contains("真题生词命中率")
                .contains("学习效率提升")
                .contains("模块一：词汇量对比")
                .contains("模块二：试卷熟词量对比")
                .contains("模块三：实考生词命中状况")
                .contains("class=\"result-value\">1.93%</div>")
                .contains("class=\"result-value\">0.48倍</div>")
                .contains("class=\"hit-stat-value\">1.93%</div>")
                .contains("class=\"hit-stat-value\">0.48倍</div>")
                .contains("class=\"hit-stat-value\">207 个</div>")
                .contains("class=\"hit-stat-value\">203 个</div>")
                .contains("class='achievement-bar-chart vocabulary-growth-chart'")
                .contains("class='achievement-bar-chart paper-known-words-chart'")
                .contains("chart-y-axis")
                .contains("chart-x-axis")
                .contains("chart-grid-line")
                .contains("chart-tick-label")
                .contains(".chart-tick-label {")
                .contains("font-size: 11px;")
                .contains("fill: #68768a;")
                .contains(">0</text>")
                .contains(">2347</text>")
                .contains(">654</text>")
                .contains("成功减少生词：<span class=\"highlight\">4 个</span>")
                .contains("class=\"word-list\"")
                .contains("class=\"word-item\">number</div>")
                .doesNotContain("193%")
                .doesNotContain("1.93%%")
                .doesNotContain("0.48倍倍")
                .doesNotContain("cdn.jsdelivr.net")
                .doesNotContain("echarts")
                .doesNotContain("<script")
                .doesNotContain("comparison-table")
                .doesNotContain("chart-cell")
                .doesNotContain("detail-cell")
                .doesNotContain("class=\"result-value\">0.0193</div>")
                .doesNotContain("class=\"result-value\">0.48</div>")
                .doesNotContain("class=\"hit-stat-value\">207</div>")
                .doesNotContain("class=\"hit-stat-value\">203</div>");

        assertThat(countOccurrences(html, "class='chart-grid-line'")).isEqualTo(6);
        assertThat(countOccurrences(html, "class='chart-tick-label'")).isEqualTo(6);
        assertThat(countOccurrences(html, "class='chart-y-axis'")).isEqualTo(2);
        assertThat(countOccurrences(html, "class='chart-x-axis'")).isEqualTo(2);
    }

    @Test
    void supportsOnlyAchievementReportType() {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();

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

    @Test
    void renderEscapesPayloadTextAndHitWords() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = withHitWords(
                withReportTitle(sampleContent(), "成果<script>alert(1)</script>"),
                List.of("number", "bear", "popular", "importance", "bear<script>"));

        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("成果&lt;script&gt;alert(1)&lt;/script&gt;")
                .contains("bear&lt;script&gt;")
                .doesNotContain("<script>alert(1)</script>")
                .doesNotContain("bear<script>");
    }

    @Test
    void renderEscapesCallerDisplayFieldsAndDoesNotExpandInjectedPlaceholders() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = withStageAndTestPaperSummaries(
                sampleContent(),
                new AchievementReportContent.StageVocabularySummary(
                        "高考<script>alert(1)</script>",
                        "3500",
                        "66.51",
                        "67.06",
                        "+0.55"),
                new AchievementReportContent.TestPaperVocabularySummary(
                        "{{hitWords}}<script>alert(2)</script>",
                        "861",
                        "207",
                        "203",
                        "75.49",
                        "75.96",
                        "+0.62"));

        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("高考&lt;script&gt;alert(1)&lt;/script&gt;词汇量")
                .contains("试卷标题：<span class=\"highlight\">{{hitWords}}&lt;script&gt;alert(2)&lt;/script&gt;</span><br/>")
                .contains("class=\"word-item\">number</div>")
                .doesNotContain("<script>alert(1)</script>")
                .doesNotContain("<script>alert(2)</script>")
                .doesNotContain("试卷标题：<span class=\"highlight\"><div class=\"word-item\">number</div>");
    }

    @Test
    void renderShowsEmptyStateWhenHitWordsAreEmpty() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = withHitWords(sampleContent(), List.of());

        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("class=\"word-empty\"")
                .contains("暂无命中单词")
                .doesNotContain("class=\"word-item\"");
    }

    @Test
    void renderShowsEmptyStateWhenHitWordsAreNull() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = withHitWords(sampleContent(), null);

        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("class=\"word-empty\"")
                .contains("暂无命中单词")
                .doesNotContain("class=\"word-item\"");
    }

    @Test
    void renderUsesSafeChartValuesWhenComparisonNumbersAreMissingOrNonFinite() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = sampleContent();
        AchievementReportContent mutated = withVocabularyComparison(
                content,
                new AchievementReportContent.Comparison(
                        null,
                        Double.NaN,
                        content.vocabularyComparison().beforeText(),
                        content.vocabularyComparison().afterText(),
                        content.vocabularyComparison().growthText()));

        String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("class='achievement-bar-chart vocabulary-growth-chart'")
                .doesNotContain("NaN")
                .doesNotContain("Infinity");
    }

    @Test
    void renderDoesNotExpandPlaceholdersInjectedByPayloadText() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = sampleContent();
        AchievementReportContent mutated = withVocabularyComparison(
                withReportTitle(content, "{{hitWords}}"),
                new AchievementReportContent.Comparison(
                        content.vocabularyComparison().beforeValue(),
                        content.vocabularyComparison().afterValue(),
                        "{{hitWords}}",
                        content.vocabularyComparison().afterText(),
                        content.vocabularyComparison().growthText()));

        String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("<h1 class=\"report-title\">{{hitWords}}</h1>")
                .contains("训练前词汇量：<span class=\"highlight\">{{hitWords}}</span><br/>")
                .contains(">{{hitWords}}</text>")
                .contains("class=\"word-item\">number</div>")
                .doesNotContain("{{hitWords}} 词");
    }

    @Test
    void renderKeepsLegacyUnitTextIdempotent() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = sampleContent();
        AchievementReportContent mutated = new AchievementReportContent(
                content.studentName(),
                content.reportTitle(),
                content.reportSubtitle(),
                content.completionTitle(),
                content.completionSubtitle(),
                new AchievementReportContent.SummaryMetrics(
                        content.summaryMetrics().vocabularyGrowthText(),
                        content.summaryMetrics().paperKnownWordsGrowthText(),
                        "1.93%",
                        "0.48倍"),
                new AchievementReportContent.Comparison(
                        content.vocabularyComparison().beforeValue(),
                        content.vocabularyComparison().afterValue(),
                        "2328 词",
                        "2347词",
                        "+19 词"),
                new AchievementReportContent.Comparison(
                        content.paperKnownWordsComparison().beforeValue(),
                        content.paperKnownWordsComparison().afterValue(),
                        "650 个",
                        "654个",
                        "+4 个"),
                new AchievementReportContent.StageVocabularySummary(
                        content.stageVocabularySummary().stageName(),
                        "3500 词",
                        "66.51%",
                        "67.06%",
                        "+0.55%"),
                new AchievementReportContent.TestPaperVocabularySummary(
                        content.testPaperVocabularySummary().testPaperTitle(),
                        "861 词",
                        "207 个",
                        "203个",
                        "75.49%",
                        "75.96%",
                        "+0.62%"),
                new AchievementReportContent.ExamUnknownWordsHitStatus(
                        "1.93%",
                        "0.48倍",
                        "207 个",
                        "203个",
                        "4 个",
                        content.examUnknownWordsHitStatus().hitWords()));

        String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("class=\"result-value\">1.93%</div>")
                .contains("class=\"result-value\">0.48倍</div>")
                .contains("训练前词汇量：<span class=\"highlight\">2328 词</span><br/>")
                .contains("训练后词汇量：<span class=\"highlight\">2347词</span><br/>")
                .contains("本次提升：<span class=\"highlight\">+19 词</span>")
                .contains("训练前熟词量：<span class=\"highlight\">650 个</span><br/>")
                .contains("训练后熟词量：<span class=\"highlight\">654个</span><br/>")
                .contains("本次提升：<span class=\"highlight\">+4 个</span>")
                .contains("高考词汇量：<span class=\"highlight\">3500 词</span><br/>")
                .contains("掌握率：<span class=\"highlight\">66.51% -&gt; 67.06%</span><br/>")
                .contains("掌握率提升：<span class=\"highlight\">+0.55%</span>")
                .contains("试卷总词量：<span class=\"highlight\">861 词</span><br/>")
                .contains("训练前/后生词：<span class=\"highlight\">207 个 -&gt; 203个</span><br/>")
                .contains("试卷词率：<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>")
                .contains("试卷词率提升：<span class=\"highlight\">+0.62%</span>")
                .contains("class=\"hit-stat-value\">1.93%</div>")
                .contains("class=\"hit-stat-value\">0.48倍</div>")
                .contains("class=\"hit-stat-value\">207 个</div>")
                .contains("class=\"hit-stat-value\">203个</div>")
                .contains("成功减少生词：<span class=\"highlight\">4 个</span>")
                .doesNotContain("1.93%%")
                .doesNotContain("0.48倍倍")
                .doesNotContain("2328 词 词")
                .doesNotContain("2347词 词")
                .doesNotContain("650 个 个")
                .doesNotContain("654个 个")
                .doesNotContain("3500 词 词")
                .doesNotContain("861 词 词")
                .doesNotContain("66.51%%")
                .doesNotContain("75.49%%")
                .doesNotContain("207 个 个")
                .doesNotContain("203个 个");
    }

    @Test
    void renderKeepsNonFinitePercentTextUnchanged() throws Exception {
        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
        AchievementReportContent content = sampleContent();
        AchievementReportContent mutated = new AchievementReportContent(
                content.studentName(),
                content.reportTitle(),
                content.reportSubtitle(),
                content.completionTitle(),
                content.completionSubtitle(),
                new AchievementReportContent.SummaryMetrics(
                        content.summaryMetrics().vocabularyGrowthText(),
                        content.summaryMetrics().paperKnownWordsGrowthText(),
                        "NaN",
                        content.summaryMetrics().learningEfficiencyText()),
                content.vocabularyComparison(),
                content.paperKnownWordsComparison(),
                content.stageVocabularySummary(),
                content.testPaperVocabularySummary(),
                new AchievementReportContent.ExamUnknownWordsHitStatus(
                        "Infinity",
                        content.examUnknownWordsHitStatus().learningEfficiencyText(),
                        content.examUnknownWordsHitStatus().unknownWordsBeforeText(),
                        content.examUnknownWordsHitStatus().unknownWordsAfterText(),
                        content.examUnknownWordsHitStatus().reducedUnknownWordsText(),
                        content.examUnknownWordsHitStatus().hitWords()));

        String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));

        assertThat(html)
                .contains("class=\"result-value\">NaN</div>")
                .contains("class=\"hit-stat-value\">Infinity</div>")
                .doesNotContain("class=\"result-value\">0%</div>")
                .doesNotContain("class=\"hit-stat-value\">0%</div>");
    }

    private AchievementReportContent sampleContent() {
        return new AchievementReportContent(
                "吴泓妤",
                "高考英语临考突击学习成果报告",
                "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 AchievementReportContent withReportTitle(AchievementReportContent content, String reportTitle) {
        return new AchievementReportContent(
                content.studentName(),
                reportTitle,
                content.reportSubtitle(),
                content.completionTitle(),
                content.completionSubtitle(),
                content.summaryMetrics(),
                content.vocabularyComparison(),
                content.paperKnownWordsComparison(),
                content.stageVocabularySummary(),
                content.testPaperVocabularySummary(),
                content.examUnknownWordsHitStatus());
    }

    private AchievementReportContent withVocabularyComparison(AchievementReportContent content,
                                                              AchievementReportContent.Comparison vocabularyComparison) {
        return new AchievementReportContent(
                content.studentName(),
                content.reportTitle(),
                content.reportSubtitle(),
                content.completionTitle(),
                content.completionSubtitle(),
                content.summaryMetrics(),
                vocabularyComparison,
                content.paperKnownWordsComparison(),
                content.stageVocabularySummary(),
                content.testPaperVocabularySummary(),
                content.examUnknownWordsHitStatus());
    }

    private AchievementReportContent withHitWords(AchievementReportContent content, List<String> hitWords) {
        AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = content.examUnknownWordsHitStatus();
        return new AchievementReportContent(
                content.studentName(),
                content.reportTitle(),
                content.reportSubtitle(),
                content.completionTitle(),
                content.completionSubtitle(),
                content.summaryMetrics(),
                content.vocabularyComparison(),
                content.paperKnownWordsComparison(),
                content.stageVocabularySummary(),
                content.testPaperVocabularySummary(),
                new AchievementReportContent.ExamUnknownWordsHitStatus(
                        hitStatus.unknownWordHitRateText(),
                        hitStatus.learningEfficiencyText(),
                        hitStatus.unknownWordsBeforeText(),
                        hitStatus.unknownWordsAfterText(),
                        hitStatus.reducedUnknownWordsText(),
                        hitWords));
    }

    private AchievementReportContent withStageAndTestPaperSummaries(
            AchievementReportContent content,
            AchievementReportContent.StageVocabularySummary stageVocabularySummary,
            AchievementReportContent.TestPaperVocabularySummary testPaperVocabularySummary) {
        return new AchievementReportContent(
                content.studentName(),
                content.reportTitle(),
                content.reportSubtitle(),
                content.completionTitle(),
                content.completionSubtitle(),
                content.summaryMetrics(),
                content.vocabularyComparison(),
                content.paperKnownWordsComparison(),
                stageVocabularySummary,
                testPaperVocabularySummary,
                content.examUnknownWordsHitStatus());
    }

    private void assertModuleUsesVerticalCard(
            String html,
            String sectionTitle,
            String chartClass,
            String beforeLine,
            String afterLine,
            String growthLine,
            String... additionalLines) {
        int sectionStart = html.indexOf(sectionTitle);
        assertThat(sectionStart)
                .as("module section should exist: %s", sectionTitle)
                .isGreaterThanOrEqualTo(0);

        int nextSectionStart = html.indexOf("<h2 class=\"section-title\">", sectionStart + sectionTitle.length());
        String sectionHtml = nextSectionStart >= 0
                ? html.substring(sectionStart, nextSectionStart)
                : html.substring(sectionStart);
        String chartBox = "<div class=\"chart-box\">";
        String svgClass = "class='achievement-bar-chart " + chartClass + "'";

        assertThat(sectionHtml)
                .as("%s should render as card -> chart-box -> data-text", sectionTitle)
                .containsSubsequence(
                        sectionTitle,
                        "<div class=\"card\">",
                        chartBox,
                        svgClass,
                        "</div>",
                        "<div class=\"data-text\">",
                        beforeLine,
                        afterLine,
                        growthLine);
        assertThat(sectionHtml)
                .as("%s should include supplemental caller payload fields", sectionTitle)
                .contains(additionalLines);

        int cardIndex = sectionHtml.indexOf("<div class=\"card\">");
        int chartBoxIndex = sectionHtml.indexOf(chartBox, Math.max(cardIndex, 0));
        int svgClassIndex = sectionHtml.indexOf(svgClass, Math.max(chartBoxIndex, 0));
        int chartBoxCloseIndex = sectionHtml.indexOf("</div>", Math.max(svgClassIndex, 0));
        int dataTextIndex = sectionHtml.indexOf("<div class=\"data-text\">", Math.max(chartBoxCloseIndex, 0));
        assertThat(cardIndex)
                .as("%s should contain card", sectionTitle)
                .isGreaterThanOrEqualTo(0);
        assertThat(chartBoxIndex)
                .as("%s should contain chart-box inside card", sectionTitle)
                .isGreaterThan(cardIndex);
        assertThat(svgClassIndex)
                .as("%s should contain the expected chart inside chart-box", sectionTitle)
                .isGreaterThan(chartBoxIndex);
        assertThat(chartBoxCloseIndex)
                .as("%s should close chart-box before data-text", sectionTitle)
                .isGreaterThan(svgClassIndex);
        assertThat(dataTextIndex)
                .as("%s should place data-text after chart-box", sectionTitle)
                .isGreaterThan(chartBoxCloseIndex);
    }

    private String extractChartSvg(String html, String chartClass) {
        int chartClassIndex = html.indexOf(chartClass);
        assertThat(chartClassIndex)
                .as("chart should exist: %s", chartClass)
                .isGreaterThanOrEqualTo(0);

        int svgStart = html.lastIndexOf("<svg", chartClassIndex);
        int svgEnd = html.indexOf("</svg>", chartClassIndex);
        assertThat(svgStart)
                .as("chart should start with svg: %s", chartClass)
                .isGreaterThanOrEqualTo(0);
        assertThat(svgEnd)
                .as("chart should close svg: %s", chartClass)
                .isGreaterThan(svgStart);
        return html.substring(svgStart, svgEnd + "</svg>".length());
    }

    private void assertSectionClassForTitle(String html, String title, String expectedClass) {
        String sectionTitle = "<h2 class=\"section-title\">" + title + "</h2>";
        int titleIndex = html.indexOf(sectionTitle);
        assertThat(titleIndex)
                .as("section title should exist: %s", title)
                .isGreaterThanOrEqualTo(0);

        int sectionStart = html.lastIndexOf("<div class=\"", titleIndex);
        int classEnd = html.indexOf("\">", sectionStart);
        assertThat(sectionStart)
                .as("section wrapper should exist before title: %s", title)
                .isGreaterThanOrEqualTo(0);
        assertThat(classEnd)
                .as("section wrapper should close before title: %s", title)
                .isGreaterThan(sectionStart);
        assertThat(html.substring(sectionStart, classEnd + "\">".length()))
                .as("section wrapper class for %s", title)
                .isEqualTo("<div class=\"" + expectedClass + "\">");
    }

    private void assertCssRuleContains(String html, String selector, String... declarations) {
        String rule = extractCssRule(html, selector);
        assertThat(rule)
                .as("CSS rule for %s", selector)
                .contains(declarations);
    }

    private String extractCssRule(String html, String selector) {
        String css = extractCssText(html);
        int searchFrom = 0;
        while (searchFrom < css.length()) {
            int selectorIndex = css.indexOf(selector, searchFrom);
            if (selectorIndex < 0) {
                break;
            }

            int braceStart = css.indexOf('{', selectorIndex + selector.length());
            if (braceStart < 0) {
                break;
            }

            boolean startsRule = isRuleStart(css, selectorIndex);
            boolean selectorMatchesExactly = css.substring(selectorIndex + selector.length(), braceStart).trim().isEmpty();
            if (startsRule && selectorMatchesExactly) {
                int braceEnd = css.indexOf('}', braceStart);
                assertThat(braceEnd)
                        .as("CSS rule for %s should close", selector)
                        .isGreaterThan(braceStart);
                return css.substring(selectorIndex, braceEnd + 1);
            }

            searchFrom = selectorIndex + selector.length();
        }

        throw new AssertionError("CSS rule should exist for selector '" + selector + "' in style block:\n" + css);
    }

    private String extractCssText(String html) {
        int styleStart = html.indexOf("<style>");
        assertThat(styleStart)
                .as("HTML should contain style block")
                .isGreaterThanOrEqualTo(0);

        int styleContentStart = styleStart + "<style>".length();
        int styleEnd = html.indexOf("</style>", styleContentStart);
        assertThat(styleEnd)
                .as("HTML style block should close")
                .isGreaterThan(styleContentStart);
        return html.substring(styleContentStart, styleEnd);
    }

    private boolean isRuleStart(String css, int selectorIndex) {
        int previous = selectorIndex - 1;
        while (previous >= 0 && Character.isWhitespace(css.charAt(previous))) {
            previous--;
        }
        return previous < 0 || css.charAt(previous) == '}';
    }

    private void assertBarFill(String svg, String rectClass, String expectedFill) {
        String rect = extractRect(svg, rectClass);
        assertThat(rect)
                .as("%s should use %s", rectClass, expectedFill)
                .contains("fill='" + expectedFill + "'");
    }

    private String extractRect(String svgOrHtml, String rectClass) {
        Matcher rectMatcher = SELF_CLOSING_RECT_PATTERN.matcher(svgOrHtml);
        String singleQuotedClass = "class='" + rectClass + "'";
        String doubleQuotedClass = "class=\"" + rectClass + "\"";
        while (rectMatcher.find()) {
            String rect = rectMatcher.group();
            if (rect.contains(singleQuotedClass) || rect.contains(doubleQuotedClass)) {
                return rect;
            }
        }
        throw new AssertionError("rect should exist for class '" + rectClass + "' in SVG/HTML fragment:\n" + svgOrHtml);
    }

    private int countOccurrences(String value, String substring) {
        int count = 0;
        int index = 0;
        while ((index = value.indexOf(substring, index)) >= 0) {
            count++;
            index += substring.length();
        }
        return count;
    }
}
