<?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.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * This file defines the export engine class.
 *
 * @package   quiz_export
 * @copyright 2020 CBlue Srl
 * @copyright based on work by 2014 Johannes Burk
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

use mod_quiz\output\attempt_summary_information;
use mod_quiz\quiz_attempt;
use quiz_export\helper\qtype_renderer_factory;

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

require_once $CFG->dirroot . '/mod/quiz/report/export/vendor/autoload.php';

/**
 * Quiz export engine class.
 *
 * @package   quiz_export
 * @copyright 2020 CBlue Srl
 * @copyright based on work by 2014 Johannes Burk
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class quiz_export_engine
{
    /**
     * Actual question page assignment like in quiz settings.
     */
    const PAGEMODE_TRUEPAGE = 0;

    /**
     * One question per page.
     */
    const PAGEMODE_QUESTIONPERPAGE = 1;

    /**
     * all questions on single page.
     */
    const PAGEMODE_SINGLEPAGE = 2;

    /**
     * Exports the given quiz attempt to a pdf file.
     * @param quiz_attempt $attemptobj The quiz attempt to export.
     * @param int $pagemode The page break mode used to render the quiz review.
     *                         One of PAGEMODE_TRUEPAGE, PAGEMODE_QUESTIONPERPAGE or PAGEMODE_SINGLEPAGE
     * @return string          File path and name as string of the pdf file.
     */
    public function a2pdf($attemptobj, $pagemode)
    {
        global $CFG;
        $parameters_additionnal_informations = $this->get_additionnal_informations($attemptobj);

        $tmp_dir = $CFG->dataroot . '/mpdf';
        if (!file_exists($tmp_dir)) {
            mkdir($tmp_dir);
        }
        ob_start();
        $tmp_file = tempnam($tmp_dir, "mdl-qexp_");
        ob_get_clean();
        $tmp_pdf_file = $tmp_file . ".pdf";
        rename($tmp_file, $tmp_pdf_file);
        chmod($tmp_pdf_file, 0644);
        ob_start();
        $tmp_file = tempnam($tmp_dir, "mdl-qexp_");
        ob_get_clean();
        $tmp_err_file = $tmp_file . ".txt";
        rename($tmp_file, $tmp_err_file);
        chmod($tmp_err_file, 0644);

        $pdf = new \Mpdf\Mpdf([
            'tempDir' => $tmp_dir,
        ]);
        $pdf->autoLangToFont = true;

        // Start output buffering html
        ob_start();
        include $CFG->dirroot . '/mod/quiz/report/export/style/styles.css';
        $css = ob_get_clean();
        $css .= "\n.que .info { width: 12em; }\n.que .content { margin: 0 0 0 13em; }\n";
        $pdf->WriteHTML($css, \Mpdf\HTMLParserMode::HEADER_CSS);

        $additionnal_informations = html_writer::tag('h3', get_string('documenttitle', 'quiz_export', $parameters_additionnal_informations), ['class' => 'text-center', 'style' => 'margin-bottom: 10px;']);

        $sumgrades = $attemptobj->get_quiz()->sumgrades;
        $percentages = [];
        foreach ($attemptobj->get_slots() as $slot) {
            $qa = $attemptobj->get_question_attempt($slot);
            $maxmark = $qa->get_max_mark();
            $percentages[$slot] = round(($maxmark / $sumgrades) * 100);
        }

        switch ($pagemode) {
            default:
            case quiz_export_engine::PAGEMODE_TRUEPAGE:
                $html_files = $this->questions_paged($attemptobj);
                break;
            case quiz_export_engine::PAGEMODE_QUESTIONPERPAGE:
                $html_files = $this->question_per_page($attemptobj);
                break;
            case quiz_export_engine::PAGEMODE_SINGLEPAGE:
                $html_files = $this->all_questions($attemptobj);

                // Start output buffering html
                ob_start();
                include $html_files[0];
                $contentHTML = ob_get_clean();
                $contentHTML = $this->preprocessDragDropQuestions($contentHTML, $attemptobj);
                $contentHTML = preg_replace("/<input type=\"text\".+?value=\"/", ' - ', $contentHTML);
                $contentHTML = preg_replace("/\" id=\"q.+?readonly\"(>| \/>)/", ' - ', $contentHTML);
                $contentHTML = $this->add_question_percentages($contentHTML, $percentages);
                $contentHTML = $this->inlineBadgeStyles($contentHTML);
                $contentHTML = $this->prepareHtmlForPdf($contentHTML);

                $pdf->WriteHTML($this->preloadImageWithCurrentSession($additionnal_informations), \Mpdf\HTMLParserMode::HTML_BODY);
                $pdf->WriteHTML($this->preloadImageWithCurrentSession($contentHTML), \Mpdf\HTMLParserMode::DEFAULT_MODE);
                break;
        }
        if ($pagemode == quiz_export_engine::PAGEMODE_TRUEPAGE || $pagemode == quiz_export_engine::PAGEMODE_QUESTIONPERPAGE) {
            $current_page = 0;
            foreach ($html_files as $html_file) {
                // Start output buffering html
                ob_start();
                include $CFG->dirroot . '/mod/quiz/report/export/style/styles.css';
                $css = ob_get_clean();
                $css .= "\n.que .info { width: 12em; }\n.que .content { margin: 0 0 0 13em; }\n";
                $pdf->WriteHTML($css, \Mpdf\HTMLParserMode::HEADER_CSS);

                // Start output buffering html
                ob_start();
                include $html_file;
                $contentHTML = ob_get_clean();
                $contentHTML = $this->preprocessDragDropQuestions($contentHTML, $attemptobj);
                $contentHTML = preg_replace("/<input type=\"text\".+?value=\"/", ' - ', $contentHTML);
                $contentHTML = preg_replace("/\" id=\"q.+?readonly\"(>| \/>)/", ' - ', $contentHTML);
                $contentHTML = $this->add_question_percentages($contentHTML, $percentages);
                $contentHTML = $this->inlineBadgeStyles($contentHTML);
                $contentHTML = $this->prepareHtmlForPdf($contentHTML);

                if ($current_page == 0) {
                    $pdf->WriteHTML($this->preloadImageWithCurrentSession($additionnal_informations), \Mpdf\HTMLParserMode::HTML_BODY);
                }
                $pdf->WriteHTML($this->preloadImageWithCurrentSession($contentHTML), \Mpdf\HTMLParserMode::DEFAULT_MODE);

                if (!$attemptobj->is_last_page($current_page)) {
                    $pdf->AddPage();
                }
                $current_page++;
            }
        }
        $pdf->Output($tmp_pdf_file, \Mpdf\Output\Destination::FILE);

        // Cleanup
        unlink($tmp_err_file);
        foreach ($html_files as $file) {
            unlink($file);
        }

        return $tmp_pdf_file;
    }

    /**
     * Replace interactive drag-and-drop question blocks by static HTML
     * compatible with mPDF.
     *
     * Iterates over the slots of the attempt, looks up the matching question
     * block in the rendered HTML and delegates the transformation to the
     * factory. Any unsupported question is left untouched.
     *
     * @param string $html Rendered review HTML.
     * @param quiz_attempt $attemptobj The current attempt.
     * @return string HTML with drag-and-drop blocks replaced.
     */
    protected function preprocessDragDropQuestions(string $html, quiz_attempt $attemptobj): string
    {
        if (trim($html) === '') {
            return $html;
        }
        try {
            $displayoptions = $attemptobj->get_display_options(true);
            return qtype_renderer_factory::transform($html, $attemptobj, $displayoptions);
        } catch (\Throwable $exception) {
            debugging('quiz_export DD preprocessing failed: ' . $exception->getMessage(), DEBUG_DEVELOPER);
            return $html;
        }
    }

    /**
     * Convert Bootstrap badge utility classes used by core Moodle (notably
     * the question version badge) into inline styles. mPDF's CSS engine
     * doesn't reliably resolve combined utility classes, which results in
     * unreadable black-on-black badges in the PDF output.
     *
     * @param string $html Source HTML.
     * @return string HTML with badges restyled.
     */
    protected function inlineBadgeStyles(string $html): string
    {
        $pattern = '#<span\b([^>]*)\bclass="([^"]*\bbadge\b[^"]*)"([^>]*)>(.*?)</span>#is';
        $result = preg_replace_callback($pattern, function ($match) {
            $classes = $match[2];
            $background = $this->resolve_bootstrap_bg_color($classes);
            $color = $this->resolve_bootstrap_text_color($classes);
            $style = 'display:inline-block; padding:2px 6px; border-radius:3px; '
                . 'font-size:11px; font-weight:bold; line-height:1; '
                . 'background-color:' . $background . '; color:' . $color . ';';
            return '<span style="' . $style . '">' . $match[4] . '</span>';
        }, $html);
        return $result !== null ? $result : $html;
    }

    /**
     * Pick a sensible background colour from the Bootstrap utility classes
     * applied to a badge.
     */
    private function resolve_bootstrap_bg_color(string $classes): string
    {
        $map = [
            'bg-primary' => '#1177d1',
            'bg-secondary' => '#6c757d',
            'bg-success' => '#398439',
            'bg-danger' => '#d43f3a',
            'bg-warning' => '#f0ad4e',
            'bg-info' => '#5bc0de',
            'bg-dark' => '#343a40',
            'bg-light' => '#f8f9fa',
        ];
        foreach ($map as $class => $color) {
            if (preg_match('#\b' . preg_quote($class, '#') . '\b#', $classes)) {
                return $color;
            }
        }
        return '#1177d1';
    }

    /**
     * Pick a sensible text colour from the Bootstrap utility classes applied
     * to a badge.
     */
    private function resolve_bootstrap_text_color(string $classes): string
    {
        if (preg_match('#\btext-(light|white)\b#', $classes)) {
            return '#ffffff';
        }
        if (preg_match('#\btext-dark\b#', $classes)) {
            return '#1a1a1a';
        }
        if (preg_match('#\bbg-(light|warning)\b#', $classes)) {
            return '#1a1a1a';
        }
        return '#ffffff';
    }

    /**
     * Add question percentages to the HTML.
     *
     * @param string $html The HTML content.
     * @param array $percentages Array of percentages keyed by slot/question number.
     * @return string Modified HTML.
     */
    protected function add_question_percentages(string $html, array $percentages)
    {
        return preg_replace_callback('#<h3 class="no">Question <span class="qno">(\d+)</span></h3>#', function($matches) use ($percentages) {
            $num = (int)$matches[1];
            $perc = $percentages[$num] ?? 0;
            return '<h3 class="no">Question <span class="qno">' . $num . '</span> (' . $perc . '%)</h3>';
        }, $html);
    }

    /**
     * Generate the html of all quiz questions (with one question per page)
     * Used with "PAGEMODE_QUESTIONPERPAGE" page mode
     *
     * @param $attemptobj
     * @return array
     * @throws coding_exception
     * @throws moodle_exception
     */
    protected function question_per_page($attemptobj)
    {
        $tmp_html_files = array();
        $showall = false;
        $num_pages = $attemptobj->get_num_pages();

        for ($page = 0; $page < $num_pages; $page++) {
            $questionids = $attemptobj->get_slots($page);
            $lastpage = $attemptobj->is_last_page($page);

            foreach ($questionids as $questionid) {
                // We have just one question id but an array is required from render function
                $slots = array();
                $slots[] = $questionid;

                $tmp_dir = sys_get_temp_dir();
                $tmp_file = tempnam($tmp_dir, "mdl-qexp_");
                $tmp_html_file = $tmp_file . ".html";
                rename($tmp_file, $tmp_html_file);
                chmod($tmp_html_file, 0644);

                $output = $this->get_review_html($attemptobj, $slots, $page, $showall, $lastpage);
                file_put_contents($tmp_html_file, $output);

                $tmp_html_files[] = $tmp_html_file;
            }
        }

        return $tmp_html_files;
    }

    /**
     * Generate the html of the actual question page
     * Used with "PAGEMODE_TRUEPAGE" page mode
     *
     * @param $attemptobj
     * @return array
     * @throws coding_exception
     * @throws moodle_exception
     */
    protected function questions_paged($attemptobj)
    {
        $tmp_html_files = array();
        $showall = false;
        $num_pages = $attemptobj->get_num_pages();

        for ($page = 0; $page < $num_pages; $page++) {
            $slots = $attemptobj->get_slots($page);
            $lastpage = $attemptobj->is_last_page($page);

            $tmp_dir = sys_get_temp_dir();
            $tmp_file = tempnam($tmp_dir, "mdl-qexp_");
            $tmp_html_file = $tmp_file . ".html";
            rename($tmp_file, $tmp_html_file);
            chmod($tmp_html_file, 0644);

            $output = $this->get_review_html($attemptobj, $slots, $page, $showall, $lastpage);
            file_put_contents($tmp_html_file, $output);

            $tmp_html_files[] = $tmp_html_file;
        }

        return $tmp_html_files;
    }

    /**
     * Generate the html of all quiz questions
     * Used with "PAGEMODE_SINGLEPAGE" page mode
     *
     * @param $attemptobj
     * @return string[]
     * @throws coding_exception
     * @throws moodle_exception
     */
    protected function all_questions($attemptobj)
    {
        $slots = $attemptobj->get_slots();
        $showall = true;
        $lastpage = true;
        $page = 0;

        $tmp_dir = sys_get_temp_dir();
        $tmp_file = tempnam($tmp_dir, "mdl-qexp_");
        $tmp_html_file = $tmp_file . ".html";
        rename($tmp_file, $tmp_html_file);
        chmod($tmp_html_file, 0644);

        $output = $this->get_review_html($attemptobj, $slots, $page, $showall, $lastpage);
        file_put_contents($tmp_html_file, $output);

        return array($tmp_html_file);
    }

    /**
     * Render the main page
     *
     * @param $attemptobj
     * @param $slots
     * @param $page
     * @param $showall
     * @param $lastpage
     * @return mixed
     * @throws coding_exception
     * @throws moodle_exception
     */
    protected function get_review_html($attemptobj, $slots, $page, $showall, $lastpage)
    {
        $html = $this->render($attemptobj, $slots, $page, $showall, $lastpage);
        return $html;
    }

    /**
     * Return the main page in which the quiz export settings can be configured.
     *
     * @throws moodle_exception
     * @throws coding_exception
     */
    protected function render($attemptobj, $slots, $page, $showall, $lastpage): string
    {
        global $PAGE;

        $options = $attemptobj->get_display_options(true);

        // Ugly hack to get a new page
        $this->setup_new_page();

        $url = new moodle_url('/mod/quiz/report/export/a2pdf.php', array('attempt' => $attemptobj->get_attemptid()));
        $PAGE->set_url($url);

        // Set up the page header.
        // $headtags = $attemptobj->get_html_head_contributions($page, $showall);
        // $PAGE->set_title($attemptobj->get_quiz_name());
        // $PAGE->set_heading($attemptobj->get_course()->fullname);

        $summarydata = attempt_summary_information::create_for_attempt($attemptobj, $options, $page, $showall);

        // Display only content
        // $PAGE->force_theme('boost');
        $PAGE->set_pagelayout('embedded');

        /* @var mod_quiz\output\renderer $output */
        $output = $PAGE->get_renderer('mod_quiz');

        // Fool out mod_quiz renderer:
        // 		set $page = 0 for showing comple summary table on every page
        // 			side effect: breaks next page links
        return $output->review_page($attemptobj, $slots, $page, $showall, $lastpage, $options, $summarydata);
    }

    /**
     * Overwrites the $PAGE global with a new moodle_page instance.
     * Code is original from lib/setup.php and lib/adminlib.php
     * @return void
     */
    protected function setup_new_page()
    {
        global $CFG, $PAGE;

        if (!empty($CFG->moodlepageclass)) {
            if (!empty($CFG->moodlepageclassfile)) {
                require_once($CFG->moodlepageclassfile);
            }
            $classname = $CFG->moodlepageclass;
        } else {
            $classname = 'moodle_page';
        }
        $PAGE = new $classname();
        unset($classname);

        $PAGE->set_context(null);
    }

    /**
     * Get student's firstname and lastname + quiz name
     * to display it at the top of the document
     *
     * @param quiz_attempt $attemptobj The attempt object the summary is for.
     * @return array contains additionnals informations
     */
    public function get_additionnal_informations($attemptobj)
    {
        global $DB;
        $user_id = $attemptobj->get_userid();
        $user_informations = $DB->get_record('user', ['id' => $user_id], 'firstname, lastname');
        return [
            'firstname' => $user_informations->firstname,
            'lastname' => $user_informations->lastname,
            'coursename' => $attemptobj->get_course()->fullname,
            'quizname' => $attemptobj->get_quiz_name()
        ];
    }

    /**
     * Encode all images in base64 to render it in the pdf
     *
     * @param $html
     * @return string|string[]
     */
    protected function preloadImageWithCurrentSession($html)
    {
        $matches = [];
        $matches_content = [];
        preg_match_all("/<img.*src=\"(https?:\/\/.*)\".*>/U", $html, $matches);

        if (count($matches[1]) > 0) {
            $cookieFile = '/tmp/cookie-pdf';
            file_put_contents($cookieFile, "MoodleSession=" . $_COOKIE['MoodleSession']);
            // Without that we have to wait the script eneded to load images => time out
            session_write_close();
            foreach ($matches[1] as $match) {
                $ch = curl_init($match);
                $strCookie = session_name() . '=' . $_COOKIE[session_name()] . '; path=/';
                curl_setopt($ch, CURLOPT_COOKIE, $strCookie);
                curl_setopt($ch, CURLOPT_HEADER, 0);
                curl_setopt($ch, CURLOPT_NOBODY, 0);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
                curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
                // Timeout in seconds
                curl_setopt($ch, CURLOPT_TIMEOUT, 5);
                $header = curl_getinfo($ch);
                $result = curl_exec($ch);
                $finfo = new finfo(FILEINFO_MIME_TYPE);
                $mimeType = $finfo->buffer($result);
                $matches_content[] = "data:" . $mimeType . ";base64," . base64_encode($result);
                curl_close($ch);
            }
            $html = str_replace($matches[1], $matches_content, $html);
        }
        return $html;
    }

    /**
     * Prepare HTML content for PDF rendering by replacing unsupported elements.
     *
     * mPDF does not support Font Awesome icons, radio buttons, or checkboxes.
     * This method replaces them with inline SVG equivalents.
     *
     * @param string $html The HTML content.
     * @return string The HTML prepared for PDF rendering.
     */
    protected function prepareHtmlForPdf($html) {
        // SVG for checked radio button (filled circle).
        $svgRadioChecked = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" '
            . 'style="display:inline; vertical-align:middle; margin-right:8px;">'
            . '<circle cx="8" cy="8" r="7" fill="none" stroke="#666" stroke-width="1.5"/>'
            . '<circle cx="8" cy="8" r="4" fill="#666"/>'
            . '</svg>';

        // SVG for unchecked radio button (empty circle).
        $svgRadioUnchecked = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" '
            . 'style="display:inline; vertical-align:middle; margin-right:8px;">'
            . '<circle cx="8" cy="8" r="7" fill="none" stroke="#666" stroke-width="1.5"/>'
            . '</svg>';

        // SVG for checked checkbox (square with checkmark) - using absolute coordinates for mPDF.
        $svgCheckboxChecked = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" '
            . 'style="display:inline; vertical-align:middle; margin-right:8px;">'
            . '<rect x="1" y="1" width="14" height="14" fill="none" stroke="#666" stroke-width="1.5" rx="2"/>'
            . '<path d="M4 8 L7 11 L12 5" fill="none" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
            . '</svg>';

        // SVG for unchecked checkbox (empty square).
        $svgCheckboxUnchecked = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" '
            . 'style="display:inline; vertical-align:middle; margin-right:8px;">'
            . '<rect x="1" y="1" width="14" height="14" fill="none" stroke="#666" stroke-width="1.5" rx="2"/>'
            . '</svg>';

        // SVG checkmark icon (green circle with check) - using absolute coordinates for mPDF.
        $svgCorrect = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" '
            . 'style="display:inline; vertical-align:middle; margin-left:4px;">'
            . '<circle cx="12" cy="12" r="10" fill="none" stroke="#198754" stroke-width="2"/>'
            . '<path d="M7 12 L10 15 L17 8" fill="none" stroke="#198754" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
            . '</svg>';

        // SVG cross icon (red circle with X) - using absolute coordinates for mPDF.
        $svgIncorrect = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" '
            . 'style="display:inline; vertical-align:middle; margin-left:4px;">'
            . '<circle cx="12" cy="12" r="10" fill="none" stroke="#dc3545" stroke-width="2"/>'
            . '<path d="M8 8 L16 16 M16 8 L8 16" fill="none" stroke="#dc3545" stroke-width="2" stroke-linecap="round"/>'
            . '</svg>';

        // Step 0a: Remove visually-hidden elements (Bootstrap 5 screen-reader-only class).
        // mPDF does not reliably support the CSS used by .visually-hidden, so strip them from HTML.
        $html = preg_replace('/<[^>]+class="[^"]*\bvisually-hidden\b[^"]*"[^>]*>.*?<\/\w+>/is', '', $html);

        // Step 0b: Convert Bootstrap badge classes to inline styles for mPDF compatibility.
        // mPDF does not reliably apply background-color + color via CSS classes on inline elements.
        $html = preg_replace(
            '/<span([^>]*class="[^"]*\bbadge\b[^"]*\bbg-primary\b[^"]*"[^>]*)>/is',
            '<span$1 style="background-color:#1177d1;color:#fff;padding:2px 6px;border-radius:3px;">',
            $html
        );

        // Step 0c: Normalize whitespace first to ensure regex patterns work correctly.
        $html = preg_replace('/>\s+</', '> <', $html);

        // Step 1: Flatten MCQ structure BEFORE replacing inputs/icons.
        // Remove <p> tags only inside MCQ answer rows (r0/r1 divs), keep <p> tags in question text (.qtext).
        $html = preg_replace_callback(
            '/<div([^>]*class="[^"]*\b(?:r0|r1)\b[^"]*"[^>]*)>(.*?)<\/div>/is',
            function ($matches) {
                $inner = preg_replace('/<p[^>]*>(.*?)<\/p>/is', '$1', $matches[2]);
                return '<div' . $matches[1] . '>' . $inner . '</div>';
            },
            $html
        );

        // Step 2: Replace <div class="flex-fill ...">content</div> with just content.
        $html = preg_replace(
            '/<div[^>]*class="[^"]*flex-fill[^"]*"[^>]*>(.*?)<\/div>/is',
            '$1',
            $html
        );

        // Step 3: Replace answer-label div with just content.
        $html = preg_replace(
            '/<div[^>]*data-region="answer-label"[^>]*>(.*?)<\/div>/is',
            '$1',
            $html
        );

        // Step 4: Remove .ms-1 span wrapper, keep content.
        $html = preg_replace(
            '/<span[^>]*class="[^"]*\bms-1\b[^"]*"[^>]*>(.*?)<\/span>/is',
            ' $1',
            $html
        );

        // Step 5: Add inline style to r0/r1 answer row divs.
        $html = preg_replace(
            '/<div([^>]*class="[^"]*\b(r0|r1)\b[^"]*"[^>]*)>/is',
            '<div$1 style="display:block; margin:5px 0; line-height:1.6;">',
            $html
        );

        // NOW replace inputs and icons on the flattened structure.

        // Replace checked radio buttons (add space after for margin).
        $html = preg_replace(
            '/<input[^>]*type=["\']radio["\'][^>]*checked[^>]*\/?>/is',
            $svgRadioChecked . '&nbsp; ',
            $html
        );

        // Replace unchecked radio buttons.
        $html = preg_replace(
            '/<input[^>]*type=["\']radio["\'][^>]*\/?>/is',
            $svgRadioUnchecked . '&nbsp; ',
            $html
        );

        // Replace checked checkboxes (add space after for margin).
        $html = preg_replace(
            '/<input[^>]*type=["\']checkbox["\'][^>]*checked[^>]*\/?>/is',
            $svgCheckboxChecked . '&nbsp; ',
            $html
        );

        // Replace unchecked checkboxes.
        $html = preg_replace(
            '/<input[^>]*type=["\']checkbox["\'][^>]*\/?>/is',
            $svgCheckboxUnchecked . '&nbsp; ',
            $html
        );

        // Replace correct answer icons (fa-circle-check with text-success).
        $html = preg_replace(
            '/<i[^>]*class="[^"]*fa-circle-check[^"]*"[^>]*>.*?<\/i>/is',
            $svgCorrect,
            $html
        );

        // Replace incorrect answer icons (fa-circle-xmark with text-danger).
        $html = preg_replace(
            '/<i[^>]*class="[^"]*fa-circle-xmark[^"]*"[^>]*>.*?<\/i>/is',
            $svgIncorrect,
            $html
        );

        return $html;
    }
}