<?php
declare(strict_types=1);

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

/**
 * Text element renderer (shared between frontend and editor).
 *
 * Phase 1, step 2 of the renderer-unification refactor. The two
 * pre-extraction renderers diverged on several structural points:
 *
 *   - ID format: frontend `ct-<id>` / editor `impulse-text-<id>`
 *     → unified to `ct-<id>` (frontend wins; only the editor's CSS
 *     selectors changed and they're internal).
 *   - Style: frontend used inline literals; editor used CSS variables
 *     for live-update binding (--impulse-color, --impulse-size, etc.).
 *     The editor pattern is preserved in editor context; the frontend
 *     keeps inline literals (no live-update needed once shipped).
 *   - DOM shape: editor wraps content in an inner
 *     <div contenteditable="true" data-field="content"> for inline
 *     editing; frontend emits content directly inside .impulse-text.
 *     Gradient text-fill is applied on the inner div in editor (because
 *     background-clip:text doesn't propagate to nested elements) and on
 *     the outer .impulse-text in frontend.
 *   - Frontend-only features: multi-column flex split for scene-mode
 *     decompose, text-reveal animations (words/lines/chars/sweep),
 *     impulse_responsive_css emission, data-content-tablet/_mobile
 *     attrs for the runtime device-swapper.
 *   - Per-device content: editor pre-resolves the active device's
 *     variant server-side ($GLOBALS['impulse_preview_device']);
 *     frontend renders desktop + emits other variants for the swapper.
 *
 * The desktop-content fallback (rendered <div> uses tablet/mobile
 * content when desktop is empty) is preserved — fix from v2.1.220.
 *
 * @param array|object $el       Element node. Frontend passes array;
 *                               editor passes object. Normalized at entry.
 * @param array        $settings Resolved settings (editor) or raw (frontend).
 * @param string       $context  'frontend' or 'editor'.
 *
 * @return string Inner HTML (no .impulse-el wrapper).
 *
 * @sideeffect In frontend context, calls wp_add_inline_style() to emit
 *             responsive CSS, text-reveal keyframes, and drop-cap CSS.
 *             Frontend may emit a one-time <script> tag for text-reveal
 *             intersection-observer triggers.
 */
function impulse_element_render_text($el, array $settings, string $context): string {
    // Normalize array/object element shape.
    if (is_object($el)) $el = (array) $el;
    $elId = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $isEditor = ($context === 'editor');

    // ── Content + per-device resolution ────────────────────────────
    $content = (string) ($settings['content'] ?? '');
    if ($isEditor) {
        // Editor renders only the active device's variant; reloads on tab change.
        $_txtDev = $GLOBALS['impulse_preview_device'] ?? 'desktop';
        if ($_txtDev !== 'desktop') {
            $_ov = (string) ($settings['content_' . $_txtDev] ?? '');
            if ($_ov !== '') {
                $content = $_ov;
            } elseif ($_txtDev === 'mobile') {
                $_ovT = (string) ($settings['content_tablet'] ?? '');
                if ($_ovT !== '') $content = $_ovT;
            }
        }
    } else {
        // Frontend desktop-content fallback: if desktop is empty but
        // tablet/mobile is set, use that as the rendered HTML so the
        // visible block is never blank. JS swapper still picks the
        // matching variant per device width. (v2.1.220 fix)
        if ($content === '') {
            $content = (string) ($settings['content_tablet'] ?? '');
            if ($content === '') $content = (string) ($settings['content_mobile'] ?? '');
        }
    }

    // ── Settings (shared) ──────────────────────────────────────────
    // In editor mode, read per-device values for the properties that
    // get emitted inline below. Frontend bakes desktop inline and
    // relies on @media to override at viewport widths — but @media
    // is suppressed in editor (the iframe reloads per device tab),
    // so the inline emission must already match the active device.
    // Without this, editor mobile preview rendered desktop sizes /
    // spacing / padding while font-size flipped to mobile, producing
    // a layout that didn't match real-device output. Locked 2026-05-27.
    $device  = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';
    $readDev = function ($key, $default) use ($settings, $isEditor, $device) {
        if ($isEditor && function_exists('impulse_device_val')) {
            return impulse_device_val($settings, $key, $device, $default);
        }
        return $settings[$key] ?? $default;
    };

    $align         = $readDev('align', 'center');
    $color         = $settings['color'] ?? '#666666';
    $bgColor       = $settings['bg_color'] ?? '';
    $size          = $readDev('size', 16);
    // Mobile rule (Sergei 2026-07-02): 14px is only the DEFAULT for text whose size
    // was never set. The moment a size is chosen manually — on ANY device, including
    // desktop (e.g. the 72px "$99") — that value is the size everywhere until a
    // per-device override changes it, and it must never snap back to 14px. So the
    // default applies ONLY when the base size AND both device overrides are unset.
    // When it does apply it is emitted as a HARD px (not the var(--impulse-size)
    // fallback) so the editor's live-preview can't restore a value on element select
    // (the "click makes it big, reload shrinks it" report). Editor previews here; the
    // frontend @media below is gated the same way.
    $_textMobDefault = (($settings['size'] ?? '') === '')
        && (($settings['size_mobile'] ?? '') === '')
        && (($settings['size_tablet'] ?? '') === '');
    $_textMobHard = false;
    if ($isEditor && $device === 'mobile' && $_textMobDefault) {
        $size = 14;
        $_textMobHard = true;
    }
    // Cast to string — DB can save weight as int (400, 700) or string ("400");
    // esc_attr in PHP 8 requires a string and throws a TypeError on int.
    $weight        = (string) ($readDev('weight', '400'));
    $fontFamily    = $settings['font_family'] ?? '';
    $lineHeight    = $readDev('line_height', 160);
    $letterSpacing = $readDev('letter_spacing', 0);
    $minHeight     = $readDev('min_height', 0);
    $widthPct      = $readDev('width', null);
    $widthPct      = ($widthPct !== null && $widthPct !== '') ? max(10, min(100, intval($widthPct))) : 100;
    $columns       = min(4, max(1, intval($readDev('columns', 1))));
    $columnGap     = $readDev('column_gap', 30);
    $italic        = !empty($settings['italic']);
    $decoration    = $settings['decoration'] ?? 'none';
    $dropCap       = !empty($settings['drop_cap']);
    $link          = $settings['link'] ?? '';
    $paddingTop    = intval($readDev('padding_top',    0));
    $paddingBottom = intval($readDev('padding_bottom', 0));
    $paddingLeft   = intval($readDev('padding_left',   0));
    $paddingRight  = intval($readDev('padding_right',  0));

    $textId = 'ct-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) $elId);

    // ── Markdown → HTML → kses → nl2br ─────────────────────────────
    $renderContent  = impulse_is_markdown($content) ? impulse_markdown_to_html($content) : $content;
    $displayContent = impulse_kses_richtext($renderContent);
    $hasBlockTags   = preg_match('/<(p|div|h[1-6]|ul|ol|li|blockquote|pre|hr|table)[^>]*>/i', $renderContent);
    if (!$hasBlockTags) $displayContent = nl2br($displayContent);

    // Typographic orphan/widow control on the FRONTEND, at EVERY text size — not
    // just headings. Subheads and body copy orphan too (a lone "We" or "a" ending
    // a line, Sergei 2026-07-19: "should NEVER happen, no matter screen size"), and
    // the helper only glues short connector words + the last two words, so it is
    // safe on body copy. Deterministic (unlike CSS text-wrap: pretty/balance) and
    // works in every browser. Editor is skipped: its content lives in a
    // contenteditable and injected nbsp would be saved back + accumulate.
    if (!$isEditor && !$hasBlockTags && function_exists('impulse_prevent_orphans')) {
        $displayContent = impulse_prevent_orphans($displayContent);
    }

    // ── Multi-column flex split (frontend only) ────────────────────
    // Scene-mode decompose targets .impulse-text-col; split when the
    // content has at least N <p>/<h> children, otherwise fall back to
    // CSS column-count below.
    $splitCols = (!$isEditor && $columns > 1) ? impulse_split_text_columns($displayContent, $columns) : false;

    // ── Build the container style ──────────────────────────────────
    $lhRatio = intval($lineHeight) / 100;
    if ($isEditor) {
        // Editor: bind each visual prop to a CSS variable so the panel's
        // live-update can update the iframe DOM by setting
        // `--impulse-<prop>` on the .impulse-el wrapper.
        // Bound to a var like every other live-updatable property: with a literal
        // here, clicking Alignment changed nothing on the canvas until a full
        // re-render, so the button read as dead (Sergei 2026-08-03).
        $style  = 'text-align:var(--impulse-align,' . esc_attr($align) . ');';
        $style .= 'color:var(--impulse-color,' . esc_attr($color) . ');';
        $style .= $_textMobHard
            ? 'font-size:14px;'
            : 'font-size:var(--impulse-size,' . intval($size) . 'px);';
        // Weight was the one property left as a literal in this branch, so the
        // Weight buttons only took effect after a re-render (audit 2026-08-03:
        // text had 9 var-bound properties and 3 literals).
        $style .= 'font-weight:var(--impulse-weight,' . esc_attr($weight) . ');';
        $style .= 'line-height:var(--impulse-line-height,' . $lhRatio . ');';
        $style .= 'letter-spacing:var(--impulse-letter-spacing,' . intval($letterSpacing) . 'px);';
        // Line balancing is a stylesheet rule now (see below), not an inline
        // declaration, so the editor and the frontend cannot drift.
    } else {
        // Frontend: inline literals (no live-update target needed).
        $style  = 'text-align:' . esc_attr($align) . ';';
        $style .= 'color:' . esc_attr($color) . ';';
        $style .= 'font-size:' . intval($size) . 'px;';
        // Multi-line copy balances its lines: a full first line with a short
        // remainder beneath reads as broken (Sergei 2026-07-25). Applied for
        // EVERY alignment, not just centre: the block's own `align` is often
        // 'left' while the CONTAINER centres it, so the old center-only gate
        // skipped exactly the copy that looked worst (hero on
        // /how-to-get-more-clients, Sergei 2026-07-28). Inherited, so <p>
        // children and reveal spans follow; browsers without text-wrap:balance
        // keep today's wrapping.
    }

    // Width sizes the CONTAINER (Sergei 2026-06-23). On the FRONTEND there is no
    // .impulse-el wrapper, so .impulse-text IS the box and carries width% +
    // centering + the 1200px cap. In the EDITOR the .impulse-el selection wrapper
    // carries width/max-width/centering (set in impulse-preview.php) so the box +
    // handles match; the text just fills it — otherwise the handles spanned full
    // width while the text floated narrow inside. Hard cap: never exceed 1200px.
    if ($isEditor) {
        $style .= 'width:100%;align-self:stretch;';
    } elseif ($widthPct < 100) {
        // A narrow box (width<100) used to always margin:auto-center, which
        // overrides both the block's own align and the parent cell's halign.
        // So a left-aligned 92%-wide text floated its glyphs left inside a
        // centered box, reading as "not left-aligned" even with the cell set
        // to left. Position the box per the block's align instead. Default
        // 'center' keeps the historical centering, so existing pages are
        // unchanged; left/right hug the matching edge. Sergei 2026-06-25.
        // box_align decouples box POSITION from text alignment (2026-07-01):
        // a centered box can hold left-aligned text (FAQ pattern). Falls back
        // to the text align when unset, so every existing page is unchanged.
        $boxAlign = $readDev('box_align', '');
        if ($boxAlign === '') $boxAlign = $align;
        $boxMargin = ($boxAlign === 'left')  ? 'margin-left:0;margin-right:auto;'
                  : (($boxAlign === 'right') ? 'margin-left:auto;margin-right:0;'
                  : 'margin-left:auto;margin-right:auto;');
        $style .= ($isEditor
            ? 'width:var(--impulse-width,' . $widthPct . '%);max-width:1200px;'
            : 'width:' . $widthPct . '%;max-width:1200px;') . $boxMargin;
    } else {
        // width==100: the box fills to a 1200px cap. box_align still governs
        // WHERE that capped box sits when the container is wider than 1200 (or
        // wider than the text). Previously this hardcoded margin:auto (center),
        // silently ignoring box_align at full width, so a left box_align read as
        // centered on wide canvases and would not line up with a left-anchored
        // sibling (button/section). Honor box_align here exactly like width<100.
        // Default (unset → falls back to text align 'center') keeps historical
        // centering, so existing centered heroes are unchanged. Sergei 2026-07-03.
        $boxAlign = $readDev('box_align', '');
        if ($boxAlign === '') $boxAlign = $align;
        $boxMargin = ($boxAlign === 'left')  ? 'margin-left:0;margin-right:auto;'
                  : (($boxAlign === 'right') ? 'margin-left:auto;margin-right:0;'
                  : 'margin-left:auto;margin-right:auto;');
        $style .= 'width:100%;max-width:1200px;align-self:stretch;' . $boxMargin;
    }

    if (!$isEditor) {
        // Frontend: emit literal weight/line-height/letter-spacing here
        // (in editor they were already emitted as CSS vars above).
        $style .= 'font-weight:' . esc_attr($weight) . ';';
        $style .= 'line-height:' . $lhRatio . ';';
        $style .= 'letter-spacing:' . intval($letterSpacing) . 'px;';
    }

    if ($fontFamily) {
        // font_family stores a full system-font stack (e.g. Georgia,"Times New Roman",serif).
        // Emit it RAW — $style is esc_attr()'d at echo (line ~385); inner
        // esc_attr here doubles up the encoding (" → &quot; → &amp;quot;)
        // and the browser ignores the rule. 2026-05-29.
        if ($isEditor) {
            $style .= 'font-family:var(--impulse-font-family,' . $fontFamily . ');';
        } else {
            $style .= 'font-family:' . $fontFamily . ';';
        }
    }

    // Height is a plain min-height in every case. It used to become a
    // FIXED height + overflow:hidden whenever columns > 1, which is what
    // tangled the Columns control together with the Height slider.
    // Columns now stand on their own (even balance, top-aligned).
    if ($minHeight) {
        $style .= 'min-height:' . intval($minHeight) . 'px;';
    }

    // Solid container bg (independent of text-fill gradient).
    if ($bgColor && $bgColor !== 'transparent') {
        $style .= 'background-color:' . esc_attr($bgColor) . ';';
    }

    // Text-fill gradient retired in v2.1.1311. Gradient typography is
    // owned by the Title (animator) block where 3D depth + presets give
    // it weight; on plain body Text it hurt readability and competed
    // with the hero. Existing text_gradient_* settings are ignored —
    // dead weight, harmless. Stubs kept so the inner-element interpolation
    // below stays valid without conditionals.
    $textGradMouseAttrs = '';
    $innerGradStyle     = '';
    $innerGradAttrs     = '';

    if ($italic) $style .= 'font-style:italic;';
    if ($decoration !== 'none') $style .= 'text-decoration:' . esc_attr($decoration) . ';';

    // Cinematic legibility halo — auto-flips dark/light based on text luminance.
    $_haloLevel = max(0, min(100, intval($settings['text_halo'] ?? 0)));
    if ($_haloLevel > 0 && function_exists('impulse_text_halo_css')) {
        $style .= impulse_text_halo_css($_haloLevel, $color);
    }

    // CSS column-count. Works on ANY content — inline runs, <br>-separated
    // text, or block-level markup all flow across columns. Built into a
    // separate var because WHERE it lands differs by context:
    //   - Frontend: on the outer .impulse-text (content is its direct
    //     child, so column-count fragments it).
    //   - Editor: on the INNER contenteditable div. The outer has a
    //     single child (the contenteditable), and a lone child can't be
    //     split across columns — which is why columns silently did
    //     nothing in the editor regardless of the buttons. Putting the
    //     flow on the contenteditable makes its text fragment properly.
    // No $hasBlockTags gate (plain / <br> text columns fine) and no
    // dependency on the Height slider.
    // Plain column-count with the default column-fill:balance — the
    // browser divides the text evenly into N columns, each top-aligned.
    // No column-fill:auto (that fills column 1 first and only looks
    // right with an explicit height — the unpredictable behaviour) and
    // no dependency on the Height slider.
    $colFlowCss = '';
    if ($columns > 1 && !$splitCols) {
        $colFlowCss = 'column-count:' . $columns . ';column-gap:' . intval($columnGap) . 'px;';
    }

    if ($isEditor && $link) {
        $style .= 'cursor:pointer;';
    }

    // Padding: editor uses CSS variables so the panel's live-update can
    // rewrite them by setting --impulse-padding-* on the .impulse-el
    // wrapper (the var on the closest ancestor wins, shadowing the page-
    // level --impulse-padding-top that .impulse-page always carries).
    // Frontend emits hard pixel values — no live-update target needed,
    // and the CSS-var approach would cause the text element to inherit
    // the page-level padding instead of using its own stored value.
    if ($isEditor) {
        $style .= 'padding-top:var(--impulse-padding-top,' . $paddingTop . 'px);';
        $style .= 'padding-bottom:var(--impulse-padding-bottom,' . $paddingBottom . 'px);';
        $style .= 'padding-left:var(--impulse-padding-left,' . $paddingLeft . 'px);';
        $style .= 'padding-right:var(--impulse-padding-right,' . $paddingRight . 'px);';
    } else {
        $style .= 'padding-top:'    . $paddingTop    . 'px;';
        $style .= 'padding-bottom:' . $paddingBottom . 'px;';
        $style .= 'padding-left:'   . $paddingLeft   . 'px;';
        $style .= 'padding-right:'  . $paddingRight  . 'px;';
    }

    // Vertical margins — frontend reads via impulse_get_responsive.
    if (!$isEditor && function_exists('impulse_get_responsive')) {
        $tMt = intval(impulse_get_responsive($settings, 'margin_top', 0));
        $tMb = intval(impulse_get_responsive($settings, 'margin_bottom', 0));
        if ($tMt) $style .= 'margin-top:' . $tMt . 'px;';
        if ($tMb) $style .= 'margin-bottom:' . $tMb . 'px;';
    }

    // Drop cap styles.
    if ($dropCap && function_exists('wp_add_inline_style')) {
        wp_add_inline_style('instinctor-page-inline', '#' . $textId . '::first-letter{float:left;font-size:3em;line-height:1;margin-right:8px;font-weight:bold}');
    }

    // ── Class composition + entrance / text-reveal (frontend only) ─
    // impulse-text-center marks CENTRED copy for the line-balancing rules in
    // frontend.css / impulse-preview.php. `text-wrap: balance` is right for a
    // statement that reads as one block and wrong for body copy that has to
    // fill a narrow measure, and the split is by CONTEXT, not by type scale:
    // inside a grid cell, non-centred copy fills; everywhere else it balances
    // (Sergei 2026-07-31). Kept in CSS rather than inline so the editor, the
    // frontend and the editor's single-element re-render all agree.
    $textClass = 'impulse-text' . ($align === 'center' ? ' impulse-text-center' : '');
    $animAttrs = ['class' => '', 'style' => ''];
    if (!$isEditor) {
        if (function_exists('impulse_get_animation_attrs')) {
            $animAttrs = impulse_get_animation_attrs($settings);
        }
        if (function_exists('impulse_get_visibility_classes')) {
            $visClass = impulse_get_visibility_classes($settings);
            if ($visClass) $textClass .= ' ' . $visClass;
        }
        if (!empty($animAttrs['class'])) $textClass .= ' ' . $animAttrs['class'];
        if (!empty($animAttrs['style'])) $style .= $animAttrs['style'];
    }

    // Text-content reveal (frontend only — animates the text itself,
    // independent of container entrance animations).
    $textReveal         = $isEditor ? 'none' : ($settings['text_reveal'] ?? 'none');
    $textRevealStagger  = 0; $textRevealDuration = 0; $textRevealDelay = 0;
    if (!$isEditor && $textReveal !== 'none' && $textReveal !== '') {
        // Per-mode minimum stagger. Words/Lines/Chars only differ visually
        // when there's a per-token delay — at stagger=0 all tokens fire
        // together and the picker becomes a no-op (the user sees a single
        // block fade and reports "preset doesn't work"). Floor to the same
        // values used as defaults for unset settings so a saved 0 still
        // animates with per-token build. To get a truly simultaneous fade,
        // the user should pick mode=Sweep (single-mask reveal, no tokens).
        // Sergei 2026-05-31.
        $minStagger = ($textReveal === 'chars' ? 0.02 : ($textReveal === 'lines' ? 0.08 : 0.04));
        $rawStagger = floatval($settings['text_reveal_stagger'] ?? $minStagger);
        $textRevealStagger = max($minStagger, $rawStagger);
        // Unroll defaults slower - a herald's scroll needs weight, 0.8 reads rushed.
        $textRevealDuration = max(0.1, floatval($settings['text_reveal_duration'] ?? ($textReveal === 'unroll' ? 1.3 : 0.8)));
        $textRevealDelay    = max(0, floatval($settings['text_reveal_delay']    ?? 0));
        $textClass .= ' impulse-text-reveal impulse-text-reveal-' . preg_replace('/[^a-z]/', '', $textReveal);
        $style     .= '--ttr-stagger:'  . $textRevealStagger  . 's;'
                    . '--ttr-duration:' . $textRevealDuration . 's;'
                    . '--ttr-delay:'    . $textRevealDelay    . 's;';
        if (!$splitCols && function_exists('impulse_split_text_for_reveal')) {
            $displayContent = impulse_split_text_for_reveal($displayContent, $textReveal);
        } elseif ($splitCols && function_exists('impulse_split_text_for_reveal')) {
            foreach ($splitCols as $i => $colHtml) {
                $splitCols[$i] = impulse_split_text_for_reveal($colHtml, $textReveal);
            }
        }
    }

    // Per-device content overrides — emit base64-encoded HTML for
    // tablet/mobile variants in data-content-* attrs; runtime swapper in
    // impulse-interactions.js picks the right one. Frontend only — the
    // editor renders the active device's variant directly server-side.
    $_contentRespAttrs = '';
    if (!$isEditor) {
        $_buildVariantHtml = function ($raw) {
            if ($raw === '' || $raw === null) return '';
            $r = impulse_is_markdown($raw) ? impulse_markdown_to_html($raw) : $raw;
            $h = impulse_kses_richtext($r);
            if (!preg_match('/<(p|div|h[1-6]|ul|ol|li|blockquote|pre|hr|table)[^>]*>/i', $r)) {
                $h = nl2br($h);
            }
            return $h;
        };
        $_tabContent = trim((string) ($settings['content_tablet'] ?? ''));
        $_mobContent = trim((string) ($settings['content_mobile'] ?? ''));
        if ($_tabContent !== '') $_contentRespAttrs .= ' data-content-tablet="' . esc_attr(base64_encode($_buildVariantHtml($_tabContent))) . '"';
        if ($_mobContent !== '') $_contentRespAttrs .= ' data-content-mobile="' . esc_attr(base64_encode($_buildVariantHtml($_mobContent))) . '"';
    }

    // ── Build HTML ─────────────────────────────────────────────────
    ob_start();
    if ($link) {
        echo '<a href="' . esc_url($link) . '" style="text-decoration:none;color:inherit;display:block">';
    }

    // SEO: optional semantic tag so a headline renders as a real heading.
    // Defaults to div, so a page that has not opted in is byte-identical to
    // before. Headings carry UA margins, so zero the block margins first and
    // let any explicit margin_top later in $style win over it.
    $semTag = strtolower(trim((string) ($settings['heading_tag'] ?? 'div')));
    if (!in_array($semTag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div'], true)) {
        $semTag = 'div';
    }
    $semReset = ($semTag === 'div') ? '' : 'margin-block:0;';

    if ($isEditor) {
        // Editor: outer .impulse-text + inner contenteditable wrapper.
        // The contenteditable lets the user edit text inline in the iframe;
        // it also carries the text-fill gradient (since bg-clip:text doesn't
        // propagate to nested elements).
        echo '<div id="' . esc_attr($textId) . '" class="' . esc_attr($textClass) . '" style="' . esc_attr($style) . '"' . wp_kses($textGradMouseAttrs, impulse_svg_allowed()) . '>';
        $isTextEmpty = empty(trim(wp_strip_all_tags($displayContent)));
        // Column flow goes on the contenteditable — the outer's single
        // child can't fragment across columns.
        $innerStyle  = 'position:relative;min-height:1em;outline:none;' . $colFlowCss . $innerGradStyle;
        echo '<div class="' . ($isTextEmpty ? 'is-empty' : '') . '" contenteditable="true" data-field="content" data-placeholder="Click to add text" style="' . esc_attr($innerStyle) . '"' . wp_kses($innerGradAttrs, impulse_svg_allowed()) . '>' . wp_kses_post($displayContent) . '</div>';
        echo '</div>';
    } elseif ($splitCols) {
        // Frontend multi-column flex split — each column is a decompose
        // target for scene-mode screen transitions.
        $outerClass = $textClass . ' impulse-text-cols';
        $outerStyle = $style . 'display:flex;flex-direction:row;gap:' . intval($columnGap) . 'px;';
        // phpcs:disable WordPress.Security.EscapeOutput -- $semTag is whitelisted at assignment: lowercased, trimmed, and forced to 'div' unless it is one of h1-h6/p/div, so it can only ever be one of eight literals. Every other value on these lines is esc_attr'd or wp_kses_post'd
        echo '<' . $semTag . ' id="' . esc_attr($textId) . '" class="' . esc_attr($outerClass) . '" style="' . esc_attr($semReset . $outerStyle) . '"' . wp_kses($textGradMouseAttrs, impulse_svg_allowed()) . wp_kses($_contentRespAttrs, impulse_svg_allowed()) . '>';
        // phpcs:enable WordPress.Security.EscapeOutput
        foreach ($splitCols as $colHtml) {
            echo '<div class="impulse-text-col" style="flex:1 1 0;min-width:0">' . wp_kses_post($colHtml) . '</div>';
        }
        // phpcs:disable WordPress.Security.EscapeOutput -- $semTag is whitelisted at assignment: lowercased, trimmed, and forced to 'div' unless it is one of h1-h6/p/div, so it can only ever be one of eight literals. Every other value on these lines is esc_attr'd or wp_kses_post'd
        echo '</' . $semTag . '>';
        // phpcs:enable WordPress.Security.EscapeOutput
    } else {
        // Frontend single-column (or CSS column-count) — content direct
        // child so bg-clip:text gradient on outer paints the glyphs.
        // column-count goes on the outer here because the content IS its
        // direct child and fragments correctly.
        // phpcs:disable WordPress.Security.EscapeOutput -- $semTag is whitelisted at assignment: lowercased, trimmed, and forced to 'div' unless it is one of h1-h6/p/div, so it can only ever be one of eight literals. Every other value on these lines is esc_attr'd or wp_kses_post'd
        echo '<' . $semTag . ' id="' . esc_attr($textId) . '" class="' . esc_attr($textClass) . '" style="' . esc_attr($semReset . $style . $colFlowCss) . '"' . wp_kses($textGradMouseAttrs, impulse_svg_allowed()) . wp_kses($_contentRespAttrs, impulse_svg_allowed()) . '>';
        // phpcs:enable WordPress.Security.EscapeOutput
        echo wp_kses_post($displayContent);
        // phpcs:disable WordPress.Security.EscapeOutput -- $semTag is whitelisted at assignment: lowercased, trimmed, and forced to 'div' unless it is one of h1-h6/p/div, so it can only ever be one of eight literals. Every other value on these lines is esc_attr'd or wp_kses_post'd
        echo '</' . $semTag . '>';
        // phpcs:enable WordPress.Security.EscapeOutput
    }

    if ($link) echo '</a>';

    // Frontend per-device WIDTH override. The desktop width% is inline; the
    // editor applies device-aware width to the .impulse-el wrapper, but the
    // frontend serves ONE html to every device, so width_tablet/width_mobile
    // need a media query — otherwise a narrow desktop column (e.g. 39%) stays
    // narrow on a phone even when width_mobile=100. !important (via
    // impulse_responsive_css) beats the inline width. v2.1.1878.
    if (!$isEditor && function_exists('impulse_responsive_css')) {
        $_wResp = impulse_responsive_css($settings, '#' . $textId, ['width' => ['width', '%']]);
        if ($_wResp !== '') {
            if (function_exists('wp_add_inline_style')) {
                wp_add_inline_style('instinctor-page-inline', $_wResp);
            } else {
                wp_add_inline_style('instinctor-page-inline', $_wResp);
            }
        }
    }

    // Mobile-first: a reduced desktop width (a readable-measure column, e.g. 62%)
    // becomes painfully narrow on a phone — body copy wraps after a few words. When
    // no explicit width_mobile override exists, expand the box to full width for
    // <=767px so text uses the screen. If width_mobile IS set, the block above
    // already handled it, so only fill the gap here.
    if (!$isEditor && $widthPct < 100
        && ($settings['width_mobile'] ?? '') === ''
        && function_exists('wp_add_inline_style')) {
        wp_add_inline_style('instinctor-page-inline',
            '@media(max-width:767px){#' . $textId . '{width:100%!important;max-width:100%!important}}');
    }

    // One-time CSS + JS for text reveal — emit once per page render.
    if (!$isEditor && !empty($textReveal) && $textReveal !== 'none' && empty($GLOBALS['impulse_text_reveal_css'])) {
        $GLOBALS['impulse_text_reveal_css'] = true;
        /* Per-token spans inherit the parent's gradient/clipped-text fill.
           Without this, the parent's `-webkit-background-clip:text` paints
           only on the parent's OWN text (none — the text now lives in
           children), so on a gradient-fill or stroked text block every
           token is rendered with the transparent text-fill-color and is
           completely invisible. By copying the gradient + clip + fill
           onto each span, the per-token glyphs paint correctly regardless
           of whether the animation keyframes (opacity / transform) put the
           span on its own compositing layer. Sergei 2026-05-31. */
        $css = '.impulse-text-reveal{--ttr-stagger:0.04s;--ttr-duration:0.8s;--ttr-delay:0s}'
             . '.impulse-text-reveal .impulse-tr-word,'
             . '.impulse-text-reveal .impulse-tr-line,'
             . '.impulse-text-reveal .impulse-tr-char{'
             .   'display:inline-block;opacity:0;'
             .   'background-image:inherit;background-clip:inherit;'
             .   '-webkit-background-clip:inherit;'
             .   '-webkit-text-fill-color:inherit;color:inherit'
             . '}'
             . '.impulse-text-reveal-lines .impulse-tr-line{display:block}'
             . '.impulse-text-reveal.is-revealed .impulse-tr-word,'
             . '.impulse-text-reveal.is-revealed .impulse-tr-line,'
             . '.impulse-text-reveal.is-revealed .impulse-tr-char{'
             .   'animation:impulse-tr-fadeup var(--ttr-duration) cubic-bezier(0.4, 0, 0.2, 1) both;'
             .   'animation-delay:calc(var(--ttr-delay) + var(--i,0) * var(--ttr-stagger))}'
             . '@keyframes impulse-tr-fadeup{from{opacity:0;transform:translateY(0.5em)}to{opacity:1;transform:translateY(0)}}'
             /* Chars: distinct per-char pop (scale) so it does not read like the
                word/line rise or the sweep wipe. Same var(--ttr-duration)/stagger.
                Sergei 2026-06-23. */
             . '.impulse-text-reveal-chars.is-revealed .impulse-tr-char{animation-name:impulse-tr-pop}'
             . '@keyframes impulse-tr-pop{from{opacity:0;transform:scale(0.5)}to{opacity:1;transform:scale(1)}}'
             . '.impulse-text-reveal-sweep{'
             .   '-webkit-mask-image:linear-gradient(90deg,transparent 0%,transparent 40%,#000 60%,#000 100%);'
             .   'mask-image:linear-gradient(90deg,transparent 0%,transparent 40%,#000 60%,#000 100%);'
             .   '-webkit-mask-size:250% 100%;mask-size:250% 100%;'
             .   '-webkit-mask-position:0% 0;mask-position:0% 0;'
             .   '-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}'
             . '.impulse-text-reveal-sweep.is-revealed{'
             .   'animation:impulse-tr-sweep var(--ttr-duration) cubic-bezier(0.4, 0, 0.2, 1) both;'
             .   'animation-delay:var(--ttr-delay)}'
             . '@keyframes impulse-tr-sweep{from{-webkit-mask-position:0% 0;mask-position:0% 0}to{-webkit-mask-position:100% 0;mask-position:100% 0}}'
             /* Unroll (Sergei 2026-07-22): herald-unrolls-a-scroll. Container-
                level like sweep. The clip edge travels top to bottom while the
                block swings down from a 3D curl (transform-origin top), then a
                HEAVY spring: overshoot past flat and two damped rebounds. */
             . '.impulse-text-reveal-unroll{opacity:0;transform-origin:top center}'
             . '.impulse-text-reveal-unroll.is-revealed{'
             .   'animation:impulse-tr-unroll var(--ttr-duration) cubic-bezier(0.4, 0, 0.2, 1) both;'
             .   'animation-delay:var(--ttr-delay)}'
             . '@keyframes impulse-tr-unroll{'
             .   '0%{opacity:0;clip-path:inset(0 0 100% 0);transform:perspective(700px) rotateX(-65deg)}'
             .   '30%{opacity:1}'
             .   '52%{clip-path:inset(0 0 0 0);transform:perspective(700px) rotateX(11deg)}'
             .   '70%{transform:perspective(700px) rotateX(-5.5deg)}'
             .   '84%{transform:perspective(700px) rotateX(2.5deg)}'
             .   '94%{transform:perspective(700px) rotateX(-1deg)}'
             .   '100%{opacity:1;clip-path:inset(0 0 0 0);transform:perspective(700px) rotateX(0deg)}'
             . '}';
        if (function_exists('wp_add_inline_style')) {
            wp_add_inline_style('instinctor-page-inline', $css);
        } else {
            wp_add_inline_style('instinctor-page-inline', $css);
        }
        /* Screen-active-aware sync. Listens to impulse-screen-changed; when
           a text-reveal's screen becomes active, replays the animation
           (concurrent with the screens engine's decomposeEnter so the
           composite opacity ramps smoothly into view). When the screen
           deactivates, resets so the next visit replays cleanly.
           Pattern: feedback_screen_visibility_gating.md.
           Sergei 2026-05-31. */
        $js = '(function(){if(window.__impulseTextRevealInit)return;window.__impulseTextRevealInit=1;'
            . 'function fire(el){el.classList.add("is-revealed");}'
            . 'function reset(el){el.classList.remove("is-revealed");}'
            . 'function replay(el){reset(el);void el.offsetWidth;fire(el);}'
            . 'function sync(){var nodes=document.querySelectorAll(".impulse-text-reveal");for(var i=0;i<nodes.length;i++){var el=nodes[i];var s=el.closest?el.closest(".impulse-screen"):null;if(!s){fire(el);continue;}if(s.classList.contains("impulse-screen-active"))replay(el);else reset(el);}}'
            . 'document.addEventListener("impulse-screen-changed",sync);'
            // Fallback for any page that has no screens engine running —
            // fire everything after a short delay. If a screen IS active
            // by then, sync() already ran and we leave it alone.
            . 'setTimeout(function(){if(document.querySelector(".impulse-screen-active"))return;document.querySelectorAll(".impulse-text-reveal").forEach(fire);},400);'
            . '})();';
        wp_add_inline_script('instinctor-frontend-js', '' . $js . '', 'after');
    }

    $html = ob_get_clean();
    // (orphan control applied earlier to $displayContent, before ob_start)

    // ── Responsive per-device CSS (frontend only) ──────────────────
    if (!$isEditor && function_exists('impulse_responsive_css') && function_exists('wp_add_inline_style')) {
        $textRespCss = impulse_responsive_css($settings, '#' . $textId, [
            // Per-device text alignment (align_tablet / align_mobile). Emitted as a
            // media-query override so a heading can be centered on desktop but left on
            // mobile (the frontend renders one desktop DOM, so alignment must flip via
            // CSS, not a re-render). Keyword value, so use the callback form.
            'align'          => function ($v) {
                $v = preg_replace('/[^a-z-]/', '', strtolower((string) $v));
                return $v !== '' ? 'text-align:' . $v : '';
            },
            'size'           => ['font-size', 'px'],
            'line_height'    => ['line-height', '%'],
            'letter_spacing' => ['letter-spacing', 'px'],
            'padding_top'    => ['padding-top', 'px'],
            'padding_bottom' => ['padding-bottom', 'px'],
            'padding_left'   => ['padding-left', 'px'],
            'padding_right'  => ['padding-right', 'px'],
            'min_height'     => ['min-height', 'px'],
            'margin_top'     => ['margin-top', 'px'],
            'margin_bottom'  => ['margin-bottom', 'px'],
        ]);
        if ($textRespCss) wp_add_inline_style('instinctor-page-inline', $textRespCss);
        // Mobile rule: default text to 14px on mobile when no explicit mobile/tablet size.
        if ($_textMobDefault) {
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $textId . '{font-size:14px!important}}');
        }
    }

    return $html;
}

if (!function_exists('impulse_prevent_orphans')) {
    /**
     * Prevent typographic orphans/widows in short inline text (headings).
     *
     * Glues short/preposition words AND the final two words together with
     * non-breaking spaces, so a lone "a", "of", "the", "&" etc. can never end
     * a line and the last line never carries a single word. Deterministic
     * (unlike CSS text-wrap: pretty/balance, which is browser-inconsistent and
     * only targets the last line) and works everywhere.
     *
     * Idempotent: only rewrites real ASCII spaces, never existing nbsp. Bails
     * on anything containing a tag so inline markup is never broken.
     */
    function impulse_prevent_orphans(string $text): string {
        if ($text === '' || strpos($text, '<') !== false) return $text;
        $nb = "\xC2\xA0"; // UTF-8 non-breaking space
        $short = '(?:a|an|and|as|at|be|but|by|do|for|if|in|is|it|its|my|no|nor|of|off|on|or|our|per|so|the|to|up|via|vs|we|your)';
        // 1. Short / preposition words glue to the following word.
        $text = preg_replace('/(?<![\p{L}\p{N}])(' . $short . ')\x20+(?=[\p{L}\p{N}])/iu', '$1' . $nb, $text);
        // 2. Any remaining 1-2 character word glues to the following word.
        $text = preg_replace('/(?<![\p{L}\p{N}])([\p{L}\p{N}]{1,2})\x20+(?=[\p{L}\p{N}])/u', '$1' . $nb, $text);
        // 3. Widont: glue the final two words (anchored to end → last space only).
        $text = preg_replace('/\x20+([^\s]+)\s*$/u', $nb . '$1', (string) $text, 1);
        return (string) $text;
    }
}
