<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

/**
 * Builds and serves both PDF exports (student fiche, CAPP livret) via
 * tc-lib-pdf (vendored, see composer.json — Moodle's own \pdf/TCPDF cannot
 * produce PDF/UA-compliant output, see PREREQUIS_SERVEUR.md and the
 * kid_pdf_ua_audit project memory for the full rationale).
 *
 * @package    local_kid
 */

namespace local_kid\local;

defined('MOODLE_INTERNAL') || die();

class pdf_service {

    /** Entry-level (not per-competency) comment fields, one per domain that has one; keyed by the fiche_builder item property, valued by its lang string. */
    private const GLOBAL_COMMENT_FIELDS = [
        'commentaireglobal' => 'stage_commentaire_global',
        'appreciationglobale' => 'capp_appreciation_globale',
    ];

    /** Competency table column widths, as percentages of the table's 100% width; must be set on every cell to keep columns aligned. */
    private const COL_COMPETENCE = '30%';
    private const COL_NIVEAU = '15%';
    private const COL_COMMENTAIRE = '55%';

    /** Reduced font size for the competency-name column, so full names wrap rather than overflow. */
    private const COMPETENCE_FONT_SIZE = '8pt';

    /**
     * DSFR-inspired palette — mirrors styles/local_kid.css's
     * body.local-kid-app custom properties, so the PDF export reads as the
     * same design system as the web app.
     */
    private const COLOR_PRIMARY = '#000091';
    private const COLOR_MARIANNE_RED = '#E1000F';
    private const COLOR_TEXT = '#1E1E1E';
    private const COLOR_MUTED = '#6B6B6B';
    private const COLOR_BORDER = '#DDDDDD';
    private const COLOR_ROW_ALT = '#F8F8F8';

    /** HTML content margins, mm — matches the convention used throughout tc-lib-pdf's own examples. */
    private const CONTENT_POSX = 15.0;
    private const CONTENT_WIDTH = 180.0;

    /** Conservative estimate of how many sommaire rows fit on one page at SOMMAIRE_ROW_HEIGHT — see build_capp_rapport()'s docblock for how the reservation count is derived from this. */
    private const SOMMAIRE_ROWS_PER_PAGE = 35;

    /** Target row height (mm) the sommaire is rendered at, so 35 rows (SOMMAIRE_ROWS_PER_PAGE) span the page's usable content height instead of clustering at the top — see SOMMAIRE_ROW_VPADDING for how this is actually achieved. */
    private const SOMMAIRE_ROW_HEIGHT = 7.0;

    /** Per-side (top and bottom) cell padding, mm, added in fill_capp_sommaire() to reach SOMMAIRE_ROW_HEIGHT — empirically calibrated against the sommaire's actual font/size, not derived by formula (tc-lib-html's natural, unpadded line height for this cell isn't exposed as a constant). */
    private const SOMMAIRE_ROW_VPADDING = 1.0;

    public static function build_fiche(\stdClass $dto): \Com\Tecnick\Pdf\Tcpdf {
        $pdf = self::init_pdf();
        $pdf->setTitle(get_string('fiche_pdf_title', 'local_kid', (object) [
            'student' => $dto->student->fullname,
            'promotion' => $dto->student->promotionname,
        ]));

        $pdf->addPage();
        $pdf->addHTMLCell(html: self::build_fiche_coverpage_html($dto), posx: self::CONTENT_POSX, posy: 15, width: self::CONTENT_WIDTH);

        $pdf->addPage();
        $pdf->addHTMLCell(html: self::build_html($dto), posx: self::CONTENT_POSX, posy: 15, width: self::CONTENT_WIDTH);

        return $pdf;
    }

    private static function build_html(\stdClass $dto): string {
        $html = self::h1(s($dto->student->fullname));
        $subtitle = s($dto->student->promotionname) . ($dto->student->codeena ? ' — ' . s($dto->student->codeena) : '');
        $html .= '<p style="color:' . self::COLOR_MUTED . ';font-size:10pt;margin:0 0 6mm 0;">' . $subtitle . '</p>';

        $html .= self::domain_html(
            get_string('student_section_eval', 'local_kid'),
            get_string('student_section_eval_empty', 'local_kid'),
            $dto->evaluations
        );
        $html .= self::domain_html(
            get_string('student_section_stage', 'local_kid'),
            get_string('student_section_stage_empty', 'local_kid'),
            $dto->stages
        );
        $html .= self::domain_html(
            get_string('student_section_capp', 'local_kid'),
            get_string('student_section_capp_empty', 'local_kid'),
            $dto->capp
        );

        return $html;
    }

    private static function domain_html(string $title, string $emptylabel, array $items): string {
        $html = self::h2($title);
        if (empty($items)) {
            $html .= '<p><i>' . s($emptylabel) . '</i></p>';
            return $html;
        }
        foreach ($items as $item) {
            $itemtitle = $item->titre . ($item->date ?? '' ? ' — ' . $item->date : '') . ' (' . $item->statuslabel . ')';
            $html .= self::h3(s($itemtitle));
            $html .= self::table_open();
            $html .= self::table_header_row([
                ['width' => self::COL_COMPETENCE, 'label' => get_string('grid_competence', 'local_kid')],
                ['width' => self::COL_NIVEAU, 'label' => get_string('grid_niveau', 'local_kid')],
                ['width' => self::COL_COMMENTAIRE, 'label' => get_string('grid_commentaire', 'local_kid')],
            ]);
            foreach ($item->competences as $index => $cell) {
                $rowstyle = self::zebra_style($index);
                $html .= '<tr' . $rowstyle . '><td width="' . self::COL_COMPETENCE . '"><span style="font-size:' . self::COMPETENCE_FONT_SIZE . ';">'
                        . s($cell->code . ' — ' . $cell->libelle) . '</span></td>'
                    . '<td width="' . self::COL_NIVEAU . '">' . s($cell->niveau ?? '') . '</td>'
                    . '<td width="' . self::COL_COMMENTAIRE . '">' . s($cell->commentaire ?? '') . '</td></tr>';
                if (!empty($cell->hasmoodlefeedback)) {
                    $html .= '<tr' . $rowstyle . '><td width="' . self::COL_COMPETENCE . '"></td><td width="' . self::COL_NIVEAU . '"></td>'
                        . '<td width="' . self::COL_COMMENTAIRE . '"><b><i>' . s(get_string('eval_feedback_heading', 'local_kid')) . '</i></b> : '
                        . nl2br(s($cell->moodlefeedback)) . '</td></tr>';
                }
            }
            $html .= '</table>';
            foreach (self::GLOBAL_COMMENT_FIELDS as $field => $labelstring) {
                if (!empty($item->$field)) {
                    $html .= '<p><b>' . s(get_string($labelstring, 'local_kid')) . '</b> : ' . s($item->$field) . '</p>';
                }
            }
        }
        return $html;
    }

    /**
     * Builds the CAPP session "livret" PDF from a capp_rapport_builder DTO:
     * page de garde (no header/footer), sommaire, Bilan général optionnel
     * (mode cohorte uniquement), puis une fiche par élève.
     *
     * The sommaire's page slot(s) are reserved right after the cover
     * (addPage()) but left empty — bilan and student pages are then added
     * normally after them, so every page lands directly in its final,
     * permanent position from the start. setBookmark() is called once per
     * student page as it's generated (this is also what feeds the PDF
     * viewer's native bookmarks/outline panel — real navigation,
     * independent of the in-page sommaire below). Only once every page
     * exists (so every bookmark's page number is final) is the sommaire
     * filled in, and only then is the header/footer pass run over every
     * page. This ordering (reserve-then-fill, never move/reorder a page
     * after the fact) is deliberate: tc-lib-pdf has no page-number-alias
     * mechanism like TCPDF's getAliasNumPage(), so a page moved after its
     * header/footer was already drawn would show a stale, wrong page
     * number — reserving the slot(s) up front avoids ever needing to move
     * anything.
     */
    public static function build_capp_rapport(\stdClass $dto): capp_rapport_pdf {
        $pdf = self::init_capp_rapport_pdf($dto);

        $pdf->addPage();
        $pdf->setBookmark(
            name: get_string('capp_rapport_coverpage_bookmark', 'local_kid'),
            link: '', level: 0, page: -1, posx: 0, posy: 0, fstyle: '', color: 'black',
        );
        $pdf->addHTMLCell(html: self::build_capp_coverpage_html($dto), posx: self::CONTENT_POSX, posy: 15, width: self::CONTENT_WIDTH);

        // Reserve exactly enough consecutive blank pages up front for the sommaire, computed from
        // the same SOMMAIRE_ROWS_PER_PAGE constant fill_capp_sommaire() renders with — see that
        // method's docblock for why the sommaire is hand-rendered rather than built via addTOC().
        $sommairepagecount = max(1, (int) ceil(count($dto->students) / self::SOMMAIRE_ROWS_PER_PAGE));
        $sommairepids = [];
        for ($i = 0; $i < $sommairepagecount; $i++) {
            $page = $pdf->addPage();
            $sommairepids[] = $page['pid'];
            if ($i === 0) {
                // Registered first so it sorts above every student bookmark in the PDF
                // viewer's outline panel — tc-lib-pdf keeps outlines in registration
                // order, not page order. fill_capp_sommaire() excludes this bookmark's
                // own page(s) from the rendered rows, so it doesn't appear as a
                // self-referential entry inside the printed sommaire table itself.
                $pdf->setBookmark(
                    name: get_string('capp_rapport_sommaire_heading', 'local_kid'),
                    link: '', level: 0, page: -1, posx: 0, posy: 0, fstyle: '', color: 'black',
                );
            }
        }

        if ($dto->bilan) {
            $pdf->addPage();
            $pdf->addHTMLCell(html: self::build_capp_bilan_html($dto), posx: self::CONTENT_POSX, posy: 20, width: self::CONTENT_WIDTH);
        }

        foreach ($dto->students as $student) {
            $pdf->addPage();
            $pdf->setBookmark(name: $student->displayname, link: '', level: 0, page: -1, posx: 0, posy: 0, fstyle: '', color: 'black');
            $pdf->addHTMLCell(html: self::build_capp_student_html($dto, $student), posx: self::CONTENT_POSX, posy: 20, width: self::CONTENT_WIDTH);
        }

        self::fill_capp_sommaire($pdf, $sommairepids);
        self::render_headers_footers($pdf);

        return $pdf;
    }

    /**
     * Fills the sommaire's reserved pages with a heading and one clickable
     * line per student (name + page number) — via addHTMLCell(), the same
     * HTML pipeline used everywhere else in this document, redirected to
     * each reserved page with setCurrentPage(). Each row is a real
     * PDF/UA-tagged link (addInternalLink() + <a href="@N">, see below),
     * on top of the PDF viewer's native bookmarks/outline panel
     * (setBookmark(), set while the student pages were generated) — two
     * independent, both-real navigation paths to the same target page.
     *
     * addTOC() was tried first and looked fine visually, but failed real
     * veraPDF PDF/UA-1 validation on the generated livret (not caught by
     * the spike, which only validated tc-lib-pdf's own generic examples):
     * both the TOC entries' text (rule 7.1-3, "neither Artifact nor tagged
     * as real content") and the clickable links addTOC() creates via
     * setLink() directly (rule 7.18.5-1, "Link annotation ... instead of
     * Link") come out untagged — a real gap in addTOC() itself. Hand-
     * rendering the same text via the low-level addTextCellXY() +
     * beginStructElem() API (tried second) turned out to have the exact
     * same gap for its links. The fix, found by reading HTML.php directly:
     * addHTMLCell()'s own <a href="..."> handling (parseHTMLTagOPENa/
     * parseHTMLTagCLOSEa) *does* wrap the link in a real Link struct
     * element in PDF/UA mode, and registers the annotation it creates
     * under that element (registerPdfUaAnnotation()) — a completely
     * different code path from setLink() called directly, and the only
     * one that comes out correctly tagged. addInternalLink($pid, 0)
     * (public API on the Tcpdf instance) returns an '@N' identifier that
     * this <a href> pipeline resolves to a same-document jump at output
     * time — confirmed clean on an isolated 14-page test (106/106 rules,
     * 2263/2263 checks, 0 failures) before being wired in here.
     *
     * A second, unrelated defect was found and patched in the vendored
     * library while chasing the addTOC() gap (see the LOCAL PATCH note on
     * Output::setPageStructParents(), vendor/tecnickcom/tc-lib-pdf/src/Output.php):
     * any tc-lib-pdf PDF/UA document past ~10 pages could get a page's
     * /StructParents entry corrupted, regardless of how the sommaire
     * itself is built — confirmed independent of this method by disabling
     * it outright and still reproducing the corruption. That patch, not
     * how the sommaire is paginated, was the real fix; multiple
     * setCurrentPage() calls here (one per reserved sommaire page) are
     * safe now.
     *
     * @param int[] $sommairepids pids of the pages build_capp_rapport() reserved for this, in order.
     */
    private static function fill_capp_sommaire(capp_rapport_pdf $pdf, array $sommairepids): void {
        $rows = [];
        foreach ($pdf->get_outlines() as $bookmark) {
            $pid = (int) $bookmark['p'];
            if (in_array($pid, $sommairepids, true)) {
                // The "Sommaire" bookmark itself (build_capp_rapport() registers it on the
                // first reserved sommaire page) — excluded so it doesn't list itself as a
                // self-referential row in the printed table.
                continue;
            }
            $rows[] = ['name' => (string) $bookmark['t'], 'page' => $pid + 1, 'link' => $pdf->addInternalLink($pid, 0)];
        }
        $pagechunks = array_chunk($rows, self::SOMMAIRE_ROWS_PER_PAGE);

        foreach ($sommairepids as $pageindex => $pid) {
            $pdf->setCurrentPage($pid);
            $html = $pageindex === 0 ? self::h2(get_string('capp_rapport_sommaire_heading', 'local_kid')) : '';
            if (($pagechunks[$pageindex] ?? []) !== []) {
                $html .= '<table cellspacing="0" style="width:100%;">';
                foreach ($pagechunks[$pageindex] as $row) {
                    // Both cells need an explicit width (as percentages of the table, so they always
                    // sum to 100%): tc-lib-html's auto column layout only content-sizes columns when
                    // NO cell in the row has an explicit width — a width on the page-number column alone
                    // made it fall back to an even 50/50 split instead, leaving the row (and its dotted
                    // border-bottom) short of the table's declared 100% width.
                    // Row height is stretched to SOMMAIRE_ROW_HEIGHT via padding-top/bottom, not the
                    // CSS `height` property (confirmed by direct rendering test to be silently ignored
                    // by tc-lib-html on table cells) nor the table's `cellpadding` HTML attribute
                    // (confirmed to be interpreted in px, not mm — barely moves the needle) — so
                    // SOMMAIRE_ROWS_PER_PAGE rows actually span the page instead of clustering at the top.
                    $vpad = 'padding-top:' . self::SOMMAIRE_ROW_VPADDING . 'mm;padding-bottom:' . self::SOMMAIRE_ROW_VPADDING . 'mm;';
                    $html .= '<tr><td width="85%" style="' . $vpad . 'border-bottom:0.2mm dotted ' . self::COLOR_BORDER . ';">'
                        . '<a href="' . $row['link'] . '">' . s($row['name']) . '</a></td>'
                        . '<td width="15%" style="' . $vpad . 'border-bottom:0.2mm dotted ' . self::COLOR_BORDER . ';text-align:right;">'
                        . $row['page'] . '</td></tr>';
                }
                $html .= '</table>';
            }
            $pdf->addHTMLCell(html: $html, posx: self::CONTENT_POSX, posy: 20, width: self::CONTENT_WIDTH);
        }
    }

    /** Draws the header/footer band on every page except the cover (pid 0) — see build_capp_rapport()'s docblock for why this must run last, after every page is in its final position. */
    private static function render_headers_footers(capp_rapport_pdf $pdf): void {
        foreach ($pdf->page->getPages() as $pid => $pagedata) {
            $pdf->render_header_footer($pid);
        }
    }

    private static function build_capp_generated_line(\stdClass $dto): string {
        return s(get_string('capp_rapport_generated', 'local_kid', (object) [
            'source' => get_string('pluginname', 'local_kid'),
            'date' => userdate($dto->generatedat, get_string('strftimedatetimeshort', 'core_langconfig')),
        ]));
    }

    /**
     * Shared page de garde layout, top to bottom: bloc-marque/INSP logos,
     * ~200px whitespace, H1 "Promotion <nom>", ~200px whitespace, a
     * caller-supplied middle line (already HTML-escaped by the caller —
     * the CAPP livret composes it from a label + session libellé, the
     * student fiche uses the student's name as-is), whitespace, date du
     * document (H4, rouge). Reused as-is by both build_capp_coverpage_html()
     * and build_fiche_coverpage_html() since only that middle line differs.
     */
    private static function build_coverpage_html(string $promotionname, string $middlelinehtml, int $generatedat): string {
        $spacer = '<div style="height:52.9mm;"></div>'; // ~200px at 96dpi.

        $html = self::coverpage_logo_row();
        $html .= $spacer;
        $html .= '<h1 style="color:' . self::COLOR_PRIMARY . ';font-size:24pt;font-weight:bold;margin:0;">'
            . s(get_string('capp_rapport_coverpage_promotion', 'local_kid')) . ' ' . s($promotionname) . '</h1>';
        $html .= $spacer;
        $html .= '<p style="font-size:16pt;font-weight:bold;color:' . self::COLOR_PRIMARY . ';margin:0;">' . $middlelinehtml . '</p>';
        $html .= '<div style="height:15mm;"></div>';
        $html .= '<p style="font-size:11pt;font-weight:bold;color:' . self::COLOR_MARIANNE_RED . ';margin:0;">'
            . s(userdate($generatedat, get_string('strftimedate', 'core_langconfig'))) . '</p>';

        return $html;
    }

    /** CAPP livret page de garde: middle line is "Conseil d'apprentissage <libellé>". */
    private static function build_capp_coverpage_html(\stdClass $dto): string {
        $middlelinehtml = s(get_string('capp_rapport_coverpage_title', 'local_kid')) . ' ' . s($dto->session->libelle);
        return self::build_coverpage_html($dto->promotion->name ?? '', $middlelinehtml, $dto->generatedat);
    }

    /** Student fiche page de garde: same layout as the CAPP livret's, but the middle line is the student's name instead of a CAPP session libellé. */
    private static function build_fiche_coverpage_html(\stdClass $dto): string {
        return self::build_coverpage_html($dto->student->promotionname ?? '', s($dto->student->fullname), $dto->generatedat);
    }

    private static function build_capp_bilan_html(\stdClass $dto): string {
        $bilan = $dto->bilan;

        $html = self::h2(get_string('capp_rapport_bilan_heading', 'local_kid'));
        $html .= '<p style="color:' . self::COLOR_MUTED . ';font-size:9pt;">' . self::build_capp_generated_line($dto) . '<br>'
            . s(get_string('capp_rapport_studentcount', 'local_kid', $bilan->studentcount)) . '</p>';

        $bilancols = [
            ['key' => 'nonobserve', 'width' => '15%', 'label' => get_string('capp_rapport_col_nonobserve', 'local_kid')],
            ['key' => 'nonacquis', 'width' => '15%', 'label' => get_string('capp_rapport_col_nonacquis', 'local_kid')],
            ['key' => 'encours', 'width' => '15%', 'label' => get_string('capp_rapport_col_encours', 'local_kid')],
            ['key' => 'acquisdla', 'width' => '15%', 'label' => get_string('capp_rapport_col_acquisdla', 'local_kid')],
        ];

        $html .= self::table_open();
        $html .= '<tr style="background-color:' . self::COLOR_PRIMARY . ';">'
            . '<th width="40%" style="color:#FFFFFF;font-weight:bold;">' . s(get_string('grid_competence', 'local_kid')) . '</th>';
        foreach ($bilancols as $col) {
            $colors = niveau_repository::bucket_colors($col['key']);
            $html .= '<th width="' . $col['width'] . '" style="background-color:' . $colors['bg'] . ';color:' . $colors['fg'] . ';font-weight:bold;">'
                . s($col['label']) . '</th>';
        }
        $html .= '</tr>';
        foreach ($bilan->rows as $index => $row) {
            $html .= '<tr' . self::zebra_style($index) . '>'
                . '<td width="40%"><span style="font-size:' . self::COMPETENCE_FONT_SIZE . ';">' . s($row->libelle) . '</span></td>';
            foreach ($bilancols as $col) {
                $key = $col['key'];
                $html .= '<td width="' . $col['width'] . '" style="text-align:center;">' . (int) $row->$key . '</td>';
            }
            $html .= '</tr>';
        }
        $html .= '</table>';
        return $html;
    }

    private static function build_capp_student_html(\stdClass $dto, \stdClass $student): string {
        $html = self::h2(get_string('capp_rapport_student_heading', 'local_kid', $student->displayname));
        if (!empty($student->codeena)) {
            $html .= '<p style="color:' . self::COLOR_MUTED . ';font-size:10pt;margin:0 0 4mm 0;">' . s($student->codeena) . '</p>';
        }

        $hasprevious = !empty($dto->previoussessioncode);
        $col1label = $hasprevious
            ? get_string('capp_rapport_col_niveau_previous', 'local_kid', $dto->previoussessioncode)
            : null;
        $col2label = get_string('capp_rapport_col_niveau_current', 'local_kid', $dto->session->code);
        $col2width = $hasprevious ? '40%' : '60%';

        $headercells = [['width' => '40%', 'label' => get_string('grid_competence', 'local_kid')]];
        if ($hasprevious) {
            $headercells[] = ['width' => '20%', 'label' => $col1label];
        }
        $headercells[] = ['width' => $col2width, 'label' => $col2label];

        $html .= self::table_open();
        $html .= self::table_header_row($headercells);

        foreach ($student->competences as $index => $cell) {
            $html .= '<tr' . self::zebra_style($index) . '><td width="40%"><span style="font-size:' . self::COMPETENCE_FONT_SIZE . ';">'
                . s($cell->code . ' — ' . $cell->libelle) . '</span></td>';
            if ($hasprevious) {
                $html .= '<td width="20%">' . self::niveau_badge_html($cell->niveaucode1 ?? null, $cell->niveaucapp1 ?? '') . '</td>';
            }
            $html .= '<td width="' . $col2width . '">' . self::niveau_badge_html($cell->niveaucode2 ?? null, $cell->niveaucapp2 ?? '') . '</td></tr>';
        }
        $html .= '</table>';

        if (!empty($student->appreciationglobale)) {
            $html .= '<p><b>' . s(get_string('capp_appreciation_globale', 'local_kid')) . '</b> : '
                . nl2br(s($student->appreciationglobale)) . '</p>';
        }

        return $html;
    }

    public static function send(\Com\Tecnick\Pdf\Tcpdf $pdf, string $filename): void {
        $pdf->setPDFFilename($filename);
        $pdf->downloadPDF($pdf->getOutPDFString());
        exit;
    }

    /**
     * Saves to a real temp file, for bulk ZIP building where several
     * fiches must exist on disk at once.
     */
    public static function save_to_temp_file(\Com\Tecnick\Pdf\Tcpdf $pdf): string {
        $dir = make_request_directory();

        // savePDF() writes through the same fopenLocal()/allowedPaths guard as
        // the <img>/font reads (see file_options()), but the request's temp
        // dir can't be known until here — extend the allowlist instead of
        // replacing it, so fonts/pix stay readable while this write succeeds.
        $pdf->file->setAllowedPaths(array_merge(self::file_options()['allowedPaths'], [$dir]));

        $pdf->setPDFFilename('fiche.pdf');
        $pdf->savePDF($dir, $pdf->getOutPDFString());
        return $dir . '/fiche.pdf';
    }

    /** Registers K_PATH_FONTS once, guarded so a future request that also loads Moodle's own TCPDF (which defines the same constant for its own, incompatible font directory) doesn't collide — see kid_pdf_ua_audit memory. */
    private static function bootstrap(): void {
        global $CFG;
        require_once($CFG->dirroot . '/local/kid/vendor/autoload.php');

        if (!defined('K_PATH_FONTS')) {
            define('K_PATH_FONTS', realpath(__DIR__ . '/../../vendor/tecnickcom/tc-lib-pdf-font/target/fonts'));
        }
    }

    /**
     * tc-lib-pdf restricts local file reads (images, fonts) to an
     * allowlist of trusted paths, defaulting to its own package tree —
     * K_PATH_FONTS must be explicitly listed even though it's the font
     * directory, and so must this plugin's pix/ (the cover page logos),
     * which otherwise silently fails to load (the HTML engine then falls
     * back to rendering the alt text as visible content, mimicking a
     * browser's broken-image fallback).
     *
     * @return array{allowedPaths: string[]}
     */
    private static function file_options(): array {
        return [
            'allowedPaths' => [K_PATH_FONTS, realpath(__DIR__ . '/../../pix')],
        ];
    }

    /**
     * Shared pdf construction: Marianne as the default font, PDF/UA-1
     * conformance mode — same rationale and same two extra calls
     * (setViewerPreferences/setLanguageArray) as init_capp_rapport_pdf(),
     * confirmed empirically sufficient there (106/106 veraPDF rules, real
     * generated documents) and reused as-is rather than duplicating a
     * separate setup. build_fiche()'s HTML has no <img>, so no alt-text
     * gap to close here unlike the livret's cover-page logos.
     */
    private static function init_pdf(): \Com\Tecnick\Pdf\Tcpdf {
        self::bootstrap();

        $pdf = new \Com\Tecnick\Pdf\Tcpdf(
            unit: \Com\Tecnick\Pdf\Page\Unit::Millimeter,
            isunicode: true,
            subsetfont: false,
            compress: true,
            mode: \Com\Tecnick\Pdf\PdfConformance::Pdfua1,
            objEncrypt: null,
            fileOptions: self::file_options(),
        );
        $pdf->setViewerPreferences(['DisplayDocTitle' => true]);
        $pdf->setLanguageArray(['a_meta_language' => 'fr-FR']);
        self::use_marianne_font($pdf);
        return $pdf;
    }

    /**
     * Same base construction as init_pdf(), but a capp_rapport_pdf instance
     * in PDF/UA-1 conformance mode, pre-filled with the header text this
     * specific livret needs.
     */
    private static function init_capp_rapport_pdf(\stdClass $dto): capp_rapport_pdf {
        self::bootstrap();

        $pdf = new capp_rapport_pdf(
            unit: \Com\Tecnick\Pdf\Page\Unit::Millimeter,
            isunicode: true,
            subsetfont: false,
            compress: true,
            mode: \Com\Tecnick\Pdf\PdfConformance::Pdfua1,
            objEncrypt: null,
            fileOptions: self::file_options(),
        );
        $pdf->setViewerPreferences(['DisplayDocTitle' => true]);
        $pdf->setLanguageArray(['a_meta_language' => 'fr-FR']);
        $pdf->setTitle(get_string('capp_rapport_pdf_title', 'local_kid', (object) [
            'session' => $dto->session->libelle,
            'promotion' => $dto->promotion->name ?? '',
        ]));
        $pdf->bodyfont = self::use_marianne_font($pdf);
        $pdf->headertext = get_string('capp_rapport_header', 'local_kid', (object) [
            'code' => $dto->session->code,
            'promotion' => $dto->promotion->name ?? '',
        ]);
        return $pdf;
    }

    /**
     * Registers Marianne (the pre-converted files in
     * vendor/tecnickcom/tc-lib-pdf-font/target/fonts/marianne/, built once
     * via Com\Tecnick\Pdf\Font\Import from fonts/*.ttf — see
     * PREREQUIS_SERVEUR.md for the build step) and makes it the current
     * font, falling back silently to dejavusans (Unicode-capable, unlike
     * the core Latin-only fonts) if the converted files aren't present on
     * this environment. Only the base/regular style is registered: the
     * HTML pipeline resolves bold/italic/bold-italic variants itself from
     * the family name via the same family+style-suffix convention Import
     * used to name the output files (confirmed empirically, not just from
     * reading the source — see kid_pdf_ua_audit memory).
     *
     * @return string the font family actually made current ('marianne' or,
     *         on fallback, 'dejavusans') — callers that draw their own text
     *         outside addHTMLCell() (capp_rapport_pdf::render_header_footer())
     *         need this rather than assuming 'marianne' is always available.
     */
    private static function use_marianne_font(\Com\Tecnick\Pdf\Tcpdf $pdf): string {
        $mariannedatafile = K_PATH_FONTS . '/marianne/marianne.json';
        if (!is_readable($mariannedatafile)) {
            $pdf->font->insert($pdf->pon, 'dejavusans', '', 10);
            return 'dejavusans';
        }

        $pdf->font->insert($pdf->pon, 'marianne', '', 10);
        return 'marianne';
    }

    /**
     * Renders a niveau value as a colored pill matching the on-screen
     * kid-level-* badge for that code (niveau_repository::badge_colors()),
     * or plain escaped text if the code is unknown/absent (empty cell, same
     * as before this feature existed). Uppercased to match the web badge's
     * CSS text-transform:uppercase (.fr-badge, not reproducible via a CSS
     * property here since TCPDF's HTML renderer doesn't support it).
     */
    private static function niveau_badge_html(?string $code, string $label): string {
        if ($label === '' || $code === null || niveau_repository::badge_class($code) === '') {
            return s($label);
        }
        $colors = niveau_repository::badge_colors($code);
        return '<span style="background-color:' . $colors['bg'] . ';color:' . $colors['fg']
            . ';padding:0.5mm 2mm;border-radius:1mm;">' . s(mb_strtoupper($label, 'UTF-8')) . '</span>';
    }

    /**
     * Letterhead row opening the livret's page de garde: the official
     * "bloc-marque" (Marianne + RÉPUBLIQUE FRANÇAISE + tricolore) at left,
     * enlarged +20% (21.6mm vs. the INSP wordmark's unchanged 18mm), and
     * the INSP wordmark at right, offset 70px (18.52mm) further down than
     * the bloc-marque via a `<div style="height:...">` spacer — NOT the
     * `<p style="line-height:...">&nbsp;</p>` trick the pre-migration
     * TCPDF version used: confirmed by isolated pixel-measured test that
     * addHTMLCell() silently ignores line-height (and img margin-top) on
     * an empty paragraph inside a table cell, so two earlier edits that
     * changed that value never actually moved anything on screen despite
     * looking plausible in an un-measured render. `<div style="height">`
     * is the mechanism already proven to work elsewhere on this same page
     * (the h1/title spacers below), confirmed to also work inside a `<td>`
     * by the same isolated test. Falls back to a plain Bleu France / blanc
     * / rouge band if the PNGs aren't deployed on this environment. Both
     * <img> tags carry a real alt= text (new — PDF/UA requires a genuine
     * accessible alt, tagged Figure+/Alt by tc-lib-pdf; absent from the
     * previous TCPDF version, whose $alt parameter was never an
     * accessibility mechanism to begin with).
     */
    private static function coverpage_logo_row(): string {
        // Must be realpath()'d, matching file_options()'s allowedPaths entry exactly — tc-lib-pdf
        // validates local <img> reads against that allowlist and rejects an unresolved "../.." path
        // even when the resolved directory is itself allowed.
        $pixdir = realpath(__DIR__ . '/../../pix') . '/';
        $marianne = $pixdir . 'marianne.png';
        $insp = $pixdir . 'logo-insp.png';
        if (!is_readable($marianne) || !is_readable($insp)) {
            return '<table cellpadding="0" cellspacing="0" style="width:100%;"><tr>'
                . '<td width="33.33%" style="background-color:' . self::COLOR_PRIMARY . ';padding:1.5mm 0;">&nbsp;</td>'
                . '<td width="33.34%" style="background-color:#FFFFFF;padding:1.5mm 0;">&nbsp;</td>'
                . '<td width="33.33%" style="background-color:' . self::COLOR_MARIANNE_RED . ';padding:1.5mm 0;">&nbsp;</td>'
                . '</tr></table>';
        }
        return '<table cellpadding="0" cellspacing="0" style="width:100%;"><tr>'
            . '<td width="50%" style="text-align:left;vertical-align:top;"><img src="' . $marianne . '" alt="'
                . s(get_string('capp_rapport_logo_marianne_alt', 'local_kid')) . '" style="height:21.6mm;"></td>'
            . '<td width="50%" style="text-align:right;vertical-align:top;">'
                . '<div style="height:18.52mm;"></div>'
                . '<img src="' . $insp . '" alt="' . s(get_string('capp_rapport_logo_insp_alt', 'local_kid')) . '" style="height:18mm;">'
            . '</td>'
            . '</tr></table>';
    }

    private static function h1(string $text): string {
        return '<h1 style="color:' . self::COLOR_PRIMARY . ';font-size:20pt;font-weight:bold;margin:0 0 3mm 0;">' . $text . '</h1>';
    }

    private static function h2(string $text): string {
        return '<h2 style="color:' . self::COLOR_PRIMARY . ';font-size:13pt;font-weight:bold;margin:5mm 0 3mm 0;'
            . 'padding-bottom:1.5mm;border-bottom:0.5mm solid ' . self::COLOR_PRIMARY . ';">' . s($text) . '</h2>';
    }

    private static function h3(string $text): string {
        return '<h3 style="color:' . self::COLOR_TEXT . ';font-size:11pt;font-weight:bold;margin:4mm 0 1.5mm 0;">' . $text . '</h3>';
    }

    private static function table_open(): string {
        return '<table border="1" cellpadding="4" cellspacing="0" style="width:100%;border-color:' . self::COLOR_BORDER . ';">';
    }

    /** @param array{width:string,label:string}[] $cells */
    private static function table_header_row(array $cells): string {
        $html = '<tr style="background-color:' . self::COLOR_PRIMARY . ';">';
        foreach ($cells as $cell) {
            $html .= '<th width="' . $cell['width'] . '" style="color:#FFFFFF;font-weight:bold;">' . s($cell['label']) . '</th>';
        }
        return $html . '</tr>';
    }

    /** Zebra striping for table body rows — every other row shaded, matching --kid-row-alt on the web app's tables. */
    private static function zebra_style(int $index): string {
        return ($index % 2 === 1) ? ' style="background-color:' . self::COLOR_ROW_ALT . ';"' : '';
    }
}
