<?php
declare(strict_types=1);

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

/**
 * Image element renderer (shared between frontend and editor).
 *
 * Phase 1, step 3 of the renderer-unification refactor. Image was the
 * most-diverged element of the simple block family. Pre-extraction
 * differences and resolutions:
 *
 * Settings shape:
 *   - Frontend reads `src` (a URL string) primarily, falls back to
 *     `image_id` (DB row id) only when `src` is empty.
 *   - Editor reads `image_id` primarily and resolves via
 *     impulse_resolve_image(); falls back to `image_src` (alt setting
 *     key) for legacy entries.
 *   → Unified: try `src` → `image_id` → `image_src` (any provides a URL).
 *
 * URL resolution:
 *   - Frontend ran inline DB queries against `instinctor_images`
 *     (derivative-first, parent fallback) and fell back to
 *     wp_get_attachment_image_src.
 *   - Editor called the impulse_resolve_image() helper which encapsulates
 *     exactly that logic.
 *   → Unified: both call impulse_resolve_image() (moved to
 *     includes/element-renderers/helpers.php).
 *
 * Tier gating:
 *   - Frontend masked premium hover effects + depth-image for free tier.
 *   - Editor admin always had full access (no gating).
 *   → Preserved: gating runs only when $context === 'frontend' AND the
 *     tier-check globals were primed by impulse_render_frontend_element.
 *
 * Placeholder:
 *   - Editor renders a `.impulse-image-placeholder` (logo SVG on grey
 *     background) when image_id is set but resolution fails AND
 *     $GLOBALS['impulse_is_edit_mode'] is on — so the empty slot is
 *     clickable + visible.
 *   - Frontend renders nothing in this case.
 *   → Preserved: placeholder gated on $context === 'editor' && edit mode.
 *
 * Focal point (object-position):
 *   - Editor supported `focal_point` (default '50% 50%').
 *   - Frontend's old code did not read it.
 *   → Unified: both now honour focal_point in fill-container mode.
 *
 * Hover-effect JS:
 *   - Editor emitted inline <script> via impulse_effect_js() per element.
 *   - Frontend used a global init script (loaded by Assets manager).
 *   → Preserved: inline emission only in editor context. Frontend's data-
 *     hover attribute is enough for the global runtime to attach.
 *
 * Overlay:
 *   - Editor used `--impulse-overlay-color` CSS variable for live-update.
 *   - Frontend baked colour into inline style.
 *   → Unified to the CSS-var pattern; works in both contexts (frontend
 *     just doesn't bind it to a panel slider).
 *
 * Responsive width @media (mobile/tablet width overrides):
 *   - Frontend only.
 *   → Preserved as frontend-only emission (editor renders the active
 *     device's value directly server-side).
 *
 * @param array|object $el       Element node (array on frontend, object on editor).
 * @param array        $settings Resolved settings (editor) or raw (frontend).
 * @param string       $context  'frontend' or 'editor'.
 *
 * @return string Inner HTML (no .impulse-el wrapper).
 *
 * @sideeffect Frontend may call wp_add_inline_style() to emit the
 *             responsive width @media block. Editor may emit an inline
 *             <script> for JS-based hover effects.
 */
function impulse_element_render_image($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    $elId      = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $parentId  = (int) ($el['parent_id'] ?? 0);
    $isEditor  = ($context === 'editor');

    // ── Lightbox mode dispatch ────────────────────────────────────
    // When `lightbox_enabled` is on, the block renders as a small
    // thumbnail-with-spinning-conic-border that opens a portal modal
    // on click (or hover) showing the full image. Previously a separate
    // `image_popup` block + renderer; folded into Image in 2.1.420
    // (one block, two display modes — matches the Modal/Off-canvas
    // pattern). All image-specific settings (width %, fill_container,
    // overlay, hover_effect, depth, link, focal_point) are ignored in
    // lightbox mode; the lightbox has its own field set (thumb_shape,
    // thumb_size, popup_shape, popup_max_width, hover, border_*).
    if (!empty($settings['lightbox_enabled'])) {
        return impulse_element_render_image_lightbox($el, $settings, $context);
    }

    // ── Resolve image URL ──────────────────────────────────────────
    // Try in order: explicit `src` (URL string from older renders) →
    // `image_id` via DB/WP resolver → `image_src` (legacy fallback key).
    $src     = (string) ($settings['src'] ?? '');
    $imageId = (int)    ($settings['image_id'] ?? 0);
    $resolvedImg = null;
    if ($imageId > 0 && function_exists('impulse_resolve_image')) {
        $resolvedImg = impulse_resolve_image($imageId, 'large');
        if ($resolvedImg && !$src) $src = (string) $resolvedImg[0];
    }
    if (!$src) {
        $src = (string) ($settings['image_src'] ?? '');
    }
    // Cutout mode (Wave B-UI): when the designer removed the background,
    // render the transparent variant stored on the image row. The original
    // file stays untouched - toggling off restores it instantly.
    if (!empty($settings['use_cutout']) && $imageId > 0 && class_exists('\\Instinctor\\Core\\DB')) {
        $cutRel = \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
            'SELECT cutout_rel_path FROM `' . \Instinctor\Core\DB::prefix() . 'instinctor_images` WHERE id = %d',
            $imageId
        ));
        if ($cutRel && class_exists('\\Instinctor\\Core\\Storage')) {
            $src = \Instinctor\Core\Storage::url((string) $cutRel);
        }
    }

    // ── Settings ──────────────────────────────────────────────────
    $alt           = (string) ($settings['alt'] ?? '');
    $width         = $settings['width'] ?? 50;
    $link          = (string) ($settings['link'] ?? '');
    $borderRadius  = intval($settings['border_radius'] ?? 0);
    $fillContainer = !empty($settings['fill_container']);

    // ── Image v2 (Sergei 2026-06-06): Pin-to-screen + per-corner radius ──
    $pinToScreen = !empty($settings['image_pin_to_screen']);
    $imgHAlign   = (string) ($settings['image_h_align'] ?? 'center');
    $imgVAlign   = (string) ($settings['image_v_align'] ?? 'center');
    $imgOffsetX  = isset($settings['image_offset_x']) && $settings['image_offset_x'] !== '' ? (float) $settings['image_offset_x'] : 0.0;
    $imgOffsetY  = isset($settings['image_offset_y']) && $settings['image_offset_y'] !== '' ? (float) $settings['image_offset_y'] : 0.0;
    // Flow offset: relative `top` nudge for in-flow images. Unlike margin,
    // it never moves surrounding content - the layout box stays put.
    $flowOffsetY = isset($settings['flow_offset_y']) && $settings['flow_offset_y'] !== '' ? (int) $settings['flow_offset_y'] : 0;
    $flowOffsetX = isset($settings['flow_offset_x']) && $settings['flow_offset_x'] !== '' ? (int) $settings['flow_offset_x'] : 0;
    $imgSizeMin  = isset($settings['image_size_min'])   && $settings['image_size_min']   !== '' ? (int) $settings['image_size_min']   : 100;
    $imgSizeFlu  = isset($settings['image_size_fluid']) && $settings['image_size_fluid'] !== '' ? (int) $settings['image_size_fluid'] : 30;
    $imgSizeMax  = isset($settings['image_size_max'])   && $settings['image_size_max']   !== '' ? (int) $settings['image_size_max']   : 600;
    $imgZ        = isset($settings['image_z'])          && $settings['image_z']          !== '' ? (int) $settings['image_z']          : 0;
    // Per-corner radius: empty -> inherit legacy $borderRadius. Allows asymmetric (pill).
    $rTL = (isset($settings['image_radius_tl']) && $settings['image_radius_tl'] !== '') ? (int) $settings['image_radius_tl'] : $borderRadius;
    $rTR = (isset($settings['image_radius_tr']) && $settings['image_radius_tr'] !== '') ? (int) $settings['image_radius_tr'] : $borderRadius;
    $rBR = (isset($settings['image_radius_br']) && $settings['image_radius_br'] !== '') ? (int) $settings['image_radius_br'] : $borderRadius;
    $rBL = (isset($settings['image_radius_bl']) && $settings['image_radius_bl'] !== '') ? (int) $settings['image_radius_bl'] : $borderRadius;
    $cornerRadiusCss = sprintf('%dpx %dpx %dpx %dpx', $rTL, $rTR, $rBR, $rBL);

    // ── Directional edge shadow (Sergei v2.1.1232) ──
    // Multi-side soft shadow OUTSIDE the silhouette so the image looks
    // recessed into the page. Per-side toggles + Depth + Soft. Filter
    // build extracted to Helpers\ShadowFilter in v2.1.1305 so the
    // video block reuses the exact same engine. Image uses the
    // 'image_shadow_' prefix; video uses 'video_shadow_'.
    $_shadow   = \Instinctor\Helpers\ShadowFilter::build($settings, intval($elId), 'image_shadow_');
    $shadowCss = $_shadow['css'];
    $shadowSvg = $_shadow['svg'];

    // Outline removed v2.1.1245. Empty stubs kept so all downstream echo
    // points and style concatenations still resolve without conditionals.
    $outlineSvg = '';

    // Image v2 Wave 2 — shape mask (shared catalog with video block).
    $maskUrl     = (string) ($settings['mask_url'] ?? '');
    $maskViewbox = (string) ($settings['mask_viewbox'] ?? '');
    $maskCss     = '';
    if ($maskUrl !== '') {
        $maskRef = str_starts_with($maskUrl, 'data:') ? 'url(' . $maskUrl . ')' : 'url(' . esc_url($maskUrl) . ')';
        // Stretch the mask to the wrapper's full rect so edge curves
        // align with the actual image edges across any aspect ratio.
        // Symmetric M-series masks become slightly oval — acceptable as
        // a creative effect, and equivalent to authoring per-aspect.
        $maskCss = '-webkit-mask-image:' . $maskRef . ';mask-image:' . $maskRef . ';'
                 . '-webkit-mask-size:100% 100%;mask-size:100% 100%;'
                 . '-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;'
                 . '-webkit-mask-position:center;mask-position:center;';
    }
    // Image v2 — Unified Crop+Curve (closed-loop Catmull-Rom).
    // Path math + clip-path SVG/CSS generation moved to
    // \Instinctor\Helpers\CropPath in v2.1.1274 so the video block can
    // reuse the same engine. The helper reads all `image_crop_*` keys
    // and returns the wrapper's clip-path CSS + the supporting SVG.
    $_crop = \Instinctor\Helpers\CropPath::build($settings, intval($elId), 'image_crop_');
    $cropCss = $_crop['css'];
    $cropSvg = $_crop['svg'];

    // Below: parse-only branch kept for backward-compat detection of the
    // legacy inset values that some upstream code may still reference.
    // The helper above already produces the canonical clip-path output.

    // Anchor -> CSS: pick top/bottom/center + left/right/center.
    // We use `inset` properties so the offset is from the chosen anchor edge.
    $anchorCss = '';
    if ($pinToScreen) {
        // Vertical anchor
        if ($imgVAlign === 'top')         $anchorCss .= 'top:0;';
        elseif ($imgVAlign === 'bottom')  $anchorCss .= 'bottom:0;';
        else                              $anchorCss .= 'top:50%;';
        // Horizontal anchor
        if ($imgHAlign === 'left')        $anchorCss .= 'left:0;';
        elseif ($imgHAlign === 'right')   $anchorCss .= 'right:0;';
        else                              $anchorCss .= 'left:50%;';
        // Centre-offset for center anchors so the image's centre aligns to the anchor.
        // Combined with the user's offset in vw/vh. calc() rejects bare 0 mixed with
        // length units, so corner cases use 0vw/0vh (otherwise the inline-style
        // declaration is dropped and the live editor's transform setter silently
        // keeps the previous translate, leaving the image visually displaced).
        $cxAdj = ($imgHAlign === 'center') ? '-50%' : '0vw';
        $cyAdj = ($imgVAlign === 'center') ? '-50%' : '0vh';
        $anchorCss .= sprintf('transform:translate(calc(%s + %svw), calc(%s + %svh));', $cxAdj, $imgOffsetX, $cyAdj, $imgOffsetY);
        $anchorCss .= sprintf('width:clamp(%dpx, %dvw, %dpx);', $imgSizeMin, $imgSizeFlu, $imgSizeMax);
        $anchorCss .= sprintf('z-index:%d;', $imgZ + 2); // +2 so it sits above overlays
    }
    $overlayColor  = (string) ($settings['overlay_color'] ?? '');
    $overlayOpacity = intval($settings['overlay_opacity'] ?? 50);
    $hoverEffect   = (string) ($settings['hover_effect'] ?? 'none');
    $focalPoint    = (string) ($settings['focal_point'] ?? '50% 50%');

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

    // ── Tier-gating (frontend only) ───────────────────────────────
    // Premium (JS-driven) hover effects gate behind hover_effects_premium.
    // Depth-image gates behind hover_effects_image_depth.
    $depthMap      = (string) ($settings['depth_map'] ?? '');
    $depthStrength = intval($settings['depth_strength'] ?? 50);
    $allowDepth    = true;
    if (!$isEditor) {
        // Hover-effects-premium gating: free-tier swaps premium hover
        // effects down to a basic zoom. As of v2.0 hover effects are
        // unlocked unconditionally — `canHoverPremium` is always true —
        // but the code path is preserved for parity with the inline
        // original in case a future tier check resumes filtering.
        $canHoverPremium = true; // hover effects free since 2.0
        if (!$canHoverPremium) {
            $premiumHoverEffects = [
                'tilt3d', 'flip3d', 'depth_glass', 'portal', 'glitch', 'hologram',
                'magnetic', 'thermal', 'burn', 'slices', 'pixelate', 'preview', 'shatter',
            ];
            if (in_array($hoverEffect, $premiumHoverEffects, true)) {
                $hoverEffect = 'zoom';
            }
        }
        // 3D depth renders on every tier. The free build burns the Instinctor
        // watermark into the depth output (build-time engine flag in
        // impulse-depth-parallax.js); Pro renders it clean. No license gate.
    }

    // ── Entrance animation (frontend has it; editor strips animations
    //     during edit so the user can position elements without motion). ──
    $animAttrs = ['class' => '', 'style' => ''];
    if (!$isEditor && function_exists('impulse_get_animation_attrs')) {
        $animAttrs = impulse_get_animation_attrs($settings);
    }

    // ── Hover effect attrs (data-hover read by global JS init) ────
    // Pin-to-screen DISABLES hover effects. JS-driven hover effects (slices,
    // tilt3d, etc.) measure the wrapper at init time, hide/clone the <img>,
    // and reveal it on hover. With position:fixed + clamp() width, the
    // measurement-then-reveal cycle produces drastic size jumps. Hover
    // effects are designed for flow-laid images; pinned images aren't.
    if ($pinToScreen) {
        $hoverEffect = 'none';
    }
    $hoverClass = ($hoverEffect && $hoverEffect !== 'none') ? ' impulse-img-hover' : '';
    // Raw value; escaped inline at each wrapper echo via esc_attr().
    $hoverVal   = ($hoverEffect && $hoverEffect !== 'none') ? $hoverEffect : '';

    // ── Depth-image attrs ─────────────────────────────────────────
    // Image v2 Wave B guard: cutout files (transparent WebP/PNG produced
    // by the rembg pipeline) must NEVER be subject to depth-map. The
    // depth model reads the background as a "far" plane and computes
    // garbage parallax around the silhouette edge. Skip depth entirely
    // when the cutout is active or the
    // source path looks like a cutout file. Rows may carry both a depth
    // pair and a cutout; this render-level gate IS the rule: cutout wins
    // while use_cutout is on, depth applies when the background is back.
    if ($depthMap && $src && (
        !empty($settings['use_cutout']) ||
        str_ends_with($src, '-cutout.webp') ||
        str_contains($src, '/cutouts/') ||
        str_contains($src, '/cutout/')
    )) {
        $allowDepth = false;
        $depthMap = '';
    }
    // Raw depth values; escaped inline at each wrapper echo. Empty src
    // signals "no depth attrs" at the echo sites.
    $depthSrc       = '';
    $depthResolved  = '';
    // Free build: image-block 3D/depth is an editor-only demo. Skip it on the
    // front end ($isEditor false) so the block publishes the flat source image;
    // the editor ($isEditor true) keeps the live 3D preview.
    $_depthDemoOnly = function_exists('instinctor_is_free_build') && instinctor_is_free_build() && !$isEditor;
    if ($allowDepth && $depthMap && $src && !$_depthDemoOnly) {
        if (!$isEditor && function_exists('impulse_resolve_depth_map')) {
            $resolvedDepthMap = impulse_resolve_depth_map($depthMap, $src);
        } else {
            $resolvedDepthMap = $depthMap;
        }
        $depthSrc      = $src;
        $depthResolved = (string) $resolvedDepthMap;
        // The depth engine only loads when something asks for it. The page
        // template's pre-scan flags SCREEN-level depth (screen_depth_* on
        // sections) but never image-block depth - on pages without a depth
        // environment the attrs rendered with no runtime and the effect
        // silently did nothing on the frontend. Enqueue from the renderer,
        // same pattern as the kinetic block (the footer print pass picks
        // up renderer-time enqueues).
        if (!$isEditor && class_exists('\\Instinctor\\Core\\Assets')) {
            $GLOBALS['impulse_needs_depth_parallax'] = true;
            \Instinctor\Core\Assets::enqueue('instinctor-three');
            \Instinctor\Core\Assets::enqueue('instinctor-depth-parallax');
        }
    }

    // ── Overlay HTML — REMOVED Image v2 cleanup. The empty editor shell
    // div was sitting on top of the <img> with z-index:1, blocking some
    // hover-effect :hover triggers. Designers don't use the colored-tint
    // overlay; cut entirely.
    $overlayHtml = '';

    // ── Build HTML ────────────────────────────────────────────────
    ob_start();

    if ($src) {
        // Real image — branch on fill-container vs natural width.
        if ($fillContainer) {
            $wrapStyle = 'position:absolute;top:0;left:0;width:100%;height:100%;';
            $imgStyle  = 'width:100%;height:100%;object-fit:cover;object-position:' . esc_attr($focalPoint) . ';display:block;';
            $imgStyle .= 'border-radius:var(--impulse-border-radius,' . $borderRadius . 'px);';
            if ($animAttrs['style']) $imgStyle .= $animAttrs['style'];
            // Shadow filter goes on the <img>, not the wrapper. CSS filter
            // composites the entire subtree of the element it's on, so
            // placing it on the wrap was tinting the crop-overlay handles
            // and tangent lines during editing. The img already has the
            // clip-path silhouette applied, so the filter's SourceAlpha is
            // the same shape either way and the visual output is
            // identical. v2.1.1265.
            $imgStyle .= $shadowCss;
            $imgClass = 'impulse-img impulse-focal-' . $elId;
            if ($animAttrs['class']) $imgClass .= ' ' . $animAttrs['class'];
            // Per-device focal (frontend): desktop object-position is inline on the img
            // (resolved per device upstream in the editor); tablet/mobile focal apply via
            // media queries on the img's unique class. Emitted only when a per-device
            // focal is set, so default images are unchanged. v2.1.1779.
            if (!$isEditor && function_exists('impulse_responsive_css')) {
                $_focalCss = impulse_responsive_css($settings, '.impulse-focal-' . $elId, ['focal_point' => function($v){ $v = preg_replace('/[^0-9%. ]/', '', (string) $v); return $v !== '' ? 'object-position:' . $v : ''; }]);
                if ($_focalCss) wp_add_inline_style('instinctor-page-inline', $_focalCss);
            }

            echo '<div class="impulse-image-wrapper' . ($hoverClass ? ' ' . esc_attr(trim($hoverClass)) : '') . '" style="' . esc_attr($wrapStyle . $maskCss . $cropCss) . 'border-radius:var(--impulse-border-radius,' . intval($borderRadius) . 'px)"' . ($hoverVal !== '' ? ' data-hover="' . esc_attr($hoverVal) . '"' : '') . ($depthSrc !== '' ? ' data-depth-image="' . esc_attr($depthSrc) . '" data-depth-map="' . esc_attr($depthResolved) . '" data-depth-strength="' . intval($depthStrength) . '"' : '') . '>';
            echo wp_kses($cropSvg, impulse_svg_allowed());
            echo wp_kses($shadowSvg, impulse_svg_allowed());
            if ($link) {
                echo '<a href="' . esc_url($link) . '" class="imp-fill" style="display:block;width:100%;height:100%"><img class="' . esc_attr($imgClass) . '" src="' . esc_url($src) . '" alt="' . esc_attr($alt) . '" style="' . esc_attr($imgStyle) . '"></a>';
            } else {
                echo '<img class="' . esc_attr($imgClass) . '" src="' . esc_url($src) . '" alt="' . esc_attr($alt) . '" style="' . esc_attr($imgStyle) . '">';
            }
            echo esc_html($overlayHtml);
            echo '</div>';
        } else {
            $imgStyle  = 'width:100%;display:block;';
            $imgStyle .= 'border-radius:var(--impulse-border-radius,' . $borderRadius . 'px);';
            if ($animAttrs['style']) $imgStyle .= $animAttrs['style'];
            // Shadow filter goes on the img (see note above). v2.1.1265.
            $imgStyle .= $shadowCss;
            $imgClass = 'impulse-img';
            if ($animAttrs['class']) $imgClass .= ' ' . $animAttrs['class'];

            // margin-top/margin-bottom: editor reads via CSS vars (live-
            // update); frontend has root-level auto-center.
            $marginStyle = $isEditor
                ? 'margin-top:var(--impulse-margin-top,0px);margin-bottom:var(--impulse-margin-bottom,0px);'
                : (empty($parentId) ? 'margin:0 auto;' : '');
            // On the frontend, honour the parent section's content_align via the
            // --impulse-ca CSS variable (set by the section renderer on its own
            // element and inherited by all descendants). align-self on a direct
            // flex child positions it within the flex container without affecting
            // the child's own width. Default flex-start = left when no var set.
            $alignSelf = (!$isEditor && !empty($parentId)) ? 'align-self:var(--impulse-ca,flex-start);' : '';

            // Image v2: Pin-to-screen uses position:fixed.
            // WYSIWYG width base: the editor desktop canvas is a 1920px
            // design surface, but full-width sections track the real
            // viewport, so N% grew past the editor rendering on wider
            // monitors. Cap the resolved width at N% of 1920: identical
            // below 1920, stops growing beyond it. In the EDITOR the
            // width lives on the outer .impulse-el (lifted v2.1.1511 so
            // the block hugs the image - see the wrapper builder in
            // impulse-preview.php; the --impulse-width slider fast path
            // targets that element); the inner wrapper just fills it.
            $_widthCss = $isEditor
                ? 'width:100%;'
                : 'width:min(var(--impulse-width,' . intval($width) . '%),' . round(intval($width) * 19.2, 1) . 'px);';
            $_pinStyle = $pinToScreen
                ? ('position:fixed;transition:none!important;' . $anchorCss)
                : ('position:relative;' . $_widthCss . 'max-width:100%;' . $alignSelf . $marginStyle);
            echo '<div id="' . esc_attr($imgElId) . '" class="impulse-image-wrapper' . ($hoverClass ? ' ' . esc_attr(trim($hoverClass)) : '') . ($pinToScreen ? ' impulse-image-pinned' : '') . '" style="' . esc_attr($_pinStyle . $maskCss . $cropCss) . 'border-radius:' . esc_attr($cornerRadiusCss) . ';"' . ($hoverVal !== '' ? ' data-hover="' . esc_attr($hoverVal) . '"' : '') . ($depthSrc !== '' ? ' data-depth-image="' . esc_attr($depthSrc) . '" data-depth-map="' . esc_attr($depthResolved) . '" data-depth-strength="' . intval($depthStrength) . '"' : '') . '>';
            echo wp_kses($cropSvg, impulse_svg_allowed());
            echo wp_kses($shadowSvg, impulse_svg_allowed());
            if ($link) {
                echo '<a href="' . esc_url($link) . '" style="display:block"><img class="' . esc_attr($imgClass) . '" src="' . esc_url($src) . '" alt="' . esc_attr($alt) . '" style="' . esc_attr($imgStyle) . '"></a>';
            } else {
                echo '<img class="' . esc_attr($imgClass) . '" src="' . esc_url($src) . '" alt="' . esc_attr($alt) . '" style="' . esc_attr($imgStyle) . '">';
            }
            echo esc_html($overlayHtml);
            echo '</div>';
        }
    } elseif ($isEditor && !empty($GLOBALS['impulse_is_edit_mode'])) {
        // Editor placeholder — no resolved image src for any reason:
        // image_id missing (just-created block), set-but-unresolvable,
        // or legacy data with neither src nor image_id. Show the
        // Instinctor logo on a grey card so the user always has
        // something clickable to swap an image into.
        //
        // Regression note: the extraction in a03ec6f added a
        // `$imageId > 0` guard here, which silently hid the placeholder
        // for just-created blocks (where image_id is 0). The
        // pre-extraction code only required edit mode — restored.
        $logoUrl = defined('INSTINCTOR_PLUGIN_URL') ? INSTINCTOR_PLUGIN_URL . 'assets/images/placeholder-logo.svg' : '';
        if ($fillContainer) {
            echo '<div class="' . esc_attr(trim($hoverClass)) . '" style="position:relative;width:100%;height:100%;border-radius:var(--impulse-border-radius,' . intval($borderRadius) . 'px);overflow:hidden"' . ($hoverVal !== '' ? ' data-hover="' . esc_attr($hoverVal) . '"' : '') . '>';
            echo '<div class="impulse-image-placeholder" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#f5f5f5;border-radius:var(--impulse-border-radius,' . intval($borderRadius) . 'px);box-sizing:border-box;cursor:pointer">';
            echo '<img src="' . esc_url($logoUrl) . '" style="width:40px;height:auto;cursor:pointer">';
            echo '</div>';
            echo esc_html($overlayHtml);
            echo '</div>';
        } else {
            echo '<div class="' . esc_attr(trim($hoverClass)) . '" style="position:relative;width:100%;border-radius:var(--impulse-border-radius,' . intval($borderRadius) . 'px);overflow:hidden"' . ($hoverVal !== '' ? ' data-hover="' . esc_attr($hoverVal) . '"' : '') . '>';
            echo '<div class="impulse-image-placeholder" style="width:100%;aspect-ratio:3/2;display:flex;align-items:center;justify-content:center;background:#f5f5f5;border-radius:var(--impulse-border-radius,' . intval($borderRadius) . 'px);box-sizing:border-box;cursor:pointer">';
            echo '<img src="' . esc_url($logoUrl) . '" style="width:40px;height:auto;cursor:pointer">';
            echo '</div>';
            echo esc_html($overlayHtml);
            echo '</div>';
        }
    }

    // ── Editor: emit inline JS for JS-driven hover effects ────────
    // Frontend uses a global init script loaded by Assets manager;
    // editor needs the inline emission because the preview iframe loads
    // a curated subset of scripts.
    if ($isEditor && $hoverEffect && function_exists('impulse_js_effects') && in_array($hoverEffect, impulse_js_effects(), true)) {
        ob_start();
        if (function_exists('impulse_effect_js')) {
            impulse_effect_js('.impulse-el[data-id="' . $elId . '"]', $hoverEffect, '.impulse-img-hover');
        }
        // NOTE: no manual </script> here. wp_add_inline_script wraps the JS in
        // its own <script> tag; a stray closing tag inside the buffer prematurely
        // terminates the editor's concatenated inline-script block, breaking every
        // JS-driven hover effect (tilt3d/flip3d/glitch/...). Leftover from the
        // raw-inline-<script> era, dropped in the WP.org enqueue conversion but
        // this echo was missed. Removed 2026-07-04.
        $_inlineJs1 = ob_get_clean();
        wp_add_inline_script('instinctor-frontend-js', $_inlineJs1, 'after');
    }

    // ── Pin-to-screen positioning ──────────────────────────────────────
    // The .impulse-image-wrapper is position:absolute. Its nearest
    // positioned ancestor is normally the screen's .impulse-section
    // (position:relative, min-height:100vh in screen mode). No hoist
    // needed — letting position:absolute resolve naturally. If the user
    // hits a screen layout where the section isn't 100vh, we use CSS
    // env() / dvh on the wrapper so it stays viewport-relative.

    $html = ob_get_clean();

    // ── Flow offsets ───────────────────────────────────────────────
    // Applied to the BLOCK wrapper (selection hitbox included), not the
    // inner image - clicking the moved image still selects it. Relative
    // top/left keeps the layout box in place so siblings never shift.
    // Selector covers both render hosts: #impulse-el-N (frontend) and
    // .impulse-el[data-id] (editor canvas).
    $_offTY = isset($settings['flow_offset_y_tablet']) && $settings['flow_offset_y_tablet'] !== '' ? (int) $settings['flow_offset_y_tablet'] : null;
    $_offMY = isset($settings['flow_offset_y_mobile']) && $settings['flow_offset_y_mobile'] !== '' ? (int) $settings['flow_offset_y_mobile'] : null;
    $_offTX = isset($settings['flow_offset_x_tablet']) && $settings['flow_offset_x_tablet'] !== '' ? (int) $settings['flow_offset_x_tablet'] : null;
    $_offMX = isset($settings['flow_offset_x_mobile']) && $settings['flow_offset_x_mobile'] !== '' ? (int) $settings['flow_offset_x_mobile'] : null;
    if ($flowOffsetY !== 0 || $flowOffsetX !== 0 || $_offTY !== null || $_offMY !== null || $_offTX !== null || $_offMX !== null) {
        // Editor wraps blocks in .impulse-el[data-id] (the selection
        // hitbox, which must move WITH the image); the frontend has NO
        // such wrapper - the image wrapper #img-N IS the block in flow.
        // One selector per context, never both: in the editor #img-N
        // sits INSIDE .impulse-el and matching both doubles the offset.
        // (#impulse-el-N matched nothing on the frontend - offsets were
        // editor-only until 2026-06-12.)
        $_offSel = $isEditor
            ? '.impulse-el[data-id="' . (int) $elId . '"]'
            : '#' . $imgElId;
        $_offCss = $_offSel . '{position:relative;top:' . $flowOffsetY . 'px;left:' . $flowOffsetX . 'px}';
        $_tProps = '';
        if ($_offTY !== null) $_tProps .= 'top:' . $_offTY . 'px!important;';
        if ($_offTX !== null) $_tProps .= 'left:' . $_offTX . 'px!important;';
        if ($_tProps !== '') $_offCss .= '@media(max-width:1024px){' . $_offSel . '{' . $_tProps . '}}';
        $_mProps = '';
        if ($_offMY !== null) $_mProps .= 'top:' . $_offMY . 'px!important;';
        if ($_offMX !== null) $_mProps .= 'left:' . $_offMX . 'px!important;';
        if ($_mProps !== '') $_offCss .= '@media(max-width:767px){' . $_offSel . '{' . $_mProps . '}}';
        wp_add_inline_style('instinctor-page-inline', $_offCss);
    }

    // ── Frontend: responsive width @media overrides ───────────────
    if (!$isEditor && !$fillContainer && $src && function_exists('wp_add_inline_style')) {
        $imgResp = '';
        $tW = isset($settings['width_tablet']) && $settings['width_tablet'] !== '' ? intval($settings['width_tablet']) : null;
        $mW = isset($settings['width_mobile']) && $settings['width_mobile'] !== '' ? intval($settings['width_mobile']) : null;
        if ($tW !== null) $imgResp .= '@media(max-width:1024px){#' . $imgElId . '{width:' . $tW . '%!important}}';
        if ($mW !== null) $imgResp .= '@media(max-width:767px){#'  . $imgElId . '{width:' . $mW . '%!important}}';
        if ($imgResp) wp_add_inline_style('instinctor-page-inline', $imgResp);
    }

    return $html;
}

/**
 * Lightbox mode renderer for the Image block.
 *
 * Two-stage element: a small thumbnail with an animated conic-gradient
 * border, and a portal modal that opens on click or hover showing the
 * full image without chrome (no border, no background).
 *
 * Settings (all inherit defaults from the surrounding Image block; only
 * the lightbox-specific fields are documented here):
 *   - thumb_shape       'circle' | 'square'   (default 'circle')
 *   - thumb_size        (px, 40-300)          (default 96)
 *   - popup_shape       'circle' | 'rect'     (default 'rect')
 *   - popup_max_width   (px, 240-1400)        (default 900)
 *   - hover             (bool) open on hover (else click)
 *   - border_speed      (s, 2-30)             (default 8) rotation period
 *   - border_color_1, border_color_2          accent + neutral border tones
 *   - border_width      (px, 1-6)             (default 2)
 *
 * Lifted verbatim from the standalone image_popup.php renderer in
 * 2.1.420 — that file deleted as part of the merge.
 *
 * @param array  $el       Element node (already normalised to array).
 * @param array  $settings Settings.
 * @param string $context  'frontend' or 'editor'.
 *
 * @return string Inner HTML (no .impulse-el wrapper).
 */
function impulse_element_render_image_lightbox(array $el, array $settings, string $context): string {
    $elId = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $isEditor = ($context === 'editor');

    // Resolve image URL (same fallback order as standard Image block).
    $src     = (string) ($settings['src'] ?? '');
    $imageId = (int)    ($settings['image_id'] ?? 0);
    if ($imageId > 0 && function_exists('impulse_resolve_image')) {
        $resolved = impulse_resolve_image($imageId, 'large');
        if ($resolved && !$src) $src = (string) $resolved[0];
    }
    if (!$src) $src = (string) ($settings['image_src'] ?? '');

    // Settings (clamped). `hover` boolean wins if set; legacy `trigger`
    // string supported for back-compat with any old image_popup rows.
    $alt           = (string) ($settings['alt'] ?? '');
    $thumbShape    = ($settings['thumb_shape'] ?? 'circle') === 'square' ? 'square' : 'circle';
    $thumbSize     = max(40, min(300, intval($settings['thumb_size'] ?? 96)));
    $popupShape    = ($settings['popup_shape'] ?? 'rect') === 'circle' ? 'circle' : 'rect';
    $popupMaxWidth = max(240, min(1400, intval($settings['popup_max_width'] ?? 900)));
    $hoverFlag     = array_key_exists('hover', $settings)
                       ? !empty($settings['hover'])
                       : (($settings['trigger'] ?? 'click') === 'hover');
    $trigger       = $hoverFlag ? 'hover' : 'click';
    $borderSpeed   = max(2, min(30, intval($settings['border_speed'] ?? 8)));
    $borderC1      = (string) ($settings['border_color_1'] ?? '#1d2327');
    $borderC2      = (string) ($settings['border_color_2'] ?? '#cccccc');
    $borderWidth   = max(1, min(6, intval($settings['border_width'] ?? 2)));

    $popupId = 'imgpop-' . intval($elId);

    // Singleton CSS (shared rules, emitted once per page).
    if (empty($GLOBALS['impulse_image_popup_css_emitted'])) {
        $GLOBALS['impulse_image_popup_css_emitted'] = true;
        $shared = '';
        $shared .= '@keyframes impulse-ipp-spin{from{--ipp-angle:0deg}to{--ipp-angle:360deg}}';
        $shared .= '@property --ipp-angle{syntax:"<angle>";initial-value:0deg;inherits:false}';
        $shared .= '.impulse-image-popup-wrap{display:inline-block;position:relative;line-height:0;vertical-align:middle}';
        $shared .= '.impulse-image-popup-wrap.is-open{z-index:10000}';
        $shared .= '.impulse-image-popup-thumb{position:relative;display:block;width:var(--ipp-size,96px);height:var(--ipp-size,96px);padding:var(--ipp-bw,2px);margin:0;border:0;outline:0;box-sizing:border-box;cursor:pointer;background:conic-gradient(from var(--ipp-angle,0deg),var(--ipp-c1,#1d2327),var(--ipp-c2,#ccc),var(--ipp-c1,#1d2327));background-clip:padding-box;animation:impulse-ipp-spin var(--ipp-bspeed,8s) linear infinite;transition:transform 0.3s ease-in-out,box-shadow 0.3s ease-in-out;-webkit-tap-highlight-color:transparent}';
        $shared .= '.impulse-image-popup-thumb:focus{outline:0}';
        $shared .= '.impulse-image-popup-thumb:hover{transform:scale(1.06);box-shadow:0 6px 20px rgba(0,0,0,0.18)}';
        $shared .= '.impulse-image-popup-thumb-circle{border-radius:50%}';
        $shared .= '.impulse-image-popup-thumb-square{border-radius:8px}';
        $shared .= '.impulse-image-popup-thumb img{display:block;width:100%;height:100%;object-fit:cover;border-radius:inherit;background:transparent;pointer-events:none}';
        $shared .= '.impulse-image-popup-backdrop{position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.7);opacity:0;pointer-events:none;transition:opacity 0.22s linear}';
        $shared .= '.impulse-image-popup-backdrop.active{opacity:1;pointer-events:auto}';
        $shared .= '.impulse-image-popup-modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(0.96);max-width:min(var(--ipp-popup-w,900px),95vw);max-height:90vh;z-index:9999;opacity:0;pointer-events:none;transition:opacity 0.22s ease-in-out,transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);line-height:0}';
        $shared .= '.impulse-image-popup-modal.active{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto}';
        $shared .= '.impulse-image-popup-modal[hidden]{display:none}';
        $shared .= '.impulse-image-popup-modal img{display:block;width:100%;height:auto;max-height:90vh;object-fit:contain;border-radius:6px}';
        $shared .= '.impulse-image-popup-modal-circle img{border-radius:50%}';
        $shared .= '.impulse-image-popup-close{position:absolute;top:-44px;right:0;width:36px;height:36px;background:rgba(255,255,255,0.92);border:none;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#1d2327;transition:background 0.2s;z-index:2}';
        $shared .= '.impulse-image-popup-close:hover{background:#fff}';
        $shared .= '@media (prefers-reduced-motion:reduce){.impulse-image-popup-thumb{animation:none}}';
        // Inline-echo unconditionally. The wp_add_inline_style path queued
        // the CSS to a stylesheet handle that the editor's partial-refresh
        // path never reaches, so lightbox images rendered without their
        // styles -- thumb showed at 1539x1031 light-grey with no border
        // radius, modal had no close-button positioning. The GLOBALS flag
        // above already guarantees we only emit once per request.
        wp_add_inline_style('instinctor-page-inline', $shared);
    }

    $cssVars  = '--ipp-size:'    . $thumbSize . 'px;';
    $cssVars .= '--ipp-bw:'      . $borderWidth . 'px;';
    $cssVars .= '--ipp-bspeed:'  . $borderSpeed . 's;';
    $cssVars .= '--ipp-c1:'      . esc_attr($borderC1) . ';';
    $cssVars .= '--ipp-c2:'      . esc_attr($borderC2) . ';';
    $cssVars .= '--ipp-popup-w:' . $popupMaxWidth . 'px;';

    ob_start();

    if (!$src) {
        if ($isEditor) {
            echo '<div class="impulse-image-popup-wrap" data-trigger="' . esc_attr($trigger) . '" data-modal-id="' . esc_attr($popupId) . '" style="' . esc_attr($cssVars) . '">';
            echo '<div class="impulse-image-popup-thumb impulse-image-popup-thumb-' . esc_attr($thumbShape) . '" style="background:#f5f5f5;color:#999;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;line-height:1;text-align:center;padding:8px;animation:none">No image</div>';
            echo '</div>';
        }
        return (string) ob_get_clean();
    }

    echo '<div class="impulse-image-popup-wrap" data-trigger="' . esc_attr($trigger) . '" data-modal-id="' . esc_attr($popupId) . '" style="' . esc_attr($cssVars) . '">';
    echo '<button type="button" class="impulse-image-popup-thumb impulse-image-popup-thumb-' . esc_attr($thumbShape) . '" aria-label="' . esc_attr($alt ?: 'Open image') . '" data-target="' . esc_attr($popupId) . '">';
    echo '<img src="' . esc_url($src) . '" alt="' . esc_attr($alt) . '" loading="lazy">';
    echo '</button>';
    echo '<div class="impulse-image-popup-backdrop" data-modal-id="' . esc_attr($popupId) . '"></div>';
    echo '<div class="impulse-image-popup-modal impulse-image-popup-modal-' . esc_attr($popupShape) . '" id="' . esc_attr($popupId) . '" hidden>';
    echo '<button type="button" class="impulse-image-popup-close" aria-label="Close"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>';
    echo '<img src="' . esc_url($src) . '" alt="' . esc_attr($alt) . '">';
    echo '</div>';
    echo '</div>';

    // Singleton JS — capture-phase click handler binds globally; only
    // emitted on the first lightbox render of the page.
    if (empty($GLOBALS['impulse_image_popup_js_emitted'])) {
        $GLOBALS['impulse_image_popup_js_emitted'] = true;
        ob_start();
        echo '(function(){'
           . 'var openId=null,closeT=null,hideT=null;'
           . 'function findWrap(id){var t=document.querySelector(".impulse-image-popup-thumb[data-target=\"" + id + "\"]");return t?t.closest(".impulse-image-popup-wrap"):null;}'
           . 'function close(){if(!openId)return;var id=openId;var m=document.getElementById(id);var bd=document.querySelector(".impulse-image-popup-backdrop[data-modal-id=\"" + id + "\"]");if(m)m.classList.remove("active");if(bd)bd.classList.remove("active");var w=findWrap(id);if(w)w.classList.remove("is-open");if(closeT)clearTimeout(closeT);if(hideT)clearTimeout(hideT);hideT=setTimeout(function(){if(m)m.hidden=true;if(bd)bd.hidden=true;},240);openId=null;document.body.style.overflow="";window.dispatchEvent(new CustomEvent("impulse-resume-effects"));}'
           . 'function open(id){if(!id)return;if(openId&&openId!==id)close();var m=document.getElementById(id);if(!m)return;var bd=document.querySelector(".impulse-image-popup-backdrop[data-modal-id=\"" + id + "\"]");if(m.parentNode!==document.body)document.body.appendChild(m);if(bd&&bd.parentNode!==document.body)document.body.appendChild(bd);if(hideT)clearTimeout(hideT);m.hidden=false;if(bd)bd.hidden=false;void m.offsetWidth;var w=findWrap(id);if(w)w.classList.add("is-open");setTimeout(function(){if(bd)bd.classList.add("active");if(m)m.classList.add("active");},10);openId=id;document.body.style.overflow="hidden";window.dispatchEvent(new CustomEvent("impulse-pause-effects"));}'
           . 'document.addEventListener("click",function(e){'
             . 'var thumb=e.target.closest&&e.target.closest(".impulse-image-popup-thumb");'
             . 'if(thumb){e.preventDefault();e.stopPropagation();var t=thumb.getAttribute("data-target");if(!t)return;if(openId===t)close();else open(t);return;}'
             . 'if(e.target.closest&&e.target.closest(".impulse-image-popup-close")){e.preventDefault();e.stopPropagation();close();return;}'
             . 'var bd=e.target.closest&&e.target.closest(".impulse-image-popup-backdrop");if(bd){e.preventDefault();e.stopPropagation();close();}'
           . '},true);'
           . 'document.addEventListener("mouseenter",function(e){'
             . 'var thumb=e.target&&e.target.closest?e.target.closest(".impulse-image-popup-thumb"):null;if(!thumb)return;'
             . 'var wrap=thumb.closest(".impulse-image-popup-wrap");if(!wrap||wrap.getAttribute("data-trigger")!=="hover")return;'
             . 'if(!window.matchMedia||!matchMedia("(hover:hover)").matches)return;'
             . 'open(thumb.getAttribute("data-target"));'
           . '},true);'
           . 'document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});'
           . '})();';
        $_inlineJs2 = ob_get_clean();
        wp_add_inline_script('instinctor-frontend-js', $_inlineJs2, 'after');
    }

    return (string) ob_get_clean();
}
