package com.davos.assessment.report;

import com.davos.assessment.assessment.Submission;
import com.davos.assessment.audit.AuditService;
import com.davos.assessment.auth.UserPrincipal;
import com.davos.assessment.common.ApiException;
import com.davos.assessment.profile.Profile;
import com.davos.assessment.profile.ProfileRepository;
import com.davos.assessment.project.Project;
import com.davos.assessment.project.ProjectAccessService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Arc2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.util.Matrix;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping({"/api/reports", "/api/projects/{slug}/reports"})
public class ReportController {
  private static final String COVER_PERSONAL = "report-templates/cover-personal.png";
  private static final String COVER_GROUP = "report-templates/cover-group.png";
  private static final String TAIL_IMAGE = "report-templates/tail.jpg";
  private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
  private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

  private final ReportRepository reportRepository;
  private final ProfileRepository profileRepository;
  private final AuditService auditService;
  private final ObjectMapper objectMapper;
  private final ProjectAccessService projectAccessService;

  public ReportController(
      ReportRepository reportRepository,
      ProfileRepository profileRepository,
      AuditService auditService,
      ObjectMapper objectMapper,
      ProjectAccessService projectAccessService) {
    this.reportRepository = reportRepository;
    this.profileRepository = profileRepository;
    this.auditService = auditService;
    this.objectMapper = objectMapper;
    this.projectAccessService = projectAccessService;
  }

  @GetMapping("/me/latest")
  @Transactional(readOnly = true)
  public ReportResponse latest(@AuthenticationPrincipal UserPrincipal principal) {
    throw new ApiException(HttpStatus.FORBIDDEN, "用户端不提供报告查看，请联系管理员");
  }

  @GetMapping("/{id}")
  @Transactional
  public ReportResponse detail(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long id) {
    Report report = ownedReport(principal, id);
    auditService.record(report.getProject(), principal.user(), "VIEW_REPORT", "REPORT", id.toString());
    return ReportResponse.from(report);
  }

  @GetMapping("/{id}/pdf")
  @Transactional
  public ResponseEntity<byte[]> pdf(
      @AuthenticationPrincipal UserPrincipal principal,
      @PathVariable Long id,
      @RequestParam(defaultValue = "personal") String coverType,
      @RequestParam(required = false) String groupName) throws IOException {
    Report report = ownedReport(principal, id);
    String normalizedCover = normalizeCoverType(coverType);
    if ("group".equals(normalizedCover) && (groupName == null || groupName.isBlank())) {
      throw new ApiException(HttpStatus.BAD_REQUEST, "团体版封面必须填写团体名称");
    }
    auditService.record(report.getProject(), principal.user(), "EXPORT_REPORT_PDF", "REPORT", id + ":" + normalizedCover);
    byte[] pdf = buildPdf(report, normalizedCover, groupName == null ? "" : groupName.trim());
    Profile profile = profileRepository.findByProjectAndUser(report.getProject(), report.getUser()).orElse(null);
    String person = filenameSafe((profile == null ? "" : profile.getName()).isBlank() ? report.getUser().getEmail() : profile.getName());
    String paperId = report.getSubmission() != null && report.getSubmission().getAssessment() != null
        ? filenameSafe(report.getSubmission().getAssessment().getTestPaperId())
        : "unknown-paper";
    String filename = person + "-user" + report.getUser().getId() + "-" + paperId + "-report" + id + ".pdf";
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_PDF);
    headers.setContentDisposition(ContentDisposition.attachment().filename(filename, StandardCharsets.UTF_8).build());
    return ResponseEntity.ok()
        .headers(headers)
        .body(pdf);
  }

  private Report ownedReport(UserPrincipal principal, Long id) {
    Project project = projectAccessService.requireProjectAdmin(projectAccessService.currentSlugOrDefault(), principal);
    Report report = reportRepository.findById(id)
        .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "报告不存在"));
    if (report.getProject() == null || !report.getProject().getId().equals(project.getId())) {
      throw new ApiException(HttpStatus.NOT_FOUND, "报告不存在");
    }
    return report;
  }

  private String normalizeCoverType(String coverType) {
    if (coverType == null || coverType.isBlank()) return "personal";
    String value = coverType.trim().toLowerCase(Locale.ROOT);
    if (!"personal".equals(value) && !"group".equals(value)) {
      throw new ApiException(HttpStatus.BAD_REQUEST, "coverType 仅支持 personal 或 group");
    }
    return value;
  }

  private String reportTitle(Report report) {
    return report.getProject() == null || report.getProject().getReportTitle() == null || report.getProject().getReportTitle().isBlank()
        ? "心理测评报告"
        : report.getProject().getReportTitle();
  }

  private byte[] buildPdf(Report report, String coverType, String groupName) throws IOException {
    ensureTemplatesReady();
    try (PDDocument document = new PDDocument(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
      PDFont bodyFont = loadChineseFont(document);
      renderCoverPage(document, report, bodyFont, coverType, groupName);
      renderHtmlBody(document, report, bodyFont);
      renderTailPage(document);
      document.save(output);
      return output.toByteArray();
    }
  }

  private void renderHtmlBody(PDDocument document, Report report, PDFont bodyFont) throws IOException {
    Submission submission = report.getSubmission();
    String testPaperId = submission.getAssessment() == null ? "" : safe(submission.getAssessment().getTestPaperId());
    Map<String, Integer> d = parseDimensionScores(submission.getDimensionScores());
    try {
      String snapshotHtml = buildSnapshotTemplateHtml(report, testPaperId, d);
      if (snapshotHtml != null && !snapshotHtml.isBlank()) {
        byte[] png = renderHtmlSnapshotPngByBrowser(snapshotHtml);
        appendImagePage(document, png);
        return;
      }
    } catch (Exception ex) {
      // Keep report export available even when the browser snapshot template cannot render locally.
    }
    renderStructuredBody(document, report, bodyFont);
  }

  private byte[] renderHtmlToPdf(String html) {
    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      PdfRendererBuilder builder = new PdfRendererBuilder();
      builder.useFastMode();
      File cjkFont = resolveCjkFontFile();
      if (cjkFont != null) {
        builder.useFont(cjkFont, "CJK");
      }
      builder.withHtmlContent(html, null);
      builder.toStream(out);
      builder.run();
      return out.toByteArray();
    } catch (Exception e) {
      throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "HTML 模板渲染 PDF 失败: " + e.getMessage());
    }
  }

  private byte[] renderHtmlSnapshotPngByBrowser(String html) throws IOException, InterruptedException {
    Path runtimeDir = resolveSnapshotRuntimeDir();
    Path htmlFile = Files.createTempFile(runtimeDir, "davos-snap-", ".html");
    Path outFile = Files.createTempFile(runtimeDir, "davos-snap-", ".png");
    Path scriptFile = Files.createTempFile(runtimeDir, "davos-snap-", ".mjs");
    Path logFile = Files.createTempFile(runtimeDir, "davos-snap-", ".log");
    try {
      Files.writeString(htmlFile, html, StandardCharsets.UTF_8);
      try (InputStream script = new ClassPathResource("report-templates/render-report-image.mjs").getInputStream()) {
        Files.writeString(scriptFile, new String(script.readAllBytes(), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
      }
      Process process = new ProcessBuilder("node", scriptFile.toString(), htmlFile.toString(), outFile.toString())
          .directory(runtimeDir.toFile())
          .redirectErrorStream(true)
          .redirectOutput(logFile.toFile())
          .start();
      boolean finished = process.waitFor(45, TimeUnit.SECONDS);
      if (!finished) {
        process.destroyForcibly();
        throw new IOException("snapshot timed out");
      }
      if (process.exitValue() != 0) {
        byte[] output = Files.readAllBytes(logFile);
        throw new IOException("snapshot failed: " + new String(output, StandardCharsets.UTF_8));
      }
      return Files.readAllBytes(outFile);
    } finally {
      try { Files.deleteIfExists(htmlFile); } catch (Exception ignored) {}
      try { Files.deleteIfExists(outFile); } catch (Exception ignored) {}
      try { Files.deleteIfExists(scriptFile); } catch (Exception ignored) {}
      try { Files.deleteIfExists(logFile); } catch (Exception ignored) {}
    }
  }

  private Path resolveSnapshotRuntimeDir() {
    Path userDir = Path.of(System.getProperty("user.dir", ".")).toAbsolutePath().normalize();
    Path[] candidates = new Path[] {
        userDir,
        userDir.resolve("backend"),
        Path.of("/app"),
        Path.of(System.getProperty("java.io.tmpdir"))
    };
    for (Path candidate : candidates) {
      try {
        Files.createDirectories(candidate);
      } catch (Exception ignored) {
        continue;
      }
      if (Files.exists(candidate.resolve("node_modules/playwright"))
          || Files.exists(candidate.resolve("backend/node_modules/playwright"))
          || "/app".equals(candidate.toString())) {
        return candidate;
      }
    }
    return Path.of(System.getProperty("java.io.tmpdir"));
  }

  private void appendImagePage(PDDocument document, byte[] imageBytes) throws IOException {
    PDPage page = new PDPage(PDRectangle.A4);
    document.addPage(page);
    PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "snapshot");
    float pw = page.getMediaBox().getWidth();
    float ph = page.getMediaBox().getHeight();
    float iw = image.getWidth();
    float ih = image.getHeight();
    float scale = Math.min(pw / iw, ph / ih);
    float dw = iw * scale;
    float dh = ih * scale;
    float x = (pw - dw) / 2f;
    float y = (ph - dh) / 2f;
    try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
      cs.drawImage(image, x, y, dw, dh);
    }
  }

  private String buildSnapshotTemplateHtml(Report report, String paperId, Map<String, Integer> d) {
    try (InputStream input = new ClassPathResource("report-templates/report-content-template.html").getInputStream()) {
      String html = new String(input.readAllBytes(), StandardCharsets.UTF_8);
      boolean sds = paperId.contains("2082");
      boolean sas = paperId.contains("2083");
      boolean scl = paperId.contains("2078");
      String mode = sds ? "sds" : (sas ? "sas" : "scl");
      int score = report.getSubmission() == null ? 0 : report.getSubmission().getRawScore();
      String level = safe(report.getSubmission() == null ? "" : report.getSubmission().getLevel());
      String summary = safe(report.getSummary()).isBlank() ? level : safe(report.getSummary());
      String introMain = sds
          ? "抑郁自评量表(Self-rating depression scale, 简称 SDS)，是由美国杜克大学教授庄 (William W.K.Zung) 于1965年编制的。包括20个项目，每个项目按症状出现的频度分为四级评分，其中10个为正向评分，10个为反向评分。主要适用于具有抑郁症状的成年人。本测试应用范围颇广，使用简单，能相当直观地反映抑郁状态的有关症状及其严重程度，适用于抑郁症患者的粗筛以及一般人群的情绪状态评定。评定的时间范围是现在或者过去一周的实际感觉。"
          : sas
              ? "焦虑自评量表 (Self - raing Anxiety Scale, 简称 SAS) 是由美国杜克大学教授庄 (William W.K.Zung) 于1971年编制的。包括20个项目，每个项目按症状出现的频度分为四级评分，其中15个正向评分，5个反向评分。主要用于评定焦虑的主观感受及其在治疗中的变化。本测验应用范围颇广，适用于各种职业，文化阶层及年龄段的正常人或各类焦虑症患者的筛查。可作为心理咨询门诊中了解焦虑症状的自评工具。评定的时间范围是现在或者\"过去一周\"的实际感觉。"
              : "症状自评量表（Self-reporting Inventory ，简称SCL-90），是世界上最著名的心理健康测试量表之一。由德若伽提斯（L.R.Derogatis）于1975年编制。共有90个项目，分别是躯体化、强迫状态、人际关系敏感、抑郁、焦虑、敌对、恐怖、偏执、精神病性、其他项目。包含有较广泛的精神病症状学内容，从感觉、情感、思维、意识、行为直至生活习惯、人际关系、饮食睡眠等均有涉及。从10个方面来了解自己的心理健康程度。本量表评定的时间范围是“现在”或者是“最近一个星期”的实际感觉。";
      String introExtra = scl
          ? "当您的总分超过160分，或阳性项目数超过43项，或任一因子分超过2分，或者您自觉症状较为严重时，应该到比较权威的心理咨询和治疗机构做进一步的检查和诊断。"
          : "";
      String suggestions = safe(report.getSuggestions());
      if (suggestions.isBlank()) {
        if (sds) suggestions = sdsAdvice(score);
        else if (sas) suggestions = sasAdvice(score);
      }
      List<String> advice = suggestions.isBlank() ? List.of() : List.of(suggestions.split("\\r?\\n"));
      List<List<String>> judgeRows = sds
          ? List.of(
              List.of("等级", "标准分", "阈值区间"),
              List.of(level, String.valueOf(score), "53以下正常；53-62轻度；63-72中度；73以上重度"))
          : sas
              ? List.of(
                  List.of("等级", "标准分", "阈值区间"),
                  List.of(level, String.valueOf(score), "50以下正常；50-59轻度；60-69中度；70以上重度"))
              : List.of(
                  List.of("评定结果", "总分", "阳性项目数", "判定阈值"),
                  List.of(level.isBlank() ? "阴性" : level, value(d, "总分"), value(d, "阳性项目数"),
                      "总分>160 或阳性项目数>43 提示异常"));
      String coreText = sds || sas
          ? "测评结果：" + level + "，标准分：" + score + "。"
          : "评定结果：" + (level.isBlank() ? "阴性" : level) + "。";
      Map<String, Object> payload = new LinkedHashMap<>();
      payload.put("mode", mode);
      payload.put("score", score);
      payload.put("level", level);
      payload.put("summary", summary);
      payload.put("introMain", introMain);
      payload.put("introExtra", introExtra);
      payload.put("judgeRows", judgeRows);
      payload.put("coreText", coreText);
      payload.put("advice", advice);
      payload.put("sdsGaugeUri", createGaugeDataUri(Math.max(0, Math.min(100, score)), "SDS"));
      payload.put("sasGaugeUri", createGaugeDataUri(Math.max(0, Math.min(100, score)), "SAS"));
      payload.put("sclSvgHtml", sclSvg(d));
      payload.put("sclLineData", List.of(
          d.getOrDefault("躯体化", 0), d.getOrDefault("强迫症状", 0), d.getOrDefault("人际关系敏感", 0), d.getOrDefault("抑郁", 0),
          d.getOrDefault("焦虑", 0), d.getOrDefault("敌对", 0), d.getOrDefault("恐怖", 0), d.getOrDefault("偏执", 0),
          d.getOrDefault("精神病性", 0), d.getOrDefault("其他", 0)));
      payload.put("sclRows", List.of(
          List.of("总分", value(d, "总分"), "", "", "141.42 ± 45.34"),
          List.of("总均分", sclAvg(d, "总均分"), "", "", "1.57 ± 0.50"),
          List.of("阴性项目数", value(d, "阴性项目数"), "", "", "58.51 ± 21.96"),
          List.of("阳性项目数", value(d, "阳性项目数"), "", "", "82.91 ± 65.97"),
          List.of("阳性项目均分", sclAvg(d, "阳性均分"), "", "", "2.45 ± 0.57"),
          List.of("躯体化", value(d, "躯体化"), sclAvg(d, "躯体化均分"), sclDegreeText(d, "躯体化均分"), "1.39 ± 0.49"),
          List.of("强迫状态", value(d, "强迫症状"), sclAvg(d, "强迫症状均分"), sclDegreeText(d, "强迫症状均分"), "1.94 ± 0.66"),
          List.of("人际关系敏感", value(d, "人际关系敏感"), sclAvg(d, "人际关系敏感均分"), sclDegreeText(d, "人际关系敏感均分"), "1.72 ± 0.65"),
          List.of("抑郁", value(d, "抑郁"), sclAvg(d, "抑郁均分"), sclDegreeText(d, "抑郁均分"), "1.56 ± 0.61"),
          List.of("焦虑", value(d, "焦虑"), sclAvg(d, "焦虑均分"), sclDegreeText(d, "焦虑均分"), "1.57 ± 0.58"),
          List.of("敌对", value(d, "敌对"), sclAvg(d, "敌对均分"), sclDegreeText(d, "敌对均分"), "1.52 ± 0.59"),
          List.of("恐怖", value(d, "恐怖"), sclAvg(d, "恐怖均分"), sclDegreeText(d, "恐怖均分"), "1.43 ± 0.53"),
          List.of("偏执", value(d, "偏执"), sclAvg(d, "偏执均分"), sclDegreeText(d, "偏执均分"), "1.55 ± 0.59"),
          List.of("精神病性", value(d, "精神病性"), sclAvg(d, "精神病性均分"), sclDegreeText(d, "精神病性均分"), "1.51 ± 0.53"),
          List.of("其他项目", value(d, "其他"), sclAvg(d, "其他均分"), sclDegreeText(d, "其他均分"), "1.16 ± 0.41")));
      String json = objectMapper.writeValueAsString(payload);
      String script = """
        <script>
          (function(){
            var p = %s;
            function text(id, value){
              var el=document.getElementById(id);
              if(el) el.textContent=value==null?'':String(value);
            }
            function toggle(id, show){
              var el=document.getElementById(id);
              if(el) el.classList.toggle('hide', !show);
            }
            function renderTable(id, rows){
              var el=document.getElementById(id);
              if(!el || !rows || !rows.length) return;
              var head=rows[0]||[];
              var body=rows.slice(1);
              el.innerHTML='<thead><tr>'+head.map(function(c){return '<th>'+String(c||'')+'</th>';}).join('')+'</tr></thead>'
                +'<tbody>'+body.map(function(row){return '<tr>'+row.map(function(c){return '<td>'+String(c==null?'':c)+'</td>';}).join('')+'</tr>';}).join('')+'</tbody>';
            }
            function renderTbody(id, rows){
              var wrap=document.querySelector('#'+id+' tbody');
              if(!wrap || !rows || !rows.length) return;
              wrap.innerHTML=rows.map(function(row){
                return '<tr>'+row.map(function(c){return '<td>'+String(c==null?'':c)+'</td>';}).join('')+'</tr>';
              }).join('');
            }
            if (document.querySelector('.toolbar')) document.querySelector('.toolbar').style.display='none';
            apply(p.mode);
            text('introMain', p.introMain);
            text('introExtra', p.introExtra);
            toggle('introExtra', !!(p.introExtra && String(p.introExtra).trim()));
            renderTable('judgeTable', p.judgeRows);
            text('coreText', p.coreText);
            if (p.mode==='sds'){
              var g=document.getElementById('gaugeSds');
              if(g) g.innerHTML='<img src="'+p.sdsGaugeUri+'" style="width:100%;height:100%;object-fit:contain" />';
              document.getElementById('sdsScore').textContent=String(p.score);
              document.getElementById('sdsLabel').textContent=p.level||document.getElementById('sdsLabel').textContent;
            }
            if (p.mode==='sas'){
              var g2=document.getElementById('gaugeSas');
              if(g2) g2.innerHTML='<img src="'+p.sasGaugeUri+'" style="width:100%;height:100%;object-fit:contain" />';
              document.getElementById('sasScore').textContent=String(p.score);
              document.getElementById('sasLabel').textContent=p.level||document.getElementById('sasLabel').textContent;
            }
            if (p.mode==='scl'){
              var l=document.getElementById('lineScl');
              if(l) l.innerHTML=p.sclSvgHtml||'';
              renderTbody('sclModule', p.sclRows);
            }
            if (p.advice && p.advice.length){ var ul=document.getElementById('adviceList'); if(ul){ ul.innerHTML=''; p.advice.forEach(function(t){ if(String(t).trim()){ var li=document.createElement('li'); li.textContent=t; ul.appendChild(li);} });}}
          })();
        </script>
      """.formatted(json);
      return html.replace("</body>", script + "\n</body>");
    } catch (Exception e) {
      return null;
    }
  }

  private String buildReportHtml(Report report, String paperId, Map<String, Integer> d) {
    boolean sds = paperId.contains("2082");
    boolean sas = paperId.contains("2083");
    boolean scl = paperId.contains("2078");
    String summary = safe(report.getSummary()).isBlank() ? safe(report.getSubmission().getLevel()) : safe(report.getSummary());
    int parsedFromSummary = parseInt(summary);
    int submissionScore = report.getSubmission() == null ? 0 : report.getSubmission().getRawScore();
    int score = (sds || sas)
        ? (submissionScore > 0 ? submissionScore : parsedFromSummary)
        : parsedFromSummary;
    if (sds || sas) {
      validateScaleScore(sds ? "SDS" : "SAS", report.getId(), score);
    }
    String displayScore = (sds || sas) ? String.valueOf(score) : summary;
    String suggestions = safe(report.getSuggestions());
    if (suggestions.isBlank()) {
      if (sds) suggestions = sdsAdvice(score);
      else if (sas) suggestions = sasAdvice(score);
      else suggestions = "暂无建议";
    }
    String level = safe(report.getSubmission().getLevel());
    String intro = sds
        ? "抑郁自评量表(Self-rating depression scale, 简称 SDS)，是由美国杜克大学教授庄 (William W.K.Zung) 于1965年编制。包括20个项目，每个项目按症状出现的频度分为四级评分，其中10个为正向评分，10个为反向评分。主要适用于具有抑郁症状的成年人。本测试应用范围颇广，使用简单，能相当直观地反映抑郁状态的有关症状及其严重程度，适用于抑郁症患者的粗筛以及一般人群的情绪状态评定。评定的时间范围是现在或者过去一周的实际感觉。"
        : sas
          ? "焦虑自评量表 (Self - raing Anxiety Scale, 简称 SAS) 是由美国杜克大学教授庄 (William W.K.Zung) 于1971年编制的。包括20个项目，每个项目按症状出现的频度分为四级评分，其中15个正向评分，5个反向评分。主要用于评定焦虑的主观感受及其在治疗中的变化。本测验应用范围颇广，适用于各种职业，文化阶层及年龄段的正常人或各类焦虑症患者的筛查。可作为心理咨询门诊中了解焦虑症状的自评工具。评定的时间范围是现在或者\"过去一周\"的实际感觉。"
          : "症状自评量表（Self-reporting Inventory ，简称SCL-90），是世界上最著名的心理健康测试量表之一。由德若伽提斯（L.R.Derogatis）于1975年编制。共有90个项目，分别是躯体化、强迫状态、人际关系敏感、抑郁、焦虑、敌对、恐怖、偏执、精神病性、其他项目。包含有较广泛的精神病症状学内容，从感觉、情感、思维、意识、行为直至生活习惯、人际关系、饮食睡眠等均有涉及。从10个方面来了解自己的心理健康程度。本量表评定的时间范围是“现在”或者是“最近一个星期”的实际感觉。";
    String introExtra = scl
        ? "当您的总分超过160分，或阳性项目数超过43项，或任一因子分超过2分，或者您自觉症状较为严重时，应该到比较权威的心理咨询和治疗机构做进一步的检查和诊断。"
        : "";
    String threshold = sds
        ? "53以下正常；53-62轻度抑郁；63-72中度抑郁；73以上重度抑郁。"
        : sas ? "50以下正常；50-59轻度焦虑；60-69中度焦虑；70以上重度焦虑。"
        : "总分>160 或阳性项目数>43 通常提示需进一步评估。";

    String sclRows = """
      <tr><td>总分</td><td>%s</td><td></td><td></td><td>141.42 ± 45.34</td></tr>
      <tr><td>总均分</td><td>%s</td><td></td><td></td><td>1.57 ± 0.50</td></tr>
      <tr><td>阴性项目数</td><td>%s</td><td></td><td></td><td>58.51 ± 21.96</td></tr>
      <tr><td>阳性项目数</td><td>%s</td><td></td><td></td><td>82.91 ± 65.97</td></tr>
      <tr><td>阳性项目平均分</td><td>%s</td><td></td><td></td><td>2.45 ± 0.57</td></tr>
      <tr><td>躯体化</td><td>%s</td><td>%s</td><td>%s</td><td>1.39 ± 0.49</td></tr>
      <tr><td>强迫状态</td><td>%s</td><td>%s</td><td>%s</td><td>1.94 ± 0.66</td></tr>
      <tr><td>人际关系敏感</td><td>%s</td><td>%s</td><td>%s</td><td>1.72 ± 0.65</td></tr>
      <tr><td>抑郁</td><td>%s</td><td>%s</td><td>%s</td><td>1.56 ± 0.61</td></tr>
      <tr><td>焦虑</td><td>%s</td><td>%s</td><td>%s</td><td>1.57 ± 0.58</td></tr>
      <tr><td>敌对</td><td>%s</td><td>%s</td><td>%s</td><td>1.52 ± 0.59</td></tr>
      <tr><td>恐怖</td><td>%s</td><td>%s</td><td>%s</td><td>1.43 ± 0.53</td></tr>
      <tr><td>偏执</td><td>%s</td><td>%s</td><td>%s</td><td>1.55 ± 0.59</td></tr>
      <tr><td>精神病性</td><td>%s</td><td>%s</td><td>%s</td><td>1.51 ± 0.53</td></tr>
      <tr><td>其他项目</td><td>%s</td><td>%s</td><td>%s</td><td>1.16 ± 0.41</td></tr>
      """.formatted(
        value(d, "总分"), sclAvg(d, "总均分"), value(d, "阴性项目数"), value(d, "阳性项目数"), sclAvg(d, "阳性均分"),
        value(d, "躯体化"), sclAvg(d, "躯体化均分"), sclDegreeText(d, "躯体化均分"),
        value(d, "强迫症状"), sclAvg(d, "强迫症状均分"), sclDegreeText(d, "强迫症状均分"),
        value(d, "人际关系敏感"), sclAvg(d, "人际关系敏感均分"), sclDegreeText(d, "人际关系敏感均分"),
        value(d, "抑郁"), sclAvg(d, "抑郁均分"), sclDegreeText(d, "抑郁均分"),
        value(d, "焦虑"), sclAvg(d, "焦虑均分"), sclDegreeText(d, "焦虑均分"),
        value(d, "敌对"), sclAvg(d, "敌对均分"), sclDegreeText(d, "敌对均分"),
        value(d, "恐怖"), sclAvg(d, "恐怖均分"), sclDegreeText(d, "恐怖均分"),
        value(d, "偏执"), sclAvg(d, "偏执均分"), sclDegreeText(d, "偏执均分"),
        value(d, "精神病性"), sclAvg(d, "精神病性均分"), sclDegreeText(d, "精神病性均分"),
        value(d, "其他"), sclAvg(d, "其他均分"), sclDegreeText(d, "其他均分"));

    String sdsSasJudge = sds
        ? "<table><thead><tr><th>等级</th><th>标准分</th><th>阈值区间</th></tr></thead><tbody><tr><td>" + esc(level) + "</td><td>" + esc(displayScore) + "</td><td>53以下正常；53-62轻度；63-72中度；73以上重度</td></tr></tbody></table>"
        : "<table><thead><tr><th>等级</th><th>标准分</th><th>阈值区间</th></tr></thead><tbody><tr><td>" + esc(level) + "</td><td>" + esc(displayScore) + "</td><td>50以下正常；50-59轻度；60-69中度；70以上重度</td></tr></tbody></table>";

    String sclJudge = "<table><thead><tr><th>评定结果</th><th>总分</th><th>阳性项目数</th><th>判定阈值</th></tr></thead><tbody><tr><td>"
        + esc(level.isBlank() ? "阴性" : level) + "</td><td>" + value(d, "总分") + "</td><td>" + value(d, "阳性项目数")
        + "</td><td>总分>160 或阳性项目数>43 提示异常</td></tr></tbody></table>";

    String adviceBlock = scl
        ? "<p style='white-space:pre-wrap'>" + esc(suggestions) + "</p>"
        : "<p>" + esc(suggestions) + "</p>";

    String vizBlock = sds || sas
        ? "<section class='module'><div class='tag'>三、测评结果可视化</div>" + gaugeHtml(sds ? "SDS" : "SAS", displayScore) + "</section>"
        : "<section class='module'><div class='tag'>三、测评结果可视化</div>" + sclChartHtml(d) + "</section>";

    if (scl) {
      String sclResultTable = "<section class='module'><div class='tag'>三、测评结果</div><table><thead><tr><th>项目</th><th>原始分</th><th>平均分</th><th>参考诊断</th><th>均分标准差</th></tr></thead><tbody>" + sclRows + "</tbody></table></section>";
      String sclAdvice = "<section class='module keep'><div class='tag'>四、指导意见</div>" + adviceBlock + "</section>";
      String sclVizBlock = vizBlock.replace("三、测评结果可视化", "四、测评结果可视化");
      sclAdvice = sclAdvice.replace("四、指导意见", "五、指导意见");
      return """
      <html><head><meta charset='UTF-8'/>
      <style>
      @page{size:A4;margin:26pt 30pt}
      body{font-family:'CJK','PingFang SC','Microsoft YaHei',sans-serif;color:#1f2530;font-size:12px}
      .module{border:1px solid #e6c7a6;border-radius:6px;padding:10px;margin-bottom:10px}
      .tag{display:inline-block;background:#f28f1f;color:#fff;border-radius:14px;padding:4px 10px;font-weight:700;margin-bottom:8px}
      p{margin:0;line-height:1.75}
      table{width:100%%;border-collapse:collapse;font-size:11px}
      th,td{border:1px solid #f1dfcc;padding:6px;text-align:center}
      th{background:#f7ebdd}
      ul{margin:0;padding-left:18px;line-height:1.75}
      .note{margin-top:10px;font-size:11px;color:#5f6775}
      .pb{page-break-before:always}
      .keep{page-break-inside:avoid}
      </style></head><body>
      <section class='module'><div class='tag'>一、量表简介</div><p>%s</p><p>%s</p></section>
      <section class='module'><div class='tag'>二、评定结果</div>%s</section>
      %s
      <div class='pb'></div>
      %s
      %s
      <p class='note'>注：本报告仅为心理测评结果的参考，不作为临床诊断依据，如有疑问请咨询专业心理咨询师或精神科医生。</p>
      </body></html>
      """.formatted(esc(intro), esc(introExtra), sclJudge, sclResultTable, sclVizBlock, sclAdvice);
    }

    String midBlock = scl
        ? "<section class='module'><div class='tag'>三、测评结果</div><table><thead><tr><th>项目</th><th>原始分</th><th>平均分</th><th>参考诊断</th><th>均分标准差</th></tr></thead><tbody>" + sclRows + "</tbody></table></section>"
        : vizBlock;

    String secondTag = scl ? "二、评定结果" : "二、结果判定";
    String secondTable = scl ? sclJudge : sdsSasJudge;
    String fourthTag = scl ? "四、指导意见" : "四、指导建议";

    return """
      <html><head><meta charset='UTF-8'/>
      <style>
      @page{size:A4;margin:26pt 30pt}
      body{font-family:'CJK','PingFang SC','Microsoft YaHei',sans-serif;color:#1f2530;font-size:12px}
      .module{border:1px solid #e6c7a6;border-radius:6px;padding:10px;margin-bottom:10px}
      .tag{display:inline-block;background:#f28f1f;color:#fff;border-radius:14px;padding:4px 10px;font-weight:700;margin-bottom:8px}
      p{margin:0;line-height:1.75}
      table{width:100%%;border-collapse:collapse;font-size:11px}
      th,td{border:1px solid #f1dfcc;padding:6px;text-align:center}
      th{background:#f7ebdd}
      .muted{color:#6b7280}
      .gauge{border:1px solid #f1dfcc;padding:10px;border-radius:8px}
      .legend-scale{display:flex;border:1px solid #e8d4be;border-radius:4px;overflow:hidden;margin-top:6px}
      .legend-block{height:12px}
      .legend-list{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px}
      .legend-item{display:flex;align-items:center;gap:6px;font-size:11px;color:#4b5563}
      .legend-dot{width:10px;height:10px;border-radius:2px;display:inline-block}
      ul{margin:0;padding-left:18px;line-height:1.75}
      .note{margin-top:10px;font-size:11px;color:#5f6775}
      </style></head><body>
      <section class='module'><div class='tag'>一、量表简介</div><p>%s</p><p class='mt'>%s</p></section>
      <section class='module'><div class='tag'>%s</div>%s</section>
      %s
      %s
      <section class='module'><div class='tag'>%s</div>%s</section>
      <p class='note'>注：本报告仅为心理测评结果的参考，不作为临床诊断依据，如有疑问请咨询专业心理咨询师或精神科医生。</p>
      </body></html>
      """.formatted(esc(intro), esc(introExtra), secondTag, secondTable, midBlock, scl ? vizBlock : "", fourthTag, adviceBlock);
  }

  private String filenameSafe(String value) {
    String v = value == null ? "" : value.trim();
    if (v.isBlank()) return "unknown";
    return v.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
  }

  private String gaugeHtml(String name, String score) {
    int v = 0;
    try { v = Integer.parseInt(score.trim()); } catch (Exception ignored) {}
    v = Math.max(0, Math.min(100, v));
    String img = createGaugeDataUri(v, name);
    boolean sds = "SDS".equalsIgnoreCase(name);
    String rules = sds
        ? """
          <div class='legend-scale'>
            <span class='legend-block' style='background:#4CAF50;width:53%'></span>
            <span class='legend-block' style='background:#F1C45B;width:9%'></span>
            <span class='legend-block' style='background:#F39A3C;width:10%'></span>
            <span class='legend-block' style='background:#E55C4E;width:28%'></span>
          </div>
          <div class='legend-list'>
            <span class='legend-item'><i class='legend-dot' style='background:#4CAF50'></i>正常（&lt;53）</span>
            <span class='legend-item'><i class='legend-dot' style='background:#F1C45B'></i>轻度（53-62）</span>
            <span class='legend-item'><i class='legend-dot' style='background:#F39A3C'></i>中度（63-72）</span>
            <span class='legend-item'><i class='legend-dot' style='background:#E55C4E'></i>重度（≥73）</span>
          </div>
          """
        : """
          <div class='legend-scale'>
            <span class='legend-block' style='background:#4CAF50;width:50%'></span>
            <span class='legend-block' style='background:#F1C45B;width:10%'></span>
            <span class='legend-block' style='background:#F39A3C;width:10%'></span>
            <span class='legend-block' style='background:#E55C4E;width:30%'></span>
          </div>
          <div class='legend-list'>
            <span class='legend-item'><i class='legend-dot' style='background:#4CAF50'></i>正常（&lt;50）</span>
            <span class='legend-item'><i class='legend-dot' style='background:#F1C45B'></i>轻度（50-59）</span>
            <span class='legend-item'><i class='legend-dot' style='background:#F39A3C'></i>中度（60-69）</span>
            <span class='legend-item'><i class='legend-dot' style='background:#E55C4E'></i>重度（≥70）</span>
          </div>
          """;
    return "<div class='gauge'>"
        + "<img style='width:300px;height:170px;display:block;margin:0 auto' src='" + img + "' alt='gauge'/>"
        + "<p style='text-align:center;margin-top:-4px;font-weight:700;font-size:22px'>标准分：" + esc(score) + "</p>"
        + rules
        + "</div>";
  }

  private int parseInt(String text) {
    try {
      return Integer.parseInt(text.replaceAll("[^0-9-]", ""));
    } catch (Exception ignored) {
      return 0;
    }
  }

  private void validateScaleScore(String scale, Long reportId, int score) {
    if (score < 0 || score > 100) {
      throw new ApiException(
          HttpStatus.UNPROCESSABLE_ENTITY,
          scale + " 报告数据异常（reportId=" + reportId + "，score=" + score + "），请重新提交该量表后再导出。");
    }
  }

  private String sdsAdvice(int standard) {
    if (standard < 53) return "根据测评得分情况来看，您的情绪平和，没有抑郁症状，自身情绪基本处于愉快、开心的境况，对自己和生活感到满意，不会经常被不开心的事情困扰。即使是偶尔遇到挫折和不幸，也比较容易自我调节，或从他人那里得到支持帮助，从而克服难关。希望您能继续保持这种快乐明朗的心态，积极面对生活中的挑战。";
    if (standard <= 62) return "根据测评得分情况来看，您有时情绪低落，睡眠不佳、容易疲劳，经常犹疑不决、有空虚、无望等感觉。您此时的抑郁程度为轻度。建议先自我调整，通过运动，逛街，跟朋友聊天，听音乐等体育和娱乐活动调整一下情绪状态。如无好转请及时求助专业人员。";
    if (standard < 72) return "根据测评得分情况来看，您经常有情绪低落的时候，特别是早晨比较严重傍晚稍好一些。总想哭，经常失眠或者总是睡不醒。食欲不振，体重减轻，感觉四肢无力，对什么事情都提不起兴趣，觉得自己什么都做不好。时常自责，有自杀的念头。您此时的抑郁程度为中度。建议您积极进行自我调整，改变不合理认知，寻求支持的社会资源，适当转移注意力。而且要与专业人士保持联系，以便获得及时的帮助，协助您抵御不良情绪。";
    return "根据测评得分情况来看，您总有情绪低落的状况，特别是早晨比较严重傍晚稍好一些。经常不由自主的泪流满面，严重的失眠，不想吃东西，不想动，性欲减退，反应迟钝，有很强的无望感，自罪自责，有强烈的自杀意念甚至已经有了自杀的行为表现。您此时为重度抑郁。建议您在家人的协助下寻求医生的帮助，按医嘱服用药物或住院治疗，辅助定期的心理咨询。让自己尽快摆脱抑郁症的困扰。";
  }

  private String sasAdvice(int standard) {
    if (standard < 50) return "根据测评得分情况来看，您过去一周内情绪比较稳定，心态平和很少焦虑，您很少感到有压力感，较少感觉到交际、烦躁的情绪或者对未来事件不确定性的担心和忧虑。这可能是出于您对生活中的各种挫折和压力，能够以比较平和的心态去面对，善于用适当的方式去化解。";
    if (standard <= 59) return "根据测评得分情况来看，您过去一周内偶尔会感到紧张、害怕、恐惧，但焦虑程度较轻。建议你先通过适量有氧运动、冥想以及其他娱乐活动进行自我调节。如无好转，请及时求助专业人士。";
    if (standard <= 69) return "根据测评得分情况来看，您过去一周内经常感到恐惧、担心，甚至会有不幸的预感以及心悸等躯体症状。您此时的焦虑程度为中度。建议您可以通过转移注意力、做有氧运动、调整认知、换个角度思考问题等方式进行自我调整。同时寻求专业人士的帮助，进行系统的身心调整。";
    return "根据测评得分情况来看，您过去一周绝大部分时间内都感到害怕、坐立不安，甚至会有胃痛、头晕、呼吸困难等躯体症状。您此时为重度焦虑。建议您尽快寻求专业人士的帮助，到医院、心理诊所等正规机构进行系统的身心调整，按照医嘱服用药物并辅助定期的心理咨询。让自己尽快摆脱焦虑症的困扰。";
  }

  private String createGaugeDataUri(int value, String name) {
    try {
      int w = 600;
      int h = 340;
      BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
      Graphics2D g = image.createGraphics();
      g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
      g.setColor(new Color(255, 255, 255, 0));
      g.fillRect(0, 0, w, h);
      int cx = w / 2;
      int cy = 260;
      int r = 180;
      int stroke = 28;
      int x = cx - r;
      int y = cy - r;
      int d = r * 2;

      // Left->Right semicircle segments based on threshold.
      boolean sds = "SDS".equalsIgnoreCase(name);
      double n1 = (sds ? 53 : 50) / 100.0;
      double n2 = (sds ? 62 : 59) / 100.0;
      double n3 = (sds ? 72 : 69) / 100.0;
      drawSeg(g, x, y, d, stroke, 180 - n1 * 180, n1 * 180, new Color(76, 175, 80));
      drawSeg(g, x, y, d, stroke, 180 - n2 * 180, (n2 - n1) * 180, new Color(241, 196, 91));
      drawSeg(g, x, y, d, stroke, 180 - n3 * 180, (n3 - n2) * 180, new Color(243, 154, 60));
      drawSeg(g, x, y, d, stroke, 0, (1 - n3) * 180, new Color(229, 92, 78));

      // Pointer
      double rad = Math.toRadians(180 - value * 1.8);
      int px = (int) (cx + (r - 16) * Math.cos(rad));
      int py = (int) (cy - (r - 16) * Math.sin(rad));
      g.setColor(new Color(50, 50, 50));
      g.setStroke(new BasicStroke(6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
      g.drawLine(cx, cy, px, py);
      g.fillOval(cx - 8, cy - 8, 16, 16);

      ByteArrayOutputStream out = new ByteArrayOutputStream();
      ImageIO.write(image, "png", out);
      g.dispose();
      return "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
    } catch (Exception e) {
      throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "仪表盘图生成失败: " + e.getMessage());
    }
  }

  private void drawSeg(Graphics2D g, int x, int y, int d, int stroke, double startDeg, double extentDeg, Color color) {
    g.setColor(color);
    g.setStroke(new BasicStroke(stroke, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER));
    Arc2D.Double arc = new Arc2D.Double(x, y, d, d, startDeg, extentDeg, Arc2D.OPEN);
    g.draw(arc);
  }

  private String sclSvg(Map<String, Integer> d) {
    String[] labels = {"躯体化", "强迫", "人际敏感", "抑郁", "焦虑", "敌对", "恐怖", "偏执", "精神病性", "其他"};
    String[] keys = {"躯体化", "强迫症状", "人际关系敏感", "抑郁", "焦虑", "敌对", "恐怖", "偏执", "精神病性", "其他"};
    int[] values = new int[keys.length];
    int max = 15;
    for (int i = 0; i < keys.length; i++) {
      values[i] = d.getOrDefault(keys[i], 0);
      if (values[i] > max) max = values[i];
    }
    int yMax = ((max + 2) / 3) * 3;
    if (yMax < 15) yMax = 15;
    if (yMax > 36) yMax = 36;
    int chartX = 62;
    int chartY = 24;
    int chartW = 588;
    int chartH = 156;
    int stepX = chartW / (labels.length - 1);
    StringBuilder points = new StringBuilder();
    StringBuilder circles = new StringBuilder();
    StringBuilder xLabels = new StringBuilder();
    for (int i = 0; i < labels.length; i++) {
      int x = chartX + i * stepX;
      int y = chartY + chartH - Math.round(values[i] * (chartH / (float) yMax));
      if (i > 0) points.append(" ");
      points.append(x).append(",").append(y);
      circles.append("<circle cx='").append(x).append("' cy='").append(y).append("' r='3.5' fill='#f28f2a'/>");
      circles.append("<text x='").append(x - 6).append("' y='").append(y - 8).append("' font-size='10' fill='#d96f00'>").append(values[i]).append("</text>");
      xLabels.append("<text x='").append(x - 10).append("' y='195' font-size='10' fill='#4b5563'>").append(labels[i]).append("</text>");
    }
    StringBuilder yGrid = new StringBuilder();
    for (int v = 0; v <= yMax; v += 3) {
      int y = chartY + chartH - Math.round(v * (chartH / (float) yMax));
      yGrid.append("<line x1='").append(chartX).append("' y1='").append(y).append("' x2='").append(chartX + chartW).append("' y2='").append(y).append("' stroke='#e9edf2'/>");
      yGrid.append("<text x='").append(chartX - 20).append("' y='").append(y + 4).append("' font-size='10' fill='#7c8798'>").append(v).append("</text>");
    }
    return "<svg width='700' height='210' xmlns='http://www.w3.org/2000/svg'>"
        + "<text x='12' y='16' font-size='12' fill='#333' font-weight='700'>得分图表</text>"
        + yGrid
        + "<line x1='" + chartX + "' y1='" + (chartY + chartH) + "' x2='" + (chartX + chartW) + "' y2='" + (chartY + chartH) + "' stroke='#c7cfdb'/>"
        + "<polyline fill='none' stroke='#f28f2a' stroke-width='2.5' points='" + points + "'/>"
        + circles
        + xLabels
        + "</svg>";
  }

  private String sclChartHtml(Map<String, Integer> d) {
    String uri = createSclChartDataUri(d);
    return "<div style='border:1px solid #f1dfcc;border-radius:8px;padding:10px'>"
        + "<img style='width:640px;height:250px;display:block;margin:0 auto' src='" + uri + "' alt='scl-chart'/>"
        + "</div>";
  }

  private String createSclChartDataUri(Map<String, Integer> d) {
    try {
      String[] labels = {"躯体化", "强迫", "人际敏感", "抑郁", "焦虑", "敌对", "恐怖", "偏执", "精神病性", "其他"};
      String[] keys = {"躯体化", "强迫症状", "人际关系敏感", "抑郁", "焦虑", "敌对", "恐怖", "偏执", "精神病性", "其他"};
      int[] values = new int[keys.length];
      int max = 15;
      for (int i = 0; i < keys.length; i++) {
        values[i] = d.getOrDefault(keys[i], 0);
        if (values[i] > max) max = values[i];
      }
      int yMax = ((max + 2) / 3) * 3;
      if (yMax < 15) yMax = 15;
      if (yMax > 36) yMax = 36;

      int w = 1280;
      int h = 500;
      int left = 90;
      int right = 40;
      int top = 40;
      int bottom = 90;
      int chartW = w - left - right;
      int chartH = h - top - bottom;
      int stepX = chartW / (labels.length - 1);

      BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
      Graphics2D g = image.createGraphics();
      g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
      g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
      g.setColor(Color.WHITE);
      g.fillRect(0, 0, w, h);

      g.setColor(new Color(233, 237, 242));
      g.setStroke(new BasicStroke(2f));
      for (int v = 0; v <= yMax; v += 3) {
        int y = top + chartH - Math.round(v * (chartH / (float) yMax));
        g.drawLine(left, y, left + chartW, y);
        g.setColor(new Color(124, 135, 152));
        g.setFont(new Font("SansSerif", Font.PLAIN, 18));
        g.drawString(String.valueOf(v), 50, y + 6);
        g.setColor(new Color(233, 237, 242));
      }

      g.setColor(new Color(199, 207, 219));
      g.drawLine(left, top + chartH, left + chartW, top + chartH);

      int[] xs = new int[labels.length];
      int[] ys = new int[labels.length];
      for (int i = 0; i < labels.length; i++) {
        xs[i] = left + i * stepX;
        ys[i] = top + chartH - Math.round(values[i] * (chartH / (float) yMax));
      }

      g.setColor(new Color(242, 143, 42));
      g.setStroke(new BasicStroke(5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
      for (int i = 1; i < labels.length; i++) g.drawLine(xs[i - 1], ys[i - 1], xs[i], ys[i]);

      g.setFont(new Font("SansSerif", Font.BOLD, 18));
      for (int i = 0; i < labels.length; i++) {
        g.fillOval(xs[i] - 5, ys[i] - 5, 10, 10);
      }

      g.setColor(new Color(75, 85, 99));
      g.setFont(new Font("SansSerif", Font.PLAIN, 16));
      for (int i = 0; i < labels.length; i++) {
        String label = labels[i];
        int labelW = g.getFontMetrics().stringWidth(label);
        g.drawString(label, xs[i] - labelW / 2, h - 44);
        g.setColor(new Color(217, 111, 0));
        g.setFont(new Font("SansSerif", Font.BOLD, 15));
        String valueText = String.valueOf(values[i]);
        int valueW = g.getFontMetrics().stringWidth(valueText);
        g.drawString(valueText, xs[i] - valueW / 2, h - 24);
        g.setColor(new Color(75, 85, 99));
        g.setFont(new Font("SansSerif", Font.PLAIN, 16));
      }

      ByteArrayOutputStream out = new ByteArrayOutputStream();
      ImageIO.write(image, "png", out);
      g.dispose();
      return "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
    } catch (Exception e) {
      throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "SCL-90 可视化图生成失败: " + e.getMessage());
    }
  }

  private String value(Map<String, Integer> d, String key) {
    Integer v = d.get(key);
    return v == null ? "-" : String.valueOf(v);
  }


  private String sclAvg(Map<String, Integer> d, String key) {
    Integer v = d.get(key);
    if (v == null) return "";
    return String.format(Locale.ROOT, "%.2f", v / 100.0);
  }

  private String sclDegreeText(Map<String, Integer> d, String avgKey) {
    Integer v = d.get(avgKey);
    if (v == null) return "";
    double score = v / 100.0;
    if (score < 1.5) return "无";
    if (score < 2.5) return "轻";
    if (score < 3.5) return "中";
    if (score < 4.5) return "重";
    return "严重";
  }

  private File resolveCjkFontFile() {
    String[] fontPaths = {
        "/Library/Fonts/Arial Unicode.ttf",
        "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
        "/System/Library/Fonts/Supplemental/NISC18030.ttf",
        "/System/Library/Fonts/Supplemental/Silom.ttf",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"
    };
    for (String path : fontPaths) {
      File f = new File(path);
      if (f.exists() && path.toLowerCase(Locale.ROOT).endsWith(".ttf")) return f;
    }
    return null;
  }

  private String esc(String v) {
    if (v == null) return "";
    return v.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
  }

  private void ensureTemplatesReady() {
    if (!new ClassPathResource(COVER_PERSONAL).exists()) {
      throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "缺少封面模板: cover-personal.png");
    }
    if (!new ClassPathResource(COVER_GROUP).exists()) {
      throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "缺少封面模板: cover-group.png");
    }
    if (!new ClassPathResource(TAIL_IMAGE).exists()) {
      throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "缺少尾页模板: tail.jpg");
    }
  }

  private void renderCoverPage(PDDocument document, Report report, PDFont font, String coverType, String groupName) throws IOException {
    PDPage page = new PDPage(PDRectangle.A4);
    document.addPage(page);
    ClassPathResource coverResource = new ClassPathResource("group".equals(coverType) ? COVER_GROUP : COVER_PERSONAL);
    try (InputStream input = coverResource.getInputStream();
         PDPageContentStream stream = new PDPageContentStream(document, page)) {
      byte[] imageBytes = input.readAllBytes();
      PDImageXObject cover = PDImageXObject.createFromByteArray(document, imageBytes, "cover");
      stream.drawImage(cover, 0, 0, page.getMediaBox().getWidth(), page.getMediaBox().getHeight());
    }

    Profile profile = profileRepository.findByProjectAndUser(report.getProject(), report.getUser()).orElse(null);
    try (PDPageContentStream textStream = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
      if ("group".equals(coverType)) {
        drawSingleLine(textStream, font, safe(groupName), 132, 78, 246, 13f, 10.5f);
        drawSingleLine(textStream, font, LocalDate.now(SHANGHAI).format(DATE_FORMAT), 132, 52, 246, 13f, 10.5f);
      } else {
        drawSingleLine(textStream, font, profile == null ? "" : safe(profile.getName()), 113, 94, 190, 13f, 10.5f);
        drawSingleLine(textStream, font, profile == null ? "" : safe(profile.getPhone()), 113, 68, 190, 13f, 10.5f);
        drawSingleLine(textStream, font, profile == null ? "" : safe(profile.getGender()), 113, 42, 190, 13f, 10.5f);
      }
    }
  }

  private void drawSingleLine(PDPageContentStream stream, PDFont font, String raw, float x, float y, float maxWidth, float size, float minSize) throws IOException {
    String text = safe(raw);
    if (text.isBlank()) return;
    float fontSize = size;
    while (fontSize > minSize && textWidth(font, text, fontSize) > maxWidth) {
      fontSize -= 0.5f;
    }
    if (textWidth(font, text, fontSize) > maxWidth) {
      text = ellipsize(font, text, fontSize, maxWidth);
    }
    stream.beginText();
    stream.setFont(font, fontSize);
    stream.newLineAtOffset(x, y);
    stream.showText(text);
    stream.endText();
  }

  private float textWidth(PDFont font, String text, float size) throws IOException {
    return font.getStringWidth(text) / 1000f * size;
  }

  private String ellipsize(PDFont font, String text, float size, float maxWidth) throws IOException {
    if (text.isBlank()) return text;
    String ellipsis = "...";
    String value = text;
    while (!value.isEmpty() && textWidth(font, value + ellipsis, size) > maxWidth) {
      value = value.substring(0, value.length() - 1);
    }
    return value.isEmpty() ? ellipsis : value + ellipsis;
  }

  private void renderStructuredBody(PDDocument document, Report report, PDFont bodyFont) throws IOException {
    Submission submission = report.getSubmission();
    String testPaperId = submission.getAssessment() == null ? "" : safe(submission.getAssessment().getTestPaperId());
    Map<String, Integer> dimensionScores = parseDimensionScores(submission.getDimensionScores());
    if (testPaperId.contains("2078")) {
      renderScl90Body(document, report, bodyFont, dimensionScores);
      return;
    }
    if (testPaperId.contains("2082")) {
      renderSdsBody(document, report, bodyFont);
      return;
    }
    if (testPaperId.contains("2083")) {
      renderSasBody(document, report, bodyFont);
      return;
    }
    renderGenericBody(document, report, bodyFont, dimensionScores);
  }

  private void renderScl90Body(PDDocument document, Report report, PDFont bodyFont, Map<String, Integer> dimensionScores) throws IOException {
    Submission submission = report.getSubmission();
    renderTextPage(document, bodyFont, List.of(
        reportTitle(report) + "（SCL-90）",
        "",
        "报告编号：" + report.getId(),
        "量表编号：SCL90-2078",
        "结果等级：" + safe(submission.getLevel()),
        "总分：" + submission.getRawScore(),
        "",
        "一、量表说明",
        "症状自评量表（SCL-90）用于评估近一周心理状态，共90题，覆盖十个症状因子。",
        "本量表用于筛查，不替代临床诊断。建议结合访谈及实际工作状态综合解释。",
        "",
        "二、结果判定",
        safe(report.getSummary()),
        "参考阈值：总分>160且阳性项目数>43时，一般提示需要进一步专业评估。"
    ));

    renderTextPage(document, bodyFont, List.of(
        "三、维度得分明细",
        dimensionLine("躯体化", dimensionScores),
        dimensionLine("强迫症状", dimensionScores),
        dimensionLine("人际关系敏感", dimensionScores),
        dimensionLine("抑郁", dimensionScores),
        dimensionLine("焦虑", dimensionScores),
        dimensionLine("敌对", dimensionScores),
        dimensionLine("恐怖", dimensionScores),
        dimensionLine("偏执", dimensionScores),
        dimensionLine("精神病性", dimensionScores),
        dimensionLine("其他", dimensionScores),
        dimensionLine("总分", dimensionScores),
        dimensionLine("阳性项目数", dimensionScores),
        "",
        "四、指导建议",
        safe(report.getSuggestions())
    ));
  }

  private void renderSdsBody(PDDocument document, Report report, PDFont bodyFont) throws IOException {
    Submission submission = report.getSubmission();
    PDPage page = new PDPage(PDRectangle.A4);
    document.addPage(page);
    try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
      drawSectionTag(stream, bodyFont, "一、量表简介", 56, 740);
      drawParagraph(stream, bodyFont, 56, 716, 500,
          "抑郁自评量表(Self-rating depression scale, 简称 SDS)，是由美国杜克大学教授庄 (William W.K.Zung) 于1965年编制。"
              + "包括20个项目，每个项目按症状出现的频度分为四级评分，其中10个为正向评分，10个为反向评分。");
      drawSectionTag(stream, bodyFont, "二、结果判定", 56, 626);
      drawParagraph(stream, bodyFont, 56, 602, 500,
          "测评结果：" + safe(submission.getLevel()) + "    标准分：" + safe(report.getSummary()));
      drawParagraph(stream, bodyFont, 56, 582, 500, "分段参考：53以下正常；53-62轻度抑郁；63-72中度抑郁；73以上重度抑郁。");
      drawSectionTag(stream, bodyFont, "三、指导建议", 56, 534);
      drawParagraph(stream, bodyFont, 56, 510, 500, safe(report.getSuggestions()));
    }
  }

  private void renderSasBody(PDDocument document, Report report, PDFont bodyFont) throws IOException {
    Submission submission = report.getSubmission();
    PDPage page = new PDPage(PDRectangle.A4);
    document.addPage(page);
    try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
      drawSectionTag(stream, bodyFont, "一、量表简介", 56, 740);
      drawParagraph(stream, bodyFont, 56, 716, 500,
          "焦虑自评量表 (Self - raing Anxiety Scale, 简称 SAS) 是由美国杜克大学教授庄 (William W.K.Zung) 于1971年编制。"
              + "包括20个项目，每个项目按症状出现的频度分为四级评分，其中15个正向评分，5个反向评分。");
      drawSectionTag(stream, bodyFont, "二、结果判定", 56, 626);
      drawParagraph(stream, bodyFont, 56, 602, 500,
          "测评结果：" + safe(submission.getLevel()) + "    标准分：" + safe(report.getSummary()));
      drawParagraph(stream, bodyFont, 56, 582, 500, "分段参考：50以下正常；50-59轻度焦虑；60-69中度焦虑；70以上重度焦虑。");
      drawSectionTag(stream, bodyFont, "三、指导建议", 56, 534);
      drawParagraph(stream, bodyFont, 56, 510, 500, safe(report.getSuggestions()));
    }
  }

  private void drawSectionTag(PDPageContentStream stream, PDFont font, String title, float x, float y) throws IOException {
    stream.setNonStrokingColor(new Color(242, 143, 31));
    stream.addRect(x, y, 132, 20);
    stream.fill();
    stream.setNonStrokingColor(Color.WHITE);
    stream.beginText();
    stream.setFont(font, 11);
    stream.newLineAtOffset(x + 8, y + 6);
    stream.showText(title);
    stream.endText();
    stream.setNonStrokingColor(Color.BLACK);
  }

  private void renderGenericBody(PDDocument document, Report report, PDFont bodyFont, Map<String, Integer> dimensionScores) throws IOException {
    Submission submission = report.getSubmission();
    renderTextPage(document, bodyFont, List.of(
        reportTitle(report),
        "",
        "报告编号：" + report.getId(),
        "量表编号：" + safe(submission.getAssessment() == null ? "" : submission.getAssessment().getTestPaperId()),
        "结果等级：" + safe(submission.getLevel()),
        "总分：" + submission.getRawScore(),
        "核心结论：" + safe(report.getSummary()),
        "建议：" + safe(report.getSuggestions()),
        "",
        "维度得分：",
        dimensionBlock(dimensionScores)
    ));
  }

  private void renderTextPage(PDDocument document, PDFont bodyFont, List<String> lines) throws IOException {
    PDPage page = new PDPage(PDRectangle.A4);
    document.addPage(page);
    try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
      stream.beginText();
      stream.setFont(bodyFont, 12);
      stream.newLineAtOffset(56, 760);
      for (String line : lines) {
        writeLine(stream, line == null ? "" : line);
      }
      stream.endText();
    }
  }

  private void drawHeaderBlock(PDPageContentStream stream, PDFont font, String title, Long reportId, String paperId) throws IOException {
    stream.setNonStrokingColor(new Color(245, 247, 252));
    stream.addRect(40, 710, 515, 90);
    stream.fill();
    stream.setNonStrokingColor(new Color(20, 36, 64));
    stream.beginText();
    stream.setFont(font, 18);
    stream.newLineAtOffset(56, 770);
    stream.showText(title);
    stream.setFont(font, 11);
    stream.newLineAtOffset(0, -22);
    stream.showText("报告编号：" + reportId + "    量表编号：" + paperId + "    生成日期：" + LocalDate.now(SHANGHAI).format(DATE_FORMAT));
    stream.endText();
  }

  private void drawParagraph(PDPageContentStream stream, PDFont font, float x, float y, float width, String text) throws IOException {
    stream.beginText();
    stream.setFont(font, 11);
    stream.newLineAtOffset(x, y);
    for (String line : wrapByWidth(font, text, width, 11)) {
      stream.showText(line);
      stream.newLineAtOffset(0, -18);
    }
    stream.endText();
  }

  private void drawResultBlock(
      PDPageContentStream stream,
      PDFont font,
      float x,
      float y,
      float width,
      float height,
      String title,
      String resultLine,
      String thresholdLine,
      String advice,
      float[] rgb) throws IOException {
    stream.setNonStrokingColor(new Color(250, 250, 250));
    stream.addRect(x, y, width, height);
    stream.fill();
    stream.setNonStrokingColor(new Color(rgb[0], rgb[1], rgb[2]));
    stream.addRect(x, y + height - 26, width, 26);
    stream.fill();
    stream.setNonStrokingColor(new Color(255, 255, 255));
    stream.beginText();
    stream.setFont(font, 12);
    stream.newLineAtOffset(x + 10, y + height - 18);
    stream.showText(title);
    stream.endText();

    stream.setNonStrokingColor(new Color(32, 32, 32));
    stream.beginText();
    stream.setFont(font, 11);
    stream.newLineAtOffset(x + 10, y + height - 44);
    stream.showText(resultLine);
    stream.newLineAtOffset(0, -18);
    stream.showText(thresholdLine);
    stream.endText();
    drawParagraph(stream, font, x + 10, y + height - 84, width - 20, "建议：" + advice);
  }

  private List<String> wrapByWidth(PDFont font, String text, float width, float size) throws IOException {
    List<String> lines = new java.util.ArrayList<>();
    if (text == null || text.isBlank()) return lines;
    StringBuilder line = new StringBuilder();
    for (char c : text.toCharArray()) {
      line.append(c);
      float w = font.getStringWidth(line.toString()) / 1000f * size;
      if (w > width) {
        line.deleteCharAt(line.length() - 1);
        lines.add(line.toString());
        line.setLength(0);
        line.append(c);
      }
    }
    if (!line.isEmpty()) lines.add(line.toString());
    return lines;
  }

  private String dimensionLine(String key, Map<String, Integer> map) {
    Integer v = map.get(key);
    return key + "：" + (v == null ? "-" : String.valueOf(v));
  }

  private Map<String, Integer> parseDimensionScores(String json) {
    if (json == null || json.isBlank()) return new LinkedHashMap<>();
    try {
      return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Integer>>() {});
    } catch (Exception ignored) {
      return new LinkedHashMap<>();
    }
  }

  private String dimensionBlock(Map<String, Integer> scores) {
    if (scores == null || scores.isEmpty()) return "暂无维度数据。";
    StringBuilder builder = new StringBuilder();
    int i = 0;
    for (Map.Entry<String, Integer> entry : scores.entrySet()) {
      if (i > 0) builder.append("；");
      builder.append(entry.getKey()).append("=").append(entry.getValue());
      i++;
    }
    return builder.toString();
  }

  private void renderTailPage(PDDocument document) throws IOException {
    PDPage page = new PDPage(PDRectangle.A4);
    document.addPage(page);
    ClassPathResource tailResource = new ClassPathResource(TAIL_IMAGE);
    try (InputStream input = tailResource.getInputStream();
         PDPageContentStream stream = new PDPageContentStream(document, page)) {
      byte[] imageBytes = input.readAllBytes();
      PDImageXObject tail = PDImageXObject.createFromByteArray(document, imageBytes, "tail");
      stream.drawImage(tail, 0, 0, page.getMediaBox().getWidth(), page.getMediaBox().getHeight());
    }
  }

  private PDFont loadChineseFont(PDDocument document) throws IOException {
    String[] fontPaths = {
        "/Library/Fonts/Arial Unicode.ttf",
        "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
        "/System/Library/Fonts/Hiragino Sans GB.ttc",
        "/System/Library/Fonts/Supplemental/Songti.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/STHeiti Light.ttc"
    };
    for (String path : fontPaths) {
      File font = new File(path);
      if (!font.exists()) continue;
      try {
        return PDType0Font.load(document, font);
      } catch (IOException ignored) {
        // continue trying next font candidate
      }
    }
    return PDType1Font.HELVETICA;
  }

  private void writeLine(PDPageContentStream stream, String text) throws IOException {
    for (String line : wrap(text, 86).split("\n")) {
      stream.showText(line);
      stream.newLineAtOffset(0, -16);
    }
  }

  private String wrap(String text, int size) {
    StringBuilder builder = new StringBuilder();
    String value = text.length() > 900 ? text.substring(0, 900) : text;
    for (int i = 0; i < value.length(); i += size) {
      builder.append(value, i, Math.min(i + size, value.length())).append('\n');
    }
    return builder.toString();
  }

  private String safe(String value) {
    return value == null ? "" : value.replace('\n', ' ').replace('\r', ' ');
  }

  public record ReportResponse(Long id, Long submissionId, int rawScore, String level, String dimensionScores, String summary, String suggestions, String createdAt) {
    static ReportResponse from(Report report) {
      Submission submission = report.getSubmission();
      return new ReportResponse(report.getId(), submission.getId(), submission.getRawScore(), submission.getLevel(),
          submission.getDimensionScores(), report.getSummary(), report.getSuggestions(), report.getCreatedAt().toString());
    }
  }
}
