package com.ruoyi.ccdi.project.service.impl;

import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.file.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.text.DecimalFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.apache.fontbox.ttf.TrueTypeCollection;
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.PDType0Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * 结果总览PDF报告导出器
 */
@Component
public class CcdiProjectOverviewReportPdfExporter {

    private static final String CONTENT_TYPE = "application/pdf";
    private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00");
    private final String pdfFontPath;

    public CcdiProjectOverviewReportPdfExporter(@Value("${ccdi.report.pdf-font-path:}") String pdfFontPath) {
        this.pdfFontPath = pdfFontPath;
    }

    public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException {
        response.setContentType(CONTENT_TYPE);
        FileUtils.setAttachmentResponseHeader(
            response,
            safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf"
        );

        try (PDDocument document = new PDDocument(); LoadedChineseFont loadedFont = loadChineseFont(document)) {
            PdfPageWriter writer = new PdfPageWriter(document, loadedFont.font());
            writer.newPage();
            writeCover(writer, report);
            writeUploadSubjects(writer, report.getUploadSubjects());
            writeParams(writer, report.getParams());
            writeRiskOverview(writer, report);
            writeRiskDetails(writer, report);
            writer.close();
            document.save(response.getOutputStream());
        }
    }

    private void writeCover(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
        writer.title("初核结果报告");
        writer.text("项目名称：" + safeText(report.getProject().getProjectName()), 12, Color.GRAY);
        writer.text("导出时间：" + LocalDateTime.now().format(EXPORT_TIME_FORMATTER), 12, Color.GRAY);
        writer.separator();
    }

    private void writeUploadSubjects(
        PdfPageWriter writer,
        List<CcdiProjectOverviewReportUploadSubjectVO> rows
    ) throws IOException {
        writer.section("一、上传文件");
        writer.table(
            List.of("序号", "主体名称", "主体账号", "数据周期", "文件数"),
            indexedRows(rows).stream()
                .map(item -> List.of(
                    item.index(),
                    safeText(item.row().getSubjectName()),
                    maskAccountList(item.row().getAccountNos()),
                    safeText(item.row().getDataPeriod()),
                    formatCount(item.row().getFileCount(), "个")
                ))
                .collect(Collectors.toList()),
            new float[] { 0.07F, 0.2F, 0.45F, 0.14F, 0.14F },
            "暂无上传文件数据"
        );
    }

    private void writeParams(PdfPageWriter writer, List<CcdiProjectOverviewReportParamVO> rows) throws IOException {
        writer.section("二、参数配置");
        writer.table(
            List.of("模型名称", "监测项", "参数值", "单位", "描述"),
            rows.stream()
                .map(item -> List.of(
                    safeText(item.getModelName()),
                    safeText(item.getParamName()),
                    safeText(item.getParamValue()),
                    safeText(item.getParamUnit()),
                    safeText(item.getParamDesc())
                ))
                .collect(Collectors.toList()),
            new float[] { 0.18F, 0.26F, 0.14F, 0.12F, 0.3F },
            "暂无参数配置数据"
        );
    }

    private void writeRiskOverview(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
        writer.section("三、风险总览");
        writer.metrics(buildOverallRiskMetrics(report));
        writer.subsection("风险模型汇总");
        writer.table(
            List.of("模型名称", "预警数量", "涉及人员"),
            report.getModelSummaries().stream()
                .map(item -> List.of(
                    safeText(item.getModelName()),
                    String.valueOf(defaultZero(item.getWarningCount())),
                    formatPeopleSummary(item)
                ))
                .collect(Collectors.toList()),
            new float[] { 0.26F, 0.14F, 0.6F },
            "暂无风险模型汇总数据"
        );

        writer.subsection("风险人员与异常点");
        writer.table(
            List.of("姓名", "工号", "身份证号", "所属部门", "命中模型", "异常标签"),
            report.getRiskPeople().stream()
                .map(item -> List.of(
                    safeText(item.getStaffName()),
                    safeText(item.getStaffCode()),
                    maskIdCard(item.getIdNo()),
                    safeText(item.getDepartment()),
                    joinText(item.getModelNames()),
                    formatHitTags(item.getHitTagList())
                ))
                .collect(Collectors.toList()),
            new float[] { 0.1F, 0.11F, 0.16F, 0.14F, 0.24F, 0.25F },
            "暂无风险人员与异常点数据"
        );

        if (hasExternalRisk(report)) {
            writer.subsection("外部人员预警");
            writer.metrics(buildExternalMetrics(report));
            writer.table(
                List.of("外部模型", "预警数量", "涉及人数"),
                report.getExternalModelSummaries().stream()
                    .map(item -> List.of(
                        safeText(item.getModelName()),
                        String.valueOf(defaultZero(item.getWarningCount())),
                        formatCount(item.getPeopleCount(), "人")
                    ))
                    .collect(Collectors.toList()),
                new float[] { 0.5F, 0.2F, 0.3F },
                "暂无外部人员模型汇总数据"
            );
            writer.table(
                List.of("姓名", "证件号", "主体类型", "风险等级", "命中模型数", "核心异常点", "涉及对象", "最近交易时间"),
                report.getExternalPersonWarnings().stream()
                    .map(item -> List.of(
                        safeText(item.getName()),
                        maskIdCard(item.getIdNo()),
                        safeText(item.getSubjectType()),
                        safeText(item.getRiskLevel()),
                        String.valueOf(defaultZero(item.getModelCount())),
                        safeText(item.getRiskPoint()),
                        safeText(item.getRelatedObject()),
                        safeText(item.getLatestTradeTime())
                    ))
                    .collect(Collectors.toList()),
                new float[] { 0.09F, 0.15F, 0.1F, 0.09F, 0.1F, 0.25F, 0.12F, 0.1F },
                "暂无外部人员预警数据"
            );
        }
    }

    private void writeRiskDetails(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
        writer.section("四、风险明细");
        writer.subsection("1. 涉疑交易明细表（共" + report.getSuspiciousTransactions().size() + "条）");
        writer.table(
            List.of("交易时间", "本方账户", "对方账户", "关联员工", "摘要/交易类型", "异常标签", "交易金额"),
            report.getSuspiciousTransactions().stream()
                .map(item -> List.of(
                    safeText(item.getTrxDate()),
                    formatAccount(item.getLeAccountNo(), item.getLeAccountName()),
                    formatAccount(item.getCustomerAccountNo(), item.getCustomerAccountName()),
                    formatRelatedStaff(item.getRelatedStaffName(), item.getRelatedStaffCode()),
                    formatSummaryAndCashType(item.getUserMemo(), item.getCashType()),
                    safeText(item.getHitTags()),
                    formatMoney(item.getDisplayAmount())
                ))
                .collect(Collectors.toList()),
            new float[] { 0.14F, 0.16F, 0.16F, 0.12F, 0.17F, 0.16F, 0.09F },
            "暂无涉疑交易明细"
        );

        writer.subsection("2. 违法信息人员表（共" + report.getIllegalPeople().size() + "人）");
        writer.table(
            List.of("姓名", "身份证号", "最近查询日期", "民事案件笔数", "民事案件金额", "强制执行笔数", "强制执行金额", "行政处罚笔数", "行政处罚金额"),
            report.getIllegalPeople().stream()
                .map(item -> List.of(
                    safeText(item.getPersonName()),
                    maskIdCard(item.getPersonId()),
                    safeText(item.getQueryDate()),
                    String.valueOf(defaultZero(item.getCivilCnt())),
                    formatMoney(item.getCivilLmt()),
                    String.valueOf(defaultZero(item.getEnforceCnt())),
                    formatMoney(item.getEnforceLmt()),
                    String.valueOf(defaultZero(item.getAdmCnt())),
                    formatMoney(item.getAdmLmt())
                ))
                .collect(Collectors.toList()),
            new float[] { 0.09F, 0.15F, 0.12F, 0.1F, 0.11F, 0.1F, 0.11F, 0.1F, 0.12F },
            "暂无违法信息人员数据"
        );

        writer.subsection("3. 异常账户信息表（共" + report.getAbnormalAccounts().size() + "条）");
        writer.table(
            List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态", "命中原因", "涉及金额"),
            report.getAbnormalAccounts().stream()
                .map(item -> List.of(
                    maskAccount(item.getAccountNo()),
                    safeText(item.getAccountName()),
                    safeText(item.getBankName()),
                    safeText(item.getAbnormalType()),
                    safeText(item.getAbnormalTime()),
                    safeText(item.getStatus()),
                    safeText(item.getReasonDetail()),
                    formatMoney(item.getInvolvedAmount())
                ))
                .collect(Collectors.toList()),
            new float[] { 0.12F, 0.1F, 0.14F, 0.14F, 0.11F, 0.08F, 0.22F, 0.09F },
            "暂无异常账户信息"
        );

        writer.subsection("4. 未提交账户流水表（共" + report.getUnsubmittedAccountFlows().size() + "条）");
        writer.table(
            List.of("主体", "未提交账号", "交易时间", "本方账号", "对方名称", "摘要/交易类型", "流入", "流出"),
            report.getUnsubmittedAccountFlows().stream()
                .map(item -> List.of(
                    safeText(item.getSubjectName()),
                    maskAccount(item.getAccountNo()),
                    safeText(item.getTrxDate()),
                    maskAccount(item.getLeAccountNo()),
                    safeText(item.getCustomerAccountName()),
                    formatSummaryAndCashType(item.getUserMemo(), item.getCashType()),
                    formatMoney(item.getAmountCr()),
                    formatMoney(item.getAmountDr())
                ))
                .collect(Collectors.toList()),
            new float[] { 0.14F, 0.13F, 0.12F, 0.13F, 0.14F, 0.17F, 0.08F, 0.09F },
            "暂无未提交账户流水"
        );
    }

    private LoadedChineseFont loadChineseFont(PDDocument document) throws IOException {
        if (pdfFontPath == null || pdfFontPath.isBlank()) {
            throw new ServiceException("未配置PDF中文字体路径，无法导出PDF报告");
        }
        String path = pdfFontPath.trim();
        File file = new File(path);
        if (!file.exists() || !file.isFile()) {
            throw new ServiceException("配置的PDF中文字体路径不可用：" + path);
        }

        String lowerPath = path.toLowerCase();
        if (lowerPath.endsWith(".ttf")) {
            return new LoadedChineseFont(PDType0Font.load(document, file), null);
        }
        if (lowerPath.endsWith(".ttc")) {
            LoadedChineseFont loadedFont = loadPreferredCollectionFont(document, file);
            if (loadedFont != null) {
                return loadedFont;
            }
            throw new ServiceException("配置的PDF中文字体不支持导出：" + path);
        }
        if (lowerPath.endsWith(".otf")) {
            return new LoadedChineseFont(loadOpenTypeFont(document, file), null);
        }

        throw new ServiceException("配置的PDF中文字体格式不支持：" + path);
    }

    private PDType0Font loadOpenTypeFont(PDDocument document, File file) throws IOException {
        try (var inputStream = Files.newInputStream(file.toPath())) {
            return PDType0Font.load(document, inputStream, false);
        }
    }

    private LoadedChineseFont loadPreferredCollectionFont(PDDocument document, File file) throws IOException {
        LoadedChineseFont font = loadCollectionFontByName(document, file);
        return font == null ? loadFirstCollectionFont(document, file) : font;
    }

    private LoadedChineseFont loadCollectionFontByName(PDDocument document, File file) throws IOException {
        TrueTypeCollection collection = new TrueTypeCollection(file);
        AtomicReference<PDType0Font> font = new AtomicReference<>();
        try {
            collection.processAllFonts(typeFont -> {
                if (font.get() == null && supportsTrueTypeSubset(typeFont)
                    && isSimplifiedChineseFont(typeFont.getName())) {
                    font.set(PDType0Font.load(document, typeFont, true));
                }
            });
            if (font.get() != null) {
                return new LoadedChineseFont(font.get(), collection);
            }
        } finally {
            if (font.get() == null) {
                collection.close();
            }
        }
        return null;
    }

    private boolean isSimplifiedChineseFont(String fontName) {
        if (fontName == null) {
            return false;
        }
        String lowerName = fontName.toLowerCase();
        return lowerName.contains("sc")
            || lowerName.contains("hans")
            || lowerName.contains("gb")
            || lowerName.contains("hei")
            || lowerName.contains("song")
            || lowerName.contains("yahei")
            || lowerName.contains("simsun")
            || lowerName.contains("simhei");
    }

    private LoadedChineseFont loadFirstCollectionFont(PDDocument document, File file) throws IOException {
        TrueTypeCollection collection = new TrueTypeCollection(file);
        AtomicReference<PDType0Font> font = new AtomicReference<>();
        try {
            collection.processAllFonts(typeFont -> {
                if (font.get() == null && supportsTrueTypeSubset(typeFont)) {
                    font.set(PDType0Font.load(document, typeFont, true));
                }
            });
            if (font.get() != null) {
                return new LoadedChineseFont(font.get(), collection);
            }
        } finally {
            if (font.get() == null) {
                collection.close();
            }
        }
        return null;
    }

    private boolean supportsTrueTypeSubset(org.apache.fontbox.ttf.TrueTypeFont typeFont) throws IOException {
        return typeFont.getTableMap().containsKey("glyf");
    }

    private record LoadedChineseFont(PDType0Font font, Closeable fontSource) implements Closeable {

        @Override
        public void close() throws IOException {
            if (fontSource != null) {
                fontSource.close();
            }
        }
    }

    private List<IndexedUploadSubject> indexedRows(List<CcdiProjectOverviewReportUploadSubjectVO> rows) {
        List<IndexedUploadSubject> result = new ArrayList<>();
        for (int i = 0; i < rows.size(); i++) {
            result.add(new IndexedUploadSubject(String.valueOf(i + 1), rows.get(i)));
        }
        return result;
    }

    private List<CcdiProjectOverviewStatVO> buildOverallRiskMetrics(CcdiProjectOverviewReportVO report) {
        List<CcdiProjectOverviewStatVO> employeeStats = report.getDashboard().getStats();
        if (employeeStats == null || employeeStats.isEmpty()) {
            return List.of(
                buildMetric("总人数", report.getExternalRiskSummary().getTotal()),
                buildMetric("高风险", report.getExternalRiskSummary().getHigh()),
                buildMetric("中风险", report.getExternalRiskSummary().getMedium()),
                buildMetric("低风险", report.getExternalRiskSummary().getLow()),
                buildMetric("无风险", report.getExternalRiskSummary().getNoRisk())
            );
        }
        int employeeTotal = metricValue(employeeStats, "people");
        int high = metricValue(employeeStats, "riskPeople");
        int medium = metricValue(employeeStats, "medium");
        int low = metricValue(employeeStats, "low");
        int noRisk = metricValue(employeeStats, "count");
        int externalTotal = defaultZero(report.getExternalRiskSummary().getTotal());
        int externalHigh = defaultZero(report.getExternalRiskSummary().getHigh());
        int externalMedium = defaultZero(report.getExternalRiskSummary().getMedium());
        int externalLow = defaultZero(report.getExternalRiskSummary().getLow());
        int externalNoRisk = defaultZero(report.getExternalRiskSummary().getNoRisk());
        return List.of(
            buildMetric("总人数", employeeTotal + externalTotal),
            buildMetric("高风险", high + externalHigh),
            buildMetric("中风险", medium + externalMedium),
            buildMetric("低风险", low + externalLow),
            buildMetric("无风险", noRisk + externalNoRisk)
        );
    }

    private Integer metricValue(List<CcdiProjectOverviewStatVO> stats, String key) {
        return defaultZero(stats.stream()
            .filter(stat -> key.equals(stat.getKey()))
            .findFirst()
            .map(CcdiProjectOverviewStatVO::getValue)
            .orElse(0));
    }

    private boolean hasExternalRisk(CcdiProjectOverviewReportVO report) {
        return defaultZero(report.getExternalRiskSummary().getHigh()) > 0
            || defaultZero(report.getExternalRiskSummary().getMedium()) > 0
            || defaultZero(report.getExternalRiskSummary().getLow()) > 0
            || !report.getExternalModelSummaries().isEmpty()
            || !report.getExternalPersonWarnings().isEmpty();
    }

    private List<CcdiProjectOverviewStatVO> buildExternalMetrics(CcdiProjectOverviewReportVO report) {
        return List.of(
            buildMetric("外部人员", report.getExternalRiskSummary().getTotal()),
            buildMetric("高风险", report.getExternalRiskSummary().getHigh()),
            buildMetric("中风险", report.getExternalRiskSummary().getMedium()),
            buildMetric("低风险", report.getExternalRiskSummary().getLow()),
            buildMetric("无风险人员", report.getExternalRiskSummary().getNoRisk())
        );
    }

    private CcdiProjectOverviewStatVO buildMetric(String label, Integer value) {
        CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
        stat.setLabel(label);
        stat.setValue(defaultZero(value));
        return stat;
    }

    private String formatPeopleSummary(CcdiProjectOverviewReportModelSummaryVO item) {
        String names = safeText(item.getPeopleNames());
        if ("-".equals(names)) {
            return names;
        }
        List<String> people = Arrays.stream(names.split("、"))
            .filter(value -> value != null && !value.isBlank())
            .distinct()
            .toList();
        if (people.size() <= 4) {
            return String.join("、", people);
        }
        return String.join("、", people.subList(0, 4)) + "等" + defaultZero(item.getPeopleCount()) + "人";
    }

    private String formatHitTags(List<CcdiProjectRiskHitTagVO> tags) {
        if (tags == null || tags.isEmpty()) {
            return "-";
        }
        String text = tags.stream()
            .map(CcdiProjectRiskHitTagVO::getRuleName)
            .filter(Objects::nonNull)
            .filter(value -> !value.isBlank())
            .distinct()
            .collect(Collectors.joining("、"));
        return text.isBlank() ? "-" : text;
    }

    private String joinText(List<String> values) {
        if (values == null || values.isEmpty()) {
            return "-";
        }
        String text = values.stream()
            .filter(Objects::nonNull)
            .filter(value -> !value.isBlank())
            .distinct()
            .collect(Collectors.joining("、"));
        return text.isBlank() ? "-" : text;
    }

    private String formatRelatedStaff(String name, String code) {
        if (name == null || name.isBlank()) {
            return "-";
        }
        if (code == null || code.isBlank()) {
            return name;
        }
        return name + "(" + code + ")";
    }

    private String formatSummaryAndCashType(String summary, String cashType) {
        return safeText(summary) + "/" + safeText(cashType);
    }

    private String formatAccount(String accountNo, String accountName) {
        String masked = maskAccount(accountNo);
        String name = safeText(accountName);
        if ("-".equals(name)) {
            return masked;
        }
        return masked + "\n" + name;
    }

    private String maskAccountList(String value) {
        if (value == null || value.isBlank()) {
            return "-";
        }
        return Arrays.stream(value.split("、|,|，"))
            .map(String::trim)
            .filter(item -> !item.isBlank())
            .map(this::maskAccount)
            .distinct()
            .collect(Collectors.joining("、"));
    }

    private String maskAccount(String value) {
        if (value == null || value.isBlank()) {
            return "-";
        }
        String text = value.trim().replaceAll("\\s+", "");
        if (text.length() <= 8) {
            return text.length() <= 4 ? text : text.substring(0, 2) + "****" + text.substring(text.length() - 2);
        }
        return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
    }

    private String maskIdCard(String value) {
        if (value == null || value.isBlank()) {
            return "-";
        }
        String text = value.trim();
        if (text.length() < 10) {
            return text;
        }
        return text.substring(0, 3) + "***********" + text.substring(text.length() - 4);
    }

    private String formatCount(Integer value, String unit) {
        return defaultZero(value) + unit;
    }

    private String formatMoney(BigDecimal value) {
        if (value == null) {
            return "-";
        }
        return MONEY_FORMAT.format(value);
    }

    private Integer defaultZero(Integer value) {
        return value == null ? 0 : value;
    }

    private String safeText(String value) {
        return value == null || value.isBlank() ? "-" : value;
    }

    private String safeFileName(String value) {
        String text = safeText(value);
        return text.replaceAll("[\\\\/:*?\"<>|]", "_");
    }

    private record IndexedUploadSubject(String index, CcdiProjectOverviewReportUploadSubjectVO row) {
    }

    private static class PdfPageWriter {

        private static final float MARGIN = 36F;
        private static final PDRectangle LANDSCAPE_A4 = new PDRectangle(
            PDRectangle.A4.getHeight(),
            PDRectangle.A4.getWidth()
        );
        private static final float CONTENT_WIDTH = LANDSCAPE_A4.getWidth() - MARGIN * 2;
        private static final float PAGE_TOP = LANDSCAPE_A4.getHeight() - MARGIN;
        private static final float PAGE_BOTTOM = MARGIN;
        private static final float BODY_FONT_SIZE = 9F;
        private static final float HEADER_FONT_SIZE = 9F;
        private static final float TITLE_FONT_SIZE = 22F;
        private static final float SECTION_FONT_SIZE = 15F;
        private static final float SUBSECTION_FONT_SIZE = 12F;
        private static final float LINE_HEIGHT = 12F;
        private static final float CELL_PADDING = 5F;
        private static final float TABLE_AFTER_GAP = 32F;

        private final PDDocument document;
        private final PDType0Font font;
        private PDPageContentStream content;
        private float y;

        PdfPageWriter(PDDocument document, PDType0Font font) {
            this.document = document;
            this.font = font;
        }

        void newPage() throws IOException {
            close();
            PDPage page = new PDPage(LANDSCAPE_A4);
            document.addPage(page);
            content = new PDPageContentStream(document, page);
            y = PAGE_TOP;
        }

        void close() throws IOException {
            if (content != null) {
                content.close();
                content = null;
            }
        }

        void title(String text) throws IOException {
            writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F, true);
        }

        void text(String text, float fontSize, Color color) throws IOException {
            writeLine(text, fontSize, color, 0F, 18F);
        }

        void section(String text) throws IOException {
            ensureSpace(32F);
            writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F, true);
        }

        void subsection(String text) throws IOException {
            ensureSpace(26F);
            writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F, true);
        }

        void separator() throws IOException {
            ensureSpace(16F);
            content.setStrokingColor(new Color(31, 78, 121));
            content.setLineWidth(1.4F);
            content.moveTo(MARGIN, y);
            content.lineTo(MARGIN + CONTENT_WIDTH, y);
            content.stroke();
            y -= 22F;
        }

        void metrics(List<CcdiProjectOverviewStatVO> stats) throws IOException {
            ensureSpace(58F);
            float cellWidth = CONTENT_WIDTH / Math.max(stats.size(), 1);
            float x = MARGIN;
            float rowHeight = 50F;
            for (CcdiProjectOverviewStatVO stat : stats) {
                drawRect(x, y - rowHeight, cellWidth, rowHeight, null);
                drawCenteredText(String.valueOf(stat.getValue()), x, y - 18F, cellWidth, 18F, new Color(31, 78, 121));
                drawCenteredText(stat.getLabel(), x, y - 36F, cellWidth, 10F, Color.GRAY);
                x += cellWidth;
            }
            y -= rowHeight + 18F;
        }

        void table(
            List<String> headers,
            List<List<String>> rows,
            float[] widthRatios,
            String emptyText
        ) throws IOException {
            List<List<String>> safeRows = rows.isEmpty()
                ? List.of(List.of(emptyText))
                : rows;
            List<String> safeHeaders = rows.isEmpty()
                ? List.of(headers.get(0))
                : headers;
            float[] widths = rows.isEmpty()
                ? new float[] { CONTENT_WIDTH }
                : calculateWidths(widthRatios);

            drawHeader(safeHeaders, widths);
            for (List<String> row : safeRows) {
                drawRow(row, widths, false);
            }
            y -= TABLE_AFTER_GAP;
        }

        private float[] calculateWidths(float[] ratios) {
            float[] widths = new float[ratios.length];
            for (int i = 0; i < ratios.length; i++) {
                widths[i] = CONTENT_WIDTH * ratios[i];
            }
            return widths;
        }

        private void drawHeader(List<String> headers, float[] widths) throws IOException {
            drawRow(headers, widths, true);
        }

        private void drawRow(List<String> cells, float[] widths, boolean header) throws IOException {
            List<List<String>> wrappedCells = new ArrayList<>();
            float rowHeight = 0F;
            for (int i = 0; i < widths.length; i++) {
                String text = i < cells.size() ? cells.get(i) : "";
                List<String> lines = wrapText(text, widths[i] - CELL_PADDING * 2, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE);
                wrappedCells.add(lines);
                rowHeight = Math.max(rowHeight, lines.size() * LINE_HEIGHT + CELL_PADDING * 2);
            }
            rowHeight = Math.max(rowHeight, 24F);
            ensureSpace(rowHeight + 4F);

            float x = MARGIN;
            for (int i = 0; i < widths.length; i++) {
                Color background = header ? new Color(234, 241, 248) : null;
                drawRect(x, y - rowHeight, widths[i], rowHeight, background);
                drawCellText(wrappedCells.get(i), x + CELL_PADDING, y - CELL_PADDING - (header ? HEADER_FONT_SIZE : BODY_FONT_SIZE), header);
                x += widths[i];
            }
            y -= rowHeight;
        }

        private void drawRect(float x, float bottomY, float width, float height, Color fill) throws IOException {
            if (fill != null) {
                content.setNonStrokingColor(fill);
                content.addRect(x, bottomY, width, height);
                content.fill();
            }
            content.setStrokingColor(new Color(205, 217, 229));
            content.setLineWidth(0.5F);
            content.addRect(x, bottomY, width, height);
            content.stroke();
        }

        private void drawCellText(List<String> lines, float x, float startY, boolean header) throws IOException {
            content.beginText();
            content.setNonStrokingColor(header ? new Color(24, 59, 90) : new Color(31, 41, 55));
            content.setFont(font, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE);
            content.newLineAtOffset(x, startY);
            for (int i = 0; i < lines.size(); i++) {
                if (i > 0) {
                    content.newLineAtOffset(0, -LINE_HEIGHT);
                }
                content.showText(lines.get(i));
            }
            content.endText();
        }

        private void drawCenteredText(String text, float x, float baselineY, float width, float fontSize, Color color)
            throws IOException {
            float textWidth = textWidth(text, fontSize);
            content.beginText();
            content.setNonStrokingColor(color);
            content.setFont(font, fontSize);
            content.newLineAtOffset(x + (width - textWidth) / 2F, baselineY);
            content.showText(text);
            content.endText();
        }

        private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException {
            writeLine(text, fontSize, color, indent, advance, false);
        }

        private void writeLine(
            String text,
            float fontSize,
            Color color,
            float indent,
            float advance,
            boolean bold
        ) throws IOException {
            ensureSpace(advance);
            content.beginText();
            content.setNonStrokingColor(color);
            content.setFont(font, fontSize);
            content.newLineAtOffset(MARGIN + indent, y);
            content.showText(text);
            content.endText();
            if (bold) {
                content.beginText();
                content.setNonStrokingColor(color);
                content.setFont(font, fontSize);
                content.newLineAtOffset(MARGIN + indent + 0.25F, y);
                content.showText(text);
                content.endText();
            }
            y -= advance;
        }

        private void ensureSpace(float height) throws IOException {
            if (y - height < PAGE_BOTTOM) {
                newPage();
            }
        }

        private List<String> wrapText(String text, float maxWidth, float fontSize) throws IOException {
            String safeText = text == null || text.isBlank() ? "-" : text;
            List<String> result = new ArrayList<>();
            for (String part : safeText.split("\\n")) {
                wrapPart(part, maxWidth, fontSize, result);
            }
            return result.isEmpty() ? List.of("-") : result;
        }

        private void wrapPart(String text, float maxWidth, float fontSize, List<String> result) throws IOException {
            StringBuilder current = new StringBuilder();
            for (int i = 0; i < text.length(); i++) {
                String next = String.valueOf(text.charAt(i));
                if (textWidth(current + next, fontSize) > maxWidth && current.length() > 0) {
                    result.add(current.toString());
                    current.setLength(0);
                }
                current.append(next);
            }
            if (current.length() > 0) {
                result.add(current.toString());
            }
        }

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