<?php
declare(strict_types=1);

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

/**
 * Flip Box element renderer (shared).
 *
 * Phase 1, step 25. A two-sided card with rich hover-animation
 * variants: spring / cube / push / zoom / glitch /
 * depth-rise / fade / slideup / fold / flip / swing.
 * (2026-07-20 cull: spotlight / xray-scan / void / stack / slide / morph
 * removed; saved pages using them fall back to spring.)
 * Each animation has a CSS setup branch and a JS
 * handler that runs on hover (frontend) or stays inert (editor
 * preview is markup-only — hover doesn't actually flip in the
 * builder).
 *
 * Pre-extraction differences:
 *
 *   - Editor honoured a `padding` uniform-slider setting that took
 *     priority over the 4-side per-direction values. Frontend
 *     always used the 4-side values. Unified to the editor's
 *     priority logic — `padding` (uniform) wins when set, else
 *     the 4-side stack.
 *
 *   - Frontend bundled the entire hover-handler JS (~250 lines)
 *     inline at the end. Editor emits markup only and lets the
 *     user see static state in the canvas.
 *
 *   - Frontend wraps with `isolation:isolate` on outer + container
 *     so 3D transforms don't bleed onto neighbors; editor doesn't.
 *
 *   - Visibility classes (hide-mobile / hide-tablet) frontend only.
 */
function impulse_element_render_flip_box($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    $isEditor = ($context === 'editor');

    $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;
    };

    // ── Content settings ─────────────────────────────────────────
    $frontTitle    = (string) ($settings['front_title']    ?? '');
    $frontContent  = (string) ($settings['front_content']  ?? '');
    $frontBg       = (string) ($settings['front_bg']       ?? '#f5f5f5');
    $frontColor    = (string) ($settings['front_color']    ?? '#1d2327');
    $frontTitleSize   = intval($read('front_title_size',   24));
    $frontContentSize = intval($read('front_content_size', 14));
    $backTitle     = (string) ($settings['back_title']     ?? '');
    $backContent   = (string) ($settings['back_content']   ?? '');
    $backButtonText  = (string) ($settings['back_button_text']  ?? '');
    $backButtonLink  = (string) ($settings['back_button_link']  ?? '#');
    $backButtonBg    = (string) ($settings['back_button_bg']    ?? 'rgba(255,255,255,0.2)');
    $backButtonColor = (string) ($settings['back_button_color'] ?? '');
    $backBg        = (string) ($settings['back_bg']        ?? '#e0e0e0');
    $backColor     = (string) ($settings['back_color']     ?? '#1d2327');

    // bg fallback chain — empty/transparent → parent section bg → page bg
    $secBg  = (string) ($GLOBALS['impulse_current_section_bg'] ?? '');
    $pageBg = (string) ($GLOBALS['impulse_page_bg_color']      ?? '#ffffff');
    $bgFallback = ($secBg !== '' && $secBg !== 'transparent') ? $secBg : $pageBg;
    if ($frontBg === '' || $frontBg === 'transparent') $frontBg = $bgFallback;
    if ($backBg  === '' || $backBg  === 'transparent') $backBg  = $bgFallback;

    $backTitleSize    = intval($settings['back_title_size']    ?? $frontTitleSize);
    $backContentSize  = intval($settings['back_content_size']  ?? $frontContentSize);
    $animationType    = (string) ($settings['animation_type']  ?? 'spring');
    // Default 900ms reads as premium-cinematic; 600 felt rushed.
    $animSpeed        = intval($settings['animation_speed']    ?? 900);

    $flipDirection  = (string) $read('flip_direction',  'horizontal');
    $slideDirection = (string) $read('slide_direction', 'left');
    $height        = intval($read('height',        250));
    $borderRadius  = intval($read('border_radius', 8));

    /* Per-device dimensions (frontend). The Height and Max Width
       sliders on the panel are responsive — they write to
       height_tablet / height_mobile / section_max_width_tablet /
       section_max_width_mobile when the active device tab isn't
       desktop. Before this block the frontend ignored those keys
       and emitted the desktop value at every viewport; the per-
       device controls felt broken. Read explicit per-device values
       here so we can emit @media overrides further below. Null =
       "designer didn't set", fall through to desktop on render. */
    $heightTablet = isset($settings['height_tablet']) && $settings['height_tablet'] !== ''
        ? intval($settings['height_tablet']) : null;
    $heightMobile = isset($settings['height_mobile']) && $settings['height_mobile'] !== ''
        ? intval($settings['height_mobile']) : null;
    $maxWidthTablet = isset($settings['section_max_width_tablet']) && $settings['section_max_width_tablet'] !== ''
        ? intval($settings['section_max_width_tablet']) : null;
    $maxWidthMobile = isset($settings['section_max_width_mobile']) && $settings['section_max_width_mobile'] !== ''
        ? intval($settings['section_max_width_mobile']) : null;

    // Padding: uniform slider takes priority over 4-side when set.
    $uniformPadding = isset($settings['padding']) ? intval($read('padding', 0)) : null;
    if ($uniformPadding !== null) {
        $padding = $uniformPadding . 'px';
    } else {
        $paddingTop    = intval($settings['padding_top']    ?? 24);
        $paddingRight  = intval($settings['padding_right']  ?? 24);
        $paddingBottom = intval($settings['padding_bottom'] ?? 24);
        $paddingLeft   = intval($settings['padding_left']   ?? 24);
        $padding = $paddingTop . 'px ' . $paddingRight . 'px ' . $paddingBottom . 'px ' . $paddingLeft . 'px';
    }

    // Visibility classes (frontend only).
    $visClass = (!$isEditor && function_exists('impulse_get_visibility_classes'))
        ? impulse_get_visibility_classes($settings) : '';

    // ── Per-side border ─────────────────────────────────────────
    $fbBorderWidth = intval($settings['border_width'] ?? 0);
    $fbBorderColor = esc_attr((string) ($settings['border_color'] ?? '#e0e0e0'));
    $fbBorderStyle = '';
    if ($fbBorderWidth > 0) {
        $fbBorderTop    = !isset($settings['border_top'])    || $settings['border_top']    !== '0';
        $fbBorderRight  = !isset($settings['border_right'])  || $settings['border_right']  !== '0';
        $fbBorderBottom = !isset($settings['border_bottom']) || $settings['border_bottom'] !== '0';
        $fbBorderLeft   = !isset($settings['border_left'])   || $settings['border_left']   !== '0';
        $bw = $fbBorderWidth . 'px';
        $fbBorderStyle = 'border-top:'    . ($fbBorderTop    ? $bw . ' solid ' . $fbBorderColor : 'none') . ';'
                       . 'border-right:'  . ($fbBorderRight  ? $bw . ' solid ' . $fbBorderColor : 'none') . ';'
                       . 'border-bottom:' . ($fbBorderBottom ? $bw . ' solid ' . $fbBorderColor : 'none') . ';'
                       . 'border-left:'   . ($fbBorderLeft   ? $bw . ' solid ' . $fbBorderColor : 'none') . ';';
    }

    // ── Base side style ─────────────────────────────────────────
    // Vertical alignment — top / center / bottom. Top reads as a
    // premium spec card (consistent baseline across cards regardless
    // of text length); center is the legacy behavior.
    $_vAlignMap = ['top' => 'flex-start', 'center' => 'center', 'bottom' => 'flex-end'];
    $fbVAlign   = $_vAlignMap[(string)($settings['vertical_align'] ?? 'center')] ?? 'center';
    // Back face has its OWN vertical alignment, fully independent of the
    // front (Sergei 2026-07-20: inheriting the front value read as "front
    // alignment affects back" - a bug). Default center.
    $fbVAlignBack = $_vAlignMap[(string)($settings['vertical_align_back'] ?? 'center')] ?? 'center';
    // Horizontal content alignment (v2.1.1828). Default 'center' keeps the
    // legacy look; 'left'/'right' set align-items + text-align so titles,
    // descriptions, and the optional icon line up on that edge.
    $_hAlignMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
    $fbHAlign     = (string)($settings['content_align'] ?? 'center');
    $fbAlignItems = $_hAlignMap[$fbHAlign] ?? 'center';
    $fbTextAlign  = in_array($fbHAlign, ['left', 'center', 'right'], true) ? $fbHAlign : 'center';
    $baseSideStyle = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:' . $fbAlignItems . ';justify-content:' . $fbVAlign . ';padding:' . $padding . ';text-align:' . $fbTextAlign . ';border-radius:' . $borderRadius . 'px;transition:all 0.6s ease-in-out;' . $fbBorderStyle;
    $baseSideStyleBack = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:' . $fbAlignItems . ';justify-content:' . $fbVAlignBack . ';padding:' . $padding . ';text-align:' . $fbTextAlign . ';border-radius:' . $borderRadius . 'px;transition:all 0.6s ease-in-out;' . $fbBorderStyle;

    // Frosted glass — applied to the OUTER card container, NOT the faces.
    // Browsers reliably skip backdrop-filter when the element is inside a
    // transform-style:preserve-3d ancestor (the .impulse-flip-inner has
    // preserve-3d for spring/cube). Putting the filter on the container
    // (which lives above the 3D context) makes the blur actually paint.
    // The faces sit on top with translucent bg as the tint layer.
    $frostedGlass = !empty($settings['frosted_glass']);

    $fbMaxWidth = intval($settings['section_max_width'] ?? 600);
    // Spring rotates the card through 3D space with perspective:1200px.
    // During the overshoot bounce (cubic-bezier(0.4, 0, 0.2, 1) sweeping
    // past 180deg) the card's projected silhouette extends slightly beyond
    // the front-facing rectangle — overflow:hidden on the container clipped
    // those edges and the flip looked cropped. Skip the base overflow rule
    // for spring; modes that genuinely translate outside (stack, slide,
    // push, cube, depth-rise) add overflow:hidden in their
    // own case branches below.
    //
    // isolation:isolate is added on the frontend to keep front/back z-index
    // stacking contained inside the card — but it creates a new stacking
    // context that backdrop-filter cannot sample outside of (the frost has
    // nothing to blur). Drop it when frosted_glass is on so backdrop-filter
    // can see the page environment behind the card.
    $containerStyle = 'width:100%;'
                    . ($fbMaxWidth > 0 ? 'max-width:' . $fbMaxWidth . 'px;' : '')
                    . 'height:' . $height . 'px;position:relative;'
                    . ($animationType === 'spring' ? '' : 'overflow:hidden;')
                    . ((!$isEditor && !$frostedGlass) ? 'isolation:isolate;' : '');
    if ($frostedGlass) {
        // Container is the only place backdrop-filter survives the
        // preserve-3d ancestor. Give it border-radius so the blur is
        // rounded; the faces still own per-side tint via their own bg.
        $containerStyle .= 'backdrop-filter:blur(20px) saturate(180%);'
                         . '-webkit-backdrop-filter:blur(20px) saturate(180%);'
                         . 'border-radius:' . $borderRadius . 'px;';
    }
    $innerStyle = 'position:relative;width:100%;height:100%;';
    $frontStyle = $baseSideStyle . 'background:' . esc_attr($frontBg) . ';color:' . esc_attr($frontColor) . ';';
    $frontImage = trim((string) ($settings['front_image'] ?? ''));
    if ($frontImage !== '') {
        // Image face: cover, with a bottom scrim so a bottom-aligned title stays
        // legible over any photo. front_bg remains the load-time fallback color.
        $frontStyle .= 'background-image:linear-gradient(to top, rgba(18,14,10,0.78) 0%, rgba(18,14,10,0.18) 55%, rgba(18,14,10,0) 100%), url(' . esc_url($frontImage) . ');background-size:cover;background-position:center;';
    }

    // Editor: back face ignores pointer events so clicking the flipped card can't
    // land on the back (which reflowed/repositioned its bottom-aligned text on click);
    // the click passes through to select the element normally. Frontend keeps the back
    // interactive so back buttons still work.
    $backStyle  = $baseSideStyleBack . 'background:' . esc_attr($backBg) . ';color:' . esc_attr($backColor) . ';' . ($isEditor ? 'pointer-events:none;' : '');

    $dataAttrs = 'data-animation="' . esc_attr($animationType) . '" data-speed="' . $animSpeed . '"';

    // ── Per-animation CSS setup ─────────────────────────────────
    switch ($animationType) {
        case 'fade':
            // No flip: the front simply fades out to reveal the back (which sits
            // underneath at full opacity). Used by demo team cards where the back is
            // the same portrait under a dark scrim, so the bio appears on a dark
            // overlay without the card flipping. The inner is preserve-3d (for the
            // flip effect), where z-index is ignored — so a tiny translateZ keeps the
            // front physically in front; opacity drives the reveal. Sergei 2026-07-13.
            $frontStyle .= 'z-index:2;transform:translateZ(1px);';
            $backStyle  .= 'z-index:1;';
            break;
        case 'slideup':
            // No flip: a (usually translucent, slightly dark) overlay slides UP from the
            // bottom over the front, carrying the back content. The front — a portrait on
            // team cards — stays put and shows through. Inner is flat (not preserve-3d
            // here), so z-index stacks the back above the front. Sergei 2026-07-14.
            $frontStyle .= 'z-index:1;';
            $backStyle  .= 'z-index:2;transform:translateY(100%);';
            break;
        case 'zoom':
            $frontStyle .= 'z-index:2;';
            $backStyle  .= 'z-index:1;transform:scale(0.82);opacity:0;';
            break;
        case 'push':
            $containerStyle .= 'overflow:hidden;';
            $innerStyle .= 'transition:transform 0.6s ease-in-out;';
            $pushOffsets = ['left'=>'transform:translateX(100%);','right'=>'transform:translateX(-100%);','up'=>'transform:translateY(100%);','down'=>'transform:translateY(-100%);'];
            $backStyle .= $pushOffsets[$slideDirection] ?? $pushOffsets['left'];
            $dataAttrs .= ' data-slide-direction="' . esc_attr($slideDirection) . '" data-push="1"';
            break;
        case 'glitch':
            $frontStyle .= 'z-index:2;transition:none;';
            $backStyle  .= 'z-index:1;opacity:0;transition:opacity 0.3s ease-in-out;';
            break;
        case 'fold':
        case 'flip':
        case 'swing':
            $frontStyle .= 'z-index:2;';
            $backStyle  .= 'z-index:1;opacity:0;transform:scale(0.92);';
            break;
        case 'cube':
            $containerStyle .= 'perspective:1000px;overflow:hidden;';
            $cubeTranslate = $height / 2;
            // Counter-translate the inner: faces sit at translateZ(+ct), which
            // perspective projects ~14% LARGER than the layout box (content
            // rendered past the card edges). Pulling the inner back by -ct puts
            // the visible face at net z=0 - true card size (Sergei 2026-07-20).
            $innerStyle .= 'transform-style:preserve-3d;transition:transform 0.6s ease-in-out;transform:translateZ(-' . $cubeTranslate . 'px);';
            $frontStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:translateZ(' . $cubeTranslate . 'px);';
            $backStyle  .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:rotateX(-90deg) translateZ(' . $cubeTranslate . 'px);';
            $dataAttrs  .= ' data-cube-translate="' . $cubeTranslate . '"';
            break;
        case 'depth-rise':
            $containerStyle .= 'perspective:600px;overflow:hidden;';
            $frontStyle .= 'z-index:2;';
            $backStyle  .= 'z-index:1;opacity:0;';
            break;
        case 'spring':
        default:
            $containerStyle .= 'perspective:1200px;';
            $innerStyle .= 'transform-style:preserve-3d;';
            $flipTransform = $flipDirection === 'vertical' ? 'rotateX(180deg)' : 'rotateY(180deg)';
            $frontStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;';
            $backStyle  .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:' . $flipTransform . ';';
            $dataAttrs  .= ' data-flip-direction="' . esc_attr($flipDirection) . '"';
            break;
    }

    // ── Compose HTML ────────────────────────────────────────────
    $flipClass = 'impulse-flip-box';
    if ($visClass !== '') $flipClass .= ' ' . $visClass;

    /* Per-instance id for the @media overrides. Designer-set
       tablet/mobile values for Height + Max Width emit a scoped
       inline-style block keyed on this id (via wp_add_inline_style).
       The global mobile rule in frontend.css that used to clamp every
       flipbox to height:auto + min-height:170px is dropped in this
       release, so the designer's actual sliders drive the per-device dimensions.
       Skip the emit in editor (already device-switches via $read). */
    $elId = isset($el['id']) ? (string) $el['id'] : '';
    $fbInstanceId = ($elId !== '') ? 'impulse-fb-' . preg_replace('/[^a-zA-Z0-9_-]/', '', $elId) : '';
    $idAttr = $fbInstanceId !== '' ? ' id="' . esc_attr($fbInstanceId) . '"' : '';

    if (!$isEditor && $fbInstanceId !== '') {
        $tabletCss = '';
        if ($heightTablet !== null)   $tabletCss .= 'height:' . $heightTablet . 'px;';
        if ($maxWidthTablet !== null) $tabletCss .= 'max-width:' . ($maxWidthTablet > 0 ? $maxWidthTablet . 'px' : 'none') . ';';
        $mobileCss = '';
        if ($heightMobile !== null)   $mobileCss .= 'height:' . $heightMobile . 'px;';
        if ($maxWidthMobile !== null) $mobileCss .= 'max-width:' . ($maxWidthMobile > 0 ? $maxWidthMobile . 'px' : 'none') . ';';
        if ($tabletCss !== '' || $mobileCss !== '') {
            $fbInline = '';
            if ($tabletCss !== '') {
                $fbInline .= '@media (max-width:1024px){#' . $fbInstanceId . '{' . $tabletCss . '}}';
            }
            if ($mobileCss !== '') {
                $fbInline .= '@media (max-width:767px){#' . $fbInstanceId . '{' . $mobileCss . '}}';
            }
            if (function_exists('wp_add_inline_style')) {
                wp_add_inline_style('instinctor-page-inline', $fbInline);
            } else {
                wp_add_inline_style('instinctor-page-inline', $fbInline);
            }
        }
    }

    /* Mobile metric clamp (Sergei 2026-07-24: back face "zoomed and content
       does not fit" on the mobile canvas). Desktop title/content/padding land
       unscaled in a ~400px viewport, so a 24px title + 32px padding fills the
       card wall-to-wall. Cap them DOWNWARD on <=767px - values already below
       the caps pass through, and any explicit *_mobile override the designer
       saved wins (we skip the clamp entirely then). Emitted for BOTH contexts:
       the editor's mobile canvas is a 430px iframe, so the media query applies
       there exactly as on a phone. */
    // A saved *_mobile value EQUAL to its desktop value is panel-materialized
    // junk (touching any control stamps ~24 absent keys), not designer intent -
    // clamp those anyway. Only a mobile override that actually DIFFERS from
    // desktop is respected per metric.
    $_fbRealMob = static function (string $key, $desktop) use ($settings): bool {
        $m = $settings[$key . '_mobile'] ?? null;
        return $m !== null && $m !== '' && intval($m) !== intval($desktop);
    };
    if ($fbInstanceId !== '') {
        $_dTitleF = intval($settings['front_title_size'] ?? 24);
        $_dTitleB = intval($settings['back_title_size'] ?? $_dTitleF);
        $_dContF  = intval($settings['front_content_size'] ?? 14);
        $_dContB  = intval($settings['back_content_size'] ?? $_dContF);
        $_fbClamp = '';
        if (!$_fbRealMob('front_title_size', $_dTitleF) && $_dTitleF > 20) {
            $_fbClamp .= '#' . $fbInstanceId . ' .impulse-flip-front .impulse-flip-title{font-size:20px!important}';
        }
        if (!$_fbRealMob('back_title_size', $_dTitleB) && $_dTitleB > 20) {
            $_fbClamp .= '#' . $fbInstanceId . ' .impulse-flip-back .impulse-flip-title{font-size:20px!important}';
        }
        if (!$_fbRealMob('front_content_size', $_dContF) && $_dContF > 15) {
            $_fbClamp .= '#' . $fbInstanceId . ' .impulse-flip-front .impulse-flip-content{font-size:15px!important}';
        }
        if (!$_fbRealMob('back_content_size', $_dContB) && $_dContB > 15) {
            $_fbClamp .= '#' . $fbInstanceId . ' .impulse-flip-back .impulse-flip-content{font-size:15px!important}';
        }
        $_dPadU = isset($settings['padding']) ? intval($settings['padding']) : null;
        $_padClamped = null;
        if ($_dPadU !== null) {
            if (!$_fbRealMob('padding', $_dPadU) && $_dPadU > 18) $_padClamped = '18px';
        } else {
            $_pt = intval($settings['padding_top'] ?? 24); $_pr = intval($settings['padding_right'] ?? 24);
            $_pb = intval($settings['padding_bottom'] ?? 24); $_pl = intval($settings['padding_left'] ?? 24);
            if (!$_fbRealMob('padding_top', $_pt) && max($_pt, $_pr, $_pb, $_pl) > 18) {
                $_padClamped = min($_pt, 18) . 'px ' . min($_pr, 18) . 'px ' . min($_pb, 18) . 'px ' . min($_pl, 18) . 'px';
            }
        }
        if ($_padClamped !== null) {
            $_fbClamp .= '#' . $fbInstanceId . ' .impulse-flip-front,#' . $fbInstanceId . ' .impulse-flip-back{padding:' . $_padClamped . '!important}';
        }
        if ($_fbClamp !== '') $_fbClamp = '@media (max-width:767px){' . $_fbClamp . '}';
        // Echoed directly (not wp_add_inline_style): that handle never
        // flushes into the editor preview document, and this renderer is the
        // EDITOR's path - the echoed tag lands next to the box markup in both
        // contexts, same as the inline hover-engine JS below.
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $_fbClamp is composed exclusively of intval()/min()-clamped integers and the sanitised element id; no user-controlled markup.
        if ($_fbClamp !== '') echo '<style>' . $_fbClamp . '</style>';
    }

    /* One-time mobile rule: icon_position=left renders a horizontal
       [icon | text] row that's cramped on a phone. On <=767px stack it to a
       centered column (icon above the text), everything centered. Echoed
       (not wp_add_inline_style) so it also lands in the editor preview, and
       shares its guard with the inline renderer in impulse-page.php so the
       page emits it exactly once regardless of which path drew the boxes. */
    if (empty($GLOBALS['impulse_flip_iconrow_css'])) {
        $GLOBALS['impulse_flip_iconrow_css'] = true;
        wp_add_inline_style('instinctor-page-inline', '@media (max-width:767px){'
            . '.impulse-flip-iconrow{flex-direction:column!important;align-items:center!important;gap:10px!important}'
            . '.impulse-flip-iconrow>div{align-items:center!important;text-align:center!important}'
            . '}');
    }

    ob_start();
    // Outer wrapper: skip isolation:isolate when frosted_glass is on so the
    // card's backdrop-filter can sample the page environment behind it. With
    // isolation set, the frost has nothing to blur.
    $outerStyle = ($isEditor || $frostedGlass)
        ? 'display:flex;justify-content:center;width:100%;margin:0 auto;align-self:center'
        : 'display:flex;justify-content:center;width:100%;margin:0 auto;align-self:center;isolation:isolate';
    echo '<div style="' . esc_attr($outerStyle) . '">';
    echo '<div class="' . esc_attr($flipClass) . '"' . wp_kses($idAttr, impulse_svg_allowed()) . ' style="' . esc_attr($containerStyle) . '" ' . wp_kses($dataAttrs, impulse_svg_allowed()) . '>';
    echo '<div class="impulse-flip-inner" style="' . esc_attr($innerStyle) . '">';

    // Front side. impulse_keep_last_words() binds the last two words
    // of each line with a non-breaking space — bulletproof orphan
    // protection that doesn't depend on text-wrap:pretty being honored.
    $_kw = function_exists('impulse_keep_last_words') ? 'impulse_keep_last_words' : function ($s) { return $s; };
    echo '<div class="impulse-flip-front" style="' . esc_attr($frontStyle) . '">';
    // Optional Lucide icon (v2.1.1828). front_icon = a Lucide name; icon
    // inherits the front text color. icon_position (v2.1.1831): 'top'
    // stacks it above the title (default); 'left' places it beside the
    // title+content as a compact horizontal row, the icon centered against
    // the two text lines.
    $frontIcon = (string) ($settings['front_icon'] ?? '');
    $iconPos   = (string) ($settings['icon_position'] ?? 'top');
    $_fiSvg = '';
    if ($frontIcon !== '' && function_exists('impulse_lucide_svg')) {
        $_fiSize = intval($settings['front_icon_size'] ?? 24);
        $_fiSvg  = impulse_lucide_svg($frontIcon, $_fiSize, 'currentColor');
    }
    $titleMarginF = ($frontContent !== '' || $isEditor) ? 'margin-bottom:4px;' : '';
    $_titleStyleF   = 'font-size:' . $frontTitleSize . 'px;font-weight:600;line-height:1.2;text-wrap:pretty;' . $titleMarginF;
    $_titleInnerF   = function_exists('impulse_kses_richtext') ? impulse_kses_richtext($_kw($frontTitle)) : esc_html($_kw($frontTitle));
    $_contentStyleF = 'font-size:' . $frontContentSize . 'px;line-height:1.3;text-wrap:pretty;opacity:0.7';
    $_contentInnerF = function_exists('impulse_kses_richtext') ? impulse_kses_richtext($_kw($frontContent)) : esc_html($_kw($frontContent));
    if ($iconPos === 'left' && $_fiSvg !== '') {
        // Horizontal: icon column + text column. align-items:center keeps the
        // icon vertically centered against the title+description block.
        echo '<div class="impulse-flip-iconrow" style="display:flex;flex-direction:row;align-items:center;gap:12px;width:100%">';
        echo   '<div class="impulse-flip-icon" style="flex:0 0 auto;line-height:0;opacity:0.9">' . wp_kses($_fiSvg, impulse_svg_allowed()) . '</div>';
        echo   '<div style="display:flex;flex-direction:column;min-width:0">';
        echo     '<div class="impulse-flip-title" style="' . esc_attr($_titleStyleF) . '">' . wp_kses_post($_titleInnerF) . '</div>';
        echo     '<div class="impulse-flip-content" style="' . esc_attr($_contentStyleF) . '">' . wp_kses_post($_contentInnerF) . '</div>';
        echo   '</div>';
        echo '</div>';
    } else {
        if ($_fiSvg !== '') {
            echo '<div class="impulse-flip-icon" style="line-height:0;margin-bottom:10px;opacity:0.9">' . wp_kses($_fiSvg, impulse_svg_allowed()) . '</div>';
        }
        echo '<div class="impulse-flip-title" style="' . esc_attr($_titleStyleF) . '">' . wp_kses_post($_titleInnerF) . '</div>';
        echo '<div class="impulse-flip-content" style="' . esc_attr($_contentStyleF) . '">' . wp_kses_post($_contentInnerF) . '</div>';
    }
    echo '</div>';

    // Back side.
    echo '<div class="impulse-flip-back" style="' . esc_attr($backStyle) . '">';
    $titleMarginB = ($backContent !== '' || $backButtonText !== '' || $isEditor) ? 'margin-bottom:12px;' : '';
    $_backTitleInner   = function_exists('impulse_kses_richtext') ? impulse_kses_richtext($_kw($backTitle)) : esc_html($_kw($backTitle));
    $_backContentInner = function_exists('impulse_kses_richtext') ? impulse_kses_richtext($_kw($backContent)) : esc_html($_kw($backContent));
    echo '<div class="impulse-flip-title" style="' . esc_attr('font-size:' . $backTitleSize . 'px;font-weight:600;text-wrap:pretty;' . $titleMarginB) . '">' . wp_kses_post($_backTitleInner) . '</div>';
    echo '<div class="impulse-flip-content" style="' . esc_attr('font-size:' . $backContentSize . 'px;text-wrap:pretty;') . '">' . wp_kses_post($_backContentInner) . '</div>';
    if ($backButtonText !== '') {
        $_btnBg    = $backButtonBg;
        $_btnColor = $backButtonColor !== '' ? $backButtonColor : 'inherit';
        echo '<a href="' . esc_url($backButtonLink) . '" class="impulse-flip-button" style="display:inline-block;margin-top:16px;padding:10px 24px;background:' . esc_attr($_btnBg) . ';color:' . esc_attr($_btnColor) . ';border-radius:6px;text-decoration:none;">' . esc_html($backButtonText) . '</a>';
    }
    echo '</div>';

    echo '</div></div></div>';

    // ── Frontend hover-handler JS (substantial; editor stays inert) ──
    if (!$isEditor) {
        ob_start();
        echo '(function(){
if(!window._impulseFlipBoxRegistry)window._impulseFlipBoxRegistry=[];
function _enh(box){
    var inner=box.querySelector(".impulse-flip-inner"),front=box.querySelector(".impulse-flip-front"),back=box.querySelector(".impulse-flip-back");
    if(!inner||!front||!back)return;
    /* Guard: without it a second call stacks duplicate touch listeners AND
       pushes the box into _impulseFlipBoxRegistry twice, so the outside-tap
       collapse would fire on it repeatedly. The flag is set only once the box
       is complete, so a half-rendered card can still be enhanced later. */
    if(box._impulseFlipTouchBound)return;
    box._impulseFlipTouchBound=true;
    var a=box.dataset.animation||"flip";
    window._impulseFlipBoxRegistry.push(box);
    var sh=null;
    var ts=0,tx=0,ty=0;
    box.addEventListener("touchstart",function(e){ts=Date.now();tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:true});
    box.addEventListener("touchend",function(e){
        var dt=Date.now()-ts,dx=e.changedTouches[0].clientX-tx,dy=e.changedTouches[0].clientY-ty,dist=Math.sqrt(dx*dx+dy*dy);
        if(dist>28||dt>650)return; // tap flips; a >28px swipe is a scroll/screen-nav gesture (ignore), long-press >650ms ignored. Prior <28 required a swipe so plain taps never flipped on mobile.
        var _tc=e.changedTouches[0];
        if(!box._touchFlipped){box._touchFlipped=true;box.dispatchEvent(new MouseEvent("mouseenter",{bubbles:true,cancelable:true,clientX:_tc.clientX,clientY:_tc.clientY}))}
        else{box._touchFlipped=false;box.dispatchEvent(new MouseEvent("mouseleave",{bubbles:true,cancelable:true}))}
    });
}
document.querySelectorAll(".impulse-flip-box").forEach(_enh);
/* Touch wiring, reachable from the hover IIFE below (separate scopes). Only
   impulseInitFlipBoxes uses it, for cards injected after load. */
window._impulseFlipEnhTouch=_enh;
/* Outside-tap collapse: tapping anywhere else folds any open card back to
   its front, so a page of flip boxes cannot end up all stuck on B-sides. */
if(!window._impulseFlipOutside){window._impulseFlipOutside=true;document.addEventListener("touchend",function(e){(window._impulseFlipBoxRegistry||[]).forEach(function(fb){if(fb._touchFlipped&&!fb.contains(e.target)){fb._touchFlipped=false;fb.dispatchEvent(new MouseEvent("mouseleave",{bubbles:true,cancelable:true}))}})},{passive:true});}
if(!window._impulseGyroInit&&("ontouchstart" in window||navigator.maxTouchPoints>0)){
    window._impulseGyroInit=true;
    function _applyGyro(e){
        var ry=(Math.max(-28,Math.min(28,e.gamma||0))/28)*18,rx=-(Math.max(-20,Math.min(20,(e.beta||45)-45))/20)*12;
        (window._impulseFlipBoxRegistry||[]).forEach(function(fb){
            if(fb._touchFlipped||fb._tiltActive)return;
            var rect=fb.getBoundingClientRect();
            if(rect.bottom<0||rect.top>window.innerHeight)return;
            var fi=fb.querySelector(".impulse-flip-inner");if(!fi)return;
            fi.style.transition="transform 0.15s ease-in-out";fi.style.transform="rotateY("+ry+"deg) rotateX("+rx+"deg)";
        });
    }
    function _startG(){if(typeof DeviceOrientationEvent!=="undefined"){if(typeof DeviceOrientationEvent.requestPermission==="function"){DeviceOrientationEvent.requestPermission().then(function(s){if(s==="granted")window.addEventListener("deviceorientation",_applyGyro)}).catch(function(){})}else{window.addEventListener("deviceorientation",_applyGyro)}}}
    document.addEventListener("touchstart",function _gr(){_startG();document.removeEventListener("touchstart",_gr)},{once:true,passive:true});
}
if(!document.getElementById("impulse-tease-css")){var _ts=document.createElement("style");_ts.id="impulse-tease-css";_ts.textContent="@media (pointer:coarse){@keyframes _ift{0%,100%{transform:perspective(600px) rotateY(0deg)}30%{transform:perspective(600px) rotateY(18deg)}60%{transform:perspective(600px) rotateY(-5deg)}}.impulse-flip-box.impulse-tease .impulse-flip-inner{animation:_ift 0.9s cubic-bezier(0.4, 0, 0.2, 1) 1 forwards}}";document.head.appendChild(_ts)}
setTimeout(function(){if(!("ontouchstart" in window)&&navigator.maxTouchPoints<=0)return;document.querySelectorAll(".impulse-flip-box").forEach(function(fb){if(fb._touchFlipped)return;fb.classList.add("impulse-tease");setTimeout(function(){fb.classList.remove("impulse-tease")},1000)})},1200);
})();';
        $_inlineJs1 = ob_get_clean();
        wp_add_inline_script('instinctor-frontend-js', $_inlineJs1, 'after');
        ob_start();
        echo '(function(){
function _rb(back){back.style.transition="none";back.style.transitionDelay="0s";back.style.opacity="0";back.style.zIndex="1";setTimeout(function(){back.style.transition="";back.style.transitionDelay=""},20)}
/* The early `if(!boxes.length)return` that used to sit here is gone: on a page
   whose flip boxes arrive later (the editor demo) it bailed before anything was
   defined, so nothing could ever bind them. forEach over an empty list is the
   same no-op it was standing in for. */
function _bindFlip(b){
var inner=b.querySelector(".impulse-flip-inner"),front=b.querySelector(".impulse-flip-front"),back=b.querySelector(".impulse-flip-back");
if(!inner||!front||!back)return;
var a=b.dataset.animation||"spring",fd=b.dataset.flipDirection||"horizontal",sd=b.dataset.slideDirection||"left",spd=parseInt(b.dataset.speed)||600,ct=parseFloat(b.dataset.cubeTranslate)||125;
var shim=null;function _sh(ms){}
// Spring — smooth 3D flip. Earlier overshoot curve removed (Y > 1
// bounced past 180° and read as toy-ish). Material-standard
// ease-in-out on both directions for a premium feel.
if(a==="spring"){
  b.addEventListener("mouseenter",function(){
    inner.style.transition="transform "+(spd/1000)+"s cubic-bezier(0.4,0,0.2,1)";
    inner.style.transform=fd==="vertical"?"rotateX(180deg)":"rotateY(180deg)";
  });
  b.addEventListener("mouseleave",function(){
    inner.style.transition="transform "+(spd/1000)+"s cubic-bezier(0.4,0,0.2,1)";
    inner.style.transform=""
  });
  return
}
b.addEventListener("mouseenter",function(){
if(a==="cube"){inner.style.transform="translateZ(-"+ct+"px) rotateX(90deg)"}
else if(a==="fade"){front.style.transition="opacity "+(spd*0.55/1000)+"s ease-in-out";front.style.opacity="0"}
else if(a==="slideup"){back.style.transition="transform "+(spd*0.62/1000)+"s cubic-bezier(0.22,1,0.36,1)";back.style.transform="translateY(0)"}
else if(a==="push"){var pm={left:"translateX(-100%)",right:"translateX(100%)",up:"translateY(-100%)",down:"translateY(100%)"};inner.style.transform=pm[sd]||pm.left}
else if(a==="zoom"){if(b._zt)clearTimeout(b._zt);front.style.transition="transform "+(spd*0.45/1000)+"s ease-in-out,opacity "+(spd*0.4/1000)+"s ease-in-out";front.style.transform="scale(0)";front.style.opacity="0";back.style.transition="transform "+(spd*0.5/1000)+"s cubic-bezier(0.4, 0, 0.2, 1) "+(spd*0.18/1000)+"s,opacity "+(spd*0.4/1000)+"s ease-in-out "+(spd*0.18/1000)+"s";back.style.transform="scale(1)";back.style.opacity="1";front.style.zIndex="1";back.style.zIndex="2"}
else if(a==="glitch"){front.classList.add("impulse-flip-glitching");b._gt=setTimeout(function(){front.style.opacity="0";back.style.opacity="1";front.style.zIndex="1";back.style.zIndex="2"},500)}
else if(a==="depth-rise"){front.style.transition="transform "+(spd*0.38/1000)+"s cubic-bezier(0.4,0,1,1),opacity "+(spd*0.32/1000)+"s ease-in-out "+(spd*0.08/1000)+"s";front.style.transform="translateZ(420px)";front.style.opacity="0";front.style.zIndex="1";back.style.zIndex="2";setTimeout(function(){back.style.transition="opacity 0.38s ease-in-out";back.style.opacity="1"},spd*0.28)}
});
b.addEventListener("mouseleave",function(){
if(a==="cube"){inner.style.transition="transform 0.5s ease-in-out";inner.style.transform="translateZ(-"+ct+"px)"}else if(a==="push"){inner.style.transition="transform 0.5s ease-in-out";inner.style.transform=""}
else if(a==="fade"){front.style.opacity=""}
else if(a==="slideup"){back.style.transform="translateY(100%)"}
else if(a==="zoom"){if(b._zt)clearTimeout(b._zt);back.style.transition="transform "+(spd*0.45/1000)+"s ease-in-out,opacity "+(spd*0.4/1000)+"s ease-in-out";back.style.transform="scale(0.82)";back.style.opacity="0";front.style.transition="transform "+(spd*0.5/1000)+"s cubic-bezier(0.4, 0, 0.2, 1) "+(spd*0.18/1000)+"s,opacity "+(spd*0.4/1000)+"s ease-in-out "+(spd*0.18/1000)+"s";front.style.transform="scale(1)";front.style.opacity="1";b._zt=setTimeout(function(){front.style.zIndex="2";back.style.zIndex="1";front.style.transition="";front.style.transform="";front.style.opacity=""},spd*0.7)}
else if(a==="glitch"){if(b._gt)clearTimeout(b._gt);front.classList.remove("impulse-flip-glitching");front.style.opacity="";front.style.zIndex="2";_rb(back)}
else if(a==="depth-rise"){_rb(back);front.style.transition="none";front.style.transform="";front.style.opacity="";front.style.zIndex="2"}
});
}
/* Bind every card present now. Same one-time pass as before, via the guard. */
function _bindOnce(b){if(b._impulseFlipBound)return;b._impulseFlipBound=true;_bindFlip(b);}
document.querySelectorAll(".impulse-flip-box").forEach(_bindOnce);
/* Cards injected AFTER load bind through here. The editor demo calls this when
   it swaps a flip-box sample into its canvas; the real block never needs it,
   because its markup is in the document before this file runs. Idempotent, so
   calling it over a region that is already wired costs nothing. */
window.impulseInitFlipBoxes=function(root){
    var scope=(root&&root.querySelectorAll)?root:document;
    scope.querySelectorAll(".impulse-flip-box").forEach(function(b){
        var fresh=!b._impulseFlipBound;
        _bindOnce(b);
        if(fresh&&typeof window._impulseFlipEnhTouch==="function")window._impulseFlipEnhTouch(b);
    });
};
})();';
        $_inlineJs2 = ob_get_clean();
        wp_add_inline_script('instinctor-frontend-js', $_inlineJs2, 'after');
    }

    return ob_get_clean();
}
