<?php
declare(strict_types=1);

namespace Help4BuilderSuite\Modules\Slider;

use Help4BuilderSuite\Contracts\ModuleContract;
use WP_Post;
use WP_Screen;

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

final class Module implements ModuleContract
{
    private const SNAPSHOT_MAX_WIDTH = 1280;
    private const SNAPSHOT_MAX_HEIGHT = 720;
    private const SNAPSHOT_MIN_WIDTH = 420;
    private const SNAPSHOT_MIN_HEIGHT = 260;

    public function key(): string
    {
        return 'slider';
    }

    public function boot(): void
    {
        add_action('init', [$this, 'registerPostType']);
        add_action('add_meta_boxes', [$this, 'registerMetaBoxes']);
        add_action('save_post_h4_slider', [$this, 'saveSliderMeta']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueSliderAdminAssets']);
        add_action('current_screen', [$this, 'setupAdminMenuHighlight']);
        add_shortcode('h4_slider', [$this, 'renderShortcode']);
        add_shortcode('h4_website_snapshot', [$this, 'renderWebsiteSnapshotShortcode']);
        add_shortcode('h4_siteshot', [$this, 'renderWebsiteSnapshotShortcode']);
    }

    public function registerPostType(): void
    {
        register_post_type(
            'h4_slider',
            [
                'label'           => __('Help4 Sliders', 'help4-builder-suite'),
                'public'          => false,
                'show_ui'         => true,
                'show_in_rest'    => true,
                'menu_icon'       => 'dashicons-images-alt2',
                'supports'        => ['title', 'thumbnail', 'excerpt'],
                'show_in_menu'    => 'h4bs-settings',
                'capability_type' => 'post',
                'map_meta_cap'    => true,
            ]
        );
    }

    public function registerMetaBoxes(): void
    {
        add_meta_box(
            'h4bs-slider-slides',
            __('Visual Slider Builder', 'help4-builder-suite'),
            [$this, 'renderSlidesMetaBox'],
            'h4_slider',
            'normal',
            'high'
        );
    }

    public function renderSlidesMetaBox(WP_Post $post): void
    {
        wp_nonce_field('h4bs_slider_meta', 'h4bs_slider_meta_nonce');
        $slides = get_post_meta($post->ID, '_h4_slider_slides', true);
        if (! is_string($slides) || $slides === '') {
            $slides = wp_json_encode(
                [
                    [
                        'source' => 'image',
                        'image_id' => 0,
                        'image_url' => '',
                        'background_alt' => '',
                        'snapshot_url' => '',
                        'snapshot_width' => 960,
                        'snapshot_height' => 540,
                        'title'    => 'Slide Title',
                        'badge'    => 'Featured',
                        'caption'  => 'Slide caption text.',
                        'button_text' => 'Learn More',
                        'content_position' => 'bottom_left',
                        'link'     => '',
                        'background_color' => '#0b1118',
                        'background_overlay' => '#000000',
                        'background_overlay_opacity' => 0.2,
                        'background_fit' => 'cover',
                        'background_position_x' => 50,
                        'background_position_y' => 50,
                        'video_url' => '',
                        'duration' => 0,
                        'layers' => [
                            [
                                'id' => 'headline',
                                'type' => 'heading',
                                'content' => 'Layered slide heading',
                                'x' => 8,
                                'y' => 18,
                                'width' => 70,
                                'font_size' => 48,
                                'font_size_tablet' => 38,
                                'font_size_mobile' => 30,
                                'color' => '#ffffff',
                                'entrance' => 'fade_up',
                                'delay' => 120,
                                'duration' => 650,
                            ],
                        ],
                    ],
                ],
                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
            );
        }
        ?>
        <div class="h4bs-slider-builder" data-h4bs-slider-builder>
            <p class="h4bs-slider-builder__intro">
                <?php esc_html_e('Build each slide on the canvas, position responsive layers by dragging, and preview desktop, tablet, and mobile before publishing. Existing slider JSON remains fully supported.', 'help4-builder-suite'); ?>
            </p>
            <div class="h4bs-slider-builder__app" data-h4bs-slider-app></div>
            <details class="h4bs-slider-builder__advanced">
                <summary><?php esc_html_e('Advanced JSON (for migration and power users)', 'help4-builder-suite'); ?></summary>
                <p><?php esc_html_e('The visual editor keeps this JSON synchronized. You can still paste an existing Help4 slider configuration here.', 'help4-builder-suite'); ?></p>
                <textarea
                    class="h4bs-slider-builder__json"
                    name="h4_slider_slides"
                    rows="16"
                    spellcheck="false"
                    data-h4bs-slider-json
                ><?php echo esc_textarea($slides); ?></textarea>
                <button class="button" type="button" data-h4bs-slider-apply-json><?php esc_html_e('Apply JSON to canvas', 'help4-builder-suite'); ?></button>
                <span class="h4bs-slider-builder__json-status" role="status" aria-live="polite" data-h4bs-slider-json-status></span>
            </details>
        </div>
        <?php
    }

    public function enqueueSliderAdminAssets(string $hookSuffix): void
    {
        if ($hookSuffix !== 'post.php' && $hookSuffix !== 'post-new.php') {
            return;
        }

        $screen = get_current_screen();
        if (! $screen instanceof WP_Screen || $screen->post_type !== 'h4_slider') {
            return;
        }

        wp_enqueue_media();
        wp_enqueue_style(
            'h4bs-slider-admin',
            H4BS_URL . 'assets/css/slider-admin.css',
            [],
            H4BS_VERSION
        );
        wp_enqueue_script(
            'h4bs-slider-admin',
            H4BS_URL . 'assets/js/slider-admin.js',
            ['jquery'],
            H4BS_VERSION,
            true
        );
        wp_localize_script(
            'h4bs-slider-admin',
            'h4bsSliderEditorL10n',
            [
                'mediaTitle' => __('Choose slider media', 'help4-builder-suite'),
                'mediaButton' => __('Use this media', 'help4-builder-suite'),
                'confirmDeleteSlide' => __('Delete this slide?', 'help4-builder-suite'),
                'confirmDeleteLayer' => __('Delete this layer?', 'help4-builder-suite'),
            ]
        );
    }

    public function saveSliderMeta(int $postId): void
    {
        if (! isset($_POST['h4bs_slider_meta_nonce'])) {
            return;
        }

        if (! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['h4bs_slider_meta_nonce'])), 'h4bs_slider_meta')) {
            return;
        }

        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        if (! current_user_can('edit_post', $postId)) {
            return;
        }

        $raw = isset($_POST['h4_slider_slides']) ? sanitize_textarea_field(wp_unslash($_POST['h4_slider_slides'])) : '[]';
        $decoded = json_decode($raw, true);
        if (! is_array($decoded)) {
            return;
        }

        $clean = [];
        foreach ($decoded as $slide) {
            if (! is_array($slide)) {
                continue;
            }

            $source = strtolower(sanitize_key((string) ($slide['source'] ?? 'image')));
            if ($source === 'siteshot') {
                $source = 'snapshot';
            }
            if (! in_array($source, ['image', 'snapshot', 'video', 'color'], true)) {
                $source = 'image';
            }

            $snapshotUrl = esc_url_raw((string) ($slide['snapshot_url'] ?? ($slide['siteshot_url'] ?? '')));
            if ($snapshotUrl !== '' && ! $this->isAllowedWebsiteSnapshotTarget($snapshotUrl)) {
                $snapshotUrl = '';
            }

            [$snapshotWidth, $snapshotHeight] = $this->normalizeWebsiteSnapshotDimensions(
                absint($slide['snapshot_width'] ?? ($slide['siteshot_width'] ?? 0)),
                absint($slide['snapshot_height'] ?? ($slide['siteshot_height'] ?? 0))
            );

            $clean[] = [
                'source'    => $source,
                'image_id'  => absint($slide['image_id'] ?? 0),
                'image_url' => esc_url_raw((string) ($slide['image_url'] ?? '')),
                'background_alt' => sanitize_text_field((string) ($slide['background_alt'] ?? '')),
                'snapshot_url' => $snapshotUrl,
                'snapshot_width' => $snapshotWidth,
                'snapshot_height' => $snapshotHeight,
                'title'     => sanitize_text_field((string) ($slide['title'] ?? '')),
                'badge'     => sanitize_text_field((string) ($slide['badge'] ?? '')),
                'caption'   => sanitize_text_field((string) ($slide['caption'] ?? '')),
                'button_text' => sanitize_text_field((string) ($slide['button_text'] ?? '')),
                'content_position' => $this->enumToken(
                    (string) ($slide['content_position'] ?? 'bottom_left'),
                    ['bottom_left', 'bottom_center', 'bottom_right', 'center_left', 'center', 'center_right'],
                    'bottom_left'
                ),
                'link'      => esc_url_raw((string) ($slide['link'] ?? '')),
                'background_color' => sanitize_hex_color((string) ($slide['background_color'] ?? '#0b1118')) ?: '#0b1118',
                'background_overlay' => sanitize_hex_color((string) ($slide['background_overlay'] ?? '#000000')) ?: '#000000',
                'background_overlay_opacity' => max(0, min(1, (float) ($slide['background_overlay_opacity'] ?? 0))),
                'background_fit' => $this->enumToken((string) ($slide['background_fit'] ?? 'cover'), ['cover', 'contain', 'fill', 'none'], 'cover'),
                'background_position_x' => $this->boundedFloat($slide['background_position_x'] ?? 50, 0, 100, 50),
                'background_position_y' => $this->boundedFloat($slide['background_position_y'] ?? 50, 0, 100, 50),
                'video_url' => esc_url_raw((string) ($slide['video_url'] ?? '')),
                'video_poster' => esc_url_raw((string) ($slide['video_poster'] ?? '')),
                'duration' => max(0, min(120000, absint($slide['duration'] ?? 0))),
                'layers' => $this->sanitizeSlideLayers(is_array($slide['layers'] ?? null) ? $slide['layers'] : []),
            ];
        }

        update_post_meta(
            $postId,
            '_h4_slider_slides',
            wp_json_encode($clean, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        );
    }

    /**
     * @param array<int, mixed> $layers
     * @return array<int, array<string, mixed>>
     */
    private function sanitizeSlideLayers(array $layers): array
    {
        $clean = [];
        foreach (array_slice($layers, 0, 80) as $index => $layer) {
            if (! is_array($layer)) {
                continue;
            }
            $type = $this->enumToken(
                (string) ($layer['type'] ?? 'text'),
                ['heading', 'text', 'button', 'image', 'icon', 'video', 'shape', 'counter', 'progress', 'html'],
                'text'
            );
            $id = sanitize_html_class((string) ($layer['id'] ?? 'layer-' . $index));
            if ($id === '') {
                $id = 'layer-' . $index;
            }
            $content = (string) ($layer['content'] ?? '');
            $clean[] = [
                'id' => $id,
                'type' => $type,
                'content' => $type === 'html' ? wp_kses_post($content) : sanitize_textarea_field($content),
                'src' => esc_url_raw((string) ($layer['src'] ?? '')),
                'url' => esc_url_raw((string) ($layer['url'] ?? '')),
                'alt' => sanitize_text_field((string) ($layer['alt'] ?? '')),
                'icon' => sanitize_key((string) ($layer['icon'] ?? 'star')),
                'tag' => $this->enumToken((string) ($layer['tag'] ?? 'h2'), ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], 'h2'),
                'x' => $this->boundedFloat($layer['x'] ?? 8, -100, 200, 8),
                'y' => $this->boundedFloat($layer['y'] ?? 12, -100, 200, 12),
                'width' => $this->boundedFloat($layer['width'] ?? 70, 1, 200, 70),
                'height' => $this->boundedFloat($layer['height'] ?? 0, 0, 200, 0),
                'x_tablet' => $this->nullableBoundedFloat($layer['x_tablet'] ?? null, -100, 200),
                'y_tablet' => $this->nullableBoundedFloat($layer['y_tablet'] ?? null, -100, 200),
                'width_tablet' => $this->nullableBoundedFloat($layer['width_tablet'] ?? null, 1, 200),
                'x_mobile' => $this->nullableBoundedFloat($layer['x_mobile'] ?? null, -100, 200),
                'y_mobile' => $this->nullableBoundedFloat($layer['y_mobile'] ?? null, -100, 200),
                'width_mobile' => $this->nullableBoundedFloat($layer['width_mobile'] ?? null, 1, 200),
                'font_size' => $this->boundedFloat($layer['font_size'] ?? 20, 8, 240, 20),
                'font_size_tablet' => $this->nullableBoundedFloat($layer['font_size_tablet'] ?? null, 8, 240),
                'font_size_mobile' => $this->nullableBoundedFloat($layer['font_size_mobile'] ?? null, 8, 240),
                'color' => sanitize_hex_color((string) ($layer['color'] ?? '#ffffff')) ?: '#ffffff',
                'background' => sanitize_hex_color((string) ($layer['background'] ?? '')) ?: '',
                'align' => $this->enumToken((string) ($layer['align'] ?? 'left'), ['left', 'center', 'right'], 'left'),
                'z' => max(0, min(500, (int) ($layer['z'] ?? ($index + 2)))),
                'opacity' => $this->boundedFloat($layer['opacity'] ?? 1, 0, 1, 1),
                'radius' => $this->boundedFloat($layer['radius'] ?? 0, 0, 200, 0),
                'padding' => $this->boundedFloat($layer['padding'] ?? 0, 0, 160, 0),
                'entrance' => $this->enumToken((string) ($layer['entrance'] ?? 'fade_up'), ['none', 'fade', 'fade_up', 'fade_down', 'fade_left', 'fade_right', 'zoom', 'rotate', 'blur'], 'fade_up'),
                'exit' => $this->enumToken((string) ($layer['exit'] ?? 'fade'), ['none', 'fade', 'fade_up', 'fade_down', 'fade_left', 'fade_right', 'zoom', 'rotate', 'blur'], 'fade'),
                'delay' => max(0, min(60000, absint($layer['delay'] ?? 0))),
                'duration' => max(0, min(60000, absint($layer['duration'] ?? 650))),
                'exit_delay' => max(0, min(60000, absint($layer['exit_delay'] ?? 0))),
                'easing' => $this->enumToken((string) ($layer['easing'] ?? 'ease_out'), ['linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring'], 'ease_out'),
                'parallax' => $this->boundedFloat($layer['parallax'] ?? 0, -2, 2, 0),
                'hide_desktop' => rest_sanitize_boolean($layer['hide_desktop'] ?? false),
                'hide_tablet' => rest_sanitize_boolean($layer['hide_tablet'] ?? false),
                'hide_mobile' => rest_sanitize_boolean($layer['hide_mobile'] ?? false),
            ];
        }
        return $clean;
    }

    /**
     * @param array<string, mixed> $atts
     */
    public function renderShortcode(array $atts): string
    {
        $atts = shortcode_atts(
            [
                'id'       => 0,
                'source' => 'manual',
                'post_type' => 'post',
                'limit' => 8,
                'taxonomy' => '',
                'terms' => '',
                'orderby' => 'date',
                'order' => 'DESC',
                'autoplay' => 4500,
                'start_index' => 0,
                'snapshot_width' => 960,
                'snapshot_height' => 540,
                'siteshot_width' => 0,
                'siteshot_height' => 0,
                'fit' => 'contain',
                'snapshot_fit' => '',
                'siteshot_fit' => '',
                'bg' => '#0b1118',
                'height_mode' => 'fixed',
                'height_px' => 540,
                'height_tablet' => 480,
                'height_mobile' => 420,
                'transition' => 'slide',
                'transition_duration' => 600,
                'easing' => 'ease',
                'motion' => 'none',
                'motion_duration' => 9000,
                'motion_force' => 'false',
                'arrows' => 'true',
                'dots' => 'true',
                'pause_button' => 'true',
                'pause_on_hover' => 'true',
                'pause_out_of_view' => 'true',
                'keyboard' => 'true',
                'swipe' => 'true',
                'loop' => 'true',
                'shuffle' => 'false',
                'progress' => 'true',
                'thumbnails' => 'false',
                'label' => '',
            ],
            $atts,
            'h4_slider'
        );

        $postId = absint($atts['id']);
        $slideSource = $this->enumToken((string) $atts['source'], ['manual', 'posts'], 'manual');
        if (! $postId && $slideSource === 'manual') {
            return '<p>Missing slider id.</p>';
        }

        $slides = $slideSource === 'posts'
            ? $this->dynamicPostSlides($atts)
            : json_decode((string) get_post_meta($postId, '_h4_slider_slides', true), true);
        $slides = apply_filters('h4bs_slider_dynamic_slides', $slides, $slideSource, $atts, $postId);
        if (rest_sanitize_boolean($atts['shuffle']) && is_array($slides)) {
            shuffle($slides);
        }
        if (! is_array($slides) || ! $slides) {
            return '<p>This slider has no slides yet.</p>';
        }
        $slides = array_values(array_slice($slides, 0, 200));

        wp_enqueue_script(
            'h4bs-slider',
            H4BS_URL . 'assets/js/slider.js',
            [],
            H4BS_VERSION,
            true
        );

        $autoplay = max(0, (int) $atts['autoplay']);
        $fit = $this->enumToken((string) $atts['fit'], ['cover', 'contain', 'fill', 'stretch', 'none'], 'contain');
        $fitCss = $fit === 'stretch' ? 'fill' : $fit;
        $snapshotFitRaw = (string) ($atts['snapshot_fit'] !== '' ? $atts['snapshot_fit'] : ($atts['siteshot_fit'] !== '' ? $atts['siteshot_fit'] : 'contain'));
        $snapshotFit = $this->enumToken($snapshotFitRaw, ['cover', 'contain', 'fill', 'stretch', 'none'], 'contain');
        $snapshotFitCss = $snapshotFit === 'stretch' ? 'fill' : $snapshotFit;
        $background = sanitize_hex_color((string) $atts['bg']);
        if (! is_string($background) || $background === '') {
            $background = '#0b1118';
        }
        $heightMode = $this->enumToken((string) $atts['height_mode'], ['fixed', 'auto'], 'fixed');
        $heightPx = max(120, min(2160, (int) $atts['height_px']));
        $heightTablet = max(120, min(2160, (int) $atts['height_tablet']));
        $heightMobile = max(120, min(2160, (int) $atts['height_mobile']));
        $transition = $this->enumToken((string) $atts['transition'], ['slide', 'fade', 'zoom', 'flip', 'stack', 'none'], 'slide');
        $transitionDuration = max(0, min(5000, (int) $atts['transition_duration']));
        $easing = $this->enumToken((string) $atts['easing'], ['linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring'], 'ease');
        $motion = $this->enumToken((string) $atts['motion'], ['none', 'zoom_in', 'zoom_out', 'pan_left', 'pan_right', 'pan_up', 'pan_down'], 'none');
        $motionDuration = max(2000, min(30000, (int) $atts['motion_duration']));
        $motionForce = rest_sanitize_boolean($atts['motion_force']);
        $showArrows = rest_sanitize_boolean($atts['arrows']);
        $showDots = rest_sanitize_boolean($atts['dots']);
        $showPauseButton = rest_sanitize_boolean($atts['pause_button']);
        $pauseOnHover = rest_sanitize_boolean($atts['pause_on_hover']);
        $pauseOutOfView = rest_sanitize_boolean($atts['pause_out_of_view']);
        $keyboard = rest_sanitize_boolean($atts['keyboard']);
        $swipe = rest_sanitize_boolean($atts['swipe']);
        $loop = rest_sanitize_boolean($atts['loop']);
        $showProgress = rest_sanitize_boolean($atts['progress']);
        $showThumbnails = rest_sanitize_boolean($atts['thumbnails']);
        $startIndex = max(0, min(count($slides) - 1, (int) $atts['start_index']));
        $label = sanitize_text_field((string) $atts['label']);
        if ($label === '') {
            $label = get_the_title($postId);
        }
        if ($label === '') {
            $label = __('Help4 Slider', 'help4-builder-suite');
        }

        $sliderClasses = ['h4bs-slider', 'h4bs-fit-' . $fitCss, 'h4bs-transition-' . $transition, 'h4bs-motion-' . $motion];
        if ($transition !== 'none') {
            $sliderClasses[] = 'has-transition';
        }
        if ($motionForce) {
            $sliderClasses[] = 'h4bs-motion-force';
        }
        $sliderClasses[] = $heightMode === 'auto' ? 'is-auto-height' : 'is-fixed-height';
        $sliderClasses[] = $showDots ? 'has-dots' : 'has-no-dots';
        $sliderClassAttr = implode(' ', array_unique($sliderClasses));
        $sliderStyleAttr = sprintf(
            '--h4bs-slider-height:%dpx;--h4bs-slider-height-tablet:%dpx;--h4bs-slider-height-mobile:%dpx;--h4bs-slider-fit:%s;--h4bs-snapshot-fit:%s;--h4bs-slider-bg:%s;--h4bs-motion-duration:%dms;--h4bs-slider-transition-duration:%dms;--h4bs-slider-easing:%s;',
            $heightPx,
            $heightTablet,
            $heightMobile,
            esc_attr($fitCss),
            esc_attr($snapshotFitCss),
            esc_attr($background),
            $motionDuration,
            $transitionDuration,
            esc_attr($this->easingCss($easing))
        );
        [$defaultShotWidth, $defaultShotHeight] = $this->normalizeWebsiteSnapshotDimensions(
            absint($atts['snapshot_width']) > 0 ? absint($atts['snapshot_width']) : absint($atts['siteshot_width']),
            absint($atts['snapshot_height']) > 0 ? absint($atts['snapshot_height']) : absint($atts['siteshot_height'])
        );
        $idAttr = 'h4bs-slider-' . $postId . '-' . wp_rand(1000, 9999);

        ob_start();
        ?>
        <div
            class="<?php echo esc_attr($sliderClassAttr); ?>"
            id="<?php echo esc_attr($idAttr); ?>"
            data-autoplay="<?php echo esc_attr((string) $autoplay); ?>"
            data-start-index="<?php echo esc_attr((string) $startIndex); ?>"
            data-transition="<?php echo esc_attr($transition); ?>"
            data-transition-duration="<?php echo esc_attr((string) $transitionDuration); ?>"
            data-easing="<?php echo esc_attr($easing); ?>"
            data-motion="<?php echo esc_attr($motion); ?>"
            data-motion-duration="<?php echo esc_attr((string) $motionDuration); ?>"
            data-motion-force="<?php echo esc_attr($motionForce ? '1' : '0'); ?>"
            data-fit="<?php echo esc_attr($fitCss); ?>"
            data-snapshot-fit="<?php echo esc_attr($snapshotFitCss); ?>"
            data-bg="<?php echo esc_attr($background); ?>"
            data-height-mode="<?php echo esc_attr($heightMode); ?>"
            data-height-px="<?php echo esc_attr((string) $heightPx); ?>"
            data-pause-on-hover="<?php echo esc_attr($pauseOnHover ? '1' : '0'); ?>"
            data-pause-out-of-view="<?php echo esc_attr($pauseOutOfView ? '1' : '0'); ?>"
            data-keyboard="<?php echo esc_attr($keyboard ? '1' : '0'); ?>"
            data-swipe="<?php echo esc_attr($swipe ? '1' : '0'); ?>"
            data-loop="<?php echo esc_attr($loop ? '1' : '0'); ?>"
            data-source="<?php echo esc_attr($slideSource); ?>"
            role="region"
            aria-roledescription="<?php esc_attr_e('carousel', 'help4-builder-suite'); ?>"
            aria-label="<?php echo esc_attr($label); ?>"
            aria-live="off"
            style="<?php echo esc_attr($sliderStyleAttr); ?>"
        >
            <?php foreach ($slides as $index => $slide) : ?>
                <?php
                $contentPosition = $this->enumToken(
                    (string) ($slide['content_position'] ?? 'bottom_left'),
                    ['bottom_left', 'bottom_center', 'bottom_right', 'center_left', 'center', 'center_right'],
                    'bottom_left'
                );
                $layers = is_array($slide['layers'] ?? null) ? $slide['layers'] : [];
                $hasLayers = $layers !== [];
                $slideFit = $this->enumToken((string) ($slide['background_fit'] ?? $fitCss), ['cover', 'contain', 'fill', 'none'], $fitCss);
                $slidePositionX = $this->boundedFloat($slide['background_position_x'] ?? 50, 0, 100, 50);
                $slidePositionY = $this->boundedFloat($slide['background_position_y'] ?? 50, 0, 100, 50);
                $slideId = $idAttr . '-slide-' . ($index + 1);
                /* translators: 1: Current slide number. 2: Total slide count. */
                $slideLabel = sprintf(__('Slide %1$d of %2$d', 'help4-builder-suite'), $index + 1, count($slides));
                ?>
                <article
                    class="h4bs-slide h4bs-slide-content-<?php echo esc_attr($contentPosition); ?><?php echo $index === $startIndex ? ' is-active' : ''; ?>"
                    id="<?php echo esc_attr($slideId); ?>"
                    role="group"
                    aria-roledescription="<?php esc_attr_e('slide', 'help4-builder-suite'); ?>"
                    aria-label="<?php echo esc_attr($slideLabel); ?>"
                    aria-hidden="<?php echo $index === $startIndex ? 'false' : 'true'; ?>"
                    data-slide-duration="<?php echo esc_attr((string) max(0, min(120000, absint($slide['duration'] ?? 0)))); ?>"
                    style="--h4bs-slide-bg:<?php echo esc_attr(sanitize_hex_color((string) ($slide['background_color'] ?? '')) ?: $background); ?>;--h4bs-slide-overlay:<?php echo esc_attr(sanitize_hex_color((string) ($slide['background_overlay'] ?? '')) ?: '#000000'); ?>;--h4bs-slide-overlay-opacity:<?php echo esc_attr((string) max(0, min(1, (float) ($slide['background_overlay_opacity'] ?? 0)))); ?>;--h4bs-slide-fit:<?php echo esc_attr($slideFit); ?>;--h4bs-slide-position-x:<?php echo esc_attr((string) $slidePositionX); ?>%;--h4bs-slide-position-y:<?php echo esc_attr((string) $slidePositionY); ?>%;"
                >
                    <?php if (! empty($slide['link']) && ! $hasLayers) : ?>
                        <a href="<?php echo esc_url((string) $slide['link']); ?>">
                    <?php endif; ?>

                    <?php
                    $source = strtolower((string) ($slide['source'] ?? 'image'));
                    $imageId = absint($slide['image_id'] ?? 0);
                    if ($source === 'siteshot') {
                        $source = 'snapshot';
                    }
                    $snapshotUrl = esc_url_raw((string) ($slide['snapshot_url'] ?? ($slide['siteshot_url'] ?? '')));
                    if ($source === 'video' && ! empty($slide['video_url'])) {
                        printf(
                            '<video class="h4bs-slide-image h4bs-slide-video" muted playsinline loop preload="metadata" src="%s"%s aria-hidden="true" tabindex="-1"></video>',
                            esc_url((string) $slide['video_url']),
                            ! empty($slide['video_poster']) ? ' poster="' . esc_url((string) $slide['video_poster']) . '"' : ''
                        );
                    } elseif ($source === 'snapshot' || $snapshotUrl !== '') {
                        $snapshotImageHtml = $this->renderWebsiteSnapshotImageMarkup(
                            $snapshotUrl,
                            (string) ($slide['title'] ?? ''),
                            absint($slide['snapshot_width'] ?? ($slide['siteshot_width'] ?? $defaultShotWidth)),
                            absint($slide['snapshot_height'] ?? ($slide['siteshot_height'] ?? $defaultShotHeight)),
                            900,
                            false
                        );
                        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderWebsiteSnapshotImageMarkup returns sanitized image markup.
                        echo $snapshotImageHtml;
                    } else {
                        if ($imageId) {
                            echo wp_get_attachment_image(
                                $imageId,
                                'full',
                                false,
                                [
                                    'class'    => 'h4bs-slide-image',
                                    'loading'  => 'lazy',
                                    'decoding' => 'async',
                                    'alt'      => array_key_exists('background_alt', $slide) ? (string) $slide['background_alt'] : (string) get_post_meta($imageId, '_wp_attachment_image_alt', true),
                                    'style'    => 'object-fit:' . esc_attr($slideFit) . ';object-position:' . esc_attr((string) $slidePositionX) . '% ' . esc_attr((string) $slidePositionY) . '%;',
                                ]
                            );
                        } elseif (! empty($slide['image_url'])) {
                            printf(
                                '<img class="h4bs-slide-image" loading="lazy" decoding="async" src="%s" alt="%s" style="object-fit:%s;">',
                                esc_url((string) $slide['image_url']),
                                esc_attr(array_key_exists('background_alt', $slide) ? (string) $slide['background_alt'] : (string) ($slide['title'] ?? '')),
                                esc_attr($slideFit)
                            );
                        }
                    }
                    ?>

                    <span class="h4bs-slide-overlay" aria-hidden="true"></span>

                    <?php if (! $hasLayers && (! empty($slide['title']) || ! empty($slide['caption']) || ! empty($slide['badge']) || ! empty($slide['button_text']))) : ?>
                        <div class="h4bs-slide-content">
                            <?php if (! empty($slide['badge'])) : ?>
                                <span class="h4bs-slide-badge"><?php echo esc_html((string) $slide['badge']); ?></span>
                            <?php endif; ?>
                            <?php if (! empty($slide['title'])) : ?>
                                <h3><?php echo esc_html((string) $slide['title']); ?></h3>
                            <?php endif; ?>
                            <?php if (! empty($slide['caption'])) : ?>
                                <p><?php echo esc_html((string) $slide['caption']); ?></p>
                            <?php endif; ?>
                            <?php if (! empty($slide['button_text'])) : ?>
                                <span class="h4bs-slide-button"><?php echo esc_html((string) $slide['button_text']); ?></span>
                            <?php endif; ?>
                        </div>
                    <?php endif; ?>

                    <?php if ($hasLayers) : ?>
                        <div class="h4bs-slide-layers">
                            <?php foreach ($layers as $layer) : ?>
                                <?php
                                if (! is_array($layer)) {
                                    continue;
                                }
                                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderSlideLayer returns escaped markup.
                                echo $this->renderSlideLayer($layer);
                                ?>
                            <?php endforeach; ?>
                        </div>
                    <?php endif; ?>

                    <?php if (! empty($slide['link']) && ! $hasLayers) : ?>
                        </a>
                    <?php endif; ?>
                </article>
            <?php endforeach; ?>

            <?php if ($showArrows && count($slides) > 1) : ?>
                <button class="h4bs-slide-nav h4bs-slide-prev" type="button" aria-label="<?php esc_attr_e('Previous Slide', 'help4-builder-suite'); ?>">&#10094;</button>
                <button class="h4bs-slide-nav h4bs-slide-next" type="button" aria-label="<?php esc_attr_e('Next Slide', 'help4-builder-suite'); ?>">&#10095;</button>
            <?php endif; ?>
            <?php if ($showPauseButton && $autoplay > 0 && count($slides) > 1) : ?>
                <button class="h4bs-slide-pause" type="button" aria-pressed="false" aria-label="<?php esc_attr_e('Pause Slider', 'help4-builder-suite'); ?>">
                    <span class="h4bs-slide-pause__play" aria-hidden="true">&#9658;</span>
                    <span class="h4bs-slide-pause__pause" aria-hidden="true">&#10073;&#10073;</span>
                </button>
            <?php endif; ?>
            <?php if ($showDots && count($slides) > 1) : ?>
                <div class="h4bs-slide-dots" role="group" aria-label="<?php esc_attr_e('Slider navigation', 'help4-builder-suite'); ?>">
                    <?php foreach ($slides as $index => $slide) : ?>
                        <?php
                        /* translators: %d: Slide number. */
                        $dotLabel = sprintf(__('Go to slide %d', 'help4-builder-suite'), $index + 1);
                        ?>
                        <button
                            class="h4bs-slide-dot<?php echo $index === $startIndex ? ' is-active' : ''; ?>"
                            type="button"
                            aria-current="<?php echo $index === $startIndex ? 'true' : 'false'; ?>"
                            aria-controls="<?php echo esc_attr($idAttr . '-slide-' . ($index + 1)); ?>"
                            aria-label="<?php echo esc_attr($dotLabel); ?>"
                            data-slide-index="<?php echo esc_attr((string) $index); ?>"
                        ></button>
                    <?php endforeach; ?>
                </div>
            <?php endif; ?>
            <?php if ($showThumbnails && count($slides) > 1) : ?>
                <div class="h4bs-slide-thumbnails" role="group" aria-label="<?php esc_attr_e('Slide thumbnails', 'help4-builder-suite'); ?>">
                    <?php foreach ($slides as $index => $slide) : ?>
                        <?php
                        $thumb = esc_url((string) ($slide['image_url'] ?? ''));
                        $thumbId = absint($slide['image_id'] ?? 0);
                        if ($thumb === '' && $thumbId > 0) {
                            $thumbSource = wp_get_attachment_image_url($thumbId, 'thumbnail');
                            $thumb = is_string($thumbSource) ? esc_url($thumbSource) : '';
                        }
                        /* translators: %d: Slide number. */
                        $thumbnailLabel = sprintf(__('Go to slide %d', 'help4-builder-suite'), $index + 1);
                        ?>
                        <button type="button" class="h4bs-slide-thumbnail<?php echo $index === $startIndex ? ' is-active' : ''; ?>" data-slide-index="<?php echo esc_attr((string) $index); ?>" aria-current="<?php echo $index === $startIndex ? 'true' : 'false'; ?>" aria-controls="<?php echo esc_attr($idAttr . '-slide-' . ($index + 1)); ?>" aria-label="<?php echo esc_attr($thumbnailLabel); ?>">
                            <?php if ($thumb !== '') : ?>
                                <img loading="lazy" src="<?php echo esc_url($thumb); ?>" alt="">
                            <?php else : ?>
                                <span><?php echo esc_html((string) ($index + 1)); ?></span>
                            <?php endif; ?>
                        </button>
                    <?php endforeach; ?>
                </div>
            <?php endif; ?>
            <?php if ($showProgress && $autoplay > 0 && count($slides) > 1) : ?>
                <div class="h4bs-slide-progress" aria-hidden="true"><span></span></div>
            <?php endif; ?>
            <p class="h4bs-slide-status screen-reader-text" aria-live="polite" aria-atomic="true"></p>
        </div>
        <?php
        return (string) ob_get_clean();
    }

    /**
     * @param array<string, mixed> $atts
     * @return array<int, array<string, mixed>>
     */
    private function dynamicPostSlides(array $atts): array
    {
        $postType = sanitize_key((string) ($atts['post_type'] ?? 'post'));
        $postTypeObject = get_post_type_object($postType);
        if (! $postTypeObject instanceof \WP_Post_Type || (! $postTypeObject->public && ! $postTypeObject->publicly_queryable)) {
            $postType = 'post';
        }
        $orderby = $this->enumToken((string) ($atts['orderby'] ?? 'date'), ['date', 'modified', 'title', 'menu_order', 'rand'], 'date');
        $order = strtoupper((string) ($atts['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
        $query = [
            'post_type' => $postType,
            'post_status' => 'publish',
            'posts_per_page' => max(1, min(100, absint($atts['limit'] ?? 8))),
            'orderby' => $orderby,
            'order' => $order,
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ];
        $taxonomy = sanitize_key((string) ($atts['taxonomy'] ?? ''));
        $terms = array_slice(
            array_values(array_filter(array_map('sanitize_title', explode(',', (string) ($atts['terms'] ?? ''))))),
            0,
            20
        );
        if ($taxonomy !== '' && is_object_in_taxonomy($postType, $taxonomy) && $terms) {
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Public source, terms, rows, and caches are strictly bounded above.
            $query['tax_query'] = [
                [
                    'taxonomy' => $taxonomy,
                    'field' => 'slug',
                    'terms' => $terms,
                ],
            ];
        }

        $posts = get_posts($query);
        $slides = [];
        foreach ($posts as $post) {
            if (! $post instanceof WP_Post) {
                continue;
            }
            $excerpt = has_excerpt($post) ? $post->post_excerpt : wp_trim_words(wp_strip_all_tags($post->post_content), 28);
            $itemPostType = get_post_type_object($post->post_type);
            $itemPostTypeLabel = $itemPostType instanceof \WP_Post_Type
                ? (string) $itemPostType->labels->singular_name
                : '';
            $slide = [
                'source' => 'image',
                'image_id' => get_post_thumbnail_id($post),
                'image_url' => '',
                'title' => get_the_title($post),
                'caption' => $excerpt,
                'badge' => $itemPostTypeLabel,
                'button_text' => __('Read more', 'help4-builder-suite'),
                'link' => get_permalink($post),
                'content_position' => 'bottom_left',
                'background_color' => '#0b1118',
                'background_overlay' => '#000000',
                'background_overlay_opacity' => 0.32,
                'duration' => 0,
                'layers' => [],
            ];
            $filtered = apply_filters('h4bs_slider_dynamic_post_slide', $slide, $post, $atts);
            if (is_array($filtered)) {
                $slides[] = $filtered;
            }
        }
        return $slides;
    }

    /**
     * @param array<string, mixed> $layer
     */
    private function renderSlideLayer(array $layer): string
    {
        $type = $this->enumToken((string) ($layer['type'] ?? 'text'), ['heading', 'text', 'button', 'image', 'icon', 'video', 'shape', 'counter', 'progress', 'html'], 'text');
        $id = sanitize_html_class((string) ($layer['id'] ?? 'layer'));
        $entrance = $this->enumToken((string) ($layer['entrance'] ?? 'fade_up'), ['none', 'fade', 'fade_up', 'fade_down', 'fade_left', 'fade_right', 'zoom', 'rotate', 'blur'], 'fade_up');
        $exit = $this->enumToken((string) ($layer['exit'] ?? 'fade'), ['none', 'fade', 'fade_up', 'fade_down', 'fade_left', 'fade_right', 'zoom', 'rotate', 'blur'], 'fade');
        $easing = $this->enumToken((string) ($layer['easing'] ?? 'ease_out'), ['linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring'], 'ease_out');
        $classes = ['h4bs-slide-layer', 'is-' . $type, 'entrance-' . str_replace('_', '-', $entrance), 'exit-' . str_replace('_', '-', $exit)];
        if (rest_sanitize_boolean($layer['hide_tablet'] ?? false)) {
            $classes[] = 'hide-tablet';
        }
        if (rest_sanitize_boolean($layer['hide_desktop'] ?? false)) {
            $classes[] = 'hide-desktop';
        }
        if (rest_sanitize_boolean($layer['hide_mobile'] ?? false)) {
            $classes[] = 'hide-mobile';
        }

        $x = $this->boundedFloat($layer['x'] ?? 8, -100, 200, 8);
        $y = $this->boundedFloat($layer['y'] ?? 12, -100, 200, 12);
        $width = $this->boundedFloat($layer['width'] ?? 70, 1, 200, 70);
        $fontSize = $this->boundedFloat($layer['font_size'] ?? 20, 8, 240, 20);
        $xTablet = $this->nullableBoundedFloat($layer['x_tablet'] ?? null, -100, 200) ?? $x;
        $yTablet = $this->nullableBoundedFloat($layer['y_tablet'] ?? null, -100, 200) ?? $y;
        $widthTablet = $this->nullableBoundedFloat($layer['width_tablet'] ?? null, 1, 200) ?? $width;
        $fontTablet = $this->nullableBoundedFloat($layer['font_size_tablet'] ?? null, 8, 240) ?? $fontSize;
        $xMobile = $this->nullableBoundedFloat($layer['x_mobile'] ?? null, -100, 200) ?? $xTablet;
        $yMobile = $this->nullableBoundedFloat($layer['y_mobile'] ?? null, -100, 200) ?? $yTablet;
        $widthMobile = $this->nullableBoundedFloat($layer['width_mobile'] ?? null, 1, 200) ?? $widthTablet;
        $fontMobile = $this->nullableBoundedFloat($layer['font_size_mobile'] ?? null, 8, 240) ?? $fontTablet;
        $color = sanitize_hex_color((string) ($layer['color'] ?? '#ffffff')) ?: '#ffffff';
        $background = sanitize_hex_color((string) ($layer['background'] ?? '')) ?: 'transparent';
        $height = $this->boundedFloat($layer['height'] ?? 0, 0, 200, 0);
        $align = $this->enumToken((string) ($layer['align'] ?? 'left'), ['left', 'center', 'right'], 'left');
        $styles = sprintf(
            '--h4-layer-x:%s%%;--h4-layer-y:%s%%;--h4-layer-width:%s%%;--h4-layer-height:%s%%;--h4-layer-x-tablet:%s%%;--h4-layer-y-tablet:%s%%;--h4-layer-width-tablet:%s%%;--h4-layer-x-mobile:%s%%;--h4-layer-y-mobile:%s%%;--h4-layer-width-mobile:%s%%;--h4-layer-font:%spx;--h4-layer-font-tablet:%spx;--h4-layer-font-mobile:%spx;--h4-layer-color:%s;--h4-layer-bg:%s;--h4-layer-align:%s;--h4-layer-opacity:%s;--h4-layer-radius:%spx;--h4-layer-padding:%spx;--h4-layer-z:%d;--h4-layer-duration:%dms;--h4-layer-delay:%dms;--h4-layer-easing:%s;--h4-layer-parallax:%s;',
            $x,
            $y,
            $width,
            $height,
            $xTablet,
            $yTablet,
            $widthTablet,
            $xMobile,
            $yMobile,
            $widthMobile,
            $fontSize,
            $fontTablet,
            $fontMobile,
            $color,
            $background,
            $align,
            $this->boundedFloat($layer['opacity'] ?? 1, 0, 1, 1),
            $this->boundedFloat($layer['radius'] ?? 0, 0, 200, 0),
            $this->boundedFloat($layer['padding'] ?? 0, 0, 160, 0),
            max(0, min(500, (int) ($layer['z'] ?? 2))),
            max(0, min(60000, absint($layer['duration'] ?? 650))),
            max(0, min(60000, absint($layer['delay'] ?? 0))),
            $this->easingCss($easing),
            $this->boundedFloat($layer['parallax'] ?? 0, -2, 2, 0)
        );
        $attrs = ' class="' . esc_attr(implode(' ', $classes)) . '" data-layer-id="' . esc_attr($id) . '" style="' . esc_attr($styles) . '" data-layer-entrance="' . esc_attr($entrance) . '" data-layer-exit="' . esc_attr($exit) . '" data-layer-exit-delay="' . esc_attr((string) max(0, min(60000, absint($layer['exit_delay'] ?? 0)))) . '"';
        $content = (string) ($layer['content'] ?? '');

        switch ($type) {
            case 'heading':
                $tag = $this->enumToken((string) ($layer['tag'] ?? 'h2'), ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], 'h2');
                return '<div' . $attrs . '><' . $tag . '>' . esc_html($content) . '</' . $tag . '></div>';
            case 'text':
                return '<div' . $attrs . '><p>' . nl2br(esc_html($content)) . '</p></div>';
            case 'button':
                return '<div' . $attrs . '><a class="h4bs-slide-layer-button" href="' . esc_url((string) ($layer['url'] ?? '#')) . '">' . esc_html($content) . '</a></div>';
            case 'image':
                return '<div' . $attrs . '><img loading="lazy" decoding="async" src="' . esc_url((string) ($layer['src'] ?? '')) . '" alt="' . esc_attr((string) ($layer['alt'] ?? '')) . '"></div>';
            case 'icon':
                $icons = ['star' => '&#9733;', 'check' => '&#10003;', 'arrow' => '&rarr;', 'play' => '&#9654;', 'heart' => '&#9829;', 'plus' => '+'];
                $icon = sanitize_key((string) ($layer['icon'] ?? 'star'));
                return '<div' . $attrs . '><span aria-hidden="true">' . ($icons[$icon] ?? $icons['star']) . '</span><span class="screen-reader-text">' . esc_html($content) . '</span></div>';
            case 'video':
                return '<div' . $attrs . '><video controls playsinline preload="metadata" src="' . esc_url((string) ($layer['src'] ?? '')) . '" aria-label="' . esc_attr((string) ($layer['alt'] ?? $content)) . '"></video></div>';
            case 'shape':
                return '<span' . $attrs . ' aria-hidden="true"></span>';
            case 'counter':
                $target = max(-999999999, min(999999999, (float) $content));
                return '<div' . $attrs . ' data-h4-layer-counter="' . esc_attr((string) $target) . '"><strong>0</strong></div>';
            case 'progress':
                $value = max(0, min(100, (float) $content));
                return '<div' . $attrs . '><progress max="100" value="' . esc_attr((string) $value) . '">' . esc_html((string) $value) . '%</progress></div>';
            case 'html':
                return '<div' . $attrs . '>' . wp_kses_post($content) . '</div>';
        }
        return '';
    }

    /**
     * @param array<string, mixed> $atts
     */
    public function renderWebsiteSnapshotShortcode(array $atts): string
    {
        $atts = shortcode_atts(
            [
                'url' => '',
                'width' => 960,
                'height' => 540,
                'refresh' => 900,
                'link' => 'false',
                'caption' => '',
                'class' => '',
                'image_url' => '',
            ],
            $atts,
            'h4_website_snapshot'
        );

        $targetUrl = esc_url_raw((string) $atts['url']);
        if ($targetUrl === '') {
            return '<p>Website Snapshot requires a URL.</p>';
        }

        $label = sanitize_text_field((string) $atts['caption']);
        $imageHtml = $this->renderWebsiteSnapshotImageMarkup(
            $targetUrl,
            $label !== '' ? $label : 'Website screenshot preview',
            absint($atts['width']),
            absint($atts['height']),
            absint($atts['refresh']),
            true,
            (string) $atts['image_url']
        );

        if ($imageHtml === '') {
            return '<p>Website Snapshot blocked for protected URL.</p>';
        }

        $classParts = preg_split('/\s+/', (string) $atts['class']);
        $safeClassParts = [];
        if (is_array($classParts)) {
            foreach ($classParts as $classPart) {
                $safe = sanitize_html_class((string) $classPart);
                if ($safe !== '') {
                    $safeClassParts[] = $safe;
                }
            }
        }
        $classes = trim('h4bs-website-snapshot-wrap ' . implode(' ', $safeClassParts));
        $inner = $imageHtml;
        if ((string) $atts['link'] === 'true') {
            $inner = sprintf(
                '<a class="h4bs-website-snapshot-link" href="%s" target="_blank" rel="noopener noreferrer nofollow">%s</a>',
                esc_url($targetUrl),
                $imageHtml
            );
        }

        return sprintf(
            '<figure class="%s">%s%s</figure>',
            esc_attr(trim($classes)),
            $inner,
            $label !== '' ? '<figcaption class="h4bs-website-snapshot-caption">' . esc_html($label) . '</figcaption>' : ''
        );
    }

    private function renderWebsiteSnapshotImageMarkup(
        string $targetUrl,
        string $alt,
        int $requestedWidth,
        int $requestedHeight,
        int $refreshSeconds = 900,
        bool $withChrome = true,
        string $overrideImageUrl = ''
    ): string {
        $cleanUrl = esc_url_raw($targetUrl);
        if ($cleanUrl === '' || ! $this->isAllowedWebsiteSnapshotTarget($cleanUrl)) {
            return '';
        }

        [$width, $height] = $this->normalizeWebsiteSnapshotDimensions($requestedWidth, $requestedHeight);
        $overrideImageUrl = esc_url_raw($overrideImageUrl, ['http', 'https']);
        $shotUrl = $overrideImageUrl !== ''
            ? $overrideImageUrl
            : $this->buildWebsiteSnapshotUrl($cleanUrl, $width, $height, $refreshSeconds);
        $fallbackUrl = set_url_scheme(H4BS_URL . 'assets/images/website-snapshot-fallback.svg', 'https');
        $onErrorJs = "if(!this.dataset.h4Fallback){this.dataset.h4Fallback='1';this.src=" . wp_json_encode($fallbackUrl) . ";}";

        $frameClass = $withChrome ? 'h4bs-website-snapshot-frame' : 'h4bs-website-snapshot-frame is-flat';
        $labelHtml = $withChrome ? '<span class="h4bs-website-snapshot-label">Website Screenshot</span>' : '';

        return sprintf(
            '<span class="%s">%s<img class="h4bs-slide-image h4bs-website-snapshot-image" loading="lazy" decoding="async" src="%s" alt="%s" width="%d" height="%d" referrerpolicy="no-referrer" onerror="%s"></span>',
            esc_attr($frameClass),
            $labelHtml,
            esc_url($shotUrl),
            esc_attr($alt !== '' ? $alt : 'Website screenshot preview'),
            $width,
            $height,
            esc_attr($onErrorJs)
        );
    }

    /**
     * @return array{0:int,1:int}
     */
    private function normalizeWebsiteSnapshotDimensions(int $width, int $height): array
    {
        $safeWidth = $width > 0 ? $width : 960;
        $safeHeight = $height > 0 ? $height : 540;

        $safeWidth = max(self::SNAPSHOT_MIN_WIDTH, min(self::SNAPSHOT_MAX_WIDTH, $safeWidth));
        $safeHeight = max(self::SNAPSHOT_MIN_HEIGHT, min(self::SNAPSHOT_MAX_HEIGHT, $safeHeight));

        return [$safeWidth, $safeHeight];
    }

    private function buildWebsiteSnapshotUrl(string $targetUrl, int $width, int $height, int $refreshSeconds): string
    {
        $refreshWindow = max(60, min(86400, $refreshSeconds > 0 ? $refreshSeconds : 900));
        $cacheBucket = (string) (int) floor(time() / $refreshWindow);
        return sprintf(
            'https://s.wordpress.com/mshots/v1/%s?w=%d&h=%d&vpw=%d&vph=%d&r=%s',
            rawurlencode($targetUrl),
            $width,
            $height,
            $width,
            $height,
            rawurlencode($cacheBucket)
        );
    }

    private function isAllowedWebsiteSnapshotTarget(string $targetUrl): bool
    {
        $parsed = wp_parse_url($targetUrl);
        if (! is_array($parsed)) {
            return false;
        }

        $scheme = strtolower((string) ($parsed['scheme'] ?? ''));
        if ($scheme !== 'http' && $scheme !== 'https') {
            return false;
        }

        $host = strtolower((string) ($parsed['host'] ?? ''));
        if (
            $host === '' ||
            $host === 'localhost' ||
            str_ends_with($host, '.local') ||
            str_ends_with($host, '.internal')
        ) {
            return false;
        }

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            $ipFlags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
            if (filter_var($host, FILTER_VALIDATE_IP, $ipFlags) === false) {
                return false;
            }
        }

        $path = strtolower((string) ($parsed['path'] ?? ''));
        $query = strtolower((string) ($parsed['query'] ?? ''));
        $full = $path . '?' . $query;

        $blockedSegments = [
            'wp-login',
            'login',
            'signin',
            'sign-in',
            'auth',
            'verify',
            'reset-password',
            'account',
            'my-account',
            'checkout',
            'cart',
            'billing',
            'payment',
            'credit-card',
            'creditcard',
            'cc-',
            'card=',
            'cvv',
            'secure-pay',
            'wp-admin',
            'admin/',
            'admin.php',
        ];

        foreach ($blockedSegments as $segment) {
            if ($segment !== '' && str_contains($full, $segment)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param array<int, string> $allowed
     */
    private function enumToken(string $raw, array $allowed, string $fallback): string
    {
        $candidate = sanitize_key(str_replace([' ', '-'], '_', strtolower(trim($raw))));
        foreach ($allowed as $allowedValue) {
            $normalizedAllowed = sanitize_key(str_replace([' ', '-'], '_', strtolower(trim($allowedValue))));
            if ($candidate === $normalizedAllowed) {
                return $allowedValue;
            }
        }
        return $fallback;
    }

    private function boundedFloat($value, float $minimum, float $maximum, float $fallback): float
    {
        if (! is_numeric($value)) {
            return $fallback;
        }
        return max($minimum, min($maximum, (float) $value));
    }

    private function nullableBoundedFloat($value, float $minimum, float $maximum): ?float
    {
        if ($value === null || $value === '' || ! is_numeric($value)) {
            return null;
        }
        return max($minimum, min($maximum, (float) $value));
    }

    private function easingCss(string $easing): string
    {
        $map = [
            'linear' => 'linear',
            'ease' => 'ease',
            'ease_in' => 'cubic-bezier(.42,0,1,1)',
            'ease_out' => 'cubic-bezier(0,0,.58,1)',
            'ease_in_out' => 'cubic-bezier(.42,0,.58,1)',
            'spring' => 'cubic-bezier(.2,1.35,.35,1)',
        ];
        return $map[$easing] ?? 'ease';
    }

    public function setupAdminMenuHighlight(WP_Screen $screen): void
    {
        if ($screen->post_type !== 'h4_slider') {
            return;
        }

        add_filter(
            'parent_file',
            static function ($parentFile): string {
                return 'h4bs-settings';
            }
        );

        add_filter(
            'submenu_file',
            static function ($submenuFile): string {
                return 'edit.php?post_type=h4_slider';
            }
        );
    }
}
