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

import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;

@Component
public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer {

    private static final String TEMPLATE_RESOURCE = "templates/outlook-exam-sprint-report-template.html";
    private static final String DEFAULT_THEME_COLOR = "#448aff";
    private static final String SVG_CJK_FONT_FAMILY = " font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
    private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
    private static final int CHART_AXIS_LEFT = 34;
    private static final int CHART_AXIS_TOP = 50;
    private static final int CHART_AXIS_BOTTOM = 180;
    private static final int CHART_AXIS_HEIGHT = CHART_AXIS_BOTTOM - CHART_AXIS_TOP;

    private final ObjectMapper objectMapper;

    public ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper) {
        this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
    }

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

    @Override
    public String render(ReportContent content, Instant generatedAt) {
        if (!(content instanceof UnmodeledReportContent unmodeledContent)
                || unmodeledContent.reportType() != ReportType.OUTLOOK
                || !(unmodeledContent.source() instanceof JsonNode payload)) {
            throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content");
        }
        try {
            OutlookExamSprintReportPayload payloadContract = objectMapper.treeToValue(payloadForDeserialization(payload), OutlookExamSprintReportPayload.class);
            OutlookReportViewModel reportPayload = adaptPayload(payloadContract);
            return loadTemplate()
                    .replace("{{syllabusMasterySection}}", renderSyllabusMasteryChart(reportPayload.syllabusMasterySection()))
                    .replace("{{pastPaperVocabularySection}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularySection()))
                    .replace("{{highFrequencyVocabularySection}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularySection()))
                    .replace("{{frequencyBandSection}}", renderVocabularyFrequencyBandChart(reportPayload.frequencyBandSection()))
                    .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.studyPlan()))
                    .replace("{{caseStudySection}}", renderScoreImprovementCaseStudy(reportPayload.caseStudy()));
        } catch (IOException exception) {
            throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
        } catch (Exception exception) {
            throw new IllegalStateException("Failed to render outlook exam sprint report", exception);
        }
    }

    private JsonNode payloadForDeserialization(JsonNode payload) {
        if (!payload.has("StudentName") || !payload.has("studentName")) {
            return payload;
        }
        ObjectNode deserializationPayload = ((ObjectNode) payload).deepCopy();
        deserializationPayload.remove("studentName");
        return deserializationPayload;
    }

    private OutlookReportViewModel adaptPayload(OutlookExamSprintReportPayload payload) {
        int stageVocabulary = payload.stageVocabulary();
        int masteredWordCount = payload.mastedWordCount();
        int unmasteredWordCount = payload.unMastedWordCount();
        int masteryPercent = (int) Math.round(percentage(masteredWordCount, stageVocabulary));
        List<OutlookExamSprintReportPayload.StudentWordLatest> words = participatingWords(payload);

        int basicUpper = (int) Math.ceil(stageVocabulary * 0.2d);
        int frequentUpper = (int) Math.ceil(stageVocabulary * 0.6d);
        int highUpper = (int) Math.ceil(stageVocabulary * 0.3d);
        int midUpper = (int) Math.ceil(stageVocabulary * 0.7d);

        return new OutlookReportViewModel(
                new SyllabusMasteryChart(
                        stageVocabulary,
                        masteredWordCount,
                        unmasteredWordCount,
                        masteryPercent,
                        "考纲词汇掌握概览",
                        "优先补齐高频和核心常考词。"),
                new PastPaperVocabularyChart(
                        payload.testPaperWordCount(),
                        payload.testPaperUnMasterWords().size(),
                        sprintUnknownWordCount(payload.testPaperUnMasterWords().size()),
                        "预计提分5-15分",
                        null),
                new HighFrequencyVocabularyChart(
                        roundedMasteryPercent(words, 0, basicUpper),
                        roundedMasteryPercent(words, basicUpper, frequentUpper),
                        roundedMasteryPercent(words, frequentUpper, stageVocabulary),
                        null),
                new VocabularyFrequencyBandChart(
                        List.of(
                                new VocabularyFrequencyBar(
                                        "高频词", masteryPercent(words, 0, highUpper), "优先学习", "#448aff"),
                                new VocabularyFrequencyBar(
                                        "中频词", masteryPercent(words, highUpper, midUpper), "重点突破", "#4caf50"),
                                new VocabularyFrequencyBar(
                                        "低频词", masteryPercent(words, midUpper, stageVocabulary), "酌情学习", "#ff9800"))),
                staticFrequencyPlan(),
                staticScoreImprovementCaseStudy());
    }

    private List<OutlookExamSprintReportPayload.StudentWordLatest> participatingWords(OutlookExamSprintReportPayload payload) {
        int stageVocabulary = payload.stageVocabulary();
        return payload.studentWordsLatest().stream()
                .filter(word -> word.wordFrequency() >= 1 && word.wordFrequency() <= stageVocabulary)
                .toList();
    }

    private int sprintUnknownWordCount(int unknownWordCountBeforeSprint) {
        return (int) Math.round(unknownWordCountBeforeSprint * 0.75d);
    }

    private int roundedMasteryPercent(List<OutlookExamSprintReportPayload.StudentWordLatest> words,
                                      int lowerExclusive,
                                      int upperInclusive) {
        return (int) Math.round(masteryPercent(words, lowerExclusive, upperInclusive));
    }

    private double masteryPercent(List<OutlookExamSprintReportPayload.StudentWordLatest> words,
                                  int lowerExclusive,
                                  int upperInclusive) {
        List<OutlookExamSprintReportPayload.StudentWordLatest> matchedWords = words.stream()
                .filter(word -> word.wordFrequency() > lowerExclusive && word.wordFrequency() <= upperInclusive)
                .toList();
        if (matchedWords.isEmpty()) {
            return 0d;
        }
        return matchedWords.stream()
                .mapToDouble(OutlookExamSprintReportPayload.StudentWordLatest::mastery)
                .average()
                .orElse(0d) * 100d;
    }

    private FrequencyPlan staticFrequencyPlan() {
        return new FrequencyPlan(
                List.of(
                        new FrequencyPlanCard(2, "+10分", 55, false, "均衡", "②"),
                        new FrequencyPlanCard(3, "+15分", 72, true, "推荐", "③"),
                        new FrequencyPlanCard(5, "+20分", 88, false, "冲刺", "④")),
                "💡建议策略",
                "7 天提分冲刺是首选节奏，按词频优先级记忆，不浪费时间；只攻克高频/中频核心词，2周15小时速记500-800必考词，快速缩小生词缺口。",
                List.of(
                        new PhaseSuggestion("考前半个月·核心突击期", "围绕高频词建立记忆闭环。"),
                        new PhaseSuggestion("考前半小时·临阵巩固期", "结合真题词做循环巩固。")));
    }

    private ScoreImprovementCaseStudy staticScoreImprovementCaseStudy() {
        return new ScoreImprovementCaseStudy(
                "真实提分 · 效果可复制",
                "王雷宇",
                "考前半个月·核心突击期",
                705,
                237,
                33.8,
                "70分以下",
                89,
                19);
    }

    private record OutlookReportViewModel(
            SyllabusMasteryChart syllabusMasterySection,
            PastPaperVocabularyChart pastPaperVocabularySection,
            HighFrequencyVocabularyChart highFrequencyVocabularySection,
            VocabularyFrequencyBandChart frequencyBandSection,
            FrequencyPlan studyPlan,
            ScoreImprovementCaseStudy caseStudy) {
    }

    private record SyllabusMasteryChart(
            int totalWordCount,
            int masteredWordCount,
            int unmasteredWordCount,
            int masteryPercent,
            String summaryLabel,
            String recommendation) {
    }

    private record PastPaperVocabularyChart(
            int totalWordCount,
            int unknownWordCountBeforeSprint,
            int unknownWordCountAfterSprint,
            String projectedScoreGainLabel,
            String recommendation) {
    }

    private record HighFrequencyVocabularyChart(
            int basicCorePercent,
            int frequentCorePercent,
            int advancedScorePercent,
            String highlightLabel) {
    }

    private record VocabularyFrequencyBandChart(List<VocabularyFrequencyBar> bars) {
    }

    private record VocabularyFrequencyBar(
            String bandLabel,
            double currentValue,
            String priorityLabel,
            String themeColor) {
    }

    private record FrequencyPlan(
            List<FrequencyPlanCard> cards,
            String recommendationTitle,
            String recommendationSummary,
            List<PhaseSuggestion> phaseSuggestions) {
    }

    private record FrequencyPlanCard(
            int cadencePerWeek,
            String scoreGainLabel,
            int winRatePercent,
            boolean recommended,
            String badgeLabel,
            String emphasisIcon) {
    }

    private record PhaseSuggestion(String title, String description) {
    }

    private record ScoreImprovementCaseStudy(
            String headline,
            String learnerName,
            String studyPeriodLabel,
            int memorizedWordCount,
            int examHitWordCount,
            double hitRatePercent,
            String baselineScoreLabel,
            int finalScore,
            int scoreGain) {
    }

    private String loadTemplate() throws IOException {
        try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
        }
    }

    private String renderSyllabusMasteryChart(SyllabusMasteryChart chart) {
        double masteredPercent = percentage(chart.masteredWordCount(), chart.totalWordCount());
        double unmasteredPercent = percentage(chart.unmasteredWordCount(), chart.totalWordCount());

        StringBuilder builder = new StringBuilder();
        builder
                .append("<div class='card'>")
                .append("<h3 class='card-title'>考纲词汇掌握情况</h3>")
                .append("<div class='chart-box'>")
                .append("<svg class='syllabus-donut-chart'").append(SVG_CJK_FONT_FAMILY)
                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
                .append("<circle class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'></circle>")
                .append(renderProgressRing("donut-mastered-arc", "donut-mastered-full-circle", 110, 110, 76, masteredPercent, "#448aff"))
                .append("<text class='chart-percent' x='110' y='106' text-anchor='middle' fill='#2b4c8a' font-size='28' font-weight='700'>")
                .append(formatTwoDecimals(masteredPercent)).append("%</text>")
                .append("<text class='chart-caption' x='110' y='131' text-anchor='middle' fill='#5f6b7a' font-size='14'>掌握率</text>")
                .append("</svg>")
                .append("</div>")
                .append("<div class='data-text'>")
                .append("考纲总量：<span class='highlight'>").append(chart.totalWordCount())
                .append("词</span> | 已掌握：<span class='highlight'>").append(chart.masteredWordCount())
                .append("词（").append(formatTwoDecimals(masteredPercent)).append("%）</span><br/>")
                .append("未掌握：<span class='highlight'>").append(chart.unmasteredWordCount())
                .append("词（").append(formatTwoDecimals(unmasteredPercent)).append("%）</span>")
                .append("</div>")
                .append("</div>");
        return builder.toString();
    }

    private String renderPastPaperVocabularyChart(PastPaperVocabularyChart chart) {
        int axisMax = chart.totalWordCount();
        int yAxisTickStep = axisMax <= 0 ? 1 : axisMax;
        int totalHeight = barHeight(chart.totalWordCount(), axisMax);
        int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), axisMax);
        double beforePercent = percentage(chart.unknownWordCountBeforeSprint(), chart.totalWordCount());
        double afterPercent = percentage(chart.unknownWordCountAfterSprint(), chart.totalWordCount());

        StringBuilder builder = new StringBuilder();
        builder
                .append("<div class='card'>")
                .append("<h3 class='card-title'>真题试卷词汇掌握情况</h3>")
                .append("<div class='chart-box'>")
                .append("<svg class='past-paper-column-chart'").append(SVG_CJK_FONT_FAMILY)
                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
                .append(renderYAxisGridLines(320, axisMax, yAxisTickStep))
                .append(renderChartAxes(320))
                .append(renderYAxisTicks(axisMax, yAxisTickStep))
                .append(renderXAxisTickMarks(112, 208))
                .append("<rect class='chart-column total-column' x='84' y='").append(CHART_AXIS_BOTTOM - totalHeight)
                .append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
                .append("<rect class='chart-column unknown-column' x='180' y='").append(CHART_AXIS_BOTTOM - unknownHeight)
                .append("' width='56' height='").append(unknownHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
                .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>总词量</text>")
                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>生词量</text>")
                .append("</svg>")
                .append("</div>")
                .append("<div class='data-text'>")
                .append("真题总词：").append(chart.totalWordCount()).append("词 | 生词量：")
                .append(chart.unknownWordCountBeforeSprint()).append("词（")
                .append(formatTwoDecimals(beforePercent)).append("%）<br/>")
                .append("冲刺后生词：").append(chart.unknownWordCountAfterSprint())
                .append("词，生词占比降至").append(formatTwoDecimals(afterPercent)).append("%，")
                .append(escape(chart.projectedScoreGainLabel()))
                .append("</div>");

        if (chart.recommendation() != null && !chart.recommendation().isBlank()) {
            builder.append("<p class='data-text'>").append(escape(chart.recommendation())).append("</p>");
        }

        return builder.append("</div>")
                .toString();
    }

    private String renderHighFrequencyVocabularyChart(HighFrequencyVocabularyChart chart) {
        int axisMax = 100;
        int basicHeight = barHeight(chart.basicCorePercent(), axisMax);
        int frequentHeight = barHeight(chart.frequentCorePercent(), axisMax);
        int advancedHeight = barHeight(chart.advancedScorePercent(), axisMax);

        StringBuilder builder = new StringBuilder();
        builder
                .append("<div class='card'>")
                .append("<h3 class='card-title'>常考词汇掌握情况</h3>")
                .append("<div class='chart-box'>")
                .append("<svg class='high-frequency-column-chart'").append(SVG_CJK_FONT_FAMILY)
                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='常考词汇掌握情况'>")
                .append(renderYAxisGridLines(360, axisMax, 20))
                .append(renderChartAxes(360))
                .append(renderYAxisTicks(axisMax, 20))
                .append(renderXAxisTickMarks(97, 187, 277))
                .append("<rect class='chart-column basic-core-column' x='70' y='").append(CHART_AXIS_BOTTOM - basicHeight)
                .append("' width='54' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
                .append("<rect class='chart-column frequent-core-column' x='160' y='").append(CHART_AXIS_BOTTOM - frequentHeight)
                .append("' width='54' height='").append(frequentHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
                .append("<rect class='chart-column advanced-score-column' x='250' y='").append(CHART_AXIS_BOTTOM - advancedHeight)
                .append("' width='54' height='").append(advancedHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
                .append("<text class='chart-caption' x='97' y='198' text-anchor='middle'>基础必会词</text>")
                .append("<text class='chart-caption' x='187' y='198' text-anchor='middle'>核心常考词</text>")
                .append("<text class='chart-caption' x='277' y='198' text-anchor='middle'>拔高拉分词</text>")
                .append("</svg>")
                .append("</div>")
                .append("<div class='data-text'>基础必会词：综合掌握率").append(chart.basicCorePercent())
                .append("%<br/>核心常考词：综合掌握率").append(chart.frequentCorePercent())
                .append("%<br/>拔高拉分词：综合掌握率").append(chart.advancedScorePercent()).append("%</div>");

        if (chart.highlightLabel() != null && !chart.highlightLabel().isBlank()) {
            builder.append("<p class='data-text'><span class='highlight'>")
                    .append(escape(chart.highlightLabel()))
                    .append("</span></p>");
        }

        return builder.append("</div>")
                .toString();
    }

    private String renderVocabularyFrequencyBandChart(VocabularyFrequencyBandChart chart) {
        List<VocabularyFrequencyBar> bars = chart.bars();
        double highest = bars.stream()
                .mapToDouble(VocabularyFrequencyBar::currentValue)
                .max()
                .orElse(1d);
        int axisMax = roundUpToStep((int) Math.ceil(highest), 50);
        int maxScaled = axisMax * 10;

        StringBuilder builder = new StringBuilder();
        builder.append("<div class='card'>")
                .append("<h3 class='card-title'>词频区间掌握度</h3>")
                .append("<div class='chart-box'>")
                .append("<svg class='frequency-band-column-chart'").append(SVG_CJK_FONT_FAMILY)
                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>")
                .append(renderYAxisGridLines(360, axisMax, 50))
                .append(renderChartAxes(360))
                .append(renderYAxisTicks(axisMax, 50))
                .append(renderXAxisTickMarks(97, 187, 277));

        int[] xPositions = {70, 160, 250};
        String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
        for (int index = 0; index < Math.min(3, bars.size()); index++) {
            VocabularyFrequencyBar bar = bars.get(index);
            int height = barHeight((int) Math.round(bar.currentValue() * 10d), maxScaled);
            int x = xPositions[index];

            builder.append("<rect class='chart-column ").append(columnClasses[index]).append("' x='")
                    .append(x).append("' y='").append(CHART_AXIS_BOTTOM - height).append("' width='54' height='")
                    .append(height).append("' rx='8' ry='8' fill='").append(safeColor(bar.themeColor())).append("'/>")
                    .append("<text class='donut-label-text' x='").append(x + 27).append("' y='198' text-anchor='middle'>")
                    .append(escape(bar.bandLabel())).append("</text>")
                    .append("<text class='chart-caption' x='").append(x + 27).append("' y='20' text-anchor='middle'>")
                    .append(escape(bar.priorityLabel())).append("</text>");
        }

        builder.append("</svg>")
                .append("</div>")
                .append("<div class='data-text'>");

        for (int index = 0; index < Math.min(3, bars.size()); index++) {
            VocabularyFrequencyBar bar = bars.get(index);
            if (index > 0) {
                builder.append("<br/>");
            }
            builder.append(escape(bar.bandLabel())).append("：")
                    .append(formatOneDecimal(bar.currentValue()))
                    .append("%")
                    .append("（")
                    .append(escape(bar.priorityLabel()))
                    .append("）");
        }

        builder.append("</div>")
                .append("</div>");
        return builder.toString();
    }

    private String renderStudySuggestionSection(FrequencyPlan studyPlan) {
        StringBuilder builder = new StringBuilder();
        builder.append("<div class='suggest-item'>")
                .append("<h4>🎯 练习学案频率与提分规划</h4>")
                .append("<p class='text-desc'>")
                .append(escape(studyPlan.recommendationSummary()))
                .append("</p>")
                .append("</div>")
                .append("<table class='frequency-table' role='presentation'><tr class='frequency-row'>");

        for (int index = 0; index < studyPlan.cards().size(); index++) {
            FrequencyPlanCard card = studyPlan.cards().get(index);
            int columnNumber = index + 1;
            builder.append("<td class='frequency-cell frequency-cell-").append(columnNumber).append("'>")
                    .append("<div class='freq-card card-").append(columnNumber);
            if (card.recommended()) {
                builder.append(" active");
            }
            builder.append("'>");

            if (card.recommended()) {
                String badgeLabel = card.badgeLabel() == null || card.badgeLabel().isBlank() ? "推荐" : card.badgeLabel();
                builder.append("<div class='freq-header'>")
                        .append(card.cadencePerWeek()).append("套/周")
                        .append(" <span class='badge'>").append(escape(badgeLabel)).append("</span>")
                        .append("</div>");
            } else if (card.cadencePerWeek() == 5) {
                builder.append("<table class='freq-header-table' role='presentation'><tr>")
                        .append("<td class='freq-title-cell'><div class='freq-header'>")
                        .append(card.cadencePerWeek()).append("套/周")
                        .append("</div></td>")
                        .append("<td class='freq-star-cell'><span class='crown'>★</span></td>")
                        .append("</tr></table>");
            } else {
                builder.append("<div class='freq-header'>")
                        .append(card.cadencePerWeek()).append("套/周")
                        .append("</div>");
            }

            builder.append(renderFrequencyPlanProgressBar(card.winRatePercent(), columnNumber))
                    .append("<div class='freq-data'>提升 <strong>")
                    .append(escape(card.scoreGainLabel()))
                    .append("</strong> · 胜率 <strong>")
                    .append(card.winRatePercent())
                    .append("%</strong></div>")
                    .append("</div>")
                    .append("</td>");
        }

        builder.append("</tr></table>")
                .append("<div class='suggest-note'><strong>")
                .append(escape(studyPlan.recommendationTitle()))
                .append("：</strong>")
                .append(escape(studyPlan.recommendationSummary()))
                .append("</div>")
                .append("<div class='suggest-box'>");

        for (PhaseSuggestion suggestion : studyPlan.phaseSuggestions()) {
            builder.append("<div class='suggest-item'>")
                    .append("<h4>").append(escape(suggestion.title())).append("</h4>")
                    .append("<p>").append(escape(suggestion.description())).append("</p>")
                    .append("</div>");
        }

        builder.append("</div>");
        return builder.toString();
    }

    private String renderFrequencyPlanProgressBar(int percent, int columnNumber) {
        int boundedPercent = Math.max(0, Math.min(100, percent));
        String clipId = "freq-progress-clip-" + columnNumber;
        return new StringBuilder()
                .append("<svg class='freq-progress-svg'").append(SVG_CJK_FONT_FAMILY)
                .append(" xmlns='http://www.w3.org/2000/svg' width='100%' height='8' viewBox='0 0 100 8' preserveAspectRatio='none' role='img' aria-label='胜率进度")
                .append(boundedPercent)
                .append("%'>")
                .append("<defs><clipPath id='").append(clipId).append("' clipPathUnits='userSpaceOnUse'>")
                .append("<rect x='0' y='0' width='100' height='8' rx='4' ry='4'/>")
                .append("</clipPath></defs>")
                .append("<rect x='0' y='0' width='100' height='8' rx='4' ry='4' fill='#dfe6ef'/>")
                .append("<rect x='0' y='0' width='").append(boundedPercent)
                .append("' height='8' fill='#2c8cff' clip-path='url(#")
                .append(clipId)
                .append(")'/>")
                .append("</svg>")
                .toString();
    }

    private String renderScoreImprovementCaseStudy(ScoreImprovementCaseStudy caseStudy) {
        double progressPercent = Math.max(0d, Math.min(100d, caseStudy.hitRatePercent()));
        double endAngle = -90 + (progressPercent * 3.6);

        return new StringBuilder()
                .append("<div class='student-case'>")
                .append("<table class='student-case-table' role='presentation'>")
                .append("<tr class='student-case-row'>")
                .append("<td class='case-chart-cell'>")
                .append("<div class='case-chart'>")
                .append("<svg").append(SVG_CJK_FONT_FAMILY)
                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
                .append("<circle cx='130' cy='130' r='86' fill='none' stroke='#ffe3c7' stroke-width='18'></circle>")
                .append(renderProgressRing("case-progress-arc", "case-progress-full-circle", 130, 130, 86, progressPercent, "#ff7d00"))
                .append("<text x='130' y='124' text-anchor='middle' fill='#8a5d36' font-size='26' font-weight='700'>")
                .append(formatOneDecimal(progressPercent)).append("%</text>")
                .append("<text x='130' y='152' text-anchor='middle' fill='#8a5d36' font-size='12'>命中率</text>")
                .append("</svg>")
                .append("</div>")
                .append("</td>")
                .append("<td class='case-info-cell'>")
                .append("<div class='case-info'>")
                .append("<h3>").append(escape(caseStudy.headline())).append("</h3>")
                .append("<p><strong>学员：</strong>").append(escape(caseStudy.learnerName())).append("</p>")
                .append("<p><strong>阶段：</strong>").append(escape(caseStudy.studyPeriodLabel())).append("</p>")
                .append("<p><strong>记忆词汇：</strong>").append(caseStudy.memorizedWordCount())
                .append("词 | 高考命中：").append(caseStudy.examHitWordCount())
                .append("词 | 命中率：").append(formatOneDecimal(progressPercent)).append("%</p>")
                .append("<p><strong>提升分数：</strong><span class='highlight score-gain'>+")
                .append(caseStudy.scoreGain()).append("分</span></p>")
                .append("<p>").append(escape(caseStudy.baselineScoreLabel())).append(" → ")
                .append(caseStudy.finalScore()).append("分</p>")
                .append("</div>")
                .append("</td>")
                .append("</tr>")
                .append("</table>")
                .append("</div>")
                .toString();
    }

    private String renderChartAxes(int width) {
        return new StringBuilder()
                .append("<line class='chart-axis' x1='").append(CHART_AXIS_LEFT).append("' y1='18' x2='")
                .append(CHART_AXIS_LEFT).append("' y2='").append(CHART_AXIS_BOTTOM)
                .append("' stroke='#9aa6b2' stroke-width='1'/>")
                .append("<line class='chart-axis' x1='").append(CHART_AXIS_LEFT).append("' y1='")
                .append(CHART_AXIS_BOTTOM).append("' x2='").append(width - 24)
                .append("' y2='").append(CHART_AXIS_BOTTOM).append("' stroke='#9aa6b2' stroke-width='1'/>")
                .toString();
    }

    private String renderYAxisGridLines(int width, int axisMax, int tickStep) {
        if (axisMax <= 0 || tickStep <= 0) {
            return "";
        }

        StringBuilder builder = new StringBuilder();
        for (int tickValue = 0; tickValue <= axisMax; tickValue += tickStep) {
            int y = CHART_AXIS_BOTTOM - (int) Math.round((tickValue / (double) axisMax) * CHART_AXIS_HEIGHT);
            builder.append("<line class='chart-grid-line chart-grid-line-y' x1='")
                    .append(CHART_AXIS_LEFT).append("' y1='").append(y)
                    .append("' x2='").append(width - 24).append("' y2='").append(y)
                    .append("' stroke='#edf2f7' stroke-width='1'/>");
        }
        return builder.toString();
    }

    private String renderYAxisTicks(int axisMax, int tickStep) {
        if (axisMax <= 0 || tickStep <= 0) {
            return "";
        }

        StringBuilder builder = new StringBuilder();
        for (int tickValue = 0; tickValue <= axisMax; tickValue += tickStep) {
            int y = CHART_AXIS_BOTTOM - (int) Math.round((tickValue / (double) axisMax) * CHART_AXIS_HEIGHT);
            builder.append("<line class='chart-axis-tick chart-axis-tick-y' x1='")
                    .append(CHART_AXIS_LEFT - 4).append("' y1='").append(y)
                    .append("' x2='").append(CHART_AXIS_LEFT).append("' y2='").append(y)
                    .append("' stroke='#9aa6b2' stroke-width='1'/>")
                    .append("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='")
                    .append(CHART_AXIS_LEFT - 8).append("' y='").append(y + 4)
                    .append("' text-anchor='end' fill='#7f8b97' font-size='11'>")
                    .append(tickValue)
                    .append("</text>");
        }
        return builder.toString();
    }

    private String renderXAxisTickMarks(int... xCenters) {
        if (xCenters == null || xCenters.length == 0) {
            return "";
        }

        StringBuilder builder = new StringBuilder();
        for (int xCenter : xCenters) {
            builder.append("<line class='chart-axis-tick chart-axis-tick-x' x1='")
                    .append(xCenter).append("' y1='").append(CHART_AXIS_BOTTOM)
                    .append("' x2='").append(xCenter).append("' y2='")
                    .append(CHART_AXIS_BOTTOM + 5)
                    .append("' stroke='#9aa6b2' stroke-width='1'/>");
        }
        return builder.toString();
    }

    private int barHeight(int value, int maxValue) {
        if (maxValue <= 0 || value <= 0) {
            return 0;
        }
        double ratio = Math.max(0d, Math.min(1d, value / (double) maxValue));
        return Math.max(16, (int) Math.round(ratio * 130));
    }

    private int roundUpToStep(int value, int step) {
        if (value <= 0) {
            return step;
        }
        if (step <= 0) {
            return value;
        }
        return ((value + step - 1) / step) * step;
    }

    private double percentage(int numerator, int denominator) {
        if (denominator <= 0) {
            return 0d;
        }
        return (numerator * 100d) / denominator;
    }

    private String describeArc(double centerX, double centerY, double radius, double startAngle, double endAngle) {
        double startRadians = Math.toRadians(startAngle);
        double endRadians = Math.toRadians(endAngle);
        double startX = centerX + (radius * Math.cos(startRadians));
        double startY = centerY + (radius * Math.sin(startRadians));
        double endX = centerX + (radius * Math.cos(endRadians));
        double endY = centerY + (radius * Math.sin(endRadians));
        int largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
        return new StringBuilder()
                .append("M ").append(formatTwoDecimals(startX)).append(' ')
                .append(formatTwoDecimals(startY)).append(' ')
                .append("A ").append(formatTwoDecimals(radius)).append(' ')
                .append(formatTwoDecimals(radius)).append(" 0 ")
                .append(largeArcFlag).append(" 1 ")
                .append(formatTwoDecimals(endX)).append(' ')
                .append(formatTwoDecimals(endY))
                .toString();
    }

    private String renderProgressRing(String arcClass,
                                      String fullCircleClass,
                                      double centerX,
                                      double centerY,
                                      double radius,
                                      double percent,
                                      String strokeColor) {
        double boundedPercent = Math.max(0d, Math.min(100d, percent));
        if (boundedPercent >= 100d) {
            return new StringBuilder()
                    .append("<circle class='").append(fullCircleClass).append("' cx='")
                    .append(formatTwoDecimals(centerX)).append("' cy='")
                    .append(formatTwoDecimals(centerY)).append("' r='")
                    .append(formatTwoDecimals(radius)).append("' fill='none' stroke='")
                    .append(strokeColor).append("' stroke-width='18'></circle>")
                    .toString();
        }
        if (boundedPercent <= 0d) {
            return "";
        }
        double endAngle = -90 + (boundedPercent * 3.6);
        return new StringBuilder()
                .append("<path class='").append(arcClass).append("' d='")
                .append(describeArc(centerX, centerY, radius, -90, endAngle))
                .append("' stroke='").append(strokeColor)
                .append("' stroke-width='18' fill='none' stroke-linecap='round'/>")
                .toString();
    }

    private String formatOneDecimal(double value) {
        return String.format(Locale.ROOT, "%.1f", value);
    }

    private String formatTwoDecimals(double value) {
        return String.format(Locale.ROOT, "%.2f", value);
    }

    private String escape(String value) {
        if (value == null) {
            return "";
        }
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }

    private String safeColor(String value) {
        if (value == null) {
            return DEFAULT_THEME_COLOR;
        }
        String trimmed = value.trim();
        if (HEX_COLOR_PATTERN.matcher(trimmed).matches()) {
            return trimmed;
        }
        return DEFAULT_THEME_COLOR;
    }
}
