<?php
declare(strict_types=1);

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

/**
 * Cell Grid element renderer (shared between frontend and editor).
 *
 * Phase 1, step 7 — first of the structural-container extractions.
 * Cell Grid is a multi-row × multi-column flex grid where each cell
 * holds children that were tagged with `_cell_row` / `_cell_col` on
 * save. The editor adds editor-only UX (cell selection class, drag-
 * handle dividers between cells, empty-cell tint + plus indicator).
 *
 * Pre-extraction differences and resolutions:
 *
 *   - **Responsive reads**: editor used `impulse_get_responsive()`
 *     to resolve per-device values. Frontend read direct + emitted
 *     @media for tablet/mobile.
 *     → Preserved per-context. Editor pre-resolves; frontend reads
 *     raw + emits responsive CSS.
 *
 *   - **CSS variables**: frontend emits `var(--impulse-row-gap,Npx)`
 *     etc. for live-update binding; editor used raw values pre-
 *     extraction (no live update on container — only on individual
 *     element panels).
 *     → Preserved: frontend uses vars on container (allows panel
 *     live-update); editor emits literal values.
 *
 *   - **Container bg_image**: editor still honoured it (legacy);
 *     frontend dropped it 2026-05-04 ("grid-level bg_color/image
 *     removed"). Per-cell bg only on both.
 *     → Unified to frontend's stance: container bg_image dropped
 *     in both contexts. If any saved page still carries it, the
 *     setting is silently ignored. Per-cell bg_image still works.
 *
 *   - **Selected-cells / dividers / empty indicator**: editor-only
 *     UX. Selection highlights the cell border, dividers let the
 *     user drag column widths, the plus indicator marks empty cells.
 *     → Gated on $isEditor.
 *
 *   - **Cell URL**: editor emits `<div>` with `data-url` for safety
 *     while clicking around in the builder. Frontend emits `<a>`.
 *     → Preserved per-context.
 *
 *   - **Mobile layout**: editor resolves the user-selected layout
 *     ('stack'/'2col'/'keep') based on the device tab and applies
 *     it inline. Frontend emits @media queries so the user-selected
 *     layout fires on real mobile.
 *     → Preserved per-context.
 *
 *   - **Tablet wrap fallback**: frontend auto-wraps >2-cell rows
 *     to 2-per-row on tablet UNLESS the user has set
 *     `mobile_layout_tablet`. Editor doesn't (editor never had this
 *     fallback — visible mismatch under "tablet" device tab).
 *     → Preserved frontend-only behaviour. The editor's tablet tab
 *     could re-emit this for parity in a future pass.
 *
 *   - **Entrance animation**: frontend applies via
 *     `impulse_get_animation_attrs($settings)`. Editor doesn't (would
 *     be distracting while editing).
 *     → Frontend-only.
 *
 *   - **Visibility classes**: frontend emits hide-mobile / hide-
 *     tablet. Editor doesn't (responsive preview already filters).
 *     → Frontend-only.
 *
 *   - **3D depth + cell tilt**: both honour depth_intensity /
 *     tilt_intensity equally — `data-depth-intensity` +
 *     `data-tilt-intensity` on the container, and per-cell
 *     `data-depth-row` / `data-depth-col` when depth_intensity > 0.
 *     Editor pre-resolves per-device.
 *
 *   - **Background shapes layer**: both emit
 *     `impulse_render_bg_shapes($settings, $device, $isEditor)`.
 *
 *   - **Default vertical alignment**: BOTH versions agreed: 1 child
 *     → center, 2+ children → top. This was a 2026-05-x fix where
 *     the renderers had drifted (frontend defaulted center for all,
 *     editor center). Both now match — and the new shared function
 *     of course matches identically.
 *
 * Children are passed in pre-collected (the caller has them as
 * `$el['children']` in frontend, `$el->children` in editor). The
 * renderer doesn't know how to traverse the tree itself — it relies
 * on the caller's render-element function for recursion.
 *
 * @param array|object $el       Element node.
 * @param array        $settings Resolved or raw settings.
 * @param array        $children Pre-collected child elements (array form).
 * @param string       $context  'frontend' or 'editor'.
 * @param callable     $renderChild  Function to render a single child.
 *                                   Frontend passes `impulse_render_frontend_element`,
 *                                   editor passes `impulse_render_element`. Both
 *                                   accept a single $el argument; signature is
 *                                   not type-hinted strict to keep mixed array/
 *                                   object child shapes compatible.
 *
 * @return string Inner HTML.
 *
 * @sideeffect Primes $GLOBALS['impulse_needs_cell_depth'] in both contexts
 *             so the cell-depth JS asset gets enqueued. Frontend additionally
 *             calls wp_add_inline_style() for responsive @media CSS.
 */
function impulse_element_render_rows($el, array $settings, array $children, string $context, callable $renderChild): 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';

    // ── Settings (responsive resolution differs per context) ──────
    // Editor pre-resolves via impulse_get_responsive (per-device).
    // Frontend reads desktop direct and emits @media at the end.
    $read = function (string $key, $default) use ($settings, $isEditor, $device) {
        if ($isEditor && function_exists('impulse_get_responsive')) {
            return impulse_get_responsive($settings, $key, $default);
        }
        return $settings[$key] ?? $default;
    };

    $gridRows = $settings['rows'] ?? [
        ['widths' => [33.33, 33.33, 33.34], 'cells' => [[], [], []]],
        ['widths' => [33.33, 33.33, 33.34], 'cells' => [[], [], []]],
    ];
    if (is_string($gridRows)) {
        $gridRows = json_decode($gridRows, true) ?: [];
    }

    $rowGap          = intval($read('row_gap', 16));
    $cellGap         = intval($read('cell_gap', 16));
    $rowHeight       = intval($read('row_height', 120));
    $cellRadius      = intval($read('cell_radius', 0));
    $paddingSides    = intval($read('padding_sides', 0));
    $paddingTop      = intval($read('padding_top', 0));
    $paddingBottom   = intval($read('padding_bottom', 0));
    $containerRadius = intval($read('border_radius', 0));
    $minHeight       = intval($read('min_height', 0));
    $marginTop       = intval($read('margin_top', 0));
    $marginBottom    = intval($read('margin_bottom', 0));

    // Editor only: cell selection set (highlight ring while clicking).
    $selectedCells = $settings['_selected_cells'] ?? [];
    if (is_string($selectedCells)) $selectedCells = json_decode($selectedCells, true) ?: [];
    if (!is_array($selectedCells)) $selectedCells = [];

    // ── Container styles ─────────────────────────────────────────
    // Frontend uses CSS vars so panel live-updates bind without
    // re-rendering; editor uses literals (live-update wired via
    // its own element-level binding).
    if ($isEditor) {
        $containerStyle = 'display:flex;flex-direction:column;gap:' . $rowGap . 'px;';
    } else {
        $containerStyle = 'width:100%;display:flex;flex-direction:column;gap:var(--impulse-row-gap,' . $rowGap . 'px);';
    }
    if ($minHeight > 0)    $containerStyle .= 'min-height:' . $minHeight . 'px;';
    if ($marginTop > 0)    $containerStyle .= 'margin-top:' . $marginTop . 'px;';
    if ($marginBottom > 0) $containerStyle .= 'margin-bottom:' . $marginBottom . 'px;';

    $contentWidth = function_exists('impulse_resolve_content_width')
        ? impulse_resolve_content_width($settings)
        : '100%';
    $containerStyle .= 'margin-left:auto;margin-right:auto;max-width:' . $contentWidth . ';';

    // Grid-level bg_color/image dropped 2026-05-04 — per-cell only.
    if ($paddingTop > 0 || $paddingSides > 0 || $paddingBottom > 0) {
        if ($isEditor) {
            $containerStyle .= 'padding:' . $paddingTop . 'px ' . $paddingSides . 'px ' . $paddingBottom . 'px ' . $paddingSides . 'px;';
        } else {
            $containerStyle .= 'padding:var(--impulse-padding-top,' . $paddingTop . 'px) var(--impulse-padding-sides,' . $paddingSides . 'px) var(--impulse-padding-bottom,' . $paddingBottom . 'px) var(--impulse-padding-sides,' . $paddingSides . 'px);';
        }
    }
    if ($containerRadius > 0) {
        if ($isEditor) {
            $containerStyle .= 'border-radius:' . $containerRadius . 'px;overflow:hidden;';
        } else {
            $containerStyle .= 'border-radius:var(--impulse-border-radius,' . $containerRadius . 'px);overflow:hidden;';
        }
    }

    // Container border (4-sided).
    $cbW = intval($settings['container_border_width'] ?? 0);
    if ($cbW > 0) {
        $cbColor = (string) ($settings['container_border_color'] ?? '#e0e0e0');
        $cbT = (($settings['container_border_top']    ?? null) === '0' || ($settings['container_border_top']    ?? null) === 0 || ($settings['container_border_top']    ?? null) === false) ? '0' : $cbW . 'px';
        $cbR = (($settings['container_border_right']  ?? null) === '0' || ($settings['container_border_right']  ?? null) === 0 || ($settings['container_border_right']  ?? null) === false) ? '0' : $cbW . 'px';
        $cbB = (($settings['container_border_bottom'] ?? null) === '0' || ($settings['container_border_bottom'] ?? null) === 0 || ($settings['container_border_bottom'] ?? null) === false) ? '0' : $cbW . 'px';
        $cbL = (($settings['container_border_left']   ?? null) === '0' || ($settings['container_border_left']   ?? null) === 0 || ($settings['container_border_left']   ?? null) === false) ? '0' : $cbW . 'px';
        $containerStyle .= 'border-width:' . $cbT . ' ' . $cbR . ' ' . $cbB . ' ' . $cbL . ';border-style:solid;border-color:' . esc_attr($cbColor) . ';';
    }

    // ── Animation + visibility (frontend only) ────────────────────
    $animAttrs = (!$isEditor && function_exists('impulse_get_animation_attrs'))
        ? impulse_get_animation_attrs($settings)
        : ['class' => '', 'style' => ''];
    if (!empty($animAttrs['style'])) $containerStyle .= $animAttrs['style'];

    $containerClass = 'impulse-rows';
    if (!$isEditor && function_exists('impulse_get_visibility_classes')) {
        $vc = impulse_get_visibility_classes($settings);
        if ($vc !== '') $containerClass .= ' ' . $vc;
    }
    if (!empty($animAttrs['class'])) $containerClass .= ' ' . $animAttrs['class'];

    // ── Cell border (global, for cells without their own) ─────────
    $cellBorderWidth = intval($settings['cell_border_width'] ?? 0);
    $cellBorderColor = (string) ($settings['cell_border_color'] ?? '#e0e0e0');
    $celltVal = $settings['cell_border_top']    ?? null;
    $cellrVal = $settings['cell_border_right']  ?? null;
    $cellbVal = $settings['cell_border_bottom'] ?? null;
    $celllVal = $settings['cell_border_left']   ?? null;
    $cellBorderTop    = !($celltVal === '0' || $celltVal === 0 || $celltVal === false);
    $cellBorderRight  = !($cellrVal === '0' || $cellrVal === 0 || $cellrVal === false);
    $cellBorderBottom = !($cellbVal === '0' || $cellbVal === 0 || $cellbVal === false);
    $cellBorderLeft   = !($celllVal === '0' || $celllVal === 0 || $celllVal === false);

    // ── Group children by cellKey ────────────────────────────────
    // Child settings may be array (frontend) or object (editor),
    // and child itself may be array or object. Normalize on read.
    $childrenMap = [];
    foreach ($children as $child) {
        $cs = is_object($child) ? ((array) ($child->settings ?? new stdClass())) : ($child['settings'] ?? []);
        if (is_object($cs)) $cs = (array) $cs;
        $key = ($cs['_cell_row'] ?? 0) . '-' . ($cs['_cell_col'] ?? 0);
        if (!isset($childrenMap[$key])) $childrenMap[$key] = [];
        $childrenMap[$key][] = $child;
    }

    // ── Mobile layout ────────────────────────────────────────────
    $mobileLayout = (string) ($settings['mobile_layout'] ?? 'stack');
    $isMobileStack = ($isEditor && $device === 'mobile' && $mobileLayout === 'stack');
    $isMobile2col  = ($isEditor && $device === 'mobile' && $mobileLayout === '2col');

    $cellGridId = 'impulse-cg-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($elId ?: uniqid()));
    $bgCellSelectors = []; // mobile min-height tracking for editor

    // ── Frontend tablet wrap fallback ─────────────────────────────
    if (!$isEditor && function_exists('wp_add_inline_style')) {
        $maxCellsInRow = 0;
        foreach ($gridRows as $r) {
            $maxCellsInRow = max($maxCellsInRow, count($r['widths'] ?? [100]));
        }
        if ($maxCellsInRow > 2 && !isset($settings['mobile_layout_tablet'])) {
            wp_add_inline_style(
                'instinctor-page-inline',
                '@media(max-width:1024px)and(min-width:768px){#' . $cellGridId . ' .impulse-rows-row{flex-wrap:wrap!important;height:auto!important}#' . $cellGridId . ' .impulse-rows-cell{flex:0 0 calc(50% - ' . intval($cellGap / 2) . 'px)!important;max-width:calc(50% - ' . intval($cellGap / 2) . 'px)!important;height:auto!important;min-height:80px!important}}'
            );
        }
        if ($mobileLayout !== 'keep') {
            ob_start();
            if ($mobileLayout === 'stack') {
                echo '@media(max-width:767px){#' . esc_html($cellGridId) . ' .impulse-rows-row{flex-direction:column!important;height:auto!important}#' . esc_html($cellGridId) . ' .impulse-rows-cell{flex:1 1 100%!important}}';
            } elseif ($mobileLayout === '2col') {
                echo '@media(max-width:767px){#' . esc_html($cellGridId) . ' .impulse-rows-row{flex-wrap:wrap!important;height:auto!important}#' . esc_html($cellGridId) . ' .impulse-rows-cell{flex:0 0 calc(50% - ' . intval($cellGap / 2) . 'px)!important;max-width:calc(50% - ' . intval($cellGap / 2) . 'px)!important;height:auto!important;min-height:80px!important}}';
            }
            $css = ob_get_clean();
            if ($css) wp_add_inline_style('instinctor-page-inline', $css);
        }
    }

    // ── 3D depth + cell tilt ─────────────────────────────────────
    $depthIntensity = max(0, min(100, intval($read('depth_intensity', 0))));
    $tiltIntensity  = max(0, min(100, intval($read('tilt_intensity',  0))));
    $GLOBALS['impulse_needs_cell_depth'] = true;

    // useFillWrapper is set by the outer render function in screen
    // mode; if available in globals on the frontend, apply height-fill.
    if (!$isEditor && !empty($GLOBALS['impulse_use_fill_wrapper'])) {
        $containerStyle .= 'height:100%;display:flex;flex-direction:column;';
    }

    // ── Compose HTML ─────────────────────────────────────────────
    $hasShapes = !empty($settings['bg_shapes']) && $settings['bg_shapes'] !== '[]';
    $outerStyle = 'position:relative;' . $containerStyle . ($hasShapes ? 'overflow:hidden;' : '');
    ob_start();
    echo '<div id="' . esc_attr($cellGridId) . '" class="' . esc_attr($containerClass) . '"';
    // Editor needs data-rows for cell-resize JS (drag handles).
    if ($isEditor) echo ' data-rows=\'' . esc_attr(wp_json_encode($gridRows)) . '\'';
    echo ' style="' . esc_attr($outerStyle) . '"';
    echo ' data-depth-intensity="' . esc_attr((string) $depthIntensity) . '" data-tilt-intensity="' . esc_attr((string) $tiltIntensity) . '">';

    // Background shapes layer.
    if (function_exists('impulse_render_bg_shapes')) {
        impulse_render_bg_shapes($settings, $device, $isEditor);
    }
    if ($isEditor && function_exists('impulse_visibility_badge')) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- impulse_visibility_badge() composes escaped markup internally.
        echo wp_kses(impulse_visibility_badge($settings), impulse_svg_allowed());
    }

    // ── Rows / cells ─────────────────────────────────────────────
    foreach ($gridRows as $rowIdx => $row) {
        $widths   = $row['widths'] ?? [100];
        $cells    = $row['cells']  ?? [[]];
        $numCells = count($widths);
        $thisRowH = isset($row['row_height']) ? intval($row['row_height']) : $rowHeight;

        $rowStyle = 'display:flex;gap:' . $cellGap . 'px;position:relative;z-index:3;';
        if ($isMobileStack) {
            $rowStyle .= 'flex-direction:column;height:auto;';
        } elseif ($isMobile2col) {
            $rowStyle .= 'flex-wrap:wrap;min-height:' . $thisRowH . 'px;';
        } else {
            $rowStyle .= 'min-height:' . $thisRowH . 'px;flex:1;';
        }
        echo '<div class="impulse-rows-row"' . ($isEditor ? ' data-row="' . esc_attr((string) $rowIdx) . '"' : '') . ' style="' . esc_attr($rowStyle) . '">';

        foreach ($widths as $colIdx => $width) {
            $cellData = $cells[$colIdx] ?? [];
            $cellKey  = $rowIdx . '-' . $colIdx;
            $flexGrow = round($width, 2);
            $hasChildren = !empty($childrenMap[$cellKey]);

            $cellPadT = intval($cellData['padding_top']    ?? 0);
            $cellPadS = intval($cellData['padding_sides']  ?? $cellData['padding_left'] ?? 0);
            $cellPadB = intval($cellData['padding_bottom'] ?? 0);

            // Unset valign defaults to TOP (Sergei 2026-07-04): content in a
            // cell taller than its content sits at the top instead of centering.
            // Matches the editor default (admin-core.js) + grid/columns. Explicit
            // center/bottom is still honoured.
            $cellValign = array_key_exists('valign', $cellData)
                ? (string) $cellData['valign']
                : 'top';
            $cellHalign = (string) ($cellData['halign'] ?? 'center');
            // 'between' = space-between: edge children flush, middle children spread.
            $valignMap  = ['top' => 'flex-start', 'center' => 'center', 'bottom' => 'flex-end', 'between' => 'space-between'];
            $halignMap  = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
            $cellJustify    = $valignMap[$cellValign] ?? 'flex-start';
            $cellAlignItems = $halignMap[$cellHalign] ?? 'center';

            $cellStyle = 'position:relative;display:flex;flex-direction:column;';
            // Editor uses overflow:hidden universally so cell-overlay clips
            // properly; frontend was minor diverged — unify on overflow:hidden
            // when borders/radius would clip anyway. Conservative: keep as-was.
            if ($isEditor) $cellStyle .= 'overflow:hidden;';

            if ($isMobileStack) {
                $cellStyle .= 'flex:1 1 100%;min-height:0;';
            } elseif ($isMobile2col) {
                $cellStyle .= 'flex:0 0 calc(50% - ' . ($cellGap / 2) . 'px);min-height:' . $thisRowH . 'px;';
            } else {
                $cellStyle .= 'flex:' . $flexGrow . ' 1 0%;min-height:' . $thisRowH . 'px;';
            }
            if ($cellRadius > 0) {
                $cellStyle .= 'border-radius:' . $cellRadius . 'px;';
                if (!$isEditor) $cellStyle .= 'overflow:hidden;';
            }
            if ($cellPadT || $cellPadS || $cellPadB) {
                $cellStyle .= 'padding:' . $cellPadT . 'px ' . $cellPadS . 'px ' . $cellPadB . 'px ' . $cellPadS . 'px;';
            }
            if (!empty($cellData['bg_color']) && $cellData['bg_color'] !== 'transparent') {
                $cellStyle .= 'background-color:' . esc_attr((string) $cellData['bg_color']) . ';';
            }
            if (!empty($cellData['bg_image'])) {
                $bgFit = (string) ($cellData['bg_fit']      ?? 'cover');
                $bgPos = (string) ($cellData['bg_position'] ?? 'center');
                $cellStyle .= 'background-image:url(' . esc_url((string) $cellData['bg_image']) . ');background-size:' . esc_attr($bgFit) . ';background-position:' . esc_attr($bgPos) . ';background-repeat:no-repeat;';
            }

            // Container-level frosted glass — one checkbox on the Rows
            // block frosts every cell (moved up from per-cell, Sergei
            // 2026-06-10). Same preset as Grid cells (grid.php) and compact
            // sections: translucent white + backdrop blur. Emitted AFTER
            // bg_color so the frosted background-color wins over a solid
            // cell bg; a bg gradient/image still shows through the
            // translucent layer for a tinted frost.
            if (!empty($settings['frosted_glass']) && $settings['frosted_glass'] !== '0') {
                $cellStyle .= 'background-color:rgba(255,255,255,0.18);';
                $cellStyle .= 'backdrop-filter:blur(20px) saturate(180%) brightness(1.15);';
                $cellStyle .= '-webkit-backdrop-filter:blur(20px) saturate(180%) brightness(1.15);';
            }

            // Per-cell border (overrides global if width > 0).
            $thisCellBorderW = intval($cellData['border_width'] ?? 0);
            if ($thisCellBorderW > 0) {
                $thisCellBorderColor = (string) ($cellData['border_color'] ?? '#e0e0e0');
                $T = (($cellData['border_top']    ?? null) === '0' || ($cellData['border_top']    ?? null) === 0 || ($cellData['border_top']    ?? null) === false) ? '0' : $thisCellBorderW . 'px';
                $R = (($cellData['border_right']  ?? null) === '0' || ($cellData['border_right']  ?? null) === 0 || ($cellData['border_right']  ?? null) === false) ? '0' : $thisCellBorderW . 'px';
                $B = (($cellData['border_bottom'] ?? null) === '0' || ($cellData['border_bottom'] ?? null) === 0 || ($cellData['border_bottom'] ?? null) === false) ? '0' : $thisCellBorderW . 'px';
                $L = (($cellData['border_left']   ?? null) === '0' || ($cellData['border_left']   ?? null) === 0 || ($cellData['border_left']   ?? null) === false) ? '0' : $thisCellBorderW . 'px';
                $cellStyle .= 'border-width:' . $T . ' ' . $R . ' ' . $B . ' ' . $L . ';border-style:solid;border-color:' . esc_attr($thisCellBorderColor) . ';';
            } elseif ($cellBorderWidth > 0) {
                $T = $cellBorderTop    ? $cellBorderWidth . 'px' : '0';
                $R = $cellBorderRight  ? $cellBorderWidth . 'px' : '0';
                $B = $cellBorderBottom ? $cellBorderWidth . 'px' : '0';
                $L = $cellBorderLeft   ? $cellBorderWidth . 'px' : '0';
                $cellStyle .= 'border-width:' . $T . ' ' . $R . ' ' . $B . ' ' . $L . ';border-style:solid;border-color:' . esc_attr($cellBorderColor) . ';';
            }

            $cellClass = 'impulse-rows-cell';
            $hasCellBg = (!empty($cellData['bg_color']) && $cellData['bg_color'] !== 'transparent') || !empty($cellData['bg_image']);

            // Editor-only cell extras.
            $hoverDuration   = isset($cellData['hover_duration']) ? intval($cellData['hover_duration']) : 300;
            $transitionTime  = ($hoverDuration / 1000) . 's';
            $cellUrl         = (string) ($cellData['url'] ?? '');

            if ($isEditor) {
                if (in_array($cellKey, $selectedCells, true)) $cellClass .= ' selected';
                if (!$hasChildren && !$hasCellBg && !empty($GLOBALS['impulse_is_edit_mode'])) {
                    // Marker class instead of inline tint so the `:has(.impulse-el)`
                    // catch-all in impulse-preview.php can strip the tint when content
                    // lands without touching user-set per-cell backgrounds. The actual
                    // tint colour is painted by an editor CSS rule keyed on this class.
                    $cellClass .= ' impulse-rows-cell-empty';
                }
                if ($hasCellBg && !$hasChildren) {
                    $bgCellSelectors[] = '[data-row="' . esc_attr((string) $rowIdx) . '"][data-col="' . esc_attr((string) $colIdx) . '"]';
                }
            }

            echo '<div class="' . esc_attr($cellClass) . '"';
            if ($isEditor) {
                if (!empty($cellData['hover_overlay']))  echo ' data-hover-overlay="' . esc_attr((string) $cellData['hover_overlay']) . '"';
                if (!empty($cellData['overlay_color'])) echo ' data-overlay-color="' . esc_attr((string) $cellData['overlay_color']) . '"';
                echo ' data-hover-duration="' . esc_attr((string) $hoverDuration) . '"';
                echo ' data-valign="' . esc_attr($cellValign) . '"';
                if ($cellUrl !== '') echo ' data-url="' . esc_attr($cellUrl) . '"';
                echo ' data-row="' . esc_attr((string) $rowIdx) . '" data-col="' . esc_attr((string) $colIdx) . '"';
            }
            if ($depthIntensity > 0) echo ' data-depth-row="' . esc_attr((string) $rowIdx) . '" data-depth-col="' . esc_attr((string) $colIdx) . '"';
            echo ' style="' . esc_attr($cellStyle) . '">';

            // Cell overlay layer.
            if (!empty($cellData['overlay_color'])) {
                $opacityClause = $isEditor ? 'opacity:0.5;' : ''; // frontend uses CSS rules for hover-overlay alpha
                echo '<div class="impulse-cell-overlay" style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:' . esc_attr((string) $cellData['overlay_color']) . ';' . esc_attr($opacityClause) . 'pointer-events:none;z-index:0;transition:background-color ' . esc_attr($transitionTime) . ';"></div>';
            } elseif ($isEditor && !empty($cellData['hover_overlay'])) {
                // Editor placeholder so hover preview has a target.
                echo '<div class="impulse-cell-overlay" style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:transparent;pointer-events:none;z-index:0;transition:background-color ' . esc_attr($transitionTime) . ';"></div>';
            }

            // Cell content (children).
            $cgGap = intval($settings['content_gap'] ?? 0);
            $contentStyle = 'position:relative;z-index:1;flex:1;display:flex;flex-direction:column;justify-content:' . $cellJustify . ';align-items:' . $cellAlignItems . ';';
            if ($cgGap > 0) $contentStyle .= 'gap:' . $cgGap . 'px;';

            $useLink = (!$isEditor && $cellUrl !== '');
            if ($useLink) {
                echo '<a href="' . esc_url($cellUrl) . '" class="impulse-cell-content impulse-cell-link" style="' . esc_attr($contentStyle) . 'text-decoration:none;color:inherit;">';
            } elseif ($hasChildren || $isEditor) {
                echo '<div class="impulse-cell-content" style="' . esc_attr($contentStyle) . '">';
            }

            if ($hasChildren) {
                foreach ($childrenMap[$cellKey] as $childEl) {
                    $renderChild($childEl);
                }
            } elseif ($isEditor && !$hasCellBg && !empty($GLOBALS['impulse_is_edit_mode'])) {
                echo '<span class="impulse-cell-empty-indicator" style="font-size:20px;color:' . esc_attr((string) ($GLOBALS['impulse_cell_empty_fg'] ?? 'rgba(128,128,128,0.15)')) . '">+</span>';
            }

            if ($useLink) {
                echo '</a>';
            } elseif ($hasChildren || $isEditor) {
                echo '</div>';
            }

            // Editor-only column divider (drag handle).
            if ($isEditor && $colIdx < $numCells - 1) {
                echo '<div class="impulse-rows-divider" data-row="' . esc_attr((string) $rowIdx) . '" data-divider="' . esc_attr((string) $colIdx) . '" style="position:absolute;top:0;bottom:0;right:-' . esc_attr((string) ($cellGap/2 + 4)) . 'px;width:' . esc_attr((string) ($cellGap + 8)) . 'px;cursor:col-resize;z-index:5;"></div>';
            }

            echo '</div>'; // .impulse-rows-cell
        }

        echo '</div>'; // .impulse-rows-row
    }

    echo '</div>'; // .impulse-rows

    // Editor-only: targeted mobile min-height for bg-only cells.
    if ($isEditor && $mobileLayout !== 'keep' && function_exists('wp_add_inline_style')) {
        // Build CSS by concatenation (not echo) so it passes through
        // wp_add_inline_style() rather than page output; $elId is esc_attr()'d
        // and $bgCellSelectors carry esc_attr()'d row/col ints (built above).
        $sel = '.impulse-el[data-id="' . esc_attr((string) $elId) . '"]';
        $css = '';
        if ($mobileLayout === 'stack') {
            $css .= '@media(max-width:767px){' . $sel . ' .impulse-rows-row{flex-direction:column!important;height:auto!important}' . $sel . ' .impulse-rows-cell{flex:1 1 100%!important}}';
        } elseif ($mobileLayout === '2col') {
            $css .= '@media(max-width:767px){' . $sel . ' .impulse-rows-row{flex-wrap:wrap!important}' . $sel . ' .impulse-rows-cell{flex:0 0 calc(50% - ' . ($cellGap / 2) . 'px)!important}}';
        }
        if (!empty($bgCellSelectors)) {
            $bgCellCss = implode(',', array_map(function ($s) use ($sel) { return $sel . ' .impulse-rows-cell' . $s; }, $bgCellSelectors));
            $css .= '@media(max-width:767px){' . $bgCellCss . '{min-height:' . max(60, $rowHeight) . 'px!important}}';
        }
        if ($css) wp_add_inline_style('instinctor-page-inline', $css);
    }

    return ob_get_clean();
}
