<?php
declare(strict_types=1);

// ── Dispatcher ───────────────────────────────────────────────────────────────

function renderDiagram(array $diagram): string
{
    $diagram = normalizeRenderDiagramSpec($diagram);
    $type    = strtolower(trim((string) ($diagram['type'] ?? '')));
    $caption = htmlspecialchars((string) ($diagram['caption'] ?? ''));
    $desc    = htmlspecialchars((string) ($diagram['description'] ?? ''));

    return match ($type) {
        // ── Fully rendered ──────────────────────────────────────────────────
        'chart', 'bar_chart', 'bar'
                                     => renderBarChart($diagram['data'] ?? [], $caption),
        'pie', 'pie_chart'           => renderPieChart($diagram['data'] ?? [], $caption),
        'line', 'line_chart',
        'line_graph'                 => renderLineChart($diagram, $caption),
        'geometry', 'shape'          => renderGeometry((string) ($diagram['svg_hint'] ?? ''), $caption),
        'table', 'comparison_table'  => renderTable($diagram, $caption),
        'flowchart', 'flow',
        'food_chain'                 => renderFlowchart($diagram, $caption, $desc),
        'venn', 'venn_diagram'       => renderVenn($diagram, $caption),
        'timeline'                   => renderTimeline($diagram, $caption),
        'number_line'                => renderNumberLine($diagram, $caption),
        'coordinate', 'axes',
        'graph', 'coordinate_plane'  => renderCoordinatePlane($diagram, $caption),
        'tree', 'tree_diagram',
        'concept_map'                => renderTree($diagram, $caption),
        'pyramid'                    => renderPyramid($diagram, $caption),
        'cycle', 'cycle_diagram'     => renderCycle($diagram, $caption),
        'fraction'                   => renderFraction($diagram, $caption),
        'shaded_circle'              => renderShadedCircle((int) ($diagram['parts'] ?? 8), (int) ($diagram['shaded'] ?? 0), $caption),
        // ── Labelled placeholders (require pen-and-paper drawing) ───────────
        'circuit', 'electric_circuit'=> renderLabelledPlaceholder($desc, $caption, 'circuit'),
        'chemistry', 'molecule',
        'structural_formula'         => renderLabelledPlaceholder($desc, $caption, 'chemistry'),
        'anatomy'                    => renderLabelledPlaceholder($desc, $caption, 'anatomy'),
        'biology', 'cell'            => renderLabelledPlaceholder($desc, $caption, 'biology'),
        'physics'                    => renderLabelledPlaceholder($desc, $caption, 'physics'),
        'science'                    => renderLabelledPlaceholder($desc, $caption, 'science'),
        'map', 'geography'           => renderLabelledPlaceholder($desc, $caption, 'map'),
        'diagram'                    => renderLabelledPlaceholder($desc, $caption, 'diagram'),
        default                      => renderPlaceholderBox($desc, $caption),
    };
}

// ══════════════════════════════════════════════════════════════════════════════
// BAR CHART
// spec: { "data": {"Label": value, ...}, "caption": "..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderBarChart(array $data, string $caption): string
{
    if ($data === []) { return renderPlaceholderBox('Bar chart', $caption); }
    $numericData = array_map('floatval', $data);
    $max    = max($numericData);
    $barW   = 50; $gap = 12; $chartH = 160; $padL = 40; $padB = 30;
    $totalW = count($numericData) * ($barW + $gap) + $padL + 20;
    $h      = $chartH + $padB + 10;
    $svg    = "<svg width='{$totalW}' height='{$h}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";
    // Y-axis ticks
    for ($t = 0; $t <= 4; $t++) {
        $ty  = (int) round($chartH - ($t / 4) * $chartH);
        $val = $max > 0 ? round(($t / 4) * $max, 1) : 0;
        $svg .= "<line x1='{$padL}' y1='{$ty}' x2='" . ($padL - 4) . "' y2='{$ty}' stroke='#666'/>";
        $svg .= "<text x='" . ($padL - 6) . "' y='" . ($ty + 4) . "' text-anchor='end' font-size='9' fill='#555'>{$val}</text>";
        $svg .= "<line x1='{$padL}' y1='{$ty}' x2='{$totalW}' y2='{$ty}' stroke='#e0e0e0' stroke-dasharray='3,3'/>";
    }
    $svg .= "<line x1='{$padL}' y1='0' x2='{$padL}' y2='{$chartH}' stroke='#333' stroke-width='1.5'/>";
    $svg .= "<line x1='{$padL}' y1='{$chartH}' x2='{$totalW}' y2='{$chartH}' stroke='#333' stroke-width='1.5'/>";
    $colors = ['#4472C4','#ED7D31','#A9D18E','#FF6B6B','#9B59B6','#F1C40F','#1ABC9C'];
    $i = 0;
    foreach ($numericData as $label => $value) {
        $barH = $max > 0 ? (int) round(($value / $max) * $chartH) : 0;
        $x    = $padL + $i * ($barW + $gap) + $gap;
        $y    = $chartH - $barH;
        $col  = $colors[$i % count($colors)];
        $svg .= "<rect x='{$x}' y='{$y}' width='{$barW}' height='{$barH}' fill='{$col}' rx='2'/>";
        $lbl  = htmlspecialchars(mb_strimwidth((string) $label, 0, 10, '…'));
        $svg .= "<text x='" . ($x + $barW / 2) . "' y='" . ($chartH + 16) . "' text-anchor='middle' font-size='9' fill='#444'>{$lbl}</text>";
        if ($barH > 14) {
            $svg .= "<text x='" . ($x + $barW / 2) . "' y='" . ($y - 3) . "' text-anchor='middle' font-size='9' fill='#333'>{$value}</text>";
        }
        $i++;
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// PIE CHART
// spec: { "data": {"Slice": value, ...}, "caption": "..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderPieChart(array $data, string $caption): string
{
    if ($data === []) { return renderPlaceholderBox('Pie chart', $caption); }
    $numericData = array_map('floatval', $data);
    $total = array_sum($numericData);
    if ($total <= 0) { return renderPlaceholderBox('Pie chart (no data)', $caption); }

    $cx = 100; $cy = 100; $r = 90;
    $colors = ['#4472C4','#ED7D31','#A9D18E','#FF6B6B','#9B59B6','#F1C40F','#1ABC9C','#E74C3C'];
    $legendX = 210; $legendY = 20;
    $svgW = 350; $svgH = 210;
    $svg  = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";

    $angle  = -M_PI / 2; // start at top
    $i = 0;
    foreach ($numericData as $label => $value) {
        $slice = ($value / $total) * 2 * M_PI;
        $x1    = $cx + $r * cos($angle);
        $y1    = $cy + $r * sin($angle);
        $x2    = $cx + $r * cos($angle + $slice);
        $y2    = $cy + $r * sin($angle + $slice);
        $large = $slice > M_PI ? 1 : 0;
        $col   = $colors[$i % count($colors)];
        $svg  .= "<path d='M{$cx},{$cy} L{$x1},{$y1} A{$r},{$r} 0 {$large},1 {$x2},{$y2} Z' fill='{$col}' stroke='#fff' stroke-width='1.5'/>";
        // Percentage label inside slice
        $midAngle = $angle + $slice / 2;
        $lx = $cx + ($r * 0.65) * cos($midAngle);
        $ly = $cy + ($r * 0.65) * sin($midAngle);
        $pct = round(($value / $total) * 100);
        if ($pct >= 5) {
            $svg .= "<text x='{$lx}' y='" . ($ly + 4) . "' text-anchor='middle' font-size='10' fill='#fff' font-weight='bold'>{$pct}%</text>";
        }
        // Legend
        $ly2 = $legendY + $i * 18;
        $svg .= "<rect x='{$legendX}' y='" . ($ly2 - 9) . "' width='12' height='12' fill='{$col}' rx='2'/>";
        $lblText = htmlspecialchars(mb_strimwidth((string) $label, 0, 18, '…'));
        $svg .= "<text x='" . ($legendX + 16) . "' y='{$ly2}' font-size='10' fill='#444'>{$lblText}</text>";
        $angle += $slice;
        $i++;
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// LINE CHART
// spec: { "points": {"x_label": y_value, ...} OR [[x,y],...],
//         "x_label":"", "y_label":"", "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderLineChart(array $diagram, string $caption): string
{
    $raw     = $diagram['points'] ?? $diagram['data'] ?? [];
    $xLabel  = htmlspecialchars((string) ($diagram['x_label'] ?? ''));
    $yLabel  = htmlspecialchars((string) ($diagram['y_label'] ?? ''));

    if (empty($raw)) { return renderPlaceholderBox('Line chart', $caption); }

    // Normalise to [[x_label, y_value], ...]
    $points = [];
    if (is_array($raw) && !empty($raw) && is_array(reset($raw))) {
        // array of [x, y] pairs
        foreach ($raw as $pair) {
            $points[] = [(string) ($pair[0] ?? ''), (float) ($pair[1] ?? 0)];
        }
    } else {
        foreach ($raw as $k => $v) {
            $points[] = [(string) $k, (float) $v];
        }
    }

    $yValues = array_column($points, 1);
    $minY = min($yValues); $maxY = max($yValues);
    $rangeY = $maxY - $minY;
    if ($rangeY === 0.0) { $rangeY = 1.0; }

    $padL = 45; $padB = 36; $padT = 10; $padR = 20;
    $plotW = 320; $plotH = 160;
    $svgW  = $plotW + $padL + $padR;
    $svgH  = $plotH + $padB + $padT;
    $n     = count($points);
    $stepX = $n > 1 ? $plotW / ($n - 1) : $plotW;

    $svg  = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";

    // Grid + Y ticks
    for ($t = 0; $t <= 4; $t++) {
        $ty  = $padT + $plotH - (int) round(($t / 4) * $plotH);
        $val = round($minY + ($t / 4) * $rangeY, 1);
        $svg .= "<line x1='{$padL}' y1='{$ty}' x2='" . ($padL + $plotW) . "' y2='{$ty}' stroke='#e0e0e0' stroke-dasharray='3,3'/>";
        $svg .= "<text x='" . ($padL - 5) . "' y='" . ($ty + 4) . "' text-anchor='end' font-size='9' fill='#555'>{$val}</text>";
    }
    // Axes
    $axX = $padL; $axY = $padT;
    $svg .= "<line x1='{$axX}' y1='{$axY}' x2='{$axX}' y2='" . ($axY + $plotH) . "' stroke='#333' stroke-width='1.5'/>";
    $svg .= "<line x1='{$axX}' y1='" . ($axY + $plotH) . "' x2='" . ($axX + $plotW) . "' y2='" . ($axY + $plotH) . "' stroke='#333' stroke-width='1.5'/>";

    // Polyline
    $polyPts = [];
    foreach ($points as $idx => $pt) {
        $px = $padL + ($n > 1 ? $idx * $stepX : $plotW / 2);
        $py = $padT + $plotH - (int) round((($pt[1] - $minY) / $rangeY) * $plotH);
        $polyPts[] = "{$px},{$py}";
        // X label
        $lbl = htmlspecialchars(mb_strimwidth($pt[0], 0, 8, '…'));
        $svg .= "<text x='{$px}' y='" . ($padT + $plotH + 14) . "' text-anchor='middle' font-size='9' fill='#444'>{$lbl}</text>";
        // Dot
        $svg .= "<circle cx='{$px}' cy='{$py}' r='3' fill='#4472C4'/>";
    }
    $svg .= "<polyline points='" . implode(' ', $polyPts) . "' fill='none' stroke='#4472C4' stroke-width='1.8'/>";

    // Axis labels
    if ($xLabel !== '') {
        $svg .= "<text x='" . ($padL + $plotW / 2) . "' y='" . ($svgH - 2) . "' text-anchor='middle' font-size='10' fill='#333'>{$xLabel}</text>";
    }
    if ($yLabel !== '') {
        $ry = (int) round($padT + $plotH / 2);
        $svg .= "<text x='10' y='{$ry}' text-anchor='middle' font-size='10' fill='#333' transform='rotate(-90,10,{$ry})'>{$yLabel}</text>";
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// GEOMETRY (extended)
// spec: { "svg_hint": "rect|triangle|circle|right_triangle|parallelogram|
//                       trapezoid|rhombus|pentagon|hexagon|...", "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderGeometry(string $hint, string $caption): string
{
    $h   = strtolower(trim($hint));
    $svg = '';

    if (str_contains($h, 'right_triangle') || str_contains($h, 'right triangle')) {
        preg_match('/a=(\d+)/i', $h, $am); preg_match('/b=(\d+)/i', $h, $bm); preg_match('/c=(\d+)/i', $h, $cm);
        $a = (int) ($am[1] ?? 60); $b = (int) ($bm[1] ?? 80); $c = (int) ($cm[1] ?? 100);
        $svg = "<svg width='180' height='140' xmlns='http://www.w3.org/2000/svg'>"
             . "<polygon points='20,120 20,20 140,120' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "<rect x='20' y='110' width='10' height='10' fill='none' stroke='#222' stroke-width='1'/>"
             . "<text x='8' y='75' font-size='11' fill='#333'>{$a}</text>"
             . "<text x='70' y='138' font-size='11' fill='#333'>{$b}</text>"
             . "<text x='90' y='65' font-size='11' fill='#333'>{$c}</text>"
             . "</svg>";
    } elseif (str_contains($h, 'parallelogram')) {
        preg_match('/w=(\d+)/i', $h, $wm); preg_match('/h=(\d+)/i', $h, $hm);
        $w = (int) ($wm[1] ?? 120); $ht = (int) ($hm[1] ?? 70);
        $off = 30;
        $svg = "<svg width='" . ($w + $off + 40) . "' height='" . ($ht + 40) . "' xmlns='http://www.w3.org/2000/svg'>"
             . "<polygon points='20," . ($ht + 20) . " " . ($off + 20) . ",20 " . ($w + $off + 20) . ",20 " . ($w + 20) . "," . ($ht + 20) . "' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "<text x='8' y='22' font-size='11'>A</text>"
             . "<text x='" . ($off + $w + 18) . "' y='22' font-size='11'>B</text>"
             . "<text x='" . ($w + 18) . "' y='" . ($ht + 35) . "' font-size='11'>C</text>"
             . "<text x='8' y='" . ($ht + 35) . "' font-size='11'>D</text>"
             . "</svg>";
    } elseif (str_contains($h, 'trapezoid') || str_contains($h, 'trapezium')) {
        preg_match('/top=(\d+)/i', $h, $tm); preg_match('/bottom=(\d+)/i', $h, $bm); preg_match('/h=(\d+)/i', $h, $hm);
        $top = (int) ($tm[1] ?? 80); $bot = (int) ($bm[1] ?? 120); $ht = (int) ($hm[1] ?? 60);
        $off = (int) (($bot - $top) / 2);
        $svg = "<svg width='" . ($bot + 40) . "' height='" . ($ht + 40) . "' xmlns='http://www.w3.org/2000/svg'>"
             . "<polygon points='" . ($off + 20) . ",20 " . ($off + $top + 20) . ",20 " . ($bot + 20) . "," . ($ht + 20) . " 20," . ($ht + 20) . "' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "</svg>";
    } elseif (str_contains($h, 'rhombus') || str_contains($h, 'diamond')) {
        preg_match('/d1=(\d+)/i', $h, $d1m); preg_match('/d2=(\d+)/i', $h, $d2m);
        $d1 = (int) ($d1m[1] ?? 120); $d2 = (int) ($d2m[1] ?? 80);
        $cx = (int) ($d1 / 2) + 10; $cy = (int) ($d2 / 2) + 10;
        $svg = "<svg width='" . ($d1 + 20) . "' height='" . ($d2 + 20) . "' xmlns='http://www.w3.org/2000/svg'>"
             . "<polygon points='{$cx},10 " . ($d1 + 10) . ",{$cy} {$cx}," . ($d2 + 10) . " 10,{$cy}' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "</svg>";
    } elseif (str_contains($h, 'rect')) {
        preg_match('/w=(\d+)/i', $h, $wm); preg_match('/h=(\d+)/i', $h, $hm); preg_match('/label=([A-Za-z]+)/i', $h, $lm);
        $w = (int) ($wm[1] ?? 120); $ht = (int) ($hm[1] ?? 80);
        $label = strtoupper($lm[1] ?? 'ABCD'); $chars = str_split($label);
        $ox = 30; $oy = 30;
        $svg  = "<svg width='" . ($w + 60) . "' height='" . ($ht + 60) . "' xmlns='http://www.w3.org/2000/svg'>";
        $svg .= "<rect x='{$ox}' y='{$oy}' width='{$w}' height='{$ht}' fill='none' stroke='#222' stroke-width='1.5'/>";
        foreach ([[$ox - 14, $oy - 8], [$ox + $w + 4, $oy - 8], [$ox + $w + 4, $oy + $ht + 14], [$ox - 14, $oy + $ht + 14]] as $ci => $co) {
            $char = $chars[$ci] ?? '';
            $svg .= "<text x='{$co[0]}' y='{$co[1]}' font-size='12' font-weight='bold'>{$char}</text>";
        }
        $svg .= '</svg>';
    } elseif (str_contains($h, 'triangle')) {
        preg_match('/label=([A-Za-z]+)/i', $h, $lm);
        $lbl = str_split(strtoupper($lm[1] ?? 'ABC'));
        $svg = "<svg width='160' height='140' xmlns='http://www.w3.org/2000/svg'>"
             . "<polygon points='80,10 10,130 150,130' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "<text x='74' y='8' font-size='12' font-weight='bold'>" . ($lbl[0] ?? 'A') . "</text>"
             . "<text x='2' y='145' font-size='12' font-weight='bold'>" . ($lbl[1] ?? 'B') . "</text>"
             . "<text x='148' y='145' font-size='12' font-weight='bold'>" . ($lbl[2] ?? 'C') . "</text>"
             . "</svg>";
    } elseif (str_contains($h, 'circle')) {
        preg_match('/r=(\d+)/i', $h, $rm);
        $r = min((int) ($rm[1] ?? 60), 80);
        $cx = $r + 10; $cy = $r + 10;
        $svg = "<svg width='" . ($cx * 2) . "' height='" . ($cy * 2) . "' xmlns='http://www.w3.org/2000/svg'>"
             . "<circle cx='{$cx}' cy='{$cy}' r='{$r}' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "<line x1='{$cx}' y1='{$cy}' x2='" . ($cx + $r) . "' y2='{$cy}' stroke='#555' stroke-width='1'/>"
             . "<text x='" . ($cx + $r / 2 - 3) . "' y='" . ($cy - 3) . "' font-size='10'>r</text>"
             . "<text x='" . ($cx - 4) . "' y='" . ($cy + 4) . "' font-size='9'>O</text>"
             . "</svg>";
    } elseif (str_contains($h, 'pentagon') || str_contains($h, 'hexagon') || str_contains($h, 'polygon')) {
        $sides = str_contains($h, 'pentagon') ? 5 : (str_contains($h, 'hexagon') ? 6 : 6);
        preg_match('/n=(\d+)/i', $h, $nm);
        if (isset($nm[1])) { $sides = max(3, min(12, (int) $nm[1])); }
        $r = 70; $cx = 80; $cy = 80;
        $pts = [];
        for ($k = 0; $k < $sides; $k++) {
            $a = -M_PI / 2 + (2 * M_PI * $k / $sides);
            $pts[] = round($cx + $r * cos($a), 1) . ',' . round($cy + $r * sin($a), 1);
        }
        $svg = "<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'>"
             . "<polygon points='" . implode(' ', $pts) . "' fill='none' stroke='#222' stroke-width='1.5'/>"
             . "</svg>";
    }

    if ($svg === '') { return renderPlaceholderBox($hint ?: 'Geometry shape', $caption); }
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// TABLE
// spec: { "headers": ["Col A","Col B"], "rows": [["r1c1","r1c2"],...], "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderTable(array $diagram, string $caption): string
{
    $headers = $diagram['headers'] ?? [];
    $rows    = $diagram['rows']    ?? [];
    if (empty($headers) && empty($rows)) {
        return renderPlaceholderBox(htmlspecialchars((string) ($diagram['description'] ?? 'Table')), $caption);
    }
    $cell = 'padding:6px 10px;border:1px solid #555;font-size:11px;vertical-align:top;';
    $th   = $cell . 'background:#e8e8e8;font-weight:bold;text-align:center;';
    $html  = "<div style='margin:12px 0;overflow-x:auto;'>";
    $html .= "<table style='border-collapse:collapse;width:100%;border:1px solid #555;'>";
    if (!empty($headers)) {
        $html .= '<thead><tr>';
        foreach ($headers as $hdr) {
            $html .= "<th style='{$th}'>" . htmlspecialchars((string) $hdr) . '</th>';
        }
        $html .= '</tr></thead>';
    }
    if (!empty($rows)) {
        $html .= '<tbody>';
        foreach ($rows as $row) {
            $html .= '<tr>';
            foreach ((is_array($row) ? $row : [$row]) as $cell_val) {
                $html .= "<td style='{$cell}'>" . htmlspecialchars((string) $cell_val) . '</td>';
            }
            $html .= '</tr>';
        }
        $html .= '</tbody>';
    }
    $html .= '</table>';
    if ($caption !== '') {
        $html .= "<p style='font-size:11px;font-style:italic;text-align:center;margin-top:4px;'>{$caption}</p>";
    }
    return $html . '</div>';
}

// ══════════════════════════════════════════════════════════════════════════════
// FLOWCHART  (also used for food_chain)
// spec: { "steps": ["Step 1","Step 2",...], "caption":"..." }
// Fallback: extracts ALL-CAPS words from "description" when steps absent.
// ══════════════════════════════════════════════════════════════════════════════
function renderFlowchart(array $diagram, string $caption, string $desc): string
{
    $steps = $diagram['steps'] ?? [];

    // Attempt to recover steps from prose description when structured data absent
    if (empty($steps)) {
        $rawDesc = (string) ($diagram['description'] ?? '');
        if ($rawDesc !== '') {
            // Strategy 1: explicit array stored under 'nodes' or 'boxes'
            $steps = $diagram['nodes'] ?? $diagram['boxes'] ?? [];
        }
        if (empty($steps) && $rawDesc !== '') {
            // Strategy 2: extract consecutive ALL-CAPS words/phrases (≥2 chars)
            preg_match_all('/\b([A-Z][A-Z\s\-]{1,24}[A-Z])\b/', $rawDesc, $m);
            if (!empty($m[1])) {
                $seen = []; $steps = [];
                foreach ($m[1] as $w) {
                    $w = trim($w);
                    if ($w !== '' && !isset($seen[$w])) { $seen[$w] = true; $steps[] = $w; }
                }
            }
        }
        if (empty($steps)) {
            return renderLabelledPlaceholder($desc ?: 'Flowchart', $caption, 'flowchart');
        }
    }

    $box  = 'display:inline-block;border:1.5px solid #444;border-radius:4px;padding:6px 14px;'
          . 'font-size:11px;background:#f0f4ff;min-width:120px;max-width:260px;text-align:center;';
    $arr  = 'font-size:18px;line-height:1;color:#555;display:block;text-align:center;margin:2px 0;';
    $html = "<div style='margin:12px 0;text-align:center;page-break-inside:avoid;'>";
    foreach ($steps as $i => $step) {
        if ($i > 0) { $html .= "<span style='{$arr}'>&#8595;</span>"; }
        $html .= "<span style='{$box}'>" . htmlspecialchars((string) $step) . '</span>';
    }
    if ($caption !== '') {
        $html .= "<p style='font-size:11px;font-style:italic;margin-top:8px;'>{$caption}</p>";
    }
    return $html . '</div>';
}

// ══════════════════════════════════════════════════════════════════════════════
// VENN DIAGRAM
// spec: { "set_a":"Set A", "set_b":"Set B",
//         "only_a":["item1"], "only_b":["item2"], "both":["item3"],
//         "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderVenn(array $diagram, string $caption): string
{
    $setA   = htmlspecialchars((string) ($diagram['set_a'] ?? 'Set A'));
    $setB   = htmlspecialchars((string) ($diagram['set_b'] ?? 'Set B'));
    $onlyA  = (array) ($diagram['only_a'] ?? []);
    $onlyB  = (array) ($diagram['only_b'] ?? []);
    $both   = (array) ($diagram['both']   ?? []);

    $svgW = 380; $svgH = 200;
    $r    = 80; $cy = 100;
    $cx1  = 130; $cx2 = 250;

    $svg  = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";
    $svg .= "<circle cx='{$cx1}' cy='{$cy}' r='{$r}' fill='rgba(68,114,196,0.15)' stroke='#4472C4' stroke-width='1.8'/>";
    $svg .= "<circle cx='{$cx2}' cy='{$cy}' r='{$r}' fill='rgba(237,125,49,0.15)' stroke='#ED7D31' stroke-width='1.8'/>";
    $svg .= "<text x='" . ($cx1 - 55) . "' y='22' font-size='11' font-weight='bold' fill='#4472C4'>{$setA}</text>";
    $svg .= "<text x='" . ($cx2 + 5)  . "' y='22' font-size='11' font-weight='bold' fill='#ED7D31'>{$setB}</text>";

    // Items in only_a (left zone)
    $ty = $cy - (count($onlyA) - 1) * 9;
    foreach ($onlyA as $item) {
        $svg .= "<text x='" . ($cx1 - 48) . "' y='{$ty}' font-size='9' fill='#333' text-anchor='middle'>" . htmlspecialchars(mb_strimwidth((string) $item, 0, 14, '…')) . "</text>";
        $ty += 14;
    }
    // Items in only_b (right zone)
    $ty = $cy - (count($onlyB) - 1) * 9;
    foreach ($onlyB as $item) {
        $svg .= "<text x='" . ($cx2 + 48) . "' y='{$ty}' font-size='9' fill='#333' text-anchor='middle'>" . htmlspecialchars(mb_strimwidth((string) $item, 0, 14, '…')) . "</text>";
        $ty += 14;
    }
    // Items in both (overlap zone)
    $ty = $cy - (count($both) - 1) * 9;
    foreach ($both as $item) {
        $svg .= "<text x='" . (($cx1 + $cx2) / 2) . "' y='{$ty}' font-size='9' fill='#333' text-anchor='middle'>" . htmlspecialchars(mb_strimwidth((string) $item, 0, 14, '…')) . "</text>";
        $ty += 14;
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// TIMELINE
// spec: { "events": [{"year":"1066","event":"Battle of Hastings"},…], "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderTimeline(array $diagram, string $caption): string
{
    $events = $diagram['events'] ?? [];
    if (empty($events)) { return renderPlaceholderBox('Timeline', $caption); }

    $lineStyle = 'border-left:2px solid #444;padding-left:14px;margin-left:8px;';
    $dotStyle  = 'width:12px;height:12px;background:#4472C4;border-radius:50%;'
               . 'display:inline-block;margin-left:-20px;margin-right:8px;vertical-align:middle;';
    $yearStyle = 'font-weight:bold;font-size:11px;color:#1a3c7a;';
    $evtStyle  = 'font-size:11px;color:#333;';

    $html = "<div style='margin:12px 0;padding:4px 0;'>";
    $html .= "<div style='{$lineStyle}'>";
    foreach ($events as $ev) {
        $year = htmlspecialchars((string) ($ev['year'] ?? $ev['date'] ?? ''));
        $text = htmlspecialchars((string) ($ev['event'] ?? $ev['text'] ?? $ev['description'] ?? ''));
        $html .= "<div style='margin-bottom:10px;'>"
               . "<span style='{$dotStyle}'></span>"
               . "<span style='{$yearStyle}'>{$year}</span> "
               . "<span style='{$evtStyle}'>— {$text}</span>"
               . '</div>';
    }
    $html .= '</div>';
    if ($caption !== '') {
        $html .= "<p style='font-size:11px;font-style:italic;margin-top:4px;'>{$caption}</p>";
    }
    return $html . '</div>';
}

// ══════════════════════════════════════════════════════════════════════════════
// NUMBER LINE
// spec: { "min":-5, "max":10, "step":1,
//         "points":[{"value":3,"label":"x"},…], "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderNumberLine(array $diagram, string $caption): string
{
    $min   = (float) ($diagram['min']  ?? -5);
    $max   = (float) ($diagram['max']  ?? 10);
    $step  = (float) ($diagram['step'] ?? 1);
    $pts   = (array) ($diagram['points'] ?? []);
    if ($max <= $min || $step <= 0) { return renderPlaceholderBox('Number line', $caption); }

    $padX = 30; $cy = 40; $lineY = 30; $svgH = 75;
    $range = $max - $min;
    $svgW  = 380;
    $scale = ($svgW - $padX * 2) / $range;

    $svg  = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";
    // Main line with arrows
    $svg .= "<line x1='{$padX}' y1='{$lineY}' x2='" . ($svgW - $padX) . "' y2='{$lineY}' stroke='#333' stroke-width='1.5'/>";
    $svg .= "<polygon points='" . ($svgW - $padX) . "," . ($lineY - 4) . " " . ($svgW - $padX + 8) . ",{$lineY} " . ($svgW - $padX) . "," . ($lineY + 4) . "' fill='#333'/>";
    // Ticks
    for ($v = $min; $v <= $max + 0.001; $v += $step) {
        $x   = (int) round($padX + ($v - $min) * $scale);
        $isMajor = ($v == (int) $v);
        $th  = $isMajor ? 8 : 4;
        $svg .= "<line x1='{$x}' y1='" . ($lineY - $th) . "' x2='{$x}' y2='" . ($lineY + $th) . "' stroke='#444' stroke-width='1'/>";
        if ($isMajor) {
            $svg .= "<text x='{$x}' y='" . ($lineY + 20) . "' text-anchor='middle' font-size='9' fill='#444'>{$v}</text>";
        }
    }
    // Marked points
    foreach ($pts as $pt) {
        $val = (float) ($pt['value'] ?? 0);
        $lbl = htmlspecialchars((string) ($pt['label'] ?? ''));
        $x   = (int) round($padX + ($val - $min) * $scale);
        $svg .= "<circle cx='{$x}' cy='{$lineY}' r='5' fill='#ED7D31' stroke='#fff' stroke-width='1'/>";
        if ($lbl !== '') {
            $svg .= "<text x='{$x}' y='" . ($lineY - 10) . "' text-anchor='middle' font-size='10' font-weight='bold' fill='#c0392b'>{$lbl}</text>";
        }
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// COORDINATE PLANE
// spec: { "x_range":[-5,5], "y_range":[-5,5],
//         "points":[{"x":2,"y":3,"label":"A"}],
//         "lines":[{"from":[0,0],"to":[3,3],"label":"y=x"}],
//         "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderCoordinatePlane(array $diagram, string $caption): string
{
    $xRange  = $diagram['x_range'] ?? [-5, 5];
    $yRange  = $diagram['y_range'] ?? [-5, 5];
    $points  = (array) ($diagram['points'] ?? []);
    $lines   = (array) ($diagram['lines']  ?? []);

    $xMin = (float) ($xRange[0] ?? -5); $xMax = (float) ($xRange[1] ?? 5);
    $yMin = (float) ($yRange[0] ?? -5); $yMax = (float) ($yRange[1] ?? 5);
    if ($xMax <= $xMin || $yMax <= $yMin) { return renderPlaceholderBox('Coordinate plane', $caption); }

    $pad   = 35; $plotW = 240; $plotH = 240;
    $svgW  = $plotW + $pad * 2; $svgH = $plotH + $pad * 2;
    $scaleX = $plotW / ($xMax - $xMin);
    $scaleY = $plotH / ($yMax - $yMin);

    $toSvgX = fn(float $v): int => (int) round($pad + ($v - $xMin) * $scaleX);
    $toSvgY = fn(float $v): int => (int) round($pad + ($yMax - $v) * $scaleY);

    $svg = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";

    // Grid lines
    for ($gx = (int) ceil($xMin); $gx <= (int) floor($xMax); $gx++) {
        $x = $toSvgX((float) $gx);
        $svg .= "<line x1='{$x}' y1='{$pad}' x2='{$x}' y2='" . ($pad + $plotH) . "' stroke='#e0e0e0' stroke-width='1'/>";
        if ($gx !== 0) { $svg .= "<text x='{$x}' y='" . ($toSvgY(0.0) + 12) . "' text-anchor='middle' font-size='9' fill='#888'>{$gx}</text>"; }
    }
    for ($gy = (int) ceil($yMin); $gy <= (int) floor($yMax); $gy++) {
        $y = $toSvgY((float) $gy);
        $svg .= "<line x1='{$pad}' y1='{$y}' x2='" . ($pad + $plotW) . "' y2='{$y}' stroke='#e0e0e0' stroke-width='1'/>";
        if ($gy !== 0) { $svg .= "<text x='" . ($toSvgX(0.0) - 6) . "' y='" . ($y + 4) . "' text-anchor='end' font-size='9' fill='#888'>{$gy}</text>"; }
    }

    // Axes
    $ox = $toSvgX(0.0); $oy = $toSvgY(0.0);
    $svg .= "<line x1='{$pad}' y1='{$oy}' x2='" . ($pad + $plotW) . "' y2='{$oy}' stroke='#333' stroke-width='1.5'/>";
    $svg .= "<polygon points='" . ($pad + $plotW) . "," . ($oy - 4) . " " . ($pad + $plotW + 7) . ",{$oy} " . ($pad + $plotW) . "," . ($oy + 4) . "' fill='#333'/>";
    $svg .= "<text x='" . ($pad + $plotW + 9) . "' y='" . ($oy + 4) . "' font-size='10' fill='#333'>x</text>";
    $svg .= "<line x1='{$ox}' y1='" . ($pad + $plotH) . "' x2='{$ox}' y2='{$pad}' stroke='#333' stroke-width='1.5'/>";
    $svg .= "<polygon points='" . ($ox - 4) . ",{$pad} {$ox}," . ($pad - 7) . " " . ($ox + 4) . ",{$pad} fill='#333'/>";
    $svg .= "<text x='" . ($ox + 4) . "' y='" . ($pad - 3) . "' font-size='10' fill='#333'>y</text>";

    // User-defined lines
    foreach ($lines as $line) {
        $from = $line['from'] ?? [0, 0]; $to = $line['to'] ?? [1, 1];
        $x1 = $toSvgX((float) ($from[0] ?? 0)); $y1 = $toSvgY((float) ($from[1] ?? 0));
        $x2 = $toSvgX((float) ($to[0]   ?? 0)); $y2 = $toSvgY((float) ($to[1]   ?? 0));
        $svg .= "<line x1='{$x1}' y1='{$y1}' x2='{$x2}' y2='{$y2}' stroke='#4472C4' stroke-width='1.5'/>";
        $lbl = htmlspecialchars((string) ($line['label'] ?? ''));
        if ($lbl !== '') {
            $svg .= "<text x='" . ($x2 + 3) . "' y='" . ($y2 - 3) . "' font-size='9' fill='#4472C4'>{$lbl}</text>";
        }
    }

    // User-defined points
    foreach ($points as $pt) {
        $px  = $toSvgX((float) ($pt['x'] ?? 0));
        $py  = $toSvgY((float) ($pt['y'] ?? 0));
        $lbl = htmlspecialchars((string) ($pt['label'] ?? ''));
        $svg .= "<circle cx='{$px}' cy='{$py}' r='4' fill='#ED7D31' stroke='#fff' stroke-width='1'/>";
        if ($lbl !== '') {
            $svg .= "<text x='" . ($px + 5) . "' y='" . ($py - 5) . "' font-size='10' font-weight='bold' fill='#c0392b'>{$lbl}</text>";
        }
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// TREE / CONCEPT MAP
// spec: { "root":"Main Topic",
//         "branches":[{"label":"Branch","children":["Leaf 1","Leaf 2"]},…],
//         "caption":"..." }
// Fallback: shows labelled placeholder with description when branches absent.
// ══════════════════════════════════════════════════════════════════════════════
function renderTree(array $diagram, string $caption): string
{
    $root     = htmlspecialchars((string) ($diagram['root'] ?? ''));
    $branches = (array) ($diagram['branches'] ?? []);
    if (empty($branches)) {
        $desc = htmlspecialchars((string) ($diagram['description'] ?? $diagram['root'] ?? 'Concept map'));
        return renderLabelledPlaceholder($desc, $caption, 'diagram');
    }

    $rootStyle   = 'display:inline-block;background:#4472C4;color:#fff;font-weight:bold;'
                 . 'padding:6px 16px;border-radius:20px;font-size:12px;';
    $branchStyle = 'display:inline-block;background:#E8F0FE;border:1.5px solid #4472C4;'
                 . 'padding:4px 12px;border-radius:14px;font-size:11px;font-weight:600;color:#1a3c7a;margin:3px;';
    $leafStyle   = 'display:inline-block;background:#fff;border:1px solid #bbb;'
                 . 'padding:3px 10px;border-radius:10px;font-size:10px;color:#444;margin:2px;';

    $html  = "<div style='margin:12px 0;text-align:center;'>";
    $html .= "<div style='{$rootStyle}'>{$root}</div>";
    $html .= "<div style='margin-top:6px;display:flex;flex-wrap:wrap;justify-content:center;gap:8px;'>";
    foreach ($branches as $branch) {
        $bLabel   = htmlspecialchars((string) ($branch['label'] ?? $branch));
        $children = is_array($branch) ? (array) ($branch['children'] ?? []) : [];
        $html .= "<div style='display:inline-block;vertical-align:top;'>";
        $html .= "<div style='{$branchStyle}'>{$bLabel}</div>";
        if (!empty($children)) {
            $html .= "<div style='margin-top:4px;'>";
            foreach ($children as $leaf) {
                $html .= "<span style='{$leafStyle}'>" . htmlspecialchars((string) $leaf) . '</span>';
            }
            $html .= '</div>';
        }
        $html .= '</div>';
    }
    $html .= '</div>';
    if ($caption !== '') {
        $html .= "<p style='font-size:11px;font-style:italic;margin-top:8px;'>{$caption}</p>";
    }
    return $html . '</div>';
}

// ══════════════════════════════════════════════════════════════════════════════
// PYRAMID (food pyramid, population pyramid, hierarchy)
// spec: { "levels":["Top (narrow)","Middle","Base (wide)"], "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderPyramid(array $diagram, string $caption): string
{
    $levels = $diagram['levels'] ?? [];
    if (empty($levels)) { return renderPlaceholderBox('Pyramid', $caption); }

    $colors = ['#FF6B6B','#FFA07A','#FFD700','#90EE90','#87CEEB','#DDA0DD'];
    $n      = count($levels);
    $svgW   = 320; $svgH = $n * 36 + 20;
    $svg    = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";

    for ($i = 0; $i < $n; $i++) {
        $ratio    = ($i + 1) / $n;
        $w        = (int) round($ratio * ($svgW - 40));
        $x        = (int) round(($svgW - $w) / 2);
        $y        = $i * 36 + 10;
        $col      = $colors[$i % count($colors)];
        $prevRatio = $i / $n;
        $prevW     = (int) round($prevRatio * ($svgW - 40));
        $prevX     = (int) round(($svgW - $prevW) / 2);
        // Trapezoid for each level
        $svg .= "<polygon points='{$prevX},{$y} " . ($prevX + $prevW) . ",{$y} " . ($x + $w) . "," . ($y + 34) . " {$x}," . ($y + 34) . "' fill='{$col}' stroke='#fff' stroke-width='1'/>";
        $lbl = htmlspecialchars(mb_strimwidth((string) ($levels[$i] ?? ''), 0, 30, '…'));
        $svg .= "<text x='" . ($svgW / 2) . "' y='" . ($y + 22) . "' text-anchor='middle' font-size='10' fill='#333'>{$lbl}</text>";
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// CYCLE DIAGRAM (water cycle, carbon cycle, cell cycle, etc.)
// spec: { "stages":["Stage 1","Stage 2","Stage 3","Stage 4"], "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderCycle(array $diagram, string $caption): string
{
    $stages = $diagram['stages'] ?? [];
    if (empty($stages)) { return renderPlaceholderBox('Cycle diagram', $caption); }

    $n    = count($stages);
    $r    = 100; $cx = 160; $cy = 160;
    $svgW = 320; $svgH = 320;
    $colors = ['#4472C4','#ED7D31','#A9D18E','#FF6B6B','#9B59B6','#F1C40F'];

    $svg  = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";
    // Dashed circle path
    $svg .= "<circle cx='{$cx}' cy='{$cy}' r='{$r}' fill='none' stroke='#ccc' stroke-width='1' stroke-dasharray='6,4'/>";

    for ($i = 0; $i < $n; $i++) {
        $angle    = -M_PI / 2 + (2 * M_PI * $i / $n);
        $nextAng  = -M_PI / 2 + (2 * M_PI * ($i + 1) / $n);
        $bx       = (int) round($cx + $r * cos($angle));
        $by       = (int) round($cy + $r * sin($angle));
        $nbx      = (int) round($cx + $r * cos($nextAng));
        $nby      = (int) round($cy + $r * sin($nextAng));
        $col      = $colors[$i % count($colors)];

        // Arrow arc (curved)
        $midAng   = $angle + (M_PI / $n);
        $mx       = (int) round($cx + ($r + 20) * cos($midAng));
        $my       = (int) round($cy + ($r + 20) * sin($midAng));
        $svg .= "<path d='M{$bx},{$by} Q{$mx},{$my} {$nbx},{$nby}' fill='none' stroke='{$col}' stroke-width='1.8' marker-end='url(#arr{$i})'/>";
        $svg .= "<defs><marker id='arr{$i}' markerWidth='6' markerHeight='6' refX='3' refY='3' orient='auto'>"
              . "<path d='M0,0 L6,3 L0,6 Z' fill='{$col}'/></marker></defs>";

        // Stage label box
        $lx = (int) round($cx + ($r + 46) * cos($angle));
        $ly = (int) round($cy + ($r + 46) * sin($angle));
        $lbl = htmlspecialchars(mb_strimwidth((string) ($stages[$i] ?? ''), 0, 18, '…'));
        $boxW = 70; $boxH = 22;
        $svg .= "<rect x='" . ($lx - $boxW / 2) . "' y='" . ($ly - $boxH / 2) . "' width='{$boxW}' height='{$boxH}' rx='4' fill='{$col}' opacity='0.85'/>";
        $svg .= "<text x='{$lx}' y='" . ($ly + 5) . "' text-anchor='middle' font-size='9' fill='#fff'>{$lbl}</text>";
    }
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// FRACTION
// spec: { "numerator":3, "denominator":4, "caption":"..." }
// ══════════════════════════════════════════════════════════════════════════════
function renderFraction(array $diagram, string $caption): string
{
    $num  = max(1, (int) ($diagram['numerator']   ?? 1));
    $den  = max(2, (int) ($diagram['denominator'] ?? 4));
    if ($num > $den) { $num = $den; }

    $boxW = 30; $gap = 4; $h = 30;
    $svgW = $den * ($boxW + $gap) + 20;
    $svgH = $h + 40;
    $svg  = "<svg width='{$svgW}' height='{$svgH}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";
    for ($k = 0; $k < $den; $k++) {
        $x   = 10 + $k * ($boxW + $gap);
        $col = $k < $num ? '#4472C4' : '#e0e0e0';
        $svg .= "<rect x='{$x}' y='10' width='{$boxW}' height='{$h}' fill='{$col}' stroke='#fff' stroke-width='2' rx='2'/>";
    }
    $label = "{$num}/{$den}";
    $svg  .= "<text x='" . ($svgW / 2) . "' y='" . ($h + 30) . "' text-anchor='middle' font-size='12' fill='#333'>{$label}</text>";
    $svg  .= '</svg>';
    return _diagramWrap($svg, $caption);
}

// ══════════════════════════════════════════════════════════════════════════════
// LABELLED PLACEHOLDER  (biology, physics, science, map, circuit, chemistry…)
// ══════════════════════════════════════════════════════════════════════════════
function renderLabelledPlaceholder(string $desc, string $caption, string $type): string
{
    $labels = [
        'biology'  => ['&#x1F9EC;', 'Biology Diagram'],
        'physics'  => ['&#x26A1;',  'Physics Diagram'],
        'science'  => ['&#x1F52C;', 'Science Diagram'],
        'map'      => ['&#x1F5FA;', 'Map / Geography Diagram'],
        'circuit'  => ['&#x1F50C;', 'Electric Circuit Diagram'],
        'chemistry'=> ['&#x1F9EA;', 'Chemistry / Molecular Diagram'],
        'anatomy'  => ['&#x1FAC0;', 'Anatomy Diagram'],
        'diagram'  => ['&#x1F4CB;', 'Diagram'],
        'flowchart'=> ['&#x1F500;', 'Flowchart'],
    ];
    [$icon, $label] = $labels[$type] ?? ['&#x1F4CA;', ucfirst($type) . ' Diagram'];
    $label = htmlspecialchars($label);
    $body  = $desc !== '' ? "<p style='color:#555;font-size:11px;font-style:italic;margin:4px 0 0;'>{$desc}</p>" : '';
    return "<div style='border:1.5px dashed #777;padding:14px 16px;margin:12px 0;min-height:90px;"
         . "text-align:center;background:#fafafa;border-radius:4px;page-break-inside:avoid;'>"
         . "<p style='font-size:13px;font-weight:600;color:#444;margin:0;'>{$icon} [{$label}]</p>"
         . $body
         . ($caption !== '' ? "<p style='font-size:10px;margin-top:6px;'>{$caption}</p>" : '')
         . '</div>';
}

// Kept for backwards-compatibility
function renderSciencePlaceholder(string $desc, string $caption): string
{
    return renderLabelledPlaceholder($desc, $caption, 'science');
}

function renderPlaceholderBox(string $desc, string $caption): string
{
    return "<div style='border:1.5px solid #555;padding:16px;margin:12px 0;min-height:90px;"
         . "text-align:center;background:#fafafa;page-break-inside:avoid;'>"
         . "<p style='color:#666;font-style:italic;font-size:12px;'>[Diagram: {$desc}]</p>"
         . "<p style='font-size:10px;'>{$caption}</p></div>";
}

// ── Internal helper ───────────────────────────────────────────────────────────
/**
 * Coerce common AI spec variations into the exact shapes the renderers expect,
 * so a slightly-off diagram_spec still renders instead of falling back to a box.
 * Idempotent and safe: only reshapes recognised variations, otherwise no-op.
 */
function normalizeRenderDiagramSpec(array $d): array
{
    $type = strtolower(trim((string) ($d['type'] ?? '')));

    // Infer a drawable type when the AI supplied only a caption/description/question.
    $known = ['bar_chart','bar','chart','pie_chart','pie','line_chart','line','line_graph','geometry','shape','table','comparison_table','flowchart','flow','food_chain','venn','venn_diagram','timeline','number_line','coordinate','axes','graph','coordinate_plane','tree','tree_diagram','concept_map','pyramid','cycle','cycle_diagram','fraction','shaded_circle'];
    if ($type === '' || !in_array($type, $known, true)) {
        $text = strtolower((string) ($d['caption'] ?? '') . ' ' . (string) ($d['description'] ?? '') . ' ' . (string) ($d['title'] ?? '') . ' ' . (string) ($d['question'] ?? ''));
        if (preg_match('/\bbar\s*(?:chart|graph)\b/', $text)) {
            $d['type'] = $type = 'bar_chart';
        } elseif (preg_match('/\bpie\s*chart\b/', $text)) {
            $d['type'] = $type = 'pie_chart';
        } elseif (preg_match('/\bline\s*(?:chart|graph)\b/', $text)) {
            $d['type'] = $type = 'line_chart';
        } elseif (preg_match('/\bnumber\s*line\b/', $text)) {
            $d['type'] = $type = 'number_line';
        } elseif (strpos($text, 'shaded') !== false || strpos($text, 'fraction') !== false) {
            [$p, $sh] = _extractShaded($text);
            if ($p > 0) { $d['type'] = $type = 'shaded_circle'; $d['parts'] = $p; $d['shaded'] = $sh; }
        } elseif (preg_match('/\b(triangle|rectangle|square|pentagon|hexagon|circle)\b/', $text, $g)) {
            $d['type'] = $type = 'geometry';
            if (empty($d['svg_hint'])) { $d['svg_hint'] = ($g[1] === 'square') ? 'rect' : $g[1]; }
        }
    }

    // shaded_circle params may live in the text even when the type was explicit.
    if ($type === 'shaded_circle' && (int) ($d['parts'] ?? 0) <= 0) {
        [$p, $sh] = _extractShaded(strtolower((string) ($d['caption'] ?? '') . ' ' . (string) ($d['description'] ?? '') . ' ' . (string) ($d['question'] ?? '')));
        if ($p > 0) { $d['parts'] = $p; $d['shaded'] = $sh; }
        else { $d['type'] = $type = 'diagram'; }
    }

    // fraction with the numbers only in the text → draw it as a shaded circle.
    if ($type === 'fraction' && !(isset($d['numerator']) && isset($d['denominator']))) {
        [$p, $sh] = _extractShaded(strtolower((string) ($d['caption'] ?? '') . ' ' . (string) ($d['description'] ?? '') . ' ' . (string) ($d['question'] ?? '')));
        if ($p > 0) { $d['type'] = $type = 'shaded_circle'; $d['parts'] = $p; $d['shaded'] = $sh; }
    }

    // Bar / pie: accept {categories|labels:[...], values:[...]} or [{label,value}] → {label: value}.
    if (in_array($type, ['bar_chart', 'bar', 'chart', 'pie_chart', 'pie'], true) && isset($d['data']) && is_array($d['data'])) {
        $d['data'] = _coercePairsToValueMap($d['data']);
    }

    // Line: accept data {x:[...], y:[...]} → points {x: y}.
    if (in_array($type, ['line_chart', 'line', 'line_graph'], true) && !isset($d['points']) && isset($d['data']) && is_array($d['data'])) {
        if (isset($d['data']['x'], $d['data']['y']) && is_array($d['data']['x']) && is_array($d['data']['y'])) {
            $xs = array_values($d['data']['x']);
            $ys = array_values($d['data']['y']);
            $points = [];
            foreach ($xs as $i => $x) {
                $points[(string) $x] = (float) ($ys[$i] ?? 0);
            }
            $d['points'] = $points;
        } else {
            $d['points'] = _coercePairsToValueMap($d['data']);
        }
    }

    // Number line: accept "marks": [2,5,8] → points [{value:n}].
    if ($type === 'number_line' && empty($d['points']) && !empty($d['marks']) && is_array($d['marks'])) {
        $points = [];
        foreach ($d['marks'] as $m) {
            $points[] = is_array($m) ? $m : ['value' => (float) $m];
        }
        $d['points'] = $points;
    }

    return $d;
}

/** Turn {categories|labels:[...], values:[...]}, [{label,value}], or [[label,value]] into {label: number}. */
function _coercePairsToValueMap(array $data): array
{
    // {categories|labels: [...], values: [...]}
    foreach (['categories', 'labels'] as $catKey) {
        if (isset($data[$catKey], $data['values']) && is_array($data[$catKey]) && is_array($data['values'])) {
            $cats = array_values($data[$catKey]);
            $vals = array_values($data['values']);
            $map = [];
            foreach ($cats as $i => $c) {
                $map[(string) $c] = (float) ($vals[$i] ?? 0);
            }
            return $map ?: $data;
        }
    }

    // List of {label,value} or [label,value] pairs.
    $isList = array_keys($data) === range(0, count($data) - 1);
    if ($isList) {
        $map = [];
        foreach ($data as $item) {
            if (!is_array($item)) {
                continue;
            }
            $label = $item['label'] ?? $item['name'] ?? $item[0] ?? null;
            $value = $item['value'] ?? $item['count'] ?? $item[1] ?? null;
            if ($label !== null) {
                $map[(string) $label] = (float) $value;
            }
        }
        return $map ?: $data;
    }

    // Already a {label: value} map — leave as-is.
    return $data;
}

/** Pull "parts" and "shaded" counts out of free text like "divided into 8 equal parts, with 3 shaded" or "3 out of 8". */
function _extractShaded(string $text): array
{
    $parts = 0;
    $shaded = 0;
    if (preg_match('/(\d+)\s*(?:out of|\/)\s*(\d+)/', $text, $m)) {
        $shaded = (int) $m[1];
        $parts  = (int) $m[2];
    } else {
        if (preg_match('/divided\s+into\s+(\d+)/', $text, $m)) {
            $parts = (int) $m[1];
        }
        if ($parts === 0 && preg_match('/(\d+)\s*equal\s*parts?/', $text, $m)) {
            $parts = (int) $m[1];
        }
        if (preg_match('/(\d+)\s*(?:parts?\s*)?(?:are\s*)?shaded/', $text, $m)) {
            $shaded = (int) $m[1];
        }
    }
    if ($parts > 0) {
        $shaded = max(0, min($shaded, $parts));
    }
    return [$parts, $shaded];
}

/** Draw a circle divided into N equal sectors with the first M shaded (fraction figure). */
function renderShadedCircle(int $parts, int $shaded, string $caption): string
{
    $parts = max(1, min($parts, 24));
    $shaded = max(0, min($shaded, $parts));
    $cx = 95; $cy = 95; $r = 82; $w = 190; $h = 190;
    $svg = "<svg width='{$w}' height='{$h}' xmlns='http://www.w3.org/2000/svg' font-family='sans-serif'>";
    if ($parts === 1) {
        $fill = $shaded >= 1 ? '#4472C4' : '#ffffff';
        $svg .= "<circle cx='{$cx}' cy='{$cy}' r='{$r}' fill='{$fill}' stroke='#333' stroke-width='1.4'/>";
    } else {
        $step = 2 * M_PI / $parts;
        $start = -M_PI / 2;
        for ($i = 0; $i < $parts; $i++) {
            $a0 = $start + $i * $step;
            $a1 = $start + ($i + 1) * $step;
            $x0 = round($cx + $r * cos($a0), 2); $y0 = round($cy + $r * sin($a0), 2);
            $x1 = round($cx + $r * cos($a1), 2); $y1 = round($cy + $r * sin($a1), 2);
            $large = $step > M_PI ? 1 : 0;
            $fill = $i < $shaded ? '#4472C4' : '#ffffff';
            $svg .= "<path d='M{$cx},{$cy} L{$x0},{$y0} A{$r},{$r} 0 {$large},1 {$x1},{$y1} Z' fill='{$fill}' stroke='#333' stroke-width='1.2'/>";
        }
    }
    $svg .= "<text x='{$cx}' y='" . ($cy + $r + 22) . "' text-anchor='middle' font-size='13' fill='#333'>{$shaded}/{$parts} shaded</text>";
    $svg .= '</svg>';
    return _diagramWrap($svg, $caption);
}

function _diagramWrap(string $svgOrHtml, string $caption): string
{
    $cap = $caption !== '' ? "<p style='font-size:11px;font-style:italic;margin-top:4px;'>{$caption}</p>" : '';
    return "<div style='margin:12px 0;text-align:center;overflow-x:auto;page-break-inside:avoid;'>{$svgOrHtml}{$cap}</div>";
}
