<?php
declare(strict_types=1);

if (!defined('ABSPATH')) { exit; }

if (!function_exists('impulse_table_richtext_allowed')) {
    /**
     * Allowlist for the OUTER wp_kses() pass around already-sanitized Table
     * cell/header HTML. The cell content is first run through
     * impulse_kses_richtext() (a wp_kses wrapper) inside the closure; this
     * second pass exists so the echoed expression is a wp_kses() call the
     * reviewer's Plugin Check / phpcs recognizes as an escaping function.
     * It is a superset of impulse_kses_richtext()'s allowlist, so the second
     * pass strips nothing and the rendered output is byte-identical.
     */
    function impulse_table_richtext_allowed() {
        static $allowed = null;
        if ($allowed === null) {
            $allowed = [
                'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [], 's' => [], 'strike' => [], 'del' => [],
                'a' => ['href' => [], 'target' => [], 'rel' => []],
                'br' => [], 'span' => ['style' => []],
                'p' => ['style' => [], 'class' => []],
                'div' => ['style' => [], 'class' => []],
                'h1' => ['style' => []], 'h2' => ['style' => []], 'h3' => ['style' => []],
                'h4' => ['style' => []], 'h5' => ['style' => []], 'h6' => ['style' => []],
                'ul' => [], 'ol' => [], 'li' => [],
                'blockquote' => [], 'pre' => [], 'code' => [],
                'hr' => [], 'table' => [], 'thead' => [], 'tbody' => [], 'tr' => [], 'th' => ['style' => []], 'td' => ['style' => []],
                'small' => [], 'mark' => [], 'sup' => [], 'sub' => [],
            ];
        }
        return $allowed;
    }
}

/**
 * Icon list (Table) element renderer (shared).
 *
 * Phase 1, step 17. Internal type name is `table`; user-facing
 * name in the panel is "Table". Supports up to 6 columns, optional
 * leading icon per row, optional zebra striping, optional header row,
 * per-column data-alignment, hover-highlight effects (parallax/tilt
 * via JS, simpler bg/transform/shadow via CSS).
 *
 * Pre-extraction notes:
 *
 *   - Editor enables contenteditable on cells + headers so the user
 *     can type inline. Frontend renders read-only spans.
 *   - Editor uses CSS vars on every styled value (text size, icon
 *     size, colors, gap) for live-preview binding.
 *   - Editor has `.is-empty` placeholder CSS that paints "New item"
 *     in difference-blend white on any cell that the editor JS has
 *     tagged as empty.
 *   - Both compress the legacy `subtle|slide|glow|lift|stroke` hover
 *     effects to `parallax` (the JS-driven path that replaces them).
 *   - The frontend pinned zebra to the icon-list's own id; editor
 *     pinned it to `.impulse-el[data-id="N"]`. Both selectors target
 *     the same DOM. Unified to use the wrapper-id approach in both
 *     contexts (one selector that works regardless of which
 *     renderer's outer wrapper exists).
 */
function impulse_element_render_icon_card_list($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    $elId     = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $isEditor = ($context === 'editor');
    $device   = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';
    $editMode = $isEditor && !empty($GLOBALS['impulse_is_edit_mode']);

    $read = function (string $key, $default) use ($settings, $isEditor) {
        if ($isEditor && function_exists('impulse_get_responsive')) {
            return impulse_get_responsive($settings, $key, $default);
        }
        return $settings[$key] ?? $default;
    };

    $items = $settings['items'] ?? [];
    if (is_string($items)) $items = json_decode($items, true) ?: [];

    $iconSize       = intval($read('icon_size',  20));
    $textSize       = intval($read('text_size',  16));
    $gap            = intval($read('gap',        12));
    $iconGap        = intval($read('icon_gap',   12));
    $iconColor      = (string) ($settings['icon_color'] ?? '#1d2327');
    $textColor      = (string) ($settings['text_color'] ?? '#666666');
    $hoverHighlight = !empty($settings['hover_highlight']);
    $hhEffect       = (string) ($settings['hover_highlight_effect'] ?? 'subtle');
    $hhDuration     = intval($settings['hh_duration'] ?? 200);
    $hhDurSec       = $hhDuration / 1000;
    $staggerItems   = !empty($settings['stagger_items']);
    $entranceAnim   = (string) ($settings['entrance_animation'] ?? 'none');

    $ilCols       = max(1, min(8, intval($settings['columns'] ?? 1)));
    // Highlight one column (1-based; 1 = the label column, 2 = first data
    // column, etc.) with a tinted background + bolder text. Built for the
    // comparison-matrix use case ("our column" stands out). 0 = none.
    $ilHlCol      = intval($settings['highlight_col'] ?? 0);
    $ilHlBg       = (string) ($settings['highlight_bg'] ?? '#E7EEDB');
    $ilHlStyle    = function (int $colIndex) use ($ilHlCol, $ilHlBg) {
        if ($ilHlCol <= 0 || $colIndex !== $ilHlCol) return '';
        return 'background:' . preg_replace('/[^#0-9a-fA-F(),.rgbahsl ]/', '', $ilHlBg)
             . ';font-weight:600;border-radius:8px;padding-top:7px;padding-bottom:7px;padding-left:14px;padding-right:14px;';
    };
    $ilShowIcons  = !isset($settings['show_icons']) || !empty($settings['show_icons']);
    $ilZebra      = !empty($settings['zebra_stripes']);
    $daRaw        = (string) ($settings['data_align'] ?? 'right');
    $ilDataAlign  = in_array($daRaw, ['left','center','right'], true) ? $daRaw : 'right';
    $ilMaxWidth   = intval($settings['section_max_width'] ?? 800);
    $ilBgColor    = trim((string) ($settings['bg_color'] ?? ''));
    // User-controlled wrapper padding (v2.1.1336). Replaces the legacy
    // hardcoded `padding:16px 20px` so designers can tighten or loosen the
    // card without diving into custom CSS. Default 16 matches the old
    // value when a bg color is set; transparent wrappers default to 0 so
    // the table sits flush in its parent like before.
    $_ilPadDefault = ($ilBgColor !== '') ? 16 : 0;
    $ilPadding    = isset($settings['padding']) && $settings['padding'] !== ''
        ? max(0, intval($settings['padding']))
        : $_ilPadDefault;
    $ilShowHeader = !isset($settings['show_header']) || (!empty($settings['show_header']) && $settings['show_header'] !== '0');

    // Compress legacy hover-effect names to canonical 'parallax'.
    if (in_array($hhEffect, ['subtle', 'slide', 'glow', 'lift', 'stroke'], true)) $hhEffect = 'parallax';

    $ulClass = 'impulse-table';
    if ($hoverHighlight) {
        $ulClass .= ' impulse-table-hover impulse-hh-' . esc_attr($hhEffect);
        if (!$isEditor && in_array($hhEffect, ['parallax', 'tilt'], true)) {
            $GLOBALS['impulse_needs_icon_list_hover'] = true;
        }
    }
    if ($staggerItems && $entranceAnim !== 'none') $ulClass .= ' impulse-table-stagger';

    // Stable wrapper id (used by zebra + empty-cell placeholder selectors).
    $ilUlId = 'impulse-il-' . intval($elId);

    // Wrapper container styles.
    $ilMaxStyle = 'width:100%;box-sizing:border-box;';
    if ($ilMaxWidth > 0) {
        $ilMaxStyle .= $isEditor
            ? 'max-width:var(--impulse-section-max-width,' . $ilMaxWidth . 'px);margin:0 auto;'
            : 'max-width:' . $ilMaxWidth . 'px;margin-left:auto;margin-right:auto;';
    }
    if ($ilPadding > 0) {
        $ilMaxStyle .= 'padding:' . $ilPadding . 'px;';
    }
    if ($ilBgColor !== '') {
        $ilMaxStyle .= 'background:' . esc_attr($ilBgColor) . ';border-radius:8px;';
    }

    // ── Editor-only: empty-cell placeholder CSS ──────────────────
    if ($editMode && function_exists('wp_add_inline_style')) {
        $sels = ['#' . $ilUlId . ' [data-col="text"].is-empty::before'];
        for ($_c = 2; $_c <= $ilCols; $_c++) {
            $sels[] = '#' . $ilUlId . ' [data-col="col' . $_c . '"].is-empty::before';
        }
        $sels[] = '#' . $ilUlId . ' [data-header].is-empty::before';
        wp_add_inline_style('instinctor-page-inline',
            implode(',', $sels) . '{content:"New item";color:#fff;mix-blend-mode:difference;pointer-events:none;opacity:0.7}'
        );
    }

    // Zebra stripes — id-targeted selector works in both contexts. The stripe
    // adapts to the band: a subtle dark wash on light backgrounds, a subtle
    // light wash on dark ones, so the striping is visible either way (the old
    // fixed rgba(0,0,0,0.035) vanished on dark sections).
    if ($ilZebra && function_exists('wp_add_inline_style')) {
        $_zSecBg  = (string) ($GLOBALS['impulse_current_section_bg'] ?? ($GLOBALS['impulse_page_bg_color'] ?? '#ffffff'));
        $_zText   = function_exists('impulse_contrast_color') ? impulse_contrast_color($_zSecBg) : '#1d2327';
        $_zDark   = !in_array($_zText, ['#111827', '#1d2327'], true); // light auto-text => dark band
        $_zStripe = $_zDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.035)';
        wp_add_inline_style('instinctor-page-inline', '#' . $ilUlId . ' > li:nth-child(even){background:' . $_zStripe . '}');
    }

    // v2.1.1374: mobile-friendly stacking. On a narrow viewport the old
    // label-flex:2 + N×data-flex:1 row layout squeezes the label into a
    // per-character-wrap sliver (the "Dr/y/Va/n" report). Solution: let the
    // row wrap, give the label its own full-width line, and let the data
    // cells share the row beneath. Headers hide on mobile — their context
    // is encoded by column position and a 1-row-per-item layout doesn't
    // need them.
    //
    // Frontend: emits a @media (max-width:600px) rule.
    // Editor: device tab reloads the iframe, so we branch on $device and
    // emit non-media-query CSS that targets the same selectors directly.
    if (function_exists('wp_add_inline_style') && $ilCols >= 2) {
        $stackSel  = '#' . $ilUlId . ' > li';
        // Descendant (not direct-child) label/data selectors: rows with a
        // url wrap their cells in an <a>, so `li > [data-col]` misses them
        // and the row renders unstacked on phones. The <a> is the flex
        // container in that case, so it gets the wrap rule too. v2.1.1422.
        $labelSel  = $stackSel . ' [data-col="text"]';
        // Headers div carries data-il="<elId>" so it can be reached without
        // walking up the DOM.
        $hdrSel    = '.impulse-table-headers[data-il="' . intval($elId) . '"]';
        // v2.1: mobile comparison = a real 2-column grid. LEFT column = the
        // feature name (spans the row, wraps freely); RIGHT column = each value
        // stacked, with its column header printed inline (data-collabel) so it
        // stays legible on a phone. Headers hide (their context moves inline).
        // Mobile winner chip follows the user's highlight_col (1-based; col1 = the
        // feature/label column, so a value highlight is >= 2). Default to the first
        // value column (col2) when unset, matching the comparison-page convention.
        // This makes the dark "winner" card a real, user-controllable feature rather
        // than a hardcoded assumption about which column is "ours". Sergei 2026-07-02.
        $_hlColM = ($ilHlCol >= 2 && $ilHlCol <= $ilCols) ? $ilHlCol : 2;
        $_whiteParts = [];
        for ($_wc = 2; $_wc <= $ilCols; $_wc++) {
            if ($_wc === $_hlColM) continue;
            $_whiteParts[] = $stackSel . ' .impulse-il-col' . $_wc;
        }
        $_whiteSel = implode(',', $_whiteParts);
        $stackCSS  =
            // Column gap follows the user's Column Gap (icon_gap) so the slider works
            // live on mobile too (editor sets --impulse-icon-gap; frontend bakes the
            // saved value as the fallback). Row gap stays compact for the stacked cards.
            $stackSel . '{display:grid !important;grid-template-columns:minmax(84px,40%) 1fr !important;column-gap:var(--impulse-icon-gap,' . $iconGap . 'px) !important;row-gap:5px !important;align-content:start !important;align-items:start !important}'
          . $stackSel . ' > a{display:contents !important}'
          // Feature label sits top-left in column 1, row 1 (NO row span — a span
          // forced dozens of implicit rows and blew the cell height up). The
          // value cells auto-flow down column 2.
          . $labelSel . '{grid-column:1 !important;grid-row:1 !important;text-align:left !important;font-weight:600;align-self:start !important}'
          . $stackSel . ' .impulse-il-col2,'
          . $stackSel . ' .impulse-il-col3,'
          . $stackSel . ' .impulse-il-col4,'
          . $stackSel . ' .impulse-il-col5,'
          . $stackSel . ' .impulse-il-col6,'
          . $stackSel . ' .impulse-il-col7,'
          . $stackSel . ' .impulse-il-col8{grid-column:2 !important;flex:none !important;min-width:0 !important;text-align:left !important;padding:6px 10px !important;border-radius:6px;box-sizing:border-box !important}'
          // Us vs them, as two distinct cards. The HIGHLIGHTED value column (the
          // user's highlight_col; defaults to the first value column, col2) becomes a
          // solid dark "winner" chip; every other value column is a quiet white card
          // with a hairline border, so it reads as a defined block on the light
          // section instead of a flat transparent gap. Strict greyscale.
          . $stackSel . ' .impulse-il-col' . $_hlColM . '{background:#1d2327 !important;color:#fff !important}'
          . $stackSel . ' .impulse-il-col' . $_hlColM . ' *{color:#fff !important}'
          . ($_whiteSel !== '' ? $_whiteSel . '{background:#fff !important;border:1px solid #e0e0e0 !important;color:#555 !important}' : '')
          . $stackSel . ' [data-collabel]:not([data-collabel=""])::before{content:attr(data-collabel);display:block;font-size:10px;font-weight:700;letter-spacing:0.4px;text-transform:uppercase;opacity:0.55;margin-bottom:2px}'
          . $stackSel . ' .impulse-il-col' . $_hlColM . '[data-collabel]::before{opacity:0.7}'
          . $hdrSel . '{display:none !important}';
        if ($isEditor) {
            if ($device === 'mobile') {
                wp_add_inline_style('instinctor-page-inline', $stackCSS);
            }
        } else {
            wp_add_inline_style('instinctor-page-inline',
                '@media (max-width:600px){' . $stackCSS . '}'
            );
        }
    }

    // Flex weights: label column wider than data columns BY DEFAULT, but each
    // column's width is user-controllable (col_flex_N). In the editor the weight
    // rides a CSS var (--ilcolN, set on .impulse-table-wrapper) so the per-column
    // width slider updates live with no re-render; the frontend bakes the literal.
    $colFlexDefault = function ($c) use ($ilCols) { return ($c === 1 && $ilCols > 1) ? 2 : 1; };
    $colFlexCss = function ($c) use ($settings, $colFlexDefault, $isEditor) {
        $raw = $settings['col_flex_' . $c] ?? '';
        $d   = $colFlexDefault($c);
        $v   = ($raw === '' || !is_numeric($raw)) ? $d : max(1, min(8, (float) $raw));
        $vStr = rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.'); // 2.00 -> 2
        // Editor rides the css_var convention: col_flex_N -> --impulse-col-flex-N
        // (set on the wrapper by the live-preview injector, inherited by cells).
        return $isEditor ? ('var(--impulse-col-flex-' . $c . ',' . $vStr . ')') : $vStr;
    };
    $col1Flex = $colFlexCss(1);

    // ── Compose HTML ─────────────────────────────────────────────
    ob_start();
    echo '<div class="impulse-table-wrapper" style="' . esc_attr($ilMaxStyle) . '">';

    // Header row — only when 2+ columns AND show_header on.
    if ($ilCols >= 2 && $ilShowHeader) {
        $hdrs = [];
        $hasHeaders = false;
        for ($_c = 1; $_c <= $ilCols; $_c++) {
            $h = (string) ($settings['col_header_' . $_c] ?? '');
            $hdrs[$_c] = $h;
            if ($h !== '') $hasHeaders = true;
        }
        if ($hasHeaders || $editMode) {
            $rowHPad   = ($hoverHighlight || $ilZebra) ? 8 : 0;
            $iconGapV  = $isEditor ? 'var(--impulse-icon-gap,' . $iconGap . 'px)' : ($iconGap . 'px');
            $iconSzV   = $isEditor ? 'var(--impulse-icon-size,' . $iconSize . 'px)' : ($iconSize . 'px');
            $textColV  = $isEditor ? 'var(--impulse-text-color,' . esc_attr($textColor) . ')' : esc_attr($textColor);
            $hdrBase   = 'min-width:0;overflow-wrap:break-word;font-size:' . max(11, intval($textSize * 0.8)) . 'px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:' . $textColV . ';opacity:0.85';
            $hdrEdit   = $editMode ? ';outline:none;cursor:text;min-width:20px' : '';
            echo '<div class="impulse-table-headers" data-il="' . intval($elId) . '" style="' . esc_attr('display:flex;align-items:center;gap:' . $iconGapV . ';padding:0 ' . $rowHPad . 'px ' . intval($gap * 0.5) . 'px ' . $rowHPad . 'px;border-bottom:1px solid rgba(128,128,128,0.15);margin-bottom:' . intval($gap * 0.5) . 'px;width:100%;box-sizing:border-box') . '">';
            if ($ilShowIcons) echo '<span style="' . esc_attr('flex-shrink:0;width:' . $iconSzV) . '"></span>';
            // Headers + cells render through impulse_kses_richtext so users
            // can use <br>, <strong>, <em>, <a>, <span style>, etc. inside
            // a Table cell — same allowlist the Text/Flip-box content
            // blocks use. v2.1.1314.
            //
            // Pre-step (v2.1.1317): unescape known-safe literal tags the
            // user typed as plain text. When you type "<br>" into a
            // contenteditable, the DOM serializes innerHTML to "&lt;br&gt;"
            // (the browser escapes < and > because they're TEXT, not a
            // tag). wp_kses then sees "&lt;br&gt;" as a text string and
            // leaves it alone — so the cell renders the literal characters
            // "<br>" instead of a line break. Convert the common typed-as-
            // text inline tags back to real tags BEFORE kses; kses then
            // accepts them through its allowlist. \n → <br> too so paste-
            // from-Notion / paste-from-Notes lands with line breaks.
            $_ilUnescapeSafe = function($s) {
                static $map = null;
                if ($map === null) {
                    $tags = ['br','b','i','u','strong','em','small','mark','sup','sub'];
                    $map = [];
                    foreach ($tags as $t) {
                        $map['&lt;' . $t . '&gt;']        = '<' . $t . '>';
                        $map['&lt;' . $t . '/&gt;']       = '<' . $t . '>';
                        $map['&lt;' . $t . ' /&gt;']      = '<' . $t . '>';
                        $map['&lt;' . strtoupper($t) . '&gt;']  = '<' . $t . '>';
                        $map['&lt;' . strtoupper($t) . '/&gt;'] = '<' . $t . '>';
                        $map['&lt;/' . $t . '&gt;']       = '</' . $t . '>';
                        $map['&lt;/' . strtoupper($t) . '&gt;'] = '</' . $t . '>';
                    }
                }
                $out = strtr((string) $s, $map);
                $out = str_replace(["\r\n", "\n"], '<br>', $out);
                return $out;
            };
            $_ilRichAllowed = impulse_table_richtext_allowed();
            $_ilRich = function($v) use ($_ilUnescapeSafe) {
                $v = $_ilUnescapeSafe($v);
                return function_exists('impulse_kses_richtext') ? impulse_kses_richtext($v) : esc_html($v);
            };
            echo '<span data-header="col_header_1"' . ($editMode ? ' contenteditable="true" spellcheck="false"' : '') . ' style="' . esc_attr($hdrBase . $hdrEdit . ';flex:' . $col1Flex . ';' . $ilHlStyle(1)) . '">' . wp_kses($_ilRich($hdrs[1] ?? ''), $_ilRichAllowed) . '</span>';
            for ($_c = 2; $_c <= $ilCols; $_c++) {
                echo '<span data-header="col_header_' . intval($_c) . '"' . ($editMode ? ' contenteditable="true" spellcheck="false"' : '') . ' style="' . esc_attr($hdrBase . $hdrEdit . ';flex:' . $colFlexCss($_c) . ';text-align:' . $ilDataAlign . ';' . $ilHlStyle($_c)) . '">' . wp_kses($_ilRich($hdrs[$_c] ?? ''), $_ilRichAllowed) . '</span>';
            }
            echo '</div>';
        }
    }

    // List items.
    $gapV     = $isEditor ? 'var(--impulse-gap,' . $gap . 'px)' : ($gap . 'px');
    $iconGapV = $isEditor ? 'var(--impulse-icon-gap,' . $iconGap . 'px)' : ($iconGap . 'px');
    echo '<ul id="' . esc_attr($ilUlId) . '" class="' . esc_attr($ulClass) . '" style="' . esc_attr('display:flex;flex-direction:column;gap:' . $gapV . ';list-style:none;margin:0;padding:0;width:100%') . '">';

    // v2.1.1374: dropped `word-break:break-word` — it forced per-character wrap
    // ("Dr/y/Va/n") when a label cell got squeezed by aggressive flex sizing.
    // `overflow-wrap:break-word` alone breaks only when a word truly can't fit,
    // which is the right behaviour for table labels.
    $cellBase = 'min-width:0;overflow-wrap:break-word;';
    $textSizeV  = $isEditor ? 'var(--impulse-text-size,' . $textSize . 'px)' : ($textSize . 'px');
    $textColorV = $isEditor ? 'var(--impulse-text-color,' . esc_attr($textColor) . ')' : esc_attr($textColor);
    $iconSzV    = $isEditor ? 'var(--impulse-icon-size,' . $iconSize . 'px)' : ($iconSize . 'px');
    $iconColV   = $isEditor ? 'var(--impulse-icon-color,' . esc_attr($iconColor) . ')' : esc_attr($iconColor);

    foreach ($items as $idx => $item) {
        $iconName = (string) ($item['icon'] ?? 'circle');
        $text     = (string) ($item['text'] ?? '');
        $url      = (string) ($item['url']  ?? '');
        $staggerStyle = ($staggerItems && $entranceAnim !== 'none') ? 'animation-delay:' . ($idx * 100) . 'ms;' : '';

        $liStyle = 'display:flex;align-items:center;gap:' . $iconGapV . ';width:100%;box-sizing:border-box;';
        if ($hoverHighlight || $ilZebra) $liStyle .= 'padding:6px 8px;border-radius:4px;';
        if ($hoverHighlight) $liStyle .= 'transition:background ' . $hhDurSec . 's ease-in-out,transform ' . $hhDurSec . 's ease-in-out,box-shadow ' . $hhDurSec . 's ease-in-out;';
        if ($staggerStyle !== '') $liStyle .= $staggerStyle;

        // impulse-table-item marks each row as a decompose unit so 3D reveals split
        // the table per-row (like grid cells), not as one flat slab. Both reveal
        // systems key on this class: overlay-fx outermostCells() and the snap
        // screen-decompose DECOMPOSE_SUBS['table']. Sergei 2026-07-02.
        echo '<li class="impulse-table-item" style="' . esc_attr($liStyle) . '" data-item-idx="' . esc_attr((string) $idx) . '">';
        if ($url !== '' && !$editMode) {
            echo '<a href="' . esc_url($url) . '" style="' . esc_attr('display:flex;align-items:center;gap:' . $iconGapV . ';text-decoration:none;color:inherit;width:100%;min-width:0') . '">';
        }

        if ($ilShowIcons && function_exists('impulse_lucide_svg')) {
            echo '<span style="' . esc_attr('flex-shrink:0;color:' . $iconColV . ';display:flex;align-items:center;justify-content:center;width:' . $iconSzV . ';height:' . $iconSzV) . '">'
               . wp_kses(impulse_lucide_svg($iconName, $iconSize, 'currentColor'), impulse_svg_allowed())
               . '</span>';
        }

        $cellStyleBase = $cellBase . 'color:' . $textColorV . ';font-size:' . $textSizeV . ';line-height:1.5;';
        $textStyle = $cellStyleBase . 'flex:' . $col1Flex . ';';
        // v2.1.1374: always emit data-col="text" so the mobile-stack CSS can
        // target the label cell on the frontend too (previously the attr only
        // existed in edit mode).
        if ($editMode) $textStyle .= 'outline:none;cursor:text;';
        $textStyle .= $ilHlStyle(1);
        // Same pre-step as headers: unescape typed-as-text safe tags +
        // newline → <br> before kses. v2.1.1317.
        $_ilUnescapeSafe = function($s) {
            static $map = null;
            if ($map === null) {
                $tags = ['br','b','i','u','strong','em','small','mark','sup','sub'];
                $map = [];
                foreach ($tags as $t) {
                    $map['&lt;' . $t . '&gt;']  = '<' . $t . '>';
                    $map['&lt;' . $t . '/&gt;'] = '<' . $t . '>';
                    $map['&lt;' . $t . ' /&gt;'] = '<' . $t . '>';
                    $map['&lt;' . strtoupper($t) . '&gt;']  = '<' . $t . '>';
                    $map['&lt;' . strtoupper($t) . '/&gt;'] = '<' . $t . '>';
                    $map['&lt;/' . $t . '&gt;']  = '</' . $t . '>';
                    $map['&lt;/' . strtoupper($t) . '&gt;'] = '</' . $t . '>';
                }
            }
            $out = strtr((string) $s, $map);
            $out = str_replace(["\r\n", "\n"], '<br>', $out);
            return $out;
        };
        // Strip whitespace from the EDGES of a cell, including the kinds that do
        // not collapse. Copy pasted out of a word processor arrives padded with
        // non-breaking spaces: aokarate's Tuesday "Class" cell carried ~30 of them
        // after the text. A regular trailing space collapses and costs nothing, but
        // U+00A0 keeps its width and cannot break, so the run wrapped onto a line of
        // its own. That cell then measured THREE line boxes against the Time cell's
        // two, and because cells are vertically centred, the two columns stopped
        // lining up: the row looked broken with nothing visibly in it (2026-08-14).
        // A trailing <br> does the same thing with an empty line box.
        //
        // Edges only. Runs INSIDE a cell are deliberate spacing and stay untouched.
        // Applied in the editor as well as the frontend so the two cannot drift.
        // ~ delimiters: the pattern contains the / of <br/>, which would close a
        // /.../ delimiter and make the rest of it garbage.
        $_ilTrimEdge = function($v) {
            $sp = '(?:\s|\x{00A0}|&nbsp;|&#0*160;|&#[xX]0*A0;)';   // a space of any kind
            $ws = '(?:' . $sp . '|<br\s*/?>)+';                     // ...or a line break
            $v  = (string) $v;
            $v = preg_replace('~^' . $ws . '~u', '', $v);
            $v = preg_replace('~' . $ws . '$~u', '', $v);
            // Also drop spaces hugging an explicit <br>. They are trailing
            // whitespace for THAT line, so on a narrow column they wrap exactly the
            // same way the cell-trailing run did - the same bug, just at a phone
            // width. Whitespace adjacent to a deliberate line break never carries
            // meaning.
            $v = preg_replace('~' . $sp . '*(<br\s*/?>)' . $sp . '*~u', '$1', $v);
            return $v;
        };
        $_ilRichCell = function($v) use ($_ilUnescapeSafe, $_ilTrimEdge) {
            $v = $_ilTrimEdge($_ilUnescapeSafe($v));
            return function_exists('impulse_kses_richtext') ? impulse_kses_richtext($v) : esc_html($v);
        };
        $_ilRichCellAllowed = impulse_table_richtext_allowed();
        echo '<span data-col="text"' . ($editMode ? ' contenteditable="true" spellcheck="false"' : '') . ' style="' . esc_attr($textStyle) . '">' . wp_kses($_ilRichCell($text), $_ilRichCellAllowed) . '</span>';

        for ($_c = 2; $_c <= $ilCols; $_c++) {
            $colKey = 'col' . $_c;
            $colVal = (string) ($item[$colKey] ?? '');
            $colStyle = $cellStyleBase . 'flex:' . $colFlexCss($_c) . ';text-align:' . $ilDataAlign . ';';
            if ($editMode) $colStyle .= 'outline:none;cursor:text;';
            $colStyle .= $ilHlStyle($_c);
            // Column header carried on the cell so the mobile stack CSS can print
            // it inline above the value (see $stackCSS ::before rule).
            //
            // DECODE entities before esc_attr. The label is shown via CSS
            // content:attr(data-collabel), and CSS attr() prints the attribute's
            // TEXT verbatim — it does not decode HTML entities. col_header comes from
            // the contenteditable header, which stores a trailing space as the literal
            // string "&nbsp;" (and "&" as "&amp;"). Without decoding: esc_attr turns
            // "0-100 mi&nbsp;" into the attribute "0-100 mi&amp;nbsp;", the HTML parser
            // gives the DOM value "0-100 mi&nbsp;", and CSS prints "0-100 MI&NBSP;"
            // literally. Decoding first turns "&nbsp;" into a real U+00A0 (a space
            // esc_attr leaves untouched) and "&amp;" back into "&" (which esc_attr
            // re-escapes correctly for the attribute). The visible header span above
            // renders through wp_kses and was already fine; only this data-attribute
            // path double-showed the entity. Sergei 2026-07-17.
            $_colLabel = trim(html_entity_decode(
                wp_strip_all_tags((string) ($settings['col_header_' . $_c] ?? '')),
                ENT_QUOTES | ENT_HTML5,
                'UTF-8'
            ));
            echo '<span class="impulse-il-col' . intval($_c) . '" data-collabel="' . esc_attr($_colLabel) . '"' . ($editMode ? ' contenteditable="true" data-col="' . esc_attr($colKey) . '" spellcheck="false"' : '') . ' style="' . esc_attr($colStyle) . '">' . wp_kses($_ilRichCell($colVal), $_ilRichCellAllowed) . '</span>';
        }

        if ($url !== '' && !$editMode) echo '</a>';
        echo '</li>';
    }
    echo '</ul></div>';
    return ob_get_clean();
}
